diff --git a/.github/pr-assets/3863-storage-skip-referenced.png b/.github/pr-assets/3863-storage-skip-referenced.png new file mode 100644 index 0000000000..0fcc6370a4 Binary files /dev/null and b/.github/pr-assets/3863-storage-skip-referenced.png differ diff --git a/.github/pr-assets/muse-spark-meta-search-content-types-400.jpg b/.github/pr-assets/muse-spark-meta-search-content-types-400.jpg new file mode 100644 index 0000000000..d18dd98dab Binary files /dev/null and b/.github/pr-assets/muse-spark-meta-search-content-types-400.jpg differ diff --git a/.github/workflows/cleanup-orphaned-workflows.yml b/.github/workflows/cleanup-orphaned-workflows.yml index 3c33c21dc8..6915a063ed 100644 --- a/.github/workflows/cleanup-orphaned-workflows.yml +++ b/.github/workflows/cleanup-orphaned-workflows.yml @@ -37,7 +37,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Remove stale workflow histories env: diff --git a/Dockerfile b/Dockerfile index 79e14399ec..06011a1f33 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.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 +ARG BUN_IMAGE=oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 FROM ${BUN_IMAGE} AS build WORKDIR /home/bun/app diff --git a/README.md b/README.md index b2ab7be63e..ca31c75ed9 100644 --- a/README.md +++ b/README.md @@ -106,16 +106,19 @@ See [SPONSORS.md](./SPONSORS.md). - - - + + + + + +
SPONSORThanks to SPONSOR for sponsoring this project! BLURBOrcaRouterThanks to OrcaRouter for sponsoring this project! OrcaRouter is one OpenAI-compatible AI gateway for production AI: adaptive routing that grades every prompt and sends it to the model that clears your bar, automatic failover, routing rules as code, zero-markup provider pricing with prompt caching, and guardrails, an agent firewall, and request logs on every call across 200+ models. Pick OrcaRouter in the Add provider picker or run ocx provider add orcarouter; orcarouter/auto is the adaptive router.
PackyCodeThanks to PackyCode for sponsoring this project! PackyCode is a stable, high-performance API relay provider, offering relay services for Claude Code, Codex, Gemini, and more. With automatic failover, smart routing, and unlimited concurrency, it turns AI into a real productivity tool. Register via this link and get started! Pick PackyCode in the Add provider picker or run ocx provider add packycode.
PackyCode 是一家稳定、高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。具备自动故障转移、智能路由和无限并发等多种功能,让 AI 编程成为真正的生产力工具。点此链接注册,立即开始使用!
---> --- @@ -303,7 +306,7 @@ full-slash form keeps working too. Details: [model routing docs](https://opencod OpenAI (ChatGPT login or API key), Anthropic, Google Gemini, xAI, Kimi, Azure OpenAI, Ollama (local + Cloud), Cursor (experimental), and every OpenAI-compatible endpoint — plus DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, -Qwen Cloud, SiliconFlow, and more. Full list: `ocx init` or the +Qwen Cloud, Qoder Global and CN (official PAT + CLI), SiliconFlow, and more. Full list: `ocx init` or the [provider docs](https://opencodex.me/guides/providers/). ## CLI diff --git a/SPONSORS.md b/SPONSORS.md index a0a2ee33b1..76a54c377b 100644 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -45,7 +45,7 @@ sponsor receives: supplied by the sponsor and published verbatim. The maintainer may decline or require edits to text that is false, misleading, disparages third parties, or breaches applicable law or GitHub policy. A second-language blurb (for example Chinese) may run alongside the English one. -- A built-in provider preset (`ocx provider select `) shipped in a public npm release, +- A built-in provider preset (`ocx provider add `) shipped in a public npm release, listed near the top of the provider picker in the dashboard and CLI and marked as a sponsor there. (The registry field and picker ordering that back this land with the first sponsor preset; today the picker follows registry order.) diff --git a/assets/pr-screenshots/client-compaction-dashboard.png b/assets/pr-screenshots/client-compaction-dashboard.png new file mode 100644 index 0000000000..89c3cf1c40 Binary files /dev/null and b/assets/pr-screenshots/client-compaction-dashboard.png differ diff --git a/assets/sponsors/orcarouter-overview-mobile.png b/assets/sponsors/orcarouter-overview-mobile.png new file mode 100644 index 0000000000..a43db45789 Binary files /dev/null and b/assets/sponsors/orcarouter-overview-mobile.png differ diff --git a/assets/sponsors/orcarouter-overview.png b/assets/sponsors/orcarouter-overview.png new file mode 100644 index 0000000000..a067b5ea60 Binary files /dev/null and b/assets/sponsors/orcarouter-overview.png differ diff --git a/assets/sponsors/orcarouter-picker.png b/assets/sponsors/orcarouter-picker.png new file mode 100644 index 0000000000..2c4e5b1628 Binary files /dev/null and b/assets/sponsors/orcarouter-picker.png differ diff --git a/assets/sponsors/orcarouter-readme.png b/assets/sponsors/orcarouter-readme.png new file mode 100644 index 0000000000..dfad19db9e Binary files /dev/null and b/assets/sponsors/orcarouter-readme.png differ diff --git a/assets/sponsors/orcarouter.png b/assets/sponsors/orcarouter.png new file mode 100644 index 0000000000..2d65f01c08 Binary files /dev/null and b/assets/sponsors/orcarouter.png differ diff --git a/assets/sponsors/packycode-overview-mobile.png b/assets/sponsors/packycode-overview-mobile.png new file mode 100644 index 0000000000..934fa6341f Binary files /dev/null and b/assets/sponsors/packycode-overview-mobile.png differ diff --git a/assets/sponsors/packycode-overview.png b/assets/sponsors/packycode-overview.png new file mode 100644 index 0000000000..ca86f481f3 Binary files /dev/null and b/assets/sponsors/packycode-overview.png differ diff --git a/assets/sponsors/packycode-picker.png b/assets/sponsors/packycode-picker.png new file mode 100644 index 0000000000..0f61d1fa86 Binary files /dev/null and b/assets/sponsors/packycode-picker.png differ diff --git a/assets/sponsors/packycode-readme.png b/assets/sponsors/packycode-readme.png new file mode 100644 index 0000000000..36e9f8a667 Binary files /dev/null and b/assets/sponsors/packycode-readme.png differ diff --git a/assets/sponsors/packycode.png b/assets/sponsors/packycode.png new file mode 100644 index 0000000000..8b2afee75f Binary files /dev/null and b/assets/sponsors/packycode.png differ diff --git a/bun.lock b/bun.lock index e25328f026..34733cee44 100644 --- a/bun.lock +++ b/bun.lock @@ -3,17 +3,17 @@ "configVersion": 1, "workspaces": { "": { - "name": "@yansigit/opencodex", + "name": "@bitkyc08/opencodex", "dependencies": { "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.4.0", + "bun": "1.4.2", "zod": "4.4.3", }, "devDependencies": { "@anthropic-ai/sdk": "0.122.0", - "@types/bun": "1.4.0", + "@types/bun": "1.4.2", "typescript": "7.0.2", }, "optionalDependencies": { @@ -68,33 +68,33 @@ "@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.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="], - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="], + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="], - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-09x7wnjMR6M5KGBDBhVl2CpfoCIQOkVDbPX2KfIhpXv4N6grbWE7dfLPw/Ydi9gaUMGhU7UKhoz444Nu6RCycA=="], + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="], - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dRwzti/qJqV1HWplU27iUWUqp+f2DtFSf2yqQKSb+HH2dDOC//Uqd9u/A5h1DMsLszfP5OGP9UwQIKxVwFODaA=="], + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="], - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y5yAtCbHK6JjprXEtkdklDQFPADgs+CkfcliyY5g4JJ8baGHyQSrfpSkX3XVJ2C+aBLsdwNDdW+oczMsAwx6uA=="], + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="], - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.0", "", { "os": "android", "cpu": "arm64" }, "sha512-HpPIxJfDNPBPhiBNMyZoo/dOLijARfsx5j72vNuLtaTvl0Hh7HUculxjsOQ2WSyGoCgqXMEr1Qqjab1im9u1RA=="], + "@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-musl": ["@oven/bun-linux-aarch64-musl@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RUjAAkJ/CdNV++zVxyANWshPc73CECYsfhk0fWAkoJjtywxJ2BwXzI6nopBBDMfs0HS+fhRGn6zGwU8ccxLeJg=="], + "@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-x64": ["@oven/bun-linux-x64@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Du44zebtPXJujvMLmtIxEQ6ykOhYt7L/Q+YIGVm+Yy+Pj/fpOnq60ggwIpKp/pGAFbYHNiTrA3JTjuZ9MTbZIg=="], + "@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-android": ["@oven/bun-linux-x64-android@1.4.0", "", { "os": "android", "cpu": "x64" }, "sha512-u++KyLlfMn36yWz+AgJs+fZtS46UFDNpSSZhrcitkytONtNwq0X6Q9BDVEFXxYl/+Eec0xme1rb6MgW+U35WeA=="], + "@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-musl": ["@oven/bun-linux-x64-musl@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1Dv+ISL8YKEKM9jAHzNifOcRUoziy6UMxh+yVXjUCP6QnbRhENDHLaIWWkQZJyBLTn0I3xozflorAlHiGzGqA=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="], - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FBAYaQpJBP0asgqzL6NFUfjdQqsV+kvTpJ/eWxPKj+RcDgIfPSuE8kvQuPYu5pa8u8JTujYMjmuyvHxVuQsInA=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="], - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="], "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], @@ -164,9 +164,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.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": ["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-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], diff --git a/devlog/_fin/260907_init_publication_guidance/010_implementation.md b/devlog/_fin/260907_init_publication_guidance/010_implementation.md new file mode 100644 index 0000000000..80b5954924 --- /dev/null +++ b/devlog/_fin/260907_init_publication_guidance/010_implementation.md @@ -0,0 +1,28 @@ +# Issue #3893: implementation plan + +Satisfy-spec work, triggered by issue #3893 and the request to implement separate draft PRs. Goal: actionable first-run publication diagnostics. Non-goals: changing file writes, permissions, replacement/cleanup guarantees, or adding a filesystem fallback. Stop after a verified draft PR; unresolved platform checks are reported, never marked passed. Escalate if resolving the issue requires weakening publication guarantees. This file records the plan and eventual evidence. + +Class C2: diagnostic propagation and user documentation. One independent branch from 522ce5f8c; no branch dependencies or orchestration state changes. + +File map: +- MODIFY src/config/initialize.ts: add an optional hardeningFailed flag to constructor options; select a fixed privacy-safe permission diagnostic when hardening throws. Track the flag around the existing harden call only, and pass it in the existing error options. Append supported-location guidance to denied-link diagnostics. All I/O order and cleanup remain identical. +- MODIFY tests/config/config-mutation-lock.test.ts: inject a harden failure and prove write/link never happen, target remains absent, no residue remains, and raw error details do not appear. Assert all five denied-link codes provide recovery guidance while retaining uncertain-publication state. Partial-write errors must not be mislabeled as permission failures. +- MODIFY tests/service/init-eof.test.ts: use its existing child bootstrap seam to inject publication errors during the real CLI wizard; verify exit=1, diagnostics and residue warnings, no configuration/backup damage or integration prompts. +- MODIFY docs-site/src/content/docs/getting-started/quickstart.md and structure/02_config-and-codex-home.md: explain supported locations, inspection before retry, separate permission and link failures, and fresh-install OPENCODEX_HOME examples. Existing translations reviewed for contradictions. + +Optional constructor input chain: created by the publication function; consumed by Error.message; no config serialization, migration, or persistent state. Existing constructor calls keep their meaning. + +Verification: focused config/init tests read the real publication and CLI code; typecheck includes src; privacy scan; required docs-site build. Baseline focused run: 35 pass, 3 skip, 2 fail (Windows file-symlink privilege: symlinkSync EPERM and dependent missing-residue assertion). No baseline failure will be hidden by changing tests. New regression checks must pass. Windows-native filesystem support remains bounded by the host. + +Audit: direct O_EXCL and replacement fallbacks rejected because they change complete-file/no-replace guarantees. Reuse the existing error and test seams; no new diagnostic module. Guidance never prints raw cause text or candidate bytes. + +## Verification before draft publication + +- `bun install --frozen-lockfile`: passed; lockfile unchanged. +- New diagnostics were observed failing before implementation: 9 failures across the focused hardening/link/CLI fault cases. After implementation: 9 passed. +- `bun test tests/config/config-mutation-lock.test.ts tests/service/init-eof.test.ts`: 38 passed, 3 skipped, 2 failed. The same two tests failed on unchanged 522ce5f8c: file-symlink creation is denied on this Windows host, and the swapped-symlink test then lacks its expected residue. New recovery tests pass; no skips or weakened assertions were added. +- `bun run typecheck`: passed. +- `bun run privacy:scan`: passed. +- `cd docs-site; bun install --frozen-lockfile; bun run build`: passed, 425 pages. Translated quickstarts contain no conflicting recovery/fallback instructions. +- CLI fault scenarios verify exit=1, distinct permission/link messages, uncertain-publication/residue warnings, backup preservation and no integration prompts. Partial-write errors keep the generic diagnostic. +- No physical non-NTFS filesystem support is claimed. Maintainer review remains required; this is a draft handoff. diff --git a/devlog/_fin/260908_b_track_quota_recovery_stack/000_plan.md b/devlog/_fin/260908_b_track_quota_recovery_stack/000_plan.md new file mode 100644 index 0000000000..d73b85568a --- /dev/null +++ b/devlog/_fin/260908_b_track_quota_recovery_stack/000_plan.md @@ -0,0 +1,69 @@ +# 000_plan.md — B트랙 대화 복구·quota 스택 배송 + +## 목표 +#3889(만료된 forward continuation의 WebSocket 복구)과 #3934(자격증명 세대 기반 늦은 WS quota 차단)를 +원저자 기여를 보존한 수동 종속 브랜치 체인으로 재구성하고, 최종 tip 한 곳에서만 CI를 태워 +green이면 tip을 dev에 통합한다. + +## 제약 (사용자 지시) +- 로컬 스위트 절대 실행 금지: bun run test / test:changed / typecheck / build / install 모두 NOT RUN. +- 푸시는 `--no-verify`. +- CI는 최종 tip에만 트리거한다. 하위 레이어에는 PR을 열지 않는다. +- 원작 PR이 있으면 원저자를 Co-authored-by로 보존한다. +- tip이 dev에 머지되는 순간 연결 이슈도 닫는다. + +## CI 트리거 계약 (근거) +`.github/workflows/ci.yml`의 `on.pull_request`에는 base 브랜치 필터가 없다(주석에 stacked child PR을 +일부러 포함시켰다고 명시). 따라서 **PR을 여는 것 자체가 CI run을 만든다.** +`push:`는 `branches: [main, preview, dev]`로 제한되므로 포크/작업 브랜치 푸시는 CI를 만들지 않는다. +결론: 하위 레이어 L1은 **브랜치 푸시만** 하고 PR을 열지 않는다. tip L2에만 PR을 연다. + +## 의존성 정렬 (PHASE-SPLIT-01) +효율이 아니라 의존 구조로 나눈다. 두 변경 모두 `src/server/responses/core.ts`를 만지므로 +같은 파일 위에서 순서를 가진 체인으로 쌓는다. + +- L1 = #3889 continuation 복구 (core.ts:3598 부근 오류 코드 계약) +- L2 = #3934 WS quota 세대 펜싱 (core.ts:1004 부근 observer) — L1 위에 쌓는다 + +텍스트 충돌은 없다(두 훅 사이 거리 약 2600줄). 체인 순서는 리뷰 단위 분리를 위한 것이다. + +## 파일 변경 맵 +### L1 (#3889, 원저자 ykvv / y2ambition-ai) +- MODIFY `src/server/responses/core.ts` — 400 응답 코드를 `invalid_request_error` → `previous_response_not_found`, + 메시지를 "전체 대화를 다시 보내라"로 변경. HTTP 상태와 인증 전 거부 위치는 유지. +- MODIFY `tests/codex-integration/issue-702-expired-replay-state.test.ts` — 기존 HTTP 기대값의 code 갱신 + + expired/missing 두 모드의 WebSocket 재연결·전체 도구 이력 재전송 회귀 추가. +- MODIFY `docs-site/src/content/docs/guides/codex-integration.md`, `.../ko/guides/codex-integration.md` + +### L2 (#3934, 원저자 luvs01) +- MODIFY `src/server/responses/core.ts` — `codexWsQuotaObserver`에서 pool 자격증명 generation을 포착하고 + `isCodexAccountGenerationLive`가 false면 늦게 도착한 quota 프레임을 무시. +- MODIFY `tests/responses/responses-account-label.test.ts` — 교체된 자격증명의 늦은 quota가 지워진 상태를 + 되살리지 못하는 회귀 추가. + +## 범위 밖 (OUT) +- `REPLAY_TTL_MS` 등 캐시 보존 기간 변경 +- 인증/자격증명 회전 정책 변경 +- main-pool writer 소유권 규칙 변경 +- B트랙 외 항목(#3906/#3886/#3922/#3917/#3900/#3896/#3924/#3930/#3890) + +## 검증자 (PLAN-VERIFIER-REAL-01) +로컬 스위트가 금지되었으므로 **로컬 검증자는 NOT RUN으로 기록한다**. 유일한 실행 검증자는 +tip PR head SHA에 대한 hosted Cross-platform CI다. 관측 대상: 4 Linux shard, Windows, +macOS lane, gates(typecheck/lint/privacy scan), packaging. +- `gh api repos/lidge-jun/opencodex/actions/runs?head_sha=` → conclusion=success +- 이 CI는 `src/**`와 `tests/**`를 changes 필터에 포함하므로 실제로 이번 변경 대상을 관측한다. + +## 수용 기준 +1. L1/L2 커밋 각각에 원저자 Co-authored-by 트레일러가 살아 있다. +2. L1에는 PR이 없고 CI run도 없다. CI run은 tip 하나뿐이다. +3. tip head SHA의 CI conclusion이 success다. +4. 로컬 스위트 미실행, 푸시는 --no-verify. +5. tip이 dev 조상이 되고, #3889/#3934가 정리되며 연결 이슈가 닫힌다. + +## 우회 경로 (PLAN-BYPASS-NAMED-01) +- tier: E2 (hosted CI 게이트) +- 실행 주체: GitHub Actions + maintainer 통합 +- 알려진 우회: admin 권한 보유자는 CI 미완료 상태에서도 머지 가능. 이 계획은 그러지 않는다. +- 잔여 위험: 하위 레이어 L1은 자체 CI 없이 tip 누적 CI로만 증명된다. 사용자 지시에 따른 의도된 선택. +- 문구 하향: 없음. diff --git a/devlog/_fin/260908_b_track_quota_recovery_stack/010_phase1_l1_continuation_recovery.md b/devlog/_fin/260908_b_track_quota_recovery_stack/010_phase1_l1_continuation_recovery.md new file mode 100644 index 0000000000..4c054dd8c3 --- /dev/null +++ b/devlog/_fin/260908_b_track_quota_recovery_stack/010_phase1_l1_continuation_recovery.md @@ -0,0 +1,56 @@ +# 010_phase1_l1_continuation_recovery.md — L1 (#3889) 브랜치 구성 + +## 목적 +만료·부재한 forward continuation 상태를 Codex WebSocket 클라이언트가 스스로 복구할 수 있게, +프록시가 돌려주는 400 오류의 코드를 클라이언트가 인식하는 `previous_response_not_found`로 바꾼다. + +## 브랜치 +`codex/b-stack-l1-continuation-recovery`, base = `origin/dev`. + +## 커밋 계약 +원저자 보존이 필수다. 체리픽으로 원 커밋의 author를 그대로 유지한다. + +``` +git cherry-pick -x e8d82a181ea0daa06c5111c09e0148475e45458f +``` + +체리픽은 원 커밋의 author(ykvv <229483879+y2ambition-ai@users.noreply.github.com>)를 보존한다. +squash 병합 시 author가 소실될 수 있으므로 커밋 메시지에 트레일러도 추가한다: + +``` +Co-authored-by: ykvv <229483879+y2ambition-ai@users.noreply.github.com> +``` + +## 정확한 변경 (before → after) +`src/server/responses/core.ts` 약 3598행: + +```diff + if ( + hasUnexpandedPreviousResponse + && isCanonicalOpenAiForwardProvider(route.provider) + ) { + return formatErrorResponse( + 400, +- "invalid_request_error", +- "OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.", ++ "previous_response_not_found", ++ "OpenAI forward continuation state is unavailable or expired; resend the full conversation without previous_response_id.", + ); + } +``` + +가드 위치(인증·어댑터·upstream I/O 이전)는 바뀌지 않는다. HTTP 상태 400도 유지한다. + +테스트: `tests/codex-integration/issue-702-expired-replay-state.test.ts` +- 기존 HTTP 케이스: `code`를 `previous_response_not_found`로 갱신, `type`은 `invalid_request_error` 유지. +- 신규: expired/missing 두 모드로 WebSocket 연결 → 거부 확인 → upstream 요청 0건 확인 → + 재연결 후 전체 이력 재전송 → upstream 1건 + `previous_response_id` 없음 + 도구 호출/결과 쌍 보존. + +문서: `docs-site/src/content/docs/guides/codex-integration.md` 및 한국어 페이지에 복구 경계 문단 추가. + +## 검증 +로컬 스위트 NOT RUN(사용자 금지). 이 레이어는 PR을 열지 않으므로 자체 CI도 없다. +증명은 L2 tip의 누적 CI가 담당한다. + +## 감사 반영 +서브에이전트 audit-3889의 결과에 따라 문서의 TTL 수치와 error type/code 매핑을 확정한다. diff --git a/devlog/_fin/260908_b_track_quota_recovery_stack/020_phase2_l2_ws_quota_generation_fence.md b/devlog/_fin/260908_b_track_quota_recovery_stack/020_phase2_l2_ws_quota_generation_fence.md new file mode 100644 index 0000000000..8ac37b83f9 --- /dev/null +++ b/devlog/_fin/260908_b_track_quota_recovery_stack/020_phase2_l2_ws_quota_generation_fence.md @@ -0,0 +1,54 @@ +# 020_phase2_l2_ws_quota_generation_fence.md — L2 (#3934) tip 레이어 + +## 목적 +pool 자격증명이 교체된 뒤 이전 WebSocket 연결에서 늦게 도착한 quota 프레임이, +새 자격증명을 위해 비워둔 quota 상태를 되살리지 못하게 막는다. + +## 브랜치 +`codex/b-stack-l2-ws-quota-generation`, base = `codex/b-stack-l1-continuation-recovery` (L1 위에 쌓음). +이 브랜치가 스택의 tip이며, **PR은 여기에만 연다.** + +## 커밋 계약 +``` +git cherry-pick -x e5c01f44e9736baba5b3a993c7f489f6b60d5ddd +``` +원저자 luvs01 보존 + `Co-authored-by: luvs01 ` 트레일러. + +## 정확한 변경 (before → after) +`src/server/responses/core.ts` 약 1004행: + +```diff ++import { isCodexAccountGenerationLive } from "../../codex/account-store"; + + function codexWsQuotaObserver(authCtx, provider): CodexWsQuotaObserver | undefined { + if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined; + const { accountId, writerGeneration } = authCtx; ++ const credentialGeneration = authCtx.kind === "pool" ? authCtx.generation : undefined; + const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; +- return headers => applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); ++ return headers => { ++ if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return; ++ applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); ++ }; + } +``` + +`credentialGeneration === undefined`면 기존 동작을 그대로 유지한다(main-pool·비pool 경로 무변경). + +테스트: `tests/responses/responses-account-label.test.ts` +- quota 10 전달 → 자격증명 교체 → quota clear → 옛 연결에서 quota 100 전달 → 최종 상태가 null인지 확인. + +## L1과의 관계 +같은 파일이지만 서로 다른 함수(약 2600줄 간격)라 텍스트 충돌이 없다. +체인 순서는 리뷰 단위를 나누기 위한 것이며, L2 diff는 이 변경만 보여준다. + +## CI 계약 +`.github/workflows/ci.yml`의 `on.pull_request`는 base 필터가 없어 PR 생성 즉시 CI가 붙는다. +따라서 L1에는 PR을 열지 않고, tip인 L2에만 PR을 연다 → CI run 정확히 1개. +`changes` 필터가 `src/**`, `tests/**`, `docs-site` 외 경로를 보므로 이 변경 세트는 `ci=true`가 되어 +4개 Linux shard, Windows, macOS lane, gates가 모두 돈다. + +## 머지 후 처리 +- tip PR 머지 → `git merge-base --is-ancestor`로 dev 조상 확인 +- #3889, #3934: 내용이 dev에 들어갔으므로 원저자 크레딧을 명시하며 닫는다 +- 연결 이슈: dev 머지 시점에 닫는다 (PR base가 dev라 GitHub 자동 종료가 안 됨 — AGENTS.md 명시) diff --git a/devlog/_fin/260908_b_track_quota_recovery_stack/030_outcome.md b/devlog/_fin/260908_b_track_quota_recovery_stack/030_outcome.md new file mode 100644 index 0000000000..7f5ef7e74e --- /dev/null +++ b/devlog/_fin/260908_b_track_quota_recovery_stack/030_outcome.md @@ -0,0 +1,58 @@ +# 030_outcome.md — 배송 결과 + +## 결과 +PR [#3937](https://github.com/lidge-jun/opencodex/pull/3937)이 `dev`에 머지되었다. +머지 커밋 `ca381ea764cfbc63bec978f53eb58e96c00c0c64`, 2026-09-07T18:31:27Z. + +## 스택 구조 (실제) +``` +dev 942c02873 + └─ 7273a0d1f docs(devlog): plan the B-track ... [계획] + └─ 531753340 fix(responses): recover expired ... [L1, author ykvv] + └─ a06bfa2f2 fix(codex): fence late WS quota [L2 = tip, author luvs01] +``` +L1에는 PR을 열지 않았다. tip에만 PR을 열어 CI를 1회 트리거했다. + +## CI 증거 +- tip `a06bfa2f2`: Cross-platform CI run 1건, conclusion=success, run_attempt=1 + ([run 34149252860](https://github.com/lidge-jun/opencodex/actions/runs/34149252860)). + 잡 21/21 완료, failure 0. Linux shard 4, macOS lane 2, gates, packaging, keyring, Docker smoke 포함. +- 하위 레이어 `531753340`: workflow run **0건**. 브랜치 푸시가 CI를 만들지 않는다는 계약이 실측으로 확인됐다. +- `enforce-target`은 동시성 그룹 충돌로 1차 시도가 취소되어, 대기 중이던 중복 run을 취소하고 재실행해 success를 받았다. + +## 검증 한계 (사실대로 기록) +- **로컬 제품 스위트는 한 번도 실행하지 않았다** (`bun run test`/`test:changed`/`typecheck`/`build`/`install`: NOT RUN). + 사용자 지시에 따른 것이며, hosted CI가 유일한 실행 검증자였다. 푸시는 전부 `--no-verify`. +- tip SHA에 취소된 체크 2건이 남아 있다: `enforce-target`(101832157192, 옛 시도)과 `label`(101827844691). + 같은 워크플로의 후속 시도가 success로 끝났고 failure는 0건이다. 체크 목록이 전부 깨끗하다고 말하면 사실이 아니다. +- CI가 검증한 트리(tip)와 최종 dev 트리는 동일하지 않다. 머지 직전 별도 PR #3936(문서)이 먼저 착륙해 + lifecycle 문서 5개가 차이로 남는다. `git diff --exit-code a06bfa2f2 ca381ea76 -- src tests`는 exit 0으로, + **소스와 테스트는 CI가 본 그대로** 착륙했다. + +## 감사 (astra-high 서브에이전트 4기) +1. `audit-3889`: PASS. `formatErrorResponse`의 2번째 인자는 `classifyError` 입력이며 + `previous_response_not_found` 분기가 `type=invalid_request_error`/`code=previous_response_not_found`를 만든다 + (`src/bridge.ts:2130`, `src/lib/errors.ts:179`). 문서의 1시간은 `RESPONSE_TTL_MS=3_600_000`과 일치. +2. `audit-3934`: PASS. `main-pool`에 `generation`이 없는 것은 의도된 분리이며 `mainQuotaWriter`가 별도 펜싱한다. + `writerGeneration`(설정 재조정)과 `generation`(영속 자격증명)은 다른 개념이라 새 검사가 중복이 아니다. + generation `0`은 엄격 동등으로 정상 처리된다. +3. `verify-stack`: PASS. 체리픽 hunk 무결성, 두 변경의 공존, import/export, 테스트 심볼, layout, privacy 6항목. +4. `verify-landing`: 7개 주장 중 6개 CONFIRMED, 1개 REFUTED(위 취소 체크 건). 이 문서가 그 반증을 반영한다. + +## 원저자 크레딧 +머지 커밋에 두 트레일러가 모두 살아 있다. +``` +Co-authored-by: ykvv <229483879+y2ambition-ai@users.noreply.github.com> +Co-authored-by: luvs01 +``` +원본 PR #3889·#3934는 배송 완료 안내와 함께 closed(미머지)로 처리했다. + +## 연결 이슈 +GraphQL `closingIssuesReferences`로 확인한 결과 #3889·#3934·#3937 모두 종료 대상 이슈가 **0건**이다. +따라서 이번 머지로 닫을 이슈는 없었다. (#3885는 A트랙 #3886 소관이라 대상이 아니다.) + +## 이번에 나아지지 않은 것 +- 하위 레이어 L1은 자체 CI 증거 없이 tip 누적 CI로만 증명됐다. 사용자 지시에 따른 의도된 선택이며, + 레이어별 독립 회귀 증거가 필요한 변경에는 이 방식을 그대로 쓰면 안 된다. +- `enforce-target` 동시성 충돌은 재실행으로 우회했을 뿐 원인을 고치지 않았다. + 같은 SHA에 워크플로가 두 번 트리거되는 조건이 남아 있다. diff --git a/devlog/_fin/260908_bug6_manual_stack/000_plan.md b/devlog/_fin/260908_bug6_manual_stack/000_plan.md new file mode 100644 index 0000000000..cda1225ad6 --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/000_plan.md @@ -0,0 +1,18 @@ +# Six-item bug stack — completed delivery + +All five new product PRs merged into `dev` on 2026-09-08. The sixth source item, #3965, had independently landed and required no duplicate PR. [071](071_delivery.md) records verification and retained failure history; [072](072_final_proof.md) records actual landing proof. + +| Source | Delivery | Disposition | +| --- | --- | --- | +| #3838 Go placement/stateless residual | #3986 | Landed; source closed; lossy mixed-ciphertext filtering declined | +| #3907 xAI string child result | #3991 | Landed; issue completed | +| #3944 V2 proxy guidance | #3992 | Landed; source closed | +| #3951 server-owned delegation preset | #3993 | Landed; source closed | +| #3965 canonical operation alias | Existing merge402be7c1f | Verified landed NOOP for another PR | +| #3973 cooldown recovery, consolidated #3995 | #4002 | Landed; issue completed and source PR closed | + +This was one ordinary manual chain. Children were retargeted to dev before their parents merged because repository settings automatically delete merged branches. Original authorship and Co-authored-by trailers were preserved. #3997/#3996 remain outside this delivery. + +The earlier decade documents are historical plans and audit amendments. Their future-tense steps describe what was required at that point; this outcome and the final ledger are authoritative for completion. The work used repeated PABCD cycles and independent Astra high source/security reviews. + +Local product tests, installs, typechecks and builds: **NOT RUN**, by owner instruction. Commits disabled hooks per invocation and pushes used --no-verify. Hosted CI, synthetic dashboard observation and isolated remote documentation builds supplied verification. No release, deployment, main/preview promotion, live account operation or reset credit was used. All 30 pre-existing user files were preserved. diff --git a/devlog/_fin/260908_bug6_manual_stack/010_go_compatibility.md b/devlog/_fin/260908_bug6_manual_stack/010_go_compatibility.md new file mode 100644 index 0000000000..fa7a4e7845 --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/010_go_compatibility.md @@ -0,0 +1,42 @@ +# wp1: Go compatibility residuals + +Historical phase record. Delivery is complete; see [071](071_delivery.md) and [072](072_final_proof.md) for terminal evidence. + +Depends on wp0. C3 with independent boundary review. Source PR #3838 head `d84e5a80a5e40a65462a0466d82cdcec463a847e`; baseline dev `9e1468d4b7a41b498ed2aca98507ada2c741afea`. #3942 already landed the generic array agent-message normalizer. Reimplement the remaining Go behavior on current owners; do not restore the removed `opencode-go.ts` or duplicate namespace logic. + +## Main decisions + +Carry Go additional-tools placement and the canonical preset's stateless intent. Retain current all-parts readable/fail-closed agent-message behavior; the old lossy mixed-content hunk is deliberately declined because successful transport alone does not prove complete task content. Original PR disposition must name that decision rather than claim every historical hunk landed. Custom renamed providers retain explicit configuration semantics; no destination-based configuration migration is added. + +## File changes + +- NEW `src/adapters/opencode-go-additional-tools.ts`: export a small immutable placement helper taking body and base URL. Match HTTPS `opencode.ai`, standard port, exact `/zen/go/v1` (optional terminal slash); reject credentials/query/fragment and other paths. For valid `additional_tools` array wrappers append their already-normalized tools to top-level tools and remove the wrapper. Preserve unrelated input and supported nameless hosted tools. Non-array malformed wrappers remain unchanged; no valid wrappers returns the original body. Existing namespace/custom owners perform identity lowering and dedupe before this pass. +- MODIFY `src/adapters/openai-responses.ts`: import helper; invoke only inside non-forward dispatch after existing namespace/custom/search lowering around baseline line 2455, before code-mode/compaction and later hosted-tool pruning. Response alias maps stay owned by prior normalization. +- MODIFY `src/providers/registry.ts`: canonical `opencode-go` entry gains `statelessResponses: true`. Existing derive logic seeds/backfills only absent values; explicit false remains authoritative. +- MODIFY `tests/providers/opencode-go-grok46-responses.test.ts`: replace the old expected private wrapper with promoted tools; cover duplicate containers, distinct namespace same-name children, custom/function handling, hosted Luna search versus Go Grok denial, tool_choice none/allowed list, tool_search_output activation, forward/Zen/lookalike/wrong-port exclusion and immutable replay. +- MODIFY `tests/providers/opencode-go-luna-wire.test.ts`: cover seed/backfill/false, full-history continuation with synthetic reasoning and paired tool results; assert previous_response_id removed, store false, call pairing/history retained. Cover stateless orphan and reasoning-summary interactions through existing focused suites. No new test file is required if these current owners remain reviewable. +- MODIFY `docs-site/src/content/docs/reference/configuration/providers.md` and `structure/04_transports-and-sidecars.md`: record Go wrapper placement and canonical stateless default with explicit override and full-history limits. Update only contradicting translated statements. + +## Before / after flow + +Before: namespace normalization leaves valid declarations inside `input.additional_tools`; strict Go receives a private wrapper. After: the same normalized declarations appear in `tools`, and valid wrappers are removed. Before: canonical Go may forward previous_response_id with replay history. After: existing stateless normalization strips the stored-continuation parameters and sends complete history. + +## Activation and observable coverage + +Use production adapter fixtures, not a duplicate normalizer oracle. Namespace alpha.lookup and beta.lookup must both remain callable; duplicate wire identities follow the existing canonical owner. Nameless hosted tools must survive placement until provider/model pruning. Malformed wrapper, unapproved destination and forward controls stay byte-identical. Seed false must differ from default true. A two-turn synthetic continuation must preserve meaningful reasoning/tool history while removing stored-state references. Inspect existing stateless orphan repair and summary tests; extend any missing Go model coverage without weakening assertions. + +Hosted verification: PR CI covers changed runtime and provider suites, with final full dispatch before integration. Local product tests/install/typecheck/build are NOT RUN. Preserve original PR account-linked Co-authored-by credit; resolve identity from GitHub before commit. The full source investigation is in ignored `.tmp/bug6-01a07e9d/go-xai-plan.md`; it is not public implementation proof. + +## wp1 P refresh + +Previous wp0 D directs Go residual implementation. During live refresh dev advanced to c15662855 (#3975), changing only tests/codex-integration/codex-prompt-text-probe.test.ts. Hook-disabled merge incorporated that unrelated probe fixture correction before B; Go owners and this design are unchanged. The initial A narrative said unchanged dev based on the pre-fetch snapshot; this entry corrects it. + +## C audit foldback and repair plan + +Independent review at 9b42c1a80 found two blockers. F1 accepted: the stateless flag enables content-to-summary output normalization, but the continuation cache records original output; full-history overlap then fails. The adapter-only full-history fixture bypassed the affected server boundary. F2 accepted: baseUrl-only matching misses split/endpoint-inclusive configurations and can affect an overridden non-Go resource. Neither finding conflicts with preserving opaque items or existing fail-closed policy. + +Repair F1: MODIFY `src/server/responses/core.ts` at `rememberPassthroughResponseChecked` only. After current namespace/custom/function restoration, apply existing `rewriteReasoningSummaryInJson` under the same `hideThinkingSummary !== true && routeUsesContentChannelReasoning(provider, model)` condition as client output, then record that representation. Preserve item content and IDs under the existing opaque-item rule; do not weaken overlap comparison or use ID-only matching. This aligns stored output with the actual client serialization for SSE and JSON. Extend the current Go server fixture to send actual full-history plus previous_response_id and assert each prior call/message occurs exactly once; retain delta replay and hiding/opaque controls. The shared callback is an explicit narrow scope expansion required by this newly activated path, not unrelated state refactoring. + +Repair F2: the helper now accepts the final resolved Responses request URL already built by the adapter. Match exact origin and `/zen/go/v1/responses`, rejecting userinfo/query/fragment. Positive fixtures cover normal base, endpoint-inclusive base and split custom path; negative fixtures cover an override resolving to Zen/non-Go and assert both actual request URL and body. Update destination wording in docs and preserve all prior host/port/immutability controls. + +Re-review the repaired diff with the same implementation auditor; retain CI failures and repair evidence. No local product commands are authorized. diff --git a/devlog/_fin/260908_bug6_manual_stack/020_xai_continuation.md b/devlog/_fin/260908_bug6_manual_stack/020_xai_continuation.md new file mode 100644 index 0000000000..8308cc290e --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/020_xai_continuation.md @@ -0,0 +1,25 @@ +# wp2: xAI string child-result continuation + +Historical phase record. Delivery is complete; see [071](071_delivery.md) and [072](072_final_proof.md) for terminal evidence. + +Depends on wp1 current outbound placement and full-history regression controls. C3. Issue #3907 posts string `agent_message.content`; #3942 already implements arrays for all non-forward destinations. Scope is the string residual only. + +## File changes + +- MODIFY `src/adapters/routed-agent-messages.ts`: extend `normalizeRoutedAgentMessages` with optional `{ allowStringContent?: boolean }`, default false. If enabled and content is a nonblank string, create one input_text part containing the exact original string. Existing attribution and array handling continue. Whitespace-only, unknown, malformed and ciphertext shapes remain unchanged; never trim the forwarded text or mutate the input. +- MODIFY `src/adapters/openai-responses.ts`: reuse `isXaiResponsesDestination` from `src/providers/xai-transport.ts`, pass its result as allowStringContent inside the existing `!forward` call. Existing array behavior stays available for other non-forward destinations. No custom-forward exception. +- MODIFY `tests/adapters/routed-agent-messages.test.ts`: exercise both exact xAI hosts, API-key and OAuth, exact text/newlines, attribution, missing transport item ID and input immutability. String controls: native/custom forward, other providers, lookalike hosts and blank content stay unchanged; existing all-parts array/ciphertext tests remain. +- MODIFY `tests/server/server-xai-responses-streaming.test.ts`: extend the synthetic server fixture with parent request, child request, then parent continuation containing string child result plus genuine paired tool history. Upstream stub rejects surviving private agent_message with 422; assert user-message child text, ordinary response completion, paired calls preserved and no repeated incompatible dispatch. This exercises the wire boundary, not the actual Codex scheduler. +- MODIFY `docs-site/src/content/docs/reference/adapters.md`, `docs-site/src/content/docs/reference/configuration/providers.md`, and the contradicting Russian adapters paragraph: describe existing non-forward array conversion and xAI string extension, preserving forward/encrypted exclusions. Sync `structure/04_transports-and-sidecars.md` without broadening the passive manifest claims. + +## Before / after + +Before the raw-body outbound normalizer requires array content and leaves the issue's string item on the strict xAI wire. After it produces `{type: message, role: user, content: [{type: input_text, text: originalText}]}` through the existing attribution rules, only for an approved non-forward xAI destination. No tool result is synthesized and no encrypted message is partially discarded. + +## Verification + +Pin parent/child fixtures to synthetic input. The strict upstream stub must reject the pre-fix request shape and accept the normalized one; destination-negative controls prove the guard is active. Hosted PR CI and final full dispatch execute adapter/server regressions. Local tests/install/typecheck/build remain NOT RUN. Source audit checks raw-body call placement and all consumers of the added option. There is no serialized configuration field or migration: option creation and consumption are both in-memory adapter calls. + +## wp2 P refresh + +Previous wp1 D: PR3986 at d1f61e933 passed run34178540141 and independent source/security audit, with18Go replay scenarios and remote docs425pages. Proceed to xAI string residual. Candidate339e42c1e was prepared in an isolated worktree under the owner-authorized parallel-preparation amendment; it is not yet adopted. Its base exactly equals the certified preceding layer, and the eight-file diff matches this plan. Existing xAI predicate remains the destination owner; no account changes. Issue3907 is still open. Main revalidates candidate before B and retains fresh hosted CI before wp2 closure. diff --git a/devlog/_fin/260908_bug6_manual_stack/030_v2_guidance.md b/devlog/_fin/260908_bug6_manual_stack/030_v2_guidance.md new file mode 100644 index 0000000000..aac9ffab00 --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/030_v2_guidance.md @@ -0,0 +1,55 @@ +# wp3: V2 guidance carry + +Historical phase record. Delivery is complete; see [071](071_delivery.md) and [072](072_final_proof.md) for terminal evidence. + +Depends on wp2 in the owner-requested manual chain. Carry PR #3944 at 6fb0fc6f1d34c77b98a74fe817e5bd90063a7d1a with both original commits and contributor trailer. Local product verification is NOT RUN. + + separate proxy routing metadata from native policy + +Source: [pinned collaboration implementation](https://github.com/lidge-jun/opencodex/blob/6fb0fc6f1d34c77b98a74fe817e5bd90063a7d1a/src/server/responses/collaboration.ts#L244), [pinned regression changes](https://github.com/lidge-jun/opencodex/blob/6fb0fc6f1d34c77b98a74fe817e5bd90063a7d1a/tests/codex-integration/multi-agent-compat.test.ts#L1188). C3 source change, with C4 care for developer-instruction and public-contract semantics; this lane does not run orchestration. + +### Concrete before -> after hunks + +| Exact path / baseline anchor | Before | After to carry | +| --- | --- | --- | +| `src/server/responses/collaboration.ts:242` | Only the native proactive constant exists. | Add private `OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG` / `CLOSE_TAG` constants after it. Leave `PROACTIVE_MULTI_AGENT_MODE_TEXT` unchanged. | +| `src/server/responses/collaboration.ts:466-490` | Custom and built-in v2 bodies use native tags; built-in prescribes overrides, `fork_turns`, and preferred-model use. | Wrap both v2 paths in the proxy tag; replace only built-in preamble with routing-metadata wording; preferred clause ends with a period. Preserve placeholder substitution, effective preferred model, account namespace filtering, roster/fallback text, stale/unknown suppression and roster-drop budget. | +| `src/server/responses/collaboration.ts:543-545` | Exact generated-item predicate only. | Add `generatedGuidanceFamily(text)` recognizing exactly the two outer tag families. This is a dedup classification, not an authorship assertion. | +| `src/server/responses/collaboration.ts:583-594` | Latest-match handling only for native tag; other text dedups against any earlier exact match. | For either known family, compare incoming text to the latest exact generated developer text within that family in the replay prefix. For untagged text retain existing exact-item behavior. | +| `src/types/config.ts:472-475,515-516` | Effort JSDoc prescribes spawn overrides; custom-body wrapper is native tag. | Describe effort as advisory v2 metadata; update wrapper name. Preserve type declarations, injectionModel dependency and reasoning-level validation documentation. | +| `tests/codex-integration/multi-agent-compat.test.ts:270,387,615,741,781,847,1185` | Old v2 wrapper and imperative expectations; native-family A-B-A only. | Carry all changed expectations and new proxy/native replay matrices from the head; preserve existing v1, catalog, placement, sanitization and shape-negative tests. | +| `docs-site/src/content/docs/reference/configuration/agents.md:77-93` | Excludes v1 leaf workers and describes model/fork overrides. | Correct already-shipped leaf eligibility; describe proxy wrapper, advisory metadata, preserved custom body and per-family latest-text dedup. State mixed-version limitations. | +| `docs-site/src/content/docs/ja/reference/configuration/agents.md:31-37` | Old roster/guidance contract. | Carry corresponding roster correction and proxy/native/replay qualification. | +| `docs-site/src/content/docs/ko/reference/configuration/agents.md:31-37` | Same old contract. | Same localized contract. | +| `docs-site/src/content/docs/ru/reference/configuration/agents.md:38-57` | Same old contract, longer prose. | Carry full localized explanation including historical-message limitation. | +| `docs-site/src/content/docs/zh-cn/reference/configuration/agents.md:31-37` | Same old contract. | Same localized contract. | + +Do not mass-replace ``: `collaboration.ts:493-497` remains the v1 max/ultra parity path, and native/legacy history must remain byte-identical. Do not add a settings migration or infer the author of an old tagged message. No revocation of already injected instructions is claimed. + +### Data creation and consumers + +Existing config fields, not new fields: `/api/injection-model` maps `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled` to `OcxConfig.injectionModel`, `injectionEffort`, `injectionPrompt`, `multiAgentGuidanceEnabled` (`agent-settings-routes.ts:501-508,529-589`). `saveConfigPreservingClaudeCode` persists the existing JSON config. No changes to that input/storage contract are needed. + +`src/server/responses/core.ts:2421-2429` passes those fields, route account namespace, featured models and fallback to `multiAgentGuidanceText`. `collaboration.ts:366-405` retains feature/tool/catalog admission; `:409-464` retains request-scoped roster and preferred/fallback derivation. Only the final rendered text changes. `applyInjectionPlaceholders` at `:504-509` still substitutes the four placeholders and preserves unknown placeholders. + +`core.ts:2430-2433` calls `injectDeveloperMessage`. `collaboration.ts:583` creates raw `{type:"message",role:"developer",content:[{type:"input_text",text}]}`; `:597-613` creates the parsed `{role:"developer",content:text,timestamp}` and places it; `:616-621` splices raw input at the matching conversation position. Both representations must retain the same ordered text across stateful replay. `src/server/responses.ts:6` reexports the same public helpers unchanged. `src/codex/subagent-model-fallback.ts:688-692` continues owning fallback prose. Native default sync is an independent consumer per `structure/03_catalog-and-subagents.md:459-465`; do not merge it with guidance. + +### Regression activation + +Carry the existing hermetic `CODEX_HOME` / catalog fixture builders (`multi-agent-compat.test.ts:23-103`), including the fresh catalog override and its cleanup. Test the actual helpers through `parseRequest`, raw input, `_replayPrefixLen`, and `_continuationConversationMessageIndex`. + +1. V2 built-in has proxy tags and model/effort/roster/fallback metadata, and lacks native tag, `fork_turns`, and preferred-model imperative. V1 max/ultra retains native tag and below-top silence. Disabled guidance and stale/unknown catalog produce null. +2. Built-in A -> custom B -> built-in A appends last A; unknown placeholders/newlines in custom B remain unchanged. +3. Native A -> proxy P -> native B -> same proxy P adds nothing; native text dedup ignores later proxy P. +4. Old built-in/native-tagged custom text + native policy + new proxy text preserves the original prefix and appends new proxy metadata. Never assert historical authorship or automatic cleanup. +5. Keep exact-shape negatives, 700-character fixture, external-task input, leading tool-result, compaction marker and raw/parsed alignment cases. +6. Small additional hunk recommended in `tests/server/server-combo-failover-e2e.test.ts:2285` beside the existing generated-guidance replay case: configure a synthetic v2 tool/catalog route, change built-in -> custom -> built-in across actual response continuations, capture adapter input, assert latest proxy guidance and native policy survive once and precede the current task. Use the existing server harness. Preserve current-dev compaction/recall fixtures in this file. This activates the caller/replay integration rather than only manually assigning parsed indices. + +Remote-only focused activation: `bun test tests/codex-integration/multi-agent-compat.test.ts`; additionally `bun test tests/server/server-combo-failover-e2e.test.ts` if adding the caller fixture. **NOT RUN here.** Negative controls for the remote verification owner: reverting the custom wrapper, collapsing the two families, or changing latest-family comparison to any-ever comparison must fail the corresponding transition tests. No local mutation/control execution. + + +Main decision: preserve the complete original diff. The optional extra server caller fixture is deferred unless source audit reveals an untested change; do not duplicate the existing replay matrix merely for volume. Sync structure/03_catalog-and-subagents.md to the new tag and policy boundary. + +## wp3 P refresh + +Previous wp2 D: PR3991 head00eb47886 passed run34180674115, source audit and remote docs425pages; proceed guidance carry. Prepared layer3 consists of24977adf2,21757b71a,8000e2482, based on d1f61e933. Intervening wp2 changes affect xAI adapter/tests, provider/adapters docs and structure04; none overlap the9layer3 files. Original #3944 remains open at6fb0fc6f. Independent prepared-source/security audit PASS in isolated v2GuidanceReviewer.md; actual adoption requires unchanged-delta/interdiff verification and own hostedCI. diff --git a/devlog/_fin/260908_bug6_manual_stack/040_v2_preset.md b/devlog/_fin/260908_bug6_manual_stack/040_v2_preset.md new file mode 100644 index 0000000000..4ab32b4768 --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/040_v2_preset.md @@ -0,0 +1,96 @@ +# wp4: server-owned proactive preset carry + +Historical phase record. Delivery is complete; see [071](071_delivery.md) and [072](072_final_proof.md) for terminal evidence. + +Depends on wp3. Carry PR #3951 at db502d486c8d8de80c0143fcfd6f86238d1ce917 with its contributor trailer. All local product commands are NOT RUN. + + make the server own proactive preset recommendations + +Source: [pinned policy module](https://github.com/lidge-jun/opencodex/blob/db502d486c8d8de80c0143fcfd6f86238d1ce917/src/codex/multi-agent-mode-policy.ts#L1), [pinned API additions](https://github.com/lidge-jun/opencodex/blob/db502d486c8d8de80c0143fcfd6f86238d1ce917/src/server/management/agent-settings-routes.ts#L252), [pinned GUI source binding](https://github.com/lidge-jun/opencodex/blob/db502d486c8d8de80c0143fcfd6f86238d1ce917/gui/src/pages/Subagents.tsx#L48). C3 API/GUI change, C4 care for persisted developer-policy text and management boundary. + +### Concrete before -> after hunks + +| Exact path / baseline anchor | Before | After to carry | +| --- | --- | --- | +| `src/codex/multi-agent-mode-policy.ts` (new, head lines 1-24) | Browser owns the preset; no recommendation owner. | Create dependency-free constant `{revision:"proactive-trigger-v1",text:[six clauses].join(" ")}`, two exact released legacy strings, and exact-equality canonicalizer. The recommendation changes the delegation trigger while preserving user/authority/scope/tool rules. | +| `src/codex/features.ts:39,1077-1094` | Non-null writer probes support then forwards original text. | Import canonicalizer; after existing capability probe set `canonicalValue = value === null ? null : canonicalizeOpenCodexModeHint(value)`; pass only that value to existing `setV2StringField`. No read-time/startup migration. | +| `src/server/management/agent-settings-routes.ts:43,250,423` | GET/PUT return stored hint only. | Import recommendation constant and append `multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION` to both successful serializers. Do not add it to accepted PUT fields or config storage. | +| `gui/src/pages/use-subagent-delegation.ts:22-31` | State has stored hint but no recommendation. | Export `UltraModeHintRecommendation {text:string;revision:string}`; require `recommendation: UltraModeHintRecommendation \| null` in `UltraModeState`. Keep patch type as hint text/null and mode. | +| `gui/src/pages/Subagents.tsx:11,43,60-79` | Mode state outlives apiBase; no recommendation validation. | Add `UNLOADED_ULTRA_MODE` including null recommendation; hold `{apiBase,mode}`; derive current/unloaded state by exact apiBase. Validate nonblank string text AND revision, retaining their exact bytes; malformed/missing recommendation becomes null. Preserve abort/generation/current-server guards. | +| `gui/src/pages/Subagents.tsx:97-118,343` | Save checks busy only; forwarded busy does not describe hydration. | Refuse save without current-server mode or matching current apiBase; forward `ultraSaving || !ultraModeCurrent`. Keep PUT then GET refresh and stale-server outcome suppression. | +| `gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:299-328` | Enable/Restore use local constant. | Enable sends current server recommendation.text; disable still sends null. Disable install if forced-v2 eligibility or recommendation is absent; existing nonblank hint remains clearable. Pass nullable server preset to editor. Keep editor key based on stored hint, not recommendation revision. | +| `gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:341-391` | Editor requires string preset; exported browser preset remains. | Nullable preset; Restore changes draft only and is disabled without preset; Save preserves nonblank draft bytes. Remove local `ULTRA_MODE_PRESET` export. | +| `gui/tests/multi-agent-guidance.test.tsx:68,153` | Fixtures lack new field. | Add null recommendation and recommendation-refresh/unsaved-draft regression. | +| `gui/tests/subagents-ultra-mode.test.tsx:1-178` | Imports local preset and asserts duplicated text. | Remove preset import; lazy-import createRoot after DOM setup; use distinctive server recommendation fixture; carry API/malformed/edit/restore/save/server-switch cases. | +| `tests/codex-integration/codex-v2-gate.test.ts:47,426,1342` | Existing TOML and API contracts only. | Import recommendation; independently pin both released strings; add writer normalization, custom preservation, GET-no-write/unrelated-PUT preservation, readback and idempotence cases. | + +All nine locale modules retain keys and change exactly these five values: `sub.ultraMode`, `sub.ultraModeText`, `sub.ultraModeLoadFail`, `sub.ultraModeSaveFail`, `sub.ultraModeSaved`. Exact baseline anchors: `gui/src/i18n/en.ts:712`, `de.ts:683`, `fr.ts:695`, `ja.ts:643`, `ko.ts:700`, `ru.ts:698`, `tr.ts:705`, `zh-TW.ts:550`, `zh.ts:693`. English label becomes “Always proactive delegation”; no hardcoded JSX copy or locale-key rename. + +Docs: merge the wp4 proactive-preset section into `docs-site/src/content/docs/reference/configuration/agents.md:48-60`, and add the equivalent section to `ja/`, `ko/`, `ru/`, `zh-cn/` versions of that same path. Preserve every wp3 roster/proxy-wrapper/replay paragraph. These five files are the only shared files between the original PRs; carry their hunks, not whole-file replacement from #3951. The combined original carry touches 26 distinct files. + +Recommended small SOT followups, main to include in its phase write allowlist: at `structure/03_catalog-and-subagents.md:459` record the separate native-hint versus proxy-guidance owners; at `structure/05_gui-and-management-api.md:129` add the response-only recommendation and explicit-save-only canonicalization contract. No broader restructuring. + +### Complete field lifecycle / consumer audit + +1. **Creation:** policy module owns constant `text` and `revision`. Revision labels server guidance; the GUI validates a nonblank string, does not whitelist revisions, does not infer installed native capability from it. +2. **Serialization:** GET at route `:234-254` and successful PUT at `:413-427` add the same nested object. `src/server/auth-cors.ts:259-263` JSON.stringify serializes it with the existing JSON content type. No new envelope, endpoint, auth path or DTO needed. +3. **GUI hydration:** `Subagents.loadUltraMode` GET reads the object, retains both fields only if valid, and stores it with exact apiBase. `SubagentsWorkspace.tsx:47-60` forwards typed state; it needs no runtime logic change. The recommendation is not written to session-list cache. +4. **GUI interaction:** `ultraOn` remains derived from nonblank stored hint (`SubagentDelegationSection.tsx:57-60`). Enable uses recommendation.text, Clear sends null, Restore only updates local draft, Save sends draft. Recommendation-only refresh must not remount the editor; stored-hint changes still do. While a new API source is pending, the previous source's editor/hint disappears and all mode writes are disabled. +5. **Write input:** `/api/v2` accepts only the existing `multiAgentModeHintText?: string|null` for this behavior. Blank/non-string validation at route `:308-310` and unsupported-runtime preflight at `:335-339` remain before mutations. The recommendation object and revision are never sent back as configuration. The GUI PUT body at `Subagents.tsx:103-107` is JSON.stringify(patch). +6. **Persistence:** route `:390` calls `setMultiAgentModeHintText`. Its capability check remains first. Exact legacy strings become recommendation.text; arbitrary custom text, including one-space variants, stays byte-identical; null stays null. `features.ts:951-1061` owns dedicated/inline/boolean TOML forms, sibling/comment/EOL preservation and atomic writes; `:485` owns string escaping. Persist only `features.multi_agent_v2.multi_agent_mode_hint_text`. Never persist text/revision as extra TOML keys: native config rejects unknown members. +7. **Readback:** `getMultiAgentModeHintText` at `features.ts:935-937` uses `getV2StringField` at `:857`; no canonicalization on read. The successful PUT re-reads stored hint and independently returns recommendation. CLI `src/cli/v2.ts:138,151,167` reads or invokes the same writer, so CLI explicit writes also gain exact-legacy normalization without a CLI implementation edit. +8. **Other consumers:** `gui/src/pages/Models.tsx:476-486,1116-1127,1244-1255` deliberately projects flag/mode/thread/hybrid fields and ignores recommendation; `dashboard-core-poll.ts:233-244` projects mode only; `use-dashboard-data.ts:609-615` sends mode only. These clients need no new required field. Existing synthetic missing-recommendation responses remain useful old-server fixtures. `ULTRA_MODE_PRESET` has only the local component and `subagents-ultra-mode.test.tsx` consumers found in the repository. +9. **Native consumer boundary:** the stored hint is for native Codex sessions; OpenCodex's v2 request guidance does not read this field. Keep the v1 proactive constant and new recommendation as distinct owners rather than replacing one with the other. No capability claim or live native delegation behavior was tested here. + +### Regression activation + +Carry backend fixtures at pinned #3951 `codex-v2-gate.test.ts:52-57,434-446,1382-1412`. Independently pinned release strings must remain in tests; do not import the production legacy array, which would make deleting a compatibility entry delete its oracle too. + +Backend cases: each legacy input canonicalizes only on an explicit write; GET preserves exact TOML bytes; unrelated PUT preserves stored legacy; successful PUT returns canonical stored hint plus unchanged recommendation; re-save is changed:false; adjacent comments and concurrency limit survive; null clears; whitespace variants/custom text survive; unsupported binary leaves a combined request unchanged; existing inline, dedicated, boolean, CRLF, quoting and multiline-refusal tests remain active. + +Small additional hunks recommended in the same registered file: + +- Beside baseline API tests at `:1360`: PUT a custom string with leading/trailing spaces, newline, quotes and backslash; assert exact response hint and decoded TOML equality, same recommendation, and no persisted recommendation/revision keys. Assert a recommendation-only body is rejected without changing files, and recommendation extras beside a valid hint never replace the server constant. +- Beside CLI test at `:1626`: `cmdV2(["mode-hint", legacy])` for both released values; read/status returns canonical text; re-save no change; custom one-space variant preserved; `--clear` removes only hint. Use `fixtureConfig`, temporary CODEX_HOME and `installModeHintRuntime(true)` with existing cleanup. + +GUI cases, pinned [subagents-ultra-mode.test.tsx:126-318](https://github.com/lidge-jun/opencodex/blob/db502d486c8d8de80c0143fcfd6f86238d1ce917/gui/tests/subagents-ultra-mode.test.tsx#L126): distinctive server text in exact PUT; missing/bad fields block install; existing custom text remains editable/clearable; Restore sends no PUT; Save sends one; A -> pending B hides A and blocks writes; B missing/malformed/valid selects correct behavior; late A response cannot overwrite B. Preserve recommendation-only refresh draft test in `multi-agent-guidance.test.tsx:154-182`. Extend malformed matrix with null, empty revision, omitted text/revision, and non-object values; no local preset fallback. + +Remote-only focused activation: `bun test tests/codex-integration/codex-v2-gate.test.ts`; from gui, `bun test tests/subagents-ultra-mode.test.tsx tests/multi-agent-guidance.test.tsx`. **NOT RUN here.** The existing test-layout entries are already present at `scripts/test-layout/layout.json:488,825` and `tests/fixtures/test-layout-expected.json:323,660`; additions to these existing files need no new registration. GUI test discovery already covers `gui/tests`. Main's full hosted gates remain authoritative. + +## UI proof: hosted bundle, synthetic API, existing browser (main-owned) + +The author's missing screenshot is **not a blocker**. Use the current workflow's existing artifact: [ci.yml at baseline](https://github.com/lidge-jun/opencodex/blob/9e1468d4b7a41b498ed2aca98507ada2c741afea/.github/workflows/ci.yml#L451). The `gates` job builds GUI when GUI changes, writes `build-commit.txt` / `build-gui-tree.txt`, and uploads `dashboard-preview-${github.sha}` (`:451-470`). No workflow edits are required for this source lane. + +Main's execution recipe: + +1. Select the hosted artifact covering the cumulative carry candidate. Verify run/event/head, archive artifact ID, build-commit, and build-gui-tree against the actual CI checkout. A pull_request merge SHA may differ from branch head; record that and prove the GUI tree matches the carried candidate. Do not relabel an old artifact as current. +2. Unpack into isolated scratch; serve prebuilt JS/CSS/assets unchanged using a small static/synthetic HTTP server. No Bun/Vite/TypeScript build or product server locally. Fixture state lives in memory and receives only synthetic local requests. +3. Serve the actual SPA at `/#subagents` (`gui/src/App.tsx:431`) and open Advanced (`SubagentDelegationSection.tsx:235-236`). Supply synthetic bootstrap metadata in served HTML: token `ocx_session_fixture`, CSRF `fixture-csrf`, browser/server origins equal the scratch origin. `gui/src/api.ts:90-105,139-149` requires these fields. This modifies only served bootstrap HTML, not bundled implementation. Implement the fixture bootstrap path if requested; never connect it to the user's daemon or credentials. +4. Minimal page fixtures: GET `/api/subagent-models` -> `{available:[],chosen:[]}`; GET `/api/subagent-model-fallback` -> `{available:[],models:[],pollMs:60000}`; GET `/api/injection-model` -> `{multiAgentGuidanceEnabled:true,syncCodexSubagentDefaults:false,model:null,effort:null,efforts:[],available:[]}`. Give other app-shell reads safe synthetic responses with appropriate content types; record requested routes rather than silently forwarding unknown routes. Nonfixture writes must fail closed. +5. GET `/api/v2` baseline -> `{enabled:true,multiAgentMode:"v2",keepNativeChatGptOnV1:false,multiAgentModeHintText:null,multiAgentModeHintRecommendation:{text:,revision:"proactive-trigger-v1"}}`. PUT validates/captures the JSON patch, changes only fixture hint/mode, and returns fresh full state plus `ok:true`. Subsequent GET echoes that state. Use the exact candidate text for screenshots, and a second distinctive text/revision for source-of-preset behavioral proof. +6. Exercise: enable -> expected text PUT and editor; edit custom -> no PUT until Save; Restore -> draft changes without PUT; Save -> exact text PUT; Clear -> null; missing and malformed recommendation with/without existing hint; disabled v2; initial GET 503 then retry; PUT 502 -> error without false saved state. Keep request log assertions next to screenshots. +7. Server switching: the actual App keys Subagents by sharedBase (`App.tsx:431`), while unit tests also cover in-place prop updates. Use two synthetic fixture sources through the existing connection UI if supported by the host; otherwise retain hosted component-test proof for the delayed A/B update and explicitly label that branch browser-NOT-RUN. Do not rebuild a special component harness locally or fake a source switch by altering product code. +8. Capture the changed component at 1440, 1024, 768, 390 and dense 320 px widths; EN and KO long labels, light/dark and visible keyboard focus. Check textarea readability, label/button clipping and horizontal overflow. Save representative off/on/custom/old-server/error screenshots and viewport/state/request-log manifest. No need for decorative assets. + +Suggested main-owned scratch outputs: `ui-v2/source-manifest.json`, `ui-v2/requests.jsonl`, `ui-v2/en-desktop-on.png`, `ui-v2/ko-mobile-custom.png`, `ui-v2/old-server.png`, `ui-v2/error-retry.png`. This lane has created none of them. Screenshots prove the carried GUI rendering and fixture interaction; they do not prove real native delegation or TOML persistence. Backend hosted tests supply persistence evidence. Main can attach its observed images to the carrying PR; it need not wait for the author's images. + + +Main decision: preserve original 23-file diff and all regressions. Suggested extra CLI/API test cases remain a targeted audit decision rather than mandatory scope expansion. Sync structure/03_catalog-and-subagents.md and structure/05_gui-and-management-api.md with the response-only recommendation and explicit-write behavior. Render QA uses the existing built artifact. + +## wp4 P refresh and artifact publication + +Previous wp3 D: PR3992 at3ceef0121 passed CI34181398746, target-check rerun34181398713, independent source/security audit and425page remote docs build. Proceed server-owned preset. Candidate5b60a4fa7+e5a6f35b8 was independently audited25files; all25preimage files on actual predecessor3ceef0121 equal candidatebase8000e2482. Preserve original contribution and both layers of agent guides. + +UI proof uses the immutable matching hosted dashboard artifact and reviewed auxiliary stdlib fixture at /tmp/ocx-bug6-prep-v2-01a07e9d/.tmp/ui-v2/server.py. No product server/build or actual account operations. Capture and inspect representative desktop/mobileEN/KO, valid/custom/restore/save/clear/missing/malformed/error cases; hosted tests retain in-place delayed-server-switch coverage. Publish only sanitized screenshot assets on an owned evidence branch via noverify push and link immutable commit URLs in this PR, so adding review evidence does not rewrite the tested product head. This evidence-only branch is not another product PR or native stack. Actual product delivery remains the one six-layer chain. Preserve asset provenance/buildGUI-tree checks and teardown proof. + +## C render foldback: narrow editor layout + +Real prebuilt GUI capture at320px shows an unbroken configuration-key hint clipped and the textarea compressed beside both actions;390px has no page overflow but editing space is unnecessarily narrow. Evidence ui-qa/ko-320-custom.png and ko-mobile-custom.png under ignored scratch. This is a bounded improvement of the changed preset surface, not a claim that the original PR introduced the old layout. + +MODIFY gui/src/styles-subagents-workspace.css only: add overflow-wrap:anywhere to existing delegation setting hints; give the preset editor a minmax(0,1fr)/auto/auto grid with min-width0 textarea; reuse existing640px media breakpoint to span textarea across a full first row and place two actions on the second row. NoJS, labels, fields or authority semantics change. Rebuild only remotely; compare head-stamped320/390/1440captures and DOMwidth/textareawidth, plus existing frontendgates. Retain prior functional UI request evidence becauseJS unchanged; verify a representative Save on finalrender. Main re-audits this CSS scope before B and captures finalartifact before publishing screenshotproof. + +## C verification foldback: bounded guard probes + +Run34183701289 timed out on macos1/2 after test-home-guard passed its unregistered-home case, then Bun reported a dangling process. Runtime root cause remains unproven (H1retainedchildhandles/H2syncwait/H3isolate state); source establishes an unbounded synchronous probe owner. This is a verification-harness correction, not a claim to fix Bun internals. No production guard behavior changes. + +MODIFY tests/ci-workflows/test-home-guard.test.ts only: convert runProbe and all its callers to awaited async Bun.spawn; reuse exported captureTestOutput from scripts/test.ts (its executable entry is import.meta.main gated, already imported by test-runner tests); preserve exact argv/cwd/environment filtering/fake homes and all existing assertions. Use existing watchdogMs(5000) below CI60s test ceiling, bounded execution/TERM/KILL/reap/output-drain stages with cleared timers and cleanup of probe scratch. Require natural exit0, no signal, complete output; timeout/nonzero/incomplete/unreaped is a thrown diagnostic, never a success marker. Record safe probe-boundary ID/PID/exit diagnostics without inherited environment. Add synthetic nonzero-exit and nonterminating-probe controls proving failures are reported and owned child gone; do not skip guards or extend CI deadlines. All product verification stays remote. If new diagnostics still stall/fail, investigate that boundary; passing the converted harness is not an upstream-root-cause claim. diff --git a/devlog/_fin/260908_bug6_manual_stack/050_credit_alias.md b/devlog/_fin/260908_bug6_manual_stack/050_credit_alias.md new file mode 100644 index 0000000000..30c38ee20b --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/050_credit_alias.md @@ -0,0 +1,29 @@ +# wp5: canonical reset-credit operation identity + +Historical phase record. Delivery is complete; see [071](071_delivery.md) and [072](072_final_proof.md) for terminal evidence. + +Depends on wp4 for the owner-requested delivery chain. C4; no live credit consumption. Source PR #3965 at `6c1477d19c7d1a77a1866cabfd2b4411f1a210d7` carries #3919 by luvs01. Revalidate both source heads and current dev before implementation; do not rewrite their branches. + +## Published patch to carry + +- MODIFY `src/codex/auth-api.ts` at the reset consume handler: `const identity` becomes `let identity`; after execute admission assign `identity = { ...identity, operationId: opened.operationId };`. Upstream dispatch and both durable settlement paths then share the canonical operation ID. Authentication, admission failures, account binding and terminal replay stay before this assignment. +- MODIFY `tests/codex-integration/codex-auth-api.test.ts`: import the existing ledger opener, assert a settled alias replay consumes no additional credit, and construct truly pending canonical operations for thrown fetch, non-2xx and unknown-code alias failures. Assert the durable row becomes ambiguous while account key and canonical ID remain unchanged and terminal code remains null. +- MODIFY `docs-site/src/content/docs/reference/management-api.md`: carry the source paragraph distinguishing unfinished alias joins, known terminal replay and new explicit intent after settlement. + +Retain `Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>` and original source commit references. Do not carry auto-redeem worker changes: #3970 is already on baseline. + +## Verification + +The final immutable three-file source range is `abb46a1599ec0d0bbfbe03905114178df92e67f5..62412d38606851f7cace76360f3c5737db9cae20`; the landed equivalent is `abb46a1599ec0d0bbfbe03905114178df92e67f5..402be7c1f88283eb8465c3aec8437ccecd2542ec`. These final pins supersede the initial intake head above. A live source-head mismatch requires renewed comparison before carry; the mutable PR files page is navigation, not patch authority. Each negative fixture begins pending, so it observes the changed failure-settlement path instead of rechecking an already ambiguous row. Existing no-operationId and ordinary terminal paths remain regression controls. Hosted CI runs the auth and ledger suites; local tests/typecheck/build/install are NOT RUN by owner instruction. A source/security reviewer verifies the exact carried head before merge. Existing source-PR CI failure is historical and must not be described as passing. + +All additional unpublished security analysis lives in ignored `.tmp/bug6-01a07e9d/credit-plan.md` and later audit artifacts. It must not be copied into this public unit. + +## wp5 P refresh + +Previous wp4 D certified PR3993 head727683f44 with CI34185870948, source/security/GUI audits, QA and remote docs. Proceed canonical alias carry. Live refresh supersedes the prepared pin: #3965 merged at03:17:07Z with head62412d386 and merge402be7c1f; prepared bdb9f4bfe+9eb44cfb4 passed two source/security audits. All three target files on actual predecessor727683f44 equal preparedbase d1f61e933; intervening V2/GUI/test-harness deltas do not modify this owner. Keep exact3filecarry and original attribution. No real credentials/resetcredits and no localproductcommands. Actual adoption equality and hostedCI remain required. + +## Verified landed disposition + +Current origin/dev402be7c1f contains the #3965 merge402be7c1f. Its three target files exactly equal prepared candidate9eb44cfb4 (git diff exit0); PR CI34181771859 passed at exacthead62412d386, including Linux4/macOS2/gates. Thus the source item is already landed, not a new product fix. The initial A narrative retained the old OPEN assumption; this fresh source/API evidence corrects it before B. + +NOOP for a new PR. Adopt the identical two contribution commits locally only as the prerequisite for wp6; preserve provenance and contributor credit. The final new recovery PR targets existing layer4 and explains the already-landed alias dependency in its base-relative diff. No duplicate fifth PR is created and no original branch is rewritten. c5 closes on live merged-state/CI/ancestry/file equality evidence; wp6 and final cumulative integration still run their full gates. The single product stack has five new PRs plus this independently landed sixth source item. diff --git a/devlog/_fin/260908_bug6_manual_stack/060_credit_recovery.md b/devlog/_fin/260908_bug6_manual_stack/060_credit_recovery.md new file mode 100644 index 0000000000..1fe1b8137e --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/060_credit_recovery.md @@ -0,0 +1,43 @@ +# wp6: manual reset recovery + +Historical phase record. Delivery is complete; see [071](071_delivery.md) and [072](072_final_proof.md) for terminal evidence. + +Depends on wp5 canonical operation identity. C4. Implements the user-visible contract in public issue https://github.com/lidge-jun/opencodex/issues/3973 . No real account actions or credit consumption are authorized by this development task. + +## File map and private implementation appendix + +- MODIFY `src/codex/auth-api.ts`: connect the authenticated manual operation with the existing quota-observation and routing-recovery ownership contracts. +- MODIFY `src/codex/routing.ts`: reuse narrowly targeted recovery ownership rather than broad account-health clearing. +- MODIFY `tests/codex-integration/codex-auth-api.test.ts` and `tests/codex-integration/codex-cooldown-recovery.test.ts`: mocked endpoint and ownership-race regressions using existing fixture conventions. +- MODIFY `docs-site/src/content/docs/reference/management-api.md` and `structure/08_openai-provider-tiers.md`: document the resulting supported contract when the patch is public, without account examples or internal proof material. +- NO CHANGE to persisted ledger schemas, auto-redemption policy, selected-account policy, GUI, or real credentials. + +The complete before/after design, exact current source anchors, threat model, reachable activation cases and observable negative assertions are recorded in ignored `.tmp/bug6-01a07e9d/credit-plan.md`, section "Layer 2", against baseline `9e1468d4b7a41b498ed2aca98507ada2c741afea`. This is a mandatory implementation appendix, not deferred planning. Repository AGENTS.md requires unpublished security working notes to stay in scratch, overriding public devlog placement. Both the A reviewer and B worker must read the appendix; loss of the appendix requires reconstructing and auditing it before B. + +## Acceptance and verification + +Only the matching account's eligible pre-existing cooldown may be recovered after confirmed reset and fresh supporting evidence. Ordinary successful requests, uncertain results and replay do not gain broader recovery authority. Existing unrelated scopes and caller selections remain intact. The private appendix enumerates the full mocked positive/negative matrix and claim cleanup requirements. + +Run no local product commands. Hosted CI must execute the affected auth, cooldown, quota and provenance suites; independent security review remains required. PR #3848 overlaps the flight interface: refresh before B and integrate any landed change without absorbing its unrelated registration behavior. New code belongs to this owned stack; do not modify other open PRs. Record privacy-safe outcome evidence here only after publication. + +## wp6 P refresh + +Previous wp5 D verified #3965 already landed in dev402be7c1f, exacthead62412d386CIpassed, and locally adopted identical prerequisite (926b3719f); no duplicatePR5. This recovery PR targets existing layer4 #3993 and identifies the already-landed alias prerequisite in its relative diff. Source3973 remains open. Fresh inventory found overlapping contributorPR3995 (e172453052bf7bbc4a0ae5aa24592982c0c64b15) and independent fallbackPR3997; the latter resolves3996 and is outside this goal. The earlier no-overlap narrative was incorrect and is superseded before B. + +Prepared recovery e6e081c09 plus repair a87a3f624 passed independent security and behavior audits. The private repair synthesis and updated handoff under ignored scratch resolve main-publication ordering and positive refresh provenance; never copy security working analysis into this public unit. All six target preimages on actual predecessor926b3719f equal auditedbase9eb44cfb4. Revalidate the unchanged candidate across intervening V2/GUI/testharness context, then adopt. All mocked regressions, current-head hostedCI, privacy, finalfullcohort proof and source-item closeout remain required. No localproductcommands or realcreditactions. + +## Concurrent source reconciliation in P/A + +Review new3995 against the prepared candidate before adoption. Preserve originalcontributor credit and include its useful language/CLI docs or regression cases when source comparison warrants. Existing prepared recovery provides bounded claims/publication/provenance invariants; no competing implementation is accepted solely from prior green claims. Comparative security/behavior source reviews are in progress, all notes remain scratch. No productdelta forwp6 has been adopted yet. + +## Consolidated source decision + +Retain audited recovery e6e081c09+a87a3f624 and consolidate contributorPR3995 rather than creating competing deliveries. Comparative security review retains its PASS; detailed algorithm findings remain private in credit3995Comparison.md. Keep pause/reauth eligibility and existing background lease ownership conservative and document that recovery can remain pending under those conditions. #3997/#3996 stays outside scope. + +Additional MODIFY paths: docs-site/src/content/docs/ko/reference/management-api.md and docs-site/src/content/docs/reference/cli/providers-accounts.md, carrying the matching contributor guidance with parity to the final conditional recovery contract. This expands six unique files to eight. Do not duplicate the fuller English API paragraph. Adapt PR3995 tests into the existing auth-api test: two cold-main reset/already_redeemed cases without prior listing/reconciliation, bogus consume99 versus freshWHAM1; strengthen the existing saturation test with pre-existing shared cooldown, one consume, zero usage and retainedcooldown; adapt the two-old-flight/current-generation convergence scenario to assert fresh fourthdispatch completes before oldresponses, then oldresponses cannotoverwritefreshquota or recoveredcooldown. Preserve and await every deferred fixture cleanup. No new testfile, account-store schema or CLI runtime change. + +Carry sourcee172453052 with Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> in the adaptation commit and final PR body; describe exactly which tests/docs are adopted. B includes local candidate adoption and these bounded test/doc additions; independent interdiff review and exacthead hostedCI remain mandatory. No local tests/install/typecheck/build or realcredits. + +## C fixture foldback + +CI34188041321 caught a shared401-recovery budget leaking between fake-home cases: prior manual-a selfrefresh spends generation2, and the next case creates a different generation2 in a newhome but doesnotreset the module budget. The early spent-budget refusal prevents the intended external-replacement replay. MODIFY only the existing auth-api test: import/call resetQuotaRecoveryForTests in beforeEach/afterEach, assert empty budget at the negative-case start, observe real force-refresh provenance, and KEEP expectedfreshremaining2, replay URLs and cooldown-preservation assertions. No production relaxation. Also use existing watchdogMs(10000) and60souter ceiling for the new convergence fixture; its current run passed, so this is convention/contended-runner safety, not increasing a failing behavioral timeout. Source/interdiff review and newexactheadCI are required. diff --git a/devlog/_fin/260908_bug6_manual_stack/070_integration.md b/devlog/_fin/260908_bug6_manual_stack/070_integration.md new file mode 100644 index 0000000000..8a2dac4482 --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/070_integration.md @@ -0,0 +1,61 @@ +# wp7: hosted verification and integration + +Historical phase record. Delivery is complete; see [071](071_delivery.md) and [072](072_final_proof.md) for terminal evidence. + +Depends on wp1–wp6. The owner explicitly requested a single manual branch chain. This cycle changes only its delivery records and evidence; a discovered product defect is assigned an audited repair cycle before integration continues. + +## File changes + +- MODIFY this unit's `000_plan.md`: replace in-progress outcomes with exact source commit, PR, run IDs, tested heads and terminal dispositions; record failed/skipped checks separately. +- NEW `071_delivery.md`: six-row original-to-carried PR mapping, attribution, pinned GitHub evidence, active branch/base topology and merge result per layer. Store no account identifiers or private payloads. +- NEW `072_final_proof.md`: fetched dev SHA; per-layer ancestry command results; final candidate tree and landed tree comparison; unchanged pre-existing-file fingerprint verification. If a merge commit contains concurrent changes, isolate and explain each difference rather than claiming whole-tree equality. +- MOVE this completed unit to `devlog/_fin/260908_bug6_manual_stack/` only when all outcomes are terminal. Evidence generated before moving records both paths. Do not move other units. +- GUI screenshot files, if needed, use the existing `.github/pr-assets/` convention after verifying the generated image contains synthetic settings only. + +## Exact delivery actions + +1. For each nonempty candidate use a new owned `codex/bug6-01a07e9d-*` branch. Bottom base is dev; each upper base is the prior owned branch. Preserve author trailers and satisfy every section of `.github/PULL_REQUEST_TEMPLATE.md`. +2. Commit with `git -c core.hooksPath=/dev/null commit`; push with `git -c core.hooksPath=/dev/null push --no-verify`. No install, test, typecheck or build hook runs locally. +3. Read each PR's current head/base and native `stack` field. A native membership conflict is inspected without mutating membership. Our newly created ordinary PRs must remain manual. +4. Inspect `gh pr checks` and matching workflow runs. Before landing obtain final candidate `ci.yml` `workflow_dispatch` with `lane=all` as well as required PR checks. Bind conclusions to `head_sha`, event and run attempt. Retry failed jobs only after investigating the actual failure and ensuring it does not hide a product regression. +5. For the preset UI, download the hosted `dashboard-preview-*` artifact from the verified head. Verify `build-commit.txt` and `build-gui-tree.txt`; serve the prebuilt bundle with synthetic API fixtures on a disposable loopback port; observe preset activation/restoration and server-switch behavior in a browser; capture/read the screenshot. No local product compilation. Existing browser driver only, no installation. +6. Refresh MAINTAINERS.md, live actor permission, reviewer objections and security evidence. Record maintainer integration in the owned PR body. The repository deletes merged head branches: inspect direct children and retarget our next child to dev immediately before merging its parent, so automatic deletion cannot close it. Land only the bottom PR with `--match-head-commit`; verify the resulting integration head and CI evidence before advancing. Never merge an upper PR into its parent branch as if that landed it in dev. Do not change repository settings or unrelated children. +7. Fetch dev after each merge and prove the merged commit is an ancestor. At final integration compare actual trees against the final certified candidate, including any explicitly reviewed concurrent dev changes. +8. Refresh each original item and mark closed only if its entire user-visible bug is resolved by the landed tree. Preserve unresolved residuals as open; report the exact residual rather than treating overlap as duplication. + +## Activation and observation + +- Failed/queued/cancelled hosted job: inspect actual run/head; no merge until required evidence is successful. +- Base advances: recompute integration tree and obtain fresh evidence; old SHA checks are historical. +- A maintainer objection remains: resolve its concrete finding or obtain withdrawal before merge. +- A source PR lands concurrently: verify its actual delta and remaining contract; use an evidence-backed NOOP rather than reapplying it. +- UI stale-server or malformed-recommendation fixture: preset install disabled; custom edit/clear retained; no cross-server write. +- Final condition: all six source contracts mapped to landed results; no destructive changes to user files, service state or credentials. + +## Validation limits + +Local product tests, installs, typechecks and builds: NOT RUN by owner instruction. Hosted tests and independent source audits provide product evidence; docs-only filesystem/link/whitespace checks provide document evidence. Neither substitutes for the other. + +## wp7 P refresh + +Previous wp6 D:6904ecd9c passed CI34188893148 and source/security/interdiff audits; coldmain, busy, same-tick replacement and converged-flight regressions passed; docs425pages plus renderedlink/KOparity passed. Latestdev402be7c1f is pinned for integration. Read-only merge-tree predicts conflicts only in reference/management-api.md and codex-auth-api.test.ts because dev already contains the canonicalalias prefix. Both dev versions exactly equal our adopted alias predecessor9eb44cfb4; resolve those two to our current versions, which include that prefix plus the audited recovery. No pre-existing userfile overlaps the incomingdevdelta. + +Merge402be7c1f into the topbranch with hooksdisabled, preserving all other incomingfiles. Record exactresolvedtree, recheckcurrentdev, publishnoverify, and dispatchci.yml lane=all on that exacthead. NewPRchain is3986→3991→3992→3993→4002;3965alreadymerged. All original candidateheads retain passingPRCI; any rewrittenhead gets freshproof. Refresh each target/head/membership/review/CI before its separately authorized ordinary merge. Use merge commits to preserve ancestry/attribution, retarget each nextchild todev, and prove resolved integration content is covered by the certifiedtop. Current-headrequiredchecks and source/security duties remain separate. + +After productlanding, close only satisfied sourceitems, including superseded3995 aftercore3973lands; preserve3997. Archive this unit with an evidence-only closing PR if needed, so completedrecords do not change the tested product tree. Verify that closingdelta is documentation-only and receives its properCI; retain exact product-tree equivalence to the full-matrix candidate rather than attributing skipped productjobs to passingexecution. No release/main/preview/deployment changes. + +## Final CI repair amendment + +Full run34190287787 at f1b436324 failed Windows3/6: the first restart-help correctness test returned an unobserved exit status after its fixed10s synchronous subprocess bound. Other observed shards passed; wait for the complete run before deciding whether any additional repair is needed. The source investigation does not establish a Bun defect or a startup latency cause. + +Modify only `tests/cli/cli-restart-health.test.ts` for this repair. Replace synchronous spawning with awaited Bun.spawn, existing captureTestOutput and watchdogMs(10000). Preserve all eight command tests, arguments, private homes, output assertions and legitimate health exit1. Independently bound execution, TERM grace5s, KILL reap2s and output drain1s; clear timers and keep timeout, signal, rejected observation, incomplete output and unreaped child as failures even after eventual exit0/1. Use an outer cleanup envelope below the existing60s CI ceiling. Keep child ownership and avoid deleting an unreaped child's private home. Emit safe stage/PID/exit diagnostics without inherited environment or credentials. Add small controlled wrapper regressions for sticky timeout, incomplete output at0/1, unreaped child and spawn/observation errors; reuse capture-owner coverage for its internals. No production CLI or workflow timeout changes, assertion removal, skip, retry loop or local product execution. + +An independent plan audit precedes implementation; an independent patch audit precedes publication. Publish the repaired top head with hooks disabled and --no-verify, obtain fresh PR checks and a new full lane=all dispatch on that exact SHA, and verify the original Windows lane. Passing results establish that head's observed outcomes, not the historical root cause. The final docs build runs remotely on the integrated docs tree. Preserve failed-run evidence and all prior user files. + +## Last-layer ancestry repair + +Four lower PRs landed with exact predicted trees; current dev is `74f62f9c2914ead2fba474aa97734e322251bd46`. Candidate `f80f39d20e8395901d3b62758d118ea3a559a9f4` passed PR CI34193213502 attempt2 and full CI34193218874 attempt2 (26 named jobs and execution steps). Each investigated macOS retry was limited to its failed/cancelled job; prior failures remain recorded. + +GitHub refuses the last PR as conflicting even after its base metadata was refreshed to actual dev; ordinary GraphQL and REST merges both refused. Local merge-tree remains clean and equals candidate tree `1b3bd117702b86da5810b1ad21988a2c60df6d17`. The histories have two merge bases, `727683f44e9f1daa9b6b1e2dbf93167e4ce30cc1` and `402be7c1f88283eb8465c3aec8437ccecd2542ec`; this explains the need to simplify ancestry without claiming a proven server implementation cause. + +Merge that exact current dev into the owned top branch with hooks disabled. Before adding this record, require exact candidate-tree equality; no product files may change. Include only this integration record in the merge commit. Independently audit both parents and the exact record-only tree delta, preserve all 30 user files, push no-verify, and obtain fresh PR and full lane=all CI on the new head before merging #4002. Preserve the earlier successful candidate evidence as historical; do not label it new-head execution. All source closures and final archive duties remain pending until landing. diff --git a/devlog/_fin/260908_bug6_manual_stack/071_delivery.md b/devlog/_fin/260908_bug6_manual_stack/071_delivery.md new file mode 100644 index 0000000000..0598a4b401 --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/071_delivery.md @@ -0,0 +1,35 @@ +# Delivery and verification ledger + +Certified final head `5d5d35756b9b672aecf10a64be0db1f7afc144ae` has tree `5fba579b0d10183e921657dbcf4edbd166c20ec7`. All five owned PRs merged bottom-up, and actual product dev `9ad218a9bdd34ee33004c35706d78396bf02eef2` has exactly the same tree. + +| Owned PR | Certified head | Actual dev merge | PR CI run / attempt | +| --- | --- | --- | --- | +| #3986 | `d1f61e933b0cde3df3862baed65546a5cf81066f` | `7b2223776450804a6b8a4509a115dd42ee1b9c40` | 34178540141 / 1 | +| #3991 | `00eb47886690e7b24b0eed69b6d870c33ceade62` | `7730f666ee1acabe2cd7729ec56f4c53149d926c` | 34180674115 / 1 | +| #3992 | `3ceef0121712b290c3d4443e9fc3f0a04cecead6` | `74292a21e6d504960ef753b403341498fd5bfe30` | 34181398746 / 1 | +| #3993 | `727683f44e9f1daa9b6b1e2dbf93167e4ce30cc1` | `74f62f9c2914ead2fba474aa97734e322251bd46` | 34185870948 / 1 | +| #4002 | `5d5d35756b9b672aecf10a64be0db1f7afc144ae` | `9ad218a9bdd34ee33004c35706d78396bf02eef2` | 34198172044 / 1 | + +Final full `ci.yml` workflow_dispatch lane=all: [34198186409](https://github.com/lidge-jun/opencodex/actions/runs/34198186409), attempt2. All 26 named jobs and mandatory execution steps were verified successful. PR #4002 CI [34198172044](https://github.com/lidge-jun/opencodex/actions/runs/34198172044) passed at the exact final head. A successful attempt2 summary includes successful jobs retained from attempt1; it does not mean those jobs executed again. + +## Failures and bounded repairs + +- Go's initial synthetic userinfo fixture failed privacy scanning; fixture construction was corrected before candidate CI34178540141 passed. +- The preset's home-guard subprocess stalled on macOS. Its bounded execution/reap/capture repair retained all10 original fixtures and38 assertions; CI34185870948 passed. The native cause remains unproven. +- Recovery CI34188041321 exposed shared test-budget state between synthetic homes. Isolation was corrected without dropping assertions; CI34188893148 passed. +- Full34190287787 at f1b436324 failed Windows restart-help with an unobserved synchronous child exit at10s. The bounded asynchronous CLI harness retained8 original tests/18 assertions and added10 lifecycle controls. Independent source review and later Windows execution passed. +- At f80f39d20, PR34193213502 attempt1 hit a macOS job cancellation at an unchanged injection-lock test boundary. Same-head full macOS1 passed the same tests; one investigated job-only rerun passed. Full34193218874 attempt1 separately timed out in an unchanged Copilot cancellation test; the same-head macOS shard passed that case. One control-only rerun passed, with21,805 main-suite passes and0 failures. Scheduler, ordering and native causes were not established. +- After four parents landed, GitHub refused the last merge while local merge calculation was clean. Two common ancestors were observed. The final ancestry merge incorporated actual dev74f62f9c; independent review proved that only the070 record changed from the already certified f80 product. Fresh exact-head CI was obtained; no old run was relabeled as execution on the new head. +- Full34198186409 attempt1 failed Windows3 cleanup: EPERM removing a fixed test directory in afterEach caused subsequent setup/cleanup failures. Identical product files had passed the preceding Windows3 run. Independent inspection supported one failed-job-only diagnostic rerun. Job101977029312 passed3,304 tests with0 failures; the affected TTL case passed172.72ms. The handle/permission owner remains unknown. No test threshold or product code changed for this retry. + +Passing jobs were retained during these job retries, not rerun. Failed/cancelled attempts remain historical evidence. No root-cause or flake-eradication claim follows from a successful retry. + +## Attribution, UI and documentation + +The carried Go intent retains `Co-authored-by: jpierrevd <265811239+jpierrevd@users.noreply.github.com>`. V2, alias and adapted #3995 coverage/docs retain `Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>`. Merge commits preserve their history. The lossy mixed-ciphertext filtering proposal from #3838 was declined; fail-closed behavior remains. + +Dashboard tree `b0bc09ba867906375e52cf0180caa4ea4ea95bea` equals the inspected artifact tree. Hosted artifact10039810403 from34183701289 was observed against a synthetic API: enable, exact custom Save, draft-only Restore, Clear, malformed/missing recommendation, error/retry, server switching,320/390px and desktop layouts, and keyboard focus. Two independent reviewers accepted it. Immutable screenshots are in [#3993](https://github.com/lidge-jun/opencodex/pull/3993), evidence commit924327cdd71a14a0aea1936e4c5e1f6b6b660438. Owned fixture/browser ports were closed. + +Docs tree `7041e912691a5150893fbf4f782734c94e49d056` equals the remotely built integrated tree. Bun1.4.0 / Node24.20.0 frozen install and build on isolated macmini-cf scratch produced425 pages; rendered CLI/API anchor and English/Korean recovery text were checked. Archive SHA-256 a51cdbd83f409472defcb7758873734edba167f116a17869ec345366e0e9063d. An early incomplete-transfer attempt was excluded from passing evidence. No docs deployment occurred. + +Local product tests/install/typecheck/build: **NOT RUN**. This archive changes only this unit's Markdown records; its own metadata/CI/privacy verification is separate from product execution. diff --git a/devlog/_fin/260908_bug6_manual_stack/072_final_proof.md b/devlog/_fin/260908_bug6_manual_stack/072_final_proof.md new file mode 100644 index 0000000000..7763124487 --- /dev/null +++ b/devlog/_fin/260908_bug6_manual_stack/072_final_proof.md @@ -0,0 +1,23 @@ +# Actual product landing proof + +Verified 2026-09-08. Final candidate `5d5d35756b9b672aecf10a64be0db1f7afc144ae` and fetched product dev `9ad218a9bdd34ee33004c35706d78396bf02eef2` have identical tree `5fba579b0d10183e921657dbcf4edbd166c20ec7`. `git diff --exit-code` returned0. Each owned head and actual merge commit is ancestral to fetched dev; #3965's existing402be7c1f merge is also ancestral. + +| PR | Actual merge commit | +| --- | --- | +| #3986 | `7b2223776450804a6b8a4509a115dd42ee1b9c40` | +| #3991 | `7730f666ee1acabe2cd7729ec56f4c53149d926c` | +| #3992 | `74292a21e6d504960ef753b403341498fd5bfe30` | +| #3993 | `74f62f9c2914ead2fba474aa97734e322251bd46` | +| #4002 | `9ad218a9bdd34ee33004c35706d78396bf02eef2` | + +Before each merge, current head/base, actor admin permission, maintainer roster/reviews, CI, native membership and direct-child inventory were refreshed. The dev-only maintainer-integration decision and exact verification were recorded in each owned PR body. No maintainer change request remained. Independent technical/security review duties were retained; this was not self-approval. Native stack membership was empty. + +The first four actual merge trees matched their serial predictions. GitHub's final merge refusal was repaired with an independently audited ancestry merge; the final actual dev tree then matched the newly certified head exactly. Full CI34198186409 attempt2 verified all26 named jobs and mandatory execution steps; PR CI34198172044 attempt1 passed. Earlier failed attempts are recorded in071. + +Sources3838/3944/3951 are closed, issue3907 is completed, and3965 was already merged. After actual4002 landing, issue3973 was completed and3995 was closed as consolidated. The scope excludes3997/3996. Thirty original user files matched their pinned SHA-256 values with0 missing and0 mismatched; they were excluded from all commits. Scratch/evidence directories remain untracked. + +## Archive-only completion record + +This closing change moves exactly this unit from `devlog/_plan/260908_bug6_manual_stack/` to `devlog/_fin/260908_bug6_manual_stack/`, updates its terminal records and adds this proof. Product content is unchanged. The closing PR and session receipt verify the exact old/new paths, regular-file modes and blob IDs against the reviewed delta, plus closing CI/metadata and a remote privacy scan. Documentation-only skipped product jobs are NOT RUN, not passing product executions. + +The closing PR's own merge SHA cannot be embedded in the commit that creates it. Its observed post-merge ancestry and exact record-only delta are verified after landing in the final session receipt and delivery report, rather than predicted here. diff --git a/devlog/_fin/260908_c248_individual_fixes/000_plan.md b/devlog/_fin/260908_c248_individual_fixes/000_plan.md new file mode 100644 index 0000000000..3dca83c8cb --- /dev/null +++ b/devlog/_fin/260908_c248_individual_fixes/000_plan.md @@ -0,0 +1,33 @@ +# Lane C: independently reviewable 2.48 preparation fixes + +Scope: satisfy-spec HOTL requested by the owner on 2026-09-08. Goal: independently land #3953, #3899 and the timezone-only part of #3950 into dev, retaining original authors. This returned/stored roadmap is the memory artifact. Baseline dev: `514350e6f79ed4539378388bc39d3fc79ff2c70c`. No resource budget was specified; native host limits apply. Tool scope: local Git/source/artifact checks, GitHub repository/Actions, and explicitly authorized A/B coordination; astra high read-only auditors. No local product tests, typechecks, builds or dependency installation, including incidental Git-hook execution. Use per-command `git -c core.hooksPath=/dev/null` for mutating Git operations, and `push --no-verify`; do not modify shared Git configuration. + +## Work phases and ownership + +`roadmap` (this docs-only full PABCD) precedes three independent delivery cycles: `privacy`, `release_notes`, `timezone`. `reconcile` depends on those deliveries and lane B's JWT evidence. The processing order is scheduling, not a code dependency: each delivery remains a separate dev-targeted PR. Reuse original #3953 if unchanged and reviewable; carry #3899 onto current dev if needed; timezone gets a new PR sourced from only commit 1d8f6ff7e8d48f33c3ce7a1b7118068754bbbe83. Never create a combined code-delivery PR or squash different bugs together. Local roadmap/outcome commits stay on this coordination branch until a separate documentation-only closeout is appropriate. A separately audited evidence-only workflow branch may add supplemental hosted platform proof without entering any delivery PR or changing its required CI. + +## Boundaries + +No JWT changes, provider/routing work, main/preview promotions, version changes, deployments, release execution, live-account/service probes, history rewriting or public reproduction of removed material. Confidential investigation stays in ignored `.tmp/c248/`. The public docs describe only approved correction scope, not sensitive values. No new runtime type/enum/field is added, so creation/serialization/deserialization/consumer field-chain work is N/A. + +## Verification contract + +The roadmap uses actual file and source-object inspection plus `git diff --check` (run in this checkout before the roadmap close). Delivery uses the repository's existing hosted CI on each PR's current head; source-sensitive suites and the actual workflow scope must be checked. Local product commands are explicitly NOT RUN. No blind retries, cancelled/skipped/pending-as-pass, broad test weakening or artificial screenshots. A docs-only scope check is not a product-suite pass. Head rewrites require fresh current-head evidence. If dev moves, classify the actual delta and do not claim an unexecuted integration tree was tested. Before merge, validate required gates and exact head; preserve unrelated destination changes and prove the landed source diff. + +## Integration coordination + +The active A and B workstreams agreed on `/ocx-248-dev-merge.lock`: atomic mkdir, owner.json with sessionId/pid/hostname/PR/SHA/acquiredAt, owned only from final refresh through landed verification. Never hold it while waiting for CI or delete another owner's lock. This is a cooperative serialization convention, not a security boundary; an uncooperative actor can bypass it. The main thread resolves collisions, missing permissions or contradictory evidence without expanding worker scope; a new worker slice requires a plan amendment, and two distinct failed workers return the slice to main. + +Use the repository PR template and MAINTAINERS.md. Explicit maintainer integration is allowed only after checking live identity/role, outstanding objections, required CI, and required security review. Existing PR head authors remain attributed; carried commits use cherry-pick provenance and a Co-authored-by trailer surviving squash. After landing, verify merge SHA ancestry, actual file delta, destination preservation, and authors. Close original carry PRs only then. #3950 stays open until both timezone and B JWT fixes are proven on dev; C owns final closure. A/B status contributes to a readiness report, not release authority. + +## Stop and outcomes + +DONE requires all three delivered/proven already present and #3950 reconciled; final report lists source PR, delivery PR, landed SHA, actual CI results, authors and residuals. A blocked item does not stop independent work. Missing authority or unsafe evidence is unresolved, not a successful criterion. Read goalplan/ledger after each D and continue remaining cycles. Scope does not include a fixed cost/time budget or new paid service purchases. + +## Roadmap audit and completion + +Independent astra high roadmap audit: PASS, no blocking findings. The timeout prose was aligned with the dedicated child marker used in the exact patch. The roadmap-only check is Git diff whitespace plus independent source/semantic audit; no product suite was run. Next cycle: adopt and validate the unchanged #3953 correction. + +## Final reconciliation + +Privacy and release-note cycles completed before the timezone cycle. All delivery evidence and residuals are in050_outcome.md. Original3950 closed after both BJWT and Ctimezone landing proof. This unit archives to_fin through a separate docs-only PR; no product commit is combined with this record. diff --git a/devlog/_fin/260908_c248_individual_fixes/010_privacy.md b/devlog/_fin/260908_c248_individual_fixes/010_privacy.md new file mode 100644 index 0000000000..6e22b7a5f2 --- /dev/null +++ b/devlog/_fin/260908_c248_individual_fixes/010_privacy.md @@ -0,0 +1,19 @@ +# Phase privacy: adopt the exact #3953 correction + +Source SHA: 05fd82807b4a0014f84b9d74d05b70a3591cb574. Source URL: https://github.com/lidge-jun/opencodex/pull/3953 + +MODIFY the two existing records under devlog/_plan/260904_provider_quota_refresh/: 030_wp3_live_verification_and_pr.md and 031_live_verification_record.md. DELETE assets/030_accounts_refresh_button.png and assets/040_accounts_refresh_result.png. The exact after-content is the existing source SHA's Git blobs, retrieved by `git show :`; do not copy removed values into this plan. The two deletions and two post-image blobs define the complete executable patch; no other path changes. Reuse the unchanged existing PR if its head still matches; otherwise refresh and re-audit instead of force-pushing the contributor branch. + +Acceptance: both asset paths are absent in the candidate tree; no retained Markdown literal reference points to them; the isolated-instance and real forced-read evidence remains; the cleanup statement still says moved to Trash, never permanent destruction. Do not open/display either removed PNG. Independent privacy/semantic review checks this exact source. Current-head hosted PR scope check and aggregate must complete, with all skipped jobs described as skipped. Inspect full candidate delta and workflow equality before approving a fork run. No public operating values are recorded in new artifacts. Public documentation is the SoT target; history cleanup is out of scope. + +Rollback: retain source/landed SHAs, but do not automatically reintroduce removed captures; any rollback needs explicit privacy assessment. Completion uses actual merged PR state/merge SHA, touched-path equality, unchanged destination paths and author attribution. + +Execution decision: use a maintainer-owned carry PR with the identical source commit. The contributor checklist asks for local-CI attestation that this run cannot truthfully supply under the no-local-product-check instruction; no source rewrite or new behavior is needed. Original #3953 remains open until the carry lands, and its author is preserved in the squash trailer. + +C review repair: CodeRabbit requested American-English afterward at the existing isolation sentence. Accepted one-word correction in 0ed232d5c, with surrounding historical facts unchanged. Previous CI 34166442230 passed 3 scope/aggregate jobs and skipped 10 product jobs; it does not certify the new head. The new head must be checked before landing. + +Source refresh correction: original #3953 advanced to ca21efd29730086ede902c4701124893ce58b404 before closure. It remains OPEN; any earlier closure claim in the operational task log was premature and has been corrected. The initial carry is already landed and must not be rewritten. Audit and carry the incremental 05fd828..ca21efd2 delta as a separate privacy follow-up: MODIFY 000_plan.md, 030_wp3_live_verification_and_pr.md, 031_live_verification_record.md under the source unit; DELETE assets/010_meta_usage_quota.png and assets/020_usage_refresh_result.png. Exact post-images are sourceca21efd2 blobs, retaining the landed afterward spelling. The complete delta is retrieved with git diff05fd828..ca21efd2 scoped to those five paths. Acceptance: one retention rule covers both Accounts/Usage, textual behavior and Trash historical outcomes remain, no PNG inspected, all four capture assets and all scoped old references absent, current-head hosted scope checks and fresh source/author/landing proof. + +Review synthesis: #3959 exposed two issues. Accepted the source plan/actual isolation mismatch and corrected both old restart sites so completed scratch evidence supersedes the working-service restart plan (fc6b07eaf). Declined history purge as explicitly outside owner scope; the historical reachability residual is preserved, not claimed fixed. Both review threads have documented dispositions. No runtime operation was performed. Latest head needs fresh hosted scope CI. + +DONE: initial carry#3955 landed9c54000c9 and follow-up#3959 landed01c23aedc. Current-head hosted scope CI34167651789 success3/skipped10, independent final privacy audit PASS, all known review findings dispositioned; automatic rereview was pending at merge and not counted as successful. Both landed trees, parents, dev ancestry and actual author trailers verified. Original#3953 closed at refreshedca21efd2. History purge remains explicitly outside scope. diff --git a/devlog/_fin/260908_c248_individual_fixes/020_release_notes.md b/devlog/_fin/260908_c248_individual_fixes/020_release_notes.md new file mode 100644 index 0000000000..de8b38e701 --- /dev/null +++ b/devlog/_fin/260908_c248_individual_fixes/020_release_notes.md @@ -0,0 +1,144 @@ +# Phase release_notes: exact leading enforcement marker normalization + +Source PR #3899, source SHA 4d6896cd0bd62434b4703a1956fe57a99cd4959a. MODIFY the three files below. Preserve/reuse the source PR's related numbered implementation record if carrying its whole commit; it is documentation for this same bug, not another feature. Security review covers title text handling only: no workflow, command dispatch, credentials, publishing or release execution change. SoT: structure/06_docs-and-release.md. + +Activation/acceptance: prefixed generated and carried notes lose only the exact leading marker in summaries and full changelog; conventional scope grouping and attribution remain; unrelated bracketed/nonleading/near-match markers remain. Hosted CI must execute tests/ci-workflows/release-notes.test.ts (via the existing shard manifest) plus required gates. Explicit security review is recorded before maintainer sponsorship/integration. No local tests or typecheck are run. Rebase/carry applies only to our branch, uses original author and -x/Co-authored-by, and exact current-head checks. Issue #3895 closes only after verified dev landing. One independent revert restores only this bug's diff. + +Exact source patch follows; refresh against latest dev at its P phase: + +```diff +diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts +index 16627f5f9..d0a58a043 100644 +--- a/scripts/release-notes.ts ++++ b/scripts/release-notes.ts +@@ -546,8 +546,14 @@ export function parseGeneratedNotes(body: string): ReleaseNoteCategory[] { + const CONVENTIONAL_COMMIT_PREFIX = + /^(?:feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert|merge|release)(?:\(([^)]+)\))?:\s*(.+)$/i; + ++function stripPrEnforcementPrefix(title: string): string { ++ const text = title.trim(); ++ const prefix = "[WRONG BRANCH] "; ++ return text.startsWith(prefix) ? text.slice(prefix.length).trim() : text; ++} ++ + export function cleanPrTitle(title: string, prNumber: number | null = null): { scope: string | null; text: string } { +- let text = title.trim(); ++ let text = stripPrEnforcementPrefix(title); + let scope: string | null = null; + const prefix = CONVENTIONAL_COMMIT_PREFIX.exec(text); + if (prefix) { +@@ -689,7 +695,7 @@ export function renderReleaseNotes(input: { + changelog.push(`Full Changelog: https://github.com/${repo}/compare/${from}...${to}`, ""); + } + for (const pr of allPrs) { +- changelog.push(`- #${pr.number} ${pr.title.trim()} @${pr.author}`); ++ changelog.push(`- #${pr.number} ${stripPrEnforcementPrefix(pr.title)} @${pr.author}`); + } + parts.push(changelog.join("\n")); + } +diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md +index 8c6149802..886c8fc04 100644 +--- a/structure/06_docs-and-release.md ++++ b/structure/06_docs-and-release.md +@@ -227,6 +227,11 @@ so stable notes are the aggregate of their preview train. The raw commit dump is + intentionally gone — non-PR commits stay reachable via the Full Changelog compare link when + that link is available. + ++Both summary bullets and full-changelog titles strip the exact leading `[WRONG BRANCH] ` ++enforcement marker. Other bracketed text is preserved. Summary bullets still remove conventional ++commit prefixes and group by scope; full-changelog entries keep those conventional prefixes, ++PR numbers, and author attribution. This normalization does not change PR-target enforcement. ++ + The deterministic renderer produces the structure but not curated prose. Maintainers who want + the OpenAI-style grouped summaries can run the optional local polish step against the rendered + body (needs an OpenAI-compatible API key): +diff --git a/tests/ci-workflows/release-notes.test.ts b/tests/ci-workflows/release-notes.test.ts +index 11196108d..d27036008 100644 +--- a/tests/ci-workflows/release-notes.test.ts ++++ b/tests/ci-workflows/release-notes.test.ts +@@ -455,6 +455,20 @@ describe("rewriteTakeoverCredits", () => { + }); + + describe("cleanPrTitle", () => { ++ test("removes the enforcement marker before extracting scope and sentence casing", () => { ++ expect(cleanPrTitle(" [WRONG BRANCH] chore(release): promote validated 2.45.0 to main (#3813) ", 3813)).toEqual({ ++ scope: "release", ++ text: "Promote validated 2.45.0 to main", ++ }); ++ }); ++ ++ test.each([ ++ ["[Preview] chore(release): keep this marker", "[Preview] chore(release): keep this marker"], ++ ["fix: document [WRONG BRANCH] markers", "Document [WRONG BRANCH] markers"], ++ ["[WRONG BRANCH]ish: keep this title", "[WRONG BRANCH]ish: keep this title"], ++ ])("preserves meaningful title text: %s", (title, text) => { ++ expect(cleanPrTitle(title).text).toBe(text); ++ }); + test("strips conventional prefix, keeps scope, and sentence-cases the title", () => { + expect(cleanPrTitle("feat(providers): add Baseten Model APIs preset", 653)).toEqual({ + scope: "providers", +@@ -488,6 +502,55 @@ describe("cleanPrTitle", () => { + }); + + describe("renderReleaseNotes", () => { ++ test.each(["delta", "carried"])("removes the bot marker from summaries and full changelogs (%s)", source => { ++ const body = [ ++ "## What's Changed", ++ "### Chores", ++ "* [WRONG BRANCH] chore(release): promote validated 2.45.0 to main by @lidge-jun in https://github.com/lidge-jun/opencodex/pull/3813", ++ ].join("\n"); ++ const notes = renderReleaseNotes({ ++ npmMetadata: "", ++ ...(source === "delta" ? { deltaPrNotes: body } : { carriedPreviewNotes: [ ++ "## Chores", "", ++ "- [WRONG BRANCH] chore(release): promote validated 2.45.0 to main (#3813)", "", ++ "## Changelog", "", ++ "- #3813 [WRONG BRANCH] chore(release): promote validated 2.45.0 to main @lidge-jun", ++ ].join("\n") }), ++ }); ++ expect(notes).toBe([ ++ "## Chores", "", ++ "- Promote validated 2.45.0 to main (#3813)", "", ++ "## Changelog", "", ++ "- #3813 chore(release): promote validated 2.45.0 to main @lidge-jun", "", ++ ].join("\n")); ++ }); ++ ++ test("groups a bot-prefixed title with ordinary titles of the same scope", () => { ++ const notes = renderReleaseNotes({ ++ npmMetadata: "", ++ deltaPrNotes: [ ++ "## What's Changed", "### Chores", ++ "* [WRONG BRANCH] chore(release): promote verified version by @maintainer in https://github.com/lidge-jun/opencodex/pull/10", ++ "* chore(release): update notes by @contributor in https://github.com/lidge-jun/opencodex/pull/11", ++ ].join("\n"), ++ }); ++ expect(notes).toContain("- Release: Promote verified version; Update notes (#10, #11)"); ++ expect(notes).toContain("- #10 chore(release): promote verified version @maintainer"); ++ expect(notes).toContain("- #11 chore(release): update notes @contributor"); ++ expect(notes).not.toContain("[WRONG BRANCH]"); ++ }); ++ ++ test.each([ ++ "[Preview] chore(release): retain the preview marker", ++ "fix: document [WRONG BRANCH] markers (#99)", ++ "[WRONG BRANCH]ish: retain this title", ++ ])("preserves meaningful full-changelog title text: %s", title => { ++ const notes = renderReleaseNotes({ ++ npmMetadata: "", ++ deltaPrNotes: `## What's Changed\n### Chores\n* ${title} by @contributor in https://github.com/lidge-jun/opencodex/pull/12`, ++ }); ++ expect(notes).toContain(`- #12 ${title} @contributor`); ++ }); + const carried = [ + "", + "", +``` + +## C-stage correction: active release builder + +Accepted Codex review: actual release.yml invokes scripts/build-release-changelog.ts, whose changelog still used pr.title.trim(). The original tests certified a renderer but not this active entry. Extend the same bug fix: export stripPrEnforcementPrefix from scripts/release-notes.ts, import/use it for PR changelog titles in scripts/build-release-changelog.ts; add public buildReleaseNotes regressions in existing tests/ci-workflows/build-release-changelog.test.ts for generated-note enrichment and associated-PR fallback, asserting cleaned summary, preserved conventional changelog title/author/ID, and unrelated/embedded/near-match preservation. Keep category policy, direct-commit policy, network/dispatch and release coverage rules unchanged. The two new paths are part of this one bug, not a new delivery. Hosted current-head CI must execute both renderer test files. No local product test. Re-audit active caller and pure-string security boundary before accepting the repair. + +Repair source audit PASS at ef15842fc: actual builder emission and generated/associated regression paths verified. Source-of-truth paragraph corrected in be1f60f28 to distinguish active builder from standalone renderer; category selection/direct-commit policy unchanged. Final proof compares landed blobs to final reviewed candidate, not the original incomplete source. Prior run34167832861 passed16/skipped3 and showed original renderer cases onLinux/macOS; final newhead must be certified separately. + +DONE: PR3960 landed9c8f66b9d, finalheadbe1f60f28; CI34168481093 success16/skipped3. Both renderer files and new5 active-builder cases were observed in Linux logs; macOS lanespassed. Independent final source/security auditPASS. Exact destination60bcb9050 plus reviewed patch tree verified; coauthor present; source3899 and issue3895 closed. The combined destination tree was verified structurally, not claimed executed as the PR test tree. Local productcommandsNOTRUN. diff --git a/devlog/_fin/260908_c248_individual_fixes/030_timezone.md b/devlog/_fin/260908_c248_individual_fixes/030_timezone.md new file mode 100644 index 0000000000..479027db3f --- /dev/null +++ b/devlog/_fin/260908_c248_individual_fixes/030_timezone.md @@ -0,0 +1,157 @@ +# Phase timezone: extract only the Santiago fixture + +Source PR #3950. Carry ONLY commit 1d8f6ff7e8d48f33c3ce7a1b7118068754bbbe83 onto current dev, retaining luvs01's author and adding a surviving Co-authored-by. MODIFY only gui/tests/usage-custom-range.test.tsx. No JWT, product UI, dependency or workflow change. No UI screenshot is fabricated: describe the test-only scope truthfully in the PR. No new SoT contract beyond fixture isolation; production date interpretation is unchanged. + +Activation/acceptance: parent TZ absent and set cases retain exact presence/value and local Date epoch; a child Bun process is created with TZ=America/Santiago and an exact anchored test-name filter, preventing recursion by that timezone value. Child asserts skipped midnight, final-day activity and tooltip as before. Process deadline 12s, child test timeout 10s, parent test timeout 15s; timeout, signal and nonzero exit surface captured diagnostics. Reviewer must check config/preload behavior under direct child invocation and Windows Bun 1.4.0 compatibility. Hosted dashboard test gate explicitly executes this test file; inspect result and logs, not only a generic check badge. Additional fault-path testing is required only if audit reveals a reachable unprotected failure; amend this doc before any code change. Local product tests/build/typecheck/install NOT RUN. + +#3950 original stays open until B's separate JWT fix is independently confirmed on dev. No assumption that the original mixed PR's CI certifies this split head. One independent revert covers the timezone test only. + +Exact source patch follows: + +```diff +diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx +index 887c31134..02df29f3d 100644 +--- a/gui/tests/usage-custom-range.test.tsx ++++ b/gui/tests/usage-custom-range.test.tsx +@@ -154,31 +154,45 @@ for (const connected of [false, true]) { + } + + test("America/Santiago midnight DST retains final-day activity and tooltip", async () => { +- const previous = process.env.TZ; +- process.env.TZ = "America/Santiago"; +- try { +- expect(new Date(2026, 8, 6, 0).getHours()).toBe(1); +- await mount(); +- await respond(0, "preset-marker"); +- await enter("2026-09-05T00:00", "2026-09-07T23:59"); +- await apply(); +- const gate = requests.at(-1)!; +- const data = report(gate, "santiago-marker", "2026-09-07"); +- data.days = ["2026-09-05", "2026-09-06", "2026-09-07"].map(date => ({ +- date, requests: date === "2026-09-07" ? 7 : 0, measuredRequests: 0, reportedRequests: 0, +- totalTokens: date === "2026-09-07" ? 700 : 0, models: [], +- })); +- 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"); +- } finally { +- if (previous === undefined) delete process.env.TZ; +- else process.env.TZ = previous; ++ if (process.env.TZ !== "America/Santiago") { ++ // Restoring an absent TZ can change Bun's effective timezone on Windows. ++ // Start the DST case in its timezone without mutating this suite's clock. ++ const timezone = { present: Object.hasOwn(process.env, "TZ"), value: process.env.TZ }; ++ const localTime = new Date(2020, 8, 15, 10, 20).getTime(); ++ const child = Bun.spawnSync([ ++ process.execPath, "test", import.meta.path, ++ "-t", "^America/Santiago midnight DST retains final-day activity and tooltip$", ++ "--timeout", "10000", ++ ], { ++ env: { ...process.env, TZ: "America/Santiago" }, ++ stdout: "pipe", stderr: "pipe", timeout: 12000, killSignal: "SIGKILL", ++ }); ++ const diagnostics = `${child.stdout.toString()}\n${child.stderr.toString()}`; ++ expect(child.exitedDueToTimeout, diagnostics).not.toBe(true); ++ expect(child.signalCode, diagnostics).toBeUndefined(); ++ expect(child.exitCode, diagnostics).toBe(0); ++ expect({ present: Object.hasOwn(process.env, "TZ"), value: process.env.TZ }).toEqual(timezone); ++ expect(new Date(2020, 8, 15, 10, 20).getTime()).toBe(localTime); ++ return; + } +-}); ++ expect(new Date(2026, 8, 6, 0).getHours()).toBe(1); ++ await mount(); ++ await respond(0, "preset-marker"); ++ await enter("2026-09-05T00:00", "2026-09-07T23:59"); ++ await apply(); ++ const gate = requests.at(-1)!; ++ const data = report(gate, "santiago-marker", "2026-09-07"); ++ data.days = ["2026-09-05", "2026-09-06", "2026-09-07"].map(date => ({ ++ date, requests: date === "2026-09-07" ? 7 : 0, measuredRequests: 0, reportedRequests: 0, ++ totalTokens: date === "2026-09-07" ? 700 : 0, models: [], ++ })); ++ 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"); ++}, 15000); + + test("Apply submits inclusive bounds once; Clear restores the held preset without custom cache entries", async () => { + await mount(); +``` + +## Audit-driven amendment before implementation + +The source patch's explicit 15-second per-test timeout overrides its child CLI 10-second timeout. Change the final test timeout to `process.env.OCX_USAGE_SANTIAGO_CHILD === "1" ? 10000 : 15000`. Add a unique completion marker printed only after the child's last UI assertion; require the marker in the parent as well as exit/signal/timeout checks. Set the child's cwd explicitly to the dashboard root resolved from import.meta.dir. These are same-bug test integrity changes; no production code changes. Preserve the original assertions and parameterized test cases. + +Existing hosted Windows/macOS jobs do not run gui/tests. A supplemental verification-only branch will use the already-registered ci.yml workflow_dispatch path, with a separately reviewed minimal workflow that checks out an immutable candidate SHA and executes only focused timezone proof on GitHub-hosted ubuntu/windows/macos. This branch/workflow is excluded from delivery and never merged. Candidate PR CI remains unchanged and required; the supplemental run is independently labeled, not passed off as normal candidate workflow CI. Actions use existing pinned SHAs, contents:read only, no secrets, checkout persist-credentials:false, Bun1.4.0, frozen root and dashboard installs on the hosted machines, and bounded jobs/processes. Never run any of these commands locally. Negative controls must restore candidate bytes before the final positive run and record source identity. + +The proposed hosted verification starts in gui/: `bun test --isolate ./tests/usage-custom-range.test.tsx`, with TZ absent, Etc/UTC, Asia/Seoul and America/Santiago in distinct subprocess environments. Verify parent environment and next tests, child success marker, nonzero-exit/absent-marker propagation and process deadline; no fixture/process may survive teardown. Exact workflow YAML, pinned commit and control script are reviewed before dispatch. The repository's Windows product runtime suite is distinct from this Windows dashboard proof. + +### Exact test-integrity follow-up diff atop the original source commit + +```diff +--- a/gui/tests/usage-custom-range.test.tsx ++++ b/gui/tests/usage-custom-range.test.tsx +@@ -1,5 +1,6 @@ + import { afterEach, beforeEach, expect, test } from "bun:test"; + import { Window } from "happy-dom"; ++import { resolve } from "node:path"; + import { act } from "react"; + import type { Root } from "react-dom/client"; + import { LanguageProvider } from "../src/i18n/provider"; +@@ -154,7 +155,7 @@ + } + + test("America/Santiago midnight DST retains final-day activity and tooltip", async () => { +- if (process.env.TZ !== "America/Santiago") { ++ if (process.env.OCX_USAGE_SANTIAGO_CHILD !== "1" && process.env.TZ !== "America/Santiago") { + // Restoring an absent TZ can change Bun's effective timezone on Windows. + // Start the DST case in its timezone without mutating this suite's clock. + const timezone = { present: Object.hasOwn(process.env, "TZ"), value: process.env.TZ }; +@@ -164,17 +165,20 @@ + "-t", "^America/Santiago midnight DST retains final-day activity and tooltip$", + "--timeout", "10000", + ], { +- env: { ...process.env, TZ: "America/Santiago" }, ++ cwd: resolve(import.meta.dir, ".."), ++ env: { ...process.env, TZ: "America/Santiago", OCX_USAGE_SANTIAGO_CHILD: "1" }, + stdout: "pipe", stderr: "pipe", timeout: 12000, killSignal: "SIGKILL", + }); + const diagnostics = `${child.stdout.toString()}\n${child.stderr.toString()}`; + expect(child.exitedDueToTimeout, diagnostics).not.toBe(true); + expect(child.signalCode, diagnostics).toBeUndefined(); + expect(child.exitCode, diagnostics).toBe(0); ++ expect(child.stdout.toString().split(/\r?\n/), diagnostics).toContain("OCX_SANTIAGO_CASE_COMPLETED"); + expect({ present: Object.hasOwn(process.env, "TZ"), value: process.env.TZ }).toEqual(timezone); + expect(new Date(2020, 8, 15, 10, 20).getTime()).toBe(localTime); + return; + } ++ expect(process.env.TZ).toBe("America/Santiago"); + expect(new Date(2026, 8, 6, 0).getHours()).toBe(1); + await mount(); + await respond(0, "preset-marker"); +@@ -192,7 +196,8 @@ + 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"); +-}, 15000); ++ if (process.env.OCX_USAGE_SANTIAGO_CHILD === "1") console.log("OCX_SANTIAGO_CASE_COMPLETED"); ++}, process.env.OCX_USAGE_SANTIAGO_CHILD === "1" ? 10000 : 15000); + + test("Apply submits inclusive bounds once; Clear restores the held preset without custom cache entries", async () => { + await mount(); +``` + +C review amendment: use the dedicated child marker as the sole recursion guard, even when the parent already starts in Santiago. This preserves all original DST assertions and makes completion/state checks run for every parent TZ. Accepted CodeRabbit finding; final source/evidence checkout SHA will be repinned and hosted proof rerun. Prior Linux/Windows proof8223788bd remains historical, not finalhead evidence. + +Final candidate ce71d9171 passed independent marker-guard source re-audit. Evidence workflow7d5f1097e/run34170111719 checks out exactcandidatece71d9171; Linux/Windows/macOS each completed10scenarios, fivepositive/fiveexpectednegative, with exactfailure attribution, timeoutPIDabsence and candidatebytesrestored. Actual evidence JSON logs checked. Normal PR3967CI34170093095 pending; no completion/landing claim yet. Existing maintainer gui-screenshot-waived exception applied for test-only change after workflow/label policy inspection. No UI screenshot fabricated, no product gate waived. + +NormalCI attempt1 of34170093095 was cancelled at macos1 job20-minute deadline. Last emitted test was the unchanged client-connect CLI rejection case, followed by dangling-process cleanup and no completion. This root macOS lane does not include gui/tests; exact cause remains under investigation. Preserve cancellation as an unsuccessful/incomplete attempt. One same-head failed-job recheck was requested for diagnosis; a green recheck alone does not establish the unrelated runner stall is fixed. Supplemental3OS timezoneproof remains separately valid. + +DONE: PR3967 landedc46c22f3e with luvs01 trailer and exactcandidate file. Final candidatece71d9171 supplemental3OS run34170111719 passed all30expected scenarios. StandardCI34170093095 attempt2 passed19jobs/skipped2; attempt1 macos1 stalled/cancelled20min at unchangedclientconnect boundary remains unresolved reliability residual, not a fixedflake claim. Exact destinationbbea77a48+candidatepatch tree and devancestry verified. BJWT3962/eb4188a9 confirmed ondev; source3950 closure follows reconciliation. diff --git a/devlog/_fin/260908_c248_individual_fixes/040_reconcile.md b/devlog/_fin/260908_c248_individual_fixes/040_reconcile.md new file mode 100644 index 0000000000..753a2fa2ee --- /dev/null +++ b/devlog/_fin/260908_c248_individual_fixes/040_reconcile.md @@ -0,0 +1,3 @@ +# Phase reconcile: independent delivery and release readiness + +No product delta. MODIFY this unit's outcome record (050_outcome.md) with each original PR, actual delivery PR, reviewed head, CI run/check counts, landed SHA, author trailer and issue state. Query B for the JWT-only landing and independently verify it in dev together with C's timezone commit before closing #3950. Inspect A/B status and record readiness without doing their work or publishing a release. Move this unit from devlog/_plan to devlog/_fin only after all scoped tasks are complete; publish a separate docs-only closeout PR if needed, keeping it out of all three bug commits. Verify that closeout's scope check and diff preserve product files. No version change or promotion. Outcomes must distinguish true merged source PRs from closed carry sources. diff --git a/devlog/_fin/260908_c248_individual_fixes/050_outcome.md b/devlog/_fin/260908_c248_individual_fixes/050_outcome.md new file mode 100644 index 0000000000..aa085d5840 --- /dev/null +++ b/devlog/_fin/260908_c248_individual_fixes/050_outcome.md @@ -0,0 +1,35 @@ +# Lane C outcome + +All three scoped corrections landed into dev through independent bug PRs. The capture correction needed a separate follow-up after its author advanced the source PR during CI. No already-landed commit was rewritten. This record is documentation only and is not another product fix. + +| Source | Delivery PR | Landed SHA | Current-head CI | Scope | +|---|---|---|---|---| +| #3953 initial | [3955](https://github.com/lidge-jun/opencodex/pull/3955) | `9c54000c937276ba8d93ce63a922b3fe6797cbde` | [34166758020](https://github.com/lidge-jun/opencodex/actions/runs/34166758020) (3 success / 10 skipped) | Current-tree Accounts capture cleanup | +| #3953 follow-up | [3959](https://github.com/lidge-jun/opencodex/pull/3959) | `01c23aedcdfcb913151a2ac8f7acebda58d91eee` | [34167651789](https://github.com/lidge-jun/opencodex/actions/runs/34167651789) (3 success / 10 skipped) | Consistent capture retention and isolation guidance | +| #3899 / #3895 | [3960](https://github.com/lidge-jun/opencodex/pull/3960) | `9c8f66b9df4cdf133a16c95a95ee07ff5171a46d` | [34168481093](https://github.com/lidge-jun/opencodex/actions/runs/34168481093) (16 success / 3 skipped) | Release-note marker in both actual and standalone builders | +| #3950 timezone only | [3967](https://github.com/lidge-jun/opencodex/pull/3967) | `c46c22f3e4d00ff31a0e6bb10f74505577806776` | [34170093095](https://github.com/lidge-jun/opencodex/actions/runs/34170093095) (19 success / 2 skipped) | Santiago subprocess isolation and oracle integrity | + +## Proof and attribution + +Each landing was serialized through the shared merge lock and checked against the then-current destination: actual merge parent, computed combined tree, dev ancestry and surviving Co-authored-by trailer. luvs01 is credited in both capture carries and the timezone carry; Joonsuh Park is credited in the release-note correction. The original source PRs were closed as carried, not described as directly merged. + +#3953 was closed only after its refreshed ca21efd2 follow-up was included. #3899 and issue #3895 closed after the active release builder was corrected and verified. #3950 was closed only after B's independent JWT delivery #3962 (eb4188a9f2e127f5ee2980b62d6e5bb213c43c70) and C's timezone delivery #3967 were both confirmed on dev. Product commits remain independently revertible. + +The release-note original patch missed scripts/build-release-changelog.ts, the actual release workflow entry. Review led to a shared normalizer and five public-builder cases covering generated and associated PR sources and negative marker preservation. Those cases and the original renderer cases were observed passing in the final Linux CI logs; the final macOS lanes also passed. The structure guide now accurately distinguishes the active and standalone renderers. + +Timezone final candidate ce71d917143ddcbd5675b6ba92d8b1053971cd25 was separately exercised by evidence workflow7d5f1097ec587a0ced441f475eb02d750e06b9ac in [run34170111719](https://github.com/lidge-jun/opencodex/actions/runs/34170111719). The workflow checked out that immutable candidate separately. Linux, Windows and macOS each completed ten scenarios: five positive/restored runs and five deliberately failing controls. Controls require the intended test failure and specific diagnostics, not any nonzero exit. All platforms verified the final candidate file hash recorded below. Child timeout termination and restored candidate bytes/HEAD were verified. The evidence branch is not in any delivery PR and is never merged. + +## Limits and remaining work + +- All local product tests, test:changed, typechecks, builds and dependency installs were NOT RUN. Mutating Git operations disabled hooks per command, and pushes used --no-verify. Git/diff/source and operational evidence checks are distinct from product tests. +- Skipped jobs are not counted as passing tests. Normal PR workflows skip the full Windows runtime suite and macOS whole-pool control; the supplementary timezone run explicitly supplies Windows/macOS focused dashboard evidence, not a full runtime-suite result. +- Timezone normal CI34170093095 attempt1 timed out after20minutes in the unchanged root macOS client-connect test. The next helper contains an unbounded synchronous child wait, but the actual stopping mechanism is unproven. Attempt2 succeeded on the same candidate without a source change. The cancelled attempt remains unsuccessful evidence and the unrelated CI reliability defect is not claimed fixed. +- Privacy cleanup affects the current tree only. Historical blobs/links were not purged, and no claim of historical erasure is made. The working proxy was not restarted or reconfigured by this task. +- Concurrent dev changes were preserved through actual-tree comparison. That structural proof does not imply every merged integration tree was separately executed by the candidate CI. +- A and B were still active at reconciliation. B's JWT slice is verified; no assertion is made that their remaining changes or the overall2.48 release are complete. main/preview promotion, version changes and npm publication were outside C's authority and were not performed. + +## Final supplemental evidence + +- win32: Bun1.4.0, candidate file SHA-256 `6cbb58c96643f500cf2541ef3b7707aed072c1f981b16b49f97949536fe30f50`, ten scenarios, restored=True. +- linux: Bun1.4.0, candidate file SHA-256 `6cbb58c96643f500cf2541ef3b7707aed072c1f981b16b49f97949536fe30f50`, ten scenarios, restored=True. +- darwin: Bun1.4.0, candidate file SHA-256 `6cbb58c96643f500cf2541ef3b7707aed072c1f981b16b49f97949536fe30f50`, ten scenarios, restored=True. diff --git a/devlog/_fin/260908_c_track_config_init_stack/000_plan.md b/devlog/_fin/260908_c_track_config_init_stack/000_plan.md new file mode 100644 index 0000000000..046e2c627d --- /dev/null +++ b/devlog/_fin/260908_c_track_config_init_stack/000_plan.md @@ -0,0 +1,79 @@ +# 000_plan.md — C track: config file + init as a manual PR stack + +## Objective + +Land the C triage track (config-file and init surfaces) on `dev` as one manual, +dependency-ordered branch chain whose tip carries every layer. The track has two +existing contributor pull requests plus one gap discovered while planning: + +| Layer | Source | Surface | +|---|---|---| +| wp1 | PR #3900 by @x3M3x | `src/config/atomic-write.ts` Bun/Windows ENOENT | +| wp2 | new (this unit) | `src/config/initialize.ts` sibling numeric flag | +| wp3 | PR #3896 by @parkjs101 | `ocx init` publication recovery guidance (closes #3893) | + +wp2 exists on its own merit, not as glue. `publishInitialConfigNoReplace` still +opens its temp file with the numeric spelling that Bun miscompiles on Windows, +so shipping wp1 alone leaves first-run config publication exposed to the same +`ENOENT`. #3900 never touches `initialize.ts`; the file overlap is between wp2 +and wp3 only. + +## Constraints (owner-stated, this session) + +- **No local product suite.** No `bun run test`, `bun run typecheck`, + `bun run build`, or install. Every such check is recorded **NOT RUN**. +- **Push with `--no-verify`** on every layer. +- **CI on the tip only.** Verified mechanism in `040`: the lower layers are + pushed as branches but **no pull request is opened for them** until the tip + has landed. `.github/workflows/ci.yml` triggers on `pull_request: {}` with no + draft filter, so opening a lower PR would start CI; draft status suppresses + nothing. +- **Green tip merges; the rest resolve.** When the tip's exact head SHA is green + against a current `dev` base, merge the tip, then resolve the source PRs and + close issue #3893. +- **Original authors are preserved** with full `Co-authored-by: Name ` + trailers that survive the squash (AGENTS.md "Landing another author's work"). + +## Build order + +``` +codex/c-track-init-guidance → the ONLY pull request (base dev) ← wp3 tip +codex/c-track-initialize-flag → branch only, no PR ← wp2 +codex/c-track-atomic-write → branch only, no PR ← wp1 +──────────────────────────────── dev +``` + +Each branch is based on the one below, so the tip's tree is the cumulative +result. wp2 sits between the carried PRs because wp3 inserts a line directly +after the `openSync` call that wp2 rewrites; constructing wp2 first means that +adjacent-hunk overlap is resolved once while carrying wp3. This is a chosen +construction order for a single conflict resolution, not a semantic +prerequisite — either change could be written first. + +## Scope boundary + +IN: the three layers above, their regression tests, the docs/structure text that +#3896 already carries, and this devlog unit. + +OUT: `#3838`/`#3917` adapter work, any other triage track, release promotion, +`main`/`preview`, and any behavioral change to hard-link publication, ACL +hardening, or credential storage beyond the flag spelling. + +## Verifiers + +Local product gates are forbidden this session, so acceptance rests on +repository CI against the tip plus read-only inspection. + +| Claim | Evidence | Status | +|---|---|---| +| Layers carry original authorship | `git log --format='%(trailers:key=Co-authored-by)'` on the tip, then on the landed commit | to run (read-only) | +| wp2 removes the numeric spelling | `rg 'constants\.O_' src/config/initialize.ts` on the pushed tip tree | to run (read-only) | +| Carried content really landed | tip tree vs. each source PR's pinned patch, then landed-merge tree comparison (see `040`) | to run (read-only) | +| Layers build and pass | repository CI on the tip head SHA, base `dev` | tip only | +| Local suite / typecheck / build | — | **NOT RUN** (owner instruction) | + +## Terminal outcome + +DONE requires: tip CI success on its exact head SHA against a current `dev` +base, tip merged into `dev` proven by fetched ancestry and tree comparison, +source PRs resolved with credit intact, and #3893 closed. diff --git a/devlog/_fin/260908_c_track_config_init_stack/010_layer1_atomic_write.md b/devlog/_fin/260908_c_track_config_init_stack/010_layer1_atomic_write.md new file mode 100644 index 0000000000..d9e76ba2bb --- /dev/null +++ b/devlog/_fin/260908_c_track_config_init_stack/010_layer1_atomic_write.md @@ -0,0 +1,72 @@ +# 010_layer1_atomic_write.md — wp1: carry PR #3900 + +Source: [PR #3900](https://github.com/lidge-jun/opencodex/pull/3900) by @x3M3x, +head `744eb644028492784446fe9f0f73813d5d1fe59f`, two commits +(`52c749561`, `744eb6440`). + +Branch: `codex/c-track-atomic-write`, base `dev`. + +## Problem + +`src/config/atomic-write.ts` builds its exclusive-create flags numerically. +Bun on Windows misreads that combination and drops the creation bit, so every +private temp write fails with `ENOENT`: `ocx start`, management-API config +saves, and OAuth credential refreshes all route through these two writers. + +## Change (MODIFY, carried unmodified from #3900) + +`src/config/atomic-write.ts` + +```diff +- const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); ++ const descriptor = openSync(path, "wx", 0o600); +``` + +Applied in both `writePrivateTempFile` and `writePrivateTempFileAsync`; the now +unused `constants` import is dropped. + +`tests/windows/windows-secret-acl.test.ts` gains the source-oracle guard +"atomic secret temp writer portability", asserting exactly two portable calls. + +## Semantics note (audit correction) + +The PR description calls `"wx"` exactly `O_WRONLY | O_CREAT | O_EXCL`. Node and +Bun actually map it to `O_WRONLY | O_CREAT | O_EXCL | O_TRUNC`. It is +**behaviorally** equivalent here rather than bit-identical: exclusive creation +rejects an existing path, so `O_TRUNC` can never truncate one. `0o600` remains +a separate mode argument and still applies. Recorded so a later reader does not +inherit the imprecise claim. + +## Security review (independent, read-only) + +No blocking finding. Exclusivity is preserved for every caller of the default +wrappers, which include OAuth `auth.json`, Codex account credentials, service +API tokens, `config.json`, and `ocx.pid`. Ownership is marked only after a +successful create, so no new pre-existing-temp or symlink-following path opens. +Windows ACL ordering (create → own → harden → identity check → write → close) +is untouched. + +## Authorship + +Both commits are cherry-picked with `-x`, so each retains +`x3M3x ` as its git author and records the source SHA: + +| Carried commit | Source commit | +|---|---| +| `6a0abcf90` fix: use portable exclusive config temp creation | `52c7495618f18f2847b7f9468421442c1c573da1` | +| `24a078d80` test: guard atomic temp writes against Bun/Windows ENOENT | `744eb644028492784446fe9f0f73813d5d1fe59f` | + +A squash landing keeps only the squash message and drops per-commit authors, so +`b1a7f111c` adds the `Co-authored-by: x3M3x ` trailer to +the branch. That trailer must be carried into the tip PR squash message and +re-read on the landed commit. + +## Verification + +An independent read-only audit of the built branch confirmed the carried +source-and-test diff is byte-identical to #3900 pinned patch (2,176 bytes), +that both `-x` annotations and the original author survive, that the trailer +parses through `git interpret-trailers`, and that no other `src/` file changed. + +Repository CI on the stack tip only. Local suite, typecheck, and build: +**NOT RUN** (owner instruction). diff --git a/devlog/_fin/260908_c_track_config_init_stack/020_layer2_initialize_flag.md b/devlog/_fin/260908_c_track_config_init_stack/020_layer2_initialize_flag.md new file mode 100644 index 0000000000..b25a419481 --- /dev/null +++ b/devlog/_fin/260908_c_track_config_init_stack/020_layer2_initialize_flag.md @@ -0,0 +1,69 @@ +# 020_layer2_initialize_flag.md — wp2: sibling flag in initialize.ts + +New work in this unit. Branch `codex/c-track-initialize-flag`, base +`codex/c-track-atomic-write`. + +## Why this layer exists + +wp1 fixes the two writers in `atomic-write.ts`, but +`publishInitialConfigNoReplace` in `src/config/initialize.ts` still opens its +temp file with the same numeric spelling. Independent inspection confirms the +identical Bun/Windows exposure: first-run `ocx init` fails before writing or +publishing `config.json`, leaving `publication = "not-published"` and +`hardLinkUnavailable = false`, so the CLI prints "Initial config publication +did not finish." and exits 1. + +It also sits between the two carried PRs deliberately. #3896 inserts a line +immediately after this `openSync` call, so building wp2 first means the +adjacent-hunk overlap is resolved once, while carrying #3896 in wp3. This is a +chosen construction order rather than a semantic prerequisite: #3900 does not +touch this file at all, and either change could be written first. + +## Change (MODIFY) + +`src/config/initialize.ts` + +```diff + import { +- closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, ++ closeSync, fchmodSync, fstatSync, linkSync, lstatSync, + openSync, unlinkSync, writeFileSync, + } from "node:fs"; +@@ +- fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); ++ fd = openSync(temp, "wx", 0o600); +``` + +`constants` is referenced only by the import and that one expression, so the +import entry is removed with it. + +## Change (MODIFY, regression) + +`tests/windows/windows-secret-acl.test.ts` gains a sibling source-oracle test +next to the wp1 guard, asserting exactly one portable call in +`src/config/initialize.ts`. + +## Contract preserved + +The no-replace publication contract does not depend on the numeric spelling: +hardening, `verifyPrivateTemp`, the single `linkSync` publication with its +`EEXIST`/`collided` and `hardLinkUnavailable` handling, and the +descriptor-owned `removeOwnedTemp` cleanup are all unchanged. + +## Out of scope: the same pattern under `src/lab/` + +An independent scan found three more exclusive opens sharing this combination: +`src/lab/ledger/store.ts:153` and `:185` (recovery mutex, ledger lock) and +`src/lab/public/private-file.ts:209` (private publication temp). They deserve the +same portability follow-up, but Lab is an opt-in subsystem off the core request +path, so they stay out of this track rather than widening a config-surface fix. + +Two further matches are not exclusive opens and must not be swept in: +`src/codex/native-main-lock-file.ts:89` and `src/lab/fabric/scratch.ts:416`. The +read/write sites in `src/lab/artifacts/secure-fs.ts` need individual treatment because +`"wx"` would drop read access. + +## Verification + +Repository CI on the stack tip only. Local suite, typecheck, and build: +**NOT RUN** (owner instruction). diff --git a/devlog/_fin/260908_c_track_config_init_stack/030_layer3_init_guidance.md b/devlog/_fin/260908_c_track_config_init_stack/030_layer3_init_guidance.md new file mode 100644 index 0000000000..5f755c686d --- /dev/null +++ b/devlog/_fin/260908_c_track_config_init_stack/030_layer3_init_guidance.md @@ -0,0 +1,65 @@ +# 030_layer3_init_guidance.md — wp3: carry PR #3896 (stack tip) + +Source: [PR #3896](https://github.com/lidge-jun/opencodex/pull/3896) by +@parkjs101 (Joonsuh Park), head `fc78bc37d419576061b995281baf39c46655eaa9`, +closes [#3893](https://github.com/lidge-jun/opencodex/issues/3893). + +Branch: `codex/c-track-init-guidance`, based on `codex/c-track-initialize-flag`. +This is the stack tip and the only pull request opened for this unit. + +## Problem + +`ocx init` already separates denied hard-link publication from a generic +failure, but a required permission-hardening failure falls into the generic +message, and neither message tells the user what to do next. The user cannot +tell why publication stopped or where to move `OPENCODEX_HOME`. + +## Change (carried from #3896, rebased onto wp2) + +- `src/config/initialize.ts`: a `hardeningFailed` flag set immediately before + the hardening call and cleared immediately after it returns — the assignments + surround `hardenInitialConfig`, which runs after `openSync`. The flag selects + a distinct message; `InitialConfigPublicationError` takes the matching option, + and both messages gain `OPENCODEX_HOME` recovery direction. +- `docs-site/src/content/docs/getting-started/quickstart.md`: inspection before + retry, preserving existing configuration, choosing a supported location. +- `structure/02_config-and-codex-home.md`: records the diagnostic distinction. +- `tests/config/config-mutation-lock.test.ts`, `tests/service/init-eof.test.ts`: + permission, link, and cleanup faults, privacy-safe messages, backup + preservation. Both files already exist in the test-layout registries, so no + registry entry is added. +- `devlog/_plan/260907_init_publication_guidance/010_implementation.md`: carried + as-is; on terminal closure that unit moves to `_fin/`. + +The rebase keeps wp2's `openSync(temp, "wx", 0o600)` and both `hardeningFailed` +assignments around the hardening call. + +## Review (independent subagent audit, read-only, this session) + +Reviewed at `fc78bc37d`, all six files. No blocking finding: + +- The flag cannot be left incorrectly true. Write, verify, link, and close + failures all occur after it is cleared (`src/config/initialize.ts:95-115`, + `:129-134`). A throwing injected `io.harden` test seam would select the same + message, which is a seam edge rather than a production defect. +- File I/O ordering, the no-replace guarantee, and private permissions are + unchanged (`:38-43`, `:98-130`). +- The new messages are fixed text naming the `OPENCODEX_HOME` variable; they + interpolate no real paths, bytes, or filesystem error text, and raw errors + stay in the `cause` the CLI does not print (`src/cli/init.ts:262-267`). + +This is a static agent review, not the maintainer security review or the +approval required by `MAINTAINERS.md`; those are recorded separately in `040`. + +## Authorship + +Carried with `Co-authored-by: Joonsuh Park `, the identity +on the source commit. The trailer must appear in the **squash message** of the +landed commit, and is verified on the landed commit rather than only on the +branch. + +## Verification + +This tip is the only layer that triggers repository CI, and its exact head SHA +must be green against a current `dev` base. Local suite, typecheck, and build: +**NOT RUN** (owner instruction). diff --git a/devlog/_fin/260908_c_track_config_init_stack/040_layer4_landing.md b/devlog/_fin/260908_c_track_config_init_stack/040_layer4_landing.md new file mode 100644 index 0000000000..4413166db9 --- /dev/null +++ b/devlog/_fin/260908_c_track_config_init_stack/040_layer4_landing.md @@ -0,0 +1,62 @@ +# 040_layer4_landing.md — wp4: tip CI, merge, and closure + +## CI suppression: mechanism, not draft status + +`.github/workflows/ci.yml` triggers on `pull_request: {}` with **no draft +filter** (line 7), and the `changes` job gates expensive work on the PR's own +file list, which includes `src/**` and `tests/**`. Opening a lower-layer pull +request — draft or not — therefore starts repository CI. + +The mechanism that actually satisfies "CI on the tip only" is to **open no pull +request for wp1 and wp2**. Their branches are pushed so the tip has a real +parent chain, but only `codex/c-track-init-guidance` gets a PR, based directly +on `dev` so its diff is the cumulative stack. One workflow run, one subject. + +## Landing sequence + +1. Push all three branches with `--no-verify`. +2. Open the tip PR only, base `dev`, with the full repository template + (Summary, Verification, Checklist) and `Closes #3893`. Record the local + suite as NOT RUN with the owner instruction as the reason; the Verification + section must not imply a local green run. +3. Confirm the tip is based on the current `dev` head before CI. If `dev` has + advanced, rebase and cascade first — CI against a stale base does not certify + the integration tree that will actually merge. +4. Wait for CI on the tip's exact head SHA. Skipped or cancelled checks are not + passing evidence. +5. Record the merge decision. Both current maintainers hold `admin`, and + `MAINTAINERS.md` permits explicit maintainer integration into `dev` without a + second approval, provided the decision and exact-head CI evidence are + recorded and security review is kept separate. The credential-adjacent + `atomic-write.ts` carry is the security-review subject; its independent audit + is summarized in `010` and must be named in the merge record. +6. Merge the tip, pinning the reviewed head SHA. + +## Proving the carried work landed + +Ancestry alone is insufficient: `dev` can contain the merge while a conflict +resolution silently dropped a contributor hunk. Before closing anything: + +- Compare each source PR's pinned patch against the tip tree, documenting the + one intentional adaptation (wp2 rewrites the `openSync` line that #3896's + hunk sits next to). +- After the merge, compare the landed tree on fetched `dev` against the + reviewed tip tree. +- Re-read the landed commit's trailers to confirm both `Co-authored-by` entries + survived the squash. + +A squash landing does not make the original contributor SHAs ancestors, so +trailer and content comparison are the credit and delivery evidence. + +## Closure + +1. Close #3900 and #3896 as landed through the tip, naming the merge commit and + crediting @x3M3x and @parkjs101 with the evidence above. +2. Close issue #3893: PRs here target `dev`, and GitHub only auto-closes linked + issues on the default branch. + +## Failure handling + +If the tip's CI fails, fix the responsible layer and cascade the rebase upward +(`DEV-STACK-02`) before re-running CI on the new tip head. Do not open or merge +a lower layer independently to bypass a red tip. diff --git a/devlog/_fin/260908_c_track_config_init_stack/050_outcome.md b/devlog/_fin/260908_c_track_config_init_stack/050_outcome.md new file mode 100644 index 0000000000..05e19c97a1 --- /dev/null +++ b/devlog/_fin/260908_c_track_config_init_stack/050_outcome.md @@ -0,0 +1,82 @@ +# 050_outcome.md — terminal record + +## Result + +Landed on `dev` as `6188458ae3f4fd84ef57344b60cf3ceeed80aa6f` through +[#3941](https://github.com/lidge-jun/opencodex/pull/3941) on 2026-09-07. + +| Layer | Source | Disposition | +|---|---|---| +| wp1 | [#3900](https://github.com/lidge-jun/opencodex/pull/3900) by @x3M3x | carried, PR closed as superseded | +| wp2 | new here | landed | +| wp3 | [#3896](https://github.com/lidge-jun/opencodex/pull/3896) by @parkjs101 | carried, PR closed as superseded | +| — | [#3893](https://github.com/lidge-jun/opencodex/issues/3893) | closed | + +Both `Co-authored-by` trailers are on the landed squash commit. The 13 files in +the reviewed tip hash identical to their landed counterparts, `atomic-write.ts` +on `dev` hashes identical to #3900's pinned version, and `initialize.ts` equals +#3896's pinned file apart from wp2's two intended substitutions. + +## What the plan got right + +The tip-only CI mechanism worked exactly as designed. `ci.yml` triggers on +`pull_request` with no draft filter, so the first plan's assumption that draft +status suppresses CI was wrong; opening no pull request for the lower layers is +what actually produced one workflow subject. No Cross-platform CI run exists for +either lower branch. + +Ordering wp2 between the two carried PRs also paid off as predicted. The cherry-pick +of #3896 produced exactly one conflict — the adjacent hunk where `hardeningFailed = true` +sits directly after the rewritten `openSync` line — and it was resolved once. + +## What the plan got wrong, and what caught it + +The first roadmap draft failed its independent audit with seven findings. Three +mattered: the false draft-CI claim above, merging on stale-base CI evidence, and +proving delivery by ancestry alone when a squash can silently drop a contributor +hunk. It also misstated the file overlap as wp1/wp3 when #3900 never touches +`initialize.ts`. The revised plan passed re-audit, and the stale-base rule +earned itself back: `dev` moved twice during this delivery, so the tip was +rebased and re-certified rather than merged on its first green run. + +## The CI failure that was not ours + +The pre-rebase head failed one job, `test 4/4`, in +`prompt probe process lifecycle > the last cancellation drains the exact child`. +Investigation attributed it to that test's final parent-side PID poll expiring at +its 15-second internal deadline: every preceding assertion passed, the replacement +command had already observed the old child gone, and the same test passed on macOS +in the same run. `src/codex/prompt-text-probe.ts` imports `node:fs` only for +`existsSync`/`statSync` and never calls the changed writers. A same-head rerun +passed; the rebased head passed 25/25 on the first attempt. + +Worth recording honestly: the investigation could not name the exact mechanism. +The replacement considered the PID absent while the parent still considered it +alive, which PID reuse, runtime liveness behavior, or a real observation defect +could all explain. It is a flake by evidence of non-reproducibility, not by proof. + +## Follow-up left open + +Three exclusive opens under `src/lab/` share the numeric spelling this track +replaced: `ledger/store.ts` (two) and `public/private-file.ts`. Lab is opt-in and +off the core request path, so they stayed out rather than widening a config-surface +fix. The read/write sites in `artifacts/secure-fs.ts` need individual treatment +because `"wx"` would drop read access; they are not a mechanical substitution. + +## Verification boundary + +The local product suite, typecheck, and build were **NOT RUN** by owner +instruction. Acceptance rested on repository CI against the tip +([run 34153124187](https://github.com/lidge-jun/opencodex/actions/runs/34153124187): +19 jobs succeeded, 2 skipped, zero failures on the first attempt) plus independent +read-only audits at each layer. The two skips are the dispatch-only `macos control` +and Windows shard lanes, so Windows packaging and keyring smoke passed but the +Windows suite itself did not run. + +One limit worth stating plainly: the green PR run tested the tip against the base it +was rebased onto, and #3940 landed on `dev` between that run and the merge. The +C-track content is byte-identical either way, and the four files #3940 touched do not +overlap this change, but the combined tree is certified by the post-merge `dev` run +rather than by the PR run. That run has since completed: +[run 34153892496](https://github.com/lidge-jun/opencodex/actions/runs/34153892496) on +`6188458ae` succeeded, 19 jobs and 2 skips, so the landed combined tree is certified. diff --git a/devlog/_fin/260908_provider_runtime_stack/000_plan.md b/devlog/_fin/260908_provider_runtime_stack/000_plan.md new file mode 100644 index 0000000000..c8da992329 --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/000_plan.md @@ -0,0 +1,65 @@ +# 000 — Plan and live manifest + +Unit: `devlog/_plan/260908_provider_runtime_stack`. Session `01a080e2-1dfc-7082-bff8-5043215bdd35`. +Snapshot: 2026-09-08T12:00Z (fetch), `origin/dev` = `29bb221c3` +(`Merge pull request #4021 from lidge-jun/codex/release-248-record`). +Carry worktree: `/private/tmp/ocx-prs-stack-01a080e2` (linked worktree of the main checkout; +`core.worktree` unset, toplevel verified). + +## Objective + +Land the open provider-runtime contributor PRs on `dev` as one ordinary manual dependent PR +stack, integrated with the repository's provider discipline (test layout, provider marks, +docs-site sections, contributor attribution), and merge the stack bottom-up into `dev` +after a single green final-head CI run. Constraints given by the maintainer: + +- Never run the local product suite, typecheck, build, or install. Every one of those is + labelled NOT RUN in the delivery record. Hosted CI on the final head is the only proof. +- Every mutating Git command runs as `git -c core.hooksPath=/dev/null` (the repository + `postmerge` hook can otherwise install dependencies and typecheck). Push with + `--no-verify`. +- CI runs once, on the top of the stack. Merge only if that head is green. +- Ordinary dependent PR bases, no GitHub native stack registration (DEV-STACK-OPT-IN-01). +- Cherry-pick, reimplement, squash, or rebase are all permitted. Original authors stay + as commit authors (`cherry-pick -x`) or in a `Co-authored-by` trailer. +- Subagents: `anthropic/claude-opus-5` unlimited; Aside browser delegation unlimited. +- Out of scope: release/publish, `main`/`preview` promotion, unrelated subsystems. + +## Work-phase map (one PABCD cycle each) + +| WP | Scope | Doc | +|----|-------|-----| +| wp1 | Docs-only roadmap: this manifest, layer plan (010), conflict map (011), mark sourcing (012), secondary dispositions (013) | 000-013 | +| wp2 | Carry L1-L3 (CodeBuddy #3340, Qoder Global #3349, Qoder CN #3350) onto `dev` with layout registration | 020 | +| wp3 | L4 marks + display names + docs-site sections + attribution; accepted secondary layers | 030 | +| wp4 | Publish, final-head CI, bottom-up admin merge, ancestry proof, closeouts, delivery record | 040, 060 | + +## Manifest (exact head at snapshot) + +| PR | Author | Head | Base | Mergeable vs dev | +/- | Files | Commits | Draft | +|----|--------|------|------|------------------|-----|-------|---------|-------| +| #3340 | Flowershangfromthebranches | `4b705e92d` | dev | clean (merge-tree) | 2108/6 | 17 | 4 | yes | +| #3349 | Flowershangfromthebranches | `4ac98bd4d` | dev | CONFLICTING (`tests/providers/provider-connection-test.test.ts`, import-path only) | 2683/14 | 30 | 4 (3 shared with #3340) | yes | +| #3350 | Flowershangfromthebranches | `a4e805084` | dev | conflicts inherited from #3349 | 2834/16 | 30 | 5 (4 shared) | yes | +| #3010 | Liang-Psych | `2e3582328` | dev | CONFLICTING; OAuth/private-protocol design the maintainer review rejected | 1474/2 | 11 | 18 | yes | + +The three Flowershangfromthebranches PRs are already a contributor-declared chain +(#3340 → #3349 → #3350); #3349 and #3350 GitHub diffs include the lower layers because +each targets `dev`. The carry keeps that chain shape but rebases each layer onto its +parent so every PR diff is layer-only (DEV-STACK-03). + +## Maintainer review state carried into this unit + +The prior maintainer reviews (grok-bot, 2026-09-03) on all three PRs left these open items, +now dispositioned here: + +| Item | Disposition | +|------|-------------| +| AUP / terms acceptance for headless CLI proxy routing (CodeBuddy, Qoder) | Maintainer decided in this session by authorizing the landing. Recorded in 040. | +| Provider marks missing in `gui/src/provider-icons.ts` | wp3, per the Meta precedent `81a1fc1cc` (#3338): first-party SVG with source notes, or documented initials tile when terms forbid. See 012. | +| docs-site guide lacks a Qoder Global/CN section | wp3. CodeBuddy section already exists at `guides/providers.md:620`. | +| Shared `coding-agent/protocol.ts` error classification broadened in the Qoder commit | Kept in L2 where the contributor put it; audit (wp2 A-phase) checks CodeBuddy fixture coverage. | +| `qoder` promoted from free-directory reference id to runtime seed with `preserveCustomDestination` | Kept; parity test in the carried commits asserts the flag. | +| #3010 relationship | Superseded by #3350 once landed; close with credit to Liang-Psych. | +| Tests at `tests/` root | Blocker on current `dev`: layout guard. Fixed per layer in wp2. | +| Draft readiness checklist (contributor-side) | Not applicable; maintainer carries the PRs under admin authority. Originals close as superseded. | diff --git a/devlog/_fin/260908_provider_runtime_stack/010_layer_plan.md b/devlog/_fin/260908_provider_runtime_stack/010_layer_plan.md new file mode 100644 index 0000000000..7735d51f34 --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/010_layer_plan.md @@ -0,0 +1,34 @@ +# 010 — Layer plan + +Stack shape (merge bottom-up, ordinary dependent bases): + +| # | Branch | Base | Thesis | Source commits | +|---|--------|------|--------|----------------| +| 1 | `codex/prs-l1-codebuddy` | `dev` | CodeBuddy Global/CN headless CLI providers + shared `coding-agent` runtime | #3340: `7e56b6399`, `f651611f1`, `18530f8e8`, `4b705e92d` (cherry-pick -x) + layout fix commit | +| 2 | `codex/prs-l2-qoder-global` | L1 | Qoder Global PAT provider, account-scoped live model discovery | #3349: `4ac98bd4d` (cherry-pick -x, import-path conflict resolved) + layout fix commit | +| 3 | `codex/prs-l3-qoder-cn` | L2 | Qoder CN PAT profile | #3350: `a4e805084` (cherry-pick -x) | +| 4 | `codex/prs-l4-marks-docs` | L3 | Provider marks, display names, docs-site Qoder section, CREDITS | new maintainer commits | +| 5+ | `codex/prs-l5-*` | L4 | Secondary PRs accepted by 013 triage, one layer each | cherry-pick -x | + +Layer rules: + +- Each layer builds at its own tip. The layout-guard fix for a layer's tests lives in + that layer, not deferred upward. +- Original author preserved by `cherry-pick -x` (author field + `(cherry picked from + commit …)` line). Maintainer-authored repair commits carry no trailer because they + are not the contributor's work; the PR body names the source PR. +- PR bodies use the repository template and carry the stack map (DEV-STACK-03). +- Only the top layer's head gets CI. Lower PRs are opened for review navigation and + merge order; their own PR CI may run (`pull_request` trigger) but is not the gate. + +Verification plan (hosted only): + +1. Push all layers with `--no-verify`. +2. If the top PR's `pull_request` CI skips platform lanes, dispatch + `gh workflow run ci.yml -R lidge-jun/opencodex --ref -f lane=all`. +3. Record run id, every job conclusion; skipped/cancelled are not passing. +4. Merge bottom-up with `--admin`, retarget the next child to `dev` after each parent + lands, keep parent branches until no open child targets them. +5. After the top merge: `git fetch origin dev`; every merge SHA must satisfy + `git merge-base --is-ancestor origin/dev`; `git rev-parse origin/dev^{tree}` + must equal the certified head's tree (or a diff limited to merge-commit metadata). diff --git a/devlog/_fin/260908_provider_runtime_stack/011_conflict_map.md b/devlog/_fin/260908_provider_runtime_stack/011_conflict_map.md new file mode 100644 index 0000000000..9c69195360 --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/011_conflict_map.md @@ -0,0 +1,24 @@ +# 011 — Conflict map (measured) + +Method: `git merge-tree --write-tree origin/dev refs/pr/` and the actual cherry-picks in +the carry worktree. + +| Layer | Conflicting file | Nature | Resolution | +|-------|------------------|--------|------------| +| L1 | none | `tests/providers/provider-registry-parity.test.ts` auto-merged | — | +| L1 | `tests/codebuddy-adapter.test.ts`, `tests/codebuddy-protocol.test.ts` | Not a git conflict; layout guard (`tests/test-layout.test.ts`) rejects root test files since `260905_test_modularization_and_windows` | Move to `tests/providers/`, rewrite `../src` → `../../src`, `./helpers` → `../helpers`; register in `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. Commit `769e4208f`. | +| L2 | `tests/providers/provider-connection-test.test.ts` | Import block: dev moved the file into `tests/providers/`; the PR adds one `setFetchQoderModelsForTests` import against the old path | Keep dev's `../../src` paths, add the Qoder import at the same depth. | +| L2 | `tests/qoder-adapter.test.ts`, `tests/qoder-live-models.test.ts` | Layout guard, as L1 | Same move + registration. Commit `094cb93d0`. | +| L3 | `tests/providers/qoder-adapter.test.ts`, `tests/providers/qoder-live-models.test.ts` | The CN commit edits the same import lines the L2 layout commit moved | Take the CN import set (adds `QODER_CN_PROFILE`, `resolveQoderProfile`) at the new depth. | + +Auto-merged without conflict (git content merge, needs the wp2 audit to confirm semantics): +`README.md`, `docs-site/.../guides/providers.md`, `docs-site/.../reference/configuration/providers.md`, +`src/codex/catalog/provider-fetch.ts`, `src/providers/registry.ts`, +`src/server/management/provider-routes.ts`, `tests/adapters/adapter-*-conformance.test.ts`, +`tests/adapters/adapter-registry-authority.test.ts`, `tests/providers/provider-registry-parity.test.ts`. + +Known dev-side drift since the PR base (`81a1fc1cc`, 2026-09-03) that touches carried files: +provider namespace ownership (`bbea77a48`), Nous catalog limits (`5cd71ec91`), OrcaRouter +PKCE (`c41232aa5`), keychain restore ownership (`924b65799`), BigModel repairs. The wp2 audit +reads each of these against the carried edits in `provider-fetch.ts`, `model-cache.ts`, and +`registry.ts`. diff --git a/devlog/_fin/260908_provider_runtime_stack/012_mark_sourcing.md b/devlog/_fin/260908_provider_runtime_stack/012_mark_sourcing.md new file mode 100644 index 0000000000..519fca9072 --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/012_mark_sourcing.md @@ -0,0 +1,23 @@ +# 012 — Mark sourcing decision + +Research agent (claude-opus-5) verified on the public web, 2026-09-08. Assets held outside the +repo at `/tmp/ocx-marks/` until wp3 commits them. + +| id | Decision | File | Source | Terms basis | +|----|----------|------|--------|-------------| +| `qoder` | ship | `qoder.svg` | `https://qoder.com/favIcon.svg` (declared site icon; 73,379 B; viewBox `0 0 206 206`; byte-identical on `qoder.cn`, `qoder.com.cn`, and the schema.org Organization logo URL) | Qoder ToS (BRIGHT ZENITH, 2026-04-29) reserves rights generally, no mark-use prohibition; same posture as `meta.svg` | +| `qoder-cn` | ship, shared asset | `qoder.svg` | same file | CN agreement (通义云启(杭州)信息技术有限公司 + Alibaba Cloud, 2026-05-20) §五(a) reserves 商标 rights without restricting third-party use | +| `codebuddy` | initials tile, documented | none | mark exists (`…/web/ide/logo.svg`) | CodeBuddy service agreement §9.3 "Tencent Logo": no use of Tencent brand features "under any circumstances" without written consent | +| `codebuddy-cn` | initials tile, documented | none | same | same clause on `codebuddy.cn/document/term` | + +Wiring consequences: + +- `gui/tests/provider-icons.test.ts` derives the asset stem from `providerId.split("-")[0]`, + so committing `qoder.svg` fails the unwired-asset check for both `qoder` and `qoder-cn` + until each has its own alias row (the Meta commit pinned both ids for the same reason). +- Do not mask `qoder.svg`: light plate + dark glyph, both neutral inks, 94.5% opaque; a + mask collapses it into a filled box (README "plate problem"). +- Display names: `qoder` → "Qoder", `qoder-cn` → "Qoder CN", `codebuddy` → "CodeBuddy", + `codebuddy-cn` → "CodeBuddy CN". +- The CodeBuddy refusal goes into `gui/public/provider-icons/README.md` because no test + can detect an absent mark; without the note a later pass would re-fetch the logo. diff --git a/devlog/_fin/260908_provider_runtime_stack/013_secondary_dispositions.md b/devlog/_fin/260908_provider_runtime_stack/013_secondary_dispositions.md new file mode 100644 index 0000000000..07f7937551 --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/013_secondary_dispositions.md @@ -0,0 +1,23 @@ +# 013 — Secondary PR dispositions (bounded triage, read-only) + +Method: `gh pr view`, `git merge-tree --write-tree` against `origin/dev` and against the L3 +head `85ad0a29a`, blob reads. No bun command run. Triage agent: claude-opus-5. + +| PR | Author | Size | Conflicts vs dev / vs stack | C4 surface | Maintainer state | Layout | Verdict | +|---|---|---|---|---|---|---|---| +| #3990 Hermes source-preserving YAML | rrmlima | 5 files +106/−44 | none / none | no | approved ("머지하세요") | already mapped | INCLUDE → L5 | +| #3988 Gemini model-tail continue nudge | rrmlima | 2 files +51/−14 | none / none | no | approved after CI | already mapped | INCLUDE → L6 | +| #3833 Command Code native integration | rrmlima | 9 files +256/−4 | none / none | no | stale review mostly fixed | layout trap: `command-code-client.test.ts` seeds to `providers` (`layout.json:14`), explicit `clients` entry would trip the seed-mismatch check (`test-layout-tooling.test.ts:282`); needs rename or `pinnedOverrides` — design call | DEFER | +| #3952 openai-chat freeform + Moonshot Responses | yxr1995-maker | 9 files +467/−11 | none / none | no | "지금 형태로는 merge하지 마세요"; bundles three changes; `apply-patch-envelope.ts:51-59` fence stripping can truncate legit bodies; flips `moonshot` adapter default | DEFER (split required) | +| #3639 EntraID for Azure Foundry | chrisoro | 39 files +590/−62 | none / none | yes (new `@azure/identity` dep, new credential path) | hygiene-blocked, security review required | — | REJECT for this stack | +| #3283 Antigravity pool + Gemini 3.8 | vanch007 | 14 files +960/−53 | 2 / 2 (`responses/parser.ts`, `server/responses/core.ts`) | yes | "merge 비추천"; competes with #2562 | — | REJECT | +| #3282 Copilot context tier | Simon-Opopeee | 39 files +521/−14 | 8 / 8 | yes | provider guard missing, screenshot missing, hygiene-blocked | root test file | REJECT | +| #2230 Gemini OAuth accounts | ppvia | 33 files +1637/−61 | 16 / 16 | yes (embedded OAuth client secret) | maintainer-sponsored security review mandatory | unregistered tests | REJECT | + +#3990 and #3988 are pairwise clean with each other and with every other candidate +(`merge-tree` exit 0 for all combinations). Both are runtime-scope, no auth/credential/workflow +surface, and the maintainer already approved their content. They become L5 and L6 above the +marks layer, each cherry-picked with `-x` to keep rrmlima as author. + +DEFER/REJECT items are not closed by this unit; their disposition is recorded here for the +next triage pass. diff --git a/devlog/_fin/260908_provider_runtime_stack/020_wp2_carry.md b/devlog/_fin/260908_provider_runtime_stack/020_wp2_carry.md new file mode 100644 index 0000000000..32677407ec --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/020_wp2_carry.md @@ -0,0 +1,23 @@ +# 020 — wp2: carry L1–L3 with layout registration + +Status at write time: carried in the worktree, unpublished. Heads: L1 `769e4208f`, +L2 `094cb93d0`, L3 `85ad0a29a` (pre-audit-fix). + +## Audit round 1 (claude-opus-5, adversarial, read-only) — NEAR-PASS + +| # | Finding | Disposition | +|---|---------|-------------| +| 1 | Qoder catalog branch in `src/codex/catalog/provider-fetch.ts` (4 hint calls, ~1598–1628) omits `captured.effectiveAlias`, which `45045623b` (#3601) threaded through every sibling branch. Git auto-merged because lines do not overlap. | FOLD — maintainer fix commit on L2 appends the argument to all four calls. | +| 2 | `tests/adapters/adapter-tool-conformance.test.ts` exempts `codebuddy`/`qoder` with a bare `continue`; a future tool bridge would keep passing silently. | RESIDUAL — v1 contract is `--tools ""`, documented in registry notes and docs-site. A guard test cannot be validated locally under the no-local-suite rule; deferred to a follow-up that can run it. | +| 3 | `src/adapters/coding-agent/protocol.ts:198` matches bare `authentication`, so vendor text like "authentication service degraded" becomes a 401 `invalid_api_key`, which drives reauth messaging and key-pool rotation. | FOLD — anchor to credential verdicts (`authentication (?:failed|error|required)`, `unauthorized`). Existing fixture "Not logged in; invalid token" still classifies 401. | +| 4 | `qoder`/`qoder-cn` seed `noVisionModels` with the full roster, advertising image input the adapter rejects. | REBUT — this is the repository convention (`registry.ts:912`, parity test :388, CodeBuddy CN roster §二十九): membership routes images through the vision sidecar and the fail-closed strip applies to every such provider. The adapter's 400 is the defense when an image reaches it without the sidecar path. | + +Non-blocking notes carried: CodeBuddy Global roster has no `noVisionModels` (static, vendor +manifest); `docs/qoder-cli-provider.md` lives outside docs-site (kept, wp3 adds the published +section); `--effort` vs `--reasoning-effort` rests on vendor manifests. + +Clean under audit: registry contract shape, seed parity fields, `qoder` free-directory +promotion + `preserveCustomDestination`, `authorityIdentity` backward compatibility, +connection-test path ordering, layout-guard JSON (delta is exactly the four new keys), +privacy (PAT redaction, allowlisted child env, SHA-256 fingerprint), CI path (no docs-site +build or provider enumeration on `pull_request`). diff --git a/devlog/_fin/260908_provider_runtime_stack/030_wp3_marks_docs.md b/devlog/_fin/260908_provider_runtime_stack/030_wp3_marks_docs.md new file mode 100644 index 0000000000..0093ab15f1 --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/030_wp3_marks_docs.md @@ -0,0 +1,15 @@ +# 030 — wp3: L4 marks/docs/credits, L5–L6 secondary layers + +L4 `codex/prs-l4-marks-docs` (maintainer-authored): + +- `gui/public/provider-icons/qoder.svg` from `/tmp/ocx-marks/qoder.svg` (verbatim). +- `gui/src/provider-icons.ts`: aliases `qoder`/`qoder-cn` → `qoder.svg`; display names + Qoder, Qoder CN, CodeBuddy, CodeBuddy CN. No CodeBuddy asset (012). +- `gui/public/provider-icons/README.md`: Qoder provenance + CodeBuddy refusal note (012 text). +- `docs-site/src/content/docs/guides/providers.md`: "Official Qoder CLI (Global & CN)" + section after the CodeBuddy section; reference/configuration adapter list adds `qoder`. +- `CREDITS.md`: not needed — original commits keep the contributor as author. + +L5 `codex/prs-l5-hermes-yaml`: cherry-pick -x `a1fe9caeb` (#3990, rrmlima). +L6 `codex/prs-l6-gemini-tail`: cherry-pick -x `1837b8f99` (#3988; commit author is +`root`, so add `Co-authored-by: rrmlima` via the PR body/merge commit). diff --git a/devlog/_fin/260908_provider_runtime_stack/040_wp4_publish_merge.md b/devlog/_fin/260908_provider_runtime_stack/040_wp4_publish_merge.md new file mode 100644 index 0000000000..cf056aafcf --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/040_wp4_publish_merge.md @@ -0,0 +1,16 @@ +# 040 — wp4: publish, CI, merge, prove, close + +1. Push six branches `--no-verify` with `-c core.hooksPath=/dev/null`. +2. Open PRs bottom-up with explicit `--base` (L1→dev, L2→L1, …), template body + stack map. +3. Dispatch `ci.yml` `lane=all` on the L6 head; record run id and every job. +4. On green: merge L1 with `--admin --match-head-commit`, retarget L2 to `dev`, repeat. + Keep parent branches until no child targets them. +5. Fetch `dev`; assert each merge SHA is an ancestor; compare `dev^{tree}` to the certified + L6 tree. +6. Close #3340/#3349/#3350 superseded (credit Flowershangfromthebranches), #3990/#3988 + superseded (credit rrmlima), #3010 superseded by the landed Qoder CN PAT provider + (credit Liang-Psych). +7. Write 060 ledger; move unit to `_fin`. + +AUP decision: the maintainer authorized landing these headless-CLI PAT providers in this +session (2026-09-08); recorded here as the maintainer decision the prior reviews asked for. diff --git a/devlog/_fin/260908_provider_runtime_stack/050_delivery_record.md b/devlog/_fin/260908_provider_runtime_stack/050_delivery_record.md new file mode 100644 index 0000000000..8ada5b0bc6 --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/050_delivery_record.md @@ -0,0 +1,58 @@ +# 050 — Delivery record + +Snapshot: 2026-09-08T14:10Z. `origin/dev` = `e2bf1672c` (was `29bb221c3` at unit start). + +## What landed + +| Layer | PR | Merge SHA | Head SHA | Source | Author credit | +|-------|----|-----------|----------|--------|---------------| +| L1 CodeBuddy Global/CN | #4026 | `b77b05aa5` | `769e4208f` | #3340 (4 commits, cherry-pick -x) + layout move | Flowershangfromthebranches (author field + trailer) | +| L2 Qoder Global | #4027 | `753ecb813` | `5adf130da` | #3349 (cherry-pick -x) + layout move + audit fix | Flowershangfromthebranches | +| L3 Qoder CN | #4028 | `07ac34b2d` | `615c5c62c` | #3350 (cherry-pick -x) | Flowershangfromthebranches; Liang-Psych trailer for #3010 direction | +| L4 marks/docs | #4029 | `9f0721299` | `6ba1e6750` | maintainer | — | +| L5 Hermes YAML | #4030 | `5bb8faf7b` | `295bcf82b` | #3990 (cherry-pick -x) + fr/zh-TW sync | rrmlima | +| L6 Gemini tail | #4031 | `e2bf1672c` | `16d49ceab` | #3988 (cherry-pick -x) + single-owner fix | rrmlima (trailer; carried commit author is `root`) | + +## Proof + +- CI: `ci.yml` `lane=all` run **34231255231** on `16d49ceab`: 26/26 jobs success. `windows 4/6` + failed once on `tests/codex-integration/token-guardian.test.ts` afterEach `EPERM rm` of its + temp dir (a file the stack does not touch); same-SHA rerun of that job passed. Earlier run + 34228268757 on `ba3912ce8` was cancelled when the head moved and is diagnostic only. +- Ancestry: all six merge SHAs and all six head SHAs are ancestors of fetched `origin/dev`. +- Tree: `origin/dev^{tree}` = `2201b9e54…` = `16d49ceab^{tree}`. Landed tree equals certified head. +- Hygiene/enforce-target: green on every PR before merge after two repairs (trailers moved to + the body end where `pr-carry-attribution.cjs` reads them; L4 got pinned icon tests for + `missing_regression_test` and a before/after screenshot for the GUI gate). + +## NOT RUN (by maintainer instruction) + +`bun install`, `bun run typecheck`, `bun run test`, `bun run test:changed`, `bun run build:gui`, +`bun run privacy:scan`, `bun run lint:gui` — none executed locally. Every Git mutation ran with +`-c core.hooksPath=/dev/null`; pushes used `--no-verify`. Hosted CI is the only execution proof. + +## Audit dispositions + +Round 1 (L1–L3): blocker 1 `captured.effectiveAlias` folded (`5adf130da`); blocker 3 auth regex +folded (same commit); blocker 2 tool-less conformance exemption → residual, follow-up; blocker 4 +`noVisionModels` → rebutted (repository convention). Round 2 (L4–L6): double `(continue)` nudge +folded (`16d49ceab`); fr/zh-TW Hermes contradiction folded (`295bcf82b`); seven locale copies of +the adapter list still stop at `azure-openai` (predates this unit; residual). + +## Closeouts + +#3340 (auto-closed by merge; credit comment added), #3349, #3350, #3990, #3988 closed as +superseded with credit; #3010 closed as superseded by the PAT design with credit to Liang-Psych. + +## Secondary PR dispositions (not closed) + +DEFER #3833 (layout seed trap, design call), #3952 (split required). REJECT for this stack +#3639, #3283, #3282, #2230 (C4 surfaces, conflicts, or maintainer-required security review). +See 013. + +## Residuals for a follow-up + +1. Guard test proving `codebuddy`/`qoder` still expose no tool catalog (audit round 1, blocker 2). +2. Locale adapter tables (ko/ja/zh-cn/zh-tw/fr/ru/tr reference/configuration/providers.md). +3. `docs/qoder-cli-provider.md` lives outside docs-site; consider folding into the guide. +4. Windows shard flake: `token-guardian.test.ts` temp-dir `EPERM` on cleanup. diff --git a/devlog/_fin/260908_provider_runtime_stack/060_ledger.md b/devlog/_fin/260908_provider_runtime_stack/060_ledger.md new file mode 100644 index 0000000000..cc5dbd4977 --- /dev/null +++ b/devlog/_fin/260908_provider_runtime_stack/060_ledger.md @@ -0,0 +1,17 @@ +# 060 — Ledger + +| When (UTC) | Event | Evidence | +|-----------|-------|----------| +| 2026-09-08T12:04 | Goal created; goalplan wp1–wp4 registered | `.codexclaw/goalplans/land-the-open-opencodex-provider-runtime-contrib` | +| 2026-09-08T12:06 | Worktree `/private/tmp/ocx-prs-stack-01a080e2` on `origin/dev` `29bb221c3`; L1–L3 carried by `cherry-pick -x` | heads L1 `769e4208f`, L2 `094cb93d0`, L3 `85ad0a29a` | +| 2026-09-08T12:30 | wp1 roadmap docs 000–040 written; audit NEAR-PASS (020) | this unit | +| 2026-09-08T12:35 | wp2 audit fixes on L2 (`5adf130da`): effectiveAlias ×4, auth regex anchor; L3 cascaded | 020 | +| 2026-09-08T12:40 | wp3: L4 `76c8a0b0b` (qoder.svg, aliases, names, README, docs-site), L5 `a49d1ad92`+`48666541b` (#3990 + fr/zh-TW sync), L6 `7bd84795b`+`ba3912ce8` (#3988 + single-owner nudge) | 030, audit round 2 | +| 2026-09-08T12:48 | Pushed six branches `--no-verify`; PRs #4026 (L1→dev), #4027, #4028, #4029, #4030, #4031 (L6) with explicit dependent bases | GitHub | +| 2026-09-08T12:49 | `ci.yml` `lane=all` dispatched on `ba3912ce8`: run 34228268757 (+ PR run 34228261835) | Actions | +| 2026-09-08T13:02 | Hygiene gate: `missing_coauthor_credit` on every PR (trailers were inside the Summary, gate reads end of body) → trailers appended at body end; `missing_regression_test` on L4 → pinned Qoder/CodeBuddy icon tests added, L4 amended `6ba1e6750`, L5/L6 cascaded, force-with-lease pushed | GitHub | +| 2026-09-08T13:24 | New top head `16d49ceab`; `lane=all` dispatched: run 34231255231 (first run 34228268757 on `ba3912ce8` kept only as diagnostic) | Actions | +| 2026-09-08T13:55 | Run 34231255231 (`16d49ceab`, lane=all): 25/26 jobs success; `windows 4/6` failed on `tests/codex-integration/token-guardian.test.ts` afterEach `EPERM rm` of its temp dir (remove-tree retry exhausted). The stack touches no oauth/guardian/remove-tree file. Rerunning that job at the same SHA. | Actions | +| 2026-09-08T14:00 | Run 34231255231 green 26/26 after same-SHA rerun of windows 4/6 | Actions | +| 2026-09-08T14:07 | Bottom-up admin merges: #4026 `b77b05aa5`, #4027 `753ecb813`, #4028 `07ac34b2d`, #4029 `9f0721299`, #4030 `5bb8faf7b`, #4031 `e2bf1672c`; `origin/dev`=`e2bf1672c`; tree == `16d49ceab^{tree}` | 050 | +| 2026-09-08T14:09 | Originals closed with credit: #3349 #3350 #3010 #3990 #3988 (#3340 auto-closed, credit comment) | GitHub | diff --git a/devlog/_fin/260908_provider_runtime_stack/assets/031_l4_provider_marks.png b/devlog/_fin/260908_provider_runtime_stack/assets/031_l4_provider_marks.png new file mode 100644 index 0000000000..18f4797503 Binary files /dev/null and b/devlog/_fin/260908_provider_runtime_stack/assets/031_l4_provider_marks.png differ diff --git a/devlog/_fin/260908_release_248/000_plan.md b/devlog/_fin/260908_release_248/000_plan.md new file mode 100644 index 0000000000..221302a72d --- /dev/null +++ b/devlog/_fin/260908_release_248/000_plan.md @@ -0,0 +1,31 @@ +# Release 2.48.0 plan + +Owner-authorized HOTL release train for OpenCodex 2.48.0. The owner asked for a regression check of `dev` against `main`, two promotion pull requests, merges into `main` and `preview`, and npm publication. The owner also forbade running the local test suite and required `--no-verify` for any push, so every verification claim in this unit rests on hosted CI at an exact SHA. Local typecheck, local `bun run test`, and local privacy scan are NOT RUN by instruction and are labeled that way wherever they would otherwise appear as evidence. + +## Candidate + +Release candidate: `7797586a8899c673eab48886a490e85b480c6d72` (`origin/dev` tip, 2.48.0 in package.json). + +Published baseline: `@bitkyc08/opencodex` `latest=2.47.0`, `preview=2.47.0-preview.20260908`. `origin/main` is `f7f890ff7` at 2.47.0; `origin/preview` is `3bef20677` at 2.47.0-preview.20260908. `dev` is 70 commits ahead of each. + +The candidate tip itself has no Cross-platform CI run because its only delta against `9ad218a9bdd34ee33004c35706d78396bf02eef2` is under `devlog/`, which the workflow's push path filter excludes. `git diff --name-only 9ad218a9b 7797586a8 -- . ':(exclude)devlog'` returns zero files, so `9ad218a9b` is the runtime-identical CI witness for the candidate: 19 successful check-runs, two deliberately skipped (`macos control`, the Windows shard placeholder). That equivalence is stated explicitly rather than assumed, because the promotion merge SHAs will carry their own push-event CI regardless. + +## Scope + +In scope: version metadata on the two promotion branches, promotion PRs into `preview` and `main`, merges, `release.yml` dispatch for preview and stable, and registry/tag verification. Also in scope: a PABCD repair cycle merged into `dev` if regression evidence shows a defect, followed by a repeat of the release verification. + +Out of scope: unrelated open PRs and issues, dev-version bumping beyond what the release requires, installed-service upgrades, account settings, and any change to branch protection or CI gates. + +## Work phases + +- wp1 — this roadmap. Pin the candidate, record the CI-equivalence argument and the promotion procedure. No product change. +- wp2 — regression verification of the candidate against `main` using hosted evidence only. +- wp3 — promotion branches and PRs, merged with exact-head CI. +- wp4 — npm preview and stable publication with registry verification. + +## Verification and outcomes + +Each promotion SHA needs its own successful push-event Cross-platform CI and Service lifecycle before any publish dispatch. Publication proof is npm dist-tags, the published `gitHead`, tarball integrity, provenance, and the GitHub tag and release. `enforce-target` is expected to reject both promotion PRs because its allowed bases contain only `dev`; that is the established authorized promotion exception and is reported as failing, never as passing. + +DONE requires both channels published and verified with `dev` still ahead. BLOCKED is a concrete external prerequisite or a failed gate with no safe remedy. A failing gate is repaired or remains a blocker; it is never weakened, and no check is disabled to hide it. + diff --git a/devlog/_fin/260908_release_248/010_release.md b/devlog/_fin/260908_release_248/010_release.md new file mode 100644 index 0000000000..9e1875d65f --- /dev/null +++ b/devlog/_fin/260908_release_248/010_release.md @@ -0,0 +1,16 @@ +# Release operation + +1. Pin candidate `7797586a8899c673eab48886a490e85b480c6d72` and record its CI witness `9ad218a9bdd34ee33004c35706d78396bf02eef2` (runtime-identical; devlog-only delta). Confirm published baseline tags and that `v2.48.0` and `v2.48.0-preview.*` are unused. + +2. Regression review of `origin/main..origin/dev`: 70 commits, 162 changed files, 25 under `src/`. Read the delta for release-blocking risk in routing, auth, credentials, release automation, and workflows. Hosted CI on the witness SHA is the mechanical evidence; the local suite is NOT RUN by owner instruction. + +3. Create two independent promotion branches from `origin/preview` and `origin/main`, merge the frozen candidate into each, resolve only the channel version conflict, and set `package.json` to `2.48.0-preview.20260908` on the preview branch and `2.48.0` on the main branch. The runtime tree on each branch must equal the candidate exactly apart from that one version line; prove it with `git diff` restricted to non-version paths. + +4. Push both branches with `--no-verify` (owner instruction), open template-complete PRs, and wait for each merge SHA's own push-event Cross-platform CI and Service lifecycle. `enforce-target` will fail on both by design; record it as the authorized promotion exception. + +5. Dispatch `release.yml` with `expected-sha` equal to the branch tip: dry-run first, then preview, then stable, serialized. Verify `npm view @bitkyc08/opencodex dist-tags`, published `gitHead`, tarball SHA-512, provenance, and the GitHub tag and release. Run a published-package smoke in an isolated home. + +6. Record the outcome in `090_delivery.md`, confirm `dev` remains ahead of both channels, and leave unrelated dirty files in the primary checkout untouched. + +Activation scenarios: a moved branch means refuse the dispatch and repin; a failed CI job means inspect and repair rather than rerun blindly; a post-publish smoke failure means inspect registry metadata before any retry, and never republish blindly. Rollback artifact `v2.47.0` stays published; no destructive rollback is planned. + diff --git a/devlog/_fin/260908_release_248/020_progress.md b/devlog/_fin/260908_release_248/020_progress.md new file mode 100644 index 0000000000..64ac908fa9 --- /dev/null +++ b/devlog/_fin/260908_release_248/020_progress.md @@ -0,0 +1,14 @@ +# Progress + +## wp1 — roadmap (this cycle) + +Candidate pinned at `7797586a8899c673eab48886a490e85b480c6d72`. CI witness `9ad218a9bdd34ee33004c35706d78396bf02eef2`: 19 successful check-runs, 2 deliberate skips. The delta between them is devlog-only, so the witness covers the candidate's runtime tree exactly. + +Roadmap committed as `38edcbf7c` on `codex/release-248-plan` in the dedicated worktree `/private/tmp/ocx-release-248`. The primary checkout keeps its pre-existing unrelated dirty files untouched. + +Local suite, typecheck, and privacy scan: NOT RUN by owner instruction. + +## wp2 — regression verification (next) + +Delta to review: 70 commits, 162 files, 25 under `src/`, ~2096 changed source lines against `origin/main`. + diff --git a/devlog/_fin/260908_release_248/030_wp2_regression_review.md b/devlog/_fin/260908_release_248/030_wp2_regression_review.md new file mode 100644 index 0000000000..3e2e50712f --- /dev/null +++ b/devlog/_fin/260908_release_248/030_wp2_regression_review.md @@ -0,0 +1,39 @@ +# wp2 — regression review of the candidate against main + +Reviewed `origin/main..origin/dev`: 70 commits, 162 files, 25 under `src/`, about 2096 changed source lines. The question this phase answers is narrow — does anything in that delta regress behavior that `main` currently ships? The local suite is NOT RUN by owner instruction, so the mechanical evidence is hosted CI and the argument below is a source read. + +## What changed, by risk + +Credential and quota handling carries the most weight. `src/codex/routing.ts` factors the background recovery settle path into `settleCooldownRecoveryLease` and adds a manual-reset claim/settle pair. The refactor moves `cooldownSource === "reset-derived"` and the scope restriction into the shared settle helper, which reads at first glance like a new restriction on the pre-existing background path. It is not: `claimDueCodexQuotaRecoveryProbes` already filters candidates to `(scope === undefined || scope === "shared")` with `cooldownSource === "reset-derived"`, so no claim that could previously settle successfully can reach the helper and fail those conditions. `tests/codex-integration/codex-cooldown-recovery.test.ts` and `codex-reset-credit-auto-redeem.test.ts` cover both paths. + +`src/codex/auth-api.ts` adds a `dispatchSequence` fence around WHAM usage publication so a slow in-flight response cannot overwrite a newer published quota. The added early returns hand back the cached account info rather than publishing, which is a strict narrowing of when stale data wins. + +`src/server/responses/core.ts` adds combo session recall for compaction triggers and a completion callback gate. The recall path is guarded on a bare model name, an actual `compaction_trigger` input item, no configured selector, and no resolvable combo id, so a request that previously routed by explicit selector still does. The previous-response error code changed from `invalid_request_error` to `previous_response_not_found`; that is a deliberate behavior change so Codex reconnects with full input instead of terminating the task, and it is the fix's whole point. + +`src/router.ts` and `src/providers/default-aliases.ts` extend alias-ownership so a provider's own configured name also claims an alias, not just an explicit `alias` field. This makes an ambiguous alias resolve to nothing rather than to the wrong provider — a correctness fix with a narrow blast radius. + +`src/config/atomic-write.ts` replaces `constants.O_WRONLY | O_CREAT | O_EXCL` with the `"wx"` flag string, which is the same semantics expressed portably; that was the point of the change on Windows. + +## Coverage + +Forty test files changed alongside the 25 source files, and every source area above has a focused test in the same domain directory. No source change in the delta arrived without paired coverage. + +## Hosted evidence + +Push-event run on `9ad218a9bdd34ee33004c35706d78396bf02eef2` (runtime-identical to the candidate): 19 successful check-runs, 2 skipped by design. + +Dispatched full-lane run [34206043085](https://github.com/lidge-jun/opencodex/actions/runs/34206043085) on the exact candidate `7797586a8899c673eab48886a490e85b480c6d72` with `lane=all`, which adds the six Windows shards and the unsharded macOS control that the push event does not run. + +## Verdict + +No regression identified against `main`. The delta is corrective, each risky path narrows rather than widens behavior, and the one intentional behavior change (the previous-response error code) is the documented fix. + + +## Full-lane CI outcome + +Run [34206043085](https://github.com/lidge-jun/opencodex/actions/runs/34206043085) on the exact candidate `7797586a8899c673eab48886a490e85b480c6d72` completed **success** after one rerun of a single job. + +The first attempt failed on `windows 3/6`: `provider outbound GET transport > proxy mode reaches one real proxy across outbound, connection-test, and model-discovery paths` timed out at its own 15s bound, and the spawned fixture child was killed (exit 143). That test file is not in the release delta — `git log origin/main..origin/dev -- tests/providers/provider-outbound.test.ts` is empty — and the same content passed `windows 3/6` in dispatch run 34198186409 ninety minutes earlier. Rerunning the failed job passed. The evidence points at cold-runner timing on a 15s child-spawn budget, not at anything the candidate changed. + +That timeout is a real fragility worth tightening later, but it is not a 2.48.0 regression and does not block this promotion. + diff --git a/devlog/_fin/260908_release_248/040_wp3_promotion.md b/devlog/_fin/260908_release_248/040_wp3_promotion.md new file mode 100644 index 0000000000..5ba9997eaf --- /dev/null +++ b/devlog/_fin/260908_release_248/040_wp3_promotion.md @@ -0,0 +1,35 @@ +# wp3 — promotion pull requests and merges + +Both promotions are on their channels. + +| Channel | PR | Merge SHA | Version | +|---|---|---|---| +| `preview` | [#4010](https://github.com/lidge-jun/opencodex/pull/4010) | `c71474e83c92be1f39e9d8c1fe0743307ce93387` | 2.48.0-preview.20260908 | +| `main` | [#4011](https://github.com/lidge-jun/opencodex/pull/4011) | `9a27e86992d7a014e0aa92c046199b9fac148201` | 2.48.0 | + +Candidate `7797586a8899c673eab48886a490e85b480c6d72` is an ancestor of both branches, verified with `git merge-base --is-ancestor` against freshly fetched refs. `git diff 7797586a8 origin/main` is empty: the main tree is byte-identical to the candidate, since the candidate already carried 2.48.0. The preview tree differs only in the channel version line. + +## Gate outcomes + +`enforce-target` failed on both, as expected and as documented in the PR bodies. Its allowed bases contain only `dev` and its one coded exception is a stacked child, so a release promotion cannot pass it. Both PRs were opened as drafts by that gate and were marked ready before the authorized admin merge. The failure is recorded as a failure; no check, protection, or target was altered. + +Every other check passed on both heads. + +## Flakes encountered, and why they are flakes + +`macos 1/2` on #4011 hung twice inside `tests/clients/client-connect.test.ts` after "connect transaction and offline disconnect > an unavailable config coordinator refuses before issuing any hub key", producing "killed 1 dangling process" and then a 20-minute job timeout. The same tree passed `macos 1/2` in candidate run 34206043085 and on #4010, and passed on the third attempt. Combined with the earlier `windows 3/6` timeout, both failures were child-process lifecycle timing on cold runners, in files outside the release delta. + +## Post-merge gates + +Preview `c71474e83`: Cross-platform CI success, Service lifecycle success. + +Main `9a27e8699`: Service lifecycle success, React Doctor success, Docs deploy success; Cross-platform CI observed in progress at the time of writing and must be green before the stable publish. + + +## Merge verification commands + +``` +git merge-base --is-ancestor 7797586a8 origin/preview # YES +git merge-base --is-ancestor 7797586a8 origin/main # YES +git diff --stat 7797586a8 origin/main # empty +``` diff --git a/devlog/_fin/260908_release_248/050_wp4_publication.md b/devlog/_fin/260908_release_248/050_wp4_publication.md new file mode 100644 index 0000000000..672c8e0b45 --- /dev/null +++ b/devlog/_fin/260908_release_248/050_wp4_publication.md @@ -0,0 +1,35 @@ +# wp4 — publication + +Both channels are published and verified. + +| Channel | Version | Source SHA | npm gitHead | +|---|---|---|---| +| `preview` | 2.48.0-preview.20260908 | `c71474e83c92be1f39e9d8c1fe0743307ce93387` | matches | +| `latest` | 2.48.0 | `9a27e86992d7a014e0aa92c046199b9fac148201` | matches | + +`npm view @bitkyc08/opencodex dist-tags` reports `{"latest":"2.48.0","preview":"2.48.0-preview.20260908"}`. GitHub releases `v2.48.0` and `v2.48.0-preview.20260908` exist at exactly those commits. Both publishes carry a signed provenance statement from GitHub Actions. + +Tarball integrity was checked independently: downloading `bitkyc08-opencodex-2.48.0.tgz` from the registry and hashing it locally yields `sha512-f2GmrBpUJYZ+bOT62VL1MWhNwIBkFz5JUVGrNPG+SAaWJheshmMsnHDoMyRIpyd5v50sK9uI0Ll4XZwI4PVjhA==`, identical to the `dist.integrity` npm reports and to the `integrity:` line in the publish log. The unpacked package declares version 2.48.0 and its `bin/ocx.mjs` launcher runs and correctly reports the Bun runtime requirement in an isolated `OPENCODEX_HOME`. + +## The dev-version gate + +The first stable dispatch failed at "Require dev to be ready for this release": `origin/dev` still carried 2.48.0, which does not outrank the version being released. That gate exists so `tests/ci-workflows/release-version-line.test.ts` does not go red on `dev` and on every pull request against it the moment a release ships. + +The repair was [#4019](https://github.com/lidge-jun/opencodex/pull/4019), a one-line `package.json` change moving `dev` to 2.49.0, with the version decided by `scripts/bump-dev-version.ts` rather than chosen by hand. It merged as `0372c43e663b25387a0a00b03b6a9ca9d4bf9048` with full CI green, after which the stable dispatch succeeded on the unchanged `main` SHA. + +## Registry propagation + +Both publishes reported "Your package is being processed" and the workflow's bounded six-attempt registry smoke ended `verification=pending` in each case. Neither was republished. Preview appeared in the registry roughly twelve minutes after acceptance, stable roughly seven; both were then verified by direct registry reads and by the independent tarball hash above. + +## Final branch state + +`dev` 2.49.0, `main` 2.48.0, `preview` 2.48.0-preview.20260908. `dev` remains ahead of both release channels. + + +## Verification commands + +``` +npm view @bitkyc08/opencodex dist-tags --json +npm view @bitkyc08/opencodex@2.48.0 dist.integrity dist.tarball --json +gh release view v2.48.0 --json tagName,targetCommitish +``` diff --git a/devlog/_plan/260904_dashboard_minimal/000_inventory.md b/devlog/_plan/260904_dashboard_minimal/000_inventory.md index 2487c1afae..a1a1d0b094 100644 --- a/devlog/_plan/260904_dashboard_minimal/000_inventory.md +++ b/devlog/_plan/260904_dashboard_minimal/000_inventory.md @@ -1,31 +1,34 @@ # 000 — Dashboard inventory (as shipped, v2.42.0, dev @ 664d80c76) -Evidence: `assets/_1440.png` (full page, ko, 1440 px headless Chrome against the live -proxy), `assets/_text.txt` (visible text), `assets/_interactive.txt` (interactive -controls with refs, `agbrowse snapshot --interactive`). Storage was captured mid-scan (its skeleton -is the honest first paint on a 1.6 GB CODEX_HOME) and is inventoried from source. +This inventory used full-page Korean captures at 1440 px against the live proxy, +plus visible-text and interactive-control snapshots. Storage was captured mid-scan: +its skeleton records the first paint, and its full inventory came from source. + +The capture pack was removed from the current tree after review. The route, +control-count, and source inventory below retain the historical conclusions. +Git history is unchanged. Counts are from the captures: interactive = controls in the snapshot, words = visible text words. -| Route | Source | Interactive | Words | Screenshot | -|---|---|---|---|---| -| Sidebar + top bar | gui/src/App.tsx, components/sidebar-github-row.tsx, styles.css | 22 | — | every capture, left rail | -| #dashboard (overview) | pages/Dashboard.tsx, dashboard-overview-sections.tsx (669 L), dashboard-dialogs.tsx | 34 | 199 | dashboard_1440.png | -| #dashboard/providers | same | 18 | — | dashboard_providers_1440.png | -| #dashboard/models | same | 28 | — | dashboard_models_1440.png | -| #startup | pages/Startup.tsx (403 L), startup-sections.tsx | 22 | 167 | startup_1440.png | -| #providers | pages/Providers.tsx, components/provider-workspace/* | 27 | 310 | providers_1440.png | -| #models | pages/Models.tsx (2329 L) | 135 | 460 | models_1440.png | -| #models/combos | pages/Combos.tsx, components/combo-workspace-* | 59 | — | models_combos_1440.png | -| #models/routing | pages/RoutingProfiles.tsx (1139 L) | 28 | — | models_routing_1440.png | -| #models/compatibility | pages/CompatibilityMatrix.tsx | 27 | — | models_compatibility_1440.png | -| #subagents | pages/Subagents.tsx, components/subagents-workspace/* | 60 | 232 | subagents_1440.png | -| #logs | pages/Logs.tsx (1147 L) | 50 | 346 | logs_1440.png | -| #logs/debug | pages/Debug.tsx, debug-log-viewer.tsx | 24 | — | logs_debug_1440.png | -| #usage | pages/Usage.tsx (889 L) | 27 | 654 | usage_1440.png | -| #storage | pages/Storage.tsx (1469 L), components/storage-workspace/* | 16 (skeleton) | — | storage_1440.png | -| #codex-set | pages/codex-set-multiauth.tsx, codex-set-prompt.tsx, components/codex-set/*, CodexAccountPool.tsx | 51 | 361 | codex-set_1440.png | -| #integrations | pages/Integrations.tsx, ApiKeys.tsx, Claude*.tsx, Grok.tsx | 86 | 233 | integrations_1440.png | +| Route | Source | Interactive | Words | +|---|---|---|---| +| Sidebar + top bar | gui/src/App.tsx, components/sidebar-github-row.tsx, styles.css | 22 | — | +| #dashboard (overview) | pages/Dashboard.tsx, dashboard-overview-sections.tsx (669 L), dashboard-dialogs.tsx | 34 | 199 | +| #dashboard/providers | same | 18 | — | +| #dashboard/models | same | 28 | — | +| #startup | pages/Startup.tsx (403 L), startup-sections.tsx | 22 | 167 | +| #providers | pages/Providers.tsx, components/provider-workspace/* | 27 | 310 | +| #models | pages/Models.tsx (2329 L) | 135 | 460 | +| #models/combos | pages/Combos.tsx, components/combo-workspace-* | 59 | — | +| #models/routing | pages/RoutingProfiles.tsx (1139 L) | 28 | — | +| #models/compatibility | pages/CompatibilityMatrix.tsx | 27 | — | +| #subagents | pages/Subagents.tsx, components/subagents-workspace/* | 60 | 232 | +| #logs | pages/Logs.tsx (1147 L) | 50 | 346 | +| #logs/debug | pages/Debug.tsx, debug-log-viewer.tsx | 24 | — | +| #usage | pages/Usage.tsx (889 L) | 27 | 654 | +| #storage | pages/Storage.tsx (1469 L), components/storage-workspace/* | 16 (skeleton) | — | +| #codex-set | pages/codex-set-multiauth.tsx, codex-set-prompt.tsx, components/codex-set/*, CodexAccountPool.tsx | 51 | 361 | +| #integrations | pages/Integrations.tsx, ApiKeys.tsx, Claude*.tsx, Grok.tsx | 86 | 233 | ## Element-level notes from the captures (main agent's own pass) diff --git a/devlog/_plan/260904_dashboard_minimal/001_subagent_opinions.md b/devlog/_plan/260904_dashboard_minimal/001_subagent_opinions.md index d4ccdedcd9..a4d9723fa8 100644 --- a/devlog/_plan/260904_dashboard_minimal/001_subagent_opinions.md +++ b/devlog/_plan/260904_dashboard_minimal/001_subagent_opinions.md @@ -1,8 +1,10 @@ # 001 — Subagent opinions (independent, read-only, dev @ 664d80c76) -Three reviewers were dispatched in parallel with the same packet (evidence pack in `assets/`, -full source access, no edits, no suites, no proxy mutation). Model requested → model that -answered (as self-reported in the REVIEWER line): +Three reviewers were dispatched in parallel with the same temporary evidence packet and full +source access (no edits, no suites, no proxy mutation). The packet was subsequently removed from +the current tree. Filenames remain below only to preserve the reviewers' original reasoning. +Model requested → model +that answered (as self-reported in the REVIEWER line): | # | Requested | Answered as | Agent | Status | |---|---|---|---|---| diff --git a/devlog/_plan/260904_dashboard_minimal/assets/011_sidebar_footer_after.png b/devlog/_plan/260904_dashboard_minimal/assets/011_sidebar_footer_after.png deleted file mode 100644 index 2d6250ab69..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/011_sidebar_footer_after.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/011_star_in_update_dialog.png b/devlog/_plan/260904_dashboard_minimal/assets/011_star_in_update_dialog.png deleted file mode 100644 index a736e944ef..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/011_star_in_update_dialog.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/021_dashboard_after.png b/devlog/_plan/260904_dashboard_minimal/assets/021_dashboard_after.png deleted file mode 100644 index 1a4d8f5b3b..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/021_dashboard_after.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/021_startup_autostart_row.png b/devlog/_plan/260904_dashboard_minimal/assets/021_startup_autostart_row.png deleted file mode 100644 index 001fc819b8..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/021_startup_autostart_row.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/031_models_after.png b/devlog/_plan/260904_dashboard_minimal/assets/031_models_after.png deleted file mode 100644 index d721fb7ef2..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/031_models_after.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/031_models_disclosures_open.png b/devlog/_plan/260904_dashboard_minimal/assets/031_models_disclosures_open.png deleted file mode 100644 index 82bf0e2d7f..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/031_models_disclosures_open.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/041_integrations_after.png b/devlog/_plan/260904_dashboard_minimal/assets/041_integrations_after.png deleted file mode 100644 index 26ac0ee107..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/041_integrations_after.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/051_codex_set_after.png b/devlog/_plan/260904_dashboard_minimal/assets/051_codex_set_after.png deleted file mode 100644 index b165b72285..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/051_codex_set_after.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/051_codex_set_more_open.png b/devlog/_plan/260904_dashboard_minimal/assets/051_codex_set_more_open.png deleted file mode 100644 index 51f76c8367..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/051_codex_set_more_open.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/061_usage_after.png b/devlog/_plan/260904_dashboard_minimal/assets/061_usage_after.png deleted file mode 100644 index dc2baee259..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/061_usage_after.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/071_startup_after.png b/devlog/_plan/260904_dashboard_minimal/assets/071_startup_after.png deleted file mode 100644 index e041ce91f0..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/071_startup_after.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/081_subagents_after.png b/devlog/_plan/260904_dashboard_minimal/assets/081_subagents_after.png deleted file mode 100644 index 1b4be7e3c4..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/081_subagents_after.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/codex-set_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/codex-set_1440.png deleted file mode 100644 index ebdd52d21e..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/codex-set_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/codex-set_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/codex-set_interactive.txt deleted file mode 100644 index 23e978afef..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/codex-set_interactive.txt +++ /dev/null @@ -1,51 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e49 tab "다중 인증" -e50 tab "프롬프트" -e55 button "Codex Spark 할당량" -e56 button "한도 도달 계정 일시 중지" -e59 button "할당량 새로고침" -e66 button "리셋 크레딧 1개" -e70 button "이 계정을 다음에 사용" -e71 button "일시 중지" -e76 combobox "이 계정의 선택 순서" -e80 button "추가" -e85 button "리셋 크레딧 1개" -e88 button "이 계정을 다음에 사용" -e89 button "일시 중지" -e92 button "별칭 편집" -e93 button "삭제 — " -e96 combobox "이 계정의 선택 순서" -e102 button "리셋 크레딧 1개" -e105 button "이 계정을 다음에 사용" -e106 button "일시 중지" -e109 button "별칭 편집" -e110 button "삭제 — " -e113 combobox "이 계정의 선택 순서" -e119 button "리셋 크레딧 0개" -e123 button "이 계정을 다음에 사용" -e124 button "일시 중지" -e127 button "별칭 편집" -e128 button "삭제 — " -e131 combobox "이 계정의 선택 순서" -e137 button "리셋 크레딧 1개" -e140 button "이 계정을 다음에 사용" -e141 button "일시 중지" -e144 button "별칭 편집" -e145 button "삭제 — " -e148 combobox "이 계정의 선택 순서" -e152 combobox "로테이션 전략" -e155 button "고급 설정" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/codex-set_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/codex-set_text.txt deleted file mode 100644 index 92e164e55c..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/codex-set_text.txt +++ /dev/null @@ -1,115 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -다중 인증 -프롬프트 -Codex 인증 -Codex Spark 할당량 -한도 도달 계정 일시 중지 -할당량 새로고침 -OpenAI 계정 모드 -풀 모드 - -메인 로그인과 사용 가능한 추가 계정이 여기에서 순환됩니다. - -메인 계정 -1 -현재 -이 계정을 다음에 사용 -일시 중지 -앱 로그인 - · pro -선택 순서 -기본 (0) -숫자가 클수록 먼저 사용됩니다. 위에 있는 계정이 모두 소진되거나 사용할 수 없을 때에만 더 낮은 숫자로 넘어갑니다. -주간 -리셋 -9월 7일 -11:46 -26% -계정 풀 -추가 - -pro -1 -이 계정을 다음에 사용 -일시 중지 -별칭 편집 - · pro · ID: account- -선택 순서 -기본 (0) -숫자가 클수록 먼저 사용됩니다. 위에 있는 계정이 모두 소진되거나 사용할 수 없을 때에만 더 낮은 숫자로 넘어갑니다. -주간 -리셋 -9월 7일 -11:28 -26% - -team -1 -이 계정을 다음에 사용 -일시 중지 -별칭 편집 - · team · ID: account- -선택 순서 -기본 (0) -숫자가 클수록 먼저 사용됩니다. 위에 있는 계정이 모두 소진되거나 사용할 수 없을 때에만 더 낮은 숫자로 넘어갑니다. -5시간 -리셋 -오늘 -06:37 -0% -주간 -리셋 -9월 8일 -01:13 -6% - -go -0 -선택됨 -이 계정을 다음에 사용 -일시 중지 -별칭 편집 - · go · ID: account- -선택 순서 -기본 (0) -숫자가 클수록 먼저 사용됩니다. 위에 있는 계정이 모두 소진되거나 사용할 수 없을 때에만 더 낮은 숫자로 넘어갑니다. -30일 -리셋 -10월 1일 -08:49 -0% - -pro -1 -이 계정을 다음에 사용 -일시 중지 -별칭 편집 - · pro · ID: account- -선택 순서 -기본 (0) -숫자가 클수록 먼저 사용됩니다. 위에 있는 계정이 모두 소진되거나 사용할 수 없을 때에만 더 낮은 숫자로 넘어갑니다. -주간 -리셋 -9월 7일 -11:37 -77% -로테이션 전략 -OpenCodex가 새 작업/바인딩 없는 작업에 계정을 배정하는 방식입니다. -할당량 전략은 사용량 임계값을 넘으면 기존 작업의 다음 요청도 다른 계정에 다시 바인딩할 수 있습니다. -새 작업/바인딩 없는 작업은 현재 계정 바인딩이 없는 요청입니다. 기존에 보이던 작업도 프록시나 어피니티 상태가 초기화되면 바인딩이 없어질 수 있습니다. -할당량 -고급 설정 diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_1440.png deleted file mode 100644 index 8810d82d43..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_interactive.txt deleted file mode 100644 index 065cfbd95a..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_interactive.txt +++ /dev/null @@ -1,37 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e51 tab "개요" -e52 tab "활성 프로바이더" -e53 tab "사용 가능한 모델" -e56 button "서브에이전트" -e58 radio "v1" -e59 radio "base" -e60 radio "v2" -e62 button "재부팅 후에도 opencodex가 자동으로 준비됩니다" -e64 combobox "서브에이전트 위임" -e67 button "설정 열기" -e69 button "지금 동기화" -e72 button "업데이트 확인" -e74 button "Codex 실행 시 opencodex 시작" -e76 combobox "모델" -e80 button "응답 실시간 스트리밍" -e82 combobox "모델" -e85 combobox "비전 사이드카 — 추론 강도" -e88 button "고급 설정" -e90 button "쉐도우 호출 가로채기" -e92 button "쉐도우 호출 가로채기" -e93 combobox "대체 모델" -e97 button "작업 완료 후 재시작" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_1440.png deleted file mode 100644 index 85c0a995fe..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_interactive.txt deleted file mode 100644 index f3cfb8af75..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_interactive.txt +++ /dev/null @@ -1,28 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e51 tab "개요" -e52 tab "활성 프로바이더" -e53 tab "사용 가능한 모델" -e56 searchbox "모델 검색…" -e57 button "Anthropic Claude 13" -e58 button "Cursor 40" -e59 button "Google Antigravity 7" -e60 button "Kimi 9" -e61 button "Lidge 1" -e62 button "Muse Code 2" -e63 button "OpenAI (Codex login) 7" -e64 button "OpenCode Free 9" -e65 button "xAI Grok 8" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_text.txt deleted file mode 100644 index 9ce296daab..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_models_text.txt +++ /dev/null @@ -1,42 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -대시보드 - -로컬 opencodex 프록시와 프로바이더, 그리고 Codex로 라우팅되는 모델의 실시간 상태입니다. - -개요 -활성 프로바이더 -사용 가능한 모델 -사용 가능한 모델 -96 -Anthropic Claude -13 -Cursor -40 -Google Antigravity -7 -Kimi -9 -Lidge -1 -Muse Code -2 -OpenAI (Codex login) -7 -OpenCode Free -9 -xAI Grok -8 diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_1440.png deleted file mode 100644 index 2f558b4bd0..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_interactive.txt deleted file mode 100644 index dce46c0e44..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_interactive.txt +++ /dev/null @@ -1,18 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e51 tab "개요" -e52 tab "활성 프로바이더" -e53 tab "사용 가능한 모델" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_text.txt deleted file mode 100644 index dfdcd5942b..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_providers_text.txt +++ /dev/null @@ -1,34 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -대시보드 - -로컬 opencodex 프록시와 프로바이더, 그리고 Codex로 라우팅되는 모델의 실시간 상태입니다. - -개요 -활성 프로바이더 -사용 가능한 모델 -활성 프로바이더 -9 -이름 어댑터 Base URL 모델 -OpenAI (Codex login) openai-responses https://chatgpt.com/backend-api/codex — -Anthropic Claude anthropic https://api.anthropic.com claude-sonnet-5 -xAI Grok openai-chat https://api.x.ai/v1 grok-4.6 -Cursor cursor https://api2.cursor.sh auto -Google Antigravity google https://daily-cloudcode-pa.googleapis.com gemini-3.7-flash -OpenCode Free openai-chat https://opencode.ai/zen/v1 — -Lidge openai-chat http://100.100.125.116:8081/v1 qwen3.8-27b-nvfp4 -Muse Code openai-responses https://api.meta.ai/v1 muse-spark-1.3 -Kimi openai-chat https://api.kimi.com/coding/v1 kimi-k2.7-code diff --git a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/dashboard_text.txt deleted file mode 100644 index 98d59d4004..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/dashboard_text.txt +++ /dev/null @@ -1,76 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -대시보드 - -로컬 opencodex 프록시와 프로바이더, 그리고 Codex로 라우팅되는 모델의 실시간 상태입니다. - -개요 -활성 프로바이더 -사용 가능한 모델 -서브에이전트 -v1 -base -v2 -상태 -온라인 -버전 -2.42.0 -가동 시간 -1시간 42분 -프로바이더 -9 -토큰 (30일) -515.1억 -커버리지 99% -재부팅 후에도 opencodex가 자동으로 준비됩니다 -서브에이전트 위임 -없음 -설정 열기 -모델 동기화 -연결해둔 프로바이더를 기준으로 Codex 모델 카탈로그를 다시 씁니다. -지금 동기화 -Codex 실행 시 opencodex 시작 -설치된 launcher shim이 ocx ensure를 실행하도록 허용합니다. 이 설정은 재부팅 보호를 설치하지 않으므로 시작 안전성에서 실제 상태를 확인하세요. -웹 검색 사이드카 -라우팅 모델의 웹 검색에 쓸 백엔드와 모델을 고릅니다. -gpt-5.6-luna -응답 실시간 스트리밍 -비전 사이드카 -텍스트 전용 라우팅 모델이 이미지를 읽을 때 쓸 백엔드와 모델을 고릅니다. -gpt-5.6-luna -low -고급 설정 -쉐도우 호출 가로채기 -⚠ 5.6-luna -— -메모리 관찰 -진행 중 요청 -3 -작업 완료 후 재시작 -경고 임계값 대비 -rss -1.1 GiB / 4.0 GiB -임계값의 28% -상주 메모리 (RSS) -1.1 GiB -JS 힙 사용량 -213.8 MiB -아레나 74.8 MiB -JSC 힙 -213.8 MiB -시간당 관측 변화 -+98.5 MiB/시간 -상세 정보 diff --git a/devlog/_plan/260904_dashboard_minimal/assets/integrations_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/integrations_1440.png deleted file mode 100644 index 3e04aece42..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/integrations_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/integrations_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/integrations_interactive.txt deleted file mode 100644 index cd24707d99..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/integrations_interactive.txt +++ /dev/null @@ -1,86 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e51 tab "개요" -e52 tab "API 키" -e53 tab "Codex" -e54 tab "Claude" -e55 tab "Grok Build" -e56 tab "Cursor" -e57 tab "OpenCode" -e58 tab "Pi" -e59 tab "OMP" -e60 tab "Hermes" -e61 tab "OpenClaw" -e62 tab "Kimi Code" -e63 tab "Gajae Code" -e64 tab "DSH" -e65 tab "MiniMax Code" -e66 tab "ZCode" -e67 tab "Prime Agent" -e68 tab "Aside" -e78 button "모두 해제…" -e82 button "키 관리" -e87 button "Codex" -e90 button "해제" -e91 button "설정" -e94 button "Claude" -e97 button "해제" -e98 button "설정" -e101 button "Claude Desktop" -e104 button "해제" -e105 button "설정" -e108 button "Grok Build" -e111 button "해제" -e112 button "설정" -e115 button "Cursor" -e118 button "설정" -e121 button "OpenCode" -e124 button "적용" -e125 button "설정" -e128 button "Pi" -e131 button "적용" -e132 button "설정" -e135 button "OMP" -e138 button "적용" -e139 button "설정" -e142 button "Hermes" -e145 button "적용" -e146 button "설정" -e149 button "OpenClaw" -e152 button "적용" -e153 button "설정" -e156 button "Kimi Code" -e159 button "적용" -e160 button "설정" -e163 button "Gajae Code" -e166 button "적용" -e167 button "설정" -e170 button "DSH" -e173 button "적용" -e174 button "설정" -e177 button "MiniMax Code" -e180 button "적용" -e181 button "설정" -e184 button "ZCode" -e187 button "해제" -e188 button "설정" -e191 button "Prime Agent" -e194 button "적용" -e195 button "설정" -e198 button "Aside" -e201 button "해제" -e202 button "설정" -e207 button "되돌리기" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/integrations_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/integrations_text.txt deleted file mode 100644 index b46f9dde21..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/integrations_text.txt +++ /dev/null @@ -1,163 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -연동 - -클라이언트를 opencodex에 연결하고 자격 증명과 설정 복원을 관리합니다. - -개요 -API 키 -Codex -Claude -Grok Build -Cursor -OpenCode -Pi -OMP -Hermes -OpenClaw -Kimi Code -Gajae Code -DSH -MiniMax Code -ZCode -Prime Agent -Aside -감지된 클라이언트 -10 -설정된 클라이언트 -6 -업데이트 필요 -2 -마지막 변경 -9/3/2026, 7:09:00 PM -모두 해제… -클라이언트 -API 키 - -발급된 키 없음 - -키 관리 - -적용하면 먼저 백업을 보관한 뒤 opencodex 제공자 블록 하나만 씁니다. 해제는 그 블록만 제거하며 보관된 스냅샷으로 복원할 수 있습니다. - -Codex -적용됨 - -Codex 요청이 이 프록시를 지납니다 - -설정 -Claude -적용됨 - -자동 (Claude 인증 감지) - -설정 -Claude Desktop -적용됨 - -Desktop이 이 프로필로 실행됩니다 - -설정 -Grok Build -적용됨 - -모델 17개 연결됨 - -설정 -Cursor -미적용 - -Private Inference 설치됨, 아직 요청 없음 - -설정 -OpenCode -미적용 - -/Users/jun/.config/opencode/opencode.json - -설정 -Pi -미적용 - -/Users/jun/.pi/agent/models.json - -설정 -OMP -미설치 - -/Users/jun/.omp/agent/models.yml - -설정 -Hermes -미설치 - -/Users/jun/.hermes/config.yaml - -설정 -OpenClaw -미설치 - -/Users/jun/.openclaw/openclaw.json - -설정 -Kimi Code -미설치 - -/Users/jun/.kimi-code/config.toml - -설정 -Gajae Code -미적용 - -/Users/jun/.gjc/agent/models.yml - -설정 -DSH -미설치 - -/Users/jun/.dsh/settings.yaml - -설정 -MiniMax Code -미설치 - -/Users/jun/.minimax/config.yaml - -설정 -ZCode -업데이트 필요 - -/Users/jun/.zcode/v2/config.json - -설정 -Prime Agent -미설치 - -/Users/jun/.prime/agent/models.json - -설정 -Aside -업데이트 필요 - -/Users/jun/.aside/u/0/models.json - -설정 -복원 센터 -적용 -aside -9/3/2026, 7:09:00 PM -되돌리기 -이전 작업 diff --git a/devlog/_plan/260904_dashboard_minimal/assets/logs_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/logs_1440.png deleted file mode 100644 index 1aa4004332..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/logs_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_1440.png deleted file mode 100644 index a8594e0974..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_interactive.txt deleted file mode 100644 index bfe5ae5086..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_interactive.txt +++ /dev/null @@ -1,24 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e50 tab "로그" -e51 tab "디버그" -e53 button "새로고침" -e56 checkbox "Follow" -e59 button "Provider debug" -e61 button "Usage 추출" -e63 button "주입 로그" -e65 button "Claude 인바운드" -e67 button "런타임 재정의 해제" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_text.txt deleted file mode 100644 index cd34f19587..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/logs_debug_text.txt +++ /dev/null @@ -1,30 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -로그&디버그 -로그 -디버그 -새로고침 -Follow - -선택적 provider transport 및 usage 추출 진단. 요청 오류와 502는 로그 탭에 표시됩니다. - -Provider debug -Usage 추출 -주입 로그 -Claude 인바운드 -런타임 재정의 해제 -디버그 로깅 꺼짐 -위 카드에서 Provider debug 또는 Usage extraction을 켜세요. 프록시로 요청을 보낸 뒤 라인이 표시됩니다. diff --git a/devlog/_plan/260904_dashboard_minimal/assets/logs_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/logs_interactive.txt deleted file mode 100644 index 9ad8f3c7ce..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/logs_interactive.txt +++ /dev/null @@ -1,25 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e49 checkbox "자동 새로고침" -e52 tab "로그" -e53 tab "디버그" -e58 radio "전체" -e59 radio "Claude" -e60 radio "Codex" -e61 radio "Grok" -e62 checkbox "가로챈 헬퍼만" -e64 searchbox "대화" -e66 searchbox "모델" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/logs_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/logs_text.txt deleted file mode 100644 index 6e4f4aa182..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/logs_text.txt +++ /dev/null @@ -1,425 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -로그&디버그 -자동 새로고침 -로그 -디버그 - -로컬 opencodex 프록시를 거친 최근 요청입니다. 최신순. - -표면 -전체 -Claude -Codex -Grok -가로챈 헬퍼만 -대화 -모델 -시간 토큰 tok/s ~$ 모델 추론 강도 프로바이더 상태 요청 소요 시간 - -2026. 9. 4. -오전 1:44:55 - -37.6만 -c 37.5만 - 23.9 약 US$0.1233 -k3[1m] - -high -reasoning_effort=high - Kimi -200 -상세보기 - -ocx-6911ee1ea81fafd776faea80362db066 - 22500ms - -2026. 9. 4. -오전 1:45:07 - -30.2만 -c 30.1만 -w 626 - 83.2 약 US$0.1118 -claude-fable-5-1 - -high - Anthropic Claude -200 -상세보기 - -ocx-30f494d499b7262c7a380ca30e4fbd26 - 6900ms - -2026. 9. 4. -오전 1:45:06 - -9.3만 -c 8.1만 - 36.3 약 US$0.1044 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-18a158710a383bc7e1c0ffd863af57e0 - 6333ms - -2026. 9. 4. -오전 1:44:58 - -8.2만 -c 7.7만 - 37.7 약 US$0.0682 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-580a3282939be78166a8f838cdc2adef - 7566ms - -2026. 9. 4. -오전 1:44:53 - -7.7만 -c 7.4만 - 34.1 약 US$0.0582 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-b90ea8f6328ee50214e2564cd8ca88cf - 5429ms - -2026. 9. 4. -오전 1:44:21 - -37.6만 -c 37.3만 - 24.5 약 US$0.1303 -k3[1m] - -high -reasoning_effort=high - Kimi -200 -상세보기 - -ocx-ad5123ab098f605baff623a6384d07f0 - 32904ms - -2026. 9. 4. -오전 1:44:47 - -7.5만 -c 7.3만 - 32.8 약 US$0.0472 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-d068d7a7725f801d192bd09cc541392e - 5698ms - -2026. 9. 4. -오전 1:44:37 - -30.2만 -c 28.3만 -w 1.8만 - 63.7 약 US$0.3371 -claude-fable-5-1 - -high - Anthropic Claude -200 -상세보기 - -ocx-555838039f421c3a2ee797f55a83bd8d - 14187ms - -2026. 9. 4. -오전 1:44:20 - -13.6만 - 9.0 약 US$0.2736 -grok-4.6 - -high -reasoning_effort=high - xAI Grok -200 -상세보기 - -ocx-041ab155bd347cc2420844ab34bab725 - 31000ms - -2026. 9. 4. -오전 1:44:33 - -12.3만 -c 12.2만 - 47.0 약 US$0.0837 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-6598f1236217e2c5b87ade3b005eb95c - 13712ms - -2026. 9. 4. -오전 1:44:39 - -7.4만 -c 6.8만 - 40.2 약 US$0.0701 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-eb23f84cb6113fcbd2dcb658a58e60a1 - 7889ms - -2026. 9. 4. -오전 1:44:33 - -6.9만 -c 6.1만 - 34.7 약 US$0.0737 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-f956cfb8d20c0b130b2a73f4d6138c61 - 5969ms - -2026. 9. 4. -오전 1:41:27 - -22.7만 -c 20.5만 - 53.8 약 US$0.4651 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-38df259bf529696880baab8045bcdafc - 190239ms - -2026. 9. 4. -오전 1:44:26 - -6.1만 -c 5.5만 - 40.4 약 US$0.0686 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-44dfd4e7aba688c641521838c389148b - 6585ms - -2026. 9. 4. -오전 1:44:21 - -12.2만 -c 9.4만 - 44.6 약 US$0.2019 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-0891d2369df2f0b331107d9a08f3e46d - 11760ms - -2026. 9. 4. -오전 1:44:26 - -28.4만 -c 28.3만 -w 322 - 32.9 약 US$0.0788 -claude-fable-5-1 - -high - Anthropic Claude -200 -상세보기 - -ocx-4a742f56bfc688c30629b797afef353a - 2428ms - -2026. 9. 4. -오전 1:44:18 - -5.5만 -c 5.4만 - 38.2 약 US$0.0386 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-760aa18797bf2188f8f250ca3aeafd10 - 7506ms - -2026. 9. 4. -오전 1:44:17 - -15.8만 -c 15.3만 -w 3764 - 56.2 약 US$0.1089 -claude-opus-5 - -high - Anthropic Claude -200 -상세보기 - -ocx-8342f0af832e94bdcab5a1a5ff8fcb5c - 6170ms - -2026. 9. 4. -오전 1:43:46 - -37.4만 -c 37.2만 - 23.1 약 US$0.1263 -k3[1m] - -high -reasoning_effort=high - Kimi -200 -상세보기 - -ocx-5d537e4bd727c92e0fcb79927752b609 - 35081ms - -2026. 9. 4. -오전 1:44:13 - -12.3만 -c 12.1만 - 36.6 약 US$0.0788 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-79c39d63269db9e84255898e8ea430d5 - 7057ms - -2026. 9. 4. -오전 1:43:53 - -12.9만 -c 12.3만 - 9.6 약 US$0.0741 -grok-4.6 - -high -reasoning_effort=high - xAI Grok -200 -상세보기 - -ocx-7b057b63f89af62d683a3c5dbc59d62e - 26562ms - -2026. 9. 4. -오전 1:44:11 - -5.5만 -c 4.5만 - 24.6 약 US$0.0742 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-9941898d08022add80e70a657a073304 - 7146ms - -2026. 9. 4. -오전 1:44:07 - -15.4만 -c 15.3만 -w 253 - 88.5 약 US$0.0977 -claude-opus-5 - -high - Anthropic Claude -200 -상세보기 - -ocx-a65529911ee077c45ad458f9d33b4513 - 8811ms - -2026. 9. 4. -오전 1:44:06 - -12.2만 -c 12만 - 35.8 약 US$0.0754 -gpt-5.6-sol - -medium - Openai P3b640f -200 -상세보기 - -ocx-8fdeaeb96af5249fabb4effce6e9c69c - 7449ms - diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/models_1440.png deleted file mode 100644 index 0a23491b65..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/models_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_combos_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/models_combos_1440.png deleted file mode 100644 index 14a96d1399..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/models_combos_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_combos_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/models_combos_interactive.txt deleted file mode 100644 index d6bc2d5998..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/models_combos_interactive.txt +++ /dev/null @@ -1,59 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e49 button "Codex 모델 목록 새로고침" -e53 button "Codex 모델 목록 새로고침" -e55 tab "모델20/96 표시" -e56 tab "콤보0" -e57 tab "라우팅 (beta)" -e58 tab "호환성" -e64 button "콤보 추가" -e67 textbox "콤보 또는 대상 검색…" -e69 button "콤보 만들기" -e71 tab "설정" -e72 tab "정보" -e75 textbox "콤보 ID" -e78 textbox "공개 모델 이름" -e80 checkbox "네이티브 OpenAI 별칭" -e84 textbox "표시 이름" -e88 radio "장애 조치" -e89 radio "라운드로빈" -e92 combobox "기본 추론 수준" -e93 option "없음 (대상 기본값)" -e94 option "low" -e95 option "medium" -e96 option "high" -e97 option "xhigh" -e98 option "max" -e99 option "ultra" -e102 button "드래그하여 순서 변경" -e103 button "위로" -e104 button "아래로" -e105 combobox "프로바이더" -e106 option "프로바이더 선택…" -e107 option "Anthropic Claude" -e108 option "Cursor" -e109 option "Google Antigravity" -e110 option "Kimi" -e111 option "Muse Code" -e112 option "OpenAI (Codex login)" -e113 option "OpenCode Free" -e114 option "xAI Grok" -e115 combobox "모델" -e116 option "먼저 프로바이더를 선택하세요…" -e118 button "삭제" -e120 button "대상 추가" -e127 button "이미지 / 멀티모달" -e130 button "적응형 추론 단계" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_combos_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/models_combos_text.txt deleted file mode 100644 index bda7a2cec9..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/models_combos_text.txt +++ /dev/null @@ -1,89 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -모델 -Codex가 이 카탈로그보다 오래된 모델 목록을 보여주고 있습니다. Codex를 재시작하면 새로 읽습니다. -Codex 모델 목록 새로고침 -모델20/96 표시 -콤보0 -라우팅 (beta) -호환성 - -여러 모델을 하나의 id로 묶어 순서대로 응답하게 합니다. failover로 대상을 연결하거나 분산 전략으로 부하를 나눕니다. - -콤보 -0 -콤보 추가 -콤보 추가 -콤보 만들기 -설정 -정보 -콤보 ID - -내부 콤보 ID입니다. 생성 후에도 변경할 수 있습니다. - -공개 모델 이름 - -선택 사항입니다. 접두사 없는 이름, vendor/model 같은 사용자 지정 접두사를 사용하거나 비워 두어 combo/를 사용할 수 있습니다. - - 네이티브 OpenAI 별칭 - -이 콤보가 지원되는 비수식 OpenAI 네이티브 모델 ID를 사용합니다. 계정/프로바이더 수식 OpenAI 경로는 별도로 유지됩니다. - -표시 이름 - -모델 선택기에 표시할 이름입니다. 네이티브 OpenAI 별칭을 사용할 때 필수입니다. - -전략 -장애 조치 -라운드로빈 - -대상을 순서대로 시도합니다. 재시도 가능한 오류(한도, 장애, 구독 게이트)면 다음으로 넘어갑니다. - -기본 추론 수준 -없음 (대상 기본값) -low -medium -high -xhigh -max -ultra - -클라이언트가 추론 수준을 생략한 경우에만 사용합니다. 옵션은 선택한 대상이 광고하는 수준의 교집합입니다. - -대상 -프로바이더 선택… -Anthropic Claude -Cursor -Google Antigravity -Kimi -Muse Code -OpenAI (Codex login) -OpenCode Free -xAI Grok -먼저 프로바이더를 선택하세요… -할당량 알 수 없음 -대상 추가 - -순서가 중요합니다 — 첫 번째가 기본입니다. - -기능 -이미지 / 멀티모달 - -선택한 모든 대상이 이미지 입력을 지원해야 사용할 수 있습니다. - -적응형 추론 단계 - -끔: 추론 단계를 조절할 수 없는 대상이 하나라도 있으면 콤보 전체의 선택기가 사라집니다. 켬: 그런 대상도 그대로 쓰면서, 선택기에는 나머지 대상이 공통으로 지원하는 단계가 남습니다. diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_1440.png deleted file mode 100644 index a8368fd723..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_interactive.txt deleted file mode 100644 index 05efdc798f..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_interactive.txt +++ /dev/null @@ -1,22 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e49 button "Codex 모델 목록 새로고침" -e53 button "Codex 모델 목록 새로고침" -e55 tab "모델20/96 표시" -e56 tab "콤보" -e57 tab "라우팅 (beta)" -e58 tab "호환성" -e61 button "새로고침" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_text.txt deleted file mode 100644 index eb45a13330..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/models_compatibility_text.txt +++ /dev/null @@ -1,27 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -모델 -Codex가 이 카탈로그보다 오래된 모델 목록을 보여주고 있습니다. Codex를 재시작하면 새로 읽습니다. -Codex 모델 목록 새로고침 -모델20/96 표시 -콤보 -라우팅 (beta) -호환성 - -랩 프로젝션 증거의 읽기 전용 호환성 판정 행렬. - -새로고침 -랩 프로젝션을 사용할 수 없습니다. 먼저 적합성 또는 라이브 프로브를 실행하세요. diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/models_interactive.txt deleted file mode 100644 index fcbee9930f..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/models_interactive.txt +++ /dev/null @@ -1,144 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e49 button "Codex 모델 목록 새로고침" -e53 button "Codex 모델 목록 새로고침" -e55 tab "모델20/96 표시" -e56 tab "콤보" -e57 tab "라우팅 (beta)" -e58 tab "호환성" -e63 button "모든 프로바이더 20/96 표시" -e64 button "OpenAI (Codex login) 3/7 표시" -e65 button "Anthropic Claude 3/13 표시" -e66 button "Cursor 6/40 표시" -e67 button "Google Antigravity 2/7 표시" -e68 button "Kimi 1/9 표시" -e69 button "Muse Code 2/2 표시" -e70 button "OpenCode Free 1/9 표시" -e71 button "xAI Grok 1/8 표시" -e74 button "새 모델을 비활성화 상태로 추가" -e75 button "기본 별칭을 전체에 사용" -e76 button "별칭" -e78 button "Codex 앱의 백그라운드 호출(gpt-5.6-luna, 제목·커밋 메시지)을 가로채 선택한 모델로 바꿉니다." -e80 button "쉐도우 호출 가로채기" -e81 combobox "쉐도우 호출 가로채기" -e86 radio "v1" -e87 radio "base" -e88 radio "v2" -e89 button "서브에이전트" -e91 combobox "기본 창 / 상한" -e94 button "전체 적용" -e96 button "모두 접기" -e97 button "모두 펼치기" -e98 button "openai OpenAI 네이티브 3/7 표시" -e101 button "공급자 별칭 편집" -e103 button "기본 별칭 사용" -e104 button "커스텀 모델 추가" -e105 button "모두 켜기" -e106 button "모두 끄기" -e107 button "기본 창 / 상한" -e108 combobox "기본 1.05M" -e111 button "사용자 지정 창" -e112 button "anthropic 3/13 표시 신규 4개, 꺼짐" -e115 button "공급자 별칭 편집" -e117 button "기본 별칭 사용" -e118 button "커스텀 모델 추가" -e120 radio "프리셋" -e121 radio "전체" -e122 button "모두 켜기" -e123 button "모두 끄기" -e124 button "기본 창 / 상한" -e125 combobox "기본 1M" -e128 button "사용자 지정 창" -e129 button "cursor 6/40 표시 신규 3개, 꺼짐" -e132 button "공급자 별칭 편집" -e134 button "기본 별칭 사용" -e135 button "커스텀 모델 추가" -e136 button "모두 켜기" -e137 button "모두 끄기" -e138 button "기본 창 / 상한" -e139 combobox "기본 1,048,576" -e142 button "사용자 지정 창" -e143 button "google-antigravity 2/7 표시 신규 1개, 꺼짐" -e146 button "공급자 별칭 편집" -e148 button "기본 별칭 사용" -e149 button "커스텀 모델 추가" -e150 button "모두 켜기" -e151 button "모두 끄기" -e152 button "기본 창 / 상한" -e153 combobox "기본 1,048,576" -e156 button "사용자 지정 창" -e157 button "kimi 1/9 표시" -e160 button "공급자 별칭 편집" -e162 button "기본 별칭 사용" -e163 button "커스텀 모델 추가" -e164 button "모두 켜기" -e165 button "모두 끄기" -e166 button "기본 창 / 상한" -e167 combobox "기본 1,048,576" -e170 button "사용자 지정 창" -e173 radio "끔" -e174 radio "켬" -e175 button "kimi/k3[1m]" -e177 button "모델 별칭 편집" -e179 button "kimi/k3" -e181 button "모델 별칭 편집" -e183 button "kimi/k3-256k" -e185 button "모델 별칭 편집" -e187 button "kimi/kimi-for-coding" -e189 button "모델 별칭 편집" -e191 button "kimi/kimi-for-coding-highspeed" -e193 button "모델 별칭 편집" -e195 button "kimi/kimi-k2.5" -e197 button "모델 별칭 편집" -e199 button "kimi/kimi-k2.6" -e201 button "모델 별칭 편집" -e203 button "kimi/kimi-k2.7-code" -e205 button "모델 별칭 편집" -e207 button "kimi/kimi-k2.7-code-highspeed" -e209 button "모델 별칭 편집" -e211 button "meta-muse 2/2 표시" -e214 button "공급자 별칭 편집" -e216 button "기본 별칭 사용" -e217 button "커스텀 모델 추가" -e218 button "모두 켜기" -e219 button "모두 끄기" -e220 button "기본 창 / 상한" -e221 combobox "기본 1,048,576" -e224 button "사용자 지정 창" -e227 radio "끔" -e228 radio "켬" -e229 button "meta-muse/muse-spark-1.3" -e231 button "모델 별칭 편집" -e233 button "meta-muse/muse-spark-1.3-contributor" -e235 button "모델 별칭 편집" -e237 button "opencode-free 1/9 표시 신규 2개, 꺼짐" -e240 button "공급자 별칭 편집" -e242 button "기본 별칭 사용" -e243 button "커스텀 모델 추가" -e244 button "모두 켜기" -e245 button "모두 끄기" -e246 button "기본 창 / 상한" -e247 combobox "기본 350k" -e250 button "사용자 지정 창" -e251 button "xai 1/8 표시 신규 1개, 꺼짐" -e254 button "공급자 별칭 편집" -e256 button "기본 별칭 사용" -e257 button "커스텀 모델 추가" -e258 button "모두 켜기" -e259 button "모두 끄기" -e260 button "기본 창 / 상한" -e261 combobox "기본 1M" -e264 button "사용자 지정 창" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_routing_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/models_routing_1440.png deleted file mode 100644 index c938d63d0e..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/models_routing_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_routing_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/models_routing_interactive.txt deleted file mode 100644 index a39bea8892..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/models_routing_interactive.txt +++ /dev/null @@ -1,28 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e49 button "Codex 모델 목록 새로고침" -e53 button "Codex 모델 목록 새로고침" -e55 tab "모델20/96 표시" -e56 tab "콤보0" -e57 tab "라우팅 (beta)0" -e58 tab "호환성" -e61 button "프로필 만들기" -e62 button "재시도" -e65 spinbutton "요청 컨텍스트 창(토큰)" -e66 checkbox "요청에 도구 필요" -e68 checkbox "요청에 이미지 입력 필요" -e70 checkbox "요청에 구조화된 출력 필요" -e72 button "후보 평가" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_routing_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/models_routing_text.txt deleted file mode 100644 index 8edb6e619a..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/models_routing_text.txt +++ /dev/null @@ -1,37 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -모델 -Codex가 이 카탈로그보다 오래된 모델 목록을 보여주고 있습니다. Codex를 재시작하면 새로 읽습니다. -Codex 모델 목록 새로고침 -모델20/96 표시 -콤보0 -라우팅 (beta)0 -호환성 - -정책 프로필, dry-run 평가, 그리고 근거가 남는 라우팅 분석입니다. - -+ -프로필 만들기 -재시도 -드라이런 평가 -요청 컨텍스트 창(토큰) -요청에 도구 필요 -요청에 이미지 입력 필요 -요청에 구조화된 출력 필요 -후보 평가 -라우팅 분석 - -분석이 아직 없습니다. 먼저 요청을 보내세요. diff --git a/devlog/_plan/260904_dashboard_minimal/assets/models_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/models_text.txt deleted file mode 100644 index 2482b4a6f8..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/models_text.txt +++ /dev/null @@ -1,166 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -모델 -Codex가 이 카탈로그보다 오래된 모델 목록을 보여주고 있습니다. Codex를 재시작하면 새로 읽습니다. -Codex 모델 목록 새로고침 -모델20/96 표시 -콤보 -라우팅 (beta) -호환성 - -Codex가 보는 모델을 켜고 끕니다 — 네이티브 GPT passthrough와 라우팅된 모델을 프로바이더별로 묶어 보여줍니다(헤더를 클릭하면 접힘). 숨긴 모델은 카탈로그와 선택기에서 빠지지만 정확한 id로 직접 호출할 수 있습니다. 변경 사항은 다음 Codex 턴에 적용됩니다 — opencodex가 Codex의 5분 모델 캐시를 무효화하므로 재시작이 필요 없습니다. - -프로바이더 -8 -모든 프로바이더 -20/96 표시 -OpenAI (Codex login) -3/7 표시 -Anthropic Claude -3/13 표시 -Cursor -6/40 표시 -Google Antigravity -2/7 표시 -Kimi -1/9 표시 -Muse Code -2/2 표시 -OpenCode Free -1/9 표시 -xAI Grok -1/8 표시 -새 모델을 비활성화 상태로 추가 -별칭 -쉐도우 호출 가로채기 ⓘ -⚠ 5.6-luna → -— -서브에이전트 -v1 -base -v2 -기본 창 / 상한 -350k -라우팅된 모든 프로바이더에 350k 기본 창을 켭니다. 중계가 context_window / context_length 를 주지 않으면 이 값이 실제 Codex 창이 됩니다. 모델 하나만 손으로 쓰려면 같은 줄의 「사용자 지정 창」을 쓰세요. 네이티브 프로바이더는 영향을 받지 않습니다. -커스텀 2개 -피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다. -모두 접기 -모두 펼치기 -openai -OpenAI 네이티브 -3/7 표시 -기본 별칭 사용 -+ -커스텀 모델 추가 -모두 켜기 -모두 끄기 -기본 창 / 상한 -1.05M -사용자 지정 창 -anthropic -3/13 표시 -신규 4개, 꺼짐 -기본 별칭 사용 -+ -커스텀 모델 추가 -프리셋 -전체 -모두 켜기 -모두 끄기 -기본 창 / 상한 -1M -사용자 지정 창 -cursor -6/40 표시 -신규 3개, 꺼짐 -기본 별칭 사용 -+ -커스텀 모델 추가 -모두 켜기 -모두 끄기 -기본 창 / 상한 -1,048,576 -사용자 지정 창 -google-antigravity -2/7 표시 -신규 1개, 꺼짐 -기본 별칭 사용 -+ -커스텀 모델 추가 -모두 켜기 -모두 끄기 -기본 창 / 상한 -1,048,576 -사용자 지정 창 -kimi -1/9 표시 -기본 별칭 사용 -+ -커스텀 모델 추가 -모두 켜기 -모두 끄기 -기본 창 / 상한 -1,048,576 -사용자 지정 창 -새 모델 정책 -끔 -켬 -kimi/k3[1m] -kimi/k3 -kimi/k3-256k -kimi/kimi-for-coding -kimi/kimi-for-coding-highspeed -kimi/kimi-k2.5 -kimi/kimi-k2.6 -kimi/kimi-k2.7-code -kimi/kimi-k2.7-code-highspeed -meta-muse -2/2 표시 -기본 별칭 사용 -+ -커스텀 모델 추가 -모두 켜기 -모두 끄기 -기본 창 / 상한 -1,048,576 -사용자 지정 창 -새 모델 정책 -끔 -켬 -meta-muse/muse-spark-1.3 -meta-muse/muse-spark-1.3-contributor -opencode-free -1/9 표시 -신규 2개, 꺼짐 -기본 별칭 사용 -+ -커스텀 모델 추가 -모두 켜기 -모두 끄기 -기본 창 / 상한 -350k -사용자 지정 창 -xai -1/8 표시 -신규 1개, 꺼짐 -기본 별칭 사용 -+ -커스텀 모델 추가 -모두 켜기 -모두 끄기 -기본 창 / 상한 -1M -사용자 지정 창 diff --git a/devlog/_plan/260904_dashboard_minimal/assets/providers_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/providers_1440.png deleted file mode 100644 index f9f618d4dd..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/providers_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/providers_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/providers_interactive.txt deleted file mode 100644 index 20c00cf244..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/providers_interactive.txt +++ /dev/null @@ -1,37 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e49 button "프로바이더 추가" -e53 searchbox "프로바이더 검색…" -e54 button "프로바이더 필터" -e57 option "Anthropic Claude 선택 — 준비됨" -e58 option "Cursor 선택 — 준비됨" -e59 option "Google Antigravity 선택 — 준비됨" -e60 option "Kimi 선택 — 준비됨" -e61 option "Muse Code 선택 — 준비됨" -e62 option "OpenAI (Codex login) 선택 — 준비됨" -e63 option "OpenCode Free 선택 — 준비됨 · 무료" -e64 option "xAI Grok 선택 — 준비됨 · 기본" -e66 option "Lidge 선택 — 비활성" -e70 button "JSON 편집" -e74 button "Cursor 방금 전 전 확인 자사 모델 9월 17일, 01:54 초기화 5% 사용 API 사용량 9월 17일, 01:54 초기화 55% 사용 30일 한도 9월 17일, 01:54 초기화 12% 사용" -e75 button "Anthropic Claude 방금 전 전 확인 5시간 한도 5시간 후 초기화 2% 사용 주간 한도 내일 08:00 초기화 55% 사용 Fable 내일 08:00 초기화 50% 사용" -e82 button "Kimi 방금 전 전 확인 5시간 한도 4시간 후 초기화 21% 사용 주간 한도 9월 9일, 23:22 초기화 10% 사용" -e83 button "Google Antigravity 방금 전 전 확인 Gem 5시간 후 초기화 1% 사용 Cla 5시간 후 초기화 0% 사용" -e84 button "xAI Grok 방금 전 전 확인 주간 한도 9월 10일, 19:26 초기화 0% 사용" -e87 button "Anthropic Claude 94.7k건 요청" -e88 button "OpenAI (Codex login) 84.1k건 요청" -e89 button "xAI Grok 16.5k건 요청" -e90 button "Kimi 4.6k건 요청" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/providers_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/providers_text.txt deleted file mode 100644 index 67ae32256f..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/providers_text.txt +++ /dev/null @@ -1,125 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -프로바이더 -프로바이더 추가 -준비됨 -8 -Anthropic Claude -모델 13개 -Cursor -모델 40개 -Google Antigravity -모델 7개 -Kimi -모델 9개 -Muse Code -모델 2개 -OpenAI (Codex login) -  -OpenCode Free -무료 -모델 9개 -xAI Grok -모델 8개 -비활성 -1 -L -Lidge -모델 1개 -프로바이더 개요 - -모든 모델 프로바이더를 한곳에서 관리합니다. - -JSON 편집 -8 -준비됨 -0 -설정 필요 -1 -비활성 -사용량 제한 -Cursor -방금 전 전 확인 -자사 모델 -9월 17일, 01:54 초기화 -5% 사용 -API 사용량 -9월 17일, 01:54 초기화 -55% 사용 -30일 한도 -9월 17일, 01:54 초기화 -12% 사용 -Anthropic Claude -방금 전 전 확인 -5시간 한도 -5시간 후 초기화 -2% 사용 -주간 한도 -내일 08:00 초기화 -55% 사용 -Fable -내일 08:00 초기화 -50% 사용 -OpenAI (Codex login) -방금 전 전 확인 -설정 가중치 기반 풀 추정치 -5시간 한도 -일부만 -0% 사용 -주간 한도 -일부만 -42% 사용 -30일 한도 -일부만 -0% 사용 -다음 용량 회복 · 주간 · 2026. 9. 7. 오전 11:28 -+8.5% 풀 용량 -현재 유효 계정 · go -30일 한도 -10월 1일, 08:49 초기화 -0% 사용 -보정되지 않은 요금제 1개는 기본 좌석 가중치로 계산되어, 이 추정치가 실제보다 낮을 수 있습니다 -일부 기간의 범위가 불완전합니다: 5개 계정에서 표시된 모든 한도 기간을 확인할 수 없습니다 -Kimi -방금 전 전 확인 -5시간 한도 -4시간 후 초기화 -21% 사용 -주간 한도 -9월 9일, 23:22 초기화 -10% 사용 -Google Antigravity -방금 전 전 확인 -Gem -5시간 후 초기화 -1% 사용 -Cla -5시간 후 초기화 -0% 사용 -xAI Grok -방금 전 전 확인 -주간 한도 -9월 10일, 19:26 초기화 -0% 사용 -최근 사용 -Anthropic Claude -94.7k건 요청 -OpenAI (Codex login) -84.1k건 요청 -xAI Grok -16.5k건 요청 -Kimi -4.6k건 요청 diff --git a/devlog/_plan/260904_dashboard_minimal/assets/startup_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/startup_1440.png deleted file mode 100644 index b81fb5d36c..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/startup_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/startup_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/startup_interactive.txt deleted file mode 100644 index 203e84bfe1..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/startup_interactive.txt +++ /dev/null @@ -1,22 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e50 button "대시보드로 돌아가기" -e51 button "새로고침" -e57 button "복사" -e69 button "Codex launcher shim - 설치하기" -e75 button "복사" -e78 button "복사" -e81 button "복사" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/startup_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/startup_text.txt deleted file mode 100644 index f5a48bd9d1..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/startup_text.txt +++ /dev/null @@ -1,59 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -시작 안전성 - -재부팅 후 로컬 프록시 라우팅이 재연결 반복으로 이어지기 전에 Codex가 opencodex에 연결될 수 있는지 확인합니다. - -대시보드로 돌아가기 -새로고침 - -OpenCodex가 Codex 0.153.0-alpha.5을(를) 사용해 일부 reasoning effort 옵션이 숨겨졌습니다(제거됨: minimal). - -ocx sync -복사 -재부팅 보호됨 -재부팅 후에도 opencodex가 자동으로 준비됩니다 - -현재 라우팅과 시작 방식이 일치합니다. 재부팅 후 ocx start를 수동으로 실행할 필요가 없습니다. - -Codex 라우팅 -로컬 프록시 -재부팅 보호 -백그라운드 서비스 -필요 시 자동 시작 -켜짐 -보호 상태 상세 -darwin -백그라운드 서비스 -로그인할 때 시작하고 프록시가 중단되면 다시 실행합니다. -사용 가능 -Codex launcher shim -지원되는 Codex 스크립트 런처가 시작될 때 ocx ensure를 실행합니다. -설치되지 않음 -설치하기 -복구 방법 - -위의 원클릭 설치를 사용하거나 수동 복구 명령을 복사할 수 있습니다. Codex Desktop과 Windows 실행 파일에는 백그라운드 서비스를 권장합니다. - -권장: 영구 백그라운드 서비스 -ocx service repair -복사 -대안: CLI launcher shim -ocx codex-shim install -복사 -안전 전환: Codex 네이티브 라우팅 복구 -ocx restore -복사 diff --git a/devlog/_plan/260904_dashboard_minimal/assets/storage_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/storage_1440.png deleted file mode 100644 index 1c27eaf9f0..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/storage_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/storage_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/storage_interactive.txt deleted file mode 100644 index 8d1f9e8682..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/storage_interactive.txt +++ /dev/null @@ -1,16 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e50 button "다시 스캔" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/storage_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/storage_text.txt deleted file mode 100644 index 04364200fc..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/storage_text.txt +++ /dev/null @@ -1,21 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -저장소 -다시 스캔 - -CODEX_HOME 사용량을 확인합니다. 정리는 활성 세션을 건드리지 않습니다. - -저장소 스캔 중… diff --git a/devlog/_plan/260904_dashboard_minimal/assets/subagents_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/subagents_1440.png deleted file mode 100644 index 854ab4bfb8..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/subagents_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/subagents_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/subagents_interactive.txt deleted file mode 100644 index 4804ad74d1..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/subagents_interactive.txt +++ /dev/null @@ -1,60 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e50 tab "추천5/5" -e51 tab "모델21" -e52 tab "설정" -e61 button "xai/grok-4.6 위로 이동" -e63 button "xai/grok-4.6 아래로 이동" -e65 button "xai/grok-4.6 삭제" -e68 button "gpt-5.6-sol 위로 이동" -e70 button "gpt-5.6-sol 아래로 이동" -e72 button "gpt-5.6-sol 삭제" -e75 button "gpt-5.6-terra 위로 이동" -e77 button "gpt-5.6-terra 아래로 이동" -e79 button "gpt-5.6-terra 삭제" -e82 button "gpt-5.6-luna 위로 이동" -e84 button "gpt-5.6-luna 아래로 이동" -e86 button "gpt-5.6-luna 삭제" -e89 button "gpt-5.5 위로 이동" -e91 button "gpt-5.5 아래로 이동" -e93 button "gpt-5.5 삭제" -e95 button "저장" -e99 textbox "모델 검색(네이티브 gpt + 라우팅)…" -e101 button "gpt-5.6-sol을(를) 추천에서 제거" -e104 button "gpt-5.6-terra을(를) 추천에서 제거" -e107 button "gpt-5.6-luna을(를) 추천에서 제거" -e110 button "anthropic/claude-fable-5-1을(를) 추천에 추가" -e113 button "anthropic/claude-opus-4-6을(를) 추천에 추가" -e116 button "anthropic/claude-opus-5을(를) 추천에 추가" -e119 button "cursor/claude-fable-5-1을(를) 추천에 추가" -e122 button "cursor/gemini-3.6-flash을(를) 추천에 추가" -e125 button "cursor/gemini-3.7-flash을(를) 추천에 추가" -e128 button "cursor/gemini-3.8-flash을(를) 추천에 추가" -e131 button "cursor/grok-4.6을(를) 추천에 추가" -e134 button "cursor/kimi-k3을(를) 추천에 추가" -e137 button "google-antigravity/claude-opus-4-6-thinking을(를) 추천에 추가" -e140 button "google-antigravity/gemini-3.8-flash을(를) 추천에 추가" -e143 button "kimi/k3[1m]을(를) 추천에 추가" -e146 button "meta-muse/muse-spark-1.3을(를) 추천에 추가" -e149 button "meta-muse/muse-spark-1.3-contributor을(를) 추천에 추가" -e152 button "opencode-free/muse-spark-1.2-contributor-free을(를) 추천에 추가" -e155 button "xai/grok-4.6을(를) 추천에서 제거" -e158 button "lidge/qwen3.8-27b-nvfp4을(를) 추천에 추가" -e161 button "gpt-5.5을(를) 추천에서 제거" -e166 combobox "서브에이전트 위임" -e170 button "Codex 설정에도 기본값으로 저장" -e172 button "일 나누는 방법 알려주기" -e174 button "울트라 모드" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/subagents_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/subagents_text.txt deleted file mode 100644 index 8932e64cc9..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/subagents_text.txt +++ /dev/null @@ -1,74 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -서브에이전트 -추천5/5 -모델21 -설정 -추천 -5/5 - -여기서 선택해 표시된 순서가 Codex 모델 피커 최상단 1~5위와 spawn_agent의 기본 모델 후보를 결정합니다. - -1 -xai/grok-4.6 -2 -gpt-5.6-sol -3 -gpt-5.6-terra -4 -gpt-5.6-luna -5 -gpt-5.5 -저장 -모델 -21 -2 -gpt-5.6-sol -3 -gpt-5.6-terra -4 -gpt-5.6-luna -anthropic/claude-fable-5-1 -anthropic/claude-opus-4-6 -anthropic/claude-opus-5 -cursor/claude-fable-5-1 -cursor/gemini-3.6-flash -cursor/gemini-3.7-flash -cursor/gemini-3.8-flash -cursor/grok-4.6 -cursor/kimi-k3 -google-antigravity/claude-opus-4-6-thinking -google-antigravity/gemini-3.8-flash -kimi/k3[1m] -meta-muse/muse-spark-1.3 -meta-muse/muse-spark-1.3-contributor -opencode-free/muse-spark-1.2-contributor-free -1 -xai/grok-4.6 -lidge/qwen3.8-27b-nvfp4 -5 -gpt-5.5 -설정 -먼저 부를 모델 -Codex가 일을 나눠 맡길 때 가장 먼저 부를 모델입니다. 위 추천 목록이 부를 수 있는 후보라면, 여기서 고른 모델이 그중 1순위가 됩니다. -없음 -Codex 설정에도 기본값으로 저장 -켜면 위에서 고른 모델이 Codex 설정 파일에 저장돼, 새로 시작하는 작업도 처음부터 그 모델을 씁니다. 끄면 여기서만 기억합니다. 반영은 다음 동기화나 재시작 때이고, 직접 적어둔 [agents] 설정은 그대로 둡니다. -일 나누는 방법 알려주기 -Codex에게 "일을 이렇게 나눠 맡기면 된다"는 짧은 쪽지를 붙여 보냅니다. v2에서는 쓸 수 있는 모델 목록과 우선 모델을 알려주고, v1에서는 추론 강도가 max나 ultra일 때만 동작합니다. 끄면 아무 쪽지도 붙지 않습니다. -울트라 모드 -모든 모델과 reasoning effort에서 Proactive 멀티에이전트 위임 정책을 켭니다 (reasoning effort 자체는 변경하지 않음). config.toml에 features.multi_agent_v2.multi_agent_mode_hint_text를 기록합니다. -v2 멀티에이전트 서피스가 필요합니다 — 먼저 multi_agent_v2를 켜고 서브에이전트 모드에서 v2를 선택하세요. diff --git a/devlog/_plan/260904_dashboard_minimal/assets/usage_1440.png b/devlog/_plan/260904_dashboard_minimal/assets/usage_1440.png deleted file mode 100644 index 498569b4f5..0000000000 Binary files a/devlog/_plan/260904_dashboard_minimal/assets/usage_1440.png and /dev/null differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/usage_interactive.txt b/devlog/_plan/260904_dashboard_minimal/assets/usage_interactive.txt deleted file mode 100644 index de4e976be7..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/usage_interactive.txt +++ /dev/null @@ -1,27 +0,0 @@ -e5 button "대시보드" -e8 button "Codex 설정" -e11 button "프로바이더" -e14 button "모델" -e17 button "서브에이전트" -e20 button "로그&디버그" -e23 button "사용량" -e26 button "저장소" -e29 button "연동" -e32 combobox "언어" -e38 button "프록시 중지" -e40 button "Codex 모델 목록 새로고침" -e42 link "GitHub" -e45 button "GitHub 스타 완료" -e46 button "업데이트 확인" -e50 button "전체" -e51 button "Codex" -e52 button "Claude" -e53 button "Grok" -e55 button "사용 가능한 기록" -e56 button "30일" -e57 button "7일" -e60 tab "개요231928" -e61 tab "모델66" -e62 tab "프로바이더19" -e63 tab "커버리지 상세99%" -e72 textbox "모델 검색…" diff --git a/devlog/_plan/260904_dashboard_minimal/assets/usage_text.txt b/devlog/_plan/260904_dashboard_minimal/assets/usage_text.txt deleted file mode 100644 index bc9a16d608..0000000000 --- a/devlog/_plan/260904_dashboard_minimal/assets/usage_text.txt +++ /dev/null @@ -1,256 +0,0 @@ -opencodex -v2.42.0 -대시보드 -Codex 설정 -프로바이더 -모델 -서브에이전트 -로그&디버그 -사용량 -저장소 -연동 -한국어 -시스템 -프록시 -GitHub -사용량 -전체 -Codex -Claude -Grok -사용 가능한 기록 -30일 -7일 - -프록시의 로컬 토큰 집계입니다. 누락된 사용량은 0으로 표시하지 않습니다. - -개요231928 -모델66 -프로바이더19 -커버리지 상세99% -요청 -231928 -측정됨 -229025 -총 토큰 -515.1억 -캐시 히트 토큰 -497.9억 -캐시 생성: 6.1억 -커버리지 -99% -활동일 -30 -API 정가 환산치 (이 기간) -~US$38,986.1775 -결제 영수증이 아닙니다. 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다. -비용 산정 불가 7432건 제외 -일별 활동 -Aug -Sep -Oct -Nov -Dec -Jan -Feb -Mar -Apr -May -Jun -Jul -Aug -월 -수 -금 -적음 -많음 -모델 -모델 프로바이더 요청 측정됨 토큰 비율 -claude-opus-5 Anthropic Claude 72724 72593 284억 - - -gpt-5.6-sol OpenAI (Codex login) 70222 69384 113.9억 - -claude-fable-5 Anthropic Claude 12246 12183 35.7억 - -claude-fable-5-1 Anthropic Claude 5182 5156 17.6억 - -grok-4.6 xAI Grok 15461 15361 17.3억 - -claude-opus-4-6 Anthropic Claude 4461 4448 9.3억 - -k3[1m] Kimi 3824 3758 7.5억 - - -gpt-5.6-terra OpenAI (Codex login) 6693 6604 7.5억 - -gemini-3.7-flash Google Antigravity 3185 3145 4.8억 - -x-preview-f-free OpenCode Free 1946 1865 4.4억 - -stealth/ox-alpha OpenRouter 2126 2102 3.5억 - - -gpt-5.6-luna OpenAI (Codex login) 6030 5079 2.9억 - -grok-4.5 xAI Grok 1011 1001 1.7억 - -gemini-3.8-flash Google Antigravity 1105 1105 1.2억 - -gpt-5.5 OpenAI (Codex login) 864 862 1억 - -qwen3.8-max-preview Alibaba Token Plan (Intl) 337 336 8588.3만 - -anthropic/claude-opus-5 Kimi 758 754 8274.7만 - -claude-opus-5 Kiro 21651 21444 4101.1만 - -gemini-3.6-flash Google Antigravity 88 88 2830.5만 - -grok-4.6 Cursor 304 291 2137.1만 - -qwen3.8-27b-nvfp4 Lidge 168 102 562.5만 - -gpt-5.3-codex-spark OpenAI (Codex login) 274 265 514.5만 - -kimi-k3 OpenCode Go 83 79 480.4만 - -claude-opus-4-6-thinking Google Antigravity 61 61 322.1만 - -kimi-k3-1m Cursor 48 34 215.6만 - -deepseek-v4-pro Alibaba Token Plan (Intl) 33 33 196.3만 - -anthropic/claude-fable-5 Kimi 9 8 191.4만 - -cursor/grok-4.5 Kimi 24 24 177만 - -gemini-3.7-flash-high Google Antigravity 11 10 160.5만 - -qwen3.8-max Alibaba Token Plan (Intl) 13 13 84.4만 - -gpt-5.4-mini OpenAI (Codex login) 26 25 83.5만 - -grok-4.5 Cursor 13 13 80.1만 - -kimi-k3 Cursor 26 26 61.2만 - -claude-opus-4.6 Kiro 665 662 50.6만 - -deepseek-v4-flash DeepSeek 16 16 32.7만 - -anthropic/claude-opus-4-6 Kimi 6 5 27.8만 - -gemini-3.7-flash Cursor 5 5 14.8만 - -muse-spark-1.3-contributor Muse Code 7 7 12.4만 - -k3 Kimi 3 3 6.8만 - -glm-5.3 Cursor 4 4 5.6만 - -gemini-3.5-flash Cursor 3 3 4.4만 - -claude-sonnet-4-5 Anthropic Claude 7 7 3.4만 - -claude-3-haiku-20240307 Anthropic Claude 49 42 1.5만 - -unpriced-model Unpriced Provider 7 7 770 - -no-such-model No Such Provider 7 7 770 - -muse-spark-1.3 Muse Code 1 1 227 - -gemini-3.6-flash Kimi 4 4 141 - -qwen3.8-27b-nvfp4 Kimi 1 1 59 - -gemini-3.5-flash-high Kimi 1 1 41 - -gemini-3.7-flash-tiered Kimi 2 2 38 - -policy/does-not-exist Kimi 1 1 38 - -gemini-3.6-flash-tiered Kimi 1 1 19 - -google/gemini-3.7-flash Zenmux 1 1 1 - -unknown Unknown 119 0 0 - -gpt-image-2 OpenAI (Codex login) 3 0 0 - -gpt-live OpenAI (Codex login) 3 0 0 - -gpt-6-astra OpenAI (Codex login) 2 0 0 - -gpt-5.6-cyber OpenAI (Codex login) 2 0 0 - -claude-opus-5 Anthropic Native 1 0 0 - - -gpt-daybreak-blue-latest OpenAI (Codex login) 1 0 0 - - -gpt-5.6-sol Kiro 1 0 0 - -gpt-5.7-astra OpenAI (Codex login) 1 0 0 - -gpt-6 OpenAI (Codex login) 1 0 0 - -mewfour xAI Grok 1 0 0 - -astra OpenAI (Codex login) 1 0 0 - -mewfour OpenAI (Codex login) 1 0 0 -프로바이더 -프로바이더 요청 측정됨 토큰 비율 -Anthropic Claude 94669 94429 346.5억 - -OpenAI (Codex login) 84124 82219 125.4억 - -xAI Grok 16473 16362 18.9억 - -Kimi 4634 4562 8.4억 - -Google Antigravity 4450 4409 6.3억 - -OpenCode Free 1946 1865 4.4억 - -OpenRouter 2126 2102 3.5억 - -Alibaba Token Plan (Intl) 383 382 8869만 - -Kiro 22317 22106 4151.6만 - -Cursor 403 376 2518.8만 - -Lidge 168 102 562.5만 - -OpenCode Go 83 79 480.4만 - -DeepSeek 16 16 32.7만 - -Muse Code 8 8 12.4만 - -Unpriced Provider 7 7 770 - -No Such Provider 7 7 770 - -Zenmux 1 1 1 - -Unknown 119 0 0 - -Anthropic Native 1 0 0 -커버리지 상세 -측정됨 -229025 -제공자 보고 -206539 -추정 -22486 -미보고 -2903 -미지원 -0 - -측정됨 항목은 제공자 보고와 추정 토큰 수치를 함께 포함합니다. 미보고/미지원 요청은 추적만 하고 0으로 환산하지 않습니다. diff --git a/devlog/_plan/260904_provider_quota_refresh/000_plan.md b/devlog/_plan/260904_provider_quota_refresh/000_plan.md index e37cdca4c2..9a378ce965 100644 --- a/devlog/_plan/260904_provider_quota_refresh/000_plan.md +++ b/devlog/_plan/260904_provider_quota_refresh/000_plan.md @@ -1,7 +1,6 @@ # Provider quota refresh affordance + Meta usage visibility -Unit opened 2026-09-04. Two defects reported against the live Providers dashboard -on `http://localhost:10100/#providers`: +Unit opened 2026-09-04. Two defects reported against the live Providers dashboard: 1. Only the Codex account pool has a "Refresh quotas" button. Every other provider — anthropic, xai, cursor, google-antigravity, meta-muse — offers the operator no @@ -9,7 +8,7 @@ on `http://localhost:10100/#providers`: 2. Meta Muse shows no quota on the provider Usage tab even though the proxy has an observation for it. -## Evidence gathered at P (live proxy, port 10100, v2.42.0, pid 73184) +## Evidence gathered at P (live proxy, v2.42.0) `GET /api/provider-quotas` returns six reports, and `meta-muse` is one of them: @@ -77,6 +76,7 @@ config save, provider add/remove. There is no operator-initiated path. The `bun x tsc --noEmit`, `bun run lint:gui` only. - Push with `--no-verify`; branch `codex/260904-provider-quota-refresh`; target `dev`. - A GUI-mentioning PR requires a screenshot in the description (`enforce-target`). -- The live proxy on port 10100 is the user's working service. Read it, restart it - only when a rebuild must be picked up, never repoint or reconfigure it. +- Verification must use an isolated scratch instance. Do not restart, repoint or + reconfigure the user's working proxy. The completed isolation record supersedes + the original working-service restart plan. - `refresh=1` must never cause a passive provider to spend an inference turn. diff --git a/devlog/_plan/260904_provider_quota_refresh/030_wp3_live_verification_and_pr.md b/devlog/_plan/260904_provider_quota_refresh/030_wp3_live_verification_and_pr.md index a8c4168b63..29fdc19756 100644 --- a/devlog/_plan/260904_provider_quota_refresh/030_wp3_live_verification_and_pr.md +++ b/devlog/_plan/260904_provider_quota_refresh/030_wp3_live_verification_and_pr.md @@ -8,32 +8,34 @@ phase is the evidence phase. 1. `bun run build:gui` — the service serves `gui/dist`, so an unbuilt change is invisible no matter how green the tests are. -2. `ocx service restart` — picks up the server-side `observed` flag. Confirm a new - pid and fresh uptime on `/healthz`, and that the port is still 10100. The service - is the user's own; restart it, never repoint or reconfigure it. -3. `curl /api/provider-quotas` with the admin token — the meta-muse row must now +2. Load the rebuilt code in the isolated scratch instance and confirm its identity + and fresh uptime on `/healthz`. The completed isolation record supersedes the + original `ocx service restart` plan; do not restart, repoint or reconfigure the + user's working proxy. +3. Query the scratch instance's `/api/provider-quotas` with its admin token — the meta-muse row must now carry `"observed": true`. This is the wire-level proof, checked before the UI so a blank screen can be attributed correctly. -## Browser verification (`aside-jun`, CLI repl on the signed-in profile) +## Browser verification (aside CLI repl on the signed-in profile) The dashboard is loopback and needs no login, so `aside repl` is the right surface: one invocation is one session, it throws on a bad path instead of skipping, and the screenshots land as real files. A whole inspect-act-verify flow must fit in a single invocation because bindings do not persist between calls. -Shots to capture into `devlog/_plan/260904_provider_quota_refresh/assets/`: +The planned capture set covered the Usage quota display, the Usage and Accounts +refresh controls, and the post-click success state. The completed record in +`031_live_verification_record.md` preserves the observed outcomes. -| File | Content | -|------|---------| -| `010_meta_usage_quota.png` | meta-muse → Usage tab with both windows and the observation age | -| `020_usage_refresh_button.png` | the Usage rate-limits header with its refresh control | -| `030_accounts_refresh_button.png` | the Accounts tab refresh control for an OAuth provider | -| `040_refresh_result.png` | the post-click success status | +Those live captures were subsequently removed from the current tree under one +retention rule for both surfaces: account and usage screenshots from a real +operator profile are unnecessary once the behavioral evidence is recorded in +text. The planned filename list is also retired; it differed from the delivered +filenames and must not be treated as an asset inventory. -Aside writes under `~/.aside/u/0/`; Codex copies the files into the repository. Every -`aside` invocation runs under `perl -e 'alarm shift; exec @ARGV' 300` because macOS -has no `timeout` and the bare spelling exits 127 without ever starting the run. +The plan required each `aside` invocation to run under +`perl -e 'alarm shift; exec @ARGV' 300` because macOS has no `timeout` and the bare +spelling exits 127 without starting the run. ## Push and PR diff --git a/devlog/_plan/260904_provider_quota_refresh/031_live_verification_record.md b/devlog/_plan/260904_provider_quota_refresh/031_live_verification_record.md index cda6fdbe9e..3dfbafb318 100644 --- a/devlog/_plan/260904_provider_quota_refresh/031_live_verification_record.md +++ b/devlog/_plan/260904_provider_quota_refresh/031_live_verification_record.md @@ -1,20 +1,14 @@ # Live verification record — 2026-09-04 Both defects were reproduced and then confirmed fixed against a running proxy serving the -built GUI. Screenshots in `assets/`. +built GUI. The observations below preserve that historical verification. ## Isolation -The user's own proxy runs on port 10100 from -`/Users/jun/Developer/new/700_projects/opencodex` under launchd — a different checkout -from this worktree, so restarting it would NOT have loaded this change, and repointing it -is out of bounds. Verification therefore ran on a scratch instance: - -- `OPENCODEX_HOME` = a `mktemp -d` directory holding only `config.json` (three providers), - `auth.json`, and `provider-account-quota-cache.json` copied from the real home. -- port 10399, started with `bun run src/cli/index.ts start --port 10399` from this worktree. -- Port 10100 was confirmed untouched afterwards: same pid 73184, uptime still climbing. -- The scratch home was moved to Trash when finished. +Verification ran against an isolated scratch instance. The existing proxy used a +separate checkout and was left untouched; its process identity and increasing +uptime were confirmed afterward. The scratch home was moved to Trash when +verification finished. ## Wire evidence @@ -46,17 +40,16 @@ The refresh control was exercised, not merely rendered: - Usage tab: clicking `Refresh quotas` produced `status: "Quotas refreshed"` and the age line re-derived from `5h ago` to `6h ago` — the read really happened. -- Accounts tab (anthropic, three pooled accounts): the control appears beside +- Accounts tab (pooled OAuth provider): the control appears beside `Add account` and reported `Quotas refreshed` after a real forced read. -## Assets +## Capture retention -| File | Content | -|---|---| -| `010_meta_usage_quota.png` | Muse Code → Usage with both windows and the refresh control | -| `020_usage_refresh_result.png` | the same tab after a click, showing the success status | -| `030_accounts_refresh_button.png` | Accounts tab control for a pooled OAuth provider | -| `040_accounts_refresh_result.png` | Accounts tab after a click | +The Accounts and Usage captures were subsequently removed from the current tree. +Both came from a real operator profile; retaining either surface is unnecessary +for the behavioral evidence above. This applies the same retention rule to both +surfaces without claiming that the Usage captures were independently cleared of +personal information. Git history is unchanged. ## CI (PR #3448, head 232afdd97) diff --git a/devlog/_plan/260904_provider_quota_refresh/assets/010_meta_usage_quota.png b/devlog/_plan/260904_provider_quota_refresh/assets/010_meta_usage_quota.png deleted file mode 100644 index f36ca63dd7..0000000000 Binary files a/devlog/_plan/260904_provider_quota_refresh/assets/010_meta_usage_quota.png and /dev/null differ diff --git a/devlog/_plan/260904_provider_quota_refresh/assets/020_usage_refresh_result.png b/devlog/_plan/260904_provider_quota_refresh/assets/020_usage_refresh_result.png deleted file mode 100644 index 944b12533e..0000000000 Binary files a/devlog/_plan/260904_provider_quota_refresh/assets/020_usage_refresh_result.png and /dev/null differ diff --git a/devlog/_plan/260904_provider_quota_refresh/assets/030_accounts_refresh_button.png b/devlog/_plan/260904_provider_quota_refresh/assets/030_accounts_refresh_button.png deleted file mode 100644 index 986fa60ad6..0000000000 Binary files a/devlog/_plan/260904_provider_quota_refresh/assets/030_accounts_refresh_button.png and /dev/null differ diff --git a/devlog/_plan/260904_provider_quota_refresh/assets/040_accounts_refresh_result.png b/devlog/_plan/260904_provider_quota_refresh/assets/040_accounts_refresh_result.png deleted file mode 100644 index ef6c83693f..0000000000 Binary files a/devlog/_plan/260904_provider_quota_refresh/assets/040_accounts_refresh_result.png and /dev/null differ diff --git a/devlog/_plan/260907_release_note_prefix/010_implementation.md b/devlog/_plan/260907_release_note_prefix/010_implementation.md new file mode 100644 index 0000000000..ebc3fbf229 --- /dev/null +++ b/devlog/_plan/260907_release_note_prefix/010_implementation.md @@ -0,0 +1,25 @@ +# Issue #3895: implementation plan + +Satisfy-spec work, triggered by issue #3895 and the request to implement separate draft PRs. Goal: remove the exact leading enforcement marker from release summaries and full changelogs. Non-goals: changing workflow enforcement, publishing a release, modifying historical releases, or generic bracket stripping. Stop after verified draft PR; report unresolved gates. Escalate if renderer changes require workflow/security-policy changes. This file records plan and evidence. + +Class C2: pure formatting behavior, without modifying release authorization or execution. Independent branch from 522ce5f8c. + +File map: +- MODIFY scripts/release-notes.ts: introduce a private exact-prefix normalization helper next to cleanPrTitle. Trim whitespace, remove one leading "[WRONG BRANCH] " marker, retain the rest. Call it before conventional-prefix parsing and for full-changelog titles. Preserve conventional prefixes and author/PR attribution in changelog entries. +- MODIFY tests/ci-workflows/release-notes.test.ts: helper expected scope/casing; complete renderer on generated and carried notes; same-scope grouping; preservation of unrelated bracket tags, nonleading marker, author and PR references. Assert both category and Changelog output. +- MODIFY structure/06_docs-and-release.md: record known-marker handling and preservation of original conventional titles in full changelogs. + +Verification: release-notes tests directly import changed helpers; typecheck covers src only and is not represented as script type checking; full prepush is required by scripts/AGENTS.md; privacy scan. Baseline on unchanged code: 71 passed. Regression expectations come from the published issue, not from cleanPrTitle itself. + +Audit: cleaning only cleanPrTitle was rejected because changelog emits the raw title. General bracket normalization would remove meaningful content. Private helper is shared by exactly two consumers and adds no runtime dependency. Explicit maintainer review for release-related changes remains pending at draft handoff. + +## Verification before draft publication + +- `bun install --frozen-lockfile`: passed; lockfile unchanged. +- Before the production change, four new assertions failed for marker leakage: helper cleanup, delta renderer, carried renderer, and same-scope grouping. Existing baseline: 71 passed. +- `bun test tests/ci-workflows/release-notes.test.ts`: 81 passed, 0 failed after expanding preservation cases. +- `bun run typecheck`: passed during prepush. +- `bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --skipLibCheck --types bun scripts/release-notes.ts`: passed; this explicitly covers the script outside the root tsconfig. +- `bun run privacy:scan`: passed. +- `bun run prepush`: not green. The parallel test lane exceeded its repository-defined 900-second deadline and exited 124; later lanes/stages did not run. Eleven failures were emitted before termination: six timeout cases across combo management, Claude messages, loopback injection, integration restore and Responses overflow; one Claude compatibility assertion failure; four Aside file-symlink EPERM cases. The full suite is incomplete, and no successful full-suite count is claimed. These files are outside the renderer change; causes other than the explicit symlink errors remain unverified. Raw local evidence is in ignored `.tmp/prepush.log`. +- Focused independent review found no concrete production blocker; it was limited and did not replace maintainer security review or complete-suite verification. Linux and macOS were not run locally. diff --git a/devlog/_plan/260907_router_selection_capture/010_implementation.md b/devlog/_plan/260907_router_selection_capture/010_implementation.md new file mode 100644 index 0000000000..9ec8b3422f --- /dev/null +++ b/devlog/_plan/260907_router_selection_capture/010_implementation.md @@ -0,0 +1,32 @@ +# Issue #3894: implementation plan + +Satisfy-spec work, triggered by issue #3894 and the request to implement separate draft PRs. Goal: remove the direct router/selection mutation dependency. Non-goals: changing key resolution/failover or eliminating all transitive router cycles. Stop after verified draft PR; report unresolved gates. Escalate if extraction requires behavioral changes. This file records plan and evidence. + +Class C2: one pure helper extracted in the existing provider module convention. Independent branch from 522ce5f8c. + +Current map: router imports api-key-selection for capture; api-key-selection imports router for route resolution. Existing direct helper callers: router and matchesSelection. No package exports change. +Chosen map: both modules import api-key-selection-capture; the old api-key-selection export forwards the same function. New leaf uses only existing OcxProviderConfig and ProviderApiKeySelection type imports. +Rejected alternative: extracting routedProviderConfig would move broad routing dependencies. Other existing transitive cycles stay outside scope. + +File map: +- NEW src/providers/api-key-selection-capture.ts: the existing function body unchanged, plus the two type-only imports. +- MODIFY src/providers/api-key-selection.ts: replace local implementation with named import and compatibility re-export. +- MODIFY src/router.ts: change capture import to the leaf. +- NEW tests/providers/api-key-selection-capture.test.ts: selected/unmatched/missing/duplicate pool cases, immutable snapshot, old-export identity, and Bun-parsed runtime import boundary for leaf and router. Parse actual source, exclude erased type imports; no whole-router acyclicity assertion. +- MODIFY scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json: register the new test using existing providers domain entries. +- MODIFY structure/01_runtime.md: document the helper ownership and preserved stateful selection direction. + +Verification: helper tests; key-failover, provider-key-store and core-lab-boundary tests; test-layout guards; typecheck; privacy scan. Baseline focused run on unchanged code: 49 passed. Conditional test cases have concrete provider objects; boundary regression returns offending imports rather than scanning prose. + +Audit: extraction is a functional dependency with no mutable globals and no changed auth behavior. Old export is preserved. A boundary test targets this exact scope; broad graph cycles are not called fixed. + +## Verification before draft publication + +- `bun install --frozen-lockfile`: passed; lockfile unchanged. +- Baseline key-failover/provider-key-store/Lab-boundary run: 49 passed. +- Restoring the old router import made the new boundary regression fail; restoring the extraction returned it to green. +- `bun test tests/providers/api-key-selection-capture.test.ts tests/adapters/key-failover.test.ts tests/providers/provider-key-store.test.ts tests/lab/core-lab-boundary.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts`: 73 passed, 0 failed. +- `bun run typecheck`: passed. +- `bun run privacy:scan`: passed. +- Review scope covers the 10-line pure helper, two consumers, compatibility re-export, 7 new tests, two layout entries, and the runtime ownership row. No other router cycle is claimed resolved. +- Whole-suite/maintainer approval is not attested; this is a draft handoff. diff --git a/devlog/_plan/260908_a_stack_responses_compat/000_plan.md b/devlog/_plan/260908_a_stack_responses_compat/000_plan.md new file mode 100644 index 0000000000..3a22b2d98f --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/000_plan.md @@ -0,0 +1,103 @@ +# 000 — a_stack_responses_compat: Plan + +## Objective + +Land four Responses-compatibility changes on `dev` as one manual dependent branch +chain whose tip carries all of them, so a single CI run certifies the whole set. +Three layers carry existing contributor pull requests; one is new work for an +issue that has no pull request. + +| Layer | Source | Author to preserve | Subject | +|---|---|---|---| +| 1 | PR #3906, commit `11c498b6c` | MohamadSabree8 | Muse Spark Contributor Free tiers keep unsupported `web_search` fields | +| 2 | PR #3886, commit `83c1d9b12` | cb8010d6 | Spark streams end `adapter_eof` when the Responses Lite header is present | +| 3 | Issue #3922, new work | — | Claude optional tool parameters become strict on Responses routes | +| 4 | PR #3917, commit `2430724e5` | mashfromband | Routed destinations reject Codex `agent_message` with 422 | + +Evidence base: four read-only `gpt-6-astra` explorer lanes read the current tree at +`2abf071e0` and returned quoted `path:line` anchors, reproduced in each phase doc. + +## Loop-spec + +- Loop archetype: satisfy-spec. Each layer has a stated correct behavior; there is + no metric to optimize. +- Trigger: maintainer request to execute workstream A as a stack. +- Goal: the tip merged into `dev`, children settled with authors preserved, linked + issues closed. +- Non-goals: registry `modelWireDefaults` for the `-free` ids; setting the Lite + header to `"false"` instead of removing it; PR #3838's tool-promotion, + `customToolWireName` export and `statelessResponses` work; any other open PR; + `main`/`preview` promotion. +- Verifier: the single Cross-platform CI run on the tip pull request's head SHA. + It runs the repository's own workflow over the cumulative tree, so it observes + every file changed by all four layers. +- Write scope: `src/adapters/openai-responses.ts`, `src/adapters/opencode-go.ts` -> + `src/adapters/routed-agent-messages.ts`, `src/claude/inbound-content-options.ts`, + their regressions, the two test-layout registries, + `docs-site/src/content/docs/reference/adapters.md`, + `docs-site/src/content/docs/reference/configuration/providers.md`, and this unit. +- Budget: no local suite runs at all (instructed). Wall-clock bound is the CI run + plus merge; a red tip after one bounded repair attempt is BLOCKED, not DONE. +- Stop condition: the stack landed on `dev`, proven for the merge method actually + used (see 050): original-tip ancestry for a merge commit, or landed-commit + ancestry plus per-path content equality for squash and rebase. +- Memory artifact: this unit, plus the goalplan at + `.codexclaw/goalplans/deliver-opencodex-workstream-a-responses-compati/`. +- Escalation: a finding that changes a carried author's intended behavior, or a + provider that rejects an explicit `strict: false`, returns to the maintainer. + +## Constraints (from the requesting maintainer) + +- No local product suite, typecheck, build or install runs in this session. Every + such check is recorded `NOT RUN`. +- Every push uses `--no-verify`. +- CI triggers on the stack tip only. When that one run is green, the tip merges + into `dev`; the remaining pull requests are then settled and the issues closed. +- Carried work keeps its original author through a `Co-authored-by` trailer. + +## Why tip-only CI is achievable + +`.github/workflows/ci.yml` declares `pull_request: {}` with no base filter, and +`push: branches: [main, preview, dev]`. Pushing `codex/a-stack-l1..l3` starts no +workflow: those refs are not integration branches and no pull request points at +them. Opening exactly one pull request, for layer 4 against `dev`, produces +exactly one Cross-platform CI run whose head contains all four layers. + +## Base and chain + +Base: `origin/dev` = `942c028735d39b2ad410b1baa95670984e16576d`. + +``` +codex/a-stack-l4-routed-agentmsg (tip, the only pull request) -> base dev +codex/a-stack-l3-claude-strict +codex/a-stack-l2-spark-lite +codex/a-stack-l1-muse-free +origin/dev 942c02873 +``` + +An ordinary dependent branch chain. GitHub native stacks are not used and were +not requested. + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp0 | 000 | This roadmap (docs only) | — | +| wp1 | 010 | Layer 1, carry #3906 | wp0 | +| wp2 | 020 | Layer 2, carry #3886 | wp1 | +| wp3 | 030 | Layer 3, implement #3922 | wp2 | +| wp4 | 040 | Layer 4, carry #3917 | wp3 | +| wp5 | 050 | Publish, one CI run, merge, settle | wp4 | + +Ordering follows textual adjacency in `src/adapters/openai-responses.ts`, which +layers 1, 2 and 4 all touch at distinct hunks (≈2125, ≈2503, ≈2366 and its import). +Layer 3 touches `src/claude/inbound-content-options.ts` only. + +## Terminal outcomes + +- DONE — the stack landed on `dev` with the merge-method-specific proof recorded, + children settled with authors preserved, issues #3885/#3922/#3911 closed. +- BLOCKED — CI red on the tip after a bounded repair attempt, or an unmet merge + requirement. +- NEEDS_HUMAN — an audit finding that would change a carried author's intended + behavior beyond what the issue asks. diff --git a/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md b/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md new file mode 100644 index 0000000000..85f9e11d3d --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md @@ -0,0 +1,62 @@ +# 010 — Phase 1: carry PR #3906 (Muse Spark Contributor Free web_search strip) + +Branch `codex/a-stack-l1-muse-free`, based on `origin/dev` `942c02873`. +Carried commit `11c498b6c62ae9f7c5b0d25ca24fc6612f607a5c` by MohamadSabree8. + +## Problem + +`stripMuseSparkUnsupportedWebSearchFields` removes `search_content_types` and +`indexed_web_access` from a Codex `web_search` tool before it reaches the Zen +Responses wire, because that gateway 400s on them. The model guard only lists the +two paid contributor ids, so the two Contributor Free ids ride the same wire and +same gateway contract but keep the rejected fields. + +## MODIFY map + +`src/adapters/openai-responses.ts` — the constant at 2125-2128. + +Before: + +```ts +const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ + "muse-spark-1.3-contributor", + "muse-spark-1.2-contributor", +]); +``` + +After: + +```ts +const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ + "muse-spark-1.3-contributor", + "muse-spark-1.3-contributor-free", + "muse-spark-1.2-contributor", + "muse-spark-1.2-contributor-free", +]); +``` + +Nothing else changes. The consumer at 2148, its model guard at 2155 +(`if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body;`), +the destination guard at 2159-2164 and the call site at 2451 are untouched. + +## TESTS + +`tests/providers/muse-spark-web-search-compat.test.ts` — add free-tier cases that +mirror the paid-tier assertions already in the file: + +- top-level `tools`: type stays `web_search`, `search_context_size` preserved, + `search_content_types` and `indexed_web_access` absent (mirrors 81-87, 131-137); +- nested `input[].additional_tools.tools`: same removal (mirrors 106-114, 150-158); +- `web_search_preview` untouched for the free ids (mirrors 90-97, 140-147). + +## Known limit (recorded, not fixed here) + +`src/providers/registry.ts:1685-1690` maps only the paid ids in +`modelWireDefaults`, so the `-free` ids do not select the Responses wire +automatically; this fix applies when that wire is chosen explicitly. Changing the +registry is out of scope, matching the carried pull request. + +## Verification (C) + +No local command. The layer is verified by the single tip CI run described in 050. +Local suites: NOT RUN by instruction. diff --git a/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md b/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md new file mode 100644 index 0000000000..13b4cc949c --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md @@ -0,0 +1,73 @@ +# 020 — Phase 2: carry PR #3886 (disable Responses Lite transport for Spark) + +Branch `codex/a-stack-l2-spark-lite`, based on layer 1. +Carried commit `83c1d9b129b80d4f65a797fd61a2026deb8c8123` by cb8010d6. + +## Problem + +Issue #3885: with `x-openai-internal-codex-responses-lite: true`, the canonical +backend opens a `gpt-5.3-codex-spark` SSE response and closes it before a terminal +event, which the adapter reports as `response.incomplete` / `adapter_eof`. The same +request without that header completes. + +## MODIFY map + +`src/adapters/openai-responses.ts` — inside the canonical-forward block, before +the existing routing-hint work. Line numbers here are against the pinned base +`942c02873` (block at 2503-2513); layer 1 adds two lines above it, so on this +branch the block sits at 2505-2515. + +After: + +```ts + if (isCanonicalOpenAiForwardProvider(provider)) { + // Spark closes Responses Lite streams before a terminal completion. Select compatibility + // from the final wire model so aliases cannot leave the caller or a static header enabled. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; + } + } + const routingHeaders = new Headers(headers); + applyCodexRoutingHint(routingHeaders, finalBody); +``` + +`finalBody` is computed at 2494-2502 and serialized at 2523 on the pinned base +(2496-2504 and 2525 on this branch), so it is the actual wire model. +`parsed.modelId` can differ; the existing test at 187-188 pins that distinction +deliberately. Keying on `finalBody.model` therefore also covers aliases. The loop +removes every case spelling, which matters because static provider headers merge +in at 2315 and 2353 on the pinned base (2317 and 2355 here) with arbitrary casing. + +## Scope of the fix, and what it does not cover (audit finding 1) + +The independent audit established a real boundary, verified against source: + +- **Covered.** Removing the header fixes the reported defect on the HTTP header + replay path, including the HTTP fallback: `prepareCodexHttpInit()` recomputes + only the routing hint (`src/server/responses/codex-ws-request.ts:46-52`) and + `httpInit` carries the header-deleted request forward (`:68`). +- **Not covered.** On the WebSocket path, `codex-ws-request.ts:30-33` writes + `client_metadata[CODEX_RESPONSES_LITE_METADATA_KEY]` only when the header is + present and reads `"true"`/`"false"`. Deleting the header leaves any + pre-existing `client_metadata` Lite value in the body untouched, and that value + reaches the frame at `:71` and the pool reuse key at + `codex-ws-pool.ts:53-55`. + +Setting the header to `"false"` instead of deleting it would also cover the WS +case, but that expands the carried author's diff beyond issue #3885 and changes +socket reuse identity. This phase carries the author's delete-only form and +records the WS metadata case as unresolved rather than silently expanding scope. +The tip pull request states this limit explicitly so the residual is visible. + +## TESTS + +`tests/codex-integration/codex-metadata-integrity.test.ts`, beside the mixed-case +test at 171: Spark wire model with a caller-provided Lite header, with a +mixed-case static header, and with `parsed.modelId` set to an alias while the +serialized model is Spark; `gpt-5.6-sol` keeps the header. Existing guards at 185, +211, 225 and 267 stay intact. + +## Verification (C) + +No local command. Verified by the single tip CI run in 050. Local suites: NOT RUN. diff --git a/devlog/_plan/260908_a_stack_responses_compat/030_phase3.md b/devlog/_plan/260908_a_stack_responses_compat/030_phase3.md new file mode 100644 index 0000000000..c0e6a00b3f --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/030_phase3.md @@ -0,0 +1,141 @@ +# 030 — Phase 3: fix issue #3922 (Claude tool strict default on Responses routes) + +Branch `codex/a-stack-l3-claude-strict`, based on layer 2. New work; no existing +pull request. This is the only layer authored here. + +## Problem + +When Claude Code sends a custom tool without an explicit `strict`, the +Messages -> Responses translation emits a function tool that also omits `strict`. +The Responses API treats an omitted `strict` as an attempt to normalize the schema +into strict mode, so parameters that the Anthropic `input_schema` marks optional +are treated as required upstream, and a tool call that omits them fails. Anthropic +requires an explicit `strict: true` to opt in, so the two defaults disagree. + +## MODIFY map + +`src/claude/inbound-content-options.ts` — `toolsToResponses`, the function-tool +branch at 26-33. + +Before: + +```ts + if (typeof raw.name === "string" && raw.name.length > 0 && isRec(raw.input_schema)) { + out.push({ + type: "function", + name: raw.name, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + parameters: raw.input_schema as Record, + }); + continue; + } +``` + +After: + +```ts + if (typeof raw.name === "string" && raw.name.length > 0 && isRec(raw.input_schema)) { + out.push({ + type: "function", + name: raw.name, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + parameters: raw.input_schema as Record, + // Anthropic opts into strict tool use explicitly, while Responses normalizes + // an omitted strict into strict mode. Carry the source intent rather than the + // destination default, so an optional input_schema parameter stays optional. + strict: typeof raw.strict === "boolean" ? raw.strict : false, + }); + continue; + } +``` + +The value is derived from the source tool rather than hardcoded, so an explicit +`strict: true` or `strict: false` from the client survives translation, which is +what issue #3922 asks for. + +## How the existing strict-tool admission policy relates (audit finding 2) + +An earlier draft of this document described `src/claude/compatibility.ts:102` +backwards. What that line actually does: + +```ts +if (tool.strict === true) codes.add("strict_tools"); +``` + +`strict_tools` is listed as an incompatible feature at `compatibility.ts:16`, and +`analyzeClaudeCompatibility` (`compatibility.ts:179`) returns `"reject"` for an +incompatible request under enforce mode (decision expression at `:189`), which +`src/server/claude-messages.ts:733-740` applies **before** +translation. So an explicit `strict: true` is already refused in enforce mode and +only reaches translation on the default and shadow paths. + +This phase does not change that policy. Detection reads the **source** Anthropic +tool before translation, so emitting a default `strict: false` downstream adds no +new rejection; `tests/claude-integration/claude-compatibility.test.ts:73-78` +already covers the explicit-false allow case, and `:24` lists strict tools among +rejected features. + +## Propagation (independently traced twice, no further change needed) + +- `src/claude/inbound-content-options.ts:26-32` builds the tool. +- `src/claude/inbound.ts:350-351` assigns it to `body.tools`. +- `src/server/claude-messages.ts:875,897` serializes that body into the internal + Responses request; `src/server/responses/core.ts:3160` parses it. +- `src/responses/schema.ts:114` accepts `strict`; `parser-tools.ts:63` preserves an + explicit value including `false`. +- `src/responses/parser.ts:570` keeps `_rawBody`, and + `src/adapters/openai-responses.ts:2362-2364` starts from it. Canonical-field + stripping only removes `external_web_access`/`defer_loading` (`:189-200`) and + schema normalization spreads the tool (`{ ...tool, parameters }`, `:648-658`), + so `strict` reaches `JSON.stringify(finalBody)` at `:2523`. + +## Boundaries + +- Hosted `web_search` leaves the function at 22-24, before this branch, so it gains + no `strict` field. +- Native Anthropic passthrough never reaches translation: + `src/server/claude-messages.ts:721-722` returns from `anthropicNativePassthrough` + before the translation call at 757. +- Other Anthropic server tools still drop at 35. + +## Schema promise, stated precisely (audit finding 4) + +At translation, `parameters` is the caller's `input_schema` reference +(`inbound-content-options.ts:31`), so `properties`, `required` and nested schemas +are unchanged there. That is not a promise of a byte-identical schema on every +outbound route: `openai-responses.ts:651` runs `normalizeXaiToolParameters`, `:657` +supplies a root `type: "object"` when absent, `responses-code-mode.ts:23-27` can +rewrite an `exec` parameter description, and Azure Chat sanitizes at +`openai-chat.ts:1359-1361`. The regression asserts an unchanged schema through the +ordinary OpenAI Responses route. + +Adding the field also shifts fallback cache-cohort hashes, because translated tool +definitions participate in the hash at `src/claude/inbound.ts:386-392`. That is a +cohort change, not a correctness change. + +## Known risk + +The same translated tools feed translated Chat Completions routes, where +`openai-chat.ts:1343` forwards an explicit `strict`; Azure deletes it at `:1364`, +and `tests/providers/azure-model-router-tool-schema.test.ts:42` already pins that +absence. No repository-declared rejection of an explicit `strict: false` was found, +but universal upstream acceptance is not proven. A provider rejecting it is an +escalation. + +## TESTS + +`tests/claude-integration/claude-inbound.test.ts`: + +- **Update the existing assertion at 80-83.** It is an exact `toEqual` on the + translated `Read` tool and will fail once `strict` is present; the expected + object gains `strict: false`. +- Keep `expect(tools[1]).toEqual({ type: "web_search" })` at 84 unchanged. +- New cases: omitted `strict` -> `false`; explicit `false` -> preserved; explicit + `true` -> preserved; an `input_schema` with one required and one optional + property keeps its `required` array through `parseRequest`. +- Assert the three values on the **serialized adapter output**, not only the + translator return, so the wire body is what is pinned. + +## Verification (C) + +No local command. Verified by the single tip CI run in 050. Local suites: NOT RUN. diff --git a/devlog/_plan/260908_a_stack_responses_compat/040_phase4.md b/devlog/_plan/260908_a_stack_responses_compat/040_phase4.md new file mode 100644 index 0000000000..b5a0f94be3 --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/040_phase4.md @@ -0,0 +1,121 @@ +# 040 — Phase 4: carry PR #3917 (routed agent_message conversion), stack tip + +Branch `codex/a-stack-l4-routed-agentmsg`, based on layer 3. This branch is the +stack tip and the only one with a pull request. +Carried commit `2430724e57e0950bde4b006c0175a2d5c70a0baf` by mashfromband. + +## Problem + +Codex writes every sub-agent reply into the rollout as an `agent_message` input +item, which is private to the ChatGPT Codex schema, so it is replayed in the input +of every later turn of that thread. The routed Responses destinations reported in +#3911 and #3907 reject the whole body with +`422 unknown item type "agent_message"`. 422 is a client error, so nothing fails +over and the thread stays broken. The plaintext conversion already existed but was +scoped to the OpenCode Go destination, and nothing in those reports is specific to +that destination. + +## MODIFY / RENAME map + +1. `src/adapters/opencode-go.ts` -> `src/adapters/routed-agent-messages.ts`. + `isOpenCodeGo` is deleted; its only production consumer is the call site below. + `normalizeOpenCodeGoAgentMessages` becomes `normalizeRoutedAgentMessages` with + the algorithm unchanged, including the fail-closed check that every content part + is `input_text`/`input_image`/`input_file`. + +2. `src/adapters/openai-responses.ts:1` and `:2366`. + + Before: + + ```ts + import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; + ... + if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); + ``` + + After: + + ```ts + import { normalizeRoutedAgentMessages } from "./routed-agent-messages"; + ... + if (!forward) outBody = normalizeRoutedAgentMessages(outBody); + ``` + + `forward` is `provider.authMode === "forward"` (2356). All forward destinations + retain the existing behavior and keep the item unchanged. + +3. `tests/providers/opencode-go-agent-messages.test.ts` -> + `tests/adapters/routed-agent-messages.test.ts`, with the two Go-specific + expectations (54, 120-122) changed from `agent_message` to the converted + `message`/`user` shape for arbitrary routed URLs. + +4. `tests/responses/responses-opaque-blob-recovery.test.ts` — the four assertions + at 553, 583, 611 and 754. That fixture is `authMode: "key"` (163-164), so its + retried item is now converted. Opaque-blob recovery repairs an undecryptable part + into `[encrypted content omitted]`, which leaves the item fully plaintext; on a + routed retry it is converted too, which is what lets the retry be accepted. + Expected object becomes: + + ```ts + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: 'Agent message {"author":"/root/child_task","recipient":"/root"}' }, + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "input_text", text: "[encrypted content omitted]" }, + ], + } + ``` + + The `authMode: "forward"` case in the same file is untouched. + +5. Both test-layout registries, because the test basename and directory change: + `scripts/test-layout/layout.json:917` and + `tests/fixtures/test-layout-expected.json:752` drop + `"opencode-go-agent-messages.test.ts": "providers"` and gain + `"routed-agent-messages.test.ts": "adapters"`. + `tests/test-layout-tooling.test.ts:250` compares the two tables exactly, so + missing either one fails. + +6. `docs-site/src/content/docs/reference/adapters.md` and + `docs-site/src/content/docs/reference/configuration/providers.md`, whose wording + describes the conversion as Go-specific, as in the carried pull request. The + carried text's universal "any routed destination" phrasing is narrowed to the + observed non-forward destinations rather than copied unchanged. + +## Preserved behavior + +Forward destinations, ciphertext and unknown part types (the `every` guard), +replay immutability and no-op reference identity, the identity prefix text, and +the session-header assertion at 55 of the moved test. + +## PR #3838 boundary + +#3838 stays open and independent. Its `normalizeOpenCodeGoAdditionalTools` +promotion, `customToolWireName` export and `statelessResponses` registry flag are +unrelated to this conversion. Its mixed-content policy drops ciphertext and unknown +parts whenever plaintext survives, which contradicts the fail-closed retention kept +here; it is not carried. + +## Verification (C) + +No local command. Verified by the single tip CI run in 050. Local suites: NOT RUN. + + +## Which destinations actually change (audit finding 5) + +`src/types/provider.ts:449` declares `authMode?: "key" | "forward" | "oauth" | "local"`. +Because the new gate is `!forward` and `forward` is `provider.authMode === "forward"` +(`openai-responses.ts:2356`), the conversion now applies to **key, oauth, local and +undefined** whenever this adapter is selected. Every forward destination is +unchanged, including noncanonical forward gateways; the built-in ChatGPT +destination is forward (`src/providers/registry.ts:1200-1204`), so its native items +stay intact. + +No repository-declared non-forward destination requires plaintext `agent_message` +preservation. Authentication mode alone cannot prove what an arbitrary custom +upstream accepts, so the carried claim that *every* routed destination rejects the +item is stated here as the observed pattern rather than a proven universal. +Regression coverage adds a non-forward mode beyond the carried key/forward +fixtures. diff --git a/devlog/_plan/260908_a_stack_responses_compat/050_phase5.md b/devlog/_plan/260908_a_stack_responses_compat/050_phase5.md new file mode 100644 index 0000000000..97b928d92c --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/050_phase5.md @@ -0,0 +1,95 @@ +# 050 — Phase 5: publish, single CI run, merge, settle + +## Publication + +Push all four branches with `--no-verify`, in chain order: + +``` +git push --no-verify origin codex/a-stack-l1-muse-free +git push --no-verify origin codex/a-stack-l2-spark-lite +git push --no-verify origin codex/a-stack-l3-claude-strict +git push --no-verify origin codex/a-stack-l4-routed-agentmsg +``` + +Pushing l1-l3 starts no workflow: `.github/workflows/ci.yml` limits its `push` +trigger to `[main, preview, dev]`, and no pull request points at those refs. + +## The single pull request + +Open exactly one pull request: `codex/a-stack-l4-routed-agentmsg` -> `dev`. Its +head contains all four layers, so the one Cross-platform CI run it starts is +cumulative evidence for the whole stack. The description follows +`.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, Checklist), states that +local suites were NOT RUN by maintainer instruction with CI as the verification +gate, and names every carried pull request and issue. + +Author preservation: each carried commit keeps its original author through a +`Co-authored-by` trailer that survives a squash, satisfying +`missing_coauthor_credit` in `.github/scripts/pr-carry-attribution.cjs`: + +- `Co-authored-by: MohamadSabree8 ` +- `Co-authored-by: R <53855466+cb8010d6@users.noreply.github.com>` +- `Co-authored-by: mashfromband ` + +## Merge gate + +Merge only when the tip's Cross-platform CI is green on the exact head SHA of the +pull request. Skipped, cancelled or queued jobs are not passing evidence, and a +green run on an earlier head does not certify a newer one. After merging, fetch +`origin/dev` and prove landing in the way the chosen merge method allows. + +The method decides the proof, and only one of the three preserves the tip SHA: + +- **Create a merge commit.** The tip SHA itself becomes reachable from `dev`, so + `git merge-base --is-ancestor origin/dev` exits 0 and is sufficient. +- **Squash and merge.** GitHub writes one new commit, so the tip SHA never becomes + an ancestor and that check would fail on a successful merge. +- **Rebase and merge.** GitHub replays the commits onto `dev` with new SHAs, so the + original tip SHA is likewise not an ancestor. + +For the two rewriting methods, identify the landed commit or commits on `dev` +first, then prove landing by content and attribution rather than by the original +SHA: + +- `git diff origin/dev -- ` is empty; +- `git merge-base --is-ancestor origin/dev` exits 0 for the landed + commit; +- the landed record carries all three `Co-authored-by` trailers. + +Record the method used together with its matching proof, rather than asserting +ancestry of the original tip generically. + +## Settlement + +Once the change is confirmed on `dev`: + +- PRs #3906, #3886 and #3917 — comment that the work landed on `dev` through the + stack tip, name the merge commit, and close them. Their authors are already + preserved in the trailers. +- Issues #3885 (Spark `adapter_eof`), #3922 (Claude tool strict) and #3911 + (routed `agent_message` 422) — close, since PRs target `dev` and GitHub only + auto-closes on merges into the default branch. +- PR #3838 stays open; its residual work is unrelated to this stack. + +## Verification (C) + +The tip CI run identified by its run id and head SHA, with every required job +reporting success, plus the merge-method-specific landing proof above against a +freshly fetched `origin/dev`. Local suites: NOT RUN by maintainer instruction. + + +## Merge readiness is broader than one workflow (audit finding 7) + +The single tip CI run is the verification evidence this session produces, but it is +not by itself the whole merge gate. `MAINTAINERS.md:57-69` also requires the +applicable required checks, resolution of outstanding maintainer objections, and +applicable security review. The `dev`-only maintainer-integration path still +records the decision and the exact-head evidence. Anything in that set that this +session cannot satisfy is reported rather than assumed. + +## Attribution is added, not inherited + +The three carried commits do **not** already contain `Co-authored-by` trailers in +their original messages. The trailers listed above are added when the commits are +carried onto the stack, and their presence is verified on the final squash-surviving +record before the children are closed. diff --git a/devlog/_plan/260908_d_group_test_infra_stack/000_plan.md b/devlog/_plan/260908_d_group_test_infra_stack/000_plan.md new file mode 100644 index 0000000000..35b2a65c67 --- /dev/null +++ b/devlog/_plan/260908_d_group_test_infra_stack/000_plan.md @@ -0,0 +1,113 @@ +# D-group test-infrastructure delivery as a single-CI manual stack + +## Objective + +Land the two D-group test-infrastructure items on `dev` as one dependency-ordered +branch chain whose **tip is the only pull request**, so the cumulative tree is +verified by exactly one Cross-platform CI run. Merge the tip once that run is +green, then settle the original pull requests and any linked issues. + +| Layer | Source | Content | +|---|---|---| +| 1 (bottom) | PR #3924 by @luvs01 | `scripts/test.ts` keeps captured lane output after a timeout; runner regressions; contributing note | +| 2 (tip) | PR #3930 by @luvs01 | `tests/providers/cursor/cursor-stream-health.test.ts` load-scaled watchdog budgets | + +Both source pull requests carry exactly one commit each, authored by `luvs01` +(`27862058+luvs01@users.noreply.github.com`), so `git cherry-pick -x` preserves +authorship without needing a reconstructed `Co-authored-by` trailer. The trailer is +added to the tip pull-request description anyway, because the repository squashes +and `.github/scripts/pr-carry-attribution.cjs` reads the trailer, not prose. + +## Why a stack, and why only one pull request + +`.github/workflows/ci.yml` triggers on a bare `pull_request:` with no base-branch +filter. That is deliberate — the comment in the file records that a +`branches: [main, dev]` filter once silently excluded stacked child pull requests. +The consequence for this unit is mechanical: **every open pull request starts a +Cross-platform CI run**, whatever its base. A two-pull-request stack therefore costs +two runs, and a child pull request based on the parent's head costs one more. + +The only way to get a single run covering both changes is to give the stack exactly +one pull request, at the tip, based on `dev`. The lower layer is pushed as a branch +for provenance and review navigation, and never gets a pull request of its own. +Pushing a branch does not start CI either: `ci.yml`'s `push:` trigger is pinned to +`branches: [main, preview, dev]`, and this stack pushes neither. + +The tip run covers the PR-enabled producers, not every job in the file. `changes` +sets `ci: true` for `tests/**` and `scripts/**` (`ci.yml:193-194`), which this stack +touches, so the four Linux shards, `gates`, `storage policy`, `api usage`, +`platform-macos`, `keyring` and `docker smoke` all execute. Three job families do +**not** run on a pull request and must never be reported as passing evidence: + +| Job | Guard | Status on this PR | +|---|---|---| +| `windows /6` | `github.event_name == 'workflow_dispatch' && (inputs.lane == '' \|\| inputs.lane == 'all')` (`ci.yml:742-743`) | SKIPPED BY WORKFLOW | +| `macos control` | `github.event_name == 'workflow_dispatch'` (`ci.yml:633`) | SKIPPED BY WORKFLOW | +| `npm-global ` | `needs.changes.outputs.packaging == 'true'` (`ci.yml:943`); the packaging allowlist (`ci.yml:215-229`) excludes all four files | SKIPPED BY WORKFLOW | + +That exclusion is acceptable for this unit: nothing here ships in the package tree, +and `scripts/test.ts` is the test runner rather than runtime source. The Windows +lane is dispatch-only for every ordinary pull request in this repository, so +requiring it here would be a new policy, not this unit's job. + +## Dependency order + +Layer 1 is the runner change; layer 2 is a fixture that the runner executes. Ordering +them the other way would put a test-timing change under an unverified runner. The +order is a build-order statement, not an effort estimate. + +## Work phases + +| Phase | Outcome | +|---|---| +| wp0 | This roadmap: stack shape, single-trigger proof, merge/close order, attribution | +| wp1 | Build both layers locally on fresh `origin/dev` with `cherry-pick -x` | +| wp2 | Push both branches with `--no-verify`; open exactly one pull request (tip → `dev`) | +| wp3 | Record tip CI, merge the tip, settle #3924/#3930 and linked issues | + +Diff-level detail for each phase: `010_phase1_stack_build.md`, +`020_phase2_publish.md`, `030_phase3_merge_and_settle.md`. + +## Constraints in force + +The owner set these for this unit, and they override the repository's default +verification habits: + +- **No local suite.** No `bun run test`, `bun test`, `bun run test:changed`, + `bun run typecheck`, or build used as a gate. Every such row is recorded + `NOT RUN (owner instruction)`, never as a pass. +- **Push with `--no-verify`.** Local hooks are skipped by instruction. +- **CI on the tip only.** Never open a pull request for a lower layer. +- **One green run, then merge.** The tip's exact head SHA is the product gate. +- **Preserve original authorship** for carried work. +- **Close linked issues** at the moment the change is on `dev`. + +## Verification model + +The product evidence is the hosted Cross-platform CI run on the tip's exact head +SHA — run id, head SHA, per-job conclusions — read as a job matrix, not as the +aggregate `ci` summary alone. The three dispatch-only or packaging-gated job +families above are recorded SKIPPED BY WORKFLOW. + +Merge additionally requires the current gate checks to be green on that same head: +`enforce-target` and `hygiene` (`enforce-pr-target.yml:679-692` folds deterministic +hygiene failures into its verdict; `pr-hygiene.yml:236-238` fails and labels on a +violation), plus resolution of any actionable automated review finding. + +Landing evidence is the squash SHA GitHub returns, proven to be an ancestor of +fetched `origin/dev`, with its tree compared against the reviewed tip. Local checks +are `NOT RUN` by instruction and are never reported as passing. + +A verifier honesty note, since this unit's plan names commands it will not run: +`bun run test` would observe `scripts/test.ts` and both test files, and +`bun run typecheck` would observe `scripts/test.ts`. Both are in scope for the +change and both are withheld by owner instruction, so their acceptance rows are +delegated to hosted CI rather than claimed locally. + +## Terminal outcomes + +- **DONE** — tip CI green on its exact head, tip merged into `dev`, #3924 and #3930 + settled with authorship preserved, linked issues closed, evidence recorded. +- **BLOCKED** — a required merge right is missing, or CI fails for a cause outside + these four files. +- **NEEDS_HUMAN** — a policy decision beyond restoring existing behavior. diff --git a/devlog/_plan/260908_d_group_test_infra_stack/001_audit_record.md b/devlog/_plan/260908_d_group_test_infra_stack/001_audit_record.md new file mode 100644 index 0000000000..b668981357 --- /dev/null +++ b/devlog/_plan/260908_d_group_test_infra_stack/001_audit_record.md @@ -0,0 +1,53 @@ +# Audit record — roadmap gate + +An independent reviewer (a separate context, `gpt-6-astra` at high effort) audited +the roadmap before any branch was built. Three rounds ran; the first two failed. +The findings are recorded here because they changed the plan, and because two of +them would have produced a false completion claim. + +## Round 1 — FAIL, four blocking defects + +1. **Overstated CI coverage.** The plan promised platform and packaging coverage + from the tip pull-request run. In fact `windows /6` and `macos control` are + `workflow_dispatch`-only (`ci.yml:633`, `742-743`), and `npm-global` needs + `packaging == 'true'`, which the packaging allowlist (`ci.yml:215-229`) does not + set for any of the four files. Fixed by adding an explicit RUN vs + SKIPPED BY WORKFLOW matrix and forbidding the skipped families from being + reported as passes. +2. **CI success treated as sufficient for merge.** `enforce-target` folds + deterministic hygiene failures into its verdict (`enforce-pr-target.yml:679-692`) + and `pr-hygiene` fails and labels on a violation (`pr-hygiene.yml:236-238`). + `MAINTAINERS.md:61` also requires the integration decision and exact-head + verification to be recorded. Fixed by adding those gates and the record step. +3. **Wrong ancestry object.** The plan checked whether the tip commit was an + ancestor of `dev`. A squash merge never makes the tip an ancestor, so that check + would have failed on a perfectly good landing — or worse, been waved through. + Fixed by recording the squash SHA GitHub returns and testing that. +4. **Attribution assumed rather than controlled.** The repository sets + `squash_merge_commit_message: COMMIT_MESSAGES`, so the pull-request description + is not the landed message. A description trailer satisfies the hygiene checker + and still leaves the contributor uncredited in the commit. Fixed by supplying the + squash body explicitly and verifying the landed trailer before closing anything. + +## Round 2 — FAIL, two blocking defects + +1. **Missing administrator bypass.** `Protect dev` requires an approving review and + code-owner review, so the merge call is refused without `--admin`. The plan named + the policy exception without naming the mechanism that exercises it. +2. **Bot findings mistaken for all findings.** The gate covered automated review + findings but not human ones. `MAINTAINERS.md:62-64` requires outstanding + maintainer change requests to be resolved or explicitly withdrawn. + +## Round 3 — PASS + +The reviewer set the phase-1 acceptance bar: freshly fetched base SHA, both +constructed commit SHAs, evidence that layer 1 follows the base and layer 2 follows +layer 1, both authors reading `luvs01`, both `-x` provenance lines, per-layer and +cumulative name/numstat comparisons, blob comparisons against the source pull +requests, and the roadmap commit accounted for separately so it stays out of the +four-file implementation delta. + +## Standing note + +Local suite, typecheck and build are **NOT RUN** for this unit by owner +instruction. That is a recorded absence of evidence, not a pass. diff --git a/devlog/_plan/260908_d_group_test_infra_stack/010_phase1_stack_build.md b/devlog/_plan/260908_d_group_test_infra_stack/010_phase1_stack_build.md new file mode 100644 index 0000000000..003cd0ad82 --- /dev/null +++ b/devlog/_plan/260908_d_group_test_infra_stack/010_phase1_stack_build.md @@ -0,0 +1,54 @@ +# Phase 1 — Build the stack locally + +Base: fetched `origin/dev`. Both source commits live in the `luvs01` remote +(`https://github.com/luvs01/opencodex.git`), already configured in this checkout. + +## Commands + +```sh +git fetch origin dev +git fetch luvs01 e24163231edeaa09a30a99ca1746e3b573af78ae 141077f7270e2f2a0564fb036d091f0cf793b784 + +# Layer 1 — PR #3924 +git switch -c codex/260908-d-group-l1-test-runner-output origin/dev +git cherry-pick -x e24163231edeaa09a30a99ca1746e3b573af78ae + +# Layer 2 — PR #3930, tip +git switch -c codex/260908-d-group-l2-cursor-watchdog +git cherry-pick -x 141077f7270e2f2a0564fb036d091f0cf793b784 +``` + +`cherry-pick -x` keeps the original author identity +(`luvs01 <27862058+luvs01@users.noreply.github.com`>) and appends the +`(cherry picked from commit ...)` provenance line. No `Co-authored-by` trailer is +needed on the commits themselves because authorship is not being reassigned. The +trailer goes in the tip pull-request description for hygiene acceptance; phase 3 +separately supplies and verifies the trailer on the landed squash commit, which is +the only thing GitHub reads for contributor credit. + +## Expected change map + +| Layer | File | Change | +|---|---|---| +| 1 | `scripts/test.ts` | +81 −8 — incremental capture, retained output on timeout, bounded drain, incomplete-capture exit policy | +| 1 | `tests/ci-workflows/test-runner.test.ts` | +147 −1 — regressions for timeout/failure/success output, split UTF-8, open pipes, read failure | +| 1 | `docs-site/src/content/docs/contributing.md` | +6 — documents the timeout and incomplete-capture behavior | +| 2 | `tests/providers/cursor/cursor-stream-health.test.ts` | +59 −26 — one scaled silence budget S, 2S heartbeat-only, ≥3S observed progress after first received text | + +Cumulative tip versus `origin/dev`: exactly those four files. + +## Conflict expectation + +None. The two file sets are disjoint, and the Cursor test file plus its +`tests/helpers/ci-watchdog.ts` import carry identical blob SHAs at `dev` and at +#3924's head (`dc7b572bf1` and `f8adcfe3d9`), so layer 2's preimage is unchanged by +layer 1. + +## Acceptance + +- `git log --format='%an <%ae>'` on both new commits reports `luvs01`. +- `git diff --name-only origin/dev..tip` lists exactly the four files above. +- `git diff --stat` matches the per-file counts in the table. +- Each cherry-picked tree is byte-identical to the source PR head's version of its files. + +Local suite: NOT RUN (owner instruction). diff --git a/devlog/_plan/260908_d_group_test_infra_stack/020_phase2_publish.md b/devlog/_plan/260908_d_group_test_infra_stack/020_phase2_publish.md new file mode 100644 index 0000000000..2af6acab12 --- /dev/null +++ b/devlog/_plan/260908_d_group_test_infra_stack/020_phase2_publish.md @@ -0,0 +1,92 @@ +# Phase 2 — Publish the stack, one pull request only + +## Push + +```sh +git push --no-verify origin codex/260908-d-group-l1-test-runner-output +git push --no-verify origin codex/260908-d-group-l2-cursor-watchdog +``` + +`--no-verify` is the owner's instruction for this unit. Neither push starts +Cross-platform CI: `ci.yml`'s `push:` trigger is limited to +`branches: [main, preview, dev]` (`ci.yml:26-27`). + +## Open exactly one pull request + +Tip only, targeting `dev`: + +```sh +gh pr create --repo lidge-jun/opencodex --base dev \ + --head codex/260908-d-group-l2-cursor-watchdog \ + --title "fix(test): preserve lane output after timeouts and stabilize the Cursor stream-health watchdog" \ + --body-file +``` + +The lower layer gets **no** pull request. `ci.yml` triggers on a bare +`pull_request:` with no base filter (`ci.yml:7`), so a second pull request would +start a second Cross-platform CI run; draft status does not suppress it either — +no job in `ci.yml` reads a draft condition. + +A stacked child pull request based on the layer-1 branch is also unavailable here: +`enforce-target` grants the wrong-base exemption only when the parent branch has +its own **open** pull request (`enforce-pr-target.yml:536-537`), which is exactly +what this design avoids. The tip therefore targets `dev` directly. + +## Description requirements + +`.github/PULL_REQUEST_TEMPLATE.md` requires Summary, Verification, and Checklist; +`enforce-target` rejects thin or malformed descriptions. The description must also: + +- name both source pull requests (#3924, #3930) and describe the stack layering; +- carry `Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>`. The + hygiene checker reads that trailer from the description or a commit message + (`pr-carry-attribution.cjs:190`), and the carry verbs in the description are what + make it demand one at all. The description trailer satisfies the gate; it does + **not** by itself put the trailer in the landed commit — see phase 3, where the + squash body carries it explicitly; +- state honestly that local suite, typecheck and build were **NOT RUN** by owner + instruction, and that hosted CI on this exact head is the verification gate, + naming which job families are skipped by the workflow; +- be substantive: `pr-quality.cjs` strips template boilerplate and requires real + content (two substantial sections, or 120+ characters across two blocks), so + placeholder bullets fail the gate. + +As a maintainer-authored pull request this needs no readiness checklist and no +`review-ready` label (`enforce-pr-target.yml:766-768`, `1096-1103`). Do not tick a +local-CI attestation box that was not earned — the owner forbade the local suite. + +No GUI files change, so the screenshot rule does not apply. + +## Other workflows that will fire + +Expected and unavoidable for any pull request: `enforce-target`, `pr-hygiene`, +`pr-labeler`, `react-doctor`, plus CodeRabbit. `service-lifecycle` does **not** +fire — none of the four paths is in its allowlist. These are gate/lint signals, not +the product suite; only Cross-platform CI is the product gate. + +## Outcome + +Executed 2026-09-08 against base `942c02873`. + +| Ref | SHA | Pull request | +|---|---|---| +| `codex/260908-d-group-l1-test-runner-output` | `ab06523e6` | none, by design | +| `codex/260908-d-group-l2-cursor-watchdog` (tip) | `8b81676ac` | [#3940](https://github.com/lidge-jun/opencodex/pull/3940), base `dev` | + +Both pushes used `--no-verify`. Neither started Cross-platform CI, as predicted by +the `push` branch filter. Opening #3940 started exactly one run on `8b81676ac`; the +first check-runs to appear were `changes`, `select windows runner`, `hygiene`, +`label`, `resolve-pr` and `react-doctor`, which matches the expected set. + +The layer-1 branch has zero pull requests in any state, which is the property that +keeps the stack to a single CI run. + +## Acceptance + +- Both branches exist on `origin` at the expected SHAs. +- `gh pr list --head codex/260908-d-group-l1-test-runner-output` returns empty. +- Exactly one open pull request has head `codex/260908-d-group-l2-cursor-watchdog` + and base `dev`. +- Exactly one Cross-platform CI run exists for the tip head SHA. "Exactly one" is + scoped to the pre-merge candidate: landing on `dev` starts a separate push run, + and a base refresh replaces the candidate with a new head and a new run. diff --git a/devlog/_plan/260908_d_group_test_infra_stack/030_phase3_merge_and_settle.md b/devlog/_plan/260908_d_group_test_infra_stack/030_phase3_merge_and_settle.md new file mode 100644 index 0000000000..5e6d3ece96 --- /dev/null +++ b/devlog/_plan/260908_d_group_test_infra_stack/030_phase3_merge_and_settle.md @@ -0,0 +1,106 @@ +# Phase 3 — Merge the tip, settle the stack + +## Gate + +The product gate is the Cross-platform CI run on the tip's **exact** head SHA. +Record run id, head SHA, and each producer's conclusion. A cancelled or superseded +run is not evidence, and a run on an earlier head is not evidence for the merged +head. Read the producers, not only the aggregate `ci` check. + +Expected to RUN (`ci: true` via `tests/**` and `scripts/**`): four Linux `test` +shards, `gates`, `storage policy`, `api usage`, `macos /2`, `keyring` (three OS), +`docker smoke`. + +Expected to be SKIPPED BY WORKFLOW, and recorded as such rather than as passes: +`windows /6` and `macos control` (both `workflow_dispatch`-only), and +`npm-global ` (needs `packaging == 'true'`, which these four files do not set). + +Merge also requires, on the same head: + +- `enforce-target` success and `hygiene` success; +- every actionable automated review finding resolved; +- **outstanding maintainer change requests resolved or explicitly withdrawn** + (`MAINTAINERS.md:62-64`) — read the human reviews immediately before merging, not + only the bot findings; +- a refreshed read of head, base, merge state, and the integrating actor's + repository permission immediately before merging. + +## Maintainer integration record + +`MAINTAINERS.md:59-63` permits a maintainer with `maintain` or `admin` to integrate +into `dev` without a second approval, and requires the decision and the exact-head +verification to be recorded in the pull request. Post that record as a comment +before merging: the integrating maintainer, the exact head SHA, the CI run link, +the job matrix including the skipped families, and the statement that local suite, +typecheck and build were NOT RUN by owner instruction. + +The `Protect dev` ruleset still requires an approving review and code-owner review, +so the merge call itself will be refused without an explicit administrator bypass. +That bypass is the mechanism this policy exception is exercised through, and it is +conditional: verified maintainer identity with `admin`, base `dev`, every planned +check green on the exact head, and the integration record posted. It is never a way +past failing CI or an unresolved objection. + +## Merge order + +1. Refresh the tip against `dev` if `dev` moved; a moved base means the green run + no longer describes the merge result, so re-run the gate on the new head. +2. Merge the tip pull request into `dev` with an explicit squash body. The repository + sets `squash_merge_commit_message: COMMIT_MESSAGES`, so the landed message is not + the pull-request description: supply it directly and make it carry the trailer. + + ```sh + gh pr merge --repo lidge-jun/opencodex --squash --admin \ + --match-head-commit --body-file + ``` + + `--body-file` supplies the **merge commit body**, which under `--squash` is the + squash commit body, replacing the repository's `COMMIT_MESSAGES` default + (verified against the installed `gh` 2.91.0 help). `--subject` is optional and + only controls the title. `--match-head-commit` refuses the merge if the head + moved after the gate was read. + + The squash body must contain, on its own line: + + ```text + Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> + ``` + +3. Read the landed squash SHA from GitHub, fetch `origin/dev`, and prove: + `git merge-base --is-ancestor origin/dev` exits 0, the landed commit + message contains the trailer, and the four files on `dev` match the reviewed tip. + +Merging is an external state change and stays user-authorized. + +## Settling the source pull requests + +Both #3924 and #3930 were carried by `cherry-pick -x`, so GitHub will not mark them +merged automatically. After the tip lands: + +- Verify the landed commit's trailer **before** closing either source pull request. + A closing comment is prose; only the trailer is contributor-graph data + (`CREDITS.md` exists because that distinction was missed 27 times). +- Then close #3924 and #3930 with a comment naming the landed squash SHA, the tip + pull request, and the preserved authorship. +- Do not delete the contributor branches on the fork; they are not ours. + +## Linked issues + +Neither #3924 nor #3930 declares a closing issue reference +(`closingIssuesReferences` is empty for both). If none is discovered during the +cycle, the "close linked issues" obligation is satisfied vacuously and recorded as +such. Any issue found to be resolved by this landing is closed at the moment the +change is on `dev`, with a comment naming the commit. + +## Acceptance + +- Tip CI: run id + head SHA + per-job conclusions on the merged head, with the three + skipped job families named as skipped. +- `enforce-target` and `hygiene` green on that head; maintainer-integration record + posted on the pull request. +- `git merge-base --is-ancestor origin/dev` exits 0 after fetch, the + landed commit carries the `luvs01` trailer, and the four files on `dev` match the + reviewed tip. +- #3924 and #3930 closed after that verification; no lower-layer pull request was + ever opened. +- Linked-issue status stated explicitly (closed, or none exists). diff --git a/devlog/_plan/260908_sponsor_overview/010_overview.md b/devlog/_plan/260908_sponsor_overview/010_overview.md new file mode 100644 index 0000000000..92493bf2a9 --- /dev/null +++ b/devlog/_plan/260908_sponsor_overview/010_overview.md @@ -0,0 +1,38 @@ +# Sponsor overview presentation + +Satisfy-spec, C2, one shared UI slice delivered to existing independent PRs #3914 and #3915. Trigger: the maintainer requested concise marketing copy, hyperlinks, tidy design and PR screenshot mockups. Stop after both remote branches and PR descriptions are updated with truthful verification. No merge, release, deployment, credential changes, outreach or new dependencies. No user-defined resource budget; use existing local tools and remote macmini-cf for heavy validation. This document and the local goalplan hold evidence. Escalate only a conflicting remote edit, inaccessible publication or unrelated baseline failure requiring broader scope; report incomplete evidence honestly. + +## Design read + +Developer dashboard, existing neutral theme and system font. Keep compact connection facts and quota data; introduce one quiet sponsor strip with product identity, a two-line value proposition, explicit Sponsor disclosure and named outbound actions. No hero, animation, invented discount or performance claim. Variance 3/10, motion 1/10, density D5. Reuse existing ProviderIcon, button/link tokens and locale dictionaries. Desktop strip places copy and actions side by side; narrow containers wrap actions below copy. Screenshot mockups use actual components and synthetic account/usage values, labelled as fixtures in PR prose. Utility dashboard exemption: no generated concept images. + +## Existing owners and necessity + +- `gui/src/pages/Providers.tsx:289`: existing cached `/api/provider-presets` request; consume its result instead of adding a request/store. +- `gui/src/components/provider-catalog/provider-presets.ts:17`: CatalogPreset already owns sponsor/sponsorUrl/dashboardUrl/note. No backend field or persistence change. +- `gui/src/components/provider-workspace/ProviderDetails.tsx:265`: pass the matched preset to Overview. +- `gui/src/components/provider-workspace/ProviderOverview.tsx:186`: note is duplicated in connection facts and NotesSection. Remove the duplicate and move the existing editable NotesSection into the wider main column. Preserve full note and its editing behavior for every provider. +- `gui/src/styles/provider-workspace-shell.css:606`: existing responsive grid/tokens. +- Searched preset matching, sponsor fields, Overview callers and note-save tests; no equivalent overview sponsor presentation exists. Configuration alone cannot add links to the current text-only view. + +## Diff map + +1. MODIFY Providers cached request typing to CatalogPreset[] and pass matching preset; match canonical id + adapter + normalized endpoint (trailing slash tolerated); mismatched endpoints or absent presets produce no sponsor strip. Do not infer endorsement from name alone. Reuse cache; no new fetch. +2. MODIFY ProviderDetails and ProviderOverview optional preset prop. NEW small ProviderSponsor component in provider-workspace: render only known sponsor identities with active sponsor metadata; localize concise OrcaRouter adaptive-routing and PackyCode multi-tool API-relay descriptions. Preserve exact existing affiliate URL, expose dashboard link only when distinct, HTTP(S) only, new-tab noopener/noreferrer. No HTML parsing of notes. +3. MODIFY Overview: render sponsor strip above columns; remove connection note row, put existing NotesSection after auth summary, leaving right column stats/quota only. Full user note remains visible/editable once. +4. MODIFY existing workspace stylesheet for strip layout, subtle border, readable copy/actions and note wrapping. MODIFY all locale dictionaries for every added key. +5. NEW focused GUI render tests for sponsor links/disclosure, missing/non-sponsor/custom-endpoint cases; extend existing note test to assert exactly one note and continued editing. No root test-map change for GUI tests. +6. MODIFY providers guide and GUI SoT for sponsor overview behavior. ADD desktop and narrow actual-render PNGs per sponsor under existing assets/sponsors; update both PR descriptions, preserving prior scope and verification distinctions. + +## Acceptance and validation + +- Active OrcaRouter API and OAuth presets show the strip only for their configured endpoint; PackyCode only on its sponsor branch. Missing catalog, non-sponsor and changed endpoint remain ordinary provider views. Focused tests activate each branch. +- Exact sponsorURL survives, duplicate dashboard URL is suppressed, unsafe URLs do not become anchors. Provider limitations and arbitrary user note remain complete and occur once; note-save failure keeps draft/error. +- Run focused GUI tests (new sponsor tests plus existing notes, catalog sponsor-pinning and locale parity), lint:i18n, lint and GUI build. Existing scripts confirmed in gui/package.json; target files/locale imports prove coverage. Fresh execution recorded in C, not claimed from script existence. +- For review-ready delivery run root typecheck and full tests plus full GUI tests on isolated macmini-cf checkout; build locally for rendered proof. Existing PR gates already fail before this patch: diagnose separately and do not claim green by inheritance. +- Browser smoke at 1440px and 390px, light/dark, English/Korean: inspect screenshot, actual hyperlinks and keyboard focus, note editing and overflow. No live account data or upstream inference. +- Preserve both original histories: build on sponsor remote heads in this bound worktree with separate local branches, carry shared commit to second branch, push fast-forward to each existing remote after refreshing identity. No native stack changes. + +## Audit and evidence + +Independent audit: GO-WITH-FIXES, one blocking coverage gap. Folded: the browser integration smoke must load the real Providers → Details → Overview chain with delayed catalog resolution, assert sponsor content appears, count the shared preset request, then change the fixture endpoint and verify the strip disappears. Component-only screenshots do not close this row. Branch matrix: Orca API/OAuth positive and Packy absent on Orca head; Packy positive and Orca strip absent on Packy head. Baseline focused tests: 13 pass / 0 fail. Existing CI failure is French modal.badge.sponsor untranslated; correct the sponsor-specific locale value while updating copy. diff --git a/devlog/_plan/260908_voice_relay/000_plan.md b/devlog/_plan/260908_voice_relay/000_plan.md new file mode 100644 index 0000000000..2675c20c84 --- /dev/null +++ b/devlog/_plan/260908_voice_relay/000_plan.md @@ -0,0 +1,39 @@ +# Codex voice relay follow-up + +Satisfy-spec HOTL loop, triggered by the maintainer's September voice source comparison request. +Goal: carry only verified OpenCodex-owned improvements and document the client/proxy boundary. +No local product tests, typecheck, build or installs; no release, deployment or user settings changes. +Verification: read pinned upstream source and Aside findings; independent review; final cumulative +remote Cross-platform CI dispatch (all lanes), followed by exact-head merge and fetched dev tree proof. +Local product verification is NOT RUN by explicit user instruction. Git diff/document inspection is +allowed but does not certify runtime behavior. No latency or live audio improvement is claimed. +Stop: audited no-change conclusion, or required corrections landed with fresh remote evidence. +Outcomes: DONE, evidence-backed NOOP, or explicit unmet external gate. No invented time/cost budget; +existing tools/credentials only, bounded individual probes, no new services or installs. +Escalation: unresolved maintainer objection, missing external authority, or unavailable required CI. + +## Ordered work phases + +1. wp1: source research and audited roadmap (documents only). +2. wp2: scoped relay correction and adjacent regression coverage; depends on wp1. +3. wp3: publish the documented contract, final cumulative CI, and merge; depends on wp2. + +Existing owners: `src/server/live.ts`, `src/server/index.ts`, `tests/server/server-live.test.ts`, +`docs-site/src/content/docs/guides/codex-integration.md`, `structure/04_transports-and-sidecars.md`. +No new production abstraction, endpoint or dependency. Preserve preexisting worktree documents. +Manual two-PR chain: relay implementation/tests, then integration documentation. User explicitly +requests final-tip-only product CI, overriding per-layer local/full-suite defaults. Automatic +redundant product CI on these task PRs may be cancelled; it is never counted as passing evidence. +Use merge commits to preserve stack ancestry, retarget the child only after the parent lands, +and recheck the current dev tree before final merge. Required checks remain truthful. + +Security working material is kept only in ignored scratch per AGENTS.md. The detailed audited +roadmap resides in `.tmp/voice-0908/010_runtime.md` and `.tmp/voice-0908/020_delivery.md` until +publication of the fix; it is intentionally not copied into this public planning directory. + +## Roadmap audit and lock + +Independent plan and security audit: PASS, no blockers. The implementation will preserve view +bounds and original frame delivery. Diagnostic replacement-character flags are not evidence of +which peer introduced malformed text. Existing logs are outside this prospective logging change. +The roadmap is locked for wp2; final runtime evidence remains due in wp3, on the cumulative tree. diff --git a/devlog/_plan/260908_voice_relay/001_sources.md b/devlog/_plan/260908_voice_relay/001_sources.md new file mode 100644 index 0000000000..b5a6c2658f --- /dev/null +++ b/devlog/_plan/260908_voice_relay/001_sources.md @@ -0,0 +1,24 @@ +# Source comparison + +Pinned upstream: openai/codex b01c3986fd2e79b8a477a08d81430f52f22bc0dc (2026-09-07 UTC). +The local corpus is `/Users/jun/Developer/codex`; its 120 and 121 upstream checkouts had older +working heads, so the named commit was fetched without modifying their worktrees. + +- https://github.com/openai/codex/commit/1b53f6a44eff890b5169bde8d3bd5b12b8766946: + local voice helper offer/answer, ordered oai-events data channel and UDP/TCP transport. +- https://github.com/openai/codex/commit/b01c3986fd2e79b8a477a08d81430f52f22bc0dc: + feature-gated TUI voice commands, captions, handoff answer delivery and lifecycle cleanup. +- `codex-rs/codex-api/src/endpoint/realtime_call.rs` at the pinned head: + backend JSON and API multipart call creation, Frameless `/live`, AVAS `/realtime/calls`. +- OpenCodex `src/server/live.ts` already implements these call-create and sideband shapes; + `src/server/index.ts` transparently relays frames and bounds pending queues and teardown. +- `tests/server/server-live.test.ts` already covers call creation, protocol headers, pool identity, + sideband joins and frame delivery. Existing implementation is reused, not duplicated. + +Fast-tier display text and local audio negotiation do not demonstrate a proxy latency gain. +The TUI merge date does not establish when a desktop binary shipped. Live microphone/audio +verification is outside the automated evidence gathered here. + +The Fast-tier metadata commit is 0e0f55fc4ec9308840e54ceba1f1f1dc9547380f, +2026-09-04T00:12:18Z; it changes only `codex-rs/models-manager/models.json`. +It describes the supported service tier, not OpenCodex voice transport performance. diff --git a/devlog/_plan/260909_config_mutation_lock_windows/000_plan.md b/devlog/_plan/260909_config_mutation_lock_windows/000_plan.md new file mode 100644 index 0000000000..e5226fa05a --- /dev/null +++ b/devlog/_plan/260909_config_mutation_lock_windows/000_plan.md @@ -0,0 +1,176 @@ +# config-mutation-lock Windows fixture: readiness budget + failure unmasking + +## Reader summary + +Windows shard 2/6 of run +[34321628628](https://github.com/lidge-jun/opencodex/actions/runs/34321628628) +(attempt 1, job 102369384143, head `ddcf8b5f9b13`, branch +`codex/pr3997-caller-main-cooldown`, a `workflow_dispatch` lane run) failed +`tests/config/config-mutation-lock.test.ts` at line 111 with `Expected: 0 / +Received: 143` after 5915.82 ms. The 143 is not a lock defect and not the +30 s teardown kill: it is the readiness-timeout path's own `child.kill()`, +and the `finally` block's exit-0 expectation then masks the real error. The +fix gives spawned-child readiness a measured 30 s budget, stops the masking, +and corrects a stale comment. No product code, no workflow changes. + +## Loop spec + +- **Loop archetype:** satisfy-spec repair of a CI test fixture. +- **Trigger:** delegated follow-up from the managing task after the xAI OAuth + unit completed; user-authorized as a small isolated maintainer PR. +- **Goal:** the Windows flake either passes (child ready within a measured + budget) or fails with the real readiness error instead of a bare 143. +- **Non-goals:** product code, CI workflow files, other tests, skipped tests, + accepting 143 as a valid outcome, bare timeout bumps without observability. + No local suite/typecheck/build (user restriction). +- **Verifier:** remote `ci.yml` — PR lane (Linux `test`, macOS + `platform-macos`, `gates`) on the PR, then a `workflow_dispatch` + `lane=all` run on the exact PR head whose Windows shards execute this file; + the previously failing test must pass there. +- **Stop condition:** PR published, PR lane green, Windows dispatch run green + for this file at the exact head, managing task handed the report. +- **Memory artifact:** this unit directory; goalplan + `.codexclaw/goalplans/` entry for this session's second goal. +- **Expected terminal outcomes:** DONE = both CI evidences green at the exact + head. BLOCKED = the Windows run shows the failure is NOT the readiness budget + (e.g. child never acquires the lock even in 30 s → real lock defect, which + would be out of this task's scope and handed back with evidence). +- **Escalation condition:** any need to touch `src/` or `.github/`, or a + Windows re-failure after the fix. + +## Verified cause (log + source, no patch before this was established) + +Timeline of the failing attempt (test duration 5915.82 ms): + +1. Parent spawns the Bun child and enters `waitForPath(readyPath)` — + **500 attempts × 10 ms = 5 s** budget (tests/config/config-mutation-lock.test.ts:27-34). +2. The child must boot Bun, transpile the `src/config.ts` import chain, and + acquire the mutation lock before writing `holder-ready`. On this loaded + runner that exceeds 5 s: the sibling child in `an abruptly exited holder + releases the OS-backed transaction…` needed **8290.11 ms** end-to-end in the + same shard (and passed, because `waitForOwnedChild` allows 30 s). The lock + itself is healthy — every other test in the file passed, and attempt 2 of the + run was green. +3. `waitForPath` throws at ~5 s; the catch kills the child — SIGTERM, exit + **143** — and rethrows an enriched error with the child's stderr + (tests/config/config-mutation-lock.test.ts:85-92). +4. The `finally` block (line 109-112) runs `writeFileSync(releasePath)` and + `expect(await waitForOwnedChild(child)).toBe(0)`. The child is already dead + with 143, so this expectation throws and **replaces** the enriched readiness + error — the log shows only the 143 mismatch at line 111, and the "child + stderr" text never appears. +5. The stale comment in `waitForOwnedChild` (lines 36-41) attributes a 5858 ms + / 143 failure to "this helper's own `kill()`" from the 5 s era — that helper + now waits 30 s, so the explanation is wrong; the 143 comes from the + readiness-timeout catch. + +## File change map + +| Path | Action | What | +|------|--------|------| +| `tests/config/config-mutation-lock.test.ts` | MODIFY | readiness wait reuses the predeclared platform policy `watchdogMs(5_000)` (5 s local / 30 s CI / 45 s Windows CI) with an elapsed deadline, a final recheck, and fail-fast on an already-exited child; unmask the primary readiness failure in both holder tests' `finally`; correct the stale `waitForOwnedChild` comment | + +OUT: `src/**`, `.github/**`, `tests/helpers/ci-watchdog.ts` (imported, not +modified), every other test file. + +Forensics correction (independent verifier Descartes, forwarded by the managing +task after the first plan draft): the readiness budget must reuse the EXISTING +`watchdogMs(5_000)` platform policy from `tests/helpers/ci-watchdog.ts` +(Windows CI floor 45 s) rather than a new hardcoded 30 s constant — that helper +is the repository's declared answer to "spawned children are slow on loaded +Windows CI", so this fix expresses policy, not a local bump. It also directed +the fail-fast on `child.exited` (no 45 s poll on an already-dead child) and +extending the unmasking to the management-API holder test. + +## Diff-level design + +### 1. `waitForPath` → `waitForOwnedChildReady` — platform-policy budget, fail-fast + +Before (lines 27-34): a fixed 500 × 10 ms (5 s) poll with no knowledge of the +child. + +After: the wait takes the spawned child, budgets `watchdogMs(5_000)` (5 s +locally, 30 s on CI, 45 s on Windows CI — the predeclared policy in +`tests/helpers/ci-watchdog.ts`), polls on an elapsed-time deadline with a final +`existsSync` recheck, and races each 10 ms tick against `child.exited` so a +child that died before writing the marker fails immediately with its exit code +and stderr instead of burning the whole budget. + +### 2. Unmask the primary failure in both holder tests + +Before (lines 84-112): + +```ts + try { + try { + await waitForPath(readyPath); + } catch (error) { + child.kill(); + await child.exited; + const stderr = await new Response(child.stderr).text().catch(() => ""); + throw new Error(`${(error as Error).message}\nchild stderr: ${stderr}`); + } + ... + } finally { + writeFileSync(releasePath, "release"); + expect(await waitForOwnedChild(child)).toBe(0); + } +``` + +After: + +```ts + let childKilled = false; + try { + try { + await waitForOwnedChildReady(child, readyPath); + } catch (error) { + childKilled = true; + child.kill(); + await child.exited; + const stderr = await new Response(child.stderr).text().catch(() => ""); + throw new Error(`${(error as Error).message}\nchild stderr: ${stderr}`); + } + ... + } finally { + writeFileSync(releasePath, "release"); + // The readiness-timeout path already killed the child; expecting exit 0 here + // would mask that primary error with a bare 143. + if (!childKilled) { + expect(await waitForOwnedChild(child)).toBe(0); + } + } +``` + +The happy path is unchanged: release marker is always written (bounded cleanup), +the exit-0 core assertion still runs whenever the child was not sacrificed, and +every lock assertion (not stolen, immediate writer failure, no stale writes) is +untouched. + +### 3. `waitForOwnedChild` comment correction + +Replace the stale 5 s-era explanation with the verified provenance: + +```ts + // The child polls for the release marker on a 10 ms sleep, so its exit is bounded by + // the filesystem noticing that write plus one Bun teardown; a loaded Windows runner + // needs real room for both. A surfaced exit 143 is never this helper's own kill() + // (which fires only after the full budget) — it is the readiness-timeout path's + // child.kill(), so read the readiness error, not this wait. +``` + +## Regression evidence plan + +- The failure mode is exercised by construction: if readiness ever exceeds the + budget again, the thrown error is the enriched `waitForOwnedChildReady` + message (with child stderr), asserted by reading the code path. A child that + *dies* before writing the marker is caught immediately by the `child.exited` + race rather than at the deadline; only a child that stays alive and never + becomes ready costs the full platform budget, and a dedicated test for that + would be a deliberate 45 s negative test on Windows CI — a cost not justified + for a CI fixture, where the unmasking is straight-line control flow reviewed + in the diff. +- Positive path: `ci.yml` PR lane plus a `workflow_dispatch` `lane=all` run + on the exact PR head; the Windows shard executing + `tests\config\config-mutation-lock.test.ts` must pass, and the run must + show this file's tests green. diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/000_plan.md b/devlog/_plan/260909_usage_custom_range_disclosure/000_plan.md new file mode 100644 index 0000000000..f600a684c3 --- /dev/null +++ b/devlog/_plan/260909_usage_custom_range_disclosure/000_plan.md @@ -0,0 +1,55 @@ +# Usage custom date range — manual-query disclosure + +Triggered by a maintainer browser comment on `/#usage`: the custom date range block should be a +dropdown (manual lookup) by default, with the fields below it, and the current layout is visually +wrong — control heights do not line up. Scope is the usage page filter area only. + +## Design read (cxc-dev-uiux-design) + +Reading this as: a dense local analytics page for a single operator who reads the presets almost +every time and reaches for an explicit interval rarely, in the quiet utilitarian language the rest +of the dashboard already speaks. Tokens come from `gui/src/styles.css`; nothing new is invented. + +```text +DESIGN_VARIANCE: 3 +MOTION_INTENSITY: 1 +Product density profile: D5 +Reasoning: dashboard/admin surface for repeated operator work — the expressive default kit is +domain-gated off, so the work is restraint, alignment and disclosure rather than decoration. +``` + +Do's: one obvious path (presets), expert control demoted behind a labelled disclosure, every +control on one height, left-aligned so the block reads with the page it belongs to. +Don'ts: no second full-width flex-end row, no decorative motion, no hidden applied state. + +## Problem + +`Usage.tsx` renders the custom-range `
` unconditionally under the page subtitle and reuses +`.usage-filters`, which is `justify-content: flex-end`. Three consequences: + +1. Two empty `datetime-local` fields are the second thing on the page even though the answer the + page exists to give is already rendered from a preset (UX-LAZY-01: an expert fork at top level). +2. The row is pushed to the right edge with a wide empty gutter, and the help caption underneath + starts at the left edge, so the two halves do not read as one control. +3. `align-items: center` centers a label+input stack (≈57px) against `btn-sm` buttons (≈26px), so + Apply/Clear float in the middle of the fields instead of sitting on their baseline. + +## Work phases + +1. wp1: collapse the block behind a closed-by-default disclosure trigger, render the fields in a + bottom-aligned grid panel on one control height, keep the applied interval visible while + collapsed, and update `gui/tests/usage-custom-range.test.tsx`. +2. wp2: publish as a PR against `dev` with a GUI screenshot and merge on exact-head CI; depends + on wp1. + +## Contract + +- Trigger reuses the existing `usage.range.custom` label, so no locale catalog gains a key. +- Collapsed state renders no date inputs; `aria-expanded`/`aria-controls` carry the state. +- An applied window keeps its `role="status"` interval line outside the panel, so collapsing never + hides which interval the numbers cover (progressive disclosure names what stays hidden). +- Draft text, validation, request identity and cache behavior are untouched: this is presentation. + +Verification: see `010_audit.md`. The maintainer forbade local suite runs mid-unit, so the +repository-wide `bun run test` is NOT RUN locally and remote exact-head CI is the only +full-suite evidence for this change. diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/010_audit.md b/devlog/_plan/260909_usage_custom_range_disclosure/010_audit.md new file mode 100644 index 0000000000..73acc8b1ba --- /dev/null +++ b/devlog/_plan/260909_usage_custom_range_disclosure/010_audit.md @@ -0,0 +1,44 @@ +# Audit and verification record + +## Independent review (explorer reviewer, A gate) + +Verdict NEAR-PASS. The reviewer read the four touched files, ran the focused suite and +typecheck, and measured the rendered panel in headless Chrome rather than trusting the CSS +comments. It cleared the behavioral half: `rangeOpen` feeds only `aria-expanded`, the +`is-active` class and the conditional render, and `loadUsage`, `resourceKey`, `presetKey`, +the held-cache gating and the `UsageWindowMismatchError` receipt check are untouched, so +request identity and caching cannot have moved. Every `var()` in the new block resolves, no +other `.usage-range*` selector exists in `gui/src`, and `usage.range.custom` is already in all +ten catalogs, so promoting it from `aria-label` to visible text adds no key. + +Findings folded in: + +1. **BLOCKING — measured spacing defect.** `repeat(2, minmax(0, 200px))` capped each date + track at 200px, but `.input` carries `min-width: auto` and a `datetime-local` control's + intrinsic minimum is ~206px in Chrome at this font size. Measured `input w=206.0` in a + 200px track, leaving a 2px visible gap where the grid declares 8px — and worse for locales + whose date format is longer than `mm/dd/yyyy`. That is the exact rhythm defect this change + exists to repair. Fixed by sizing every track to its content: `repeat(4, auto)` with + `justify-content: start`. +2. **MINOR — dangling IDREF.** `aria-controls` named a panel that is unmounted while closed. + Now emitted only while open. +3. **MINOR — hidden validation state.** `rangeError` survived a collapse, so a submitted + invalid range left an alert behind an unmarked trigger. Closing now retires the error while + keeping the draft; covered by a new regression test. +4. **MINOR — vacuous-assertion risk.** The `interval()` helper was class-coupled; it is now + scoped to `.usage-range-bar [role="status"]`. +5. **MINOR, accepted.** Disclosure state across a preset click stays open and is left unpinned. + +## Verification status + +| Check | Result | +|---|---| +| `bun run typecheck` | pass (before the no-local-suite instruction) | +| `cd gui && bun test tests` | 1937 pass / 0 fail (before the instruction; 26 in the usage suite after the review fixes) | +| `cd gui && bun run lint` | pass | +| `cd gui && bun run build` | pass | +| `bun run test` (repository-wide) | **NOT RUN** — killed on the maintainer's explicit instruction | +| Rendered browser check | Collapsed, open, applied-then-collapsed, validation error, dark theme and 430px width, against a live proxy | + +Screenshots in `assets/`: `010_before.png` is the shipped 2.49.0 layout, `020_after_collapsed.png` +and `030_after_open.png` are this change. Remote exact-head CI is the full-suite authority. diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/assets/010_before.png b/devlog/_plan/260909_usage_custom_range_disclosure/assets/010_before.png new file mode 100644 index 0000000000..56ae4fb615 Binary files /dev/null and b/devlog/_plan/260909_usage_custom_range_disclosure/assets/010_before.png differ diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/assets/020_after_collapsed.png b/devlog/_plan/260909_usage_custom_range_disclosure/assets/020_after_collapsed.png new file mode 100644 index 0000000000..d1d692f08a Binary files /dev/null and b/devlog/_plan/260909_usage_custom_range_disclosure/assets/020_after_collapsed.png differ diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/assets/030_after_open.png b/devlog/_plan/260909_usage_custom_range_disclosure/assets/030_after_open.png new file mode 100644 index 0000000000..3ac6661ffc Binary files /dev/null and b/devlog/_plan/260909_usage_custom_range_disclosure/assets/030_after_open.png differ diff --git a/devlog/_plan/260909_xai_oauth_retry_hardening/000_plan.md b/devlog/_plan/260909_xai_oauth_retry_hardening/000_plan.md new file mode 100644 index 0000000000..785622752e --- /dev/null +++ b/devlog/_plan/260909_xai_oauth_retry_hardening/000_plan.md @@ -0,0 +1,101 @@ +# xAI OAuth retry hardening — unit plan + +## Reader summary + +Four filed defects in `src/oauth/xai.ts` (#4045, #4046, #4047, #4048) all sit in +the token-request path that runs on every Grok login and every token refresh: +`postXaiToken` clamps the server-provided `Retry-After` to 2 s, ignores its +HTTP-date and fractional forms, and keeps retrying after the caller has aborted +whenever the abort carries a custom reason; `validateXaiEndpoint` accepts any +`*.x.ai` subdomain and URLs with embedded userinfo on the endpoint that receives +the `refresh_token`. This unit ships two pull requests: phase 1 fixes the three +retry/abort bugs as one cohesive change, phase 2 hardens endpoint validation as a +separate security change layered on phase 1. Grok-account users get retries that +honor the server and stop when cancelled; the credential-destination check stops +trusting unbounded subdomains. + +## Loop spec + +- **Loop archetype:** satisfy-spec — the expected contracts are stated in the four + public issues; no candidate exploration. +- **Trigger:** delegated release-preparation task from the managing thread + (`01a08498-4ebf-7ad3-89c2-fb56c8ea53bf`), user-authorized: implement, publish + PRs, verify via remote CI, await serial merge assignment. +- **Goal:** PR1 (base `dev`) fixes #4045/#4046/#4047 with regression tests; PR2 + (base = PR1 head) fixes #4048 with regression tests; both carry exact-head + remote CI evidence. +- **Non-goals:** release, promotion, `main` push, deploy, merges (merge awaits + the managing task's serial assignment); no local product test + suite/typecheck/build/install (user restriction — labeled NOT RUN); no changes + to other providers' auth lanes; no GitHub native stacks (ordinary dependent PR + chain only). +- **Verifier:** GitHub Actions `ci.yml` ("Cross-platform CI") on each pull + request — it triggers on every `pull_request` event with no base filter and + its `changes` gate includes `src/**` and `tests/**`, so both PRs (including + the stacked child) receive the real PR jobs: Linux `test`, macOS + `platform-macos`, and `gates` (`tsc --noEmit`). The Windows + `platform-windows` and `macos-control` jobs are NOT ON PR — they run only + via `workflow_dispatch` `lane=all`; the cumulative final-head `lane=all` + dispatch before any merge is owned by the managing task. Exact-head check runs + are recorded per PR. Local verifiers (`bun test`, `bun run typecheck`) are + NOT RUN by user restriction; the plan instead maps each new test to the file CI + executes (`tests/providers/xai/xai-oauth-retry.test.ts`). +- **Stop condition:** both PRs published with template-complete bodies, + exact-head CI recorded, and the managing task handed the PR/head/CI report. +- **Memory artifact:** this unit directory; goalplan + `.codexclaw/goalplans/fix-opencodex-4045-4046-4047-xai-oauth-retry-aft/`; + scratch-only security detail in `.tmp/260909_xai_endpoint_security/` + (gitignored) per the repository security-notes policy. +- **Expected terminal outcomes:** DONE = both PRs published with green exact-head + CI and handoff reported. BLOCKED = CI failure that requires out-of-scope files, + or a policy question only the managing task can answer. +- **Escalation condition:** any need to touch files outside the owned set + (`src/oauth/xai.ts`, `tests/providers/xai/`, this unit, scratch), any merge or + `main`/`preview` action, or a verifier verdict that contradicts the issue + contract. A delegated slice that two agents fail returns to the main lane rather + than being re-dispatched. + +### HOTL resource bounds + +- Tool/credential scope: `gh` as the repository owner for reads and PR creation + on `lidge-jun/opencodex`; no merge, release, or settings writes. +- Write scope: `src/oauth/xai.ts`, `tests/providers/xai/xai-oauth-retry.test.ts`, + `devlog/_plan/260909_xai_oauth_retry_hardening/`, `.tmp/` scratch. Git + mutations use `-c core.hooksPath=/dev/null`; pushes use `--no-verify`. +- Token/cost and wall-clock budgets: none set by the user; unbounded within the + session, reported at handoff. + +## Constraints + +- `src/AGENTS.md`: OAuth/token changes are security-boundary changes; regression + coverage sits near the existing subsystem tests; public exports are preserved. +- `tests/providers/xai/xai-oauth-retry.test.ts` already exists in + `scripts/test-layout/layout.json` `explicit` (line 1301) and + `tests/fixtures/test-layout-expected.json`; extending it avoids layout churn. +- Root `AGENTS.md`: pre-disclosure security working notes live in scratch + (`.tmp/`), never in `devlog/`. Issue #4048 is public and its sketch fix is + public, but the assessment and patch plan for phase 2 are held in scratch until + the PR diff itself is public. +- `MAINTAINERS.md` (2026-09-06): maintainer integration into `dev` without a + second approval exists but is exercised only by the managing task; this unit + performs no merges. + +## Work-phase map (dependency-ordered) + +| Phase | Doc | Output | Depends on | +|-------|-----|--------|------------| +| wp1 (this cycle) | `000_plan.md`, `010_*`, `020_*` | Locked roadmap | — | +| wp2 | `010_phase1_retry_after_abort.md` | PR1: retry/abort fixes + tests, base `dev` | wp1 | +| wp3 | `020_phase2_endpoint_validation.md` | PR2: endpoint validation hardening + tests, base = PR1 head | wp2 (same file; layered to avoid self-conflict) | + +Phase order follows the build order: the retry-path repair rewrites the same +function cluster the endpoint guard sits next to, so the security layer stacks on +the repaired file rather than racing it as a parallel root. + +## Independent verification + +Four read-only verifier subagents (xai/grok-4.6, one per issue) re-check each +claimed defect and proposed fix against the live code before phase 1 builds; their +verdicts fold into the phase-1 audit. Live discovery evidence (2026-09-09): +`https://auth.x.ai/.well-known/openid-configuration` returns only +`auth.x.ai` hosts for `authorization_endpoint` and `token_endpoint`. diff --git a/devlog/_plan/260909_xai_oauth_retry_hardening/010_phase1_retry_after_abort.md b/devlog/_plan/260909_xai_oauth_retry_hardening/010_phase1_retry_after_abort.md new file mode 100644 index 0000000000..7524598fe4 --- /dev/null +++ b/devlog/_plan/260909_xai_oauth_retry_hardening/010_phase1_retry_after_abort.md @@ -0,0 +1,351 @@ +# Phase 1 (wp2): honor Retry-After, stop retrying aborted token requests + +Closes #4045, #4046, #4047. One cohesive PR: all three defects live in the same +retry loop of `postXaiToken` and the same helper cluster; splitting them would +produce three PRs editing adjacent lines of one function. + +Revision 3, folding two audit rounds. Round 1: four issue verifiers (#4045 +CONFIRMED, #4046 PARTIALLY, #4047 PARTIALLY, #4048 CONFIRMED) — strict parser +copied from `src/combos/failover.ts`, two-name terminal guard, pre-sleep abort +check. Round 2: plan auditor GO-WITH-FIXES (blockers=1) plus a bounded +retry-algorithm reviewer FAIL (one real High) — folded below: donor-fidelity +date regex, non-vacuous HTTP-date test, hostile-vector tests, corrected CI map, +**abort-aware in-wait sleep**, and the **retry-budget terminal rule** replacing +the issue sketch's silent 60 s clamp. + +## File change map + +| Path | Action | What | +|------|--------|------| +| `src/oauth/xai.ts` | MODIFY | import `abortError`/`sleepWithAbort` from `../lib/upstream-retry`; `retryDelay` rewrite (returns `number \| undefined`), new `jitterDelay`/`parseRetryAfterMs`/`parseHttpDateMs`/`sleepAbortable`, remove `isAbortError`, terminal abort/timeout handling + abort-aware backoff in `postXaiToken` | +| `tests/providers/xai/xai-oauth-retry.test.ts` | MODIFY | new regression tests (below); existing five must keep passing unmodified | +| `docs-site/` | none | retry timing is internal; no user-facing configuration or documented behavior changes | + +`src/lib/upstream-retry.ts` is a documented leaf module (its header: "MUST stay +a leaf module") importing only `./abort`, so the new import adds no transitive +weight to the OAuth path and reuses the repo-standard `abortError` shape +(`signal.reason ?? DOMException("The operation was aborted", "AbortError")`). + +Scope boundary — IN: the two rows above plus this unit directory. OUT: every other +provider's OAuth lane, `callback-server.ts`, `pkce.ts`, `validateXaiEndpoint` +(phase 2), `src/combos/failover.ts` (parser donor — copied, not imported), +CLI surfaces, GUI. + +## Diff-level design + +### 1. Constants (NEW, next to `TOKEN_REQUEST_TIMEOUT_MS` at src/oauth/xai.ts:13) + +```ts +const RETRY_AFTER_MAX_DELAY_MS = 60_000; +const JITTER_DELAY_CAP_MS = 2_000; +``` + +### 2. Parser and delay helpers (NEW/REWRITE) + +Before (src/oauth/xai.ts:98, current `dev`): + +```ts +function retryDelay(attempt:number,retryAfter:string|null,random:()=>number):number{const base=attempt===1?100:250,j=Math.round(base*(.75+random()*.5)),seconds=retryAfter!==null&&/^\d+$/.test(retryAfter)?Number(retryAfter):0;return Math.min(2000,Math.max(j,seconds*1000));} +``` + +After: + +```ts +const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; +const HTTP_MONTH_INDEX: Record = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +function parseHttpDateMs(value: string): number | undefined { + const match = IMF_FIXDATE_RE.exec(value); + if (!match) return undefined; + const month = HTTP_MONTH_INDEX[match[2]!.toLowerCase()]; + if (month === undefined) return undefined; + const year = Number(match[3]); + const day = Number(match[1]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const timestamp = Date.UTC(year, month, day, hour, minute, second); + const parsed = new Date(timestamp); + return parsed.getUTCFullYear() === year + && parsed.getUTCMonth() === month + && parsed.getUTCDate() === day + && parsed.getUTCHours() === hour + && parsed.getUTCMinutes() === minute + && parsed.getUTCSeconds() === second + ? timestamp + : undefined; +} + +function parseRetryAfterMs(retryAfter: string | null): number | undefined { + const text = retryAfter?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const ms = Math.ceil(Number(text) * 1000); + return ms > 0 ? ms : undefined; + } + const timestamp = parseHttpDateMs(text); + if (timestamp === undefined) return undefined; + const delay = timestamp - Date.now(); + return delay > 0 ? delay : undefined; +} + +function jitterDelay(attempt: number, random: () => number): number { + const base = attempt === 1 ? 100 : 250; + return Math.min(JITTER_DELAY_CAP_MS, Math.round(base * (0.75 + random() * 0.5))); +} + +/** + * Delay before the next attempt, or undefined when the server asked for a wait + * beyond the retry budget — retrying earlier than Retry-After is the original + * #4045 defect shape, so the caller must fail instead of clamping. + */ +function retryDelay(attempt: number, retryAfter: string | null, random: () => number): number | undefined { + const serverMs = parseRetryAfterMs(retryAfter); + if (serverMs === undefined) return jitterDelay(attempt, random); + return serverMs <= RETRY_AFTER_MAX_DELAY_MS ? serverMs : undefined; +} + +async function sleepAbortable( + ms: number, + sleep: (ms: number) => Promise, + signal: AbortSignal | undefined, +): Promise { + if (!signal) return sleep(ms); + if (signal.aborted) throw abortError(signal); + let onAbort!: () => void; + try { + await Promise.race([ + sleep(ms), + new Promise((_, reject) => { + onAbort = () => reject(abortError(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} +``` + +Default sleep primitive (post-review amendment, verified by direct read of +`src/lib/upstream-retry.ts:53`): the production default changes from +`Bun.sleep` to the already-exported `sleepWithAbort`, which clears its own +timer on abort — so a cancelled CLI leaves no live 60 s timer behind: + +```ts +const sleep = deps.sleep ?? ((ms: number) => sleepWithAbort(ms, signal)); +``` + +`sleepAbortable` remains as the wrapper so a test-injected `deps.sleep` is +still raced against the caller signal; in production the composition is +sleepWithAbort's own abort handling plus the wrapper's reason-preserving +rejection. `upstream-retry.ts` is a documented leaf importing only +`./abort`; no shared-module export or API change is needed. + +Parser provenance (round-1 fold): compact copy of the strict parser the +repository already maintains in `src/combos/failover.ts:117`, at donor fidelity +(round-2 fold): case-insensitive enumerated weekday/month names and a full +six-field UTC round-trip check, so `10:60:00` overflow and lowercase dates +behave exactly as the donor. NOT the issue's `Number()`/`Date.parse` sketch: +bare `Number()` over-accepts (`"1e3"`, `"0x10"`, `"+2"`) and +`Date.parse` is implementation-defined off IMF-fixdate. The donor is not +imported because that would couple `src/oauth/` to `src/combos/`; a +`src/lib/` unification is a possible follow-up, out of scope here. HTTP-date +support covers ALL THREE RFC 9110 formats at full donor fidelity (round-3 fold +of a CodeRabbit Major): IMF-fixdate, RFC 850 (including the two-digit-year +50-year rule), and asctime — RFC 9110 §5.6.7 requires a recipient parsing an +HTTP-date to accept all three formats; only senders are confined to +IMF-fixdate. An earlier draft of this plan claimed recipients need only parse +IMF-fixdate and was wrong. Fractional seconds are a repo-local interop +extension already honored at `failover.ts:124`. Zero, negative, past-dated, +and unparseable values fall back to jitter. + +Contract changes, all intentional: + +- Server-provided delays up to `RETRY_AFTER_MAX_DELAY_MS` (60 s) are honored + exactly instead of being clamped to 2 s (#4045). The 2 s cap now applies to + the jittered fallback only. +- **Retry-budget terminal rule (round-2 fold, supersedes the issue sketch's + `Math.min(seconds*1000, 60_000)`):** a server delay ABOVE the 60 s budget + (`Retry-After: 61`, `3600`, a far-future date) makes the attempt terminal — + the 429/5xx error is thrown immediately with zero further fetches. Clamping + to 60 s would retry earlier than the server asked, recreating the original + defect; the local retry budget cannot honor that floor, so it stops instead. +- In-wait cancellation (round-2 fold): the backoff sleep is raced against the + caller signal with listener cleanup, so an abort DURING a honored 60 s wait + rejects promptly with the abort reason instead of up to ~120 s late + (`Bun.sleep` is not signal-aware). The test-injected `deps.sleep` primitive + is preserved — the wrapper races whatever sleep is injected. +- The old `Math.max(jitter, serverMs)` floor is dropped: a present server + value wins outright; jitter exists only for the no-header case. +- Ceiling interplay: two honored 60 s waits can stretch wall-clock to ~120 s + while `TOKEN_REQUEST_TIMEOUT_MS` stays 30 s per attempt; per-attempt fetch + timeout is unchanged. An abort during any wait now cancels promptly. + +### 3. Abort/timeout terminal handling in `postXaiToken` (MODIFY) + +Catch branch — before (src/oauth/xai.ts:114 area): + +```ts +}catch(error){if(isAbortError(error)&&signal?.aborted)throw error;last=error; +``` + +After (only the abort predicate and the sleep change; the attempt-3 wrap and +continue are kept verbatim): + +```ts +} catch (error) { + if (signal?.aborted) throw error; + const name = (error as { name?: string } | undefined)?.name; + if (name === "AbortError" || name === "TimeoutError") throw error; + last = error; + if (attempt === 3) { + throw new XaiTokenRequestError(undefined, undefined, "xAI token request failed: network error", { cause: error }); + } + await sleepAbortable(jitterDelay(attempt, random), sleep, signal); + continue; +``` + +Response branch — after: + +```ts + const error = await readTokenError(response); + last = error; + if (!(response.status === 429 || response.status >= 500) || attempt === 3) throw error; + if (signal?.aborted) throw error; + const delay = retryDelay(attempt, response.headers.get("retry-after"), random); + if (delay === undefined) throw error; + await sleepAbortable(delay, sleep, signal); +``` + +`isAbortError` is deleted (definition and only use are both in this file). +Rationale: + +- The class check fails when `controller.abort(reason)` carries a custom reason: + fetch rejects with the reason object as-is, so `instanceof DOMException` is + false and the loop slept and retried an already-aborted request (#4047). + Checking `signal?.aborted` covers every abort reason. +- The internal 30 s `AbortSignal.timeout` in `requestSignal` fires without + aborting the caller's signal; the name guard covers BOTH `AbortError` and + `TimeoutError` because Bun linked-signal timeouts often reject as + `AbortError` (`src/server/images.ts:349`), matching the two-name + non-retryable policy at `src/lib/upstream-retry.ts:178`. The check is safe + here because the fetch signal is always `requestSignal(signal)` — a + composition of exactly the caller signal and the internal timeout — so an + abort-named rejection with a live caller signal can only mean the internal + timeout fired. +- The pre-sleep `signal?.aborted` check in the response branch plus the + abort-raced sleep mean a caller abort is honored before AND during the wait. + +## Regression tests (all in `tests/providers/xai/xai-oauth-retry.test.ts`) + +Existing helpers reused: `queue(...)`, `ok()`, `body`, injected +`{ sleep, random }` deps. All Retry-After cases drive the exported +`postXaiToken` with a 429 response carrying the header — never the unexported +helpers directly. + +1. `429 honors Retry-After seconds beyond the jitter cap` — queue + `[429(retry-after: 60), ok()]`, `random: () => 0.5`; expect sleeps + `[60000]` and 2 fetch calls. Proves #4045. +2. `Retry-After above the 60s budget is terminal, never retried early` — + `retry-after: 3600`; expect rejection with the 429 `XaiTokenRequestError`, + exactly 1 fetch call, zero sleeps. Proves the retry-budget rule (the + anti-#4045 invariant: never retry earlier than the server asked). +3. `Retry-After one second above the budget is terminal` — `retry-after: 61`; + same expectations as test 2. Pins the boundary. +4. `Retry-After below the old 2s cap is still honored exactly` — + `retry-after: 1`; expect `[1000]`. Pins the no-clamp edge. +5. `fractional Retry-After is honored` — `retry-after: 1.5`; expect + `[1500]`. Proves #4046 (fractional). +6. `HTTP-date Retry-After is honored` — header + `new Date(Date.now() + 30_000).toUTCString()`, `random: () => 0.5` pinned; + expect one sleep `> 2000` and `<= 30000` — above the jitter cap, so a + missing or broken `parseHttpDateMs` (which would sleep ~100 ms of jitter) + fails this test. Proves #4046 (HTTP-date). +6b. `RFC 850 HTTP-date Retry-After is honored` — future date formatted + `Wednesday, 09-Sep-26 ... GMT` (two-digit year, 50-year rule); same + `> 2000 && <= 30000` assertion with pinned random. Proves the RFC 850 + recipient form (round-3 fold). +6c. `asctime HTTP-date Retry-After is honored` — future date formatted + `Wed Sep 9 ... 2026` (space-padded day); same assertion. Proves the + asctime recipient form (round-3 fold). +7. `unparseable Retry-After falls back to jitter` — `retry-after: soon`, + `random: () => 0.5`; expect `[100]`. +8. `past HTTP-date falls back to jitter` — `Sun, 06 Nov 1994 08:49:37 GMT`, + `random: () => 0.5`; expect `[100]`. +9. `whitespace-padded seconds are honored` — `retry-after: " 2 "`; expect + `[2000]`. Proves the trim. +10. `hostile Retry-After vectors fall back to jitter` — one test looping over + `["0", "-5", "1e3", "0x10", ""]` with a fresh 429-then-ok queue and + `random: () => 0.5` per value; each expects exactly `[100]`. Pins the + strict parser against a `Number()`-swap regression. +11. `abort with a custom reason is not retried` — + `controller.abort(new Error("user cancel"))` before the call; fetch stub + rejects with `controller.signal.reason`; expect rejection with that exact + error (NOT wrapped in `XaiTokenRequestError`), 1 fetch call, zero sleeps. + Proves #4047 (reason-carrying abort). +12. `token request timeout is terminal` — fetch stub rejects with + `new DOMException("timed out", "TimeoutError")`, no caller abort; expect + rejection with `name: "TimeoutError"` (not wrapped), 1 fetch, zero sleeps. +13. `Bun-shaped timeout abort is terminal` — fetch stub rejects with + `new DOMException("The operation was aborted", "AbortError")` while the + caller signal is NOT aborted; expect rejection, 1 fetch, zero sleeps. +14. `caller aborted before a 429 backoff does not sleep` — abort inside the + fetch stub before returning the 429; expect rejection with the 429 + `XaiTokenRequestError` and zero sleeps. Proves the pre-sleep guard. +15. `caller abort during a Retry-After wait rejects promptly` — 429 with + `retry-after: 60`; injected `sleep` records its argument then returns a + never-resolving promise; the test body waits until the `60000` argument is + recorded, THEN calls `controller.abort(new Error("cancel during wait"))` + (never synchronously inside the injected sleep — `Promise.race` evaluates + `sleep(ms)` before the abort listener is attached); expect rejection with + that exact error, 1 fetch call, and the recorded sleep argument `60000`. + Proves the in-wait abort race (round-2 High fold). + +Existing-test compatibility (traced line by line by the round-2 auditor): +`network retry succeeds` still sleeps `[100]`; `429 and 5xx retry at most +three attempts` still sleeps `[100, 250]`; `third transient failure is +final` and `permanent 4xx` untouched; `caller abort is not retried` still +rejects with the `AbortError` DOMException via the `signal?.aborted` guard. + +## Verifier + +Remote: on the pull request, `ci.yml` runs the Linux `test` job, the macOS +`platform-macos` job, and the `gates` job (`tsc --noEmit`); the +`changes` filter covers `src/**` and `tests/**`, so +`tests/providers/xai/xai-oauth-retry.test.ts` executes in the Linux batches and +macOS shards. The Windows job (`platform-windows`) and `macos-control` are +NOT ON PR — they run only on `workflow_dispatch` with `lane=all`; the +cumulative final-head `lane=all` dispatch before merge is owned by the managing +task. The PR records the exact-head run URLs. Local: NOT RUN (`bun test`, +`bun run typecheck`) — user restriction; compile risk is covered by the +`gates` typecheck job, and the diff stays inside one already-typed function +cluster. + +## Audit record + +- Round 1 (four read-only xai/grok-4.6 issue verifiers): #4045 CONFIRMED; + #4046 PARTIALLY (sketch parser wrong — folded: strict donor parser); + #4047 PARTIALLY (Bun timeout surfaces as `AbortError`, per-attempt fresh + timer — folded: two-name guard, Bun-shaped test); #4048 CONFIRMED (phase 2). +- Round 2 (independent plan auditor): GO-WITH-FIXES (blockers=1) — HTTP-date + test was vacuous (folded: pinned random, assertion above the jitter cap); + CI map overstated Windows (folded); hostile parser vectors untested (folded); + donor-fidelity regex and truncated catch hunk (both folded). +- Round 2 (bounded retry-algorithm reviewer, via managing task): FAIL, one real + High — in-wait abort: `Bun.sleep` is not signal-aware, so an abort during a + honored 60 s wait could cancel up to ~120 s late. Folded: `sleepAbortable` + race with listener cleanup around the (possibly injected) sleep primitive, + pre-sleep and in-wait coverage, test 15. Managing-task invariant folded: a + server delay beyond the local budget must be terminal, never a silent early + retry — the retry-budget rule replaces the issue sketch's clamp. +- Round 3 (PR #4087 review bots on the published diff): Codex P1 — the 020 + phase-2 doc restated an unreleased endpoint-validation weakness and its + remediation in tracked devlog; folded by stripping 020 to a minimal stub with + all assessment/plan detail in gitignored scratch only. CodeRabbit Major — + RFC 9110 §5.6.7 requires recipients to accept all three HTTP-date formats; + folded by copying the donor parser at full fidelity (IMF-fixdate + RFC 850 + 50-year rule + asctime) and adding tests 6b/6c. diff --git a/devlog/_plan/260909_xai_oauth_retry_hardening/020_phase2_endpoint_validation.md b/devlog/_plan/260909_xai_oauth_retry_hardening/020_phase2_endpoint_validation.md new file mode 100644 index 0000000000..15e222db81 --- /dev/null +++ b/devlog/_plan/260909_xai_oauth_retry_hardening/020_phase2_endpoint_validation.md @@ -0,0 +1,19 @@ +# Phase 2 (wp3): xAI OAuth endpoint validation hardening + +Closes #4048 (security hardening). Separate PR layered on the phase-1 head +because it edits the same file. + +Per the repository security-notes policy (root `AGENTS.md`), everything about +this phase beyond the fact that it exists — the assessment, the patch plan, and +the test plan — is held in scratch space (`.tmp/260909_xai_endpoint_security/`, +gitignored) until the fix's own diff is public. A public issue describing a +weakness is not by itself license to restate the weakness, its blast radius, and +the remediation in a tracked document before the fix ships; the published +outcome (the merged diff and its release note) is what enters the record. + +## Phase entry condition + +Phase 2 starts only after phase 1's PR is published, and rebases onto the +published phase-1 head (manual chain: PR2 base = PR1 head branch). Its +pre-written scratch plan is re-verified against the rebased code before +implementation (LOOP-CONTINUITY-01). diff --git a/docs-site/public/pr-screenshots/3984-model-feedback.png b/docs-site/public/pr-screenshots/3984-model-feedback.png new file mode 100644 index 0000000000..91d05e8060 Binary files /dev/null and b/docs-site/public/pr-screenshots/3984-model-feedback.png differ diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 874b98b4bd..7b4c8b2fe8 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -43,6 +43,12 @@ bun run prepare:package # refresh package launchers/assets `origin/dev`, then local `dev`. It reports that ref and the exact `git merge-base HEAD ` commit, then passes the merge-base SHA to Bun. +If a test lane times out, the runner prints the stdout and stderr it has already +captured and exits with code 124. After a process exits, captured pipes have a +one-second drain limit so a descendant holding a pipe open cannot stall the runner. +Incomplete capture is reported explicitly and does not count as a successful run, +even if the direct child exited with code 0. + Tests are Bun tests in domain directories that mirror `src/`: `tests/server/`, `tests/providers/`, `tests/adapters/openai/`, `tests/cli/` and so on. `scripts/test-layout/layout.json` is the map and `tests/test-layout.test.ts` enforces it, so a new test goes into its domain directory and gets 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 a351adeae5..eeaf4f8af8 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -385,10 +385,9 @@ d'actualisation `chatgpt` vaut `proactive` et si `tokenGuardian.codexWarmupEnabl ## Restauration de Codex natif -opencodex ne vous enferme jamais dans sa configuration. **`ocx stop` est l'unique commande qui restaure -entièrement Codex natif** : elle arrête le proxy et le service d'arrière-plan s'il est installé, puis supprime -toutes les lignes injectées et toutes les entrées routées du catalogue. La commande `codex` fonctionne alors -exactement comme si opencodex n'avait jamais été installé : +`ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. + +Si la configuration ou le profil actuel diffère de l'original sauvegardé et que le journal ne contient pas le hash de l'état injecté de ce fichier, la récupération automatique conserve les deux fichiers et le journal sans les modifier. Un fichier déjà identique à son original n'est pas réécrit. La réinjection d'une configuration routée refuse aussi cet état incertain ; une configuration native peut créer un nouvel instantané. Voir les [règles de récupération](/guides/codex-integration/#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 97babedd18..9dd4e5bc2e 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -125,9 +125,9 @@ l'actualisation fusionne les changements autour de vos entrées et les conserve, comme `1e999`, un nombre qu'une réécriture arrondirait (un très grand entier ou une valeur si petite qu'elle deviendrait zéro), `-0`, une même clé écrite deux fois dans un objet ou une imbrication de plus de 1000 niveaux. Dans ces cas, le commutateur est verrouillé afin que rien ne soit modifié ou supprimé silencieusement. -**OMP** n'est pas affecté non plus par les modifications voisines, mais pour une autre raison : son outil -d'écriture ne modifie, octet par octet, que sa propre plage `providers.opencodex` ; le reste du fichier -n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (Hermes, OpenClaw, +**OMP, DSH et Hermes** ne sont pas affectés non plus par les modifications voisines, mais pour une autre raison : leurs outils +d'écriture ne modifient, octet par octet, que leur propre plage `providers.opencodex` ; le reste du fichier +n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (OpenClaw, Kimi Code, Gajae Code, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. diff --git a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md index ce0dcf747f..050d1dfe8f 100644 --- a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md @@ -86,8 +86,9 @@ lorsqu'un modèle préféré, une liste éligible ou une chaîne de secours est est suffisant pour afficher une invite personnalisée ; si une valeur non qualifiée ne peut pas être résolue de manière unique, `{{model}}` se développe en une chaîne vide. -Sur la v1, opencodex injecte uniquement les conseils de délégation proactive de style amont à `max` ou `ultra` -effort. Il n’ajoute aucun modèle préféré, aucune liste, aucune chaîne de repli ni aucune invite personnalisée en v1. +Sur la v1, opencodex injecte le même texte de délégation proactive que le préréglage recommandé de la v2, uniquement aux niveaux d’effort `max` ou `ultra`. +Seule la condition de déclenchement change : aucune demande de délégation distincte n’est nécessaire ; les instructions de l’utilisateur, les autorisations, le périmètre de la tâche et les règles des outils de collaboration restent applicables. +Il n’ajoute aucun modèle préféré, aucune liste, aucune chaîne de repli ni aucune invite personnalisée en v1. L'option `syncCodexSubagentDefaults` désactivée par défaut est distincte du guidage. Quand opencodex possède le routage Codex actif, la synchronisation ou le redémarrage peut écrire les valeurs sélectionnées en tant que propriété du marqueur 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 f772e8397b..db97b0e0a9 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -53,6 +53,10 @@ Récupération explicite destinée aux anciennes versions de développement qui Il s'agit d'un réétiquetage large et destructif : chaque fil contenant un message utilisateur et actuellement marqué `opencodex` passe à `openai`, `exec` est normalisé en `cli` et l'indicateur d'événement est activé. L'historique légitime d'un fournisseur dédié est également concerné. Sauvegardez l'état et n'exécutez la commande que si vous souhaitez cette portée complète. +### `ocx recover-history --ocx-compaction --yes` + +Réparez l'historique d'une tâche compactée par un fournisseur routé avant de la reprendre avec Codex natif. La commande sélectionne exactement une tâche par UUID, enregistre d'abord une sauvegarde privée octet par octet, puis convertit uniquement l'état de compaction `ocx1:` propre à OpenCodeX en résumé ordinaire relisible par Codex natif. Le contenu chiffré natif et les autres tâches restent inchangés. Fermez la tâche sélectionnée avant d'exécuter la commande ; toute modification simultanée du rollout interrompt la récupération sans remplacer le fichier. + ### `ocx uninstall` · `ocx remove` Arrête le service et le proxy, supprime le service et le shim Codex, rétablit le fonctionnement natif de Codex, puis supprime la configuration locale d’opencodex uniquement si toutes les étapes de restauration ont réussi. `remove` est un alias de `uninstall`. Le nettoyage de la configuration exige les métadonnées de propriété créées par une installation récente ; les répertoires anciens ou partagés sont conservés. diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index b319274602..fef98b70fb 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -20,6 +20,10 @@ la sécurité des réponses se produit toujours à la limite du proxy. Configure [Configuration](/fr/reference/configuration/); utilisez [Combos](/fr/guides/combos/) lorsqu'un identifiant de modèle public doit choisir parmi plusieurs cibles. +## Redirections en amont + +Les requêtes de modèle, d’image, de vidéo et de recherche contenant des identifiants ne suivent pas automatiquement les redirections HTTP, même vers la même origine. Configurez l’URL finale de l’API plutôt qu’un alias qui redirige. Le serveur ne renvoie ni les identifiants ni le corps de la requête à la destination d’une redirection. Chaque chemin conserve sa gestion des erreurs ou son relais existant ; les routes Responses natives et compact peuvent renvoyer le 3xx et le `Location` d’origine au client. Le comportement de redirection du client est distinct de cette politique de transport du serveur. + ## Présentation du point de terminaison | Espace client | Point de terminaison | Résultat non-stream réussi | Résultat de flux ou de socket réussi | @@ -321,16 +325,18 @@ utilisez la matrice ci-dessous. « Dédié » signifie `X-OpenCodex-API-Key` ; l | Surfaces | Dédié | Porteur | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP et WebSocket | Obligatoire | Rejeté pour l’admission au proxy | Rejeté | -| `/v1/responses/compact` | Obligatoire | Rejeté pour l’admission au proxy | Rejeté | -| `/v1/chat/completions` | Obligatoire | Rejeté pour l’admission au proxy | Rejeté | +| `/v1/responses` HTTP et WebSocket | Accepté | Accepté | Rejeté | +| `/v1/responses/compact` | Accepté | Accepté | Rejeté | +| `/v1/chat/completions` | Accepté | Accepté | Rejeté | | `/v1/messages` et `/v1/messages/count_tokens` | Accepté | Accepté | Accepté | | `/v1/models` | Accepté | Accepté | Accepté | | `/v1/live`, `/v1/realtime/calls` et jointures de bande latérale | Accepté | Accepté | Accepté | -Réponses-famille et demandes de chat réservées `Authorization` au fournisseur ou Codex Direct -passthrough, donc une clé proxy distante doit utiliser l'en-tête dédié. Messages et surfaces en temps réel -ont besoin d’une compatibilité client plus large et acceptent donc les trois formes. +Les requêtes Responses et Chat acceptent une clé du proxy dans l’en-tête dédié ou dans Bearer. Sur une route native, l’identifiant Codex stocké sélectionné remplace le bearer d’admission ; sur les autres routes, ce bearer est supprimé. Il ne sert jamais d’identifiant upstream. Utilisez l’en-tête dédié si vous fournissez aussi un bearer distinct pour le fournisseur. + +Une route Cursor sans clé et sans OAuth peut utiliser ce bearer distinct de l’appelant, mais jamais un secret du proxy ni l’authentification ChatGPT main ajoutée automatiquement. La sélection Combo/policy et les réécritures effectives shadow/thread-spawn ne transmettent pas les identifiants bruts de l’appelant aux nouvelles cibles. Le routage OpenAI canonique peut restaurer l’unique bearer de l’appelant qui n’est pas une clé du proxy après un changement de route interne uniquement si son JWT contient un claim de compte ChatGPT et si tout en-tête de compte explicite correspond à ce claim. La transmission de l’authentification de l’appelant aux sidecars OpenAI facultatifs exige un unique JWT et un `chatgpt-account-id` explicite et correspondant. Les bearers opaques ne sont pas restaurés lors des changements de route, même avec un en-tête de compte explicite. Dans les autres cas, la cible finale doit disposer de son propre identifiant configuré, OAuth ou stocké ; sinon, la requête échoue localement. Un simple marqueur thread-spawn sans changement de route ne supprime pas les identifiants. + +Le replay Claude ne conserve l’authentification main que dans un snapshot en mémoire dont le turn a acquis la propriété, et ne la reconstruit que pour une route ChatGPT canonique finale. :::caution Les clés du plan de données ne sont pas des informations d’identification de gestion. La gestion API utilise un secret d'administration distinct ; diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 1d4b6b1b26..fe074e4a3d 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -36,6 +36,34 @@ stop setup without falling back to an overwrite. If publication or temporary-fil finish, inspect the config directory before retrying: a complete config or private temporary file may remain. +If setup reports that initial config permissions could not be secured, the filesystem or account +could not apply the required private permissions (NTFS ACLs on Windows). This happens before +config contents are written. A hard-link publication error is a separate failure: private +permissions were applied, but publishing the completed file failed or its outcome is uncertain. + +Inspect the selected config directory before retrying. Preserve any existing `config.json`; +do not delete it to force setup to proceed. For a fresh installation, choose a writable location +that supports both hard links and private permissions. A local NTFS directory is a suitable +Windows choice when your account can apply its ACLs. For example, select a new location in the +same terminal before running setup: + +```powershell +# Windows PowerShell: choose a fresh directory on a local NTFS volume. +$env:OPENCODEX_HOME = Join-Path $env:LOCALAPPDATA "opencodex-local" +ocx init +``` + +```sh +# macOS/Linux: choose a fresh directory on a filesystem with hard links and Unix permissions. +export OPENCODEX_HOME="$HOME/.opencodex-local" +ocx init +``` + +Use the same `OPENCODEX_HOME` for subsequent commands and the service that runs the proxy. +Changing this variable selects a separate configuration location; it does not migrate an existing +installation. Setup intentionally has no direct-write or replacing-rename fallback: creating an +exclusive file and then writing to it could expose partial config contents. + :::note[GPT-5.6 rollout entries] The current stable release seeds GPT-5.6 Sol/Terra/Luna for ChatGPT passthrough, OpenAI API-key, OpenRouter, and diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index ae067bad4f..04090553be 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -622,6 +622,39 @@ Replay preserves non-hidden signed blocks (including empty thinking) and opaque role; `tool_result` without `tool_use_id`; `tool_use` without id/name; named `tool_choice` without name. +### Unicode-property patterns in tool schemas + +A JSON Schema `pattern` written for JavaScript may use Unicode property escapes such as +`\p{Cc}` or `\P{L}`. OpenAI-family backends validate `pattern` by compiling it with Python's +`re`, which does not support those escapes, and a schema they cannot compile is refused whole — +so a single such pattern on one built-in tool fails every request in the session, not just calls +to that tool. + +To keep ordinary Artifact parameters working, the `openai-chat` and `openai-responses` adapter +paths omit scalar `pattern` constraints containing Unicode property escapes in ordinary positive +schema positions. Sibling constraints, `required`, literal data and supported regexes remain. +A tool implementation must validate its own inputs because an omitted constraint is not enforced +by this proxy. + +`patternProperties` matchers and their value schemas remain unchanged. Removing a matcher can +change which keys are evaluated by an ancestor's `unevaluatedProperties`, so local openness is +not enough to prove a safe transformation. Patterns under `not`, `oneOf`, `if`, `contains`, +`$defs` and `definitions` also remain unchanged: relaxing those subtrees can change negation, +branch selection, match counts or the meaning of a reference. + +The destination validates these preserved schemas. An ECMA-compatible destination can use the +original regex; a destination that cannot compile it may reject the schema. OpenCodex does not +silently replace that contract with one that forbids previously valid arguments. + +This is normalization on the selected adapter path, not a provider-wide guarantee. Provider +configuration and authentication are untouched, and a provider on a different adapter is +unaffected. + +It is a compatibility measure, not a claim that every custom OpenAI-compatible backend rejects +these patterns. What it costs is worth knowing: an omitted regex is not preserved anywhere and is +not enforced upstream, so a tool implementation should validate its own inputs rather than relying +on the schema to reject a malformed argument. + ## Outbound translation (Responses → Messages SSE) | Responses event | Messages SSE | diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index a83719c7fd..513218fe12 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -320,3 +320,5 @@ ocx sync opencodex rewrites `models_cache.json` with a deliberately stale cache wrapper whenever catalog visibility, priority, or metadata changes, so the next Codex model refresh reads the new catalog. + +After a catalog or model-cache write, OpenCodex invalidates its cached app-server observation so the next request checks process freshness again. A configuration sync also invalidates the observation when catalog contents are unchanged. This refresh does not restart Codex processes. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index f5592c0a72..4e23da2e32 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -20,6 +20,13 @@ plus `openai-apikey/` for the configured API key. Pool includes main plus Direct uses only the caller/main bearer. The routes do not fall back to one another. Shipped v1 configs migrate to marker 2 and preserve `config.json.pre-openai-tiers-v2.bak` for manual restore. +Within Pool mode, a request carrying a validated native Codex login can use that login when the +selected stored account is cooling down and no eligible stored alternative or recovery probe is +available. This also covers a new request blocked before sending, following the same caller +validation used after an upstream rejection. Existing model-permission and main-account policy +checks still apply. The fallback preserves the stored account's cooldown and does not persist the +caller credential as the Pool selection. An exact account binding remains bound to that account. + ## Config injection `ocx init`, `ocx start`, and `ocx sync` call the injector. On the default loopback bind, it keeps @@ -49,10 +56,69 @@ current bearer, so the key only keeps the join on the proxy path. It is written `openai_base_url` form, is removed together with it, and a user-owned `experimental_realtime_ws_base_url` is never overwritten. +### Voice transport and task handoffs + +Codex owns the microphone and speaker, WebRTC media negotiation, captions, mute controls, and +voice cleanup when switching threads. OpenCodex relays call creation and the sideband connection; +work delegated by voice uses the normal Responses routing path. Choosing a text provider does +not replace the realtime speech model or enable voice in a client that does not support it. + +The upstream [WebRTC helper change](https://github.com/openai/codex/commit/1b53f6a44eff890b5169bde8d3bd5b12b8766946) +and [TUI voice integration](https://github.com/openai/codex/commit/b01c3986fd2e79b8a477a08d81430f52f22bc0dc) +describe these client responsibilities, including speaking final answers from voice handoffs. +Their merge dates do not establish when the same behavior reached the desktop app. + +Optional `OCX_LIVE_FRAME_LOG` diagnostics write only frame timestamp, direction, kind, byte count, +and a replacement-character flag (`ts`, `dir`, `kind`, `bytes`, `fffd`). They do not store voice +text or frame excerpts. For binary frames, UTF-8 decoding can itself produce replacement +characters, so the flag alone does not identify where corruption occurred. Existing log files +are not rewritten. + +### Fast mode + The injected `fast_mode` follows the tri-state `fastMode` setting: `true` writes `fast_mode = true`, `false` writes `fast_mode = false`, and unset leaves an existing `fast_mode` untouched without adding a `[features]` table. +Fast mode is separate from voice transport. A supported model's service-tier speed description +does not guarantee lower microphone, WebRTC, or end-to-end voice latency through OpenCodex. + +### ChatGPT-family channel and latency + +Requests routed through opencodex via the canonical ChatGPT-login `openai` provider — adapter +`openai-responses`, `authMode: "forward"`, and the `https://chatgpt.com/backend-api/codex` +endpoint, covering both Pool and Direct modes — use the public ChatGPT endpoint. Provider routing +or account selection does not bypass the upstream ChatGPT channel. The upstream may spend time +queueing a request before the first output even when the local proxy and network path are healthy. + +Only some turns take the ChatGPT websocket transport — the same `responses_websockets` lane Codex +CLI defaults to. A turn is eligible when the Bun runtime supports the bounded relay, the request +is a `POST` to the canonical Responses URL or a configured WebSocket route, and its JSON body sets +`stream` to `true` at the root. Everything else stays on SSE over HTTP, and an eligible turn still +falls back to it when the request cannot be prepared, the `response.create` frame exceeds its size +limit, or the proxy route cannot carry the socket. + +Local provider pacing can also hold a request before it is dispatched at all. So a slow first +output has several possible contributors, and upstream queueing is only one of them. `ocx doctor` +classifies configuration and measures none of these: compare actual transport, pacing, network, +and provider observations before concluding. + +What decides whether a request takes that public channel is the destination it resolves to, not +the name of the provider entry. A provider that resolves somewhere else — `openai-apikey`, or a +custom entry pointing at its own API — reaches that endpoint directly and sees no ChatGPT queueing. +A custom-named entry that resolves to `https://chatgpt.com/backend-api/codex` with forward auth +takes the same public channel as the built-in row, because the classification reads the adapter, +auth mode and destination rather than the entry's name. + +The `ocx doctor` hint is narrower than the endpoint behavior it describes: it inspects only the +built-in `openai` row, so its absence tells you nothing about where any other provider resolves. + +`service_tier: priority` is a request preference. On the ChatGPT backend the echoed +`service_tier` cannot confirm or deny the granted tier: turns scheduled as priority can still +echo `default`, so request logs show the response tier as an observation with confirmation +`assumed`. For latency-sensitive work, compare observed first-output times across the providers you +actually use rather than assuming any particular channel is faster. + The proxy listens on port `10100` by default and serves `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations`, `POST /v1/images/edits`, `GET /v1/models`, `GET /healthz`, and the `/api/*` management surface. @@ -213,6 +279,83 @@ provider advertises `supports_websockets = true` only when `"websockets": true`; built-in provider may try WebSocket first, and a disabled proxy returns `426` so Codex falls back to HTTP/SSE. +If a canonical ChatGPT forward continuation references expired or missing local replay state, +opencodex returns `previous_response_not_found` before sending anything upstream. Codex's +WebSocket client recognizes this error and can reconnect with its full retained context, +including completed tool calls and their results, within its normal stream retry budget. An +idle task therefore does not need a new task solely because the proxy's one-hour cache expired. +The cache remains bounded; this does not extend retention or recover history the client no +longer has. HTTP clients must handle the error explicitly and resend their full context without +`previous_response_id`. Retrying only the same ID cannot recover missing state. + +### Client-side compaction (opt-in) + +Authenticated loopback routing normally keeps Codex on its built-in `openai` provider identity. +That preserves native thread identity, but it also makes Codex request native remote compaction. +When a routed provider cannot return a native compaction blob, OpenCodeX stores the summary in its +own `ocx1:` envelope. Native ChatGPT cannot verify that envelope if OpenCodeX is later removed from +the request path. + +On an authenticated loopback route, enable client-side compaction to keep V2 sub-agent routing while preventing new `ocx1:` compaction summaries. Non-loopback and API-key routes retain their existing provider and authentication behavior: + +```bash +ocx system settings --client-compaction on # or "codexClientCompaction": true in config.json +ocx sync # rewrites the active config (default: ~/.codex/config.toml); restart Desktop +``` + +The setting defaults to off. For authenticated loopback routing, OpenCodeX selects its existing +dedicated provider form with `requires_openai_auth = true`. If `codexDesktopAuthless` is also +enabled, that stronger compatibility setting takes precedence and writes +`requires_openai_auth = false`: + +```toml +model_provider = "opencodex" + +[model_providers.opencodex] +name = "OpenCodex Proxy" +base_url = "http://127.0.0.1:10100/v1" +wire_api = "responses" +requires_openai_auth = true +``` + +Codex then owns compaction and stores a portable plaintext summary rather than a new OpenCodeX +envelope. The compacting request still routes through OpenCodeX and can consume quota on the +selected provider. V2 sub-agent requests keep their existing provider selection and quota +accounting. Client-side compaction does not change plaintext delivery, encrypted task passthrough +through `allowEncryptedV2AgentTasks`, or configured recovery and fallback behavior. + +This preference affects future compactions only, and it rewrites no existing `ocx1:` payload in +any configuration, so use the explicit history recovery workflow for a thread that needs one. + +Whether resume-history metadata is re-tagged depends on which form the injection takes. On its +own, on an authenticated loopback bind, client-side compaction re-tags nothing: it keeps the root +override instead, as described below. Enabled together with `codexDesktopAuthless`, or on a +non-loopback bind, the stronger form wins and those forms behave exactly as they do today, +including their existing forward-tagging of resume history with originals backed up for restore. + +Going back from one of those forms to plain Design B migrates the re-tagged threads back. Turning +off `codexDesktopAuthless` while leaving client-side compaction on does not: that lands on the +compaction-only form, which skips the history unit, so threads already tagged `opencodex` keep +that tag. They still reach this proxy, through the provider table rather than the root override. + +On the compaction-only form, existing threads keep working because the injection keeps the root +`openai_base_url` override alongside the provider table. New threads default to `opencodex` and +get client-side compaction, while a thread already tagged `openai` still resolves to Codex's +built-in provider — which the retained override still points at this proxy. Without it that +thread would resume against OpenAI directly, taking configured routing with it. The authless and +non-loopback forms cannot use the root key, which is why they keep re-tagging instead. + +That guarantee covers the override OpenCodeX manages. A root `openai_base_url` you wrote +yourself is never replaced, and in that case the built-in provider keeps the destination you +chose, so an `openai`-tagged thread follows your configuration rather than this proxy. Turning +the setting off and syncing removes the table and returns to the plain Design B override, unless +`codexDesktopAuthless` or non-loopback admission still requires the provider-table form; those +two forms cannot use the root key and are unchanged. + +While the mode is active, the realtime voice sideband override +(`experimental_realtime_ws_base_url`) is not injected — the dedicated provider-table form cannot +carry it — so Codex Desktop voice uses its native endpoint rather than the proxy. + ### Authless Codex Desktop (opt-in) In **Dashboard → Overview**, **Open Codex without signing in** controls this existing @@ -573,9 +716,9 @@ off by default; it runs only when Token Guardian is enabled, the `chatgpt` refre ## Restoring native Codex -opencodex never traps you. **`ocx stop` is the single command that fully reverts to native Codex** — it -stops the proxy, stops the background service if one is installed, and strips every injected line and -routed catalog entry so plain `codex` works exactly as if opencodex was never there: +`ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. + +Recovery may require manual review when the journal cannot verify the current files; see [recovery without injection hashes](#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex @@ -587,6 +730,24 @@ When opencodex runs as a managed [background service](/reference/cli/#ocx-servic `OCX_SERVICE=1` so a service-driven restart does **not** thrash the Codex config — only an explicit `ocx stop` / `ocx service stop` restores native Codex. +### Recovery without injection hashes + +The journal saves the original `config.toml` and `opencodex.config.toml` plus hashes of the state +OpenCodex injected. A legacy journal or an interruption before those hashes were recorded cannot +prove that later file contents belong to OpenCodex. If either file differs from its saved original +and lacks its own injected-state hash, automatic journal recovery and native restore report failure +without changing either file or the journal. The saved original remains available for comparison; +review it alongside the current files before choosing a manual recovery action. + +Files already equal to their saved originals are accepted without rewriting them. A missing file +is distinct from an empty file. Verified injected hashes still allow normal snapshot restoration, +and later edits in hash-backed configurations retain the existing owned-field cleanup behavior. + +Sync and `ocx restore back` also reject an existing routed configuration whose hashless journal +does not match the pre-injection baseline. This prevents a new injection hash from being attached +to an older original. A genuinely native configuration can be saved as a fresh baseline before +injection. Explicit external-provider opt-out behavior is unchanged. + ### Sub-agent fallback and V2 compatibility diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 031766373e..db94da045f 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -74,6 +74,19 @@ Aliases change the public name clients request; they do not change the combo's s concrete provider/model selectors behind it. ::: +## Compaction after switching combos + +When a client compacts using a bare model name after switching combos, opencodex can recall the +combo that most recently completed successfully on that conversation lane. The model must match +the completed response, and the combo and its target must still exist in the current configuration. +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. +Without usable conversation identity or valid remembered state, normal compaction routing applies. +A restart clears the remembered state. + ## Codex Desktop native-allowlist compatibility Some Codex Desktop releases apply a remote native-only `available_models` allowlist after the diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index b475b71e20..166a848614 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -172,11 +172,11 @@ normalized. The exception is something JSON cannot rewrite exactly — a non-fin number like `1e999`, a number a rewrite would round (a very large integer, or one so small it collapses to zero), `-0`, the same key written twice in one object, or nesting deeper than 1000 levels — which locks the switch instead, so nothing is silently changed or dropped. -**OMP** is unaffected by sibling edits too, for a different reason: its writer -patches only its own `providers.opencodex` range byte-wise, so the rest of the +**OMP, DSH and Hermes** are unaffected by sibling edits too, for a different reason: their writers +patch only their own managed provider ranges byte-wise, so the rest of the file is never rewritten. For the remaining formats that can carry comments -(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code, Raycast — YAML, JSON5 and TOML -written as whole documents), or +(OpenClaw, Kimi Code, Gajae Code, MiniMax Code, Raycast — JSON5 and TOML +written as whole documents, or generic YAML without source preservation), or whenever our own entries were edited, the switch locks and disable refuses rather than guessing which edits were yours. @@ -192,7 +192,7 @@ parse, or one whose structure we cannot reason about, still refuses. **Formatting is generally not preserved.** Applying parses a config and writes it back out, so JSON, JSON5 and TOML may be reformatted and comments in JSON5 or TOML are lost. -OMP and DSH are the exceptions: their YAML writers patch only `providers.opencodex` and +OMP, DSH and Hermes are the exceptions: their YAML writers patch only `providers.opencodex` and `llm-pi-ai.providers.opencodex`, respectively, preserving unrelated provider comments and formatting byte-for-byte. If that exact source range cannot be identified safely, the operation refuses instead. For other clients, use diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index cd6a8c2205..8cd68f9a63 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -6,6 +6,13 @@ description: Every way opencodex authenticates and talks to an LLM provider — A **provider** is one upstream LLM endpoint plus how to reach it: an adapter, a base URL, an auth mode, and an optional model list. Providers live under `providers` in `~/.opencodex/config.json`. +The dashboard provider Overview separates connection details, account usage and editable notes. +Notes appear once, below the connection and authentication sections. Supported sponsor presets +also show a short introduction, a Sponsor label and links to the provider's site or console. +These links preserve the preset's referral parameters. Sponsor information is shown only when +the configured provider name, adapter and endpoint match the preset; it never changes routing, +account selection or defaults. + ## OpenAI account modes | Provider id | Use | Credential/account rule | @@ -383,6 +390,21 @@ preserves those requested tiers; any backend-specific normalization remains Clin available in the Cline IDE/CLI, not through the API; `minimax/minimax-m2.5` is the documented API free-experimentation model. +**OrcaRouter** ([sponsor](https://github.com/lidge-jun/opencodex/blob/main/SPONSORS.md)) is an +OpenAI-compatible gateway at `https://api.orcarouter.ai/v1` with vendor-namespaced model ids +(`openai/gpt-5.5`, `anthropic/claude-opus-4.8`, `deepseek/deepseek-v4-pro`, ...) and an adaptive +router, `orcarouter/auto`, that grades each prompt and picks the model. Create a key in the +[OrcaRouter console](https://www.orcarouter.ai/console); the preset pins the row near the top of the +Add provider picker and marks it as a sponsor, and nothing else about routing or defaults changes. + +**PackyCode** ([sponsor](https://github.com/lidge-jun/opencodex/blob/main/SPONSORS.md)) is an API +relay for Claude Code, Codex, Gemini and more. The preset targets their OpenAI-compatible Chat +Completions endpoint, `https://cf.api.fan/v1`, with live model discovery narrowed to what your +token group allows (`gpt-5.5` and `gpt-5.1-codex` are seeded). Register at +[packyapi.com](https://www.packyapi.com/register?aff=k5KT) and create a Codex-group token; the preset +pins the row near the top of the Add provider picker and marks it as a sponsor, and nothing else about +routing or defaults changes. + | Provider | Base URL | | --- | --- | | **OpenAI (API key)** | `https://api.openai.com/v1` | @@ -406,6 +428,7 @@ free-experimentation model. | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | | OrcaRouter | `https://api.orcarouter.ai/v1` | +| PackyCode | `https://cf.api.fan/v1` | | Meta Model API | `https://api.meta.ai/v1` | | Meta Muse Code (CLI credential) | `https://api.meta.ai/v1` | | SambaNova Cloud | `https://api.sambanova.ai/v1` | @@ -674,6 +697,72 @@ Create a key in [Novita's key manager](https://novita.ai/settings/key-management > hosts and schemas and are not routed by this preset. > Live discovery for this preset is capped at a 1 MiB response and 256 raw model rows. +### Official CodeBuddy Code CLI (Global & CN) + +OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code CLI via the `codebuddy` (Global) and `codebuddy-cn` (China) presets. + +```json +{ + "providers": { + "codebuddy": { + "adapter": "codebuddy", + "baseUrl": "https://www.codebuddy.ai", + "apiKey": "${CODEBUDDY_API_KEY}" + }, + "codebuddy-cn": { + "adapter": "codebuddy", + "baseUrl": "https://www.codebuddy.cn", + "apiKey": "${CODEBUDDY_CN_API_KEY}" + } + } +} +``` + +- **Prerequisites:** Install the official CodeBuddy CLI globally: + ```bash + npm install -g @tencent-ai/codebuddy-code + ``` +- **Authentication:** Obtain your official API key from the vendor console: + - 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. +- **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) + +OpenCodex provides official adapter support for Qoder through the `qoder` (Global) and `qoder-cn` (China) presets. Both use a user-supplied Personal Access Token and the vendor's headless CLI; OpenCodex never reads Qoder Desktop sessions, browser cookies, refresh tokens, or private console APIs. + +```json +{ + "providers": { + "qoder": { + "adapter": "qoder", + "baseUrl": "https://qoder.com", + "apiKey": "${QODER_PERSONAL_ACCESS_TOKEN}" + }, + "qoder-cn": { + "adapter": "qoder", + "baseUrl": "https://qoder.cn", + "apiKey": "${QODERCN_PERSONAL_ACCESS_TOKEN}" + } + } +} +``` + +- **Prerequisites:** Install the official CLI for the region you use: + ```bash + npm install -g @qoder-ai/qodercli # Global: qoder / qodercli + npm install -g @qodercn-ai/qoderclicn # CN: qodercn / qoderclicn + ``` +- **Authentication:** Create a PAT in the account integrations page + ([Global](https://qoder.com/account/integrations), [CN](https://qoder.cn/account/integrations)) and paste it as the provider's API key. The stored key reaches the CLI only as `QODER_PERSONAL_ACCESS_TOKEN` (Global) or `QODERCN_PERSONAL_ACCESS_TOKEN` (CN) in a scoped child environment. +- **Region Isolation:** Each preset accepts only its canonical destination (`https://qoder.com` or `https://qoder.cn`) and resolves its own executable. Credentials, model cache, usage, and health are independent; neither region falls back to the other. An older custom provider named `qoder` with a different destination keeps its existing adapter and URL. +- **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed. +- **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. v1 is text and reasoning only; image input fails explicitly. +- **Quota:** No public quota API is used, so totals and reset times are unavailable. Insufficient-credit errors (vendor code 118) surface as HTTP 429 `insufficient_quota`. +- **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. + ### A6API credit quota A custom `openai-chat` provider using `authMode: "key"` and the canonical diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 53e9fe2617..fcaa576b53 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -155,8 +155,10 @@ when a preferred model, eligible roster, or fallback chain resolves. A configure is sufficient to render a custom prompt; if a bare value cannot resolve uniquely, `{{model}}` expands to an empty string. -On v1, opencodex injects only the upstream-style proactive delegation guidance at `max` or `ultra` -effort. It does not add a preferred model, roster, fallback list, or custom prompt on v1. +On v1, opencodex injects the same proactive delegation guidance as the v2 recommended preset only +at `max` or `ultra` effort. Only the delegation trigger changes: no separate delegation request is +needed; user instructions, authority, task scope, and collaboration-tool rules still apply. +It does not add a preferred model, roster, fallback list, or custom prompt on v1. The default-off `syncCodexSubagentDefaults` option is separate from guidance. When opencodex owns active Codex routing, sync or restart can write the selected values as marker-owned diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 72adcdcd70..7e86901233 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -331,7 +331,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. | | `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, generatedAt, total, logs }` where `total` is the filtered row count before pagination. | | `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. | -| `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. Refused with `respawnable_service` on the Windows Task Scheduler backend, and with `service_state_unknown` when that state cannot be read; nothing is changed either way. | +| `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. Refused with `respawnable_service` on the Windows Task Scheduler backend, with `self_unload_service` when this proxy is itself the installed launchd/systemd job, and with `service_state_unknown` when the Task Scheduler state cannot be read; nothing is changed in any of those cases. | :::tip Adding **Ollama Cloud** or another catalog provider from the dashboard copies its text-versus-vision 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 46c33320f2..df320c660f 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -245,7 +245,9 @@ ChatGPT アカウントが Codex アカウント プールに追加されると ## ネイティブ Codexの復元 -opencodex は決してあなたを罠にはめることはありません。 **`ocx stop` は、ネイティブ Codex に完全に戻す単一のコマンドです**。プロキシを停止し、バックグラウンド サービスがインストールされている場合はそれを停止し、挿入されたすべての行とルーティングされたカタログ エントリを削除するため、プレーンな `codex` は、opencodex が存在しなかったかのように正確に動作します。 +`ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ Codex の復元を試みます。OpenCodex は所有を確認できるルーティング設定を削除し、設定ファイルを安全に復元できない場合は未完了として報告します。 + +現在の設定またはプロファイルが保存された元の内容と異なり、そのファイルの注入後の状態のハッシュがジャーナルにない場合、自動復元は両方のファイルとジャーナルを変更せずに残します。元の内容と同じファイルは再書き込みしません。ルーティング済み設定への再注入も、この未確認の状態では拒否されます。ネイティブ設定では新しいスナップショットを作成できます。[復元規則](/guides/codex-integration/#recovery-without-injection-hashes)を参照してください。 ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index d1993dd94e..09bf368ff9 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -58,7 +58,9 @@ v2 ロスターの場合、適格性には 3 つの状態があります。`"v2" 組み込みの v2 ガイダンスの予算は 700 文字です。予算を超える場合、opencodex はコア スポーン命令を切り捨てるのではなく、まずロスターを削除します。組み込みガイダンスは、優先モデル、適格なロスター、またはフォールバック チェーンが解決された場合にのみ起動されます。カスタムプロンプトは `injectionModel` が設定されていれば生成され、セレクターなしの値を一意に解決できない場合は `{{model}}` が空文字列になります。 -v1 では、opencodex は、`max` または `ultra` の取り組みでアップストリーム スタイルのプロアクティブな委任ガイダンスのみを挿入します。 v1 では、優先モデル、ロスター、フォールバック リスト、カスタム プロンプトは追加されません。 +v1 では、opencodex は effort が `max` または `ultra` の場合に限り、v2 の推奨プリセットと同じプロアクティブな委任テキストを挿入します。 +変わるのは委任の開始条件だけで、委任を別途依頼する必要はなく、ユーザーの指示、権限、タスクの範囲、コラボレーションツールのルールは引き続き適用されます。 +v1 では、優先モデル、ロスター、フォールバック リスト、カスタム プロンプトは追加されません。 デフォルトでオフになっている `syncCodexSubagentDefaults` オプションは、ガイダンスとは別のものです。 opencodex がアクティブな Codex ルーティングを所有している場合、同期または再起動により、選択された値をマーカー所有の `[agents] default_subagent_model` および `default_subagent_reasoning_effort` エントリとして Codex TOML に書き込むことができます。 opencodex は、そのマーカーを持つフィールドのみを更新または削除します。いずれかのターゲット フィールドがユーザー所有の場合、ペアは部分的に書き込まれるのではなく、変更されないままになります。曖昧な TOML は書き込みなしで拒否されます。外部プロバイダー マネージャーとユーザー所有のルート ルーティングも引き続き権限を持ちます。 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 f73a85a217..e751f1ee00 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -53,6 +53,10 @@ ocx eject back これは広範囲で破壊的な再ラベル付けです。ユーザーメッセージを持ち、現在 `opencodex` とタグ付けされているすべてのスレッドを `openai` に変更し、`exec` を `cli` に正規化してイベントマーカーを設定します。正当な専用プロバイダー履歴も対象です。状態をバックアップし、この全範囲を意図する場合にのみ実行してください。 +### `ocx recover-history --ocx-compaction --yes` + +ルーティングされたプロバイダーで圧縮されたタスクをネイティブ Codex で再開する前に、その履歴を修復します。このコマンドは UUID で 1 つのタスクだけを選択し、非公開のバイト単位バックアップを保存してから、OpenCodeX 所有の `ocx1:` 圧縮状態だけをネイティブ Codex が再生できる通常の要約に変換します。ネイティブの暗号化コンテンツと他のタスクは変更しません。実行前に対象タスクを閉じてください。処理中に rollout が変更された場合、ファイルを置き換えずに修復を中止します。 + ### `ocx uninstall`・`ocx remove` すべての復元手順が成功した場合にのみ、サービスとプロキシを停止し、サービスと Codex シムを削除し、ネイティブ Codex を復元してから、opencodex ローカル設定を削除します。 `remove` は `uninstall` の別名です。設定のクリーンアップには、新規インストールによって作成された所有権メタデータが必要です。従来のディレクトリまたは共有ディレクトリはそのまま残ります。 @@ -212,10 +216,28 @@ ocx codex-shim status ocx codex-shim uninstall ``` +:::note[Windows のトークン環境] +新しく生成される Windows CMD と PowerShell のシムは、実行後に呼び出し元の `OPENCODEX_API_AUTH_TOKEN` を元の状態に戻します。Codex とその子プロセスには、引き続きトークンが継承される可能性があります。 + +OpenCodex の更新後、既存の Windows シムにこの動作を適用するには、`ocx codex-shim uninstall`、続いて `ocx codex-shim install` を実行して再作成してください。通常の更新では、正常な Windows シムは書き換えられません。 +::: + :::tip[サービス vs シム] 常時オンのバックグラウンド プロキシには `ocx service` を使用します (推奨)。デーモンを使用しない軽量のオンデマンド起動には、`ocx codex-shim` を使用します。プロキシは、`codex` が起動された場合にのみ起動します。 ::: +#### Codex へのトークン注入 + +非ループバックアドレスにバインドする場合、注入されるプロバイダーには `env_key = "OPENCODEX_API_AUTH_TOKEN"` が含まれます。この行は、読み取る変数を Codex に指定するだけで、変数を作成するものではありません。変数が存在しない場合、Codex はリクエストの開始を拒否し(`Missing environment variable: OPENCODEX_API_AUTH_TOKEN`)、プロキシには到達しません。値は `$OPENCODEX_HOME/service-api-token` に保存されており、起動元のプロセスが Codex の環境にその値を渡す必要があります。 + +`ocx codex-shim install` でインストールされる、保守対象のシムを使用してください。起動コンテキストでこのシムが選択されると、シムは OpenCodex が作成したトークンファイルを読み取り、変数を Codex に渡します。デスクトップ、cron、サービスから起動する場合は、このシムが選択される PATH またはランチャーパスを使用する必要があります。インストールによって、それらの環境が自動的に設定されるわけではありません。Codex 自身の子プロセスにも、トークンが継承される可能性があります。 + +この Bearer トークンをシェルの起動ファイルからエクスポートしたり、`config.toml` にコピーしたりしないでください。`service-api-token` ファイルに含まれるのは `NAME=value` 形式の代入ではなくトークンそのものなので、systemd の `EnvironmentFile=` として直接使用することはできません。 + +`opencodex-proxy.service` の `EnvironmentFile=` または `OCX_API_TOKEN_FILE` は、プロキシプロセスだけを設定するものであり、独立して起動された `codex exec` に渡されることはありません。 + +ランチャーを置き換える Codex のアップグレードによって、シムは削除されます。次に通常の `ocx` コマンドを実行すると復元されますが(上記参照)、その前に実行された `codex exec` は失敗します。`ocx doctor` は、この状態(env_key が設定済み、変数が未設定、シムが存在しないか正常でない、トークンファイルは存在する)を修復コマンドとともに "Codex env_key launch readiness" の項目で報告し、トークンを表示することはありません。トークンファイルの読み取りは、注入された `env_key` の契約には含まれません。起動元のプロセスがその変数を渡す必要があります。 + ### `ocx tray [--json] [--no-start]` Windows ステータス トレイ アイコンをインストールして制御します。 Windows ログイン時に開始され、ワンクリックでプロキシ コントロールを提供します。 `start` および `stop` はアイコンのみを制御します。そのメニューを使用してプロキシを制御します。 `--no-start` は `install` に適用され、トレイをすぐに起動せずにインストールします。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index 522f246801..44b0c70371 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -26,15 +26,25 @@ description: マルチエージェント サーフェス、委任ガイダンス 管理 API は、`GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models`、および `/api/subagent-model-fallback` を公開します。インジェクションモデルの更新は部分的です。カスタム プロンプトは、その API の `prompt` フィールドです。 +## 常時プロアクティブ委任 + +Subagents → 詳細設定の **常時プロアクティブ委任**(旧 **Ultra mode**)は、推論のエフォートを変更せず、委任を開始する条件だけを変更します。推奨プリセットでも、ユーザーの指示、権限の境界、タスクの範囲、ツールの規則は維持されます。 + +`GET` と `PUT /api/v2` は、追加で `multiAgentModeHintRecommendation: { text, revision }` を返します。ダッシュボードはプリセットの有効化と復元にサーバー提供のテキストを使い、固定文へのフォールバックは行いません。旧サーバーが推奨値を返さない場合や値の形式が不正な場合、プリセットの適用と復元は利用できませんが、既存のカスタムヒントの編集と削除は引き続き利用できます。プリセットの復元はローカルの下書きだけを変更し、保存操作で確定します。 + +設定の読み取り、無関係な更新、アップグレードでは、保存済みのヒントは移行されません。ヒントを明示的に更新し、その本文が既知の旧 OpenCodex プリセット2種類のいずれかとバイト単位で完全一致する場合に限り、現在の推奨文に置き換わります。それ以外の有効なカスタムテキストは、空白だけが異なるものも含め、バイト単位で保持されます。既存の v2 有効化、機能サポート確認、ヒント削除の規則は変わらず、変更は新しい Codex セッションに適用されます。 + ## ロスターとガイダンス -有効な v2 ロスターは、v2 と互換性があり、挿入されたカタログに存在する、構成され、ピッカーに表示され、優先順位で並べ替えられた最初の 5 つのモデルです。 V2 の適格性は、明示的な `"v2"`、`null`、または欠落しているアップストリーム ピンを適格なものとして扱います。実際の `"v1"` ピンは除外されます。除外されたエントリは設定に残るため、後で適格になる可能性があります。 +有効な v2 ロスターは、設定済みでピッカーに表示され、優先順位で並べ替えられた最初の 5 つのモデルのうち、挿入されたカタログに存在し、明示的に `"disabled"` とされていないモデルです。明示的な `"v2"` ピンは再帰的なワーカーをサポートし、`"v1"`、`null`、ピンの省略はリーフワーカーとして引き続き適格です。除外されたエントリは設定に残るため、後で適格になる可能性があります。 表面検出はツール形状を使用します。 `send_input`、`resume_agent`、または `close_agent` を持つ名前空間付き `spawn_agent` は v1 です。 `send_message`、`followup_task`、`interrupt_agent`、または `list_agents` を備えたフラット `spawn_agent` は v2 です。 V1 ガイダンスは、`max` または `ultra` でのみプロアクティブ テキストです。 V2 は、優先モデル、適格なロスター、またはフォールバック チェーンが存在する場合にのみ、プロキシ作成の開発者メッセージを受信します。組み込みの v2 ガイダンスには 700 文字のバジェットがあり、必要に応じて最初にロスターが削除されます。ガイダンスはリプレイ プレフィックス全体で重複排除され、後続の `compaction_trigger` の前に挿入されます。 -`injectionModel` および `injectionEffort` は、ネイティブデフォルト同期が有効になっていない限り、推奨事項です。組み込みの v2 テキストは、サポートされているモデル/エフォートのオーバーライドを `fork_turns: "none"` を使用して `spawn_agent` に渡すように Codex に要求します。カスタム `injectionPrompt` は、欠落している値を空の文字列に置き換えます。 +組み込みの v2 サブエージェントガイダンスとカスタム `injectionPrompt` 本文は、どちらも `` を使用し、Codex ネイティブの `` メッセージとは区別されます。組み込みテキストは、解決済みの優先モデル、ロスター、フォールバックチェーンを示しますが、委任、モデルのオーバーライド、`fork_turns` は指示しません。カスタム本文のプレースホルダー置換と内容は維持されます。`injectionModel` および `injectionEffort` は、ネイティブデフォルト同期が有効になっていない限り推奨事項であり、カスタムプレースホルダーの欠落値は引き続き空の文字列に置き換えられます。 + +リプレイの重複排除では、タグの種類ごとに最新のテキストとの完全一致を確認します。両方の値が新しいプロキシのタグを使用する場合、カスタムガイダンスから組み込み形式へ戻すと、その時点の内容が追加されます。途中でネイティブモードが変わっても、変更のないプロキシガイダンスは重複追加されません。既存のネイティブメッセージと旧タグ付きの履歴は保持されます。ラッパーの変更によって過去のメッセージの作成者が判明したり、以前の指示が取り消されたりするわけではありません。複数バージョンが混在する履歴は、旧タグだけでは分類できず、そのような履歴での設定変更の検出は保証されません。 ## ネイティブ Codex のデフォルト同期 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index ed41ad4a93..3fd27c8871 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -72,6 +72,10 @@ account を削除しても mapping は保持され、同じ id を再追加す `openaiProviderTierVersion: 2` は、現在の単一プロバイダーの投影をマークします。出荷された v1 設定を移行する前に、opencodex は別のバックアップを置き換えずに `config.json.pre-openai-tiers-v2.bak` を作成し、既知の名前空間で選択された既知のレガシー ID を裸の ID に書き換えます。 +## プロバイダー名前空間のエイリアス + +プロバイダーには、`google-antigravity` の `agy` のような組み込みの短縮名があります。設定済みのプロバイダー名または明示的なエイリアスが、大文字と小文字を区別せずにその短縮名を使用している場合、別のプロバイダーの組み込み短縮名はカタログ表示とエイリアスルーティングの両方で無効になります。たとえば、`agy` というプロバイダーを設定すると、Google のモデルは `google-antigravity/` と表示され、`agy/` は設定済みのプロバイダーを選択します。正規のプロバイダー名は引き続き大文字と小文字の完全一致が必要で、認識されない接頭辞には既存のモデルルーティングのフォールバックが適用されます。 + ## プロバイダーエントリー (`OcxProviderConfig`) |フィールド |タイプ |意味 | diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 2526714048..a576455311 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -14,6 +14,10 @@ provider events → internal adapter events → client dialect 応答表現はブリッジの中心です。ネイティブ互換ルートは、変換の一部をスキップしてリクエストを通過させる可能性がありますが、認証、ルーティング、アドミッション コントロール、および応答の安全性は依然としてプロキシ境界で発生します。 [構成](/reference/configuration/) でリスナーとアドミッション キーを構成します。 1 つのパブリック モデル ID を複数のターゲットから選択する必要がある場合は、[コンボ](/guides/combos/) を使用します。 +## 上流のリダイレクト + +認証情報を含むモデル・画像・動画・検索リクエストは、同一オリジンを含む HTTP リダイレクトを自動追跡しません。リダイレクトする別名ではなく、最終的な上流 API URL を設定してください。サーバーはリダイレクト先に認証情報やリクエスト本文を再送しません。各応答処理の既存のエラー処理・中継動作は維持され、native Responses と compact は元の 3xx と `Location` をクライアントへ返す場合があります。クライアントのリダイレクト動作は、このサーバー転送ポリシーとは別です。 + ## エンドポイントの概要 |クライアントサーフェス |エンドポイント |非ストリームの結果が成功 |成功したストリームまたはソケットの結果 | @@ -247,14 +251,18 @@ API ではありません。Desktop のキー移行・復旧・切断は既存 |表面 |専用 |ベアラー | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP と WebSocket |必須 |代理入場を拒否されました |拒否されました | -| `/v1/responses/compact` |必須 |代理入場を拒否されました |拒否されました | -| `/v1/chat/completions` |必須 |代理入場を拒否されました |拒否されました | +| `/v1/responses` HTTP と WebSocket | 承認済み | 承認済み |拒否されました | +| `/v1/responses/compact` | 承認済み | 承認済み |拒否されました | +| `/v1/chat/completions` | 承認済み | 承認済み |拒否されました | | `/v1/messages` および `/v1/messages/count_tokens` |承認済み |承認済み |承認済み | | `/v1/models` |承認済み |承認済み |承認済み | | `/v1/live`、`/v1/realtime/calls`、および側波帯結合 |承認済み |承認済み |承認済み | -Responses-family および Chat リクエストは、プロバイダーまたは Codex Direct パススルー用に `Authorization` を予約するため、リモート プロキシ キーは専用ヘッダーを使用する必要があります。メッセージとリアルタイム サーフェスは、より広範なクライアント互換性を必要とするため、3 つの形式すべてを受け入れます。 +Responses 系列と Chat のリクエストは、専用ヘッダーまたは Bearer フィールドのプロキシキーを受け付けます。ネイティブルートでは選択された保存済み Codex 認証情報が admission bearer を置き換え、他のルートではその bearer を削除します。プロキシキーを upstream の認証情報として使うことはありません。別の provider bearer も渡す場合は、プロキシキーを専用ヘッダーに設定してください。 + +キーがなく OAuth を使用しない Cursor ルートは、別途指定された呼び出し元 bearer を使用できますが、プロキシ secret や自動補完された ChatGPT main 認証は使用しません。Combo/policy の選択と実際の shadow/thread-spawn ルート変更では、呼び出し元の生の認証情報を新しい対象へ渡しません。正規の OpenAI ルーティングでは、JWT に ChatGPT アカウントの claim が含まれ、明示的なアカウントヘッダーがある場合はその claim と一致するときに限り、内部ルート変更後にプロキシキーではない呼び出し元の単一 bearer を復元できます。 オプションの OpenAI sidecar に呼び出し元の認証を転送するには、単一の JWT とそれに一致する明示的な `chatgpt-account-id` が必要です。Opaque bearer は、明示的なアカウントヘッダーがあっても、ルート変更をまたいで復元されません。 それ以外の最終対象には自身の設定済み・OAuth・保存済み認証情報が必要で、なければローカルで失敗します。ルート変更のない thread-spawn マーカーだけでは認証情報を削除しません。 + +Claude replay は、その turn が所有権を確保した main 認証だけをメモリ内 snapshot に保持し、最終対象が正規の ChatGPT ルートである場合にのみ復元します。 :::caution データプレーン キーは管理資格情報ではありません。管理 API は別の管理シークレットを使用します。 [管理 API](/reference/management-api/)を参照してください。 1 つのシークレットを両方のプレーンに再利用しないでください。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 33adf2bcbf..c65476352c 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -452,6 +452,34 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. **오류 조건(400):** 잘못된 JSON, 누락되거나 빈 `model`, 누락되거나 빈 `messages`, 지원하지 않는 role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는 이름 지정 `tool_choice`예요. +### 도구 스키마의 유니코드 속성 패턴 + +자바스크립트 기준으로 작성한 JSON Schema `pattern`에는 `\p{Cc}`나 `\P{L}` 같은 유니코드 속성 +이스케이프가 들어갈 수 있어요. OpenAI 계열 백엔드는 `pattern`을 파이썬 `re`로 컴파일해 검사하는데 +`re`는 이 이스케이프를 지원하지 않고, 컴파일하지 못한 스키마는 통째로 거절해요. 그래서 내장 도구 +하나에 그런 패턴이 하나만 있어도 그 도구 호출뿐 아니라 세션의 모든 요청이 실패해요. + +일반적인 Artifact 매개변수가 동작하도록 `openai-chat`·`openai-responses` 어댑터는 일반적인 양의 조건 +위치에 있는 문자열 `pattern` 중 유니코드 속성 이스케이프를 쓰는 제약을 빼요. 형제 제약, `required`, +리터럴 데이터와 지원되는 정규식은 그대로 둬요. 빠진 제약을 프록시가 대신 검사하지 않으므로 도구 구현이 +입력을 직접 검증해야 해요. + +`patternProperties`의 매처와 값 스키마는 그대로 전달해요. 매처를 빼면 상위 `unevaluatedProperties`가 +검사하는 키가 달라질 수 있어, 해당 객체가 열려 있다는 사실만으로 안전성을 판단할 수 없어요. +`not`, `oneOf`, `if`, `contains`, `$defs`, `definitions` 아래의 패턴도 그대로 둬요. 이 하위 조건을 +느슨하게 바꾸면 부정 조건, 분기 선택, 일치 개수나 참조의 의미가 달라질 수 있기 때문이에요. + +보존된 스키마는 목적지 백엔드가 검사해요. ECMA 정규식을 지원하는 백엔드는 원래 패턴을 쓸 수 있고, +컴파일하지 못하는 백엔드는 스키마를 거절할 수 있어요. OpenCodex가 이를 원래 허용되던 입력까지 막는 +스키마로 조용히 바꾸지는 않아요. + +이건 선택된 어댑터 경로에서 일어나는 정규화이지 프로바이더 전체에 대한 보장이 아니에요. 프로바이더 설정과 +인증은 건드리지 않고, 다른 어댑터를 쓰는 프로바이더는 영향을 받지 않아요. + +호환성을 위한 조치일 뿐, 모든 OpenAI 호환 백엔드가 이런 패턴을 거절한다고 확인한 건 아니에요. 대가는 +알아 두는 게 좋아요. 빠진 정규식은 어디에도 보존되지 않고 상위에서 강제되지도 않으니, 도구 구현이 +스키마의 거절에 기대지 말고 입력을 직접 검증해야 해요. + ## 출력 변환(Responses → Messages SSE) | Responses 이벤트 | Messages SSE | 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 1f324adaf2..976da6c96a 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -7,6 +7,12 @@ opencodex는 Codex가 읽는 두 가지, 즉 설정(`$CODEX_HOME/config.toml`, 프록시는 bare `openai` Codex 로그인 경로 하나와 Pool(기본) 및 Direct 계정 모드, 그리고 설정된 API 키용 `openai-apikey/`을 제공합니다. Pool은 메인 계정과 추가된 계정을 포함하고, Direct는 호출자/메인 bearer만 사용합니다. 경로들은 서로 fallback하지 않습니다. shipped v1 config는 marker 2로 이관되며, 수동 복원을 위해 `config.json.pre-openai-tiers-v2.bak`를 보존합니다. +Pool 모드에서는 선택된 저장 계정이 쿨다운 중이고 사용 가능한 다른 저장 계정이나 복구 probe가 +없을 때, 요청에 포함된 검증된 native Codex 로그인을 사용할 수 있습니다. 상류 거절 후 재시도와 +같은 호출자 검증을 적용하므로, 전송 전에 막힌 새 요청도 이 경로를 사용할 수 있습니다. 기존 모델 +권한과 main 계정 정책 검사는 유지됩니다. 이 fallback은 저장 계정의 쿨다운을 해제하거나 호출자 +인증을 Pool 선택으로 저장하지 않습니다. 특정 계정에 정확히 고정된 요청은 그 계정에 계속 묶입니다. + ## 설정 주입 `ocx init`, `ocx start`, `ocx sync`는 모두 인젝터를 호출합니다. 기본 loopback 바인드에서는 Codex의 빌트인 `openai` 프로바이더 id를 그대로 유지한 채, 그 프로바이더가 opencodex를 바라보게 합니다. @@ -119,6 +125,15 @@ Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들 전용 provider 모드의 `requires_openai_auth = true`는 Codex App/TUI의 계정 게이트 화면을 네이티브 Codex와 같은 조건으로 맞춥니다. opencodex는 `/v1/responses`도 WebSocket으로 제공합니다. 전용 provider는 `"websockets": true`일 때만 `supports_websockets = true`를 광고합니다. loopback에서는 Codex의 빌트인 provider가 먼저 WebSocket을 시도할 수 있으며, 비활성화된 proxy는 `426`을 반환해서 Codex가 HTTP/SSE로 fallback합니다. +네이티브 ChatGPT forward 요청의 로컬 재생 상태가 만료되었거나 없으면 opencodex는 +upstream 요청 전에 `previous_response_not_found`를 반환합니다. Codex WebSocket 클라이언트는 +일반 스트림 재시도 한도 안에서 다시 연결하고, 완료된 도구 호출과 결과를 포함한 현재 보유 +컨텍스트 전체를 다시 보낼 수 있습니다. 따라서 프록시의 1시간 캐시가 만료되었다는 이유만으로 +새 작업을 만들 필요는 없습니다. 캐시 한도와 보존 기간은 그대로이며, 클라이언트가 더 이상 +보유하지 않는 기록을 복구하는 기능은 아닙니다. HTTP 클라이언트는 이 오류를 직접 처리하고 +`previous_response_id` 없이 전체 컨텍스트를 다시 보내야 합니다. 같은 ID만 재시도해서는 +누락된 상태를 복구할 수 없습니다. + ## 스레드 식별자와 대화 기록 기본 loopback 형식은 새 thread에 네이티브 `openai` provider 태그를 유지하므로 일반적인 resume history는 다시 매핑할 필요가 없습니다. sync와 restore는 일치하는 백업 manifest만 적용하여 각 thread의 원래 provider, source, event marker를 정확히 복원합니다. manifest가 없는 `opencodex` row는 변경하지 않으며, legacy 재태깅을 명시적으로 강제하려는 경우에만 `ocx recover-history --legacy-openai --yes`를 사용합니다. 이 명령은 의도적으로 범위가 넓습니다. 사용자 메시지가 있고 현재 `opencodex`로 표시된 모든 thread를 `openai`로 바꾸고, `exec`를 `cli`로 정규화하며 event marker를 설정합니다. 정상적인 dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 범위를 의도한 경우에만 사용하세요. non-loopback 전용 provider 모드는 활성 상태일 때만 history를 `opencodex` provider 아래로 미러링하고, 종료할 때는 백업된 메타데이터를 복원합니다. history를 건드리지 않으려면 `syncResumeHistory: false`로 설정하세요. @@ -241,7 +256,9 @@ ChatGPT 계정을 Codex account pool에 추가하면, opencodex는 이를 저장 ## 네이티브 Codex 복원 -opencodex는 절대 사용자를 가두지 않습니다. **`ocx stop`은 네이티브 Codex로 완전히 되돌리는 단일 명령입니다**. proxy를 중지하고, 설치된 background service가 있으면 그것도 중지한 뒤, 주입된 모든 라인과 라우팅된 catalog 항목을 제거해서 plain `codex`가 opencodex가 처음부터 없었던 것처럼 정확히 동작하게 합니다: +`ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 Codex 복원을 시도합니다. OpenCodex 소유로 확인된 라우팅 항목을 제거하며, 설정 파일을 안전하게 복구할 수 없으면 미완료로 보고합니다. + +현재 config 또는 profile이 저장된 원본과 다르고 해당 파일의 주입 상태 해시가 저널에 없으면, 자동 snapshot 복원은 두 파일과 저널을 변경하지 않고 검토용으로 남깁니다. 이미 원본과 같은 파일은 다시 쓰지 않습니다. 기존 라우팅 설정의 재주입도 이 불확실한 원본을 사용하지 않으며, 네이티브 설정에서는 새 snapshot을 만들 수 있습니다. [자세한 복구 규칙](/guides/codex-integration/#recovery-without-injection-hashes)을 참고하세요. ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index aef5ca1cc1..633feb838b 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -62,6 +62,12 @@ alias를 설정해도 정식 `combo/` 형식은 계속 해석됩니다. 정 alias는 클라이언트가 요청하는 공개 이름만 바꿉니다. 콤보에 저장된 ID나 그 뒤의 실제 공급자/모델 선택자는 바꾸지 않습니다. ::: +## 콤보를 바꾼 뒤 대화 압축 + +클라이언트가 콤보를 바꾼 뒤 공급자 접두사 없는 모델 이름으로 압축을 요청하면, opencodex는 같은 대화에서 가장 최근에 응답을 성공적으로 마친 콤보를 기억해 사용할 수 있습니다. 모델 이름이 완료된 응답과 일치하고, 현재 설정에 해당 콤보와 대상이 남아 있어야 합니다. 압축 요청도 일반 콤보 선택과 페일오버를 따릅니다. + +명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하며 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다. + ## 전략 선택 ### 페일오버: 순서가 있는 기본값과 예비값 diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 11edd84cdc..2b0409b818 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -58,7 +58,9 @@ v2 로스터의 경우 적합성은 세 가지 상태로 나뉩니다. `"v2"`로 내장 v2 가이드는 700자 예산을 가집니다. 이 한도를 넘기면 opencodex는 핵심 스폰 지시를 자르는 대신 로스터를 먼저 제거합니다. 내장 가이드는 선호 모델, 적합한 로스터 또는 폴백 체인이 해석될 때만 발화합니다. 사용자 정의 프롬프트는 `injectionModel`만 설정되어 있어도 발화하며, 선택자가 없는 값을 하나로 해석할 수 없으면 `{{model}}`은 빈 문자열로 치환됩니다. -v1에서는 opencodex가 `max` 또는 `ultra` 추론 강도에서만 업스트림 스타일의 능동 위임 가이드만 주입합니다. v1에는 선호 모델, 로스터, 폴백 목록, 사용자 정의 프롬프트를 추가하지 않습니다. +v1에서는 opencodex가 `max` 또는 `ultra` 추론 강도에서만 v2 권장 프리셋과 같은 능동 위임 가이드를 주입합니다. +별도의 위임 요청이 필요하지 않도록 시작 조건만 바꾸며, 사용자 지시와 권한·작업 범위·협업 도구 규칙은 계속 적용됩니다. +v1에는 선호 모델, 로스터, 폴백 목록, 사용자 정의 프롬프트를 추가하지 않습니다. 기본값이 꺼진 `syncCodexSubagentDefaults` 옵션은 가이드와 별개입니다. opencodex가 활성 Codex 라우팅을 소유하는 경우, 동기화나 재시작 시 선택한 값을 Codex TOML의 표식이 붙은 `[agents] default_subagent_model` 및 `default_subagent_reasoning_effort` 항목으로 쓸 수 있습니다. opencodex는 자신이 붙인 표식이 있는 필드만 갱신하거나 제거합니다. 대상 필드 중 하나라도 사용자 소유라면 부분 쓰기는 하지 않고 쌍을 그대로 둡니다. 애매한 TOML은 쓰기 없이 거부합니다. 외부 프로바이더 관리자와 사용자 소유 루트 라우팅도 여전히 최종 권한을 가집니다. 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 048b5f3ca3..7e4e1807fc 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -16,6 +16,27 @@ description: 멀티 에이전트, 콤보, 관측성, 접근, 통합, 시스템, ocx agent subagents set ark/model-a,openai/gpt-5.5 ``` +### `ocx effort [status|set|clear]` + +실행 중인 프록시를 통해 메인·서브에이전트의 reasoning-effort 상한을 조회하거나 변경하며, +프록시가 없으면 로컬 설정을 사용합니다. 상한은 `low`, `medium`, `high`, `xhigh`, `max`, +`ultra`이고, `-`는 해당 상한을 해제합니다. `none`과 `minimal`은 상한 단계가 아니므로 같은 +명령의 다른 옵션이 유효하더라도 프록시 탐색이나 설정 변경 요청 전에 거부됩니다. +두 값은 상한이 아닌 별도의 injection effort를 설정하는 `--injection`에서는 그대로 사용할 수 있습니다. + +```bash +ocx effort status --json +ocx effort set --main high --subagent low +ocx effort set --subagent - +``` + +상태 조회는 저장값 또는 런타임 상한 원문을 보존하고, 지원하지 않는 값은 `warnings`에 표시합니다 +(모두 지원되는 값이면 빈 배열). 일반 출력에도 같은 경고가 나오며, 무시되는 필드와 수정 명령을 +안내합니다. 상태 조회가 기존 값을 자동으로 복구하거나 덮어쓰지는 않습니다. 서브에이전트 필드가 +무시되더라도 유효한 메인 상한이 사라지는 것은 아닙니다. `ocx effort clear`는 별도의 injection-effort +설정을 유지하면서 두 상한을 해제합니다. 상한이 적용되는 요청 surface는 +[Sub-agent surfaces](/ko/guides/sub-agent-surface/)를 참고하세요. + ### `ocx v2 |threads >` Codex `multi_agent_v2` 기능 플래그와 세 상태 멀티 에이전트 surface mode를 관리합니다. 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 df15047266..352276f9fc 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -34,6 +34,12 @@ ocx start --port 8080 백그라운드 서비스가 설치되어 있으면 `ocx stop`이 먼저 그 서비스를 중지하므로 프록시가 다시 올라올 수 없습니다. 웹 대시보드의 **Stop** 버튼도 같은 동작(`POST /api/stop`)을 하지만, Windows 작업 스케줄러는 예외입니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 대시보드는 `respawnable_service`로 거절하고 아무것도 바꾸지 않은 채 `ocx stop` 실행을 안내합니다. +프록시가 종료된 것만으로 Codex/Grok 공유 설정 복원까지 성공했다고 판단하지 않습니다. 종료 응답이 +실패를 보고하거나, 읽을 수 없거나, 요청한 복원 처리 방식을 확인해 주지 않으면 기존 소유권·재시작 +검사를 거친 부모 CLI가 복원을 맡습니다. 이미 종료가 확인된 프로세스를 강제 종료하는 경로로는 +넘어가지 않습니다. 영수증에 근거한 지연 복원도 최종 복원과 영수증 정리를 부모가 담당하며, +부모의 공유 설정 복원이 실패하면 종료 실패로 남기고 미완료 영수증을 보존합니다. + ### `ocx restart` 프록시가 실행 중이면 확인된 정확한 PID와 포트에 in-place 재시작을 요청하고, 정상 드레인을 @@ -44,6 +50,11 @@ ocx start --port 8080 stop/start 대체 동작 없이 안전하게 실패합니다. 소유권을 확인한 뒤 `ocx stop`과 `ocx start`를 순서대로 한 번 실행하세요. +중지·업데이트 후 포트 회수 중에는 종료 전에 기록한 PID라도 OCX 프로세스 확인 실패를 무시하지 않습니다. +확인이 거부된 살아 있는 프로세스는 종료하지 않으며 TCP 연결 정보도 정리하지 않습니다. +계속 확인할 수 없으면 포트가 사용 중인 채로 대기 제한 시간에 도달할 수 있습니다. +현재 포트 사용 프로세스를 확인하고 충돌을 해소한 뒤 재시작을 다시 시도하세요. + ### `ocx ensure` 백그라운드 프록시가 실행 중인지 멱등적으로 보장한 다음, 살아 있는 모델 카탈로그를 동기화합니다. @@ -54,6 +65,10 @@ stop/start 대체 동작 없이 안전하게 실패합니다. 소유권을 확 프록시를 중지하지 않고 기본 Codex를 **복원**합니다. 주입된 설정 줄과 라우팅된 카탈로그 항목을 제거하므로 일반 `codex`가 다시 네이티브로 동작합니다. `eject`는 `restore`의 별칭입니다. +저장된 저널에 해당 파일의 주입 상태 해시가 없으면, 변경된 설정 파일을 덮어쓰는 대신 복원 실패를 +보고합니다. 현재 파일과 저널은 검토용으로 보존됩니다. +[해시 없는 저널의 복구 규칙](/guides/codex-integration/#recovery-without-injection-hashes)을 참고하세요. + 둘 중 어느 표기든 `back`을 붙이면 이미 실행 중인 프록시를 가리키도록 일반 `codex`를 다시 연결하되, 프록시 수명 주기는 바꾸지 않습니다. @@ -71,6 +86,10 @@ ocx eject back thread를 `openai`로 바꾸고, `exec`를 `cli`로 정규화하며 event marker를 설정합니다. 정상적인 dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 범위를 의도한 경우에만 실행하세요. +### `ocx recover-history --ocx-compaction --yes` + +라우팅된 provider를 통해 압축된 작업을 native Codex에서 다시 열기 전에 해당 기록을 복구합니다. 이 명령은 UUID로 정확히 하나의 작업을 선택하고 비공개 바이트 단위 백업을 저장한 뒤, OpenCodeX가 소유한 `ocx1:` 압축 상태만 native Codex가 재생할 수 있는 일반 요약으로 변환합니다. native 암호화 콘텐츠와 다른 작업은 변경하지 않습니다. 실행 전에 선택한 작업을 닫으십시오. 처리 중 rollout이 변경되면 파일을 교체하지 않고 복구를 중단합니다. + ### `ocx uninstall` · `ocx remove` 서비스와 프록시를 중지하고, 서비스와 Codex shim을 제거한 뒤, 기본 Codex를 복원합니다. 그 다음 @@ -185,6 +204,10 @@ probe이며, `--wait`는 준비 또는 timeout까지 polling하지만 종단 `fa 해당할 때 서비스 마이그레이션을 설명합니다. 이 진단에 표시되는 경로는 OS 사용자 이름을 마스킹합니다. doctor는 복구 힌트를 보여 주지만 직접 적용하지는 않습니다. +프로젝트 설정 진단은 `developer_instructions` 같은 TOML 여러 줄 문자열 안의 공급자 예시를 +무시합니다. 종료 구분자 바로 앞에 이스케이프된 따옴표가 있어도, 문자열이 끝난 뒤의 실제 +공급자 및 프로필 설정은 계속 검사합니다. + **OAuth 안정성** 섹션은 자격 증명 저장소에 쓰기 가능한지, `OPENCODEX_HOME` 아래에 refresh single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또는 Codex pool 계정(마스킹된 ID)과 복구용 `Action:`, 그리고 Codex 전달 경로가 공식 클라이언트 메타데이터를 꾸며 내지 않는다는 @@ -293,11 +316,29 @@ ocx codex-shim status ocx codex-shim uninstall ``` +:::note[Windows 토큰 환경] +새로 생성된 Windows CMD 및 PowerShell shim은 실행 후 호출자의 `OPENCODEX_API_AUTH_TOKEN`을 원래 상태로 복원합니다. Codex와 자식 프로세스는 여전히 토큰을 상속할 수 있습니다. + +OpenCodex를 업데이트한 뒤 기존 Windows shim에 이 동작을 적용하려면 `ocx codex-shim uninstall`을 실행한 다음 `ocx codex-shim install`로 다시 설치하세요. 일반 업데이트는 정상인 Windows shim을 다시 작성하지 않습니다. +::: + :::tip[서비스와 shim] 항상 켜져 있는 백그라운드 프록시에는 `ocx service`를 사용합니다(권장). 데몬 없이 가볍게 필요할 때만 시작하려면 `ocx codex-shim`을 사용합니다. 이 경우 프록시는 `codex`를 실행할 때만 시작됩니다. ::: +#### Codex에 토큰 주입 + +루프백이 아닌 주소에 바인딩하면 주입된 공급자에 `env_key = "OPENCODEX_API_AUTH_TOKEN"`이 포함됩니다. 이 줄은 Codex가 읽을 변수를 지정할 뿐, 변수를 생성하지는 않습니다. 변수가 없으면 Codex는 요청 시작을 거부하며(`Missing environment variable: OPENCODEX_API_AUTH_TOKEN`), 요청은 프록시에 도달하지 않습니다. 값은 `$OPENCODEX_HOME/service-api-token`에 저장되며, 실행을 시작하는 프로세스가 Codex의 환경에 이 값을 제공해야 합니다. + +`ocx codex-shim install`로 설치되는 shim을 사용하세요. 실행 환경에서 이 shim이 선택되면 OpenCodex가 생성한 토큰 파일을 읽고 Codex에 변수를 제공합니다. 데스크톱, cron, 서비스에서 실행할 때는 shim을 선택하는 PATH 또는 실행기 경로를 사용해야 합니다. 설치 과정에서 이러한 환경이 자동으로 구성되지는 않습니다. Codex 자체의 자식 프로세스도 토큰을 상속할 수 있습니다. + +이 Bearer 토큰을 셸 시작 파일에서 내보내거나 `config.toml`에 복사하지 마세요. `service-api-token` 파일에는 `NAME=value` 형식의 대입문이 아닌 토큰 원문이 들어 있으므로 systemd의 `EnvironmentFile=`로 직접 사용할 수 없습니다. + +`opencodex-proxy.service`의 `EnvironmentFile=` 또는 `OCX_API_TOKEN_FILE`은 프록시 프로세스만 구성하며, 별도로 실행된 `codex exec`에 전달되지 않습니다. + +실행기를 교체하는 Codex 업그레이드는 shim을 제거합니다. 다음 일반 `ocx` 명령이 shim을 복원하지만(위 내용 참조), 그보다 먼저 실행되는 `codex exec`는 실패합니다. `ocx doctor`는 이 상태(env_key 구성됨, 변수 미설정, shim 누락 또는 비정상, 토큰 파일 존재)를 "Codex env_key launch readiness" 항목에서 복구 명령과 함께 보고하며, 토큰은 출력하지 않습니다. 토큰 파일 읽기는 주입된 `env_key`의 계약에 포함되지 않습니다. 실행을 시작하는 프로세스가 해당 변수를 제공해야 합니다. + ### `ocx tray [--json] [--no-start]` Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로그인 시 시작되며, 프록시를 원클릭으로 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 20d1e96256..e553adf1c3 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 @@ -93,6 +93,12 @@ ocx login anthropic 아닙니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청은 사용량을 더 쓸 수 있습니다. 추가 계정과 다른 공급자는 계속 사용할 수 있습니다. +보호 기능이 켜져 있으면 소유권이 확인된 시작 과정에서 native 프로필 복구와 정리를 마친 뒤 +메인 인증정보의 메모리 내 식별 연결을 복원하므로, 저장된 99% 차단이 재시작 후에도 유지됩니다. +연결을 준비하는 동안 호출자 인증정보를 쓰는 Direct, 메인 계정 지정, 메인 fallback, 메인 pin +요청은 잠시 503을 받을 수 있고, 저장된 Pool 계정은 그동안에도 그대로 쓸 수 있습니다. +이 초기화를 위해 다른 서비스 소유이거나 소유권이 미확인인 홈의 인증정보를 읽지는 않습니다. + 차단 중에는 해당 메인 계정의 Luna Reserve도 쓸 수 없습니다. 일반 사용량이 소진되지 않으면 Reserve가 활성화되지 않을 수 있습니다. 스위치를 끄면 원래 처리 방식으로 돌아가지만 서버가 허용하는 사용량이 늘어나지는 않습니다. 계정의 사용량 새로고침으로 최신 수치를 확인할 수 있으며, diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index d6656f8c16..cf7e3da8d5 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -26,15 +26,25 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 관리 API는 `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`를 제공합니다. injection-model 업데이트는 부분 업데이트입니다. 사용자 지정 프롬프트는 이 API의 `prompt` 필드입니다. +## 항상 능동 위임 + +서브에이전트 → 고급의 **항상 능동 위임**(이전 이름: **울트라 모드**)은 추론 노력을 바꾸지 않고 위임을 시작하는 조건만 변경합니다. 권장 프리셋에서도 사용자 지침, 권한 경계, 작업 범위, 도구 규칙은 유지됩니다. + +`GET`과 `PUT /api/v2`는 `multiAgentModeHintRecommendation: { text, revision }`도 반환합니다. 대시보드는 프리셋을 켜거나 복원할 때 서버가 제공한 텍스트를 사용하며, 고정된 대체 문구는 사용하지 않습니다. 이전 서버가 권장값을 제공하지 않거나 잘못된 형식으로 반환하면 프리셋 적용·복원은 사용할 수 없으며, 기존 사용자 지정 힌트의 편집·삭제는 계속 사용할 수 있습니다. **프리셋 복원**은 로컬 초안만 바꾸고, **저장**을 눌러야 저장됩니다. + +설정 조회, 관련 없는 업데이트, 업그레이드는 저장된 힌트를 자동 변경하지 않습니다. 힌트를 명시적으로 업데이트할 때 본문이 알려진 두 가지 이전 OpenCodex 프리셋 중 하나와 바이트 단위로 정확히 일치하는 경우에만 현재 권장 문구로 바뀝니다. 그 밖의 유효한 사용자 지정 텍스트는 공백 차이가 있는 변형까지 바이트 단위로 보존됩니다. 기존 v2 활성화·기능 지원 확인·힌트 해제 규칙은 그대로이며, 변경은 새 Codex 세션에 적용됩니다. + ## 로스터와 안내 -실제 v2 로스터는 설정되어 있고, 선택기에 보이며, 우선순위로 정렬된 상위 다섯 모델 중 v2와 호환되고 주입된 카탈로그에 존재하는 모델입니다. v2 적격성은 명시적인 `"v2"`, `null`, 또는 생략된 상위 고정값을 적격으로 보고, 실제 `"v1"` 고정값은 제외합니다. 제외된 항목은 나중에 적격이 될 수 있도록 설정에 그대로 남습니다. +실제 v2 로스터는 설정되어 있고, 선택기에 보이며, 우선순위로 정렬된 상위 다섯 모델 중 주입된 카탈로그에 존재하고 명시적으로 `"disabled"`로 표시되지 않은 모델입니다. 명시적인 `"v2"` 고정값은 재귀 작업자를 지원하며, `"v1"`, `null`, 생략된 고정값도 하위 작업을 다시 위임하지 않는 작업자로 참여할 수 있습니다. 제외된 항목은 나중에 적격이 될 수 있도록 설정에 그대로 남습니다. 표면 판별은 도구 형태를 기준으로 합니다. 네임스페이스가 붙은 `spawn_agent`에 `send_input`, `resume_agent`, `close_agent`가 있으면 v1입니다. 평평한 `spawn_agent`에 `send_message`, `followup_task`, `interrupt_agent`, `list_agents`가 있으면 v2입니다. V1 안내는 `max` 또는 `ultra`에서만 선제 텍스트로 제공됩니다. V2는 선호 모델, 적격 로스터, 대체 체인 중 하나가 있을 때만 프록시가 작성한 개발자 메시지를 받습니다. 내장 v2 안내에는 700자 예산이 있고, 필요하면 로스터를 먼저 줄입니다. 안내는 replay prefix 전반에서 중복 제거되며, 뒤에 오는 `compaction_trigger` 앞에 삽입됩니다. -`injectionModel`과 `injectionEffort`는 네이티브 기본값 동기화가 활성화되지 않으면 권고 수준입니다. 내장 v2 텍스트는 Codex에게 지원되는 모델/노력 오버라이드를 `fork_turns: "none"`과 함께 `spawn_agent`로 전달하라고 요청합니다. 사용자 지정 `injectionPrompt`는 누락된 값을 빈 문자열로 대체합니다. +내장 v2 서브에이전트 안내와 사용자 지정 `injectionPrompt` 본문은 모두 ``를 사용하며, Codex 네이티브 `` 메시지와 구분됩니다. 내장 텍스트는 결정된 선호 모델, 모델 목록, 대체 체인을 알리지만 위임, 모델 오버라이드, `fork_turns`를 지시하지는 않습니다. 사용자 지정 본문의 자리표시자 치환과 내용은 유지됩니다. `injectionModel`과 `injectionEffort`는 네이티브 기본값 동기화가 활성화되지 않으면 계속 권고 수준이며, 사용자 지정 자리표시자의 누락된 값은 빈 문자열로 대체됩니다. + +replay 중복 제거는 각 태그 계열의 가장 최근 텍스트와 정확히 일치하는지 비교합니다. 두 값 모두 새 프록시 태그 계열을 사용하는 경우, 사용자 지정 안내에서 내장 형식으로 돌아오면 현재 안내가 추가됩니다. 그 사이에 네이티브 모드가 바뀌어도 변경되지 않은 프록시 안내가 중복 추가되지는 않습니다. 기존 네이티브 메시지와 예전 태그가 붙은 이력은 보존됩니다. 래퍼 변경으로 과거 메시지의 작성자가 판별되거나 이전 지침이 철회되는 것은 아닙니다. 여러 버전이 섞인 이력은 예전 태그만으로 분류할 수 없으며, 이러한 이력에서 설정 전환이 감지된다고 보장하지 않습니다. ## Codex 기본값 동기화 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 04b332d159..fe5c25b1cb 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -72,6 +72,10 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 `openaiProviderTierVersion: 2`는 현재의 단일 공급자 투영을 표시합니다. 출시된 v1 설정을 마이그레이션하기 전에 opencodex는 `config.json.pre-openai-tiers-v2.bak`를 만들고, 기존에 다른 백업이 있더라도 덮어쓰지 않으며, 알려진 레거시 네임스페이스 지정 선택 id를 bare id로 다시 씁니다. +## 공급자 네임스페이스 별칭 + +공급자는 `google-antigravity`의 `agy`처럼 기본 축약 이름을 제공할 수 있습니다. 설정된 공급자 이름이나 명시적 별칭이 대소문자 구분 없이 그 이름을 사용하면, 다른 공급자의 기본 축약 이름은 카탈로그 표시와 별칭 라우팅 모두에서 비활성화됩니다. 예를 들어 `agy`라는 공급자를 설정하면 Google 모델은 `google-antigravity/`로 표시되고, `agy/`는 설정된 공급자를 선택합니다. 정식 공급자 이름은 계속 대소문자가 정확히 일치해야 하며, 인식되지 않는 접두사는 기존 모델 라우팅의 대체 경로를 따릅니다. + ## 공급자 항목 (`OcxProviderConfig`) | 필드 | 타입 | 의미 | diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index d7a54f9ff1..7c89e9a19d 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -253,6 +253,20 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | `POST /api/codex-auth/login/cancel` | Codex 로그인 흐름을 취소합니다 | — | | `GET /api/codex-auth/login-status` | 흐름 또는 account 로그인 상태를 조회합니다. 새 계정 완료 시 복구가 필요할 때만 `catalogRefreshPending: true`를 포함합니다. | 알 수 없는 흐름은 `expired`로 보고되며, 활성 흐름이 없으면 `idle`로 보고됩니다 | +수동 소비가 `reset`으로 확인되면 같은 계정의 새 usage를 조회하여 기존 shared reset-derived +쿨다운을 즉시 복구할 수 있습니다. 복구는 조건부입니다. 계정이 일시 정지되었거나 재인증이 +필요하거나 다른 진행 중인 probe가 쿨다운을 소유하면 쿨다운은 유지됩니다. reset 이전에 시작한 +조회, 불완전하거나 소진된 usage, 신원이 바뀐 계정, 더 최근의 quota 실패로는 복구하지 않습니다. +오래된 main usage 응답은 더 최근에 반영한 관측을 덮어쓰지 않습니다. credential 갱신을 거쳤다면 +해당 인증에서 이어진 갱신인지 확인되어야 하며, 외부에서 교체된 credential은 같은 계정이어도 +복구 근거가 되지 않습니다. 명시적 `Retry-After`, Spark/Reserve 쿨다운, pause·pin·선택 +설정도 보존됩니다. `already_redeemed`와 저장된 결과 재생은 새 reset을 증명하지 않습니다. + +`reset` 또는 `already_redeemed`가 확인된 뒤 usage 조회가 실패하거나 바쁘더라도 소비 응답은 +HTTP 200과 원래 `code`를 유지합니다. 새 잔여 수를 얻지 못하면 `remaining`을 생략합니다. +이는 소비 결과의 확인이며 라우팅 가능 상태를 보장하지 않습니다. usage를 다시 조회하십시오. +usage 조회 실패를 재시도하기 위해 reset credit을 다시 소비하지 마십시오. + 새 account의 config row는 저장되었지만 credential setup을 완료하지 못하면 OAuth `login-status`는 `status: "error"`를 보고하며 `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, 필요한 경우 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 564357acec..af5e31d7a2 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -19,6 +19,10 @@ Responses 표현이 이 연결의 중심입니다. 네이티브 호환 경로는 [Configuration](/reference/configuration/)에서 리스너와 admission 키를 설정하십시오. 하나의 공개 모델 id가 여러 대상 중 하나를 골라야 할 때는 [Combos](/guides/combos/)를 사용하십시오. +## 업스트림 리다이렉트 + +자격 증명을 포함하는 모델·이미지·동영상·검색 요청은 동일 출처를 포함한 HTTP 리다이렉트를 자동으로 따라가지 않습니다. 리다이렉트하는 별칭 대신 최종 업스트림 API URL을 설정하세요. 서버는 리다이렉트 대상으로 자격 증명이나 요청 본문을 다시 보내지 않습니다. 각 응답 처리 경로의 기존 오류·전달 동작은 유지되며, native Responses와 compact 경로는 원래 3xx와 `Location`을 클라이언트에 반환할 수 있습니다. 클라이언트의 리다이렉트 동작은 이 서버 전송 정책과 별개입니다. + ## 엔드포인트 개요 | 클라이언트 표면 | 엔드포인트 | 성공한 비스트리밍 결과 | 성공한 스트리밍 또는 소켓 결과 | @@ -310,16 +314,18 @@ loopback 전용 bind에서는 data-plane admission에 설정된 key가 필요하 | 표면 | Dedicated | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP and WebSocket | 필요함 | proxy admission에서는 거부됨 | 거부됨 | -| `/v1/responses/compact` | 필요함 | proxy admission에서는 거부됨 | 거부됨 | -| `/v1/chat/completions` | 필요함 | proxy admission에서는 거부됨 | 거부됨 | +| `/v1/responses` HTTP and WebSocket | 허용됨 | 허용됨 | 거부됨 | +| `/v1/responses/compact` | 허용됨 | 허용됨 | 거부됨 | +| `/v1/chat/completions` | 허용됨 | 허용됨 | 거부됨 | | `/v1/messages`와 `/v1/messages/count_tokens` | 허용됨 | 허용됨 | 허용됨 | | `/v1/models` | 허용됨 | 허용됨 | 허용됨 | | `/v1/live`, `/v1/realtime/calls`, 및 sideband joins | 허용됨 | 허용됨 | 허용됨 | -Responses 계열과 Chat 요청은 `Authorization`을 provider 또는 Codex Direct passthrough용으로 예약하므로, remote -proxy key는 전용 헤더를 사용해야 합니다. Messages와 Realtime 표면은 더 넓은 클라이언트 호환성이 필요하므로 -세 가지 형식을 모두 허용합니다. +Responses 계열과 Chat 요청은 전용 헤더 또는 Bearer 필드의 프록시 키를 허용합니다. 네이티브 경로에서는 선택한 저장 Codex 자격 증명이 admission bearer를 대체하고, 다른 경로에서는 해당 bearer를 제거합니다. 프록시 키를 upstream 자격 증명으로 사용하지 않습니다. 별도의 provider bearer도 전달하려면 프록시 키는 전용 헤더에 넣으십시오. + +키가 없고 OAuth를 쓰지 않는 Cursor 경로는 별도의 호출자 bearer를 사용할 수 있지만, 프록시 secret이나 자동으로 보충한 ChatGPT main 인증은 사용할 수 없습니다. Combo/policy 선택과 실제 shadow/thread-spawn 경로 변경은 호출자의 원본 자격 증명을 새 대상으로 넘기지 않습니다. 정규 OpenAI 라우팅은 JWT에 ChatGPT 계정 claim이 포함되어 있고 명시적 계정 헤더가 있으면 그 claim과 일치하는 경우에만, 내부 경로 변경 후 프록시 키가 아닌 호출자의 단일 bearer를 복원할 수 있습니다. 선택적 OpenAI sidecar에 호출자 인증을 전달하려면 단일 JWT와 이에 일치하는 명시적 `chatgpt-account-id`가 필요합니다. Opaque bearer는 명시적 계정 헤더가 있어도 경로 변경을 거쳐 복원되지 않습니다. 그 외의 최종 대상에는 자체 설정·OAuth·저장 자격 증명이 필요하며, 없으면 로컬에서 실패합니다. thread-spawn 표지만 있고 경로가 바뀌지 않으면 자격 증명을 제거하지 않습니다. + +Claude replay는 해당 turn이 소유권을 확보한 main 인증만 메모리 snapshot으로 유지하며, 최종 대상이 정규 ChatGPT 경로일 때만 복원합니다. :::caution data-plane key는 management credential이 아닙니다. management API는 별도의 admin secret을 사용합니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 4853466c56..1d0383af16 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -133,11 +133,18 @@ collision-safe public function tool. Matching request history and JSON/SSE funct translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward keeps the native private type unchanged. -For OpenCode Go at `https://opencode.ai/zen/go/v1`, requests with `authMode` other -than `"forward"` convert plaintext Codex `agent_message` items into public user messages, preserving content parts and readable author/recipient -metadata. This conversion leaves encrypted or unknown content unchanged and does not apply -to other destinations. Providers using `authMode: "forward"` retain these items unchanged. -See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages) +Requests with `authMode` other than `"forward"` convert Codex `agent_message` +items containing nonempty arrays of supported plaintext parts into public user messages, preserving those parts and readable author/recipient +metadata. `agent_message` is private to the ChatGPT Codex backend, and the routed +destinations reported so far reject the entire body with +`422 unknown item type "agent_message"` — and because Codex replays sub-agent history on +every turn, that failure repeats for the rest of the thread. This conversion leaves +encrypted or unknown content unchanged. Providers using `authMode: "forward"` retain +these items unchanged. For xAI Responses on HTTPS `api.x.ai` or `cli-chat-proxy.grok.com` +using the standard port, a nonblank string child result is also converted into an `input_text` +part with its exact whitespace and newlines. Other destinations retain string-valued items; +blank strings and mixed encrypted/unknown parts are not partially converted. +See [agent messages](/reference/configuration/providers/#routed-agent-messages) for the separate opt-in encrypted-task recovery behavior. The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 3af72db1d4..29977e3a4b 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -33,6 +33,27 @@ ocx agent sidecar web --list ocx agent sidecar web --model gpt-5.6-luna ``` +### `ocx effort [status|set|clear]` + +Inspect or change main and subagent reasoning-effort caps through the live proxy, or the local +configuration when no proxy is available. Cap values are `low`, `medium`, `high`, `xhigh`, `max`, +and `ultra`; `-` clears the selected cap. `none` and `minimal` are not cap levels and are rejected +before probing the proxy or submitting an update, including when another option in the same command is valid. +They remain valid for `--injection`, which sets the separate injection effort rather than a cap. + +```bash +ocx effort status --json +ocx effort set --main high --subagent low +ocx effort set --subagent - +``` + +Status preserves existing stored/runtime cap values and reports unsupported values in `warnings` +(an empty array when none are unsupported). The same warnings appear in human output and name the +field that is ignored with a correction command. Status never repairs or rewrites those values. +An ignored subagent field does not remove a valid main cap. `ocx effort clear` clears both caps +while retaining the separate injection-effort setting. See [Sub-agent surfaces](/guides/sub-agent-surface/) +for the request surfaces where caps apply. + ### `ocx v2 |keep-native-v1 |threads |mode-hint >` Manage the Codex `multi_agent_v2` feature flag and the three-state multi-agent surface mode. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 8235b63ead..3eace3ef13 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -38,6 +38,19 @@ and only a stop running outside the proxy can verify that restart window before your client config — so the dashboard refuses with `respawnable_service`, changes nothing, and asks you to run `ocx stop`. +The dashboard also refuses when the proxy is running *as* the installed launchd or systemd +service. Stopping that manager from inside the proxy would terminate the process before +native Codex is restored, leaving your client config pointed at a proxy that is gone, so the +dashboard returns `self_unload_service`, changes nothing, and asks you to run `ocx stop` — +which stops the service from outside and completes the restore. + +A proxy exit alone does not confirm that shared Codex/Grok restoration succeeded. If the stop +response reports failure, is unreadable, or does not confirm the assigned teardown mode, the CLI +keeps restoration with the stopping parent after the existing ownership and respawn checks. +It does not enter the forced-stop fallback for a process already observed to have exited. A +receipt-backed deferral still leaves final restoration and receipt cleanup with the parent; +failure to restore shared client configuration keeps the stop failed and its receipt outstanding. + ### `ocx restart` When a proxy is running, ask that exact attested PID and port to restart in place, wait for its @@ -49,6 +62,11 @@ closed without an `ensure` or stop/start fallback. After confirming ownership, u `ocx start` for a standalone proxy. For a service-managed proxy, use `ocx stop` followed by `ocx service start` so supervision is restored. +Port recovery after stop or update respects a failed OCX process check even when the PID was +recorded before shutdown. A rejected live holder is left running and prevents TCP-row cleanup. +If it stays unverified, the bounded recovery wait can expire with the port still busy. Check the +current port holder and retry the restart after the conflict is resolved. + ### `ocx ensure` Idempotently ensure a background proxy is running, then sync its live model catalog. If @@ -59,6 +77,10 @@ Idempotently ensure a background proxy is running, then sync its live model cata Restore native Codex **without** stopping the proxy — strips the injected config lines and routed catalog entries so plain `codex` works natively again. `eject` is an alias of `restore`. +Restoration reports failure instead of replacing changed configuration files when a saved journal +lacks the corresponding injection hashes. The current files and journal remain available for +review; see [recovery without injection hashes](/guides/codex-integration/#recovery-without-injection-hashes). + Pass `back` to either spelling to re-point plain `codex` at an already-running proxy without changing the proxy lifecycle: @@ -77,6 +99,15 @@ changed to `openai`, `exec` is normalized to `cli`, and the event marker is set. legitimate dedicated-provider history. Back up the state and run it only when that full scope is intended. +### `ocx recover-history --ocx-compaction --yes` + +Repair one thread that was compacted through a routed provider before resuming it through native +Codex. The command reads the exact thread selected by UUID, saves a private byte-for-byte backup, +then converts only OpenCodeX-owned `ocx1:` compaction state into a plain summary that native Codex +can replay. Native encrypted content and other threads are left unchanged. Close the selected +thread before running the command; a concurrent rollout change makes recovery stop without +replacing the file. + ### `ocx uninstall` · `ocx remove` Stop the service and proxy, remove the service and Codex shim, restore native Codex, then remove @@ -214,6 +245,10 @@ and pending history migration. The Codex app-home targeting section also detects Orca runtime-home mismatch and explains service migration when applicable. Paths shown by this diagnostic redact the OS username. Doctor prints repair hints but does not apply them. +Project-config diagnostics ignore provider examples inside TOML multiline strings, including +`developer_instructions`. Real provider and profile settings after the closing delimiter are still +checked, even when an escaped quote immediately precedes that delimiter. + The **OAuth reliability** section reports whether credential storage is writable, whether refresh single-flight/lock files can be created under `OPENCODEX_HOME`, non-healthy OAuth or Codex pool accounts (redacted ids) with a recovery `Action:`, and a static OK that the Codex forward path does @@ -440,36 +475,43 @@ ocx codex-shim status ocx codex-shim uninstall ``` +:::note[Windows token environment] +Newly generated Windows CMD and PowerShell shims restore the caller's `OPENCODEX_API_AUTH_TOKEN` after execution. Codex and its child processes can still inherit the token. + +After updating OpenCodex, recreate an existing Windows shim with `ocx codex-shim uninstall` followed by `ocx codex-shim install` to obtain this behavior. An ordinary update does not rewrite a healthy Windows shim. +::: + :::tip[Service vs Shim] Use `ocx service` for an always-on background proxy (recommended). Use `ocx codex-shim` for lightweight, on-demand startup without a daemon — the proxy starts only when `codex` is launched. ::: -#### Token injection without the shim +#### Token injection into Codex On a non-loopback bind the injected provider carries `env_key = "OPENCODEX_API_AUTH_TOKEN"`. That line tells Codex which variable to read; it does not create it. Codex refuses to start a request when the variable is missing (`Missing environment variable: OPENCODEX_API_AUTH_TOKEN`), and the -proxy is never reached. The value lives in `$OPENCODEX_HOME/service-api-token`; only a process that -exports it into Codex's environment closes the gap. +proxy is never reached. The value lives in `$OPENCODEX_HOME/service-api-token`; the launching process +must supply it in Codex's environment. -What does carry the token into a Codex process: +Use the maintained shim installed by `ocx codex-shim install`. When the launching context resolves +this shim, it reads the token file created by OpenCodex and supplies the variable to Codex. +Desktop, cron, and service launches must use a PATH or launcher path that selects the shim; +installation does not configure those environments automatically. Codex's own child processes +may still inherit the token. -- the shim installed by `ocx codex-shim install` (reads the token file at launch; the supported path - for Codex started from shells, Desktop, cron, or another service); -- exporting `OPENCODEX_API_AUTH_TOKEN` yourself in the process that starts Codex — a shell profile, - the cron line, or an `Environment=`/`EnvironmentFile=` on the systemd unit that launches - **Codex** (not the proxy). Point it at the existing token file; do not copy the value into - `config.toml`. +Do not export this bearer token from a shell startup file or copy it into `config.toml`. The +`service-api-token` file contains the raw token, not `NAME=value` assignments, so it cannot be used +directly as a systemd `EnvironmentFile=`. -What does not: an `EnvironmentFile=` or `OCX_API_TOKEN_FILE` on `opencodex-proxy.service`. Those -configure the proxy process only and never flow into an independently launched `codex exec`. +An `EnvironmentFile=` or `OCX_API_TOKEN_FILE` on `opencodex-proxy.service` configures the proxy process +only and never flows into an independently launched `codex exec`. A Codex upgrade that replaces the launcher removes the shim; the next ordinary `ocx` command restores it (see above), but a `codex exec` that runs before that fails. `ocx doctor` reports this exact state under "Codex env_key launch readiness" (env_key configured, variable unset, shim missing or unhealthy, token file present) with the repair command, and never prints the token. Reading the token -file directly from Codex is not something Codex supports, so there is no OpenCodex directive for it. +file is not part of the injected `env_key` contract; the launching process must supply that variable. ### `ocx tray [--json] [--no-start]` 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 223a825dc8..384dfe0ed5 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -154,6 +154,12 @@ by default. This protects new requests using the identified main account, not th already-running requests, unmatched caller-owned keyring credentials, and traffic outside the proxy can still spend quota. Added accounts and other providers remain available. +With protection enabled, an owned startup restores the main credential's in-memory identity +binding after native-profile recovery and cleanup, so a persisted 99% block survives a restart. +Caller-owned Direct, exact-main, main-fallback, and main-pin requests can briefly receive 503 +while that binding is pending; healthy stored Pool accounts stay eligible throughout. No +credential is read from a foreign or unconfirmed service home for this initialization. + While this policy blocks main, Luna Reserve on that account is blocked too. Staying below ordinary quota exhaustion may prevent Reserve activation. Disabling the switch restores normal local handling, not additional upstream entitlement. Use the account quota refresh action to obtain a @@ -440,6 +446,14 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- Inspect Codex reset credits for an account. Consuming a credit is destructive and requires both `--consume` and `--yes`. +After a confirmed `reset`, fresh usage can recover the same account's eligible existing +shared reset-derived cooldown. Paused accounts, accounts needing reauthentication and +cooldowns owned by an in-flight probe remain excluded from this recovery. A failed or busy +usage refresh after confirmed consumption does not require another credit: check usage +again instead of repeating `--consume`. Consume success does not guarantee routability; +see the [management API recovery contract](/reference/management-api/#codex-authentication-delegation) +for reset/replay, freshness and scope limits. + ### `ocx account main ` Manage named native Codex main-login profiles without changing OpenCodex account-pool routing: diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 1296e0ced1..0435a439b5 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -49,20 +49,33 @@ Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT / `config.json` key; `ocx v2 threads ` writes `max_concurrent_threads_per_session` under `[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after v2 is enabled. -**Ultra mode** (the Subagents dashboard toggle, `PUT /api/v2` field -`multiAgentModeHintText`, and `ocx v2 mode-hint`) writes +**Always proactive delegation** in Subagents → Advanced (formerly **Ultra mode**) changes the +delegation trigger without changing reasoning effort. Its preset preserves user instructions, +authority boundaries, task scope, and tool rules. The dashboard toggle, `PUT /api/v2` field +`multiAgentModeHintText`, and `ocx v2 mode-hint` write `features.multi_agent_v2.multi_agent_mode_hint_text` in Codex's `$CODEX_HOME/config.toml`. The CLI `ocx v2 mode-hint` command persists this key even -when `multi_agent_v2` is disabled; it does not toggle the feature. The hint overrides -codex-rs's effort-derived multi-agent policy, so any model and any reasoning effort -receives the Proactive delegation prompt; it does **not** change reasoning effort. +when `multi_agent_v2` is disabled; it does not toggle the feature. The hint replaces +codex-rs's effort-derived multi-agent policy when that native surface is active. A `null` value removes the key so the effort-derived policy (ultra = proactive, otherwise explicit) resumes; empty or whitespace-only values are rejected because a present empty override would suppress even the ultra-derived Proactive message. The -Subagents dashboard's Ultra mode **on** toggle requires both the native feature and +Subagents dashboard's **Always proactive delegation** toggle requires both the native feature and an explicit v2 surface (`multiAgentMode: "v2"`, equivalent to `ocx v2 mode v2`); `ocx v2 on` alone does not satisfy that dashboard gate. +`GET` and `PUT /api/v2` also return `multiAgentModeHintRecommendation: { text, revision }`. +The dashboard uses this server-provided text when enabling or restoring the preset, with no +hardcoded fallback. If an older server omits the recommendation or returns a malformed value, +preset installation and restoration are unavailable; editing or clearing an existing custom hint +remains available. **Restore preset** changes only the local draft; **Save** persists it. + +Reading settings, unrelated updates, and upgrades do not migrate a stored hint. Only an explicit +hint update that matches either of the two recognized legacy OpenCodex presets byte-for-byte is +replaced with the current recommendation. Other valid custom text, including whitespace variants, +is preserved byte-for-byte. Mode-hint support is still checked before writing, and changes apply +to new Codex sessions. + The management API exposes `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models`, `/api/subagent-roles`, and `/api/subagent-model-fallback`. Injection-model updates are partial; the custom prompt is the `prompt` field on that API. Role catalog updates are a full replace, or @@ -148,9 +161,9 @@ loudly when the installed Codex build does not know the flag yet. ## Roster and guidance The effective v2 roster is the configured, picker-visible, priority-sorted first five models that -are compatible with v2 and present in the injected catalog. V2 eligibility treats an explicit `"v2"`, -`null`, or absent upstream pin as eligible; a real `"v1"` pin is excluded. Excluded entries remain in -configuration so they can become eligible later. +are present in the injected catalog and are not explicitly marked `"disabled"`. An explicit `"v2"` +pin supports recursive workers; `"v1"`, `null`, and absent pins remain eligible as leaf workers. +Excluded entries remain in configuration so they can become eligible later. Surface detection uses tool shape. A namespaced `spawn_agent` with `send_input`, `resume_agent`, or `close_agent` is v1. A flat `spawn_agent` with `send_message`, `followup_task`, `interrupt_agent`, or @@ -161,9 +174,19 @@ message only when a preferred model, eligible roster, or fallback chain exists. has a 700-character budget and drops the roster first if necessary. Guidance is deduplicated across replay prefixes and inserted before a trailing `compaction_trigger`. -`injectionModel` and `injectionEffort` are advisory unless native-default sync is enabled. The built-in -v2 text asks Codex to pass supported model/effort overrides to `spawn_agent` with -`fork_turns: "none"`. A custom `injectionPrompt` substitutes missing values with an empty string. +Both built-in v2 subagent guidance and custom `injectionPrompt` bodies use +``, separate from Codex's native `` messages. +Built-in text reports the resolved preferred model, roster, and fallback chain without prescribing +delegation, model overrides, or `fork_turns`. Custom bodies retain their placeholder substitution +and content. `injectionModel` and `injectionEffort` remain advisory unless native-default sync is +enabled; missing custom placeholder values are still replaced with an empty string. + +Replay deduplication compares the latest exact text in each tag family. When both values use the +new proxy family, switching custom guidance back to the built-in form appends the current value; +intervening native mode changes do not duplicate unchanged proxy guidance. Existing native and +legacy-tagged history is preserved. This wrapper change does not identify the author of old +messages or revoke prior instructions. Mixed-version histories cannot be classified from the +legacy tag alone, and transition detection across such histories is not guaranteed. ## Native Codex default sync diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9a7a1f2e8f..78d938fe17 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -116,11 +116,15 @@ published long-context bands on `openai` and `openai-apikey`. The two Daybreak B follow the Sol API reference. These are comparison estimates, not invoices or credit-balance predictions. Explicit provider/model price overrides still take precedence. +## Provider namespace aliases + +Providers can expose a built-in shorthand, such as `agy` for `google-antigravity`. A configured provider name or explicit alias claims that shorthand case-insensitively; a different provider's built-in shorthand is then suppressed in both catalog names and alias routing. For example, configuring a provider named `agy` keeps Google's models under `google-antigravity/`, while `agy/` selects the configured provider. Canonical provider names still require an exact case match, and unrecognized prefixes retain the existing model-routing fallback. + ## Provider entries (`OcxProviderConfig`) | Field | Type | Meaning | | --- | --- | --- | -| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`). | +| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval; `jitterMs` adds only a positive random delay (0–60,000 ms). Provider limits apply across all models, while `models` entries use exact upstream model IDs and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `tlsProfile?` | `"antigravity-browser"` | Explicit, experimental Antigravity-only TLS/HTTP2 compatibility profile. It requires Google OAuth Cloud Code Assist and canonical Antigravity hosts. It is unofficial, does not ensure Terms-of-Service compliance or prevent suspension, may make traffic more distinctive, and falls back to Bun if initialization fails. OAuth/token/onboarding traffic remains on standard Bun TLS. Prefer official Gemini API-key, Vertex, or documented Code Assist routes when policy safety matters. | @@ -950,6 +954,32 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 } ``` +## OpenCode Go Responses compatibility + +On non-forward requests whose resolved endpoint is `https://opencode.ai/zen/go/v1/responses`, OpenCodex moves +Codex's `additional_tools` input declarations into top-level `tools` after tool and namespace +normalization. Supported hosted tools are preserved until model-specific filtering; malformed +wrappers remain unchanged. This does not discard ciphertext or unknown agent-message content. +The check uses the final URL, so endpoint-inclusive base URLs and split `baseUrl`/`responsesPath` +configurations receive the same behavior. A custom path resolving elsewhere does not. + +The canonical `opencode-go` preset defaults to `statelessResponses: true`: requests use explicit +history with `store: false`, without `previous_response_id`, `conversation`, `background`, +`metadata`, or stored `prompt` references. This avoids Go's rejection of reasoning ciphertext +combined with `previous_response_id`. The continuation cache records reasoning in the same +representation returned to the client, including the visible content-to-summary rewrite, so +echoing full history with `previous_response_id` does not duplicate that history. Hidden-summary +requests and opaque reasoning blobs retain their existing representation. Cache hits can also +supply earlier history for delta continuations; +after a cache miss, resend the complete conversation without `previous_response_id`. Stateless +repair labels orphan results and missing tool results; it cannot reconstruct lost history or +prove whether a missing tool execution succeeded. + +An explicit `statelessResponses: false` is preserved. Existing canonical preset configurations +receive the default only when the setting is absent; custom renamed entries keep their configured +value and do not acquire this default by destination matching. Chat model routes keep their +existing protocol. The stateless flag does not force Responses streaming into JSON. + ## OpenCode Go reasoning efforts Go catalog rows preserve their configured reasoning efforts exactly, including during @@ -969,14 +999,21 @@ their previous behavior. See the [ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders). `modelDisplayNames` on a provider controls readable labels without changing wire ids. -## OpenCode Go session and agent messages +## Routed agent messages -With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and -base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items -become user messages when `authMode` is not `"forward"` (for example, `"key"`). -Providers using `authMode: "forward"` retain these items unchanged. This conversion is scoped to that destination, including -renamed provider entries; other Responses destinations keep their input unchanged. +With the [`openai-responses` adapter](/reference/adapters/#openai-responses), Codex +`agent_message` items containing nonempty arrays of supported plaintext parts become user messages when `authMode` is not `"forward"` +(for example, `"key"`). Providers using `authMode: "forward"` retain these items unchanged. +`agent_message` is private to the ChatGPT Codex backend, and the routed destinations +reported so far answer the whole request with +`422 unknown item type "agent_message"`; Codex replays sub-agent history on every +subsequent turn, so the thread keeps failing until the item is converted. Author and recipient remain explicit text metadata, and the content parts are preserved. +For HTTPS `api.x.ai` and `cli-chat-proxy.grok.com` on the standard port, non-forward +Responses dispatch also accepts a nonblank string child result and turns it into one +`input_text` part. The original string, including leading/trailing whitespace and newlines, +is preserved. Other destinations keep string-valued agent messages unchanged. Empty or +whitespace-only strings remain unchanged, as do incomplete and mixed encrypted/unknown shapes. Encrypted and unknown content is not normalized; native encrypted tasks still require the separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). @@ -994,6 +1031,6 @@ current tail message (ignoring trailing `compaction_trigger` or `additional_tool It does not batch-recover unseen historical messages; those remain unchanged. A cache miss or expiry does not extend the history-recovery contract. -Sender and recipient on Go Responses are context for the receiving model, not a new +Sender and recipient on routed Responses are context for the receiving model, not a new machine-readable routing protocol. Tool routing continues to use the existing collaboration contracts. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 1a123fd6c1..80d8eea6db 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -28,7 +28,8 @@ runs helper features around provider requests. | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. False makes ensure a no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Restore an installed shim after a completed external Codex update replaces it. Environment opt-out: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `codexDesktopAuthless?` | `boolean` | `false` | Opt-in authless Codex Desktop routing on a loopback bind: inject the dedicated `opencodex` provider with `requires_openai_auth = false` so Desktop opens without a ChatGPT login. Ignored on non-loopback binds. `ocx system settings --desktop-authless on`. See [Codex integration](/guides/codex-integration/#authless-codex-desktop-opt-in). | -| `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Logs carry a hashed account key only. | +| `codexClientCompaction?` | `boolean` | `false` | Opt into Codex client-side compaction on an authenticated loopback bind. Uses the dedicated `opencodex` provider identity with `requires_openai_auth = true`, preventing new routed compactions from storing OpenCodeX-owned `ocx1:` state. `codexDesktopAuthless` takes precedence when both are enabled and keeps `requires_openai_auth = false`. V2 sub-agent routing is unchanged. `ocx system settings --client-compaction on`. See [Codex integration](/guides/codex-integration/#client-side-compaction-opt-in). | +| `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefix is `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Web-search sidecar options. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 60b945871e..6c301ec8bf 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -461,7 +461,7 @@ whether to star the repository. | --- | --- | --- | | `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps. `spillLastWriteFailureOrigin` is `retry_returned_timeout`, `timeout_memo_refusal`, or null; cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications. See [Windows spill diagnostics](/troubleshooting/windows-memory/) for process-local semantics. Raw errors and paths are never returned. | — | | `POST /api/system/restart` | Begin a drain-aware process restart without removing client injection | Returns 202; repeated calls report the existing drain | -| `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | +| `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 `self_unload_service` when this proxy is running as the installed launchd/systemd service, because stopping the manager from inside it would end the process before native Codex is restored — run `ocx stop` instead (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | | `GET /api/system/codex-app-server` | Report whether running Codex app-servers predate the current model catalog | — | | `POST /api/system/codex-restart` | Refresh the catalog, then ask stale Codex app-servers to exit so the model picker reloads | Returns 200 with `code: partially_stopped` when a target survives | @@ -499,6 +499,31 @@ manager. Its routes are: | `POST /api/codex-auth/login/cancel` | Cancel a Codex login flow | — | | `GET /api/codex-auth/login-status` | Poll a flow or account login state. A completed new-account flow includes `catalogRefreshPending: true` only when recovery is needed. | Unknown flows report `expired`; no active flow reports `idle` | +For reset-credit consumption, a different `operationId` supplied while the same physical +account has an unfinished operation joins that operation as an alias. Its retry uses the +original upstream request ID and records the outcome under that same identity, so later +requests with the original ID or a known alias replay the stored result without another +consume request. A previously unseen ID supplied after settlement starts a new explicit +redemption; clients retrying an existing action should keep its ID. + +After a confirmed manual `reset`, OpenCodex checks fresh usage for that same account +and can reconcile its eligible pre-existing shared reset-derived cooldown immediately. +Paused accounts, accounts requiring reauthentication and cooldowns already owned by an +in-flight probe remain excluded from this recovery; their cooldowns are retained. Usage +started before the reset, incomplete or exhausted usage, a changed account, and a newer +quota failure do not qualify. Older main-account usage responses cannot replace a newer +published observation. If usage needs credential refresh, recovery requires that refresh's +confirmed lineage; an externally replaced credential does not qualify merely because it +belongs to the same account. Explicit `Retry-After`, Spark/Reserve cooldowns, pause +settings, pins and the selected account are preserved. `already_redeemed` and durable +replay do not prove a new reset and do not gain this recovery behavior. + +A failed or busy usage refresh after a confirmed `reset` or `already_redeemed` does not +turn the completed consumption into an error: the response remains HTTP 200 with its +consume `code`, omitting `remaining` when no fresh count was obtained. This response +confirms the consume outcome, not that the account is now routable. Refresh usage to +check availability; do not consume another credit to retry a failed usage refresh. + If a new account config row is saved but credential setup cannot finish, OAuth `login-status` reports `status: "error"` with `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, and optional diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index b1c4f9e70f..6ca52f905a 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -20,6 +20,10 @@ response safety still happen at the proxy boundary. Configure the listener and a [Configuration](/reference/configuration/); use [Combos](/guides/combos/) when one public model id should select among several targets. +## Upstream redirects + +Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | @@ -520,16 +524,18 @@ use the matrix below. “Dedicated” means `X-OpenCodex-API-Key`; the other col | Surface | Dedicated | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP and WebSocket | Required | Rejected for proxy admission | Rejected | -| `/v1/responses/compact` | Required | Rejected for proxy admission | Rejected | -| `/v1/chat/completions` | Required | Rejected for proxy admission | Rejected | +| `/v1/responses` HTTP and WebSocket | Accepted | Accepted | Rejected | +| `/v1/responses/compact` | Accepted | Accepted | Rejected | +| `/v1/chat/completions` | Accepted | Accepted | Rejected | | `/v1/messages` and `/v1/messages/count_tokens` | Accepted | Accepted | Accepted | | `/v1/models` | Accepted | Accepted | Accepted | | `/v1/live`, `/v1/realtime/calls`, and sideband joins | Accepted | Accepted | Accepted | -Responses-family and Chat requests reserve `Authorization` for provider or Codex Direct -passthrough, so a remote proxy key must use the dedicated header. Messages and Realtime surfaces -need broader client compatibility and therefore accept all three forms. +Responses-family and Chat requests accept a proxy key in the dedicated header or Bearer field. On native routes, the selected stored Codex credential replaces the admission bearer; on other routes it is removed. It is never an upstream credential. Use the dedicated header when also supplying a separate provider bearer. + +A keyless, non-OAuth Cursor route may use that separate caller bearer, but never a proxy secret or automatic ChatGPT-main enrichment. Combo/policy selection and actual shadow/thread-spawn rewrites do not transfer raw caller credentials to new targets. Canonical OpenAI routing can restore the caller’s single non-proxy bearer after an internal route change only when its JWT carries a ChatGPT account claim and any explicit account header matches that claim. Forwarding caller authentication to optional OpenAI sidecars requires a single JWT and a matching explicit `chatgpt-account-id`. Opaque bearers are not restored across route changes, even with an explicit account header. Otherwise, the final target needs its own configured, OAuth, or stored credential; otherwise it fails locally. A thread-spawn marker alone does not strip credentials. + +Claude replay retains main auth only as a turn-claimed in-memory snapshot and reconstructs it only for a final canonical ChatGPT route. :::caution Data-plane keys are not management credentials. The management API uses a separate admin secret; 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 23581e56f6..01e4d8003d 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -379,10 +379,9 @@ Responses item'ов (`input: [{ type: "message", ... }]`), ждёт `response.co ## Восстановление нативного Codex -opencodex не запирает вас внутри себя. **`ocx stop` — это единственная команда, которая полностью -возвращает нативный Codex**: она останавливает прокси, останавливает фоновую службу, если она -установлена, и убирает все внедрённые строки и маршрутизируемые записи каталога, так что обычный -`codex` снова работает так, будто opencodex никогда не существовал: +`ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. + +Если текущая конфигурация или профиль отличаются от сохранённого оригинала, а журнал не содержит хеша внедрённого состояния этого файла, автоматическое восстановление сохраняет оба файла и журнал без изменений. Файл, уже совпадающий с оригиналом, не перезаписывается. Повторное внедрение в настроенную для прокси конфигурацию также отклоняет такое неподтверждённое состояние; нативная конфигурация может создать новый снимок. См. [правила восстановления](/guides/codex-integration/#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index 13d3d04866..ccf1036a72 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -80,8 +80,9 @@ guidance-сообщений, которые opencodex пишет сам, на о `injectionModel` достаточно, чтобы отобразить пользовательский prompt; если значение без селектора нельзя разрешить однозначно, `{{model}}` заменяется пустой строкой. -На v1 opencodex внедряет только upstream-style proactive guidance о делегировании на уровнях -effort `max` или `ultra`. Предпочитаемую модель, ростер, fallback list и custom prompt на v1 он +На v1 opencodex внедряет тот же текст о проактивном делегировании, что и рекомендуемый пресет v2, только на уровнях effort `max` или `ultra`. +Меняется только условие запуска: отдельный запрос на делегирование не требуется; инструкции пользователя, полномочия, рамки задачи и правила инструментов совместной работы остаются в силе. +Предпочитаемую модель, ростер, fallback list и custom prompt на v1 он не добавляет. Опция `syncCodexSubagentDefaults`, выключенная по умолчанию, отделена от guidance. Когда diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 895f514f72..d2d67c022a 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -94,10 +94,20 @@ interface ProviderAdapter { ## `openai-responses` -**Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает исходное тело -запроса и стримит ответ обратно **без преобразования**. +**Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает тело +запроса и ответ с преобразованиями совместимости для выбранного провайдера. **Аутентификация:** `forward` (ретрансляция заголовков вызывающей стороны) или `key`. +При `authMode`, отличном от `"forward"`, элементы Codex `agent_message` с непустым +массивом поддерживаемых открытых частей преобразуются в обычные сообщения пользователя. +Содержимое и читаемые поля author/recipient сохраняются. Для HTTPS `api.x.ai` и +`cli-chat-proxy.grok.com` на стандартном порту также поддерживается непустой строковый +результат дочерней задачи: он становится частью `input_text` без удаления пробелов и +переносов строк. Другие адреса сохраняют строковые элементы без изменений. Пустые строки, +зашифрованное содержимое и смешанные массивы с неизвестными или зашифрованными частями +не преобразуются частично. При `authMode: "forward"` элементы `agent_message` остаются +без изменений. + При `key`-аутентификации [`retryOn429`](/ru/reference/configuration/) действует и здесь: 429 до начала потока ждёт и, до любой другой обработки или фейловера, повторяет идентичный запрос на том же ключе, как и в переводимом пути `openai-chat`/Anthropic. Пользовательские транспорты 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 cefa6e3380..1979996f96 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -78,6 +78,10 @@ ocx eject back устанавливается. Корректная история выделенного провайдера тоже входит в охват. Сначала сделайте резервную копию и запускайте команду только если нужен весь этот охват. +### `ocx recover-history --ocx-compaction --yes` + +Исправьте историю одной задачи, сжатой через маршрутизируемого провайдера, перед её возобновлением в нативном Codex. Команда выбирает ровно одну задачу по UUID, сначала сохраняет приватную побайтовую резервную копию, а затем преобразует только принадлежащее OpenCodeX состояние сжатия `ocx1:` в обычную сводку, которую может воспроизвести нативный Codex. Нативное зашифрованное содержимое и другие задачи не изменяются. Перед запуском закройте выбранную задачу; если rollout изменится во время обработки, восстановление остановится без замены файла. + ### `ocx uninstall` · `ocx remove` Остановить службу и прокси, удалить службу и Codex shim, восстановить native Codex, а затем @@ -313,12 +317,30 @@ ocx codex-shim status ocx codex-shim uninstall ``` +:::note[Окружение токена в Windows] +Новые обёртки для Windows CMD и PowerShell восстанавливают исходное состояние `OPENCODEX_API_AUTH_TOKEN` в вызывающей оболочке после выполнения. Codex и его дочерние процессы по-прежнему могут унаследовать токен. + +После обновления OpenCodex пересоздайте существующую обёртку Windows командами `ocx codex-shim uninstall`, затем `ocx codex-shim install`, чтобы применить это поведение. Обычное обновление не перезаписывает исправную обёртку Windows. +::: + :::tip[Service vs Shim] Используйте `ocx service` для всегда работающего фонового прокси (рекомендуется). Используйте `ocx codex-shim` для лёгкого on-demand запуска без демона — в этом случае прокси стартует только когда запускается `codex`. ::: +#### Передача токена в Codex + +При привязке к адресу, отличному от loopback, внедрённый провайдер содержит `env_key = "OPENCODEX_API_AUTH_TOKEN"`. Эта строка указывает Codex, какую переменную читать, но не создаёт её. Если переменная отсутствует, Codex отказывается начинать запрос (`Missing environment variable: OPENCODEX_API_AUTH_TOKEN`), и запрос не доходит до прокси. Значение хранится в `$OPENCODEX_HOME/service-api-token`; запускающий процесс должен передать его в окружение Codex. + +Используйте поддерживаемую обёртку, устанавливаемую командой `ocx codex-shim install`. Если в контексте запуска выбирается эта обёртка, она читает файл токена, созданный OpenCodex, и передаёт переменную Codex. При запуске из настольной среды, cron или службы необходимо использовать PATH либо путь к средству запуска, который выбирает обёртку; установка не настраивает эти окружения автоматически. Собственные дочерние процессы Codex также могут унаследовать токен. + +Не экспортируйте этот Bearer-токен из файла запуска оболочки и не копируйте его в `config.toml`. Файл `service-api-token` содержит сам токен, а не присваивания вида `NAME=value`, поэтому его нельзя напрямую использовать как `EnvironmentFile=` в systemd. + +`EnvironmentFile=` или `OCX_API_TOKEN_FILE` в `opencodex-proxy.service` настраивает только процесс прокси и никогда не передаётся в независимо запущенный `codex exec`. + +Обновление Codex, заменяющее средство запуска, удаляет обёртку; следующая обычная команда `ocx` восстанавливает её (см. выше), но `codex exec`, запущенный до этого, завершается ошибкой. `ocx doctor` сообщает именно об этом состоянии в разделе "Codex env_key launch readiness" (env_key настроен, переменная не задана, обёртка отсутствует или неисправна, файл токена присутствует), приводит команду исправления и никогда не выводит токен. Чтение файла токена не входит в контракт внедрённого `env_key`; запускающий процесс должен передать эту переменную. + ### `ocx tray [--json] [--no-start]` Установить и управлять Windows tray icon со статусом. Иконка стартует при логине в Windows и даёт diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index 865fb3d358..3677c7357e 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -33,13 +33,21 @@ Management API предоставляет `GET`/`PUT /api/v2`, `/api/injection-m `/api/subagent-models` и `/api/subagent-model-fallback`. Обновления injection-model частичные; custom prompt на этом API передаётся полем `prompt`. +## Всегда проактивное делегирование + +**Всегда проактивное делегирование** в Subagents → Дополнительно (прежнее название — **Ultra mode**) меняет только условие запуска делегирования, не меняя reasoning effort. Рекомендуемый preset сохраняет инструкции пользователя, границы полномочий, область задачи и правила работы с инструментами. + +`GET` и `PUT /api/v2` дополнительно возвращают `multiAgentModeHintRecommendation: { text, revision }`. При включении или восстановлении preset дашборд использует текст сервера без встроенного запасного варианта. Если старый сервер не возвращает рекомендацию или возвращает некорректное значение, применение и восстановление preset недоступны; существующий custom hint по-прежнему можно редактировать или удалять. Восстановление preset меняет только локальный черновик; действие сохранения записывает его. + +Чтение настроек, несвязанные изменения и обновление версии не мигрируют сохранённый hint. Только явное обновление hint, текст которого побайтово совпадает с одним из двух известных старых preset OpenCodex, заменяет его текущей рекомендацией. Остальной допустимый custom text, включая варианты с отличиями в пробельных символах, сохраняется побайтово. Существующие правила включения v2, проверки поддержки и удаления hint не меняются; изменения применяются к новым сессиям Codex. + ## Roster и guidance Эффективный ростер v2 — это настроенные, видимые в picker'е, отсортированные по priority первые -пять моделей, совместимых с v2 и присутствующих во внедряемом каталоге. Для v2 запись считается -допустимой, если upstream pin равен `"v2"`, `null` либо вовсе отсутствует; реальный pin `"v1"` -исключает модель. Исключённые записи всё равно остаются в конфигурации, чтобы позже снова стать -допустимыми. +пять моделей, присутствующих во внедряемом каталоге и не отмеченных явно как `"disabled"`. +Явный pin `"v2"` поддерживает рекурсивных подагентов; `"v1"`, `null` и отсутствующий pin +остаются допустимыми для подагентов без дальнейшего делегирования. Исключённые записи остаются +в конфигурации, чтобы позже снова стать допустимыми. Определение surface основано на форме tool'ов. Namespaced `spawn_agent` вместе с `send_input`, `resume_agent` или `close_agent` — это v1. Плоский `spawn_agent` вместе с `send_message`, @@ -51,10 +59,22 @@ roster или fallback chain. Встроенное guidance v2 ограниче сначала удаляет roster. Guidance дедуплицируется по replay-prefix и вставляется перед завершающим `compaction_trigger`. -`injectionModel` и `injectionEffort` носят рекомендательный характер, если только не включён -native-default sync. Встроенный текст v2 просит Codex передавать поддерживаемые override'ы model -и effort в `spawn_agent` с `fork_turns: "none"`. В custom `injectionPrompt` отсутствующие значения -подставляются как пустая строка. +И встроенные указания v2 для подагентов, и пользовательские тела `injectionPrompt` используют +``, отдельно от нативных сообщений Codex ``. +Встроенный текст сообщает итоговую предпочтительную модель, список моделей и цепочку резервных +моделей, но не предписывает делегирование, переопределение модели или `fork_turns`. Подстановка +значений в плейсхолдеры и содержимое пользовательских тел сохраняются. `injectionModel` и +`injectionEffort` остаются рекомендациями, если не включена синхронизация нативных значений по +умолчанию; отсутствующие значения пользовательских плейсхолдеров заменяются пустой строкой. + +Дедупликация replay проверяет точное совпадение с последним текстом в каждой группе тегов. +Если оба значения используют новую группу тегов прокси, при возврате от пользовательских указаний +к встроенной форме добавляется её текущее содержимое; промежуточные изменения нативного режима +не дублируют неизменившиеся указания прокси. +Существующая история нативных сообщений и сообщений со старым тегом сохраняется. Изменение +обёртки не устанавливает автора старых сообщений и не отменяет прежние инструкции; историю +со смешанными версиями нельзя классифицировать только по старому тегу, и обнаружение переходов +в такой истории не гарантируется. ## Синхронизация native default'ов Codex diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 5dc59ff534..82b52a9829 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -85,6 +85,10 @@ cross-route credential fallback не существует. Строки API GPT- перезаписывая отличающуюся backup-копию, и переписывает известные legacy namespaced-id, выбранные в `selectedModels`, в bare-id. +## Псевдонимы пространств имён провайдеров + +У провайдеров могут быть встроенные сокращения, например `agy` для `google-antigravity`. Если имя настроенного провайдера или явный псевдоним занимает такое сокращение без учёта регистра, встроенное сокращение другого провайдера отключается и в каталоге, и при маршрутизации по псевдониму. Например, при настройке провайдера с именем `agy` модели Google отображаются как `google-antigravity/`, а `agy/` выбирает настроенного провайдера. Канонические имена провайдеров по-прежнему требуют точного совпадения регистра; для нераспознанных префиксов сохраняется существующий резервный путь маршрутизации модели. + ## Записи провайдеров (`OcxProviderConfig`) | Поле | Тип | Значение | diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 6da7d796ea..9d41a4538d 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -21,6 +21,10 @@ control и safety ответа всё равно происходят на гр настраиваются в [Конфигурации](/reference/configuration/); если один публичный id модели должен выбирать между несколькими целями, используйте [Combos](/guides/combos/). +## Перенаправления upstream + +Запросы к моделям, изображениям, видео и поиску, содержащие учётные данные, не следуют HTTP-перенаправлениям автоматически, в том числе в пределах одного origin. Укажите конечный URL API вместо перенаправляющего адреса. Сервер не отправляет учётные данные и тело запроса по адресу перенаправления. Существующая обработка ошибок и передача ответа сохраняются; маршруты native Responses и compact могут вернуть клиенту исходные 3xx и `Location`. Поведение перенаправлений клиента не определяется этой транспортной политикой сервера. + ## Обзор endpoint'ов | Клиентская поверхность | Endpoint | Успешный non-stream результат | Успешный результат потока или сокета | @@ -312,16 +316,18 @@ conversation. | Поверхность | Выделенный | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP и WebSocket | Обязателен | Отклоняется для proxy-admission | Отклоняется | -| `/v1/responses/compact` | Обязателен | Отклоняется для proxy-admission | Отклоняется | -| `/v1/chat/completions` | Обязателен | Отклоняется для proxy-admission | Отклоняется | +| `/v1/responses` HTTP и WebSocket | Принимается | Принимается | Отклоняется | +| `/v1/responses/compact` | Принимается | Принимается | Отклоняется | +| `/v1/chat/completions` | Принимается | Принимается | Отклоняется | | `/v1/messages` и `/v1/messages/count_tokens` | Принимается | Принимается | Принимается | | `/v1/models` | Принимается | Принимается | Принимается | | `/v1/live`, `/v1/realtime/calls` и sideband-join'ы | Принимается | Принимается | Принимается | -Responses-family и Chat-запросы резервируют `Authorization` под passthrough провайдера или Codex -Direct, поэтому remote proxy key здесь обязан идти через dedicated-заголовок. Surface'ам Messages -и Realtime нужна более широкая совместимость с клиентами, поэтому там принимаются все три формы. +Запросы Responses и Chat принимают ключ прокси в выделенном заголовке или поле Bearer. На нативных маршрутах bearer допуска заменяется выбранными сохранёнными учётными данными Codex, а на остальных удаляется. Он никогда не используется для аутентификации upstream. Если передаётся отдельный bearer провайдера, ключ прокси следует указать в выделенном заголовке. + +Маршрут Cursor без ключа и без OAuth может использовать отдельный bearer вызывающей стороны, но не секрет прокси и не автоматически добавленную аутентификацию ChatGPT main. Выбор Combo/policy и фактические изменения маршрута shadow/thread-spawn не передают исходные учётные данные вызывающей стороны новым целям. Каноническая маршрутизация OpenAI может восстановить единственный bearer вызывающей стороны, не являющийся ключом прокси, после внутреннего изменения маршрута только если его JWT содержит claim учётной записи ChatGPT и любой явно указанный заголовок учётной записи соответствует этому claim. Для передачи аутентификации вызывающей стороны в необязательные sidecar OpenAI необходимы один JWT и явно указанный соответствующий `chatgpt-account-id`. Непрозрачные bearer не восстанавливаются после изменения маршрута даже при наличии явно указанного заголовка учётной записи. В остальных случаях конечной цели нужны собственные настроенные, OAuth или сохранённые учётные данные; иначе запрос завершается локальной ошибкой. Один маркер thread-spawn без изменения маршрута не удаляет учётные данные. + +Claude replay сохраняет аутентификацию main только в снимке в памяти, владение которым обеспечено текущим turn, и восстанавливает её только для конечного канонического маршрута ChatGPT. :::caution Ключи data plane — это не management credentials. У management API свой отдельный admin-secret; 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 d13af52274..46b5f8e296 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -437,10 +437,9 @@ olduğunda ve `tokenGuardian.codexWarmupEnabled` true olduğunda çalışır. ## Yerel Codex'i geri yükleme -opencodex sizi asla tuzağa düşürmez. **`ocx stop`, yerel Codex'e tamamen geri -dönen tek komuttur** — proxy'yi durdurur, kuruluysa arka plan servisini durdurur -ve enjekte edilen her satırı ve yönlendirilen katalog girdisini kaldırır, -böylece düz `codex` sanki opencodex hiç var olmamış gibi tam olarak çalışır: +`ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. + +Mevcut yapılandırma veya profil kayıtlı özgün içerikten farklıysa ve günlükte o dosyanın enjekte edilmiş durumunun karması yoksa otomatik kurtarma iki dosyayı ve günlüğü değiştirmeden korur. Özgün içerikle zaten aynı olan dosya yeniden yazılmaz. Yönlendirilmiş bir yapılandırmaya yeniden enjeksiyon da bu belirsiz durumu reddeder; yerel yapılandırma yeni bir anlık görüntü oluşturabilir. [Kurtarma kurallarına](/guides/codex-integration/#recovery-without-injection-hashes) bakın. ```bash ocx stop # proxy'yi + servisi durdurun, yerel Codex'i geri yükleyin diff --git a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md index 5e74546bcf..943a293d40 100644 --- a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md @@ -96,8 +96,9 @@ rehberlik yalnızca tercih edilen bir model, uygun kadro veya geri dönüş zinc istem oluşturmak için yeterlidir; yalın bir değer benzersiz şekilde çözümlenemezse `{{model}}` boş bir dizeye genişler. -v1'de opencodex yalnızca `max` veya `ultra` çabada yukarı akış tarzı proaktif -yetkilendirme rehberliğini enjekte eder. v1'de tercih edilen bir model, kadro, +v1'de opencodex, yalnızca `max` veya `ultra` çaba düzeylerinde v2'nin önerilen ön ayarıyla aynı proaktif görev devri metnini enjekte eder. +Yalnızca tetikleme koşulu değişir: ayrıca görev devri talep edilmesi gerekmez; kullanıcı talimatları, yetkiler, görev kapsamı ve iş birliği araçlarının kuralları geçerliliğini korur. +v1'de tercih edilen bir model, kadro, geri dönüş listesi veya özel istem eklemez. Varsayılan olarak kapalı olan `syncCodexSubagentDefaults` seçeneği rehberlikten 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 a275db3f9b..1a5f34da3c 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -84,6 +84,10 @@ Bu, geniş kapsamlı ve yıkıcı bir yeniden etiketlemedir: kullanıcı iletisi olarak normalleştirilir ve event marker ayarlanır. Geçerli dedicated-provider geçmişi de kapsama dahildir. Durumu yedekleyin ve yalnızca bu kapsamın tamamını istiyorsanız çalıştırın. +### `ocx recover-history --ocx-compaction --yes` + +Yönlendirilmiş bir sağlayıcı üzerinden sıkıştırılmış bir görevi yerel Codex ile sürdürmeden önce geçmişini onarın. Komut UUID ile yalnızca bir görevi seçer, önce özel ve bayt bayt bir yedek kaydeder, ardından yalnızca OpenCodeX'e ait `ocx1:` sıkıştırma durumunu yerel Codex'in yeniden oynatabileceği düz bir özete dönüştürür. Yerel şifreli içerik ve diğer görevler değişmeden kalır. Komutu çalıştırmadan önce seçili görevi kapatın; işlem sırasında rollout değişirse kurtarma dosyayı değiştirmeden durur. + ### `ocx uninstall` · `ocx remove` Servisi ve proxy'yi durdurun, servisi ve Codex dolgusunu kaldırın, yerel Codex'i diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 9625887762..ad304fde78 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -23,6 +23,10 @@ sınırında gerçekleşir. Dinleyiciyi ve kabul anahtarlarını genel model kimliği birkaç hedef arasından seçim yapması gerektiğinde [Kombolar](/tr/guides/combos/) kullanın. +## Üst sunucu yönlendirmeleri + +Kimlik bilgisi taşıyan model, görsel, video ve arama istekleri, aynı origin içindeki yönlendirmeler dâhil HTTP yönlendirmelerini otomatik izlemez. Yönlendiren bir adres yerine son API URL’sini yapılandırın. Sunucu, kimlik bilgilerini veya istek gövdesini yönlendirme hedefine yeniden göndermez. Mevcut hata işleme ve yanıt aktarma davranışı korunur; native Responses ve compact yolları, özgün 3xx ve `Location` değerini istemciye döndürebilir. İstemcinin yönlendirme davranışı bu sunucu aktarım politikasından ayrıdır. + ## Uç nokta genel bakışı | İstemci yüzeyi | Uç nokta | Başarılı akışsız sonuç | Başarılı akış veya soket sonucu | @@ -328,17 +332,18 @@ ve `x-api-key` anlamına gelir. | Yüzey | Özel | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP ve WebSocket | Gerekli | Proxy kabulü için reddedilir | Reddedilir | -| `/v1/responses/compact` | Gerekli | Proxy kabulü için reddedilir | Reddedilir | -| `/v1/chat/completions` | Gerekli | Proxy kabulü için reddedilir | Reddedilir | +| `/v1/responses` HTTP ve WebSocket | Kabul Edilir | Kabul Edilir | Reddedilir | +| `/v1/responses/compact` | Kabul Edilir | Kabul Edilir | Reddedilir | +| `/v1/chat/completions` | Kabul Edilir | Kabul Edilir | Reddedilir | | `/v1/messages` ve `/v1/messages/count_tokens` | Kabul Edilir | Kabul Edilir | Kabul Edilir | | `/v1/models` | Kabul Edilir | Kabul Edilir | Kabul Edilir | | `/v1/live`, `/v1/realtime/calls` ve yan bant katılımları | Kabul Edilir | Kabul Edilir | Kabul Edilir | -Responses ailesi ve Sohbet istekleri `Authorization`'ı sağlayıcı veya Codex -Direct doğrudan geçişi için ayırır, bu nedenle uzak bir proxy anahtarı özel -başlığı kullanmalıdır. Messages ve Realtime yüzeyleri daha geniş istemci -uyumluluğuna ihtiyaç duyar ve bu nedenle üç formu da kabul eder. +Responses ailesi ve Chat istekleri, özel başlıkta veya Bearer alanında bir proxy anahtarını kabul eder. Yerel Codex rotalarında seçilen kayıtlı Codex kimlik bilgisi kabul bearer’ının yerini alır; diğer rotalarda bu bearer kaldırılır. Proxy anahtarı hiçbir zaman upstream kimlik bilgisi olarak kullanılmaz. Ayrı bir sağlayıcı bearer’ı da gönderiyorsanız proxy anahtarını özel başlığa koyun. + +Anahtarı olmayan ve OAuth kullanmayan bir Cursor rotası, çağıranın ayrı bearer’ını kullanabilir; proxy sırrını veya otomatik eklenen ChatGPT main kimlik bilgisini kullanamaz. Combo/policy seçimi ve gerçekleşen shadow/thread-spawn rota değişiklikleri, çağıranın ham kimlik bilgilerini yeni hedeflere aktarmaz. Kanonik OpenAI yönlendirmesi, dahili rota değişikliğinden sonra çağıranın proxy anahtarı olmayan tek bearer’ını yalnızca JWT’si bir ChatGPT hesap claim’i içeriyorsa ve açıkça belirtilmiş herhangi bir hesap başlığı bu claim ile eşleşiyorsa geri yükleyebilir. Çağıranın kimlik doğrulamasını isteğe bağlı OpenAI sidecar’larına iletmek için tek bir JWT ve onunla eşleşen, açıkça belirtilmiş bir `chatgpt-account-id` gerekir. Opaque bearer’lar, açıkça belirtilmiş bir hesap başlığı olsa bile rota değişikliklerinden sonra geri yüklenmez. Diğer durumlarda son hedefin kendi yapılandırılmış, OAuth veya kayıtlı kimlik bilgisi bulunmalıdır; aksi hâlde istek yerel olarak başarısız olur. Rota değişmeden yalnızca thread-spawn işaretinin bulunması kimlik bilgilerini kaldırmaz. + +Claude replay, main kimlik bilgisini yalnızca ilgili turn tarafından sahipliği alınmış bir bellek snapshot’ında tutar ve yalnızca son hedef kanonik bir ChatGPT rotasıysa geri yükler. :::caution Veri düzlemi anahtarları yönetim kimlik bilgileri değildir. Yönetim API'si ayrı 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 0c3df0c0e2..1170d9207f 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 @@ -321,9 +321,9 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 ## 恢复原生 Codex -opencodex 绝不会把你困住。**`ocx stop` 是完全恢复原生 Codex 的单一命令** —— 它会停止 proxy、 -停止后台服务(如已安装),并剥除所有注入的行和路由的目录条目,使普通的 `codex` 完全像 opencodex -从未存在过一样工作: +`ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 Codex。OpenCodex 只移除能够确认归属的路由配置;如果无法安全恢复配置文件,会报告恢复未完成。 + +如果当前 config 或 profile 与保存的原始内容不同,且日志缺少该文件注入状态的哈希值,自动快照恢复会保留两个文件和日志,不作修改。已经与原始内容相同的文件不会重写。对已路由配置的再次注入也会拒绝使用这种未确认的基线;原生配置可以建立新的快照。详见[恢复规则](/guides/codex-integration/#recovery-without-injection-hashes)。 ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index d1cde7034b..9d794686a3 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -58,7 +58,9 @@ Dashboard 上的 **Sub-agent delegation** 控件管理三个相关设置: 内置的 v2 指引有 700 字符预算。如果会超出预算,opencodex 会优先删除 roster,而不是截断核心 spawn 指令。内置指引仅在首选模型、可用 roster 或 fallback chain 解析成功时触发。只要配置了 `injectionModel`,自定义提示词就会触发;如果未限定的值无法唯一解析,`{{model}}` 会替换为空字符串。 -在 v1 上,opencodex 只会在 `max` 或 `ultra` effort 下注入上游风格的主动委派指引。它不会在 v1 上额外添加首选模型、roster、fallback list 或自定义提示词。 +在 v1 上,opencodex 只在 `max` 或 `ultra` 推理强度下注入与 v2 推荐预设相同的主动委派指引。 +仅改变委派的触发条件:不再需要单独提出委派请求;用户指示以及权限、任务范围和协作工具规则仍然适用。 +它不会在 v1 上额外添加首选模型、roster、fallback list 或自定义提示词。 默认关闭的 `syncCodexSubagentDefaults` 选项与指引是分开的。当 opencodex 拥有活跃的 Codex 路由时,同步或重启可以把所选值写入 Codex TOML 中带标记的 `[agents] default_subagent_model` 和 `default_subagent_reasoning_effort` 条目。opencodex 只会更新或移除带有其标记的字段。如果任一目标字段属于用户,整对值会保持不变,而不会部分写入;含糊不清的 TOML 会在不写入的情况下被拒绝。外部 provider 管理器和用户拥有的根路由也仍然具有最终权威。 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 6d6414af15..e9e850e5bf 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 @@ -53,6 +53,10 @@ ocx eject back 这是范围很广且具有破坏性的重标记:所有包含用户消息且当前标记为 `opencodex` 的线程都会改标为 `openai`,`exec` 会规范化为 `cli`,并设置事件标记。正常的专用提供方历史记录也在范围内。请先备份状态,并且仅在确实需要这一完整范围时执行。 +### `ocx recover-history --ocx-compaction --yes` + +在通过原生 Codex 恢复某个曾由路由提供方压缩的任务前,修复该任务的历史记录。此命令按 UUID 精确选择一个任务,先保存私有的逐字节备份,然后仅将 OpenCodeX 自有的 `ocx1:` 压缩状态转换为原生 Codex 可重放的普通摘要。原生加密内容和其他任务保持不变。运行前请关闭所选任务;如果 rollout 在处理期间发生变化,恢复会停止且不会替换原文件。 + ### `ocx uninstall` · `ocx remove` 停止服务和代理,移除服务和 Codex shim,恢复原生 Codex,然后仅在所有恢复步骤都成功时才删除 opencodex 本地配置。`remove` 是 `uninstall` 的别名。配置清理需要由全新安装创建的所有权元数据;旧版或共享目录会保留原样。 @@ -209,10 +213,28 @@ ocx codex-shim status ocx codex-shim uninstall ``` +:::note[Windows 令牌环境] +新生成的 Windows CMD 和 PowerShell shim 会在执行后恢复调用方原有的 `OPENCODEX_API_AUTH_TOKEN` 状态。Codex 及其子进程仍可能继承令牌。 + +更新 OpenCodex 后,如需让现有 Windows shim 采用此行为,请先运行 `ocx codex-shim uninstall`,再运行 `ocx codex-shim install` 重新安装。常规更新不会重写正常的 Windows shim。 +::: + :::tip[Service vs Shim] 将 `ocx service` 用于始终在线的后台代理(推荐)。将 `ocx codex-shim` 用于无需守护进程的轻量按需启动——代理只会在启动 `codex` 时运行。 ::: +#### 向 Codex 注入令牌 + +绑定到非回环地址时,注入的提供程序包含 `env_key = "OPENCODEX_API_AUTH_TOKEN"`。这一行告诉 Codex 应读取哪个变量,但不会创建该变量。如果变量不存在,Codex 会拒绝发起请求(`Missing environment variable: OPENCODEX_API_AUTH_TOKEN`),请求也不会到达代理。变量值保存在 `$OPENCODEX_HOME/service-api-token` 中;启动进程必须将其传入 Codex 的环境。 + +请使用通过 `ocx codex-shim install` 安装且受维护的 shim。如果启动上下文选择此 shim,它会读取 OpenCodex 创建的令牌文件,并将变量传给 Codex。从桌面、cron 或服务启动时,必须使用能够选中该 shim 的 PATH 或启动器路径;安装过程不会自动配置这些环境。Codex 自身的子进程也可能继承令牌。 + +不要在 shell 启动文件中导出此 Bearer 令牌,也不要将其复制到 `config.toml`。`service-api-token` 文件包含的是原始令牌,而不是 `NAME=value` 形式的赋值,因此不能直接用作 systemd 的 `EnvironmentFile=`。 + +`opencodex-proxy.service` 中的 `EnvironmentFile=` 或 `OCX_API_TOKEN_FILE` 仅配置代理进程,绝不会传入独立启动的 `codex exec`。 + +替换启动器的 Codex 升级会移除 shim;下一次执行普通的 `ocx` 命令时会将其恢复(见上文),但在此之前运行的 `codex exec` 会失败。`ocx doctor` 会在 "Codex env_key launch readiness" 项下报告这一确切状态(env_key 已配置、变量未设置、shim 缺失或不正常、令牌文件存在),并给出修复命令,且绝不会输出令牌。读取令牌文件不属于注入的 `env_key` 的约定;启动进程必须提供该变量。 + ### `ocx tray [--json] [--no-start]` 安装并控制 Windows 状态托盘图标。它会在 Windows 登录时启动,并提供一键代理控制。`start` 和 `stop` 只控制图标本身;要控制代理,请使用其菜单。`--no-start` 适用于 `install`,会安装托盘但不会立即启动。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index ffac2b6776..c043b6ef47 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -26,15 +26,25 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 管理 API 公开 `GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models` 和 `/api/subagent-model-fallback`。injection-model 更新是部分更新;自定义 prompt 是该 API 上的 `prompt` 字段。 +## 始终主动委派 + +Subagents → 高级中的 **始终主动委派**(原名 **Ultra mode**)只改变触发委派的条件,不改变推理 effort。推荐预设仍遵循用户指令、权限边界、任务范围和工具规则。 + +`GET` 和 `PUT /api/v2` 还会返回 `multiAgentModeHintRecommendation: { text, revision }`。仪表板在启用或恢复预设时使用服务器提供的文本,不会回退到硬编码文案。如果旧服务器没有提供推荐值,或返回的值格式无效,则无法应用或恢复预设;仍可编辑或清除现有的自定义提示。恢复预设只修改本地草稿,保存操作才会将其写入配置。 + +读取设置、无关更新和版本升级不会迁移已保存的提示。只有显式更新提示,且正文与两种已知旧版 OpenCodex 预设之一逐字节完全一致时,才会替换为当前推荐文本。其他有效的自定义文本,包括仅空白字符不同的变体,都会逐字节保留。现有的 v2 启用、功能支持检查和清除提示规则保持不变;更改会应用于新的 Codex 会话。 + ## 名单与引导 -有效的 v2 名单,是已配置、在选择器中可见、按优先级排序的前五个模型中,和 v2 兼容且存在于注入目录中的那些模型。v2 资格判定会把显式的 `"v2"`、`null`,或缺失的上游固定值视为可用;真正的 `"v1"` 固定值会被排除。被排除的条目仍会保留在配置中,以便将来重新变为可用。 +有效的 v2 名单,是已配置、在选择器中可见、按优先级排序的前五个模型中,存在于注入目录且未明确标记为 `"disabled"` 的模型。显式的 `"v2"` 标记支持递归子代理;`"v1"`、`null` 和缺失的标记仍可作为叶子子代理。被排除的条目仍会保留在配置中,以便将来重新变为可用。 界面检测使用工具形状来判断。带命名空间的 `spawn_agent`,如果具有 `send_input`、`resume_agent` 或 `close_agent`,就是 v1。平铺的 `spawn_agent`,如果具有 `send_message`、`followup_task`、`interrupt_agent` 或 `list_agents`,就是 v2。 V1 引导只会在 `max` 或 `ultra` 时以主动文本形式出现。V2 只有在存在首选模型、可用名单或回退链时,才会收到代理生成的开发者消息。内置 v2 引导有 700 个字符的预算,必要时会先删减名单。引导会在 replay prefix 之间去重,并插入到末尾的 `compaction_trigger` 之前。 -除非启用了原生默认值同步,`injectionModel` 和 `injectionEffort` 都只是建议。内置 v2 文本会要求 Codex 使用 `fork_turns: "none"` 将受支持的模型/effort 覆盖传给 `spawn_agent`。自定义 `injectionPrompt` 会把缺失值替换为空字符串。 +内置 v2 子代理引导和自定义 `injectionPrompt` 正文都使用 ``,与 Codex 原生的 `` 消息区分开来。内置文本会说明解析后的首选模型、名单和回退链,但不会指示委派、模型覆盖或 `fork_turns`。自定义正文的占位符替换和内容保持不变。除非启用了原生默认值同步,`injectionModel` 和 `injectionEffort` 仍只是建议;自定义占位符的缺失值仍替换为空字符串。 + +replay 去重会分别与每类标签的最新文本进行精确比较。当两个值都使用新的代理标签时,从自定义引导切回内置形式会追加当前的引导内容;期间原生模式的变化不会导致未改变的代理引导被重复添加。现有的原生消息历史和带旧标签的历史都会保留。更换包装标签并不能确定旧消息的作者,也不会撤销先前的指令;对于混合版本的历史,不能仅凭旧标签进行分类,也不保证检测到这类历史中的设置切换。 ## Codex 原生默认值同步 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index a978d26d8f..5ff2094547 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -72,6 +72,10 @@ selector,而不是分配一个新名称。 `openaiProviderTierVersion: 2` 标记当前的单提供者投影。对已发布的 v1 配置进行迁移之前,opencodex 会创建 `config.json.pre-openai-tiers-v2.bak`,且不会覆盖不同的备份文件,并会把已知的旧式命名空间选择 id 重写为裸 id。 +## 提供者命名空间别名 + +提供者可以有内置缩写,例如 `google-antigravity` 的 `agy`。如果已配置的提供者名称或显式别名占用了该缩写(不区分大小写),另一个提供者的内置缩写就会在目录名称和别名路由中同时禁用。例如,配置名为 `agy` 的提供者后,Google 模型会显示为 `google-antigravity/`,而 `agy/` 会选择已配置的提供者。规范提供者名称仍要求大小写完全一致;无法识别的前缀继续沿用现有的模型路由回退行为。 + ## 提供者条目(`OcxProviderConfig`) | 字段 | 类型 | 含义 | diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index b975226d7b..b6ff7a5f4a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -19,6 +19,10 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 [Configuration](/reference/configuration/) 中配置监听器和准入密钥;当一个公开模型 ID 需要在多个目标之间选择时,请使用 [Combos](/guides/combos/)。 +## 上游重定向 + +携带凭据的模型、图像、视频和搜索请求不会自动跟随 HTTP 重定向,包括同源重定向。请配置最终上游 API URL,而不是会重定向的别名。服务器不会向重定向目标重新发送凭据或请求正文。各响应处理路径保留原有的错误处理或转发行为;原生 Responses 和 compact 路径仍可向客户端返回原始 3xx 和 `Location`。客户端的重定向行为与此服务器传输策略是不同的边界。 + ## 端点总览 | 客户端表面 | 端点 | 成功的非流式结果 | 成功的流式或套接字结果 | @@ -257,15 +261,18 @@ Compaction 会为需要缩短长 Responses 会话的客户端返回替换历史 | 表面 | Dedicated | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP 和 WebSocket | 必需 | 被代理准入拒绝 | 被拒绝 | -| `/v1/responses/compact` | 必需 | 被代理准入拒绝 | 被拒绝 | -| `/v1/chat/completions` | 必需 | 被代理准入拒绝 | 被拒绝 | +| `/v1/responses` HTTP 和 WebSocket | 接受 | 接受 | 被拒绝 | +| `/v1/responses/compact` | 接受 | 接受 | 被拒绝 | +| `/v1/chat/completions` | 接受 | 接受 | 被拒绝 | | `/v1/messages` 和 `/v1/messages/count_tokens` | 接受 | 接受 | 接受 | | `/v1/models` | 接受 | 接受 | 接受 | | `/v1/live`、`/v1/realtime/calls` 和 sideband join | 接受 | 接受 | 接受 | -Responses 家族和 Chat 请求会把 `Authorization` 留给提供方或 Codex Direct -透传,因此远程代理密钥必须使用专用头。Messages 和 Realtime 表面需要更广泛的客户端兼容性,因此接受这三种形式。 +Responses 系列和 Chat 请求接受专用标头或 Bearer 字段中的代理密钥。在原生路由上,所选的已保存 Codex 凭据会替换 admission bearer;其他路由会移除该 bearer。代理密钥绝不会用作 upstream 凭据。如果还要提供独立的 provider bearer,请将代理密钥放在专用标头中。 + +没有密钥且不使用 OAuth 的 Cursor 路由可以使用调用方单独提供的 bearer,但不能使用代理 secret 或自动补充的 ChatGPT main 凭据。Combo/policy 选择以及实际发生的 shadow/thread-spawn 路由改写不会将调用方的原始凭据传递给新目标。规范 OpenAI 路由仅在 JWT 包含 ChatGPT 账户声明,且任何显式账户标头都与该声明匹配时,才可在内部路由变更后恢复调用方的单个非代理密钥 bearer。 向可选的 OpenAI sidecar 转发调用方认证时,需要单个 JWT 以及显式提供且匹配的 `chatgpt-account-id`。即使提供了显式账户标头,opaque bearer 也不会跨路由变更恢复。 除此之外,最终目标必须拥有自己的配置、OAuth 或已保存凭据,否则请求会在本地失败。只有 thread-spawn 标记而没有路由变化时,不会移除凭据。 + +Claude replay 只会以当前 turn 已取得所有权的内存 snapshot 保留 main 凭据,并且仅在最终目标为规范 ChatGPT 路由时恢复它。 :::caution 数据平面密钥不是管理凭证。管理 API 使用单独的 admin secret; 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 44ee5bbb3b..b8db326af6 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 @@ -328,9 +328,9 @@ body。背景重新驗證是獨立功能,預設關閉;只有啟用 Token Gua ## 恢復原生 Codex -opencodex 絕不會把你困住。**`ocx stop` 是完整恢復原生 Codex 的單一命令**。它會停止 proxy、停止 -背景服務(若已安裝),並移除所有注入行與路由目錄條目,讓普通的 `codex` 就像從未安裝 opencodex 一樣 -運作: +`ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 + +若目前的 config 或 profile 與儲存的原始內容不同,且日誌缺少該檔案注入狀態的雜湊值,自動快照恢復會保留兩個檔案及日誌,不做修改。已與原始內容相同的檔案不會重新寫入。對已路由設定再次注入時,也會拒絕使用這種未確認的基準;原生設定可以建立新的快照。詳見[恢復規則](/guides/codex-integration/#recovery-without-injection-hashes)。 ```bash ocx stop # 停止 proxy + service,恢復原生 Codex diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 46b03df9a1..cd767e0a9b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -80,7 +80,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil - **Restore this point…** 會出現在較舊的操作上,或當檔案在那次操作之後有變更時。跨過這樣的變更做回復會再詢問一次,才覆蓋你的較新編輯——並且也會備份它們,所以那次的回復本身也可以復原。 - 每個客戶端保留十份備份。超過之後,最舊的快照檔案會被移除,其歷史列顯示為 **Backup expired**。 -停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP、DSH 與 Hermes** 同樣不受旁邊編輯影響,但原因不同:它們的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(OpenClaw、Kimi Code、Gajae Code、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 ## 誠實的預期 diff --git a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md index b6c9108d07..61cbfd2c14 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md @@ -63,7 +63,9 @@ opencodex 允許你為目錄中的所有模型選擇多代理協作介面。儀 內建指引只在偏好模型、合格名冊或 fallback 鏈解析成功時觸發。設定了 `injectionModel` 就足以渲染 自訂提示詞;若裸值無法唯一解析,`{{model}}` 會展開為空字串。 -在 v1 上,opencodex 只在 `max` / `ultra` effort 注入上游風格的主動委派指引。v1 不會附加偏好模型、 +在 v1 上,opencodex 只在 `max` 或 `ultra` 推理強度下注入與 v2 建議預設相同的主動委派指引。 +僅改變委派的觸發條件:不再需要另外提出委派請求;使用者指示以及權限、任務範圍與協作工具規則仍然適用。 +v1 不會附加偏好模型、 名冊、fallback 清單或自訂提示詞。 預設關閉的 `syncCodexSubagentDefaults` 選項與指引無關。當 opencodex 擁有作用中的 Codex 路由時, 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 4e1fc9700b..5ec90ab0f7 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 @@ -52,6 +52,10 @@ ocx eject back 這是範圍很廣且具破壞性的重新標記:所有含有使用者訊息且目前標記為 `opencodex` 的 thread 都會改標為 `openai`,`exec` 會正規化為 `cli`,並設定 event marker。正常的專用 provider 歷史也包含在內。請先備份狀態,而且只有在確實需要這個完整範圍時才執行。 +### `ocx recover-history --ocx-compaction --yes` + +在透過原生 Codex 恢復曾由路由提供方壓縮的工作前,修復該工作的歷史記錄。此命令依 UUID 精確選取一個工作,先儲存私有的逐位元組備份,然後只把 OpenCodeX 自有的 `ocx1:` 壓縮狀態轉換成原生 Codex 可重播的普通摘要。原生加密內容與其他工作不會變更。執行前請關閉所選工作;若 rollout 在處理期間發生變化,復原會停止且不會取代原始檔案。 + ### `ocx uninstall` · `ocx remove` 停止服務與代理、移除服務與 Codex shim、還原原生 Codex,然後僅在所有還原步驟成功時移除 opencodex 本機設定。`remove` 是 `uninstall` 的別名。設定清理需要由全新安裝建立的擁有權中繼資料;舊版或共享目錄會被原樣保留。 diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 24abd657c4..f39b2ffc40 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -14,6 +14,10 @@ opencodex 以多種客戶端方言呈現一個本機代理。Codex 客戶端可 Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯並 passthrough 請求,但認證、路由、許可控制與回應安全仍在代理邊界發生。在[設定](/zh-tw/reference/configuration/)中設定監聽器與許可金鑰;當一個公開模型 id 應在多個目標間選擇時使用[組合](/zh-tw/guides/combos/)。 +## 上游重新導向 + +攜帶憑證的模型、圖片、影片和搜尋請求不會自動跟隨 HTTP 重新導向,包括同源重新導向。請設定最終上游 API URL,而非會重新導向的別名。伺服器不會向重新導向目標再次傳送憑證或請求內文。各回應處理路徑保留原有的錯誤處理或轉送行為;原生 Responses 和 compact 路徑仍可向用戶端回傳原始 3xx 與 `Location`。用戶端的重新導向行為與此伺服器傳輸政策屬於不同邊界。 + ## 端點概覽 | 客戶端介面 | 端點 | 成功的非串流結果 | 成功的串流或 socket 結果 | @@ -224,14 +228,18 @@ Compaction 為需要縮短長 Responses 對話的客戶端回傳取代歷史。 | 介面 | 專屬 | Bearer | `x-api-key` | | --- | --- | --- | --- | -| `/v1/responses` HTTP 與 WebSocket | 必填 | 代理許可被拒 | 被拒 | -| `/v1/responses/compact` | 必填 | 代理許可被拒 | 被拒 | -| `/v1/chat/completions` | 必填 | 代理許可被拒 | 被拒 | +| `/v1/responses` HTTP 與 WebSocket | 接受 | 接受 | 被拒 | +| `/v1/responses/compact` | 接受 | 接受 | 被拒 | +| `/v1/chat/completions` | 接受 | 接受 | 被拒 | | `/v1/messages` 與 `/v1/messages/count_tokens` | 接受 | 接受 | 接受 | | `/v1/models` | 接受 | 接受 | 接受 | | `/v1/live`、`/v1/realtime/calls` 與 sideband join | 接受 | 接受 | 接受 | -Responses 家族與 Chat 請求為供應商或 Codex Direct passthrough 保留 `Authorization`,因此遠端代理金鑰必須使用專屬標頭。Messages 與 Realtime 介面需要更廣的客戶端相容性,因此接受所有三種形式。 +Responses 系列和 Chat 請求接受專用標頭或 Bearer 欄位中的代理金鑰。在原生路由上,所選的已儲存 Codex 憑證會取代 admission bearer;其他路由會移除該 bearer。代理金鑰絕不會用作 upstream 憑證。如果還要提供獨立的 provider bearer,請將代理金鑰放在專用標頭中。 + +沒有金鑰且不使用 OAuth 的 Cursor 路由可以使用呼叫端另外提供的 bearer,但不能使用代理 secret 或自動補入的 ChatGPT main 憑證。Combo/policy 選擇及實際發生的 shadow/thread-spawn 路由改寫不會將呼叫端的原始憑證傳遞給新目標。正規 OpenAI 路由僅在 JWT 包含 ChatGPT 帳戶宣告,且任何明確提供的帳戶標頭都與該宣告相符時,才可在內部路由變更後還原呼叫端的單一非代理金鑰 bearer。 將呼叫端驗證轉送至選用的 OpenAI sidecar 時,需要單一 JWT,以及明確提供且相符的 `chatgpt-account-id`。即使明確提供了帳戶標頭,opaque bearer 也不會跨路由變更還原。 除此之外,最終目標必須擁有自己的設定、OAuth 或已儲存憑證,否則請求會在本機失敗。只有 thread-spawn 標記而沒有路由變更時,不會移除憑證。 + +Claude replay 只會以目前 turn 已取得所有權的記憶體 snapshot 保留 main 憑證,並且僅在最終目標為正規 ChatGPT 路由時還原它。 :::caution Data-plane 金鑰不是管理憑證。管理 API 使用獨立的管理秘密;請見[管理 API](/zh-tw/reference/management-api/)。絕不為兩個平面重用同一個秘密。 diff --git a/docs/qoder-cli-provider.md b/docs/qoder-cli-provider.md new file mode 100644 index 0000000000..8148506626 --- /dev/null +++ b/docs/qoder-cli-provider.md @@ -0,0 +1,73 @@ +# Qoder CLI providers + +OpenCodex supports Qoder Global and Qoder CN through their official Personal Access Tokens and headless CLIs. +It does not read Qoder Desktop sessions, browser cookies, refresh tokens, or private console APIs. + +## Qoder Global + +1. Install the official CLI: `npm install -g @qoder-ai/qodercli`. +2. Create a PAT from `https://qoder.com/account/integrations`. +3. Add the `qoder` provider in `ocx init` or the Providers workspace and paste that PAT as the API key. +4. Run `ocx provider test qoder` to verify CLI authentication and account-specific model discovery. + +OpenCodex passes the stored key only as `QODER_PERSONAL_ACCESS_TOKEN` in a scoped child environment. +The adapter accepts only the canonical `https://qoder.com` destination. A legacy custom provider +named `qoder` with another destination keeps its existing adapter and URL. + +The CLI is invoked in one-turn `stream-json` mode with built-in tools disabled (`--tools ""`), MCP +restricted with an empty strict configuration, setting sources disabled, and session persistence +disabled. Codex remains the only tool owner. The first version is text/reasoning only; image input +fails explicitly until the provider route has verified multimodal evidence. + +`qoder --list-models` is the authoritative entitlement roster for the current PAT. OpenCodex uses +its normal model cache and credential-generation invalidation. If discovery fails, it degrades to a +stale cache and then the documented static seed. Quota totals and reset times remain unavailable +because no public quota API is used; insufficient-credit errors are still surfaced as HTTP 429. + +Free, trial, promotional, and subscription credits are expected to use the account attached to the +official PAT/CLI, but the exact product eligibility is account-controlled and is not inferred by +OpenCodex. There is no automatic regional failover or credential exchange. The companion Qoder CN +integration is intentionally delivered as a separate provider/PR with its own PAT, CLI profile, +model entitlement, cache, usage, and health state. + +## Qoder CN + +1. Install the official CLI: `npm install -g @qodercn-ai/qoderclicn` (the vendor install script is also supported). +2. Create a PAT at `https://qoder.cn/account/integrations`. +3. Add the `qoder-cn` provider and paste the PAT as its API key. +4. Run `ocx provider test qoder-cn` to verify the exact account's authentication and live roster. + +The CN profile accepts only `https://qoder.cn`, resolves `qodercn`/`qoderclicn`, and passes the +credential only as `QODERCN_PERSONAL_ACCESS_TOKEN`. It never reads the local interactive login or +OpenCodex OAuth state. Global and CN credentials, executable resolution, model cache identity, +usage, and health are independent; neither region falls back to the other. + +The static CN roster is only a degraded seed captured from authenticated `qoderclicn --list-models` +on 2026-09-03. Live discovery remains authoritative. A real headless turn reached Qoder CN and +returned vendor error code 118 because that test account had zero credits. This proves the local +authentication/transport/model route, not successful inference; no successful CN response is claimed. + +Qoder CN primary sources (verified 2026-09-03): + +- Installation: +- PAT authentication: +- Headless scripts: +- SDK authentication: +- SDK quick start: + +This implementation credits Liang Xu (`Liang-Psych`) for the earlier Qoder CN exploration in +OpenCodex PR #3010. It retains the useful high-level direction—official CLI, headless stream JSON, +and tools disabled—but deliberately replaces that PR's OAuth/private-protocol and ambient-session +design with the documented PAT environment contract and the shared audited coding-agent adapter. + +Primary sources (verified 2026-09-03): + +- Installation: +- PAT authentication: +- Headless scripts and CI: +- Account model discovery: +- SDK/tool configuration: +- Terms: + +The service terms identify BRIGHT ZENITH PRIVATE LIMITED as the operator. This integration uses the +documented CLI automation surface; maintainers should still make the final routing/AUP determination. diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index f5e64b568b..1aab8d651c 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -259,3 +259,69 @@ are the same company, and the mainland console publishes only the wordmark. the same shape as the three Alibaba ids sharing `alibaba-color.svg`. **Not masked:** three linear gradients in Meta brand blue (#0064E0 -> #0278F1), and masking flattens a gradient to a single ink. + +## Qoder (2026-09-08) + +- `qoder.svg` — fetched 2026-09-08 from `https://qoder.com/favIcon.svg`, the icon + the site declares in its own ``. The + conventional paths are all 404s here (`/favicon.svg`, `/icon.svg`, `/logo.svg`), + and the lowercase spelling is one of them — the served path capitalizes the I. + Unmodified: no comments, no ``/`<desc>`, no `data-name`, `xmlns` already + present, so nothing needed stripping. + + Corroborated four ways rather than assumed. `qoder.cn/favIcon.svg` and + `qoder.com.cn/favIcon.svg` serve the same 73379 bytes (MD5 `95f4aecb…`), and so + does the `logo` URL Qoder declares in its own schema.org `Organization` block + (`img.alicdn.com/imgextra/i4/O1CN018ikLCF1sGya2c3YY4_!!6000000005740-55-tps-206-206.svg`). + The 105x26 lockup on the marketing site is the wordmark and is refused for the + usual reason. + + Wired to both `qoder` and `qoder-cn`. One brand on two operators — Global is + BRIGHT ZENITH PRIVATE LIMITED, CN is 通义云启(杭州)信息技术有限公司 with + Alibaba Cloud as co-provider — which is the `meta-model`/`meta-muse` shape, not + a plan split. **Not masked:** an `#F3F3F3` rounded plate carrying a `#0F0D0C` + glyph, 94.5% opaque at 160px. Both inks are neutral, so masking would collapse + plate and glyph into the single filled box the plate problem above records. + As an image it reads on both surfaces. + + Neither terms document prohibits this. Qoder's Terms of Service + (`qoder.com/product-service`, updated 2026-04-29) reserve rights generally in + §4.1 and confine §9 Intellectual Property to a complaints procedure; its only + trademark sentence warrants the user's own marks in User Content. The CN + agreement (`qoder.cn/product-service`, updated 2026-05-20) §五(a) reserves + 商标 rights without restricting third-party use. Silence plus reserved rights + is the same posture under which `meta.svg` shipped. + + The file is 73 KB, the largest here, all of it high-precision path + coordinates. `docs.qoder.com/logo.svg` is the same symbol at 39665 bytes on a + dark `#111113` plate and would be an acceptable swap under the same + docs-subdomain precedent as `together.svg`; the favicon was preferred for its + corroboration. + +### CodeBuddy: mark exists, terms forbid it + +`codebuddy` and `codebuddy-cn` keep the fallback tile by decision, not for lack +of an asset. Tencent publishes a usable 40x40 square symbol — a gradient roundel +at `codebuddy-1328495429.cos.accelerate.myqcloud.com/web/ide/logo.svg`, declared +as the site icon, byte-identical to the one `codebuddy.cn` serves from +`download.codebuddy.cn` — and it would render well at 19px. + +It is not ours to use. §9.3 "Tencent Logo" of the CodeBuddy service agreement, +identical on `codebuddy.ai/document/term` and `codebuddy.cn/document/term`: +"You shall not use Tencent's trademarks service marks, trade names, domain +names, website names or other distinctive brand features of Tencent under any +circumstances… Without the prior written consent of the Tencent, you shall not +display, use, or otherwise dispose of the aforesaid Tencent Logos in any way, +either alone or in combination." §6.2 adds that unauthorized use "may also +violate applicable laws including… trademark laws." "Under any circumstances" +and "other distinctive brand features" reach the CodeBuddy product mark, which +is a Tencent Cloud brand. + +There is no brand-permission page to rely on: `codebuddy.ai/document/brand` +returns 200 but is byte-identical to a route that does not exist, so it is the +SPA catch-all shell; `/press` is a real 404. + +This one needs to stay written down. The wiring test only fires when an asset +named after the provider id is already committed, so an absent mark produces no +signal at all — nothing would stop a later pass from fetching that logo and +committing it. diff --git a/gui/public/provider-icons/packycode.svg b/gui/public/provider-icons/packycode.svg new file mode 100644 index 0000000000..5ee69b8cd9 --- /dev/null +++ b/gui/public/provider-icons/packycode.svg @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29"> + <defs> + <style> + .cls-1 { + fill: currentColor; + stroke: currentColor; + stroke-miterlimit: 10; + } + </style> + </defs> + <g id="_图层_1-2" data-name="图层 1"> + <g> + <path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/> + <path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/> + <path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/> + </g> + </g> +</svg> \ No newline at end of file diff --git a/gui/public/provider-icons/qoder.svg b/gui/public/provider-icons/qoder.svg new file mode 100644 index 0000000000..599212e57a --- /dev/null +++ b/gui/public/provider-icons/qoder.svg @@ -0,0 +1,5 @@ +<svg width="206" height="206" viewBox="0 0 206 206" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="206" height="206" rx="52" fill="#F3F3F3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M104.003 35.8361C104.122 35.8899 104.223 35.938 104.308 35.9805C104.575 36.1139 104.784 36.2211 104.937 36.3023L154.881 62.632L154.9 62.6418C154.992 62.6907 155.084 62.7426 155.173 62.7973C160.753 65.8115 165.083 70.1612 168.163 75.8464C171.221 81.491 172.75 87.925 172.75 95.1484V117.637C172.75 117.795 172.743 117.954 172.728 118.111V158.251C172.728 163.577 170.488 167.682 166.008 170.563C161.529 173.445 156.866 173.781 152.021 171.573L143.479 167.682L143.449 167.699L143.279 167.796C143.276 167.798 143.237 167.821 143.159 167.867C142.593 168.202 142.154 168.454 141.84 168.625C141.789 168.654 141.686 168.709 141.532 168.79C141.479 168.819 141.443 168.837 141.426 168.846L141.174 168.979C140.759 169.203 140.355 169.414 139.961 169.61L139.342 169.911L139.192 169.982C138.849 170.145 138.46 170.326 138.025 170.524L137.252 170.86C136.994 170.973 136.623 171.127 136.137 171.32L136.002 171.374L135.892 171.42C135.835 171.443 135.78 171.466 135.725 171.488C135.49 171.584 135.294 171.661 135.137 171.716C134.872 171.814 134.453 171.961 133.881 172.16L133.879 172.161L133.715 172.219C133.549 172.28 133.366 172.342 133.165 172.407C132.571 172.603 131.9 172.805 131.151 173.012L127.646 173.973C126.996 174.153 126.392 174.306 125.833 174.432L125.437 174.515L125.039 174.596C124.663 174.676 124.363 174.738 124.139 174.782C124.007 174.809 123.855 174.836 123.681 174.864C123.646 174.869 123.625 174.872 123.619 174.874L123.11 174.956C123.106 174.957 123.084 174.961 123.044 174.967C122.729 175.02 122.483 175.059 122.304 175.085C122.178 175.104 121.986 175.129 121.727 175.159L121.331 175.209L120.674 175.285C120.526 175.302 120.362 175.317 120.18 175.33L120.066 175.341L120.015 175.345C119.546 175.384 119.175 175.411 118.903 175.426C118.815 175.433 118.717 175.438 118.608 175.443L118.645 175.439C118.497 175.452 118.347 175.459 118.198 175.459H118.137C117.737 175.478 117.398 175.487 117.121 175.487H116.455C116.418 175.487 116.381 175.487 116.344 175.486C116.298 175.485 116.224 175.484 116.122 175.483C115.843 175.479 115.623 175.473 115.461 175.466C115.335 175.462 115.132 175.452 114.852 175.434L114.824 175.432L114.654 175.426L114.273 175.407C114.212 175.404 114.15 175.399 114.088 175.394L113.388 175.332C112.948 175.29 112.527 175.243 112.124 175.19L111.925 175.166C111.882 175.162 111.839 175.155 111.796 175.149C111.339 175.081 110.875 175.005 110.403 174.921L109.901 174.827L109.871 174.821C109.63 174.777 109.429 174.736 109.27 174.698L109.224 174.687L109.158 174.672L109.037 174.644C108.61 174.549 108.15 174.435 107.656 174.301L107.629 174.294C107.5 174.263 107.358 174.226 107.201 174.181L107.2 174.181C106.608 174.013 106.029 173.831 105.463 173.634C105.307 173.586 105.154 173.53 105.004 173.466L104.884 173.42C104.405 173.241 104.002 173.083 103.675 172.948C103.58 172.909 103.469 172.861 103.342 172.804C103.29 172.781 103.261 172.768 103.255 172.766L103.084 172.692C102.756 172.551 102.39 172.384 101.986 172.19L101.906 172.152L101.891 172.145C101.798 172.103 101.715 172.063 101.64 172.025C101.571 171.99 101.501 171.955 101.431 171.918C101.407 171.906 101.386 171.895 101.369 171.886C101.249 171.835 101.13 171.778 101.012 171.716L101.009 171.715L51.5574 145.645C51.215 145.509 50.8889 145.336 50.5849 145.128C48.367 143.914 46.3274 142.453 44.4659 140.744C44.4647 140.743 44.4635 140.742 44.4623 140.741C44.4584 140.737 44.4546 140.734 44.4507 140.73C44.4362 140.717 44.4214 140.704 44.4064 140.69C43.6029 139.953 42.8113 139.14 42.0317 138.252C42.0244 138.243 42.0171 138.235 42.0097 138.226C42.0082 138.224 42.0067 138.222 42.0051 138.22C41.5797 137.727 41.1892 137.252 40.8338 136.795C40.7387 136.672 40.6464 136.551 40.5567 136.432C35.6857 129.988 33.2502 122.172 33.2502 112.983V90.4946C33.2502 89.8308 33.2644 89.0633 33.2927 88.1921C33.3022 87.9838 33.3174 87.7494 33.3384 87.4888C33.3459 87.3951 33.3511 87.3283 33.3539 87.2882C33.3866 86.7286 33.4259 86.2074 33.4719 85.7247C33.4848 85.5587 33.5136 85.2969 33.5583 84.9392C33.5691 84.8524 33.5769 84.7892 33.5817 84.7498C33.589 84.6881 33.6006 84.5877 33.6166 84.4487C33.672 83.9681 33.7187 83.5958 33.7569 83.3318C33.7999 83.0465 33.8556 82.7028 33.9239 82.3007C33.9303 82.2616 33.9429 82.1836 33.9618 82.0668C34.0386 81.5927 34.1025 81.2235 34.1537 80.9593C34.1539 80.9576 34.1542 80.9559 34.1545 80.9542L34.1507 80.9713C34.1669 80.871 34.1817 80.7851 34.1951 80.7134C34.3125 80.1121 34.4286 79.5636 34.5433 79.0679C34.5554 79.0129 34.5689 78.9553 34.5837 78.895C34.704 78.3592 34.8544 77.7535 35.0349 77.078C35.0683 76.9444 35.1121 76.78 35.1663 76.5847C35.1764 76.5481 35.1839 76.5212 35.1886 76.5042C35.4221 75.6865 35.6047 75.0694 35.7364 74.653C35.7408 74.639 35.7453 74.6249 35.7499 74.6109C35.7573 74.5867 35.7647 74.5627 35.7723 74.539C35.7789 74.517 35.7857 74.495 35.7926 74.473C35.8241 74.3734 35.8643 74.2549 35.9134 74.1174L35.9251 74.085C36.1639 73.3479 36.3895 72.6905 36.6021 72.1127C36.6575 71.9603 36.7417 71.7385 36.8547 71.4475C36.8818 71.3777 36.9019 71.3258 36.9151 71.2917C36.9324 71.247 36.9593 71.177 36.9959 71.0817C37.2171 70.5062 37.3921 70.0651 37.5208 69.7585L37.5168 69.7661C37.5549 69.6676 37.5905 69.5789 37.6236 69.5C37.6858 69.3466 37.7661 69.1613 37.8644 68.944C37.8998 68.8657 37.9232 68.8133 37.9347 68.7869C38.152 68.2878 38.3184 67.9104 38.4338 67.6548C38.5471 67.4055 38.7105 67.0593 38.9238 66.6163C38.9706 66.5208 39.03 66.3991 39.1019 66.2511C39.2648 65.9163 39.392 65.6588 39.4835 65.4786L39.5155 65.4136C39.5653 65.3091 39.6106 65.2167 39.6516 65.1362C39.7258 64.9869 39.8207 64.8074 39.9362 64.5977C39.9744 64.5283 39.9996 64.4822 40.0115 64.4596C40.0477 64.3922 40.0935 64.3066 40.1489 64.2029C40.3258 63.872 40.463 63.6187 40.5605 63.4432C40.7672 63.0697 40.9619 62.726 41.1445 62.4123C41.2987 62.1332 41.489 61.8063 41.7152 61.4316C41.7383 61.3935 41.7619 61.3555 41.786 61.318L41.7824 61.323C41.7846 61.3193 41.7867 61.3156 41.7888 61.3118L41.7824 61.323C41.8335 61.2318 41.8771 61.1561 41.9132 61.0959C42.0161 60.9245 42.159 60.697 42.342 60.4136C42.5227 60.1209 42.7188 59.811 42.9304 59.484C43.0841 59.2469 43.3076 58.9123 43.601 58.4804L43.9154 58.0238L44.2115 57.5939C44.2325 57.5634 44.2537 57.5333 44.2753 57.5034L44.2749 57.5021C44.3548 57.3822 44.4252 57.2801 44.4861 57.1958C44.5458 57.1108 44.6445 56.9765 44.7821 56.7929C44.8446 56.7095 44.8877 56.6514 44.9115 56.6186C45.2257 56.1886 45.4678 55.8625 45.6377 55.6403L45.9946 55.1837L46.3262 54.7597C46.5387 54.4816 46.7902 54.1653 47.0808 53.811C47.0969 53.7914 47.1242 53.7575 47.1628 53.7094C47.2305 53.6246 47.2851 53.5571 47.3267 53.5067C47.4115 53.4034 47.53 53.264 47.6821 53.0885C47.7338 53.0289 47.771 52.9858 47.7938 52.9593C47.8766 52.8642 47.9852 52.7384 48.1197 52.582C48.3585 52.3041 48.544 52.0909 48.6764 51.9423L49.3723 51.1748C49.5804 50.9454 49.8853 50.6178 50.2869 50.1921C50.3199 50.1572 50.3534 50.1227 50.3874 50.0888L50.3985 50.0741C50.5274 49.9324 50.6395 49.8139 50.7349 49.7185C50.7855 49.6679 50.8533 49.6012 50.9383 49.5185C50.99 49.4683 51.0247 49.4343 51.0424 49.4165C51.4915 48.9675 51.9017 48.5656 52.2729 48.2107C52.3283 48.1583 52.4007 48.0891 52.49 48.0029C52.6771 47.8224 52.8271 47.6806 52.9399 47.5775C53.3761 47.1664 53.9052 46.6923 54.5271 46.155C54.5279 46.1543 54.5287 46.1536 54.5295 46.1528L54.5189 46.161C54.617 46.0697 54.7025 45.9924 54.7754 45.9292C54.8391 45.8741 54.904 45.8206 54.9705 45.7688C54.997 45.7476 55.0236 45.7269 55.0503 45.7065L55.0774 45.6851L55.0939 45.6717C55.5288 45.3081 55.9911 44.9311 56.4808 44.5407C56.5166 44.5125 56.5529 44.4839 56.5898 44.4548C56.7923 44.2954 56.9507 44.1724 57.0648 44.0858C57.2979 43.9051 57.6655 43.6275 58.1675 43.253C58.2075 43.2232 58.2479 43.1939 58.2888 43.1654C58.3729 43.1065 58.5143 43.0042 58.7128 42.8586C59.1025 42.577 59.5704 42.2518 60.1163 41.883L60.1145 41.8841L60.1593 41.8524C60.2852 41.7638 60.3942 41.6892 60.4862 41.6287C60.5329 41.5967 60.5792 41.5661 60.6251 41.5367C61.2865 41.1057 61.8541 40.7475 62.3278 40.4623C62.4222 40.4035 62.5743 40.3142 62.7839 40.1944C62.7838 40.1941 62.8182 40.1736 62.8873 40.1328C63.453 39.7986 63.8928 39.5462 64.2065 39.3754L64.8387 39.0383L64.872 39.0207C65.2867 38.7973 65.6911 38.5871 66.0852 38.39C66.2669 38.2991 66.5247 38.1744 66.8586 38.0158C67.1979 37.8542 67.5857 37.6746 68.0218 37.477L68.4373 37.2954L68.7948 37.1398C69.0934 37.0111 69.5097 36.84 70.0435 36.6265C70.1097 36.6005 70.1844 36.5712 70.2678 36.5384C70.5114 36.4427 70.6998 36.3699 70.833 36.3199C71.1527 36.2027 71.5991 36.0447 72.1722 35.8457C72.2132 35.8316 72.2719 35.8109 72.3482 35.7837C72.574 35.7031 72.7593 35.64 72.904 35.5945C73.5201 35.3928 74.1846 35.1929 74.8975 34.9948L78.4057 34.0333C79.0348 33.8574 79.6373 33.7044 80.2131 33.5744C80.378 33.5359 80.5868 33.4913 80.8395 33.4407C81.1612 33.3685 81.5282 33.2925 81.9406 33.2127C82.0871 33.1842 82.2808 33.151 82.5216 33.1131C82.5981 33.101 82.6517 33.0924 82.6826 33.0873C82.7524 33.0758 82.852 33.0591 82.9815 33.0372C83.2985 32.9834 83.5459 32.9435 83.7238 32.9177C83.7635 32.9119 83.8033 32.9066 83.8432 32.9018L84.1737 32.8619L84.504 32.822C84.5516 32.8162 84.6345 32.8056 84.7525 32.7903C85.0348 32.7538 85.2559 32.7271 85.4158 32.7105C85.5254 32.6991 85.6353 32.6913 85.7453 32.6873L86.07 32.6632C86.533 32.6238 86.9091 32.5967 87.1982 32.582C87.3378 32.5734 87.5561 32.5646 87.8533 32.5556L87.8692 32.5551C87.9598 32.5532 88.0717 32.5499 88.205 32.5453C88.4606 32.5364 88.6706 32.5314 88.835 32.5303C88.9748 32.5275 89.1472 32.5271 89.3523 32.529L89.537 32.5302C89.6342 32.5318 89.7388 32.5334 89.851 32.5349C90.1448 32.5389 90.3757 32.5444 90.5436 32.5514C90.6803 32.5549 90.9082 32.5655 91.2273 32.5832C91.2891 32.5871 91.3656 32.5912 91.4568 32.5957C91.6011 32.6026 91.7158 32.6089 91.8012 32.6144C91.9206 32.6204 92.0953 32.6356 92.3254 32.6598L92.6313 32.6871C93.0373 32.7257 93.4523 32.7724 93.8761 32.8271L93.8896 32.8288L93.8913 32.829C94.0015 32.8408 94.0983 32.8527 94.1819 32.8646C94.6093 32.9223 95.085 32.9984 95.6089 33.093C95.6739 33.1047 95.7386 33.1178 95.8031 33.1321L95.8062 33.1318C95.8842 33.1466 95.9548 33.1606 96.0179 33.1737C96.0408 33.1779 96.0713 33.1835 96.1093 33.1905C96.3333 33.2314 96.5177 33.2683 96.6627 33.301C96.7395 33.3173 96.8375 33.3418 96.9566 33.3744C96.9806 33.3797 97.0046 33.385 97.0288 33.3903C97.2476 33.4387 97.4749 33.4924 97.7107 33.5517C97.9063 33.6006 98.1067 33.6529 98.3119 33.7085L98.3072 33.7071L98.3401 33.7149C98.4501 33.742 98.5513 33.7682 98.6438 33.7934C98.6594 33.7976 98.6743 33.8018 98.6886 33.8058C98.7485 33.8224 98.8045 33.8387 98.8566 33.8545C98.8608 33.8557 98.865 33.8569 98.8692 33.8581C99.3732 34.0019 99.9241 34.1774 100.522 34.3846C100.548 34.3932 100.574 34.4019 100.6 34.4109C100.733 34.4556 100.881 34.5109 101.044 34.5769C101.508 34.7451 101.931 34.9089 102.311 35.0684C102.368 35.0928 102.417 35.1139 102.459 35.1317C102.655 35.2169 102.808 35.2839 102.918 35.3328C103.261 35.4856 103.61 35.6469 103.963 35.8169L104.003 35.8361ZM101.032 34.5728L101.03 34.5721C101.033 34.573 101.035 34.5739 101.038 34.5748C101.036 34.5741 101.034 34.5735 101.032 34.5728ZM104.903 162.561L105.666 162.963C105.717 162.991 105.807 163.037 105.935 163.102C105.972 163.121 106.002 163.136 106.025 163.148C106.092 163.178 106.131 163.197 106.145 163.205L106.281 163.269C106.554 163.401 106.811 163.519 107.052 163.622L107.063 163.627C107.148 163.663 107.253 163.708 107.38 163.765C107.425 163.785 107.449 163.796 107.452 163.797C107.68 163.891 107.998 164.015 108.408 164.169L108.458 164.189C108.52 164.211 108.58 164.234 108.641 164.257L108.655 164.263C109.054 164.403 109.476 164.536 109.919 164.661C109.914 164.66 109.885 164.654 109.835 164.643C109.904 164.659 109.973 164.675 110.041 164.693L110.249 164.747C110.596 164.84 110.948 164.927 111.306 165.008L111.379 165.025L111.451 165.041L111.564 165.067L111.672 165.086L111.703 165.092L112.139 165.174C112.496 165.238 112.847 165.295 113.191 165.347L113.391 165.372C113.685 165.41 113.977 165.443 114.269 165.47L114.88 165.525L115.066 165.535L115.442 165.551L115.47 165.553C115.644 165.565 115.787 165.572 115.899 165.576C115.964 165.579 116.087 165.581 116.267 165.584C116.363 165.585 116.443 165.586 116.506 165.587H117.121C117.26 165.587 117.474 165.58 117.764 165.566C117.848 165.561 117.932 165.559 118.015 165.559H118.037C118.081 165.557 118.126 165.555 118.174 165.552L118.156 165.555C118.215 165.55 118.274 165.546 118.334 165.542C118.501 165.534 118.779 165.514 119.169 165.481L119.325 165.467C119.376 165.462 119.427 165.458 119.479 165.455C119.506 165.453 119.53 165.451 119.549 165.449L120.088 165.388L120.592 165.324C120.728 165.308 120.832 165.295 120.904 165.284C121.005 165.27 121.174 165.243 121.411 165.203C121.454 165.196 121.491 165.19 121.522 165.185L121.924 165.119C121.976 165.11 122.042 165.1 122.123 165.087L122.114 165.091C122.152 165.082 122.191 165.074 122.23 165.067C122.398 165.035 122.677 164.977 123.068 164.895L123.398 164.828L123.653 164.775C124.058 164.683 124.517 164.567 125.03 164.425L128.507 163.472C129.101 163.307 129.652 163.141 130.162 162.973C130.219 162.955 130.253 162.943 130.266 162.939C130.308 162.922 130.35 162.907 130.393 162.892L130.632 162.808H130.634C131.154 162.628 131.551 162.487 131.825 162.387C131.834 162.383 131.882 162.364 131.971 162.328C132.052 162.295 132.097 162.276 132.106 162.273L132.326 162.182C132.367 162.166 132.414 162.147 132.467 162.126C132.853 161.972 133.13 161.858 133.299 161.784L133.938 161.506C134.318 161.334 134.65 161.181 134.935 161.045L135.024 161.003L135.533 160.756C135.838 160.604 136.178 160.426 136.553 160.224L136.711 160.141C136.766 160.112 136.839 160.073 136.929 160.025C136.993 159.992 137.052 159.961 137.107 159.93C137.318 159.814 137.658 159.619 138.125 159.343C138.192 159.304 138.261 159.263 138.332 159.221L138.46 159.147H138.461C138.544 159.098 138.619 159.055 138.687 159.017C139.131 158.749 139.551 158.484 139.947 158.224L140.305 157.979C141.736 157.002 143.572 156.845 145.148 157.563L156.125 162.564C157.693 163.279 159.202 163.17 160.652 162.237C162.103 161.304 162.828 159.976 162.828 158.251V117.614C162.828 117.456 162.835 117.298 162.85 117.14V95.1484C162.85 83.7403 158.651 75.8219 150.254 71.3934L149.959 71.2374C149.909 71.2132 149.855 71.1867 149.796 71.158C149.721 71.1217 149.663 71.0932 149.62 71.0724C149.384 70.9567 149.129 70.8386 148.853 70.718C148.676 70.6389 148.548 70.5828 148.468 70.5496C148.234 70.4515 147.944 70.3392 147.597 70.2126L147.415 70.1456L147.313 70.1067C146.939 69.9768 146.534 69.8488 146.096 69.7227L145.999 69.6973L145.777 69.6395C145.321 69.5221 144.979 69.4377 144.751 69.3863C144.724 69.3805 144.683 69.3717 144.629 69.3597C144.371 69.3034 144.185 69.2642 144.072 69.242C144.03 69.2339 143.989 69.2253 143.948 69.2162L143.83 69.1927C143.449 69.1241 143.093 69.0673 142.761 69.0223C142.792 69.0267 142.81 69.0291 142.814 69.0296C142.703 69.0177 142.628 69.0088 142.589 69.0029C142.287 68.9641 141.996 68.931 141.716 68.9037L141.71 68.9033L141.383 68.8734C141.233 68.8611 140.993 68.845 140.663 68.8252C140.435 68.8126 140.276 68.805 140.186 68.8025C140.128 68.8008 140.071 68.7982 140.014 68.7947C139.934 68.7897 139.76 68.7873 139.491 68.7873L138.982 68.7863C138.823 68.7894 138.572 68.7974 138.23 68.8105C138.13 68.8157 138.008 68.8205 137.866 68.8249C137.791 68.8271 137.731 68.8296 137.686 68.8322C137.515 68.8408 137.229 68.8619 136.827 68.8954L136.781 68.899C136.729 68.9029 136.661 68.9074 136.577 68.9127L136.442 68.9231C136.355 68.9316 136.204 68.9492 135.988 68.9761C135.849 68.9934 135.758 69.0044 135.717 69.0091C135.625 69.0209 135.503 69.035 135.352 69.0513C135.248 69.0625 135.179 69.0706 135.146 69.0756C135.049 69.09 134.884 69.1175 134.65 69.158C134.503 69.1832 134.384 69.2036 134.291 69.219C134.229 69.2289 134.145 69.2422 134.038 69.2587C133.907 69.2786 133.814 69.2938 133.759 69.3042C133.645 69.3251 133.444 69.3668 133.157 69.4293C133.028 69.4574 132.949 69.4744 132.92 69.4802C132.827 69.5013 132.7 69.5285 132.54 69.5617C132.451 69.5801 132.403 69.5901 132.396 69.5916C131.906 69.7035 131.445 69.8191 131.014 69.9384L127.538 70.8908C126.968 71.049 126.455 71.2026 125.998 71.3517L125.707 71.4525L125.352 71.575C124.951 71.7086 124.6 71.8325 124.3 71.9467C124.181 71.9913 123.979 72.0708 123.693 72.1852C123.284 72.3461 122.971 72.4736 122.752 72.5678L122.104 72.8516C121.973 72.9113 121.833 72.9757 121.684 73.0449V91.2807C121.684 92.0491 121.665 92.8184 121.628 93.5885C121.622 93.7365 121.609 93.9283 121.591 94.1637L121.59 94.1679L121.571 94.4471L121.57 94.4744C121.528 95.1281 121.491 95.6208 121.457 95.9524C121.439 96.1399 121.414 96.3666 121.381 96.6324L121.329 97.0655C121.322 97.1194 121.312 97.2021 121.299 97.3136C121.248 97.7482 121.207 98.0619 121.178 98.255C121.155 98.4224 121.12 98.6496 121.074 98.9367C121.068 98.9704 121.065 98.9883 121.065 98.9905L121.009 99.3453L121.009 99.3467C120.917 99.902 120.844 100.324 120.788 100.612C120.661 101.278 120.528 101.923 120.389 102.547L120.362 102.657C120.224 103.259 120.071 103.869 119.902 104.487L119.761 104.999C119.578 105.652 119.397 106.263 119.219 106.83L119.068 107.297C118.85 107.97 118.629 108.613 118.405 109.225C118.356 109.361 118.29 109.536 118.205 109.751L118.188 109.793L118.192 109.783L118.08 110.078C118.063 110.121 118.039 110.184 118.007 110.266C117.79 110.827 117.625 111.244 117.512 111.517C117.449 111.674 117.358 111.889 117.239 112.163C117.192 112.273 117.165 112.336 117.158 112.351L117.095 112.503C116.916 112.924 116.752 113.292 116.603 113.609C116.52 113.796 116.401 114.053 116.244 114.381C116.192 114.49 116.148 114.584 116.109 114.664C116.087 114.71 116.055 114.778 116.011 114.868C115.823 115.258 115.676 115.559 115.568 115.77C115.376 116.148 115.2 116.486 115.04 116.784L114.726 117.371L114.51 117.768C114.385 117.994 114.197 118.325 113.947 118.761C113.932 118.786 113.907 118.83 113.871 118.893C113.665 119.251 113.496 119.536 113.364 119.749C113.175 120.066 112.975 120.389 112.764 120.718C112.747 120.745 112.719 120.789 112.68 120.851C112.458 121.207 112.294 121.463 112.191 121.62C112.082 121.789 111.924 122.028 111.715 122.339C111.63 122.465 111.557 122.575 111.495 122.666L111.474 122.698L110.894 123.543L110.828 123.635L110.248 124.44C110.006 124.774 109.774 125.086 109.554 125.376C109.374 125.614 109.136 125.921 108.838 126.296C108.792 126.354 108.722 126.444 108.626 126.564C108.415 126.831 108.255 127.032 108.146 127.165C107.99 127.358 107.765 127.627 107.472 127.971L107.432 128.017L107.427 128.022C107.169 128.329 106.885 128.659 106.573 129.01C106.415 129.187 106.185 129.443 105.883 129.777C105.674 130.009 105.372 130.333 104.979 130.75C104.865 130.87 104.702 131.038 104.49 131.254C104.4 131.346 104.327 131.42 104.271 131.477C103.929 131.825 103.523 132.223 103.055 132.67L102.875 132.844L102.673 133.037L102.415 133.284C101.896 133.765 101.374 134.233 100.848 134.686L100.749 134.772L100.669 134.843L100.589 134.915C100.544 134.955 100.499 134.995 100.452 135.032L100.254 135.197C99.956 135.449 99.52 135.806 98.9463 136.268L98.9399 136.273C98.8505 136.346 98.7376 136.435 98.6012 136.539C98.551 136.577 98.5019 136.615 98.4539 136.652L98.3712 136.715C97.9722 137.023 97.5654 137.329 97.1506 137.632C97.1246 137.65 97.0729 137.688 96.9956 137.746C96.8643 137.844 96.7619 137.919 96.6886 137.971C96.2495 138.286 95.8236 138.581 95.4107 138.856L95.0917 139.078C95.0575 139.102 95.0231 139.126 94.9883 139.148C94.3982 139.537 93.7872 139.923 93.1553 140.305L93.1461 140.31C93.3774 150.816 97.2951 158.232 104.899 162.559C104.9 162.559 104.902 162.56 104.903 162.561ZM85.109 152.14C83.8619 148.186 83.2373 143.919 83.2353 139.337V137.521C83.2347 137.489 83.2344 137.458 83.2344 137.426V116.845C83.2344 116.045 83.2534 115.247 83.2913 114.451C83.2988 114.292 83.313 114.083 83.334 113.822C83.3415 113.729 83.3466 113.663 83.3493 113.626C83.3544 113.552 83.3621 113.44 83.3724 113.29C83.4083 112.76 83.4408 112.342 83.4697 112.035C83.4825 111.908 83.5022 111.733 83.5288 111.511C83.5477 111.353 83.5635 111.213 83.5763 111.091C83.5843 111.024 83.5963 110.921 83.6122 110.782C83.6676 110.301 83.7158 109.919 83.757 109.636C83.797 109.37 83.8504 109.041 83.917 108.649C83.9225 108.616 83.931 108.565 83.9424 108.496C84.0263 107.992 84.0935 107.6 84.144 107.32C84.2642 106.675 84.3948 106.043 84.5356 105.422C84.5486 105.362 84.5632 105.3 84.5794 105.234C84.5829 105.219 84.5864 105.203 84.59 105.188C84.7498 104.507 84.9083 103.874 85.0654 103.29C85.0711 103.27 85.0838 103.223 85.1035 103.148C85.1407 103.006 85.1675 102.907 85.1837 102.851C85.3667 102.187 85.5509 101.565 85.7364 100.984C85.7394 100.975 85.7425 100.965 85.7454 100.956C85.7604 100.907 85.7756 100.859 85.7911 100.812C85.8274 100.699 85.8714 100.57 85.9233 100.425C85.9267 100.416 85.9288 100.41 85.9293 100.409C86.1871 99.6224 86.4078 98.9796 86.5913 98.4808C86.651 98.3166 86.7374 98.0889 86.8505 97.7977C86.8777 97.7276 86.8935 97.687 86.8979 97.6759C87.1126 97.1079 87.3121 96.6018 87.4965 96.1574C87.5769 95.96 87.6945 95.6837 87.8495 95.3284C87.8867 95.2432 87.9153 95.1776 87.9353 95.1316C88.1491 94.6407 88.3155 94.2636 88.4344 94.0001C88.5435 93.7602 88.7034 93.4213 88.9141 92.9835C88.9155 92.9806 88.9169 92.9777 88.9182 92.9749C89.099 92.5951 89.2811 92.2247 89.4646 91.8639C89.5557 91.6816 89.689 91.4218 89.8645 91.0844C89.9194 90.9789 89.9656 90.8899 90.0032 90.8175C90.201 90.4424 90.3841 90.1034 90.5524 89.8004C90.7617 89.4223 90.9573 89.0771 91.1389 88.7647L91.4063 88.3047L91.7037 87.7941C91.8473 87.5548 92.0487 87.2295 92.3079 86.8181C92.4094 86.6532 92.5571 86.4188 92.7509 86.1149C92.8189 86.0084 92.8779 85.9158 92.9281 85.8371C93.0803 85.6022 93.2952 85.2806 93.5727 84.8722C93.8298 84.4867 94.0521 84.1637 94.2398 83.9033C94.4559 83.5971 94.6806 83.2865 94.9138 82.9715C95.2236 82.5476 95.4605 82.2284 95.6245 82.014C95.8073 81.7734 96.0476 81.4661 96.3452 81.0921L96.69 80.6582L97.0805 80.168C97.2347 79.9804 97.4631 79.7075 97.7656 79.3492L97.7895 79.3212C97.8343 79.2689 97.9023 79.1892 97.9935 79.0823C98.2842 78.7413 98.506 78.4851 98.6587 78.3137C98.8175 78.1339 99.0523 77.8728 99.3629 77.5304C99.5705 77.3015 99.8791 76.9701 100.289 76.536C100.534 76.2773 100.774 76.0318 101.006 75.7993C101.356 75.4442 101.766 75.0422 102.236 74.5932C102.529 74.3084 102.75 74.0982 102.898 73.9627C103.423 73.4747 103.975 72.9804 104.555 72.4797L104.568 72.4679C104.572 72.4646 104.575 72.4614 104.579 72.4582L104.568 72.4679C104.69 72.3555 104.78 72.2755 104.837 72.2279C104.93 72.1485 105.043 72.0556 105.178 71.9492C105.67 71.5386 106.104 71.1853 106.48 70.8893C106.615 70.7826 106.802 70.6384 107.041 70.4566C107.478 70.1188 107.863 69.8283 108.196 69.5852L108.738 69.1881C109.156 68.886 109.606 68.5732 110.088 68.2498C110.094 68.2454 110.111 68.2342 110.137 68.2159C110.281 68.1154 110.413 68.0272 110.532 67.9513C111.047 67.6097 111.632 67.2385 112.286 66.8376C112.329 66.8116 112.372 66.7861 112.415 66.7614C112.544 66.6878 112.642 66.6306 112.71 66.59C113.351 66.212 113.817 65.9444 114.107 65.7874C114.195 65.7371 114.288 65.6854 114.387 65.6323C114.427 65.6108 114.467 65.5899 114.507 65.5696C114.569 65.5368 114.639 65.5005 114.715 65.4606C114.784 65.425 114.833 65.3993 114.863 65.3835C115.228 65.1868 115.634 64.9766 116.082 64.7529C116.336 64.6259 116.591 64.5012 116.849 64.3787C117.279 64.1741 117.669 63.9926 118.018 63.8342L118.772 63.5029C119.128 63.3494 119.559 63.1731 120.064 62.9741C120.385 62.8458 120.638 62.7468 120.824 62.6771C121.224 62.5247 121.69 62.36 122.221 62.183L122.468 62.0972L122.76 61.9962C123.466 61.7648 124.175 61.5501 124.888 61.352L128.396 60.3905C128.613 60.3306 128.835 60.2715 129.063 60.213L100.3 45.0496C100.214 45.004 100.083 44.937 99.9066 44.8487C99.9047 44.8478 99.9027 44.8469 99.9007 44.846L99.9075 44.8496C99.7964 44.7991 99.6999 44.7535 99.6181 44.7129C99.4811 44.6472 99.3429 44.5824 99.2036 44.5184C99.1007 44.4713 98.9939 44.4231 98.8832 44.3739C98.8044 44.3386 98.6867 44.2871 98.53 44.2193C98.4876 44.201 98.455 44.1869 98.4322 44.177C98.4314 44.1767 98.4306 44.1763 98.4299 44.176C98.3382 44.1381 98.2427 44.0998 98.1433 44.061C97.9965 44.0041 97.8311 43.9427 97.6473 43.8766C97.5717 43.85 97.4776 43.814 97.3649 43.7686C97.3527 43.7645 97.3407 43.7603 97.3286 43.7561C96.8799 43.5998 96.4567 43.4648 96.059 43.3512L95.9727 43.3283C95.8926 43.3086 95.8272 43.2921 95.7763 43.2788C95.3802 43.1715 95.0531 43.0905 94.795 43.036C94.6972 43.0166 94.5792 42.9882 94.4409 42.9508C94.4084 42.9445 94.3714 42.9375 94.33 42.93C94.2346 42.9125 94.1589 42.8984 94.1029 42.8876C94.0584 42.8789 94.014 42.8697 93.9698 42.8599L93.9667 42.8601C93.9009 42.8477 93.8405 42.8359 93.7854 42.8246C93.4377 42.7625 93.1212 42.7121 92.8358 42.6733C92.725 42.6614 92.6275 42.6495 92.5435 42.6375C92.2732 42.6029 91.997 42.5722 91.7147 42.5453L91.3599 42.5131L91.2927 42.5057L91.289 42.5054L91.1859 42.4959C91.1239 42.4922 91.055 42.4886 90.9793 42.4849C90.835 42.478 90.7203 42.4717 90.6349 42.4662C90.4366 42.4553 90.292 42.4486 90.201 42.446C90.0797 42.4413 89.9183 42.4376 89.7169 42.4349C89.5824 42.433 89.4787 42.4313 89.4058 42.4298C89.4064 42.4305 89.3577 42.4304 89.26 42.4294C89.1424 42.4283 89.0705 42.4281 89.0444 42.4289C88.9973 42.4302 88.9501 42.4309 88.903 42.4309C88.8446 42.4309 88.7268 42.434 88.5495 42.4401C88.3825 42.4459 88.248 42.4499 88.1461 42.4522C87.9529 42.458 87.8209 42.4631 87.7499 42.4674C87.5399 42.4782 87.2415 42.5 86.8546 42.5328L86.5715 42.554L86.3409 42.5711C86.3298 42.572 86.3187 42.5728 86.3077 42.5735C86.2335 42.5825 86.1393 42.5944 86.0254 42.6091C85.8845 42.6274 85.7759 42.6412 85.6996 42.6505L85.3608 42.6915L85.0984 42.7231C84.9951 42.7388 84.8419 42.764 84.6388 42.7985C84.4899 42.8237 84.3759 42.8429 84.2967 42.8558C84.246 42.8643 84.1678 42.8768 84.0619 42.8935C83.9382 42.913 83.8599 42.9259 83.827 42.9323C83.5008 42.9954 83.1868 43.0605 82.885 43.1276C82.6666 43.172 82.5146 43.2042 82.429 43.2242C81.993 43.3228 81.5326 43.4398 81.0478 43.5754L77.5317 44.539C76.9734 44.6941 76.4388 44.8552 75.928 45.0223C75.8676 45.0416 75.7842 45.0701 75.6778 45.1081C75.5623 45.1493 75.4704 45.1817 75.4021 45.2051C74.8937 45.3817 74.5177 45.5145 74.2741 45.6037C74.2132 45.6267 74.085 45.6766 73.8895 45.7534C73.7991 45.7889 73.7301 45.816 73.6823 45.8346C73.2548 46.0058 72.9375 46.1359 72.7303 46.2251L72.3994 46.3693L72.0419 46.5249C71.7073 46.677 71.3972 46.821 71.1115 46.957L71.1072 46.9591C70.8335 47.0891 70.6356 47.1846 70.5135 47.2457C70.2089 47.398 69.8811 47.5686 69.5302 47.7576L69.4965 47.7756L68.9012 48.0927C68.7153 48.1941 68.3892 48.3824 67.9232 48.6577C67.8482 48.702 67.7923 48.735 67.7555 48.7567C67.6073 48.8417 67.518 48.8937 67.4876 48.9125C67.0806 49.1579 66.5958 49.4639 66.0332 49.8305C65.9931 49.8578 65.9533 49.8842 65.9139 49.9097L65.8575 49.9496C65.7862 49.9998 65.7282 50.0401 65.6836 50.0706C65.2073 50.3925 64.8258 50.657 64.5392 50.864C64.3368 51.0125 64.1686 51.1342 64.0344 51.2291C63.607 51.5482 63.2914 51.7866 63.0876 51.9444C62.9946 52.0152 62.8699 52.1121 62.7135 52.2352C62.6748 52.2656 62.6456 52.2887 62.6258 52.3042C62.2109 52.635 61.8299 52.9455 61.4827 53.2357C61.4226 53.2875 61.3346 53.3593 61.2187 53.4512C61.1402 53.5238 61.0704 53.5865 61.0095 53.6393C60.4881 54.0895 60.0428 54.4885 59.6734 54.8362C59.591 54.912 59.488 55.0095 59.3645 55.1286C59.248 55.241 59.1565 55.3286 59.0899 55.3914C58.7889 55.6792 58.4401 56.0213 58.0437 56.4178C57.9931 56.4684 57.9253 56.535 57.8403 56.6177C57.7886 56.6679 57.7539 56.7019 57.7362 56.7197L57.7249 56.7344C57.6255 56.8437 57.536 56.9393 57.4565 57.0211C57.1154 57.3829 56.865 57.6517 56.7055 57.8275L56.0398 58.5614C55.9662 58.6442 55.8292 58.8023 55.6287 59.0355C55.4778 59.2111 55.3624 59.3447 55.2825 59.4363C55.2667 59.4549 55.227 59.5009 55.1632 59.5745C55.0686 59.6836 55.0053 59.7575 54.9736 59.7962C54.9593 59.8136 54.9328 59.8464 54.8943 59.8946C54.8265 59.9794 54.7719 60.047 54.7303 60.0973C54.5152 60.3596 54.3244 60.5997 54.1578 60.8176L53.8013 61.2738L53.4693 61.6981C53.3654 61.8344 53.1797 62.0856 52.9122 62.4515C52.8654 62.5159 52.7968 62.6084 52.7064 62.7292C52.6248 62.8381 52.5706 62.9115 52.5439 62.9494L52.5131 62.9944C52.4514 63.087 52.3954 63.1689 52.345 63.2403L52.0696 63.6401L51.7733 64.0703C51.532 64.4257 51.3545 64.691 51.2409 64.8662C51.0597 65.1463 50.8943 65.4079 50.7447 65.6511C50.7251 65.683 50.7052 65.7146 50.6848 65.7459C50.5481 65.9573 50.4543 66.1054 50.4033 66.1903L50.3824 66.227C50.3045 66.3638 50.2323 66.4853 50.1657 66.5917C50.0113 66.8483 49.8731 67.0865 49.751 67.3064C49.5715 67.6161 49.3943 67.9291 49.2192 68.2454C49.1453 68.3783 49.0325 68.5866 48.881 68.8703C48.8223 68.98 48.7781 69.0627 48.7481 69.1185C48.72 69.1716 48.6738 69.2567 48.6094 69.3736C48.5585 69.4661 48.5308 69.5168 48.5265 69.5259C48.5046 69.571 48.482 69.6157 48.4588 69.66L48.4547 69.6708C48.3897 69.8073 48.3322 69.9233 48.2822 70.0189C48.2355 70.1113 48.1433 70.299 48.0058 70.5818C47.9333 70.7308 47.8767 70.8467 47.8361 70.9296C47.6588 71.2978 47.5309 71.5684 47.4524 71.7412C47.3585 71.9492 47.2134 72.2784 47.0172 72.7288C46.9875 72.7976 46.9436 72.8962 46.8857 73.0244C46.8378 73.1302 46.8106 73.1914 46.8041 73.2079C46.7849 73.2566 46.7649 73.305 46.7441 73.353C46.7433 73.3551 46.7425 73.3573 46.7417 73.3594L46.75 73.3409C46.7166 73.4274 46.6876 73.5004 46.6631 73.5599C46.5646 73.7947 46.4228 74.1529 46.2376 74.6347C46.1998 74.7329 46.1713 74.807 46.152 74.8571C46.1361 74.8984 46.1135 74.9566 46.0843 75.0318C45.9971 75.2565 45.9358 75.4173 45.9005 75.5143C45.7283 75.9826 45.5384 76.5369 45.3309 77.1772C45.3081 77.2459 45.2843 77.3155 45.2595 77.386C45.2346 77.4698 45.2075 77.5532 45.178 77.636C45.1726 77.6528 45.1672 77.6694 45.1617 77.6859C45.0586 78.0149 44.9117 78.5129 44.7212 79.1799L44.7065 79.2334C44.6722 79.3569 44.6508 79.4367 44.6421 79.4728C44.6323 79.5129 44.6222 79.553 44.6115 79.5928C44.4631 80.1463 44.3425 80.6288 44.2497 81.0401C44.2363 81.1019 44.2212 81.1671 44.2043 81.2356C44.2015 81.2478 44.1987 81.2602 44.1958 81.2724C44.1057 81.6601 44.0145 82.0896 43.9222 82.5611C43.9219 82.5631 43.9215 82.5651 43.9212 82.5671L43.9253 82.5485C43.9091 82.6488 43.8943 82.7348 43.8809 82.8064C43.8451 82.9907 43.7966 83.2719 43.7355 83.6497C43.7153 83.7743 43.6999 83.8688 43.6893 83.9332C43.6284 84.2926 43.5824 84.5751 43.5513 84.7805C43.5298 84.9296 43.4969 85.1967 43.4525 85.5819C43.4353 85.7314 43.4221 85.8449 43.4129 85.9225C43.4057 85.9831 43.3956 86.0649 43.3828 86.168C43.3563 86.3799 43.3402 86.5204 43.3347 86.5896C43.2957 87.0031 43.2621 87.4507 43.2337 87.9323C43.2261 88.0425 43.2174 88.1588 43.2075 88.2812C43.1952 88.435 43.1878 88.5377 43.1854 88.5891C43.1625 89.3034 43.1511 89.9389 43.1511 90.4954V112.984C43.1511 119.71 44.7969 125.37 48.0885 129.964C48.2105 130.135 48.3347 130.304 48.4612 130.471C48.7612 130.87 49.1067 131.296 49.4976 131.749C50.0389 132.365 50.5851 132.925 51.1362 133.429C51.1394 133.432 51.1427 133.435 51.146 133.438C51.159 133.45 51.1727 133.462 51.1869 133.475C52.1229 134.329 53.1204 135.091 54.1794 135.76C54.6482 136.057 55.1293 136.336 55.6229 136.597C55.6387 136.605 55.6545 136.613 55.6701 136.622L55.7005 136.637C55.7175 136.646 55.7346 136.655 55.7515 136.664L85.109 152.14ZM93.1353 128.236C93.4493 127.981 93.6922 127.78 93.8641 127.635L94.0621 127.471L94.0756 127.459L94.1552 127.388L94.3292 127.235C94.7991 126.83 95.2489 126.428 95.6787 126.029L95.8253 125.887L96.0277 125.694L96.1784 125.548C96.5995 125.146 96.9459 124.807 97.2176 124.531C97.2581 124.489 97.3285 124.417 97.4289 124.315C97.6004 124.141 97.7231 124.014 97.7971 123.936C98.1301 123.583 98.3794 123.315 98.5452 123.132C98.8197 122.829 99.0227 122.603 99.1543 122.453C99.428 122.145 99.6664 121.87 99.8696 121.627C99.8932 121.599 99.9173 121.571 99.9416 121.543L99.961 121.521C100.189 121.254 100.352 121.059 100.451 120.936C100.54 120.827 100.676 120.657 100.86 120.424C100.962 120.296 101.039 120.198 101.092 120.131C101.332 119.828 101.519 119.588 101.653 119.41C101.842 119.162 102.036 118.901 102.235 118.626L102.797 117.846L102.819 117.815L103.309 117.099L103.311 117.096C103.344 117.047 103.406 116.954 103.499 116.816C103.668 116.564 103.788 116.384 103.858 116.274C103.965 116.111 104.109 115.885 104.29 115.597C104.328 115.536 104.357 115.49 104.378 115.456C104.573 115.153 104.739 114.885 104.877 114.652C104.898 114.616 104.92 114.58 104.942 114.545C105.011 114.435 105.127 114.238 105.289 113.956C105.325 113.894 105.353 113.846 105.371 113.813C105.591 113.43 105.753 113.146 105.856 112.96L106.027 112.645L106.306 112.122C106.44 111.873 106.587 111.591 106.746 111.278C106.819 111.134 106.935 110.898 107.092 110.573C107.137 110.478 107.172 110.405 107.196 110.356C107.213 110.32 107.251 110.241 107.309 110.118C107.427 109.87 107.51 109.694 107.557 109.589C107.708 109.262 107.852 108.94 107.988 108.622L108.004 108.58C108.048 108.477 108.098 108.361 108.153 108.233C108.24 108.031 108.301 107.889 108.334 107.807C108.436 107.558 108.583 107.187 108.773 106.695C108.806 106.611 108.831 106.547 108.849 106.501L108.93 106.284C108.956 106.217 108.98 106.157 109 106.106C109.044 105.996 109.071 105.926 109.082 105.896C109.28 105.353 109.469 104.803 109.649 104.247L109.805 103.768C109.931 103.364 110.073 102.88 110.231 102.318L110.353 101.873C110.486 101.389 110.605 100.913 110.713 100.445L110.766 100.224C110.856 99.8165 110.954 99.3298 111.062 98.7642C111.104 98.5444 111.164 98.2039 111.239 97.7427L111.24 97.7413L111.286 97.4467C111.291 97.4136 111.297 97.3803 111.302 97.3468C111.335 97.1476 111.356 97.0084 111.367 96.9292C111.395 96.7383 111.429 96.4795 111.468 96.1528C111.482 96.0307 111.493 95.9379 111.501 95.8744L111.551 95.4529C111.576 95.2556 111.592 95.114 111.6 95.0279C111.626 94.7717 111.656 94.3788 111.689 93.8493L111.691 93.8218L111.715 93.4636C111.719 93.4193 111.721 93.3886 111.722 93.3716C111.731 93.2627 111.736 93.1977 111.737 93.1766C111.768 92.5227 111.784 91.8907 111.784 91.2807V79.3334C111.683 79.4164 111.58 79.5026 111.473 79.592C111.434 79.6239 111.396 79.6552 111.357 79.6859L111.24 79.7802C111.239 79.7812 111.238 79.7822 111.237 79.7833L111.249 79.7743C111.126 79.8862 111.041 79.9618 110.993 80.0012C110.538 80.3944 110.077 80.8076 109.611 81.2411C109.514 81.3295 109.345 81.4906 109.104 81.7244C108.688 82.1222 108.331 82.4721 108.034 82.7742C107.843 82.9645 107.658 83.1541 107.479 83.3429C107.121 83.7221 106.86 84.0021 106.696 84.1831C106.414 84.4937 106.204 84.7277 106.065 84.8851C105.951 85.0125 105.772 85.2195 105.528 85.5059C105.432 85.6192 105.366 85.6963 105.331 85.7372C105.056 86.0624 104.873 86.2807 104.78 86.3921L104.435 86.827L104.094 87.2552C103.841 87.5734 103.643 87.8273 103.498 88.0169C103.372 88.1822 103.169 88.4556 102.89 88.8374C102.689 89.1089 102.493 89.3801 102.302 89.651C102.166 89.8402 101.994 90.0899 101.787 90.4002C101.534 90.7732 101.359 91.034 101.262 91.1826C101.225 91.2418 101.17 91.3279 101.098 91.4409C100.933 91.6994 100.805 91.9027 100.713 92.0507C100.474 92.4308 100.313 92.6903 100.229 92.8291L99.9616 93.2888L99.698 93.7424C99.551 93.995 99.3887 94.2817 99.2111 94.6025C99.0755 94.8467 98.9312 95.1137 98.7783 95.4034C98.7492 95.4597 98.7058 95.5433 98.6481 95.6541C98.497 95.9446 98.3829 96.1669 98.3057 96.321C98.1488 96.6297 97.9937 96.9454 97.8402 97.268L97.8392 97.2703C97.658 97.6465 97.5293 97.9186 97.453 98.0866C97.3556 98.3021 97.2097 98.633 97.0154 99.0794C96.9949 99.1266 96.9646 99.1963 96.9244 99.2882C96.8019 99.5691 96.7118 99.7803 96.654 99.9218C96.5019 100.289 96.3327 100.718 96.1463 101.211C96.1315 101.25 96.1094 101.306 96.0801 101.382C95.9928 101.607 95.9293 101.774 95.8897 101.882C95.7393 102.291 95.5393 102.876 95.2896 103.637C95.2779 103.671 95.262 103.715 95.242 103.771L95.2393 103.772C95.2279 103.809 95.2161 103.847 95.204 103.884C95.1874 103.939 95.1706 103.991 95.1534 104.043C95.0129 104.485 94.8672 104.979 94.7164 105.526C94.7107 105.546 94.698 105.593 94.6784 105.668C94.6411 105.81 94.6173 105.898 94.607 105.932C94.4938 106.355 94.3708 106.848 94.2382 107.411C94.2266 107.464 94.2138 107.518 94.1998 107.575L94.1965 107.589C94.082 108.092 93.9774 108.598 93.8825 109.107C93.8433 109.324 93.7855 109.662 93.7091 110.122C93.6976 110.191 93.6881 110.247 93.6806 110.293C93.6225 110.634 93.5794 110.898 93.5513 111.084C93.5269 111.253 93.4925 111.53 93.4482 111.915C93.4309 112.065 93.421 112.149 93.4184 112.166C93.4056 112.294 93.3859 112.469 93.3592 112.691C93.3403 112.848 93.3287 112.949 93.3243 112.992C93.3052 113.194 93.2806 113.518 93.2505 113.962C93.2397 114.12 93.2313 114.243 93.2252 114.328C93.2203 114.397 93.213 114.492 93.2032 114.615C93.1908 114.769 93.1834 114.871 93.181 114.923C93.1505 115.562 93.1353 116.203 93.1353 116.846V128.236Z" fill="#0F0D0C"/> +<path d="M41.7824 61.323L41.786 61.318C41.7619 61.3555 41.7383 61.3935 41.7152 61.4316C41.489 61.8063 41.2987 62.1332 41.1445 62.4123C40.9619 62.726 40.7672 63.0697 40.5605 63.4432C40.463 63.6187 40.3258 63.872 40.1489 64.2029C40.0935 64.3066 40.0477 64.3922 40.0115 64.4596C39.9996 64.4822 39.9744 64.5283 39.9362 64.5977C39.8207 64.8074 39.7258 64.9869 39.6516 65.1362C39.6106 65.2167 39.5653 65.3091 39.5155 65.4136L39.4835 65.4786C39.392 65.6588 39.2648 65.9163 39.1019 66.2511C39.03 66.3991 38.9706 66.5208 38.9238 66.6163C38.7105 67.0593 38.5471 67.4055 38.4338 67.6548C38.3184 67.9104 38.152 68.2878 37.9347 68.7869C37.9232 68.8133 37.8998 68.8657 37.8644 68.944C37.7661 69.1613 37.6858 69.3466 37.6236 69.5C37.5905 69.5789 37.5549 69.6676 37.5168 69.7661L37.5208 69.7585C37.3921 70.0651 37.2171 70.5062 36.9959 71.0817C36.9593 71.177 36.9324 71.247 36.9151 71.2917C36.9019 71.3258 36.8818 71.3777 36.8547 71.4475C36.7417 71.7385 36.6575 71.9603 36.6021 72.1127C36.3895 72.6905 36.1639 73.3479 35.9251 74.085L35.9134 74.1174C35.8643 74.2549 35.8241 74.3734 35.7926 74.473C35.7857 74.495 35.7789 74.517 35.7723 74.539C35.7647 74.5627 35.7573 74.5867 35.7499 74.6109C35.7453 74.6249 35.7408 74.639 35.7364 74.653C35.6047 75.0694 35.4221 75.6865 35.1886 76.5042C35.1839 76.5212 35.1764 76.5481 35.1663 76.5847C35.1121 76.78 35.0683 76.9444 35.0349 77.078C34.8544 77.7535 34.704 78.3592 34.5837 78.895C34.5689 78.9553 34.5554 79.0129 34.5433 79.0679C34.4286 79.5636 34.3125 80.1121 34.1951 80.7134C34.1817 80.7851 34.1669 80.871 34.1507 80.9713L34.1545 80.9542C34.1542 80.9559 34.1539 80.9576 34.1537 80.9593C34.1025 81.2235 34.0386 81.5927 33.9618 82.0668C33.9429 82.1836 33.9303 82.2616 33.9239 82.3007C33.8556 82.7028 33.7999 83.0465 33.7569 83.3318C33.7187 83.5958 33.672 83.9681 33.6166 84.4487C33.6006 84.5877 33.589 84.6881 33.5817 84.7498C33.5769 84.7892 33.5691 84.8524 33.5583 84.9392C33.5136 85.2969 33.4848 85.5587 33.4719 85.7247C33.4259 86.2074 33.3866 86.7286 33.3539 87.2882C33.3511 87.3283 33.3459 87.3951 33.3384 87.4888C33.3174 87.7494 33.3022 87.9838 33.2927 88.1921C33.2644 89.0633 33.2502 89.8308 33.2502 90.4946V112.983C33.2502 122.172 35.6857 129.988 40.5567 136.432C40.6464 136.551 40.7387 136.672 40.8338 136.795C41.1892 137.252 41.5797 137.727 42.0051 138.22C42.0067 138.222 42.0082 138.224 42.0097 138.226C42.0171 138.235 42.0244 138.243 42.0317 138.252C42.8113 139.14 43.6029 139.953 44.4064 140.69C44.4214 140.704 44.4362 140.717 44.4507 140.73C44.4546 140.734 44.4584 140.737 44.4623 140.741C44.4635 140.742 44.4647 140.743 44.4659 140.744C46.3274 142.453 48.367 143.914 50.5849 145.128C50.8889 145.336 51.215 145.509 51.5574 145.645L101.009 171.715L101.012 171.716C101.13 171.778 101.249 171.835 101.369 171.886C101.386 171.895 101.407 171.906 101.431 171.918C101.501 171.955 101.571 171.99 101.64 172.025C101.715 172.063 101.798 172.103 101.891 172.145L101.906 172.152L101.986 172.19C102.39 172.384 102.756 172.551 103.084 172.692L103.255 172.766C103.261 172.768 103.29 172.781 103.342 172.804C103.469 172.861 103.58 172.909 103.675 172.948C104.002 173.083 104.405 173.241 104.884 173.42L105.004 173.466C105.154 173.53 105.307 173.586 105.463 173.634C106.029 173.831 106.608 174.013 107.2 174.181L107.201 174.181C107.358 174.226 107.5 174.263 107.629 174.294L107.656 174.301C108.15 174.435 108.61 174.549 109.037 174.644L109.158 174.672L109.224 174.687L109.27 174.698C109.429 174.736 109.63 174.777 109.871 174.821L109.901 174.827L110.403 174.921C110.875 175.005 111.339 175.081 111.796 175.149C111.839 175.155 111.882 175.162 111.925 175.166L112.124 175.19C112.527 175.243 112.948 175.29 113.388 175.332L114.088 175.394C114.15 175.399 114.212 175.404 114.273 175.407L114.654 175.426L114.824 175.432L114.852 175.434C115.132 175.452 115.335 175.462 115.461 175.466C115.623 175.473 115.843 175.479 116.122 175.483C116.224 175.484 116.298 175.485 116.344 175.486C116.381 175.487 116.418 175.487 116.455 175.487H117.121C117.398 175.487 117.737 175.478 118.137 175.459H118.198C118.347 175.459 118.497 175.452 118.645 175.439L118.608 175.443C118.717 175.438 118.815 175.433 118.903 175.426C119.175 175.411 119.546 175.384 120.015 175.345L120.066 175.341L120.18 175.33C120.362 175.317 120.526 175.302 120.674 175.285L121.331 175.209L121.727 175.159C121.986 175.129 122.178 175.104 122.304 175.085C122.483 175.059 122.729 175.02 123.044 174.967C123.084 174.961 123.106 174.957 123.11 174.956L123.619 174.874C123.625 174.872 123.646 174.869 123.681 174.864C123.855 174.836 124.007 174.809 124.139 174.782C124.363 174.738 124.663 174.676 125.039 174.596L125.437 174.515L125.833 174.432C126.392 174.306 126.996 174.153 127.646 173.973L131.151 173.012C131.9 172.805 132.571 172.603 133.165 172.407C133.366 172.342 133.549 172.28 133.715 172.219L133.879 172.161L133.881 172.16C134.453 171.961 134.872 171.814 135.137 171.716C135.294 171.661 135.49 171.584 135.725 171.488C135.78 171.466 135.835 171.443 135.892 171.42L136.002 171.374L136.137 171.32C136.623 171.127 136.994 170.973 137.252 170.86L138.025 170.524C138.46 170.326 138.849 170.145 139.192 169.982L139.342 169.911L139.961 169.61C140.355 169.414 140.759 169.203 141.174 168.979L141.426 168.846C141.443 168.837 141.479 168.819 141.532 168.79C141.686 168.709 141.789 168.654 141.84 168.625C142.154 168.454 142.593 168.202 143.159 167.867C143.237 167.821 143.276 167.798 143.279 167.796L143.449 167.699L143.479 167.682L152.021 171.573C156.866 173.781 161.529 173.445 166.008 170.563C170.488 167.682 172.728 163.577 172.728 158.251V118.111C172.743 117.954 172.75 117.795 172.75 117.637V95.1484C172.75 87.925 171.221 81.491 168.163 75.8464C165.083 70.1612 160.753 65.8115 155.173 62.7973C155.084 62.7426 154.992 62.6907 154.9 62.6418L154.881 62.632L104.937 36.3023C104.784 36.2211 104.575 36.1139 104.308 35.9805C104.223 35.938 104.122 35.8899 104.003 35.8361L103.963 35.8169C103.61 35.6469 103.261 35.4856 102.918 35.3328C102.808 35.2839 102.655 35.2169 102.459 35.1317C102.417 35.1139 102.368 35.0928 102.311 35.0684C101.931 34.9089 101.508 34.7451 101.044 34.5769C100.881 34.5109 100.733 34.4556 100.6 34.4109C100.574 34.4019 100.548 34.3932 100.522 34.3846C99.9241 34.1774 99.3732 34.0019 98.8692 33.8581C98.865 33.8569 98.8608 33.8557 98.8566 33.8545C98.8045 33.8387 98.7485 33.8224 98.6886 33.8058C98.6743 33.8018 98.6594 33.7976 98.6438 33.7934C98.5513 33.7682 98.4501 33.742 98.3401 33.7149L98.3072 33.7071L98.3119 33.7085C98.1067 33.6529 97.9063 33.6006 97.7107 33.5517C97.4749 33.4924 97.2476 33.4387 97.0288 33.3903C97.0046 33.385 96.9806 33.3797 96.9566 33.3744C96.8375 33.3418 96.7395 33.3173 96.6627 33.301C96.5177 33.2683 96.3333 33.2314 96.1093 33.1905C96.0713 33.1835 96.0408 33.1779 96.0179 33.1737C95.9548 33.1606 95.8842 33.1466 95.8062 33.1318L95.8031 33.1321C95.7386 33.1178 95.6739 33.1047 95.6089 33.093C95.085 32.9984 94.6093 32.9223 94.1819 32.8646C94.0983 32.8527 94.0015 32.8408 93.8913 32.829L93.8896 32.8288L93.8761 32.8271C93.4523 32.7724 93.0373 32.7257 92.6313 32.6871L92.3254 32.6598C92.0953 32.6356 91.9206 32.6204 91.8012 32.6144C91.7158 32.6089 91.6011 32.6026 91.4568 32.5957C91.3656 32.5912 91.2891 32.5871 91.2273 32.5832C90.9082 32.5655 90.6803 32.5549 90.5436 32.5514C90.3757 32.5444 90.1448 32.5389 89.851 32.5349C89.7388 32.5334 89.6342 32.5318 89.537 32.5302L89.3523 32.529C89.1472 32.5271 88.9748 32.5275 88.835 32.5303C88.6706 32.5314 88.4606 32.5364 88.205 32.5453C88.0717 32.5499 87.9598 32.5532 87.8692 32.5551L87.8533 32.5556C87.5561 32.5646 87.3378 32.5734 87.1982 32.582C86.9091 32.5967 86.533 32.6238 86.07 32.6632L85.7453 32.6873C85.6353 32.6913 85.5254 32.6991 85.4158 32.7105C85.2559 32.7271 85.0348 32.7538 84.7525 32.7903C84.6345 32.8056 84.5516 32.8162 84.504 32.822L84.1737 32.8619L83.8432 32.9018C83.8033 32.9066 83.7635 32.9119 83.7238 32.9177C83.5459 32.9435 83.2985 32.9834 82.9815 33.0372C82.852 33.0591 82.7524 33.0758 82.6826 33.0873C82.6517 33.0924 82.5981 33.101 82.5216 33.1131C82.2808 33.151 82.0871 33.1842 81.9406 33.2127C81.5282 33.2925 81.1612 33.3685 80.8395 33.4407C80.5868 33.4913 80.378 33.5359 80.2131 33.5744C79.6373 33.7044 79.0348 33.8574 78.4057 34.0333L74.8975 34.9948C74.1846 35.1929 73.5201 35.3928 72.904 35.5945C72.7593 35.64 72.574 35.7031 72.3482 35.7837C72.2719 35.8109 72.2132 35.8316 72.1722 35.8457C71.5991 36.0447 71.1527 36.2027 70.833 36.3199C70.6998 36.3699 70.5114 36.4427 70.2678 36.5384C70.1844 36.5712 70.1097 36.6005 70.0435 36.6265C69.5097 36.84 69.0934 37.0111 68.7948 37.1398L68.4373 37.2954L68.0218 37.477C67.5857 37.6746 67.1979 37.8542 66.8586 38.0158C66.5247 38.1744 66.2669 38.2991 66.0852 38.39C65.6911 38.5871 65.2867 38.7973 64.872 39.0207L64.8387 39.0383L64.2065 39.3754C63.8928 39.5462 63.453 39.7986 62.8873 40.1328C62.8182 40.1736 62.7838 40.1941 62.7839 40.1944C62.5743 40.3142 62.4222 40.4035 62.3278 40.4623C61.8541 40.7475 61.2865 41.1057 60.6251 41.5367C60.5792 41.5661 60.5329 41.5967 60.4862 41.6287C60.3942 41.6892 60.2852 41.7638 60.1593 41.8524L60.1145 41.8841L60.1163 41.883C59.5704 42.2518 59.1025 42.577 58.7128 42.8586C58.5143 43.0042 58.3729 43.1065 58.2888 43.1654C58.2479 43.1939 58.2075 43.2232 58.1675 43.253C57.6655 43.6275 57.2979 43.9051 57.0648 44.0858C56.9507 44.1724 56.7923 44.2954 56.5898 44.4548C56.5529 44.4839 56.5166 44.5125 56.4808 44.5407C55.9911 44.9311 55.5288 45.3081 55.0939 45.6717L55.0774 45.6851L55.0503 45.7065C55.0236 45.7269 54.997 45.7476 54.9705 45.7688C54.904 45.8206 54.8391 45.8741 54.7754 45.9292C54.7025 45.9924 54.617 46.0697 54.5189 46.161L54.5295 46.1528C54.5287 46.1536 54.5279 46.1543 54.5271 46.155C53.9052 46.6923 53.3761 47.1664 52.9399 47.5775C52.8271 47.6806 52.6771 47.8224 52.49 48.0029C52.4007 48.0891 52.3283 48.1583 52.2729 48.2107C51.9017 48.5656 51.4915 48.9675 51.0424 49.4165C51.0247 49.4343 50.99 49.4683 50.9383 49.5185C50.8533 49.6012 50.7855 49.6679 50.7349 49.7185C50.6395 49.8139 50.5274 49.9324 50.3985 50.0741L50.3874 50.0888C50.3534 50.1227 50.3199 50.1572 50.2869 50.1921C49.8853 50.6178 49.5804 50.9454 49.3723 51.1748L48.6764 51.9423C48.544 52.0909 48.3585 52.3041 48.1197 52.582C47.9852 52.7384 47.8766 52.8642 47.7938 52.9593C47.771 52.9858 47.7338 53.0289 47.6821 53.0885C47.53 53.264 47.4115 53.4034 47.3267 53.5067C47.2851 53.5571 47.2305 53.6246 47.1628 53.7094C47.1242 53.7575 47.0969 53.7914 47.0808 53.811C46.7902 54.1653 46.5387 54.4816 46.3262 54.7597L45.9946 55.1837L45.6377 55.6403C45.4678 55.8625 45.2257 56.1886 44.9115 56.6186C44.8877 56.6514 44.8446 56.7095 44.7821 56.7929C44.6445 56.9765 44.5458 57.1108 44.4861 57.1958C44.4252 57.2801 44.3548 57.3822 44.2749 57.5021L44.2753 57.5034C44.2537 57.5333 44.2325 57.5634 44.2115 57.5939L43.9154 58.0238L43.601 58.4804C43.3076 58.9123 43.0841 59.2469 42.9304 59.484C42.7188 59.811 42.5227 60.1209 42.342 60.4136C42.159 60.697 42.0161 60.9245 41.9132 61.0959C41.8771 61.1561 41.8335 61.2318 41.7824 61.323ZM41.7824 61.323C41.7846 61.3193 41.7867 61.3156 41.7888 61.3118L41.7824 61.323ZM104.568 72.4679L104.555 72.4797C103.975 72.9804 103.423 73.4747 102.898 73.9627C102.75 74.0982 102.529 74.3084 102.236 74.5932C101.766 75.0422 101.356 75.4442 101.006 75.7993C100.774 76.0318 100.534 76.2773 100.289 76.536C99.8791 76.9701 99.5705 77.3015 99.3629 77.5304C99.0523 77.8728 98.8175 78.1339 98.6587 78.3137C98.506 78.4851 98.2842 78.7413 97.9935 79.0823C97.9023 79.1892 97.8343 79.2689 97.7895 79.3212L97.7656 79.3492C97.4631 79.7075 97.2347 79.9804 97.0805 80.168L96.69 80.6582L96.3452 81.0921C96.0476 81.4661 95.8073 81.7734 95.6245 82.014C95.4605 82.2284 95.2236 82.5476 94.9138 82.9715C94.6806 83.2865 94.4559 83.5971 94.2398 83.9033C94.0521 84.1637 93.8298 84.4867 93.5727 84.8722C93.2952 85.2806 93.0803 85.6022 92.9281 85.8371C92.878 85.9158 92.8189 86.0084 92.7509 86.1149C92.5571 86.4188 92.4094 86.6532 92.3079 86.8181C92.0487 87.2295 91.8473 87.5548 91.7037 87.7941L91.4063 88.3047L91.1389 88.7647C90.9573 89.0771 90.7617 89.4223 90.5524 89.8004C90.3841 90.1034 90.201 90.4424 90.0032 90.8175C89.9656 90.89 89.9194 90.9789 89.8645 91.0844C89.689 91.4218 89.5557 91.6816 89.4646 91.8639C89.2811 92.2247 89.099 92.5951 88.9182 92.9749C88.9169 92.9777 88.9155 92.9806 88.9141 92.9835C88.7034 93.4213 88.5435 93.7602 88.4344 94.0001C88.3155 94.2636 88.1491 94.6407 87.9353 95.1316C87.9153 95.1776 87.8867 95.2432 87.8495 95.3284C87.6946 95.6837 87.5769 95.96 87.4965 96.1574C87.3121 96.6018 87.1126 97.1079 86.8979 97.6759C86.8935 97.6871 86.8777 97.7276 86.8505 97.7977C86.7374 98.0889 86.651 98.3166 86.5913 98.4808C86.4078 98.9796 86.1871 99.6224 85.9293 100.409C85.9288 100.41 85.9267 100.416 85.9233 100.425C85.8714 100.57 85.8274 100.699 85.7911 100.812C85.7756 100.859 85.7604 100.907 85.7454 100.956C85.7425 100.965 85.7394 100.975 85.7364 100.984C85.5509 101.565 85.3667 102.187 85.1837 102.851C85.1675 102.907 85.1407 103.006 85.1034 103.148C85.0838 103.223 85.0711 103.27 85.0654 103.29C84.9083 103.874 84.7498 104.507 84.59 105.188C84.5864 105.203 84.5829 105.219 84.5794 105.234C84.5632 105.3 84.5486 105.362 84.5356 105.422C84.3948 106.043 84.2642 106.675 84.144 107.32C84.0935 107.6 84.0263 107.992 83.9424 108.496C83.931 108.565 83.9225 108.616 83.917 108.649C83.8504 109.041 83.797 109.37 83.757 109.636C83.7158 109.919 83.6676 110.301 83.6122 110.782C83.5963 110.921 83.5843 111.024 83.5763 111.091C83.5635 111.213 83.5477 111.353 83.5288 111.511C83.5022 111.733 83.4825 111.908 83.4697 112.035C83.4408 112.342 83.4083 112.76 83.3724 113.29C83.3621 113.44 83.3544 113.552 83.3493 113.626C83.3466 113.663 83.3415 113.729 83.334 113.822C83.313 114.083 83.2988 114.292 83.2913 114.451C83.2534 115.247 83.2344 116.045 83.2344 116.845V137.426C83.2344 137.458 83.2347 137.489 83.2353 137.521V139.337C83.2373 143.919 83.8619 148.186 85.109 152.14L55.7515 136.664C55.7346 136.655 55.7175 136.646 55.7005 136.637L55.6701 136.622C55.6545 136.613 55.6387 136.605 55.6229 136.597C55.1293 136.336 54.6482 136.057 54.1794 135.76C53.1204 135.091 52.1229 134.329 51.1869 133.475C51.1727 133.462 51.159 133.45 51.146 133.438C51.1427 133.435 51.1394 133.432 51.1362 133.429C50.5851 132.925 50.0389 132.365 49.4976 131.749C49.1067 131.296 48.7612 130.87 48.4612 130.471C48.3347 130.304 48.2105 130.135 48.0885 129.964C44.7969 125.37 43.1511 119.71 43.1511 112.984V90.4954C43.1511 89.9389 43.1625 89.3034 43.1854 88.5891C43.1878 88.5377 43.1952 88.435 43.2075 88.2812C43.2174 88.1588 43.2261 88.0425 43.2337 87.9323C43.2621 87.4507 43.2957 87.0031 43.3347 86.5896C43.3402 86.5204 43.3563 86.3799 43.3828 86.168C43.3956 86.0649 43.4057 85.9831 43.4129 85.9225C43.4221 85.8449 43.4353 85.7314 43.4525 85.5819C43.4969 85.1967 43.5298 84.9296 43.5513 84.7805C43.5824 84.5751 43.6284 84.2926 43.6893 83.9332C43.6999 83.8688 43.7153 83.7743 43.7355 83.6497C43.7966 83.2719 43.8451 82.9907 43.8809 82.8064C43.8943 82.7348 43.9091 82.6488 43.9253 82.5485L43.9212 82.5671C43.9215 82.5651 43.9219 82.5631 43.9222 82.5611C44.0145 82.0896 44.1057 81.6601 44.1958 81.2724C44.1987 81.2602 44.2015 81.2478 44.2043 81.2356C44.2212 81.1671 44.2363 81.1019 44.2497 81.0401C44.3425 80.6288 44.4631 80.1463 44.6115 79.5928C44.6222 79.553 44.6323 79.5129 44.6421 79.4728C44.6508 79.4367 44.6722 79.3569 44.7065 79.2334L44.7212 79.1799C44.9117 78.5129 45.0586 78.0149 45.1617 77.6859C45.1672 77.6694 45.1726 77.6528 45.178 77.636C45.2075 77.5532 45.2346 77.4698 45.2595 77.386C45.2843 77.3155 45.3081 77.2459 45.3309 77.1772C45.5384 76.5369 45.7283 75.9826 45.9005 75.5143C45.9358 75.4173 45.9971 75.2564 46.0843 75.0318C46.1135 74.9566 46.1361 74.8984 46.152 74.8571C46.1713 74.807 46.1998 74.7329 46.2376 74.6347C46.4228 74.1529 46.5646 73.7947 46.6631 73.5599C46.6876 73.5004 46.7166 73.4274 46.75 73.3409L46.7417 73.3594C46.7425 73.3573 46.7433 73.3551 46.7441 73.353C46.7649 73.305 46.7849 73.2566 46.8041 73.2079C46.8106 73.1914 46.8378 73.1302 46.8857 73.0244C46.9436 72.8962 46.9875 72.7976 47.0172 72.7288C47.2134 72.2784 47.3585 71.9492 47.4524 71.7412C47.5309 71.5684 47.6588 71.2978 47.8361 70.9296C47.8767 70.8467 47.9333 70.7308 48.0058 70.5818C48.1433 70.2989 48.2355 70.1113 48.2822 70.0189C48.3322 69.9233 48.3897 69.8073 48.4547 69.6708L48.4588 69.66C48.482 69.6157 48.5046 69.571 48.5265 69.5259C48.5308 69.5168 48.5585 69.4661 48.6094 69.3736C48.6738 69.2567 48.72 69.1716 48.7481 69.1184C48.7781 69.0627 48.8223 68.98 48.881 68.8703C49.0325 68.5866 49.1453 68.3783 49.2192 68.2454C49.3943 67.9291 49.5715 67.6161 49.751 67.3064C49.8731 67.0865 50.0113 66.8483 50.1657 66.5917C50.2323 66.4853 50.3045 66.3638 50.3824 66.227L50.4033 66.1903C50.4543 66.1054 50.5481 65.9573 50.6848 65.7459C50.7052 65.7146 50.7251 65.683 50.7447 65.6511C50.8943 65.4079 51.0597 65.1463 51.2409 64.8662C51.3545 64.691 51.532 64.4257 51.7733 64.0703L52.0696 63.6401L52.345 63.2403C52.3954 63.1689 52.4514 63.087 52.5131 62.9944L52.5439 62.9494C52.5706 62.9115 52.6248 62.8381 52.7064 62.7292C52.7968 62.6084 52.8654 62.5159 52.9122 62.4515C53.1797 62.0856 53.3654 61.8344 53.4693 61.6981L53.8013 61.2738L54.1578 60.8176C54.3244 60.5997 54.5152 60.3596 54.7303 60.0973C54.7719 60.047 54.8265 59.9794 54.8943 59.8946C54.9328 59.8464 54.9593 59.8136 54.9736 59.7962C55.0053 59.7575 55.0686 59.6836 55.1632 59.5745C55.227 59.5009 55.2667 59.4549 55.2825 59.4363C55.3624 59.3447 55.4778 59.2111 55.6287 59.0355C55.8292 58.8023 55.9662 58.6442 56.0398 58.5614L56.7055 57.8275C56.865 57.6517 57.1154 57.3829 57.4565 57.0211C57.536 56.9393 57.6255 56.8437 57.7249 56.7344L57.7362 56.7197C57.7539 56.7019 57.7886 56.6679 57.8403 56.6177C57.9253 56.535 57.9931 56.4684 58.0437 56.4178C58.4401 56.0213 58.7889 55.6792 59.0899 55.3914C59.1565 55.3286 59.248 55.241 59.3645 55.1286C59.488 55.0095 59.591 54.912 59.6734 54.8362C60.0428 54.4885 60.4881 54.0895 61.0095 53.6393C61.0704 53.5865 61.1402 53.5238 61.2187 53.4512C61.3346 53.3593 61.4226 53.2875 61.4827 53.2357C61.8299 52.9455 62.2109 52.635 62.6258 52.3042C62.6456 52.2887 62.6748 52.2656 62.7135 52.2352C62.8699 52.1121 62.9946 52.0152 63.0876 51.9444C63.2914 51.7866 63.607 51.5482 64.0344 51.2291C64.1686 51.1342 64.3368 51.0125 64.5392 50.864C64.8258 50.657 65.2073 50.3925 65.6836 50.0706C65.7282 50.0401 65.7862 49.9998 65.8575 49.9496L65.9139 49.9097C65.9533 49.8842 65.9931 49.8578 66.0332 49.8305C66.5958 49.4639 67.0806 49.1579 67.4876 48.9125C67.518 48.8937 67.6073 48.8417 67.7555 48.7567C67.7923 48.735 67.8482 48.702 67.9232 48.6577C68.3892 48.3824 68.7153 48.1941 68.9012 48.0927L69.4965 47.7756L69.5302 47.7576C69.8811 47.5686 70.2089 47.398 70.5135 47.2457C70.6356 47.1846 70.8335 47.0891 71.1072 46.9591L71.1115 46.957C71.3972 46.821 71.7073 46.677 72.0419 46.5249L72.3994 46.3693L72.7303 46.2251C72.9375 46.1359 73.2548 46.0058 73.6823 45.8346C73.7301 45.816 73.7991 45.7889 73.8894 45.7534C74.085 45.6766 74.2132 45.6267 74.2741 45.6037C74.5177 45.5145 74.8937 45.3817 75.4021 45.2051C75.4704 45.1817 75.5623 45.1493 75.6778 45.1081C75.7842 45.0701 75.8676 45.0416 75.928 45.0223C76.4388 44.8552 76.9734 44.6941 77.5317 44.539L81.0478 43.5754C81.5326 43.4398 81.993 43.3228 82.429 43.2242C82.5146 43.2042 82.6666 43.172 82.885 43.1276C83.1868 43.0605 83.5008 42.9954 83.827 42.9323C83.8599 42.9259 83.9382 42.913 84.0619 42.8935C84.1678 42.8768 84.246 42.8643 84.2967 42.8558C84.3759 42.8429 84.4899 42.8237 84.6388 42.7985C84.8419 42.764 84.9951 42.7388 85.0984 42.7231L85.3608 42.6915L85.6996 42.6505C85.7759 42.6412 85.8845 42.6274 86.0254 42.6091C86.1393 42.5944 86.2335 42.5825 86.3077 42.5735C86.3187 42.5728 86.3298 42.572 86.3409 42.5711L86.5715 42.554L86.8546 42.5328C87.2415 42.5 87.5399 42.4782 87.7499 42.4674C87.8209 42.4631 87.9529 42.458 88.1461 42.4522C88.248 42.4499 88.3825 42.4459 88.5495 42.4401C88.7268 42.434 88.8446 42.4309 88.903 42.4309C88.9501 42.4309 88.9973 42.4302 89.0444 42.4289C89.0705 42.4281 89.1424 42.4283 89.26 42.4294C89.3578 42.4304 89.4064 42.4305 89.4058 42.4298C89.4787 42.4313 89.5824 42.433 89.7169 42.4349C89.9183 42.4376 90.0797 42.4413 90.2011 42.446C90.292 42.4486 90.4366 42.4553 90.6349 42.4662C90.7203 42.4717 90.835 42.478 90.9793 42.4849C91.055 42.4886 91.1239 42.4922 91.1859 42.4959L91.289 42.5054L91.2927 42.5057L91.3599 42.5131L91.7147 42.5453C91.997 42.5722 92.2732 42.6029 92.5435 42.6375C92.6275 42.6495 92.725 42.6614 92.8358 42.6733C93.1212 42.7121 93.4377 42.7625 93.7854 42.8246C93.8405 42.8359 93.9009 42.8477 93.9667 42.8601L93.9698 42.8599C94.014 42.8697 94.0584 42.8789 94.1029 42.8876C94.1589 42.8984 94.2346 42.9125 94.33 42.93C94.3714 42.9375 94.4084 42.9445 94.4409 42.9508C94.5792 42.9882 94.6972 43.0166 94.795 43.036C95.0531 43.0905 95.3802 43.1715 95.7763 43.2788C95.8272 43.2921 95.8926 43.3086 95.9727 43.3283L96.059 43.3512C96.4567 43.4648 96.8799 43.5998 97.3286 43.7561C97.3407 43.7603 97.3527 43.7645 97.3649 43.7686C97.4776 43.814 97.5717 43.85 97.6473 43.8766C97.8311 43.9427 97.9965 44.0041 98.1433 44.061C98.2427 44.0998 98.3382 44.1381 98.4299 44.176C98.4306 44.1763 98.4314 44.1767 98.4322 44.177C98.455 44.1869 98.4876 44.201 98.53 44.2193C98.6867 44.2871 98.8044 44.3386 98.8832 44.3739C98.9939 44.4231 99.1007 44.4713 99.2036 44.5184C99.343 44.5824 99.4811 44.6472 99.6181 44.7129C99.6999 44.7535 99.7964 44.7991 99.9075 44.8496L99.9007 44.846C99.9027 44.8469 99.9047 44.8478 99.9066 44.8487C100.083 44.937 100.214 45.004 100.3 45.0496L129.063 60.213C128.835 60.2715 128.613 60.3306 128.396 60.3905L124.888 61.352C124.175 61.5501 123.466 61.7648 122.76 61.9962L122.468 62.0972L122.221 62.183C121.69 62.36 121.224 62.5247 120.824 62.6771C120.638 62.7468 120.385 62.8458 120.064 62.9741C119.559 63.1731 119.128 63.3494 118.772 63.5029L118.018 63.8342C117.669 63.9926 117.279 64.1741 116.849 64.3787C116.591 64.5012 116.336 64.6259 116.082 64.7529C115.634 64.9766 115.228 65.1868 114.863 65.3835C114.833 65.3993 114.784 65.425 114.715 65.4606C114.639 65.5005 114.569 65.5368 114.507 65.5696C114.467 65.5899 114.427 65.6108 114.387 65.6323C114.288 65.6854 114.195 65.7371 114.107 65.7874C113.817 65.9444 113.351 66.212 112.71 66.59C112.642 66.6306 112.544 66.6878 112.415 66.7614C112.372 66.7861 112.329 66.8116 112.286 66.8376C111.632 67.2385 111.047 67.6097 110.532 67.9513C110.413 68.0272 110.281 68.1154 110.137 68.2159C110.111 68.2342 110.094 68.2454 110.088 68.2498C109.606 68.5732 109.156 68.886 108.738 69.1881L108.196 69.5852C107.863 69.8283 107.478 70.1188 107.041 70.4566C106.802 70.6384 106.615 70.7826 106.48 70.8893C106.104 71.1853 105.67 71.5386 105.178 71.9492C105.043 72.0556 104.93 72.1485 104.837 72.2279C104.78 72.2755 104.69 72.3555 104.568 72.4679ZM104.568 72.4679C104.572 72.4646 104.575 72.4614 104.579 72.4582L104.568 72.4679ZM101.032 34.5728L101.03 34.5721C101.033 34.573 101.035 34.5739 101.038 34.5748C101.036 34.5741 101.034 34.5735 101.032 34.5728ZM104.903 162.561L105.666 162.963C105.717 162.991 105.807 163.037 105.935 163.102C105.972 163.121 106.002 163.136 106.025 163.148C106.092 163.178 106.131 163.197 106.145 163.205L106.281 163.269C106.554 163.401 106.811 163.519 107.052 163.622L107.063 163.627C107.148 163.663 107.253 163.708 107.38 163.765C107.425 163.785 107.449 163.796 107.452 163.797C107.68 163.891 107.998 164.015 108.408 164.169L108.458 164.189C108.519 164.211 108.58 164.234 108.641 164.257L108.655 164.263C109.054 164.403 109.476 164.536 109.919 164.661C109.914 164.66 109.885 164.654 109.835 164.643C109.904 164.659 109.973 164.675 110.041 164.693L110.249 164.747C110.596 164.84 110.948 164.927 111.306 165.008L111.379 165.025L111.451 165.041L111.564 165.067L111.672 165.086L111.703 165.092L112.139 165.174C112.496 165.238 112.847 165.295 113.191 165.347L113.391 165.372C113.685 165.41 113.977 165.443 114.269 165.47L114.88 165.525L115.066 165.535L115.442 165.551L115.47 165.553C115.644 165.565 115.787 165.572 115.899 165.576C115.964 165.579 116.087 165.581 116.267 165.584C116.363 165.585 116.443 165.586 116.506 165.587H117.121C117.26 165.587 117.474 165.58 117.764 165.566C117.848 165.561 117.932 165.559 118.015 165.559H118.037C118.081 165.557 118.126 165.555 118.174 165.552L118.156 165.555C118.215 165.55 118.274 165.546 118.334 165.542C118.501 165.534 118.779 165.514 119.169 165.481L119.325 165.467C119.376 165.462 119.427 165.458 119.479 165.455C119.506 165.453 119.53 165.451 119.549 165.449L120.088 165.388L120.592 165.324C120.728 165.308 120.832 165.295 120.904 165.284C121.005 165.27 121.174 165.243 121.411 165.203C121.454 165.196 121.491 165.19 121.522 165.185L121.924 165.119C121.976 165.11 122.042 165.1 122.123 165.087L122.114 165.091C122.152 165.082 122.191 165.074 122.23 165.067C122.398 165.035 122.677 164.977 123.068 164.895L123.398 164.828L123.653 164.775C124.058 164.683 124.517 164.567 125.03 164.425L128.507 163.472C129.101 163.307 129.652 163.141 130.162 162.973C130.219 162.955 130.253 162.943 130.266 162.939C130.308 162.922 130.35 162.907 130.393 162.892L130.632 162.808H130.634C131.154 162.628 131.551 162.487 131.825 162.387C131.834 162.383 131.882 162.364 131.971 162.328C132.052 162.295 132.097 162.276 132.106 162.273L132.326 162.182C132.367 162.166 132.414 162.147 132.467 162.126C132.853 161.972 133.13 161.858 133.299 161.784L133.938 161.506C134.318 161.334 134.65 161.181 134.935 161.045L135.024 161.003L135.533 160.756C135.838 160.604 136.178 160.426 136.553 160.224L136.711 160.141C136.766 160.112 136.839 160.073 136.929 160.025C136.993 159.992 137.052 159.961 137.107 159.93C137.318 159.814 137.658 159.619 138.125 159.343C138.192 159.304 138.261 159.263 138.332 159.221L138.46 159.147H138.461C138.544 159.098 138.619 159.055 138.687 159.017C139.131 158.749 139.551 158.484 139.947 158.224L140.305 157.979C141.736 157.002 143.572 156.845 145.148 157.563L156.125 162.564C157.693 163.279 159.202 163.17 160.652 162.237C162.103 161.304 162.828 159.976 162.828 158.251V117.614C162.828 117.456 162.835 117.298 162.85 117.14V95.1484C162.85 83.7403 158.651 75.8219 150.254 71.3934L149.959 71.2374C149.909 71.2132 149.855 71.1867 149.796 71.158C149.721 71.1217 149.663 71.0932 149.62 71.0724C149.384 70.9567 149.129 70.8386 148.853 70.718C148.676 70.6389 148.548 70.5828 148.468 70.5496C148.234 70.4515 147.944 70.3392 147.597 70.2126L147.415 70.1456L147.313 70.1067C146.939 69.9768 146.534 69.8488 146.096 69.7227L145.999 69.6973L145.777 69.6395C145.321 69.5221 144.979 69.4377 144.751 69.3863C144.724 69.3805 144.683 69.3717 144.629 69.3597C144.371 69.3034 144.185 69.2642 144.072 69.242C144.03 69.2339 143.989 69.2253 143.948 69.2162L143.83 69.1927C143.449 69.1241 143.093 69.0673 142.761 69.0223C142.792 69.0267 142.81 69.0291 142.814 69.0296C142.703 69.0177 142.628 69.0088 142.589 69.0029C142.287 68.9641 141.996 68.931 141.716 68.9037L141.71 68.9033L141.383 68.8734C141.233 68.8611 140.993 68.845 140.663 68.8252C140.435 68.8126 140.276 68.805 140.186 68.8025C140.128 68.8008 140.071 68.7982 140.014 68.7947C139.934 68.7897 139.76 68.7872 139.491 68.7872L138.982 68.7863C138.823 68.7894 138.572 68.7974 138.23 68.8105C138.13 68.8157 138.008 68.8205 137.866 68.8249C137.791 68.8271 137.731 68.8296 137.686 68.8322C137.515 68.8408 137.229 68.8619 136.827 68.8954L136.781 68.899C136.729 68.9029 136.661 68.9074 136.577 68.9127L136.442 68.9231C136.355 68.9316 136.204 68.9492 135.988 68.9761C135.849 68.9934 135.758 69.0044 135.717 69.0091C135.625 69.0209 135.503 69.035 135.352 69.0513C135.248 69.0625 135.179 69.0706 135.146 69.0756C135.049 69.09 134.884 69.1175 134.65 69.158C134.503 69.1832 134.384 69.2036 134.291 69.219C134.229 69.2289 134.145 69.2422 134.038 69.2587C133.907 69.2786 133.814 69.2938 133.759 69.3042C133.645 69.3251 133.444 69.3668 133.157 69.4293C133.028 69.4574 132.949 69.4744 132.92 69.4802C132.827 69.5013 132.7 69.5285 132.54 69.5617C132.451 69.5801 132.403 69.5901 132.396 69.5916C131.906 69.7035 131.445 69.8191 131.014 69.9384L127.538 70.8908C126.968 71.049 126.455 71.2026 125.998 71.3517L125.707 71.4525L125.352 71.575C124.951 71.7086 124.6 71.8325 124.3 71.9467C124.181 71.9913 123.979 72.0708 123.693 72.1852C123.284 72.3461 122.971 72.4736 122.752 72.5678L122.104 72.8516C121.973 72.9113 121.833 72.9757 121.684 73.0449V91.2807C121.684 92.0491 121.665 92.8184 121.628 93.5885C121.622 93.7365 121.609 93.9283 121.591 94.1637L121.59 94.1679L121.571 94.4471L121.57 94.4744C121.528 95.1281 121.491 95.6208 121.457 95.9524C121.439 96.1399 121.414 96.3666 121.381 96.6324L121.329 97.0655C121.322 97.1194 121.312 97.2021 121.299 97.3136C121.248 97.7482 121.207 98.0619 121.178 98.255C121.155 98.4224 121.12 98.6496 121.074 98.9367C121.068 98.9704 121.065 98.9883 121.065 98.9905L121.009 99.3453L121.009 99.3467C120.917 99.902 120.844 100.324 120.788 100.612C120.661 101.278 120.528 101.923 120.389 102.547L120.362 102.657C120.224 103.259 120.071 103.869 119.902 104.487L119.761 104.999C119.578 105.652 119.397 106.263 119.219 106.83L119.068 107.297C118.85 107.97 118.629 108.613 118.405 109.225C118.356 109.361 118.29 109.536 118.205 109.751L118.188 109.793L118.192 109.783L118.08 110.078C118.063 110.121 118.039 110.184 118.007 110.266C117.79 110.827 117.625 111.244 117.512 111.517C117.449 111.674 117.358 111.889 117.239 112.163C117.192 112.273 117.165 112.336 117.158 112.351L117.095 112.503C116.916 112.924 116.752 113.292 116.603 113.609C116.52 113.796 116.401 114.053 116.244 114.381C116.192 114.49 116.148 114.584 116.109 114.664C116.087 114.71 116.055 114.778 116.011 114.868C115.823 115.258 115.676 115.559 115.568 115.77C115.376 116.148 115.2 116.486 115.04 116.784L114.726 117.371L114.51 117.768C114.385 117.994 114.197 118.325 113.947 118.761C113.932 118.786 113.907 118.83 113.871 118.893C113.665 119.251 113.496 119.536 113.364 119.749C113.175 120.066 112.975 120.389 112.764 120.718C112.747 120.745 112.719 120.789 112.68 120.851C112.458 121.207 112.294 121.463 112.191 121.62C112.082 121.789 111.924 122.028 111.715 122.339C111.63 122.465 111.557 122.575 111.495 122.666L111.474 122.698L110.894 123.543L110.828 123.635L110.248 124.44C110.006 124.774 109.774 125.086 109.554 125.376C109.374 125.614 109.136 125.921 108.838 126.296C108.792 126.354 108.722 126.444 108.626 126.564C108.415 126.831 108.255 127.032 108.146 127.165C107.99 127.358 107.765 127.627 107.472 127.971L107.432 128.017L107.427 128.022C107.169 128.329 106.885 128.659 106.573 129.01C106.415 129.187 106.185 129.443 105.883 129.777C105.674 130.009 105.372 130.333 104.979 130.75C104.865 130.87 104.702 131.038 104.49 131.254C104.4 131.346 104.327 131.42 104.271 131.477C103.929 131.825 103.523 132.223 103.055 132.67L102.875 132.844L102.673 133.037L102.415 133.284C101.896 133.765 101.374 134.233 100.848 134.686L100.749 134.772L100.669 134.843L100.589 134.915C100.544 134.955 100.499 134.995 100.452 135.032L100.254 135.197C99.956 135.449 99.52 135.806 98.9463 136.268L98.9399 136.273C98.8505 136.346 98.7376 136.435 98.6012 136.539C98.551 136.577 98.5019 136.615 98.4539 136.652L98.3712 136.715C97.9722 137.023 97.5654 137.329 97.1506 137.632C97.1246 137.65 97.0729 137.688 96.9956 137.746C96.8643 137.844 96.7619 137.919 96.6886 137.971C96.2495 138.286 95.8236 138.581 95.4107 138.856L95.0917 139.078C95.0575 139.102 95.0231 139.126 94.9883 139.148C94.3982 139.537 93.7872 139.923 93.1553 140.305L93.1461 140.31C93.3774 150.816 97.2951 158.232 104.899 162.559C104.9 162.559 104.902 162.56 104.903 162.561ZM93.1353 128.236C93.4493 127.981 93.6922 127.78 93.8641 127.635L94.0621 127.471L94.0756 127.459L94.1552 127.388L94.3292 127.235C94.7991 126.83 95.2489 126.428 95.6787 126.029L95.8253 125.887L96.0277 125.694L96.1784 125.548C96.5995 125.146 96.9459 124.807 97.2176 124.531C97.2581 124.489 97.3285 124.417 97.429 124.315C97.6004 124.141 97.7231 124.014 97.7971 123.936C98.1301 123.583 98.3794 123.315 98.5452 123.132C98.8197 122.829 99.0227 122.603 99.1543 122.453C99.428 122.145 99.6664 121.87 99.8696 121.627C99.8932 121.599 99.9173 121.571 99.9416 121.543L99.961 121.521C100.189 121.254 100.352 121.059 100.451 120.936C100.54 120.827 100.676 120.657 100.86 120.424C100.962 120.296 101.039 120.198 101.092 120.131C101.332 119.828 101.519 119.588 101.653 119.41C101.842 119.162 102.036 118.901 102.235 118.626L102.797 117.846L102.819 117.815L103.309 117.099L103.311 117.096C103.344 117.047 103.406 116.954 103.499 116.816C103.668 116.564 103.788 116.384 103.858 116.274C103.965 116.111 104.109 115.885 104.29 115.597C104.328 115.536 104.357 115.49 104.378 115.456C104.573 115.153 104.739 114.885 104.877 114.652C104.898 114.616 104.92 114.58 104.942 114.545C105.011 114.435 105.127 114.238 105.289 113.956C105.325 113.894 105.353 113.846 105.371 113.813C105.591 113.43 105.753 113.146 105.856 112.96L106.027 112.645L106.306 112.122C106.44 111.873 106.587 111.591 106.746 111.278C106.819 111.134 106.935 110.898 107.092 110.573C107.137 110.478 107.172 110.405 107.196 110.356C107.213 110.32 107.251 110.241 107.309 110.118C107.427 109.87 107.51 109.694 107.557 109.589C107.708 109.262 107.852 108.94 107.988 108.622L108.004 108.58C108.048 108.477 108.098 108.361 108.153 108.233C108.24 108.031 108.301 107.889 108.334 107.807C108.436 107.558 108.583 107.187 108.773 106.695C108.806 106.611 108.831 106.547 108.849 106.501L108.93 106.284C108.956 106.217 108.98 106.157 109 106.106C109.044 105.996 109.071 105.926 109.082 105.896C109.28 105.353 109.469 104.803 109.649 104.247L109.805 103.768C109.931 103.364 110.073 102.88 110.231 102.318L110.353 101.873C110.486 101.389 110.605 100.913 110.713 100.445L110.766 100.224C110.856 99.8165 110.954 99.3298 111.062 98.7642C111.104 98.5444 111.164 98.2039 111.239 97.7427L111.24 97.7413L111.286 97.4468C111.291 97.4136 111.297 97.3803 111.302 97.3468C111.335 97.1476 111.356 97.0084 111.367 96.9292C111.395 96.7383 111.429 96.4795 111.468 96.1528C111.482 96.0307 111.493 95.9379 111.501 95.8744L111.551 95.4529C111.576 95.2556 111.592 95.114 111.6 95.0279C111.626 94.7717 111.656 94.3788 111.689 93.8493L111.691 93.8218L111.715 93.4636C111.719 93.4193 111.721 93.3886 111.722 93.3716C111.731 93.2627 111.736 93.1977 111.737 93.1766C111.768 92.5227 111.784 91.8907 111.784 91.2807V79.3334C111.683 79.4164 111.58 79.5026 111.473 79.592C111.434 79.6239 111.396 79.6552 111.357 79.6859L111.24 79.7802C111.239 79.7812 111.238 79.7822 111.237 79.7833L111.249 79.7743C111.126 79.8862 111.041 79.9618 110.993 80.0012C110.538 80.3944 110.077 80.8076 109.611 81.2411C109.514 81.3295 109.345 81.4906 109.104 81.7244C108.688 82.1222 108.331 82.4721 108.034 82.7741C107.843 82.9645 107.658 83.1541 107.479 83.3429C107.121 83.7221 106.86 84.0021 106.696 84.1831C106.414 84.4937 106.204 84.7277 106.065 84.8851C105.951 85.0125 105.772 85.2195 105.528 85.5059C105.432 85.6192 105.366 85.6963 105.331 85.7372C105.056 86.0624 104.873 86.2807 104.78 86.3921L104.435 86.827L104.094 87.2552C103.841 87.5734 103.643 87.8273 103.498 88.0169C103.372 88.1822 103.169 88.4556 102.89 88.8374C102.689 89.1089 102.493 89.3801 102.302 89.651C102.166 89.8402 101.994 90.0899 101.787 90.4002C101.534 90.7732 101.359 91.034 101.262 91.1826C101.225 91.2418 101.17 91.3279 101.098 91.4409C100.933 91.6994 100.805 91.9027 100.713 92.0507C100.474 92.4308 100.313 92.6903 100.229 92.8291L99.9616 93.2888L99.698 93.7424C99.551 93.995 99.3887 94.2817 99.2111 94.6025C99.0755 94.8467 98.9312 95.1137 98.7783 95.4034C98.7492 95.4597 98.7058 95.5433 98.6481 95.6541C98.497 95.9446 98.3829 96.1669 98.3057 96.321C98.1488 96.6297 97.9937 96.9454 97.8403 97.268L97.8392 97.2703C97.658 97.6465 97.5293 97.9186 97.453 98.0866C97.3556 98.3021 97.2097 98.633 97.0154 99.0794C96.9949 99.1266 96.9646 99.1963 96.9244 99.2882C96.8019 99.5691 96.7118 99.7803 96.6541 99.9218C96.502 100.289 96.3327 100.718 96.1463 101.211C96.1314 101.25 96.1094 101.306 96.0801 101.382C95.9928 101.607 95.9293 101.774 95.8897 101.882C95.7393 102.291 95.5393 102.876 95.2897 103.637C95.2779 103.671 95.262 103.715 95.2419 103.771L95.2393 103.772C95.2279 103.809 95.2161 103.847 95.204 103.884C95.1874 103.939 95.1706 103.991 95.1534 104.043C95.0129 104.485 94.8672 104.979 94.7164 105.526C94.7107 105.546 94.698 105.593 94.6784 105.668C94.6411 105.81 94.6173 105.898 94.607 105.932C94.4938 106.355 94.3708 106.848 94.2382 107.411C94.2266 107.464 94.2138 107.518 94.1998 107.575L94.1965 107.589C94.082 108.092 93.9774 108.598 93.8825 109.107C93.8433 109.324 93.7855 109.662 93.7091 110.122C93.6976 110.191 93.6881 110.247 93.6806 110.293C93.6225 110.634 93.5794 110.898 93.5513 111.084C93.5269 111.253 93.4925 111.53 93.4482 111.915C93.4309 112.065 93.421 112.149 93.4184 112.166C93.4056 112.294 93.3859 112.469 93.3592 112.691C93.3403 112.848 93.3287 112.949 93.3243 112.992C93.3052 113.194 93.2806 113.518 93.2505 113.962C93.2397 114.12 93.2313 114.243 93.2252 114.328C93.2203 114.397 93.213 114.492 93.2032 114.615C93.1908 114.769 93.1834 114.871 93.1809 114.923C93.1505 115.562 93.1353 116.203 93.1353 116.846V128.236Z" stroke="#0F0D0C" stroke-width="3.5"/> +</svg> diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index 608fd5eb19..82c440844a 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -8,6 +8,7 @@ import { useId, useMemo, useState } from "react"; import { useT } from "../../i18n/shared"; import { bucketPresets, + pinSponsors, filterPresets, type CatalogPreset, } from "./provider-presets"; @@ -99,7 +100,7 @@ export default function ProviderCatalog({ }); }, [catalog, usageRank]); - const buckets = useMemo(() => bucketPresets(ranked), [ranked]); + const buckets = useMemo(() => bucketPresets(pinSponsors(ranked)), [ranked]); const tierList = buckets[tier]; const rows = useMemo(() => filterPresets(tierList, query), [tierList, query]); // oxlint-disable-next-line local-i18n/no-hardcoded-ui-strings -- DOM id suffix, not UI copy @@ -138,7 +139,10 @@ export default function ProviderCatalog({ const free = (p.freeTier || p.keyOptional) && p.auth === "key" ? <span className="badge badge-green">{t("modal.badge.free")}</span> : null; - return <>{free}{auth}</>; + const sponsor = p.sponsor + ? <span className="badge badge-accent provider-catalog-sponsor" title={p.sponsorUrl}>{t("modal.badge.sponsor")}</span> + : null; + return <>{sponsor}{free}{auth}</>; }; return ( diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 36f5231fa2..ae05e55e7f 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -6,7 +6,7 @@ * predicates), search filtering, and deterministic sorting. No React, no fetch. */ -import { providerTier, type ProviderTier, type WorkspaceProvider } from "../../provider-workspace/catalog"; +import { providerTier, type ProviderTier, type WorkspaceProvider, type WorkspaceItem } from "../../provider-workspace/catalog"; import type { ProviderPayload } from "../../provider-payload"; /** Row shape returned by GET /api/provider-presets (mirrors DerivedProviderPreset). */ @@ -28,6 +28,9 @@ export interface CatalogPreset { keyOptional?: boolean; /** Free pricing — may still require an API key (e.g. NVIDIA NIM). */ freeTier?: boolean; + /** Sponsor tier (SPONSORS.md). Sponsor rows are pinned to the top of their tab and chipped. */ + sponsor?: "main" | "standard"; + sponsorUrl?: string; /** * Endpoint picker (e.g. Qwen Cloud). Choice without `baseUrl` = Custom (show text field). */ @@ -36,6 +39,21 @@ export interface CatalogPreset { provider?: ProviderPayload; } +/** A configured name alone cannot identify a sponsor after its endpoint is edited. */ +export function matchingWorkspacePreset(item: WorkspaceItem, presets: CatalogPreset[]): CatalogPreset | undefined { + const endpoint = (value: string) => { + try { + const url = new URL(value.trim()); + if (url.username || url.password || url.search || url.hash) return undefined; + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; + } catch { return undefined; } + }; + const base = endpoint(item.baseUrl); + if (!base) return undefined; + return presets.find(preset => preset.id === item.name && preset.adapter === item.adapter + && endpoint(preset.baseUrl) === base); +} + /** * Adapt a preset row to the WorkspaceProvider shape the tier predicates expect * (preset `auth` ↔ config `authMode`; booleans normalized). @@ -68,3 +86,20 @@ export function filterPresets(presets: CatalogPreset[], query: string): CatalogP if (!q) return presets; return presets.filter(p => p.label.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)); } + +const SPONSOR_RANK: Record<NonNullable<CatalogPreset["sponsor"]>, number> = { main: 0, standard: 1 }; + +/** + * Sponsor rows first — Main before Standard, alphabetical by label within a tier — then the + * caller's order untouched. Stable, so usage ranking still decides the non-sponsor tail. + * Alphabetical among sponsors is deliberate: it is the one order no sponsor can buy. + */ +export function pinSponsors(presets: CatalogPreset[]): CatalogPreset[] { + const sponsors = presets.filter(p => p.sponsor); + if (sponsors.length === 0) return presets; + sponsors.sort((a, b) => + SPONSOR_RANK[a.sponsor!] - SPONSOR_RANK[b.sponsor!] + || a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) + || a.id.localeCompare(b.id)); + return [...sponsors, ...presets.filter(p => !p.sponsor)]; +} diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index d6065e0298..aa843b86fc 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -13,6 +13,7 @@ import { ProviderIcon } from "./ProviderRail"; import { Switch } from "../../ui"; import { IconChevron, IconTrash } from "../../icons"; import ProviderOverview from "./ProviderOverview"; +import type { CatalogPreset } from "../provider-catalog/provider-presets"; import type { ModelRow } from "../../pages/models-shared"; import ProviderModels from "./ProviderModels"; import ProviderUsage from "./ProviderUsage"; @@ -27,6 +28,7 @@ type Tab = "overview" | "models" | "usage" | "accounts" | "settings"; export default function ProviderDetails({ item, + preset, usageTotals, modelUsage, quotaReport, @@ -63,6 +65,7 @@ export default function ProviderDetails({ onRefreshQuota, }: { item: WorkspaceItem; + preset?: CatalogPreset; usageTotals?: ProviderUsageTotals; modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; @@ -264,6 +267,7 @@ export default function ProviderDetails({ {tab === "overview" && ( <ProviderOverview item={item} + preset={preset} apiBase={apiBase} connectionIdentity={connectionIdentity} usageTotals={usageTotals} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 6fbca99789..60efdb3950 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -13,12 +13,13 @@ import type { AccountQuotaReading, ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch, ProviderUpdateResult } from "./types"; import ProviderCurrentQuota from "./ProviderCurrentQuota"; +import type { CatalogPreset } from "../provider-catalog/provider-presets"; +import ProviderSponsor from "./ProviderSponsor"; type ConnectionTestResult = { applicable?: boolean; ok?: boolean; latencyMs?: number; - authState?: "connected" | "checking" | "needs_reauth" | "unsupported"; reason?: string; message?: string; error?: string; @@ -31,13 +32,13 @@ type ConnectionTestState = { }; export default function ProviderOverview({ - item, usageTotals, quotaReport, currentQuotaReading, onRefreshQuota, oauthEmail, oauth, + item, preset, usageTotals, quotaReport, currentQuotaReading, onRefreshQuota, oauthEmail, oauth, apiBase, connectionIdentity, onEditSettings, onViewUsage, onUpdateProvider, onReauthenticate, onCancelLogin, reauthBusy = false, - onRefreshConfig, }: { item: WorkspaceItem; + preset?: CatalogPreset; usageTotals?: ProviderUsageTotals; quotaReport?: ProviderQuotaReportView; currentQuotaReading?: AccountQuotaReading; @@ -54,34 +55,16 @@ export default function ProviderOverview({ onReauthenticate?: () => void; onCancelLogin?: () => void; reauthBusy?: boolean; - onRefreshConfig?: () => void | Promise<void>; }) { const t = useT(); const { locale } = useI18n(); const status = binProviderStatus(item); const needsAttention = Boolean(item.activeNeedsReauth); - const isAiStudioWeb = item.googleMode === "ai-studio-web" || item.name === "google-aistudio"; - const [localAiStudioAuthState, setLocalAiStudioAuthState] = useState<"connected" | "checking" | "needs_reauth" | "unsupported" | null>(null); - const aiStudioAuthState = localAiStudioAuthState ?? item.aiStudioAuthState ?? (item.hasAiStudioSession ? "checking" : "needs_reauth"); - const aiStudioStatusText = aiStudioAuthState === "connected" - ? t("pws.aiStudio.connected") - : aiStudioAuthState === "checking" - ? t("pws.aiStudio.checking") - : aiStudioAuthState === "unsupported" - ? t("pws.aiStudio.unsupported") - : t("pws.aiStudio.needsReauth"); - const needsAiStudioReauth = isAiStudioWeb && aiStudioAuthState === "needs_reauth"; - const [aiStudioReauthBusy, setAiStudioReauthBusy] = useState(false); - const [aiStudioReauthMsg, setAiStudioReauthMsg] = useState<string | null>(null); - const aiStudioReauthAbortRef = useRef<AbortController | null>(null); - const aiStudioAutoTestKeyRef = useRef<string | null>(null); - const statusText = isAiStudioWeb - ? aiStudioStatusText - : status === "ready" - ? t("pws.status.connected") - : status === "needs-setup" - ? (needsAttention ? t("pws.status.needsAttention") : t("pws.status.needsSetup")) - : t("prov.disabledBadge"); + const statusText = status === "ready" + ? t("pws.status.connected") + : status === "needs-setup" + ? (needsAttention ? t("pws.status.needsAttention") : t("pws.status.needsSetup")) + : t("prov.disabledBadge"); const requests = usageTotals?.requests; const tokens = usageTotals?.totalTokens; const connectionProbeKey = JSON.stringify([ @@ -98,7 +81,6 @@ export default function ProviderOverview({ item.allowPrivateNetwork === true, item.keyOptional === true, item.activeNeedsReauth === true, - aiStudioAuthState, connectionIdentity ?? null, ]); const [connectionTest, setConnectionTest] = useState<ConnectionTestState | null>(null); @@ -112,8 +94,6 @@ export default function ProviderOverview({ connectionAbortRef.current.controller.abort(); connectionAbortRef.current = null; } - aiStudioReauthAbortRef.current?.abort(); - aiStudioReauthAbortRef.current = null; }; }, [connectionProbeKey]); @@ -132,15 +112,6 @@ export default function ProviderOverview({ if (!result) throw new Error(t("pws.connectionFailed")); if (!controller.signal.aborted) { setConnectionTest({ key: connectionProbeKey, testing: false, result }); - if (isAiStudioWeb) { - if (result.authState) { - setLocalAiStudioAuthState(result.authState); - } else if (result.ok === true) { - setLocalAiStudioAuthState("connected"); - } else if (result.error && /re-?authentication/i.test(result.error)) { - setLocalAiStudioAuthState("needs_reauth"); - } - } } } catch (error) { if (!controller.signal.aborted) { @@ -159,63 +130,7 @@ export default function ProviderOverview({ connectionAbortRef.current = null; } } - }, [apiBase, connectionProbeKey, isAiStudioWeb, item.name, t]); - - const handleAiStudioReauth = useCallback(async () => { - if (!apiBase) return; - aiStudioReauthAbortRef.current?.abort(); - const controller = new AbortController(); - aiStudioReauthAbortRef.current = controller; - setAiStudioReauthBusy(true); - setAiStudioReauthMsg(null); - try { - const res = await fetch(`${apiBase}/api/aistudio/login/native`, { - method: "POST", - signal: controller.signal, - }); - if (controller.signal.aborted) return; - if (!res.ok) { - const data = (await res.json().catch(() => null)) as { error?: string | { message?: string } } | null; - const error = typeof data?.error === "string" ? data.error : data?.error?.message; - setAiStudioReauthMsg(error || t("pws.aiStudio.loginFailed")); - return; - } - const data = (await res.json().catch(() => null)) as { ok?: boolean; error?: string | { message?: string } } | null; - const error = typeof data?.error === "string" ? data.error : data?.error?.message; - if (data?.ok) { - setLocalAiStudioAuthState("connected"); - setAiStudioReauthMsg(t("pws.aiStudio.reauthenticated")); - await onRefreshConfig?.(); - return; - } - if (error) { - setAiStudioReauthMsg(error); - return; - } - setAiStudioReauthMsg(t("pws.aiStudio.loginFailed")); - } catch (error) { - if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return; - setAiStudioReauthMsg(error instanceof Error ? error.message : t("pws.aiStudio.loginFailed")); - } finally { - if (aiStudioReauthAbortRef.current === controller) { - aiStudioReauthAbortRef.current = null; - } - if (!controller.signal.aborted) { - setAiStudioReauthBusy(false); - } - } - }, [apiBase, onRefreshConfig, t]); - - useEffect(() => { - if (!isAiStudioWeb || aiStudioAuthState !== "checking" || !apiBase) return; - const autoTestKey = `${item.name}:${connectionProbeKey}`; - if (aiStudioAutoTestKeyRef.current === autoTestKey) return; - aiStudioAutoTestKeyRef.current = autoTestKey; - const timer = window.setTimeout(() => { - void testConnection(); - }, 0); - return () => window.clearTimeout(timer); - }, [aiStudioAuthState, apiBase, connectionProbeKey, isAiStudioWeb, item.name, testConnection]); + }, [apiBase, connectionProbeKey, item.name, t]); const connectionState = connectionResult?.applicable === false ? "not-applicable" @@ -228,6 +143,8 @@ export default function ProviderOverview({ ? (connectionResult.message || t("pws.connectionOk")) : (connectionResult?.error || t("pws.connectionFailed")); return ( + <> + <ProviderSponsor item={item} preset={preset} /> <div className="pws-overview-layout"> <div className="pws-overview-main"> <section className="pws-section" aria-label={t("pws.connection")}> @@ -254,36 +171,7 @@ export default function ProviderOverview({ <dt>{t("modal.defaultModel")}</dt> <dd>{item.defaultModel ?? <span className="muted">—</span>}</dd> </div> - {isAiStudioWeb && ( - <div className="pws-kv-row"> - <dt>{t("pws.aiStudio")}</dt> - <dd>{aiStudioStatusText}</dd> - </div> - )} - {item.note && ( - <div className="pws-kv-row"> - <dt>{t("pws.cell.note")}</dt> - <dd className="muted">{item.note}</dd> - </div> - )} </dl> - {isAiStudioWeb && (needsAiStudioReauth || aiStudioReauthMsg) && ( - <div className="row" style={{ marginTop: 12, alignItems: "center" }}> - {needsAiStudioReauth && ( - <button - type="button" - className="btn btn-primary btn-sm" - disabled={aiStudioReauthBusy} - onClick={() => void handleAiStudioReauth()} - > - {aiStudioReauthBusy ? t("common.loading") : status !== "ready" ? t("pws.aiStudio.connect") : t("pws.reauthenticate")} - </button> - )} - {aiStudioReauthMsg && ( - <span role="status" className="muted">{aiStudioReauthMsg}</span> - )} - </div> - )} {apiBase && ( <div className="row" style={{ marginTop: 12, alignItems: "center" }}> <button @@ -361,6 +249,7 @@ export default function ProviderOverview({ </div> )} </section> + <NotesSection item={item} onUpdateProvider={onUpdateProvider} /> </div> <aside className="pws-overview-sidebar"> @@ -391,9 +280,9 @@ export default function ProviderOverview({ </section> <ProviderCurrentQuota key={`${item.name}:${connectionIdentity ?? ""}`} report={quotaReport} reading={currentQuotaReading} onRefreshQuota={onRefreshQuota} /> - <NotesSection item={item} onUpdateProvider={onUpdateProvider} /> </aside> </div> + </> ); } diff --git a/gui/src/components/provider-workspace/ProviderSponsor.tsx b/gui/src/components/provider-workspace/ProviderSponsor.tsx new file mode 100644 index 0000000000..6b52252b91 --- /dev/null +++ b/gui/src/components/provider-workspace/ProviderSponsor.tsx @@ -0,0 +1,44 @@ +import { useT } from "../../i18n/shared"; +import { IconExternal } from "../../icons"; +import type { WorkspaceItem } from "../../provider-workspace/catalog"; +import { matchingWorkspacePreset, type CatalogPreset } from "../provider-catalog/provider-presets"; + +function webLink(value?: string): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value); + return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password + ? value : undefined; + } catch { return undefined; } +} + +/** Presentation only: sponsorship never changes routing or account state. */ +export default function ProviderSponsor({ item, preset }: { item: WorkspaceItem; preset?: CatalogPreset }) { + const t = useT(); + if (!preset?.sponsor || !matchingWorkspacePreset(item, [preset])) return null; + const brand = preset.id === "orcarouter" || preset.id === "orcarouter-oauth" + ? "OrcaRouter" : preset.id === "packycode" ? "PackyCode" : undefined; + if (!brand) return null; + const orca = brand === "OrcaRouter"; + const visit = webLink(preset.sponsorUrl); + const dashboard = webLink(preset.dashboardUrl); + + return <section className="pws-sponsor" aria-label={`${brand} · ${t("modal.badge.sponsor")}`}> + <div className="pws-sponsor-copy"> + <div className="pws-sponsor-byline"> + <span>{brand}</span> + <span className="pws-sponsor-badge">{t("modal.badge.sponsor")}</span> + </div> + <h3>{t(orca ? "pws.sponsor.orcaTitle" : "pws.sponsor.packyTitle")}</h3> + <p>{t(orca ? "pws.sponsor.orcaDescription" : "pws.sponsor.packyDescription")}</p> + </div> + {(visit || dashboard) && <div className="pws-sponsor-actions"> + {visit && <a className="btn btn-primary" href={visit} target="_blank" rel="noopener noreferrer"> + {t("pws.sponsor.visit", { provider: brand })}<IconExternal width={14} height={14} aria-hidden="true" /> + </a>} + {dashboard && dashboard !== visit && <a className="pws-sponsor-console" href={dashboard} target="_blank" rel="noopener noreferrer"> + {t("pws.sponsor.console")}<IconExternal width={13} height={13} aria-hidden="true" /> + </a>} + </div>} + </section>; +} diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx index d1fb018f3c..19108e04cd 100644 --- a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +++ b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx @@ -3,16 +3,16 @@ * * This panel used to sit on the Dashboard, which is otherwise a read-only status page — the * one place you could change something was also the first thing a new user saw. It reads - * better next to the roster it affects: the roster picks who may be called, while this panel - * holds guidance preferences and the native omitted-model synchronization setting. + * better next to the roster it affects: the roster picks who may be called, this picks who + * gets called first. */ import { useLayoutEffect, useRef, useState } from "react"; -import { Select, Switch, Tooltip } from "../../ui"; +import { Select, Tooltip } from "../../ui"; import { IconArrowDown, IconArrowUp, IconInfo, IconX } from "../../icons"; import { useT, type TKey } from "../../i18n/shared"; import { formatNamespacedModelId } from "../../provider-icons"; -import type { DelegationPatch, DelegationModelOption, NativeDefaultState } from "../../pages/use-subagent-delegation"; -import type { UltraModePatch, UltraModeState, V2NativeParentOverrideState, AgentTaskRecoveryState, V2RoutedDelegationBridgeState } from "../../pages/use-subagent-delegation"; +import type { DelegationPatch, DelegationModelOption } from "../../pages/use-subagent-delegation"; +import type { UltraModePatch, UltraModeState } from "../../pages/use-subagent-delegation"; export interface SubagentDelegationSectionProps { model: string; @@ -21,28 +21,13 @@ export interface SubagentDelegationSectionProps { available: DelegationModelOption[]; guidanceEnabled: boolean; syncCodexDefaults: boolean; - nativeDefaultState?: NativeDefaultState; saving: boolean; onSave: (patch: DelegationPatch) => void; - prompt: string; - childInstructions: string; - childInstructionsSaving: boolean; - onChildInstructionsSave: (value: string | null) => void; ultraMode: UltraModeState; ultraSaving: boolean; onUltraModeSave: (patch: UltraModePatch) => void; ultraLoadFailed: boolean; onUltraModeRetry: () => void; - keepNativeChatGptOnV1?: boolean; - nativeParentOverride?: V2NativeParentOverrideState; - nativeParentOverrideSaving?: boolean; - onNativeParentOverrideSave?: (state: V2NativeParentOverrideState) => void; - agentTaskRecovery?: AgentTaskRecoveryState; - agentTaskRecoverySaving?: boolean; - onAgentTaskRecoverySave?: (state: AgentTaskRecoveryState) => void; - routedDelegationBridge?: V2RoutedDelegationBridgeState; - routedDelegationBridgeSaving?: boolean; - onRoutedDelegationBridgeSave?: (enabled: boolean) => void; fallback: string[]; fallbackPollMs: number; fallbackBusy: boolean; @@ -59,28 +44,13 @@ export default function SubagentDelegationSection({ available, guidanceEnabled, syncCodexDefaults, - nativeDefaultState = "disabled", saving, onSave, - prompt, - childInstructions, - childInstructionsSaving, - onChildInstructionsSave, ultraMode, ultraSaving, onUltraModeSave, ultraLoadFailed, onUltraModeRetry, - keepNativeChatGptOnV1 = false, - nativeParentOverride = { enabled: false, model: null, active: false }, - nativeParentOverrideSaving = false, - onNativeParentOverrideSave = () => {}, - agentTaskRecovery = { enabled: false, model: null }, - agentTaskRecoverySaving = false, - onAgentTaskRecoverySave = () => {}, - routedDelegationBridge = { enabled: false }, - routedDelegationBridgeSaving = false, - onRoutedDelegationBridgeSave = () => {}, fallback, fallbackPollMs, fallbackBusy, availableModels, onFallbackChange, onFallbackPollMsChange, onFallbackSave, }: SubagentDelegationSectionProps) { const t = useT(); @@ -88,16 +58,6 @@ export default function SubagentDelegationSection({ // Proactive message, so it must render as OFF (and the toggle can install the // preset). Only a nonblank hint is "on". const ultraOn = (ultraMode.hintText ?? "").trim().length > 0; - const safeNativeDefaultState = nativeDefaultState === "active" - || nativeDefaultState === "pending" - || nativeDefaultState === "blocked" - ? nativeDefaultState - : "disabled"; - const nativeParentTargets = available.filter(option => option.canonical !== true); - const nativeRecoveryTargets = available.filter(option => ( - option.provider === "openai" && option.namespaced === option.model - )); - const nativeParentCanActivate = ultraMode.multiAgentV2Enabled && !keepNativeChatGptOnV1 && nativeParentOverride.model !== null; const routedPreferred = available.some(option => option.namespaced === model && !(option.provider === "openai" && option.namespaced === option.model)); const nativeMayUseV2 = ultraMode.enabled || (ultraMode.multiAgentMode !== "v1" @@ -166,22 +126,6 @@ export default function SubagentDelegationSection({ </button> </div> )} - <div className="swi-delegation-row"> - <div className="setting-copy"> - <div className="font-semibold">{t("sub.routedDelegationBridge")}</div> - <div className="muted setting-hint">{t("sub.routedDelegationBridgeHint")}</div> - </div> - <Switch - on={routedDelegationBridge.enabled} - onClick={() => onRoutedDelegationBridgeSave(!routedDelegationBridge.enabled)} - disabled={routedDelegationBridgeSaving} - label={t("sub.routedDelegationBridge")} - /> - {!ultraMode.multiAgentV2Enabled && routedDelegationBridge.enabled && ( - <div className="muted setting-hint">{t("sub.routedDelegationBridgeInactive")}</div> - )} - </div> - <div className="swi-delegation-row"> <div className="setting-copy"> <div className="font-semibold">{t("sub.delegation.model")}</div> @@ -271,7 +215,6 @@ export default function SubagentDelegationSection({ <div className="setting-copy"> <div className="font-semibold">{t("dash.syncCodexSubagentDefaults")}</div> <div className="muted setting-hint">{t("dash.syncCodexSubagentDefaultsHint")}</div> - <div className="muted setting-hint">{t(`sub.nativeDefaultState.${safeNativeDefaultState}`)}</div> </div> <button type="button" @@ -356,10 +299,15 @@ export default function SubagentDelegationSection({ <button type="button" className={`switch ${ultraOn ? "on" : ""}`} - onClick={() => onUltraModeSave({ multiAgentModeHintText: ultraOn ? null : ULTRA_MODE_PRESET })} + onClick={() => { + if (ultraOn) onUltraModeSave({ multiAgentModeHintText: null }); + else if (ultraMode.recommendation) { + onUltraModeSave({ multiAgentModeHintText: ultraMode.recommendation.text }); + } + }} // Turning OFF (clear) is always safe, even when v2 is disabled — a stale // hint would otherwise silently re-activate on the next v2 enable. - disabled={saving || ultraSaving || (!ultraOn && !ultraMode.multiAgentV2Enabled)} + disabled={saving || ultraSaving || (!ultraOn && (!ultraMode.multiAgentV2Enabled || !ultraMode.recommendation))} aria-label={t("sub.ultraMode")} aria-pressed={ultraOn} > @@ -376,7 +324,7 @@ export default function SubagentDelegationSection({ initialHint={ultraMode.hintText ?? ""} disabled={saving || ultraSaving} onSave={onUltraModeSave} - preset={ULTRA_MODE_PRESET} + preset={ultraMode.recommendation?.text ?? null} labels={{ text: t("sub.ultraModeText"), preset: t("sub.ultraModePreset"), @@ -385,126 +333,6 @@ export default function SubagentDelegationSection({ /> </div> )} - - <div className="swi-delegation-row"> - <div className="setting-copy"> - <div className="font-semibold">{t("sub.nativeParentOverride")}</div> - <div className="muted setting-hint">{t("sub.nativeParentOverrideHint")}</div> - <div className="muted setting-hint">{t("sub.nativeParentOverridePrivacyWarning")}</div> - </div> - <div className="swi-delegation-controls"> - <Select - value={nativeParentOverride.model ?? ""} - options={[ - { value: "", label: t("dash.injectionNone") }, - ...nativeParentTargets.map(option => ({ - value: option.namespaced, - label: formatNamespacedModelId(`${option.provider}/${option.model}`, t), - })), - ]} - onChange={value => onNativeParentOverrideSave({ - enabled: value ? nativeParentOverride.enabled : false, - model: value || null, - active: nativeParentOverride.active, - })} - disabled={nativeParentOverrideSaving} - label={t("sub.nativeParentOverrideModel")} - align="right" - /> - <Switch - on={nativeParentOverride.enabled} - onClick={() => onNativeParentOverrideSave({ - enabled: !nativeParentOverride.enabled, - model: nativeParentOverride.model, - active: nativeParentOverride.active, - })} - disabled={nativeParentOverrideSaving || (!nativeParentOverride.enabled && !nativeParentCanActivate)} - label={t("sub.nativeParentOverride")} - /> - </div> - {!nativeParentCanActivate && !nativeParentOverride.active && ( - <div className="muted setting-hint">{t("sub.nativeParentOverrideV2Required")}</div> - )} - </div> - - <div className="swi-delegation-row"> - <div className="setting-copy"> - <div className="font-semibold">{t("sub.agentTaskRecovery")}</div> - <div className="muted setting-hint">{t("sub.agentTaskRecoveryHint")}</div> - </div> - <div className="swi-delegation-controls"> - <Select - value={agentTaskRecovery.model ?? ""} - options={[ - { value: "", label: t("sub.agentTaskRecoveryDefault") }, - // Recovery unwraps ciphertext through the ChatGPT backend, so only - // native catalog rows are valid targets. A saved value that no longer - // resolves in the catalog still displays instead of being discarded. - ...(agentTaskRecovery.model - && !nativeRecoveryTargets.some(option => option.model === agentTaskRecovery.model) - ? [{ value: agentTaskRecovery.model, label: agentTaskRecovery.model }] - : []), - ...nativeRecoveryTargets.map(option => ({ - value: option.model, - label: formatNamespacedModelId(option.provider + "/" + option.model, t), - })), - ]} - onChange={v => onAgentTaskRecoverySave({ - enabled: agentTaskRecovery.enabled, - model: v || null, - })} - disabled={agentTaskRecoverySaving} - label={t("sub.agentTaskRecoveryModel")} - align="right" - /> - <Switch - on={agentTaskRecovery.enabled} - onClick={() => onAgentTaskRecoverySave({ - enabled: !agentTaskRecovery.enabled, - model: agentTaskRecovery.model, - })} - disabled={agentTaskRecoverySaving} - label={t("sub.agentTaskRecovery")} - /> - </div> - </div> - - <div className="swi-delegation-row swi-prompt-editor"> - <div className="setting-copy"> - <div className="font-semibold">{t("sub.injectionPrompt")}</div> - <div className="muted setting-hint"> - {t("sub.injectionPromptHint")}{" "} - <code>{"{{model}}"}</code>{" "} - <code>{"{{effort}}"}</code>{" "} - <code>{"{{roster}}"}</code>{" "} - <code>{"{{fallback}}"}</code>{" "} - <code>{"{{roles}}"}</code> - </div> - </div> - <PromptDraftEditor - key={`prompt:${prompt}`} - initialValue={prompt} - disabled={saving} - ariaLabel={t("sub.injectionPrompt")} - saveLabel={t("sub.injectionPromptSave")} - onSave={value => onSave({ prompt: value.trim() ? value : null })} - /> - </div> - - <div className="swi-delegation-row swi-prompt-editor"> - <div className="setting-copy"> - <div className="font-semibold">{t("sub.childInstructions")}</div> - <div className="muted setting-hint">{t("sub.childInstructionsHint")}</div> - </div> - <PromptDraftEditor - key={`child:${childInstructions}`} - initialValue={childInstructions} - disabled={saving || childInstructionsSaving} - ariaLabel={t("sub.childInstructions")} - saveLabel={t("sub.childInstructionsSave")} - onSave={value => onChildInstructionsSave(value.trim() ? value : null)} - /> - </div> </details> </div> ); @@ -525,7 +353,7 @@ function UltraModeEditor({ initialHint: string; disabled: boolean; onSave: (patch: UltraModePatch) => void; - preset: string; + preset: string | null; labels: { text: string; preset: string; save: string }; }) { const [draft, setDraft] = useState(initialHint); @@ -546,8 +374,8 @@ function UltraModeEditor({ <button type="button" className="btn btn-ghost btn-sm" - onClick={() => setDraft(preset)} - disabled={disabled} + onClick={() => { if (preset !== null) setDraft(preset); }} + disabled={disabled || preset === null} > {labels.preset} </button> @@ -562,44 +390,3 @@ function UltraModeEditor({ </> ); } - -/** Canonical Proactive delegation text mirrored from codex-rs (multi_agent_mode_instructions.rs). */ -export const ULTRA_MODE_PRESET = - "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it."; - -function PromptDraftEditor({ - initialValue, - disabled, - ariaLabel, - saveLabel, - onSave, -}: { - initialValue: string; - disabled: boolean; - ariaLabel: string; - saveLabel: string; - onSave: (value: string) => void; -}) { - const [draft, setDraft] = useState(initialValue); - return ( - <div className="swi-prompt-draft"> - <textarea - className="input swi-ultra-mode-textarea" - value={draft} - onChange={e => setDraft(e.target.value)} - disabled={disabled} - rows={4} - aria-label={ariaLabel} - /> - <button - type="button" - className="btn btn-primary btn-sm" - onClick={() => onSave(draft)} - disabled={disabled} - aria-label={saveLabel} - > - {saveLabel} - </button> - </div> - ); -} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8fd2d0e086..fc865be3dc 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -680,14 +680,14 @@ export const de: Record<TKey, string> = { "sub.workspace.selectModel": "Modell auswählen", "sub.workspace.selectModelDesc": "Wählen Sie ein Modell aus der Liste, um Details anzuzeigen und es für spawn_agent hervorzuheben.", "sub.workspace.selector": "Öffentlicher Selektor", - "sub.ultraMode": "Ultra-Modus", + "sub.ultraMode": "Immer proaktiv delegieren", "sub.ultraModeHint": "Aktiviert die proaktive Multi-Agent-Delegierungsrichtlinie für alle Modelle und Reasoning-Efforts (ändert den Reasoning-Effort selbst nicht). Schreibt features.multi_agent_v2.multi_agent_mode_hint_text in config.toml.", "sub.ultraModeV2Required": "Erfordert die v2-Multi-Agent-Oberfläche — aktivieren Sie zuerst multi_agent_v2 und wählen Sie v2 in der Subagentenmodus-Steuerung.", - "sub.ultraModeText": "Delegierungstext des Ultra-Modus", + "sub.ultraModeText": "Text für proaktive Delegierung", "sub.ultraModePreset": "Voreinstellung wiederherstellen", - "sub.ultraModeLoadFail": "Ultra-Modus-Einstellungen konnten nicht geladen werden — läuft der Proxy?", - "sub.ultraModeSaveFail": "Ultra-Modus-Einstellungen konnten nicht gespeichert werden", - "sub.ultraModeSaved": "Ultra-Modus gespeichert. Gilt für neue Codex-Sitzungen.", + "sub.ultraModeLoadFail": "Einstellungen für proaktive Delegierung konnten nicht geladen werden — läuft der Proxy?", + "sub.ultraModeSaveFail": "Einstellungen für proaktive Delegierung konnten nicht gespeichert werden", + "sub.ultraModeSaved": "Proaktive Delegierung gespeichert. Gilt für neue Codex-Sitzungen.", "logs.title": "Anfrage-Protokolle", "logs.tabLogs": "Protokolle", "logs.tabDebug": "Diagnose", @@ -1778,6 +1778,7 @@ export const de: Record<TKey, string> = { "storage.cleanup.confirmPermanent": "Dauerhaft löschen", "storage.cleanup.doneQuarantine": "{count} Datei(en) in Quarantäne ({size}).", "storage.cleanup.donePermanent": "{count} Datei(en) dauerhaft gelöscht ({size}).", + "storage.cleanup.skippedReferenced": "{count} referenzierte Datei(en) übersprungen.", "storage.cleanup.previewFailed": "Vorschau fehlgeschlagen.", "storage.cleanup.cleanupFailed": "Bereinigung fehlgeschlagen.", "storage.cleanup.err.codex_busy": "Codex verwendet state.sqlite — beende Codex und versuche es erneut.", @@ -1876,6 +1877,13 @@ export const de: Record<TKey, string> = { "modal.badge.direct": "Direct", "modal.badge.pool": "Pool", "modal.badge.free": "Kostenlos", + "modal.badge.sponsor": "Sponsor", + "pws.sponsor.orcaTitle": "Das passende Modell für jede Anfrage", + "pws.sponsor.orcaDescription": "Ein OpenAI-kompatibles Gateway mit adaptivem Routing und automatischem Failover.", + "pws.sponsor.packyTitle": "Claude Code, Codex und Gemini an einem Ort", + "pws.sponsor.packyDescription": "Ein API-Relay für Ihre KI-Programmierwerkzeuge. Starten Sie mit einem Token der Codex-Gruppe.", + "pws.sponsor.visit": "{provider} entdecken", + "pws.sponsor.console": "Konsole öffnen", "modal.invalidPreset": "Diese integrierte Anbietervorlage ist unvollständig. Starten Sie den Proxy neu und versuchen Sie es erneut.", "modal.freeTierTitle": "Kostenloser Tarif", "modal.freeTierDefault": "Kein API-Schlüssel nötig. Funktioniert sofort.", @@ -2486,6 +2494,8 @@ export const de: Record<TKey, string> = { "dash.visionAdvancedPopover": "Erweiterte Vision-Einstellungen", "dash.codexDesktopAuthless": "Codex ohne Anmeldung öffnen", "dash.codexDesktopAuthlessHint": "Standardmäßig aus. Überspringt die separate Desktop-Anmeldung bei geeigneten lokalen Verbindungen. Zugangsdaten für den Anbieter bleiben erforderlich. Codex nach einer Änderung neu starten. Kontogebundene Desktop-Funktionen können fehlen.", + "dash.codexClientCompaction": "Clientseitige Komprimierung verwenden", + "dash.codexClientCompactionHint": "Standardmäßig aus; nur für authentifiziertes Loopback-Routing. Künftige Komprimierungen speichern portable Klartext-Zusammenfassungen, während das OpenCodeX-Provider-Routing und die V2-Subagent-Zustellung aktiv bleiben; der konfigurierte Anbieter kann sie verarbeiten und Kontingent verbrauchen. Vorhandene ocx1-Verläufe müssen weiterhin wiederhergestellt werden. Codex nach einer Änderung neu starten.", "models.newPolicyGlobal": "Neue Modelle zunächst deaktivieren", "models.newPolicyProvider": "Richtlinie für neue Modelle", "models.newPolicy_inherit": "Übernehmen", "models.newPolicy_off": "Aus", "models.newPolicy_on": "An", "models.newBadge": "NEU", "models.newCount": "{count} neu, aus", "models.aliases": "Aliase", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 91127509b3..8354fe53cb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -580,6 +580,8 @@ export const en = { "dash.multiAgent": "Sub-agent", "dash.codexDesktopAuthless": "Open Codex without signing in", "dash.codexDesktopAuthlessHint": "Off by default. Skip the separate Desktop sign-in for eligible local connections. Upstream credentials are still required. Restart Codex after changing this setting. Account-gated Desktop features may be unavailable.", + "dash.codexClientCompaction": "Use client-side compaction", + "dash.codexClientCompactionHint": "Off by default; authenticated loopback only. Future compactions store portable plaintext summaries while OpenCodeX and V2 provider routing stay active; the configured provider may process them and consume quota. History is left untouched, and existing threads keep routing through the proxy via the openai_base_url override OpenCodeX manages; if you set that line yourself it is kept, and those threads follow your destination instead. Existing ocx1 history stays recoverable; recover a thread separately only before replaying it in native Codex. Restart Codex after changing this setting.", "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", "models.v2Applied": "Sub-agent mode updated — applies to new sessions (restart the Codex app to refresh the picker)", "models.v2ThreadsLabel": "Max threads", @@ -710,14 +712,14 @@ export const en = { "sub.workspace.mainAria": "Subagent model details", "sub.workspace.notFeatured": "Not featured", "sub.workspace.priority": "Priority", - "sub.ultraMode": "Ultra mode", + "sub.ultraMode": "Always proactive delegation", "sub.ultraModeHint": "Enable the Proactive multi-agent delegation policy for every model and reasoning effort (does not change reasoning effort itself). Writes features.multi_agent_v2.multi_agent_mode_hint_text in config.toml.", "sub.ultraModeV2Required": "Requires the v2 multi-agent surface — enable multi_agent_v2 and select v2 in the Sub-agent mode control first.", - "sub.ultraModeText": "Ultra mode delegation text", + "sub.ultraModeText": "Proactive delegation text", "sub.ultraModePreset": "Restore preset", - "sub.ultraModeLoadFail": "Failed to load Ultra mode settings — is the proxy running?", - "sub.ultraModeSaveFail": "Failed to save Ultra mode settings", - "sub.ultraModeSaved": "Ultra mode saved. Applies to new Codex sessions.", + "sub.ultraModeLoadFail": "Failed to load proactive delegation settings — is the proxy running?", + "sub.ultraModeSaveFail": "Failed to save proactive delegation settings", + "sub.ultraModeSaved": "Proactive delegation saved. Applies to new Codex sessions.", "sub.workspace.removeFromFeatured": "Remove {m} from featured", "sub.workspace.selectModel": "Select a model", "sub.workspace.selectModelDesc": "Pick a model from the list to see details and feature it for spawn_agent.", @@ -1011,6 +1013,7 @@ export const en = { "storage.cleanup.confirmPermanent": "Delete permanently", "storage.cleanup.doneQuarantine": "Quarantined {count} file(s) ({size}).", "storage.cleanup.donePermanent": "Permanently deleted {count} file(s) ({size}).", + "storage.cleanup.skippedReferenced": "Skipped {count} referenced file(s).", "storage.cleanup.previewFailed": "Preview failed.", "storage.cleanup.cleanupFailed": "Cleanup failed.", "storage.cleanup.err.codex_busy": "Codex is using state.sqlite — try again after quitting Codex.", @@ -1133,6 +1136,13 @@ export const en = { "modal.badge.direct": "Direct", "modal.badge.pool": "Pool", "modal.badge.free": "Free", + "modal.badge.sponsor": "Sponsor", + "pws.sponsor.orcaTitle": "A model for every prompt", + "pws.sponsor.orcaDescription": "An OpenAI-compatible gateway with adaptive routing and automatic failover.", + "pws.sponsor.packyTitle": "Claude Code, Codex and Gemini in one place", + "pws.sponsor.packyDescription": "An API relay for your AI coding tools. Start with a Codex-group token.", + "pws.sponsor.visit": "Explore {provider}", + "pws.sponsor.console": "Open console", "modal.invalidPreset": "This built-in provider preset is incomplete. Restart the proxy and try again.", "modal.freeTierTitle": "Free tier", "modal.freeTierDefault": "No API key required. Works out of the box.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 0f97431448..44429e1e21 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -565,6 +565,8 @@ export const fr: Record<TKey, string> = { "dash.multiAgent": "Sous-agent", "dash.codexDesktopAuthless": "Ouvrir Codex sans se connecter", "dash.codexDesktopAuthlessHint": "Désactivé par défaut. Ignore la connexion Desktop séparée pour les connexions locales admissibles. Les identifiants du fournisseur restent nécessaires. Redémarrez Codex après toute modification. Certaines fonctions Desktop liées au compte peuvent être indisponibles.", + "dash.codexClientCompaction": "Utiliser la compaction côté client", + "dash.codexClientCompactionHint": "Désactivé par défaut, uniquement pour le routage loopback authentifié. Les compactages futurs stockent des résumés portables en texte clair tout en conservant le routage OpenCodeX/V2 ; le fournisseur configuré peut les traiter et consommer son quota. L'historique ocx1 existant doit toujours être restauré. Redémarrez Codex après modification.", "models.v2Conflict": "[agents] max_threads est défini — codex refusera de démarrer ; supprimez-le de config.toml", "models.v2Applied": "Mode sous-agent mis à jour — s’applique aux nouvelles sessions (redémarrez l’application Codex pour actualiser le sélecteur)", "models.v2ThreadsLabel": "Nombre maximal de fils", @@ -693,14 +695,14 @@ export const fr: Record<TKey, string> = { "sub.workspace.mainAria": "Détails du modèle de sous-agent", "sub.workspace.notFeatured": "Non mis à la une", "sub.workspace.priority": "Priorité", - "sub.ultraMode": "Mode Ultra", + "sub.ultraMode": "Délégation toujours proactive", "sub.ultraModeHint": "Activer la politique de délégation multi-agent proactive pour tous les modèles et niveaux de raisonnement (sans modifier le niveau de raisonnement lui-même). Écrit features.multi_agent_v2.multi_agent_mode_hint_text dans config.toml.", "sub.ultraModeV2Required": "Nécessite l’interface multi-agent v2 — activez multi_agent_v2 et sélectionnez d’abord v2 dans le contrôle du mode Sous-agent.", - "sub.ultraModeText": "Texte de délégation du mode Ultra", + "sub.ultraModeText": "Texte de délégation proactive", "sub.ultraModePreset": "Rétablir le préréglage", - "sub.ultraModeLoadFail": "Échec du chargement des paramètres du mode Ultra — le proxy est-il en cours d’exécution ?", - "sub.ultraModeSaveFail": "Échec de l’enregistrement des paramètres du mode Ultra", - "sub.ultraModeSaved": "Mode Ultra enregistré. S’applique aux nouvelles sessions Codex.", + "sub.ultraModeLoadFail": "Échec du chargement des paramètres de délégation proactive — le proxy est-il en cours d’exécution ?", + "sub.ultraModeSaveFail": "Échec de l’enregistrement des paramètres de délégation proactive", + "sub.ultraModeSaved": "Délégation proactive enregistrée. S’applique aux nouvelles sessions Codex.", "sub.workspace.removeFromFeatured": "Retirer {m} de la sélection À la une", "sub.workspace.selectModel": "Sélectionner un modèle", "sub.workspace.selectModelDesc": "Choisissez un modèle dans la liste pour afficher ses détails et le mettre à la une pour spawn_agent.", @@ -987,6 +989,7 @@ export const fr: Record<TKey, string> = { "storage.cleanup.confirmPermanent": "Supprimer définitivement", "storage.cleanup.doneQuarantine": "{count} fichier(s) mis en quarantaine ({size}).", "storage.cleanup.donePermanent": "{count} fichier(s) supprimé(s) définitivement ({size}).", + "storage.cleanup.skippedReferenced": "{count} fichier(s) référencé(s) ignoré(s).", "storage.cleanup.previewFailed": "Échec de l’aperçu.", "storage.cleanup.cleanupFailed": "Échec du nettoyage.", "storage.cleanup.err.codex_busy": "Codex utilise state.sqlite — réessayez après avoir quitté Codex.", @@ -1106,6 +1109,13 @@ export const fr: Record<TKey, string> = { "modal.badge.direct": "Direct", "modal.badge.pool": "Groupe", "modal.badge.free": "Gratuit", + "modal.badge.sponsor": "Partenaire sponsor", + "pws.sponsor.orcaTitle": "Un modèle adapté à chaque requête", + "pws.sponsor.orcaDescription": "Une passerelle compatible OpenAI avec routage adaptatif et basculement automatique.", + "pws.sponsor.packyTitle": "Claude Code, Codex et Gemini au même endroit", + "pws.sponsor.packyDescription": "Un relais API pour vos outils de développement IA. Commencez avec un jeton du groupe Codex.", + "pws.sponsor.visit": "Découvrir {provider}", + "pws.sponsor.console": "Ouvrir la console", "modal.invalidPreset": "Ce préréglage de fournisseur intégré est incomplet. Redémarrez le proxy et réessayez.", "modal.freeTierTitle": "Offre gratuite", "modal.freeTierDefault": "Aucune clé API requise. Fonctionne immédiatement.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4fdd2947b7..6251088e09 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -641,14 +641,14 @@ export const ja: Record<TKey, string> = { "sub.workspace.selectModel": "モデルを選択", "sub.workspace.selectModelDesc": "一覧からモデルを選んで詳細を確認し、spawn_agent のおすすめに設定します。", "sub.workspace.selector": "公開セレクター", - "sub.ultraMode": "ウルトラモード", + "sub.ultraMode": "常時プロアクティブ委任", "sub.ultraModeHint": "すべてのモデルと reasoning effort で Proactive マルチエージェント委任ポリシーを有効にします(reasoning effort 自体は変更しません)。config.toml に features.multi_agent_v2.multi_agent_mode_hint_text を書き込みます。", "sub.ultraModeV2Required": "v2 マルチエージェントサーフェスが必要です — 先に multi_agent_v2 を有効にし、サブエージェントモードで v2 を選択してください。", - "sub.ultraModeText": "ウルトラモード委任テキスト", + "sub.ultraModeText": "プロアクティブ委任テキスト", "sub.ultraModePreset": "プリセットを復元", - "sub.ultraModeLoadFail": "ウルトラモード設定を読み込めませんでした — プロキシは実行中ですか?", - "sub.ultraModeSaveFail": "ウルトラモード設定の保存に失敗しました", - "sub.ultraModeSaved": "ウルトラモードを保存しました。新しい Codex セッションから適用されます。", + "sub.ultraModeLoadFail": "プロアクティブ委任設定を読み込めませんでした — プロキシは実行中ですか?", + "sub.ultraModeSaveFail": "プロアクティブ委任設定の保存に失敗しました", + "sub.ultraModeSaved": "プロアクティブ委任設定を保存しました。新しい Codex セッションから適用されます。", // logs "logs.title": "リクエストログ", @@ -926,6 +926,7 @@ export const ja: Record<TKey, string> = { "storage.cleanup.confirmPermanent": "完全に削除", "storage.cleanup.doneQuarantine": "{count} 件を隔離しました({size})。", "storage.cleanup.donePermanent": "{count} 件を完全削除しました({size})。", + "storage.cleanup.skippedReferenced": "参照されている {count} 件をスキップしました。", "storage.cleanup.previewFailed": "プレビューに失敗しました。", "storage.cleanup.cleanupFailed": "クリーンアップに失敗しました。", "storage.cleanup.err.codex_busy": "Codex が state.sqlite を使用中です — Codex を終了して再試行してください。", @@ -1048,6 +1049,13 @@ export const ja: Record<TKey, string> = { "modal.badge.direct": "ダイレクト", "modal.badge.pool": "プール", "modal.badge.free": "無料", + "modal.badge.sponsor": "スポンサー", + "pws.sponsor.orcaTitle": "プロンプトに合うモデルを一か所で", + "pws.sponsor.orcaDescription": "自動ルーティングとフェイルオーバーに対応したOpenAI互換ゲートウェイです。", + "pws.sponsor.packyTitle": "Claude Code、Codex、Geminiを一か所で", + "pws.sponsor.packyDescription": "AIコーディングツール向けAPIリレーです。Codexグループのトークンで始められます。", + "pws.sponsor.visit": "{provider}を見る", + "pws.sponsor.console": "コンソールを開く", "modal.invalidPreset": "この組み込みプロバイダープリセットは不完全です。プロキシを再起動してもう一度お試しください。", "modal.freeTierTitle": "無料枠", "modal.freeTierDefault": "API キー不要です。そのまま利用できます。", @@ -2507,6 +2515,8 @@ export const ja: Record<TKey, string> = { "dash.visionAdvancedPopover": "詳細なビジョン設定", "dash.codexDesktopAuthless": "ログインせずに Codex を開く", "dash.codexDesktopAuthlessHint": "既定ではオフです。対象のローカル接続で Desktop の個別ログインを省略します。上流プロバイダーの認証情報は引き続き必要です。変更後は Codex を再起動してください。アカウントに依存する Desktop 機能が利用できない場合があります。", + "dash.codexClientCompaction": "クライアント側コンパクションを使用", + "dash.codexClientCompactionHint": "既定ではオフで、認証済みループバックルーティング専用です。今後のコンパクションは、OpenCodeX と V2 プロバイダーのルーティングを維持したまま移植可能な平文要約を保存します。設定済みプロバイダーが要約を処理し、割り当てを消費する場合があります。既存の ocx1 履歴は別途復旧が必要です。変更後は Codex を再起動してください。", "models.newPolicyGlobal": "新しいモデルを無効で追加", "models.newPolicyProvider": "新しいモデルのポリシー", "models.newPolicy_inherit": "継承", "models.newPolicy_off": "オフ", "models.newPolicy_on": "オン", "models.newBadge": "新着", "models.newCount": "新着 {count} 件、オフ", "models.aliases": "エイリアス", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 73f9f70d21..59f08fd7a3 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -697,14 +697,14 @@ export const ko: Record<TKey, string> = { "sub.workspace.selectModel": "모델 선택", "sub.workspace.selectModelDesc": "목록에서 모델을 선택하여 세부 정보를 확인하고 spawn_agent에 추천하세요.", "sub.workspace.selector": "공개 셀렉터", - "sub.ultraMode": "울트라 모드", + "sub.ultraMode": "항상 능동 위임", "sub.ultraModeHint": "모든 모델과 reasoning effort에서 Proactive 멀티에이전트 위임 정책을 켭니다 (reasoning effort 자체는 변경하지 않음). config.toml에 features.multi_agent_v2.multi_agent_mode_hint_text를 기록합니다.", "sub.ultraModeV2Required": "v2 멀티에이전트 서피스가 필요합니다 — 먼저 multi_agent_v2를 켜고 서브에이전트 모드에서 v2를 선택하세요.", - "sub.ultraModeText": "울트라 모드 위임 텍스트", + "sub.ultraModeText": "능동 위임 텍스트", "sub.ultraModePreset": "프리셋 복원", - "sub.ultraModeLoadFail": "울트라 모드 설정을 불러오지 못했습니다 — 프록시가 실행 중인가요?", - "sub.ultraModeSaveFail": "울트라 모드 설정 저장에 실패했습니다", - "sub.ultraModeSaved": "울트라 모드가 저장되었습니다. 새 Codex 세션부터 적용됩니다.", + "sub.ultraModeLoadFail": "능동 위임 설정을 불러오지 못했습니다 — 프록시가 실행 중인가요?", + "sub.ultraModeSaveFail": "능동 위임 설정 저장에 실패했습니다", + "sub.ultraModeSaved": "능동 위임 설정이 저장되었습니다. 새 Codex 세션부터 적용됩니다.", "sub.fallbackLabel": "서브에이전트 폴백 체인", "sub.fallbackHint": "서브에이전트 모델을 사용할 수 없거나 실패할 때 순서대로 시도할 모델입니다.", "sub.fallbackAdd": "폴백 모델 추가…", @@ -1817,6 +1817,7 @@ export const ko: Record<TKey, string> = { "storage.cleanup.confirmPermanent": "영구 삭제", "storage.cleanup.doneQuarantine": "파일 {count}개를 격리했습니다({size}).", "storage.cleanup.donePermanent": "파일 {count}개를 영구 삭제했습니다({size}).", + "storage.cleanup.skippedReferenced": "참조된 파일 {count}개를 건너뛰었습니다.", "storage.cleanup.previewFailed": "미리보기에 실패했습니다.", "storage.cleanup.cleanupFailed": "정리에 실패했습니다.", "storage.cleanup.err.codex_busy": "Codex가 state.sqlite를 사용 중입니다 — Codex를 종료한 뒤 다시 시도하세요.", @@ -1915,6 +1916,13 @@ export const ko: Record<TKey, string> = { "modal.badge.direct": "Direct", "modal.badge.pool": "풀", "modal.badge.free": "무료", + "modal.badge.sponsor": "스폰서", + "pws.sponsor.orcaTitle": "요청에 맞는 모델을 한곳에서", + "pws.sponsor.orcaDescription": "자동 라우팅과 대체 모델 연결을 지원하는 OpenAI 호환 게이트웨이입니다.", + "pws.sponsor.packyTitle": "Claude Code, Codex, Gemini를 한곳에서", + "pws.sponsor.packyDescription": "여러 AI 코딩 도구에 연결하는 API 릴레이입니다. Codex 그룹 토큰으로 시작하세요.", + "pws.sponsor.visit": "{provider} 살펴보기", + "pws.sponsor.console": "콘솔 열기", "modal.invalidPreset": "내장 프로바이더 설정이 완전하지 않습니다. 프록시를 다시 시작한 뒤 재시도하세요.", "modal.freeTierTitle": "무료 티어", "modal.freeTierDefault": "API 키가 필요 없습니다. 바로 사용할 수 있습니다.", @@ -2508,6 +2516,8 @@ export const ko: Record<TKey, string> = { "dash.visionAdvancedPopover": "고급 비전 설정", "dash.codexDesktopAuthless": "로그인 없이 Codex 열기", "dash.codexDesktopAuthlessHint": "기본값은 꺼짐입니다. 지원되는 로컬 연결에서 별도의 Desktop 로그인을 건너뜁니다. 업스트림 인증 정보는 여전히 필요합니다. 변경 후 Codex를 다시 시작하세요. 계정에 연결된 Desktop 기능을 사용하지 못할 수 있습니다.", + "dash.codexClientCompaction": "클라이언트 측 컴팩션 사용", + "dash.codexClientCompactionHint": "기본값은 꺼짐이며 인증된 루프백 라우팅에만 적용됩니다. 향후 컴팩션은 OpenCodeX 및 V2 제공자 라우팅을 유지하면서 이식 가능한 평문 요약을 저장합니다. 설정된 제공자가 요약을 처리하고 할당량을 사용할 수 있습니다. 기록은 건드리지 않으며, 기존 스레드는 OpenCodeX가 관리하는 openai_base_url override를 통해 프록시 경로를 유지합니다. 그 줄을 직접 설정해 두셨다면 그대로 보존하므로 해당 스레드는 설정하신 목적지를 따릅니다. 기존 ocx1 기록은 그대로 복구할 수 있고, 네이티브 Codex에서 해당 스레드를 재개하기 전에만 별도로 복구하세요. 변경 후 Codex를 다시 시작하세요.", "models.newPolicyGlobal": "새 모델을 비활성화 상태로 추가", "models.newPolicyProvider": "새 모델 정책", "models.newPolicy_inherit": "상속", "models.newPolicy_off": "끔", "models.newPolicy_on": "켬", "models.newBadge": "신규", "models.newCount": "신규 {count}개, 꺼짐", "models.aliases": "별칭", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b6969e0370..6b47ae7a31 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -696,14 +696,14 @@ export const ru: Record<TKey, string> = { "sub.workspace.selectModel": "Выберите модель", "sub.workspace.selectModelDesc": "Выберите модель из списка, чтобы увидеть детали и добавить её в избранные для spawn_agent.", "sub.workspace.selector": "Публичный селектор", - "sub.ultraMode": "Ультра-режим", + "sub.ultraMode": "Всегда проактивное делегирование", "sub.ultraModeHint": "Включает политику упреждающего делегирования мультиагентов для всех моделей и уровней reasoning effort (сам reasoning effort не меняется). Записывает features.multi_agent_v2.multi_agent_mode_hint_text в config.toml.", "sub.ultraModeV2Required": "Требуется мультиагентная поверхность v2 — сначала включите multi_agent_v2 и выберите v2 в переключателе режима субагентов.", - "sub.ultraModeText": "Текст делегирования ультра-режима", + "sub.ultraModeText": "Текст проактивного делегирования", "sub.ultraModePreset": "Восстановить пресет", - "sub.ultraModeLoadFail": "Не удалось загрузить настройки ультра-режима — работает ли прокси?", - "sub.ultraModeSaveFail": "Не удалось сохранить настройки ультра-режима", - "sub.ultraModeSaved": "Ультра-режим сохранён. Применяется к новым сеансам Codex.", + "sub.ultraModeLoadFail": "Не удалось загрузить настройки проактивного делегирования — работает ли прокси?", + "sub.ultraModeSaveFail": "Не удалось сохранить настройки проактивного делегирования", + "sub.ultraModeSaved": "Проактивное делегирование сохранено. Применяется к новым сеансам Codex.", // logs "logs.title": "Журнал запросов", @@ -981,6 +981,7 @@ export const ru: Record<TKey, string> = { "storage.cleanup.confirmPermanent": "Удалить навсегда", "storage.cleanup.doneQuarantine": "В карантин: {count} файл(ов) ({size}).", "storage.cleanup.donePermanent": "Удалено навсегда: {count} файл(ов) ({size}).", + "storage.cleanup.skippedReferenced": "Пропущено файлов, на которые ссылается история: {count}.", "storage.cleanup.previewFailed": "Не удалось выполнить предпросмотр.", "storage.cleanup.cleanupFailed": "Не удалось выполнить очистку.", "storage.cleanup.err.codex_busy": "Codex использует state.sqlite — закройте Codex и повторите попытку.", @@ -1103,6 +1104,13 @@ export const ru: Record<TKey, string> = { "modal.badge.direct": "Прямой", "modal.badge.pool": "Пул", "modal.badge.free": "Бесплатно", + "modal.badge.sponsor": "Спонсор", + "pws.sponsor.orcaTitle": "Подходящая модель для каждого запроса", + "pws.sponsor.orcaDescription": "Совместимый с OpenAI шлюз с адаптивной маршрутизацией и автоматическим переключением.", + "pws.sponsor.packyTitle": "Claude Code, Codex и Gemini в одном месте", + "pws.sponsor.packyDescription": "API-ретранслятор для инструментов ИИ-разработки. Начните с токена группы Codex.", + "pws.sponsor.visit": "Подробнее о {provider}", + "pws.sponsor.console": "Открыть консоль", "modal.invalidPreset": "Этот встроенный пресет провайдера неполный. Перезапустите прокси и попробуйте ещё раз.", "modal.freeTierTitle": "Бесплатный тариф", "modal.freeTierDefault": "API-ключ не нужен. Работает из коробки.", @@ -2509,6 +2517,8 @@ export const ru: Record<TKey, string> = { "dash.visionAdvancedPopover": "Дополнительные настройки изображений", "dash.codexDesktopAuthless": "Открывать Codex без входа", "dash.codexDesktopAuthlessHint": "По умолчанию выключено. Пропускает отдельный вход в Desktop для допустимых локальных подключений. Учётные данные провайдера по-прежнему нужны. После изменения перезапустите Codex. Функции Desktop, связанные с аккаунтом, могут быть недоступны.", + "dash.codexClientCompaction": "Использовать сжатие на стороне клиента", + "dash.codexClientCompactionHint": "По умолчанию выключено; только для аутентифицированной loopback-маршрутизации. Будущие сжатия сохраняют переносимые текстовые сводки, а маршрутизация OpenCodeX и V2 остаётся активной; настроенный провайдер может обрабатывать сводки и расходовать квоту. Существующую историю ocx1 всё равно нужно восстановить. После изменения перезапустите Codex.", "models.newPolicyGlobal": "Добавлять новые модели выключенными", "models.newPolicyProvider": "Политика новых моделей", "models.newPolicy_inherit": "Наследовать", "models.newPolicy_off": "Выкл.", "models.newPolicy_on": "Вкл.", "models.newBadge": "НОВАЯ", "models.newCount": "Новых: {count}, выкл.", "models.aliases": "Псевдонимы", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 3af806d34b..faa68a50c6 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -703,14 +703,14 @@ export const tr: Record<TKey, string> = { "sub.workspace.selectModel": "Bir model seçin", "sub.workspace.selectModelDesc": "Detayları görmek için listeden bir model seçin.", "sub.workspace.selector": "Genel seçici", - "sub.ultraMode": "Ultra modu", + "sub.ultraMode": "Her zaman proaktif delegasyon", "sub.ultraModeHint": "Tüm modeller ve reasoning effort için Proactive çoklu ajan delegasyon politikasını etkinleştirir (reasoning effort değerini değiştirmez). config.toml dosyasına features.multi_agent_v2.multi_agent_mode_hint_text yazar.", "sub.ultraModeV2Required": "v2 çoklu ajan yüzeyi gerekir — önce multi_agent_v2'yi etkinleştirin ve alt ajan modu denetiminde v2'yi seçin.", - "sub.ultraModeText": "Ultra modu delegasyon metni", + "sub.ultraModeText": "Proaktif delegasyon metni", "sub.ultraModePreset": "Ön ayarı geri yükle", - "sub.ultraModeLoadFail": "Ultra modu ayarları yüklenemedi — proxy çalışıyor mu?", - "sub.ultraModeSaveFail": "Ultra modu ayarları kaydedilemedi", - "sub.ultraModeSaved": "Ultra modu kaydedildi. Yeni Codex oturumlarına uygulanır.", + "sub.ultraModeLoadFail": "Proaktif delegasyon ayarları yüklenemedi — proxy çalışıyor mu?", + "sub.ultraModeSaveFail": "Proaktif delegasyon ayarları kaydedilemedi", + "sub.ultraModeSaved": "Proaktif delegasyon kaydedildi. Yeni Codex oturumlarına uygulanır.", "sub.fallbackLabel": "Alt ajan yedek zinciri", "sub.fallbackHint": "Alt ajan modeli kullanılamadığında veya başarısız olduğunda sırayla denenecek modeller.", "sub.fallbackAdd": "Yedek model ekle…", @@ -1000,6 +1000,7 @@ export const tr: Record<TKey, string> = { "storage.cleanup.confirmPermanent": "Kalıcı Olarak Sil", "storage.cleanup.doneQuarantine": "{count} dosya karantinaya alındı ({size}).", "storage.cleanup.donePermanent": "{count} dosya kalıcı olarak silindi ({size}).", + "storage.cleanup.skippedReferenced": "Başvurulan {count} dosya atlandı.", "storage.cleanup.previewFailed": "Önizleme başarısız oldu.", "storage.cleanup.cleanupFailed": "Temizleme başarısız oldu.", "storage.cleanup.err.codex_busy": "Codex state.sqlite dosyasını kullanıyor.", @@ -1122,6 +1123,13 @@ export const tr: Record<TKey, string> = { "modal.badge.direct": "Doğrudan", "modal.badge.pool": "Havuz", "modal.badge.free": "Ücretsiz", + "modal.badge.sponsor": "Sponsor", + "pws.sponsor.orcaTitle": "Her istem için uygun model", + "pws.sponsor.orcaDescription": "Uyarlanabilir yönlendirme ve otomatik yük devretme sunan OpenAI uyumlu bir ağ geçidi.", + "pws.sponsor.packyTitle": "Claude Code, Codex ve Gemini tek yerde", + "pws.sponsor.packyDescription": "Yapay zekâ kodlama araçlarınız için API aktarma hizmeti. Codex grubu belirteciyle başlayın.", + "pws.sponsor.visit": "{provider} hakkında", + "pws.sponsor.console": "Konsolu aç", "modal.invalidPreset": "Bu yerleşik sağlayıcı ayarı eksik.", "modal.freeTierTitle": "Ücretsiz katman", "modal.freeTierDefault": "API anahtarı gerekmez. Doğrudan çalışır.", @@ -2509,6 +2517,8 @@ export const tr: Record<TKey, string> = { "dash.visionAdvancedPopover": "Gelişmiş görsel ayarları", "dash.codexDesktopAuthless": "Codex’i oturum açmadan başlat", "dash.codexDesktopAuthlessHint": "Varsayılan olarak kapalıdır. Uygun yerel bağlantılarda ayrı Desktop oturum açma adımını atlar. Sağlayıcı kimlik bilgileri yine gereklidir. Değişiklikten sonra Codex’i yeniden başlatın. Hesaba bağlı Desktop özellikleri kullanılamayabilir.", + "dash.codexClientCompaction": "İstemci tarafı sıkıştırmayı kullan", + "dash.codexClientCompactionHint": "Varsayılan olarak kapalıdır ve yalnızca kimliği doğrulanmış geri döngü yönlendirmesinde geçerlidir. Gelecekteki sıkıştırmalar, OpenCodeX ve V2 sağlayıcı yönlendirmesi etkin kalırken taşınabilir düz metin özetleri kaydeder; yapılandırılmış sağlayıcı bunları işleyip kotasını tüketebilir. Mevcut ocx1 geçmişi yine ayrıca kurtarılmalıdır. Değişiklikten sonra Codex’i yeniden başlatın.", "models.newPolicyGlobal": "Yeni modeller devre dışı başlasın", "models.newPolicyProvider": "Yeni model ilkesi", "models.newPolicy_inherit": "Devral", "models.newPolicy_off": "Kapalı", "models.newPolicy_on": "Açık", "models.newBadge": "YENİ", "models.newCount": "{count} yeni, kapalı", "models.aliases": "Takma adlar", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 00dc2386ec..c8527bcf0b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -548,14 +548,14 @@ export const zhTW: Record<TKey, string> = { "sub.moveUp": "上移 {m}", "sub.moveDown": "下移 {m}", "sub.removeAria": "移除 {m}", - "sub.ultraMode": "超級模式", + "sub.ultraMode": "始終主動委派", "sub.ultraModeHint": "為所有模型和推理力度啟用主動多代理委派策略(不改變推理力度本身)。將 features.multi_agent_v2.multi_agent_mode_hint_text 寫入 config.toml。", "sub.ultraModeV2Required": "需要 v2 多代理表面 — 請先啟用 multi_agent_v2,並在子代理模式控制項中選擇 v2。", - "sub.ultraModeText": "超級模式委派文字", + "sub.ultraModeText": "主動委派文字", "sub.ultraModePreset": "還原預設", - "sub.ultraModeLoadFail": "無法載入超級模式設定 — 代理是否在執行?", - "sub.ultraModeSaveFail": "儲存超級模式設定失敗", - "sub.ultraModeSaved": "超級模式已儲存。適用於新的 Codex 會話。", + "sub.ultraModeLoadFail": "無法載入主動委派設定 — 代理是否在執行?", + "sub.ultraModeSaveFail": "儲存主動委派設定失敗", + "sub.ultraModeSaved": "主動委派設定已儲存。適用於新的 Codex 會話。", "logs.title": "請求日誌", "logs.tabLogs": "日誌", "logs.tabDebug": "除錯", @@ -789,6 +789,7 @@ export const zhTW: Record<TKey, string> = { "storage.cleanup.confirmPermanent": "永久刪除", "storage.cleanup.doneQuarantine": "已隔離 {count} 個檔案({size})。", "storage.cleanup.donePermanent": "已永久刪除 {count} 個檔案({size})。", + "storage.cleanup.skippedReferenced": "已略過 {count} 個被參照的檔案。", "storage.cleanup.previewFailed": "預覽失敗。", "storage.cleanup.cleanupFailed": "清理失敗。", "storage.cleanup.err.codex_busy": "Codex 正在使用 state.sqlite — 請退出 Codex 後重試。", @@ -901,6 +902,13 @@ export const zhTW: Record<TKey, string> = { "modal.badge.direct": "Direct", "modal.badge.pool": "帳號池", "modal.badge.free": "免費", + "modal.badge.sponsor": "贊助商", + "pws.sponsor.orcaTitle": "為每個提示選擇合適的模型", + "pws.sponsor.orcaDescription": "相容 OpenAI 的閘道,支援自適應路由和自動容錯移轉。", + "pws.sponsor.packyTitle": "一站連接 Claude Code、Codex 和 Gemini", + "pws.sponsor.packyDescription": "適用於 AI 程式開發工具的 API 中轉服務。使用 Codex 群組權杖開始。", + "pws.sponsor.visit": "了解 {provider}", + "pws.sponsor.console": "開啟控制台", "modal.invalidPreset": "此內建供應商預設不完整。請重新啟動代理後重試。", "modal.freeTierTitle": "免費層級", "modal.freeTierDefault": "無需 API 金鑰,開箱即用。", @@ -2471,6 +2479,8 @@ export const zhTW: Record<TKey, string> = { "dash.visionAdvancedPopover": "進階視覺設定", "dash.codexDesktopAuthless": "無需登入即可開啟 Codex", "dash.codexDesktopAuthlessHint": "預設關閉。為符合條件的本機連線略過獨立的 Desktop 登入。仍需上游供應商憑證。變更後請重新啟動 Codex。依賴帳戶的 Desktop 功能可能無法使用。", + "dash.codexClientCompaction": "使用用戶端壓縮", + "dash.codexClientCompactionHint": "預設關閉,僅適用於已驗證的 loopback 路由。未來壓縮會儲存可攜的純文字摘要,同時保留 OpenCodeX 與 V2 提供方路由;已設定的提供方可能處理摘要並消耗其額度。既有 ocx1 歷程仍須另行復原。變更後請重新啟動 Codex。", "models.newPolicyGlobal": "新模型預設停用", "models.newPolicyProvider": "新模型策略", "models.newPolicy_inherit": "繼承", "models.newPolicy_off": "關閉", "models.newPolicy_on": "開啟", "models.newBadge": "新增", "models.newCount": "{count} 個新增,已關閉", "models.aliases": "別名", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4a7e834e0d..ee9d6d8112 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -690,14 +690,14 @@ export const zh: Record<TKey, string> = { "sub.workspace.selectModel": "选择模型", "sub.workspace.selectModelDesc": "从列表中选择一个模型以查看详情,并将其设为 spawn_agent 的精选模型。", "sub.workspace.selector": "公开选择器", - "sub.ultraMode": "超级模式", + "sub.ultraMode": "始终主动委派", "sub.ultraModeHint": "为所有模型和推理力度启用主动多代理委派策略(不改变推理力度本身)。将 features.multi_agent_v2.multi_agent_mode_hint_text 写入 config.toml。", "sub.ultraModeV2Required": "需要 v2 多代理表面 — 请先启用 multi_agent_v2,并在子代理模式控件中选择 v2。", - "sub.ultraModeText": "超级模式委派文本", + "sub.ultraModeText": "主动委派文本", "sub.ultraModePreset": "恢复预设", - "sub.ultraModeLoadFail": "无法加载超级模式设置 — 代理是否在运行?", - "sub.ultraModeSaveFail": "保存超级模式设置失败", - "sub.ultraModeSaved": "超级模式已保存。适用于新的 Codex 会话。", + "sub.ultraModeLoadFail": "无法加载主动委派设置 — 代理是否在运行?", + "sub.ultraModeSaveFail": "保存主动委派设置失败", + "sub.ultraModeSaved": "主动委派设置已保存。适用于新的 Codex 会话。", // logs "logs.title": "请求日志", @@ -1798,6 +1798,7 @@ export const zh: Record<TKey, string> = { "storage.cleanup.confirmPermanent": "永久删除", "storage.cleanup.doneQuarantine": "已隔离 {count} 个文件({size})。", "storage.cleanup.donePermanent": "已永久删除 {count} 个文件({size})。", + "storage.cleanup.skippedReferenced": "已跳过 {count} 个被引用的文件。", "storage.cleanup.previewFailed": "预览失败。", "storage.cleanup.cleanupFailed": "清理失败。", "storage.cleanup.err.codex_busy": "Codex 正在使用 state.sqlite — 请退出 Codex 后重试。", @@ -1896,6 +1897,13 @@ export const zh: Record<TKey, string> = { "modal.badge.direct": "直连", "modal.badge.pool": "账户池", "modal.badge.free": "免费", + "modal.badge.sponsor": "赞助商", + "pws.sponsor.orcaTitle": "为每个提示选择合适的模型", + "pws.sponsor.orcaDescription": "兼容 OpenAI 的网关,支持自适应路由和自动故障转移。", + "pws.sponsor.packyTitle": "一站连接 Claude Code、Codex 和 Gemini", + "pws.sponsor.packyDescription": "面向 AI 编程工具的 API 中转服务。使用 Codex 分组令牌开始。", + "pws.sponsor.visit": "了解 {provider}", + "pws.sponsor.console": "打开控制台", "modal.invalidPreset": "此内置提供方预设不完整。请重启代理后重试。", "modal.freeTierTitle": "免费层级", "modal.freeTierDefault": "无需 API 密钥,开箱即用。", @@ -2507,6 +2515,8 @@ export const zh: Record<TKey, string> = { "dash.visionAdvancedPopover": "高级视觉设置", "dash.codexDesktopAuthless": "无需登录即可打开 Codex", "dash.codexDesktopAuthlessHint": "默认关闭。为符合条件的本地连接跳过单独的 Desktop 登录。仍需上游提供商凭据。更改后请重启 Codex。依赖账户的 Desktop 功能可能不可用。", + "dash.codexClientCompaction": "使用客户端压缩", + "dash.codexClientCompactionHint": "默认关闭,仅适用于已认证的 loopback 路由。未来压缩会保存可移植的明文摘要,同时保留 OpenCodeX 与 V2 提供方路由;已配置的提供方可能处理摘要并消耗其额度。已有 ocx1 历史仍需单独恢复。更改后请重启 Codex。", "models.newPolicyGlobal": "新模型默认停用", "models.newPolicyProvider": "新模型策略", "models.newPolicy_inherit": "继承", "models.newPolicy_off": "关闭", "models.newPolicy_on": "开启", "models.newBadge": "新增", "models.newCount": "{count} 个新增,已关闭", "models.aliases": "别名", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 91992aabbf..d14a81005d 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -7,7 +7,7 @@ import type { AppServerStateOutcome } from "../codex-app-server-state"; import { useCodexRestart } from "../use-codex-restart"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Switch, Notice, EmptyState, Select, Tooltip } from "../ui"; -import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh, IconPencil, IconTrash } from "../icons"; +import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh, IconPencil } from "../icons"; import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; @@ -74,7 +74,7 @@ import { type V2Status, } from "./models-shared"; import { EmptyProviderHint } from "./models-provider-hints"; -import { shadowCallModelOptions, useModalDialog } from "./dashboard-shared"; +import { shadowCallModelOptions } from "./dashboard-shared"; import { shadowSourceModelBadge, shadowSourceModelLabel } from "./shadow-call-source"; type CachedModelsPage = { @@ -187,8 +187,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; useEffect(() => { // Once on mount/base change or restart completion, never on a timer. appServerReadBase.current = apiBase; - // Clear the previous server's observation before this resource starts its bounded read. - // oxlint-disable-next-line react/react-compiler setAppServerState(null); void reloadAppServerState(); return cancelAppServerRead; @@ -270,14 +268,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; { isEmpty: () => false, enabled: catalogActive, deadlineMs: 15_000, initialData: cachedPicker }, ); const pickerSettings = pickerResource.state.data; - const refreshPicker = pickerResource.refresh; const pickerMode = pickerDraft ?? modelPickerOrderMode( pickerSettings?.pickerAvailable ?? [], pickerSettings?.pickerOrder ?? [], pickerSettings?.pickerOrderMode, ); useLayoutEffect(() => { pickerGeneration.current++; - // A changed server or inactive catalog invalidates this resource's local mutation draft. - // oxlint-disable-next-line react/react-compiler setPickerDraft(null); setPickerBusy(false); return () => { @@ -288,8 +283,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; cancelAppServerRead(); }; }, [apiBase, catalogActive, cancelAppServerRead]); - // Pin inferred Custom before children render; a later GET must not unmount its draft. - if (catalogActive && pickerDraft === null && pickerMode === "custom") setPickerDraft("custom"); + useLayoutEffect(() => { + // Pin inferred Custom before any late GET can switch mode and unmount its draft. + if (catalogActive && pickerDraft === null && pickerMode === "custom") setPickerDraft("custom"); + }, [catalogActive, pickerDraft, pickerMode]); const [customCap, setCustomCap] = useState(""); const [showCustom, setShowCustom] = useState(false); const [providerCapCustomOpen, setProviderCapCustomOpen] = useState<Record<string, boolean>>({}); @@ -340,11 +337,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [threadsCustom, setThreadsCustom] = useState(""); const [showThreadsCustom, setShowThreadsCustom] = useState(false); const [v2HelpOpen, setV2HelpOpen] = useState(false); - const v2HelpTriggerRef = useRef<HTMLButtonElement>(null); - const v2HelpDialogRef = useModalDialog(v2HelpOpen, v2HelpTriggerRef); const [customModalOpen, setCustomModalOpen] = useState(false); - const customModalTriggerRef = useRef<HTMLButtonElement>(null); - const customDialogRef = useModalDialog(customModalOpen, customModalTriggerRef); const [displayNameModel, setDisplayNameModel] = useState<ModelRow | null>(null); const [priceModel, setPriceModel] = useState<ModelRow | null>(null); const priceTriggerRef = useRef<HTMLButtonElement | null>(null); @@ -371,11 +364,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; }, [apiBase]); useEffect(() => { const controller = new AbortController(); - const timeout = window.setTimeout(() => void reloadAliases(controller.signal), 0); - return () => { - window.clearTimeout(timeout); - controller.abort(); - }; + void reloadAliases(controller.signal); + return () => controller.abort(); }, [reloadAliases]); const saveProviderAlias = async (provider: string) => { @@ -443,8 +433,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [contextDefaultTouched, setContextDefaultTouched] = useState(false); const [contextSaving, setContextSaving] = useState(false); const [contextError, setContextError] = useState(""); - const contextModalTriggerRef = useRef<HTMLButtonElement>(null); - const contextDialogRef = useModalDialog(contextModalProvider !== null, contextModalTriggerRef); const [hoveredModel, setHoveredModel] = useState<{ namespaced: string; rect: DOMRect } | null>(null); const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const [shadowCall, setShadowCall] = useState<ShadowCallData | null>(null); @@ -586,7 +574,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; // Follow-up mutation refreshes retain their existing awaitable contract while publishing // the result through the same shared store used by the initial catalog subscription. setClientResourceData(cacheKey, next); - refreshPicker(); + pickerResource.refresh(); return true; } catch { return false; @@ -595,7 +583,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; loadPendingRef.current = false; } } - }, [applyCatalog, cacheKey, fetchCatalog, refreshPicker]); + }, [applyCatalog, cacheKey, fetchCatalog, pickerResource.refresh]); const finishDisplayNameEdit = useCallback(() => { const trigger = displayNameTriggerRef.current; @@ -709,31 +697,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }, [apiBase, displayNameModel, displayNameRecovery, finishDisplayNameEdit, load, publishFeedback, t]); - /** #2465: load the per-provider preset preview. */ - const loadPresets = useCallback(async () => { - const bounded = createBoundedFetch(15_000); - try { - const response = await fetch(`${apiBase}/api/model-presets`, { signal: bounded.signal }); - const data = await readJsonIfOk<{ providers?: Record<string, ModelPresetView> }>(response); - setPresets(data?.providers ?? {}); - } catch { - // A preset preview is decoration on top of a working Models page; failing to load it must - // not take the page down. - setPresets({}); - } finally { - bounded.clear(); - } - }, [apiBase]); - - const loadModelDiscovery = useCallback(async () => { - try { - const response = await fetch(`${apiBase}/api/model-discovery`); - setModelDiscovery((await readJsonIfOk<ModelDiscoveryView>(response)) ?? null); - } catch { - setModelDiscovery(null); - } - }, [apiBase]); - // Shadow/v2 controls must not wait on the models catalog (live discovery can be slow). useEffect(() => { // Both belong to the catalog tab; a hidden panel polling /api/v2 every ten seconds @@ -755,7 +718,18 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; window.clearTimeout(timeout); stop(); }; - }, [catalogActive, loadModelDiscovery, loadPresets, loadShadowCall, loadV2]); + // oxlint-disable-next-line react/react-compiler -- existing exhaustive-deps exception is intentional + // eslint-disable-next-line react-hooks/exhaustive-deps -- loadPresets is a plain async loader + // like the rest of this file's; a useCallback wrapper trips PreserveManualMemo, and the + // effect only ever needs the current closure. Verified 2026-08-27: converting both loaders + // to useCallback and completing the dep array turns ONE warning into five react-compiler + // errors - two PreserveManualMemo, two Immutability (they are declared ~430 lines below this + // effect), and one EffectSetState - so the note above still holds against oxlint 1.78. + // Both gates suppress this one rule for this one file by config rather than by comment: + // gui/.oxlintrc.json (override) and gui/doctor.config.json (ignore.overrides). An in-file + // react-doctor-disable comment was tried and removed - it changed nothing, and + // react/react-compiler penalises a component for carrying suppressions at all. + }, [catalogActive, loadShadowCall, loadV2]); const groups = useMemo( () => buildProviderModelGroups(models, providers), @@ -770,9 +744,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const catalogCountReady = models.length > 0 || catalogState.data !== undefined; const openContextSettings = (group: ProviderModelGroup<ModelRow>) => { - contextModalTriggerRef.current = document.activeElement?.tagName === "BUTTON" - ? document.activeElement as HTMLButtonElement - : null; const modelIds = [...new Set([ ...group.rows.map(model => model.id), ...group.configuredModels, @@ -1174,6 +1145,32 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; if (!v2 || v2.multiAgentMode === mode) return; await putV2Setting({ multiAgentMode: mode }); }; + + + /** + * #2465: load the per-provider preset preview. Rules are evaluated server-side against the + * CURRENT catalog, so the count shown is the count an apply would produce. + */ + const loadPresets = async () => { + try { + const bounded = createBoundedFetch(15_000); + const r = await fetch(`${apiBase}/api/model-presets`, { signal: bounded.signal }); + const data = await readJsonIfOk<{ providers?: Record<string, ModelPresetView> }>(r); + setPresets(data?.providers ?? {}); + } catch { + // A preset preview is decoration on top of a working Models page; failing to load it must + // not take the page down. + setPresets({}); + } + }; + + const loadModelDiscovery = async () => { + try { + const r = await fetch(`${apiBase}/api/model-discovery`); + setModelDiscovery((await readJsonIfOk<ModelDiscoveryView>(r)) ?? null); + } catch { setModelDiscovery(null); } + }; + const saveModelDiscovery = async (policy: "on" | "off", provider?: string) => { const r = await fetch(`${apiBase}/api/model-discovery`, { method: "PUT", headers: { "content-type": "application/json" }, @@ -1295,6 +1292,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; hoverTimerRef.current = setTimeout(() => setHoveredModel(null), 120); }; + const keepRowTipOpen = () => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + }; + const addCustomModel = async ( provider: string, modelId: string, @@ -1472,7 +1473,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; className="btn btn-ghost btn-sm text-caption" onClick={(e) => { e.stopPropagation(); - customModalTriggerRef.current = e.currentTarget; setCustomModalMode("add"); setCustomModalProvider(provider); setCustomModalId(""); @@ -1708,7 +1708,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; </span> )} {!m.native && m.provider !== "combo" && ( - <> + <> {m.manualPricing === true && <span className="models-chip muted text-caption">{t("pricing.override.badge")}</span>} <button type="button" @@ -1724,71 +1724,26 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; </button> </> )} - {m.custom && m.customId && ( - <span className="models-model-row-actions"> - <button - type="button" - className="btn btn-ghost btn-sm text-caption" - aria-label={t("models.customEditNamed", { name: m.displayName ?? m.id })} - onClick={event => { - customModalTriggerRef.current = event.currentTarget; - setCustomModalMode("edit"); - setCustomModalProvider(m.provider); - setCustomModalId(m.customId!); - setCustomFormModelId(m.id); - setCustomFormDisplayName(m.displayName ?? ""); - setCustomFormContextWindow(m.contextWindow ? String(m.contextWindow) : ""); - setCustomFormShowCustomCtx(false); - setCustomFormModalities(m.inputModalities ?? ["text"]); - setCustomFormReasoning(Array.isArray(m.reasoningEfforts)); - setCustomFormReasoningEfforts(m.reasoningEfforts ?? []); - customFormReasoningInitializedRef.current = Array.isArray(m.reasoningEfforts); - setCustomError(""); - setCustomModalOpen(true); - setHoveredModel(null); - }} - ><IconPencil width={13} height={13} aria-hidden="true" /></button> - <button - type="button" - className="btn btn-ghost btn-sm text-caption" - aria-label={t("models.customDeleteNamed", { name: m.displayName ?? m.id })} - style={{ color: "var(--red)" }} - onClick={() => { - if (window.confirm(t("models.customDeleteConfirm", { name: m.displayName ?? m.id }))) { - void deleteCustomModel(m.customId!); - } - setHoveredModel(null); - }} - ><IconTrash width={13} height={13} aria-hidden="true" /></button> - </span> - )} {!m.custom && recentIds.has(m.id) && <span className="badge badge-amber">{t("models.newBadge")}</span>} {m.contextCapped && <span className="models-chip muted mono text-caption">{t("models.contextCappedValue", { value: fmtK(m.contextCap ?? contextCapValue) })}</span>} </div> {hoveredModel?.namespaced === m.namespaced && (() => { const r = hoveredModel.rect; - const tipWidth = Math.max(0, Math.min(480, window.innerWidth - 16)); - const tipLeft = Math.max(8, Math.min(r.left + 24, window.innerWidth - tipWidth - 8)); - const tipTop = Math.max(8, r.bottom + 4); - const roomBelow = Math.max(0, window.innerHeight - tipTop - 8); - const roomAbove = Math.max(0, r.top - 12); - const flipUp = roomBelow < Math.min(360, roomAbove); - const tipMaxHeight = Math.min(360, flipUp ? roomAbove : roomBelow); + const tipTop = r.bottom + 4; + const flipUp = tipTop + 360 > window.innerHeight; return ( <div - className={`model-tip${flipUp ? " flip-up" : ""}`} + className={`model-tip${m.custom ? " has-actions" : ""}${flipUp ? " flip-up" : ""}`} role="tooltip" style={{ position: "fixed", - left: tipLeft, - width: "max-content", - minWidth: Math.min(320, tipWidth), - maxWidth: tipWidth, - maxHeight: tipMaxHeight, + left: r.left + 24, ...(flipUp - ? { bottom: Math.max(8, window.innerHeight - r.top + 4) } + ? { bottom: window.innerHeight - r.top + 4 } : { top: tipTop }), }} + onMouseEnter={keepRowTipOpen} + onMouseLeave={onRowLeave} > <div className="model-tip-id">{m.native ? m.id : m.namespaced}</div> {m.displayName && <div className="model-tip-display">{m.displayName}</div>} @@ -1815,6 +1770,46 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; <span className="model-tip-key">{t("models.tipStatus")}</span> <span className="model-tip-val">{off ? t("models.tipDisabled") : t("models.tipActive")}</span> </div> + {m.custom && m.customId && ( + <div className="model-tip-actions"> + <button + type="button" + className="btn btn-ghost btn-sm text-caption" + onClick={() => { + setCustomModalMode("edit"); + setCustomModalProvider(m.provider); + setCustomModalId(m.customId!); + setCustomFormModelId(m.id); + setCustomFormDisplayName(m.displayName ?? ""); + setCustomFormContextWindow(m.contextWindow ? String(m.contextWindow) : ""); + setCustomFormShowCustomCtx(false); + setCustomFormModalities(m.inputModalities ?? ["text"]); + // Only a STORED ladder counts as "configured": an inherited one + // would show a phantom override that saves "inherit" over the + // provider row's current metadata. + setCustomFormReasoning(Array.isArray(m.reasoningEfforts)); + setCustomFormReasoningEfforts(m.reasoningEfforts ?? []); + // A stored ladder — even an explicit empty one — is a real + // configuration: re-enabling must preserve it, not reseed. + customFormReasoningInitializedRef.current = Array.isArray(m.reasoningEfforts); + setCustomError(""); + setCustomModalOpen(true); + setHoveredModel(null); + }} + >{t("models.customEdit")}</button> + <button + type="button" + className="btn btn-ghost btn-sm text-caption" + style={{ color: "var(--red)" }} + onClick={() => { + if (window.confirm(t("models.customDeleteConfirm", { name: m.displayName ?? m.id }))) { + void deleteCustomModel(m.customId!); + } + setHoveredModel(null); + }} + >{t("models.customDelete")}</button> + </div> + )} </div> ); })()} @@ -1942,10 +1937,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; className="btn btn-ghost btn-sm" style={{ width: 24, height: 24, minWidth: 24, flex: "0 0 24px", padding: 0, borderRadius: "var(--radius-pill)", color: "var(--muted)" }} disabled={!v2} - onClick={event => { - v2HelpTriggerRef.current = event.currentTarget; - setV2HelpOpen(true); - }} + onClick={() => setV2HelpOpen(true)} aria-label={t("models.v2Label")} aria-haspopup="dialog" > @@ -2133,11 +2125,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const modalsBlock = ( <> {v2HelpOpen && ( - <dialog ref={v2HelpDialogRef} className="modal-overlay" aria-labelledby="models-v2-help-title" onCancel={event => { event.preventDefault(); setV2HelpOpen(false); }}> - <button type="button" className="modal-backdrop-dismiss" aria-label={t("common.close")} tabIndex={-1} onClick={() => setV2HelpOpen(false)} /> + <div className="modal-overlay" role="dialog" aria-modal="true" aria-label={t("models.v2Label")} onClick={() => setV2HelpOpen(false)} onKeyDown={e => { if (e.key === "Escape") setV2HelpOpen(false); }}> <div className="modal-card" onClick={e => e.stopPropagation()}> <div className="modal-head"> - <h3 id="models-v2-help-title">{t("models.v2Label")}</h3> + <h3>{t("models.v2Label")}</h3> <button type="button" className="btn btn-ghost btn-sm" onClick={() => setV2HelpOpen(false)} aria-label={t("common.close")}>×</button> </div> <div className="modal-desc leading-relaxed" style={{ whiteSpace: "pre-line" }}> @@ -2152,24 +2143,23 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; <button type="button" className="btn btn-primary" onClick={() => setV2HelpOpen(false)}>{t("common.ok")}</button> </div> </div> - </dialog> + </div> )} {contextModalProvider && ( - <dialog - ref={contextDialogRef} + <div className="modal-overlay" + role="dialog" + aria-modal="true" aria-label={t("models.contextSettings")} - aria-labelledby="models-context-dialog-title" - onCancel={event => { - event.preventDefault(); - if (!contextSaving) setContextModalProvider(null); + onClick={() => { if (!contextSaving) setContextModalProvider(null); }} + onKeyDown={(event) => { + if (event.key === "Escape" && !contextSaving) setContextModalProvider(null); }} > - <button type="button" className="modal-backdrop-dismiss" aria-label={t("common.close")} tabIndex={-1} disabled={contextSaving} onClick={() => setContextModalProvider(null)} /> <div className="modal-card" onClick={event => event.stopPropagation()}> <div className="modal-head"> - <h3 id="models-context-dialog-title">{t("models.contextSettingsTitle", { + <h3>{t("models.contextSettingsTitle", { provider: formatProviderDisplayName(contextModalProvider, t), })}</h3> <button @@ -2251,23 +2241,23 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; </button> </div> </div> - </dialog> + </div> )} {customModalOpen && ( - <dialog - ref={customDialogRef} + <div className="modal-overlay" - aria-labelledby="models-custom-dialog-title" - onCancel={event => { - event.preventDefault(); - if (!customSaving) setCustomModalOpen(false); + role="dialog" + aria-modal="true" + aria-label={t("models.customAdd")} + onClick={() => { if (!customSaving) setCustomModalOpen(false); }} + onKeyDown={(e) => { + if (e.key === "Escape" && !customSaving) setCustomModalOpen(false); }} > - <button type="button" className="modal-backdrop-dismiss" aria-label={t("common.close")} tabIndex={-1} disabled={customSaving} onClick={() => setCustomModalOpen(false)} /> <div className="modal-card" onClick={e => e.stopPropagation()}> <div className="modal-head"> - <h3 id="models-custom-dialog-title"> + <h3> {customModalMode === "add" ? t("models.customAddTitle", { provider: formatProviderDisplayName(customModalProvider, t) }) : t("models.customEditTitle", { provider: formatProviderDisplayName(customModalProvider, t) })} @@ -2463,7 +2453,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; </button> </div> </div> - </dialog> + </div> )} </> ); diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 1a0ca89e8d..2cc59a6b75 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -2,6 +2,7 @@ import { usageSummary30dResourceKey } from "../usage-summary-resource"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; import ProviderDetails from "../components/provider-workspace/ProviderDetails"; +import { matchingWorkspacePreset, type CatalogPreset } from "../components/provider-catalog/provider-presets"; import { isAccountProvider, type WorkspaceProvider } from "../provider-workspace/catalog"; import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import { oauthTosRisk } from "../oauth-tos-risk"; @@ -292,13 +293,13 @@ export default function Providers({ apiBase }: { apiBase: string }) { // modal does not wait on a cold /api/provider-presets round-trip (~same key as // AddProviderModal). Prefetch usage too so the catalog does not paint alpha then // re-rank when the slow usage probe (~5s cold) finally returns. - useKeyedClientResource( + const presetResource = useKeyedClientResource( `add-provider-presets:${apiBase}`, [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/provider-presets`, { signal }); if (!res.ok) throw new Error(String(res.status)); - const data = await res.json() as { providers?: unknown[] }; + const data = await res.json() as { providers?: CatalogPreset[] }; return Array.isArray(data.providers) && data.providers.length > 0 ? data.providers : null; }, ); @@ -610,6 +611,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { <ProviderDetails key={item.name} item={item} + preset={matchingWorkspacePreset(item, presetResource.data ?? [])} usageTotals={data.usageTotals} modelUsage={data.modelUsage} quotaReport={data.quotaReport} diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 34ea9a677b..04d80134d6 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -30,6 +30,7 @@ interface CleanupResult { trashDir?: string; error?: string; message?: string; + skippedReferencedPaths?: string[]; } interface TrashEntry { @@ -215,11 +216,12 @@ function ArchivedCleanupPanel({ throw new Error(mapCleanupError(json.error, json.message, json.trashDir)); } closeConfirm(true); - setStatus( - permanent + const complete = permanent ? t("storage.cleanup.donePermanent", { count: String(json.count), size: formatBytes(json.bytes, locale) }) - : t("storage.cleanup.doneQuarantine", { count: String(json.count), size: formatBytes(json.bytes, locale) }), - ); + : t("storage.cleanup.doneQuarantine", { count: String(json.count), size: formatBytes(json.bytes, locale) }); + setStatus(json.skippedReferencedPaths?.length + ? `${complete} ${t("storage.cleanup.skippedReferenced", { count: String(json.skippedReferencedPaths.length) })}` + : complete); onDone(); } catch (e) { // Keep the dialog open (except stale_preview) so the failure is visible. diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index 47baa0817c..94a8df7086 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -6,22 +6,19 @@ import SubagentsWorkspace, { FEATURED_MAX } from "../components/subagents-worksp import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; -import { useSubagentDelegation, type UltraModePatch, type UltraModeState, type V2NativeParentOverrideState, type AgentTaskRecoveryState, type V2RoutedDelegationBridgeState } from "./use-subagent-delegation"; -import { CodexStaleBanner } from "../components/codex-stale-banner"; -import { useCodexRestart } from "../use-codex-restart"; +import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from "./use-subagent-delegation"; -type CatalogState = "fresh" | "stale" | "not_running" | "unknown"; -type CachedSubagents = { available: string[]; chosen: string[]; catalogState?: { state?: CatalogState }; fallback?: string[]; pollMs?: number; fallbackAvailable?: string[] }; +type CachedSubagents = { available: string[]; chosen: string[]; fallback?: string[]; pollMs?: number; fallbackAvailable?: string[] }; + +const UNLOADED_ULTRA_MODE: UltraModeState = { + enabled: false, hintText: null, recommendation: null, + multiAgentV2Enabled: false, multiAgentMode: "default", +}; function seedSubagents(cacheKey: string): CachedSubagents | null { return readSessionListCache<CachedSubagents>(cacheKey); } -function readCatalogState(value: CachedSubagents | null): CatalogState { - const state = value?.catalogState?.state; - return state === "fresh" || state === "stale" || state === "not_running" ? state : "unknown"; -} - export default function Subagents({ apiBase }: { apiBase: string }) { const t = useT(); const cacheKey = `ocx.subagents.v1:${apiBase}`; @@ -47,24 +44,11 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const [busy, setBusy] = useState(false); /** Sync guard: state-only `busy` can miss clicks before the disabled re-render commits. */ const saveInFlight = useRef(false); - const catalogLoadGeneration = useRef(0); const delegation = useSubagentDelegation(apiBase); - const [catalogState, setCatalogState] = useState<CatalogState>(() => readCatalogState(cached)); - const [ultraMode, setUltraMode] = useState<UltraModeState>({ enabled: false, hintText: null, multiAgentV2Enabled: false, multiAgentMode: "default" }); - const [nativeParentOverride, setNativeParentOverride] = useState<V2NativeParentOverrideState>({ enabled: false, model: null, active: false }); - const [agentTaskRecovery, setAgentTaskRecovery] = useState<AgentTaskRecoveryState>({ enabled: false, model: null }); - const [routedDelegationBridge, setRoutedDelegationBridge] = useState<V2RoutedDelegationBridgeState>({ enabled: false }); - const [multiAgentMode, setMultiAgentMode] = useState<"v1" | "default" | "v2">("default"); - const [keepNativeChatGptOnV1, setKeepNativeChatGptOnV1] = useState(false); - const [childInstructions, setChildInstructions] = useState(""); - const [childInstructionsSaving, setChildInstructionsSaving] = useState(false); + const [ultraState, setUltraState] = useState<{ apiBase: string; mode: UltraModeState } | null>(null); + const ultraModeCurrent = ultraState?.apiBase === apiBase; + const ultraMode = ultraModeCurrent ? ultraState.mode : UNLOADED_ULTRA_MODE; const [ultraSaving, setUltraSaving] = useState(false); - const [nativeParentOverrideSaving, setNativeParentOverrideSaving] = useState(false); - const nativeParentOverrideSavingRef = useRef(false); - const [agentTaskRecoverySaving, setAgentTaskRecoverySaving] = useState(false); - const agentTaskRecoverySavingRef = useRef(false); - const routedDelegationBridgeSavingRef = useRef(false); - const [routedDelegationBridgeSaving, setRoutedDelegationBridgeSaving] = useState(false); const [ultraLoadFailed, setUltraLoadFailed] = useState(false); const ultraLoadGeneration = useRef(0); const currentUltraApiBase = useRef(apiBase); @@ -83,42 +67,33 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const data = await readJsonOrThrow<{ enabled?: boolean; multiAgentMode?: "v1" | "default" | "v2"; - keepNativeChatGptOnV1?: boolean; multiAgentModeHintText?: string | null; - subagentDeveloperInstructions?: string | null; - v2NativeParentOverride?: Partial<V2NativeParentOverrideState>; - agentTaskRecovery?: Partial<AgentTaskRecoveryState>; - v2RoutedDelegationBridge?: unknown; + keepNativeChatGptOnV1?: boolean; + multiAgentModeHintRecommendation?: { text?: unknown; revision?: unknown }; }>(res, t("sub.ultraModeLoadFail")); if (!data) return false; if (signal?.aborted || generation !== ultraLoadGeneration.current || currentUltraApiBase.current !== apiBase) return false; setUltraLoadFailed(false); - setMultiAgentMode(data.multiAgentMode === "v1" || data.multiAgentMode === "v2" ? data.multiAgentMode : "default"); - setKeepNativeChatGptOnV1(data.keepNativeChatGptOnV1 === true); - setChildInstructions(typeof data.subagentDeveloperInstructions === "string" ? data.subagentDeveloperInstructions : ""); - setUltraMode({ + const rawRecommendation = data.multiAgentModeHintRecommendation; + const recommendation = rawRecommendation + && typeof rawRecommendation.text === "string" + && rawRecommendation.text.trim().length > 0 + && typeof rawRecommendation.revision === "string" + && rawRecommendation.revision.trim().length > 0 + ? { text: rawRecommendation.text, revision: rawRecommendation.revision } + : null; + setUltraState({ apiBase, mode: { enabled: data.enabled ?? false, loaded: true, keepNativeChatGptOnV1: data.keepNativeChatGptOnV1 === true, hintText: data.multiAgentModeHintText ?? null, + recommendation, // Ultra mode replaces Codex's effort-derived policy for every model. The // `default` surface still preserves upstream V1 pins (for example luna), // so only an explicitly forced V2 catalog is an effective surface here. multiAgentV2Enabled: data.enabled === true && data.multiAgentMode === "v2", multiAgentMode: data.multiAgentMode ?? "default", - }); - const override = data.v2NativeParentOverride; - setNativeParentOverride({ - enabled: override?.enabled === true, - model: typeof override?.model === "string" ? override.model : null, - active: override?.active === true, - }); - const recovery = data.agentTaskRecovery; - setAgentTaskRecovery({ - enabled: recovery?.enabled === true, - model: typeof recovery?.model === "string" ? recovery.model : null, - }); - setRoutedDelegationBridge({ enabled: data.v2RoutedDelegationBridge === true }); + } }); return true; }, [apiBase, t]); @@ -137,7 +112,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { }, [loadUltraMode, t]); const saveUltraMode = async (patch: UltraModePatch) => { - if (ultraSaving) return; + if (ultraSaving || !ultraModeCurrent || currentUltraApiBase.current !== apiBase) return; const requestApiBase = apiBase; setUltraSaving(true); setStatus(""); @@ -160,108 +135,6 @@ export default function Subagents({ apiBase }: { apiBase: string }) { } }; - const saveNativeParentOverride = async (next: V2NativeParentOverrideState) => { - if (nativeParentOverrideSavingRef.current) return; - nativeParentOverrideSavingRef.current = true; - const requestApiBase = apiBase; - setNativeParentOverrideSaving(true); - setStatus(""); - try { - const res = await fetch(`${apiBase}/api/v2`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ v2NativeParentOverride: { enabled: next.enabled, model: next.model } }), - }); - await readJsonOrThrow(res, t("sub.nativeParentOverrideSaveFail")); - if (currentUltraApiBase.current !== requestApiBase || !await loadUltraMode()) return; - setOk(true); - setStatus(t("sub.nativeParentOverrideSaved")); - } catch (error) { - if (currentUltraApiBase.current !== requestApiBase) return; - setOk(false); - setStatus(error instanceof Error && error.message ? error.message : t("sub.networkError")); - } finally { - nativeParentOverrideSavingRef.current = false; - setNativeParentOverrideSaving(false); - } - }; - - const saveAgentTaskRecovery = async (next: AgentTaskRecoveryState) => { - if (agentTaskRecoverySavingRef.current) return; - agentTaskRecoverySavingRef.current = true; - const requestApiBase = apiBase; - setAgentTaskRecoverySaving(true); - setStatus(""); - try { - const res = await fetch(`${apiBase}/api/v2`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentTaskRecovery: { enabled: next.enabled, model: next.model } }), - }); - await readJsonOrThrow(res, t("sub.agentTaskRecoverySaveFail")); - if (currentUltraApiBase.current !== requestApiBase || !await loadUltraMode()) return; - setOk(true); - setStatus(t("sub.agentTaskRecoverySaved")); - } catch (error) { - if (currentUltraApiBase.current !== requestApiBase) return; - setOk(false); - setStatus(error instanceof Error && error.message ? error.message : t("sub.networkError")); - } finally { - agentTaskRecoverySavingRef.current = false; - setAgentTaskRecoverySaving(false); - } - }; - - const saveRoutedDelegationBridge = async (enabled: boolean) => { - if (routedDelegationBridgeSavingRef.current) return; - routedDelegationBridgeSavingRef.current = true; - const requestApiBase = apiBase; - setRoutedDelegationBridgeSaving(true); - setStatus(""); - try { - const res = await fetch(`${apiBase}/api/v2`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ v2RoutedDelegationBridge: enabled }), - }); - await readJsonOrThrow(res, t("sub.routedDelegationBridgeSaveFail")); - if (currentUltraApiBase.current !== requestApiBase || !await loadUltraMode()) return; - setOk(true); - setStatus(t("sub.routedDelegationBridgeSaved")); - } catch (error) { - if (currentUltraApiBase.current !== requestApiBase) return; - setOk(false); - setStatus(error instanceof Error && error.message ? error.message : t("sub.networkError")); - } finally { - routedDelegationBridgeSavingRef.current = false; - setRoutedDelegationBridgeSaving(false); - } - }; - - const saveChildInstructions = async (value: string | null) => { - if (childInstructionsSaving) return; - const requestApiBase = apiBase; - setChildInstructionsSaving(true); - setStatus(""); - try { - const res = await fetch(`${apiBase}/api/v2`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ subagentDeveloperInstructions: value }), - }); - await readJsonOrThrow(res, t("sub.childInstructionsSaveFailed")); - if (currentUltraApiBase.current !== requestApiBase || !await loadUltraMode()) return; - setOk(true); - setStatus(t("sub.childInstructionsSaved")); - } catch (error) { - if (currentUltraApiBase.current !== requestApiBase) return; - setOk(false); - setStatus(error instanceof Error && error.message ? error.message : t("sub.networkError")); - } finally { - setChildInstructionsSaving(false); - } - }; - const retryUltraMode = useCallback(async () => { try { if (!await loadUltraMode()) return; @@ -320,11 +193,8 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const loadSubagents = useCallback(async (signal?: AbortSignal): Promise<CachedSubagents> => { // Auxiliary fallback discovery must neither reject nor delay the roster resource. const rosterReadRevision = rosterRevision.current; - // The resource layer's deadline abort must reach the wire — a signal dropped - // here is a store that can only settle by race timeout. - const generation = ++catalogLoadGeneration.current; const rosterRes = await fetch(`${apiBase}/api/subagent-models`, { signal }); - const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[]; catalogState?: { state?: CatalogState } }>(rosterRes, t("sub.loadFail")); + const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(rosterRes, t("sub.loadFail")); if (!response) throw new Error(t("sub.loadFail")); const available = response.available ?? []; const availableSet = new Set(available); @@ -333,13 +203,10 @@ export default function Subagents({ apiBase }: { apiBase: string }) { ...fallbackSnapshot.current, available, chosen: rosterCurrent ? (response.chosen ?? []).filter(model => availableSet.has(model)) : committed.current?.chosen ?? [], - catalogState: response.catalogState, }; if (signal?.aborted) throw signal.reason; committed.current = next; - if (generation !== catalogLoadGeneration.current) return next; if (rosterCurrent) setChosen(next.chosen); - setCatalogState(readCatalogState(next)); writeSessionListCache(cacheKey, next); return next; }, [apiBase, cacheKey, t]); @@ -356,12 +223,6 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const load = resource.refresh; const snapshot = state.data ?? cached; const available = snapshot?.available ?? []; - const refreshDelegation = delegation.refresh; - const restartSettled = useCallback(() => { - load(); - void refreshDelegation(); - }, [refreshDelegation, load]); - const codexRestart = useCodexRestart(apiBase, { onSettled: restartSettled }); const toggle = (m: string) => { if (busy) return; @@ -381,15 +242,9 @@ export default function Subagents({ apiBase }: { apiBase: string }) { }); }; - const setRoleStatus = useCallback((nextOk: boolean, message: string) => { - setOk(nextOk); - setStatus(message); - }, []); - const save = async () => { if (busy || saveInFlight.current) return; saveInFlight.current = true; - const saveCatalogGeneration = catalogLoadGeneration.current; rosterRevision.current += 1; setBusy(true); setStatus(""); @@ -404,11 +259,9 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const applied = d?.applied ?? chosen; if (d?.applied) setChosen(d.applied); // A legacy roster-only seed does not prove that an empty fallback was loaded. - const next = { ...committed.current, available, chosen: applied, catalogState: { state: catalogState } }; + const next = { ...committed.current, available, chosen: applied }; committed.current = next; - if (saveCatalogGeneration === catalogLoadGeneration.current) { - writeSessionListCache(cacheKey, next); - } + writeSessionListCache(cacheKey, next); setOk(true); setStatus(t("sub.saved", { n: applied.length, cmd: "ocx sync" })); } catch (error) { @@ -440,7 +293,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { setFallback(d.models); setFallbackPollMs(d.pollMs); fallbackSnapshot.current = { ...fallbackSnapshot.current, fallback: d.models, pollMs: d.pollMs }; - const next = { ...committed.current, available, chosen: committed.current?.chosen ?? [], ...fallbackSnapshot.current }; + const next = { available, chosen: committed.current?.chosen ?? [], ...fallbackSnapshot.current }; committed.current = next; writeSessionListCache(cacheKey, next); setOk(true); @@ -476,8 +329,6 @@ export default function Subagents({ apiBase }: { apiBase: string }) { </div> {status && <Notice tone={ok ? "ok" : "err"}>{status}</Notice>} {state.showError && <Notice tone="err">{t("sub.loadFail")}</Notice>} - <p className="page-sub">{t("sub.catalogState.label")}: {t(`sub.catalogState.${catalogState}`)}</p> - <CodexStaleBanner state={catalogState} controller={codexRestart} /> {fallbackError && ( <Notice tone="err"> {t("sub.fallbackLabel")}: {t("sub.loadFail")} @@ -506,46 +357,13 @@ export default function Subagents({ apiBase }: { apiBase: string }) { available: delegation.available, guidanceEnabled: delegation.guidanceEnabled, syncCodexDefaults: delegation.syncCodexDefaults, - nativeDefaultState: delegation.nativeDefaultState, saving: delegation.saving, - onSave: patch => { - void (async () => { - const result = await delegation.save(patch); - if ("prompt" in patch) { - if (result.ok) { - setOk(true); - setStatus(t("sub.injectionPromptSaved")); - } else { - setOk(false); - setStatus(result.error || t("sub.injectionPromptSaveFailed")); - } - } - })(); - }, + onSave: patch => { void delegation.save(patch); }, ultraMode, - ultraSaving, + ultraSaving: ultraSaving || !ultraModeCurrent, onUltraModeSave: patch => { void saveUltraMode(patch); }, ultraLoadFailed, onUltraModeRetry: () => { void retryUltraMode(); }, - nativeParentOverride, - nativeParentOverrideSaving, - onNativeParentOverrideSave: next => { void saveNativeParentOverride(next); }, - agentTaskRecovery, - agentTaskRecoverySaving, - onAgentTaskRecoverySave: next => { void saveAgentTaskRecovery(next); }, - routedDelegationBridge, - routedDelegationBridgeSaving, - onRoutedDelegationBridgeSave: enabled => { void saveRoutedDelegationBridge(enabled); }, - prompt: delegation.prompt, - childInstructions, - childInstructionsSaving, - onChildInstructionsSave: value => { void saveChildInstructions(value); }, - }} - roles={{ - apiBase, - multiAgentMode, - keepNativeChatGptOnV1, - onStatus: setRoleStatus, }} /> </> diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index f336f1261c..3e08df9610 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -5,6 +5,7 @@ import { formatTokens } from "../format-tokens"; import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { EmptyState, Notice } from "../ui"; +import { IconChevron } from "../icons"; import { modelLabel } from "../model-display"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; @@ -895,6 +896,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas const [draftWindow, setDraftWindow] = useState({ since: "", until: "" }); const [customWindow, setCustomWindow] = useState<UsageTimeWindow | null>(null); const [rangeError, setRangeError] = useState<UsageRangeError | null>(null); + const [rangeOpen, setRangeOpen] = useState(false); const since = customWindow?.since; const until = customWindow?.until; @@ -968,54 +970,85 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas <UsageFilters surface={surface} range={customWindow ? null : range} onSurface={setSurface} onRange={selectRange} t={t} /> </div> <p className="page-sub">{t("usage.subtitle")}</p> - <form aria-label={t("usage.range.custom")} noValidate onSubmit={event => { - event.preventDefault(); - const result = parseUsageTimeRange(draftWindow.since, draftWindow.until); - if (result.ok === false) { - setRangeError(result.error); - return; - } - setRangeError(null); - setCustomWindow(result.window); - }}> - <div className="usage-filters"> - <label> - <span className="field-label">{t("usage.range.start")}</span> - <input className="input" type="datetime-local" step="60" required - value={draftWindow.since} - aria-invalid={rangeError !== null} - aria-describedby={rangeError ? "usage-range-help usage-range-error" : "usage-range-help"} - onChange={event => { - const value = event.currentTarget.value; - setDraftWindow(current => ({ ...current, since: value })); - setRangeError(null); - }} /> - </label> - <label> - <span className="field-label">{t("usage.range.end")}</span> - <input className="input" type="datetime-local" step="60" required - value={draftWindow.until} - aria-invalid={rangeError !== null} - aria-describedby={rangeError ? "usage-range-help usage-range-error" : "usage-range-help"} - onChange={event => { - const value = event.currentTarget.value; - setDraftWindow(current => ({ ...current, until: value })); - setRangeError(null); - }} /> - </label> - <button type="submit" className="btn btn-primary btn-sm">{t("usage.range.apply")}</button> - <button type="button" className="btn btn-ghost btn-sm" onClick={clearCustomWindow}>{t("usage.range.clear")}</button> + {/* + An explicit interval is the rare path — the presets answer the question almost every + time — so the two date fields open on request instead of greeting every visit as the + second thing on the page. The applied interval stays outside the panel: collapsing the + controls must never hide which window the totals below actually cover. + */} + <section className="usage-range"> + <div className="usage-range-bar"> + <button + type="button" + className={`usage-range-toggle${customWindow ? " is-active" : ""}`} + aria-expanded={rangeOpen} + // The panel is unmounted while closed, so naming it then would leave a dangling IDREF. + aria-controls={rangeOpen ? "usage-range-panel" : undefined} + // A validation failure is only legible next to the fields that caused it. Closing the + // panel would otherwise park an invisible error on a trigger that looks untouched, and + // re-render the alert on reopen for a draft the user walked away from. The check reads + // the rendered value rather than an updater argument: a setState updater has to stay + // pure, and this one would fire the second setState twice under StrictMode. + onClick={() => { + if (rangeOpen) setRangeError(null); + setRangeOpen(!rangeOpen); + }} + > + <span>{t("usage.range.custom")}</span> + <IconChevron width={12} height={12} aria-hidden="true" className="usage-range-chevron" /> + </button> + {customWindow && <p className="usage-range-applied muted text-control" role="status">{(() => { + const formatter = new Intl.DateTimeFormat(locale, { + year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", + second: "2-digit", fractionalSecondDigits: 3, timeZoneName: "short", + }); + return t("usage.range.applied", { start: formatter.format(customWindow.since), end: formatter.format(customWindow.until) }); + })()}</p>} </div> - <p id="usage-range-help" className="muted text-caption">{t("usage.range.help")}</p> - {rangeError && <p id="usage-range-error" role="alert" className="notice notice-err">{t(`usage.range.${rangeError}`)}</p>} - {customWindow && <p className="muted text-control" role="status">{(() => { - const formatter = new Intl.DateTimeFormat(locale, { - year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", - second: "2-digit", fractionalSecondDigits: 3, timeZoneName: "short", - }); - return t("usage.range.applied", { start: formatter.format(customWindow.since), end: formatter.format(customWindow.until) }); - })()}</p>} - </form> + {rangeOpen && ( + <form id="usage-range-panel" className="usage-range-panel" aria-label={t("usage.range.custom")} noValidate onSubmit={event => { + event.preventDefault(); + const result = parseUsageTimeRange(draftWindow.since, draftWindow.until); + if (result.ok === false) { + setRangeError(result.error); + return; + } + setRangeError(null); + setCustomWindow(result.window); + }}> + <div className="usage-range-fields"> + <label className="usage-range-field"> + <span className="field-label">{t("usage.range.start")}</span> + <input className="input" type="datetime-local" step="60" required + value={draftWindow.since} + aria-invalid={rangeError !== null} + aria-describedby={rangeError ? "usage-range-help usage-range-error" : "usage-range-help"} + onChange={event => { + const value = event.currentTarget.value; + setDraftWindow(current => ({ ...current, since: value })); + setRangeError(null); + }} /> + </label> + <label className="usage-range-field"> + <span className="field-label">{t("usage.range.end")}</span> + <input className="input" type="datetime-local" step="60" required + value={draftWindow.until} + aria-invalid={rangeError !== null} + aria-describedby={rangeError ? "usage-range-help usage-range-error" : "usage-range-help"} + onChange={event => { + const value = event.currentTarget.value; + setDraftWindow(current => ({ ...current, until: value })); + setRangeError(null); + }} /> + </label> + <button type="submit" className="btn btn-primary btn-sm usage-range-action">{t("usage.range.apply")}</button> + <button type="button" className="btn btn-ghost btn-sm usage-range-action" onClick={clearCustomWindow}>{t("usage.range.clear")}</button> + </div> + <p id="usage-range-help" className="muted text-caption">{t("usage.range.help")}</p> + {rangeError && <p id="usage-range-error" role="alert" className="notice notice-err">{t(`usage.range.${rangeError}`)}</p>} + </form> + )} + </section> {/* Only shown when connected. Naming the source is a two-plane concept: it answers "which store served these numbers", and that question only exists once there are diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 6606c4f560..c71687acb9 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -439,6 +439,7 @@ function VisionAdvancedPopover({ t, open, triggerRef, onClose, maxValue, maxInva export function DashboardSidecarPanels({ d }: { d: Dash }) { const { t, settings, settingsSaving, syncing, toggleCodexAutoStart, toggleCodexDesktopAuthless, + toggleCodexClientCompaction, sidecar, sidecarSaving, sidecarModels, visionModels, models, saveSidecar, shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, } = d; @@ -525,6 +526,26 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { </div> </div> + <div className="panel"> + <div className="spread"> + <div style={{ flex: 1, minWidth: 0 }}> + <div className="font-semibold">{t("dash.codexClientCompaction")}</div> + <div className="muted setting-hint">{t("dash.codexClientCompactionHint")}</div> + {settings?.catalogRefreshPending && <div className="muted setting-hint" role="status">{t("codexAuth.catalogRefreshPending")}</div>} + </div> + <button + type="button" + className={`switch ${settings?.codexClientCompaction ?? false ? "on" : ""}`} + onClick={toggleCodexClientCompaction} + disabled={!settings || settingsSaving || syncing} + aria-label={t("dash.codexClientCompaction")} + aria-pressed={settings?.codexClientCompaction ?? false} + > + <span className="knob" /> + </button> + </div> + </div> + <div className="dash-sidecar-grid"> {/* Both sidecar cards wear the DashboardInjectionPanel shell: the PANEL is the flex row, copy left, controls right. */} diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index dc28019423..27343629b4 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -49,6 +49,7 @@ export interface ModelInfo { id: string; provider: string; namespaced: string; o export interface SettingsData { codexAutoStart: boolean; codexDesktopAuthless?: boolean; + codexClientCompaction?: boolean; catalogRefreshPending?: boolean; /** Whether a login may open a browser on the machine running the proxy. */ oauthOpenBrowser?: boolean; diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index b662773b9a..5bbe1210bc 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -70,7 +70,7 @@ type CachedOverview = { type MaMode = "v1" | "default" | "v2"; -type CodexPreference = "codexAutoStart" | "codexDesktopAuthless"; +type CodexPreference = "codexAutoStart" | "codexDesktopAuthless" | "codexClientCompaction"; type DashboardSettingsState = { settings: SettingsData | null; beforeSave: SettingsData | null; @@ -81,7 +81,6 @@ type DashboardSettingsAction = | { type: "save-succeeded"; key: CodexPreference; settings: SettingsData } | { type: "save-failed" } | { type: "save-finished" } - | { type: "server-saved"; server: NonNullable<SettingsData["server"]> } | { type: "applied" }; // Own both server snapshots and the local save/apply transaction. A poll has no @@ -107,7 +106,9 @@ function dashboardSettingsReducer(state: DashboardSettingsState, action: Dashboa settings: { ...state.settings, [action.key]: action.settings[action.key], - catalogRefreshPending: action.key === "codexDesktopAuthless" ? true : state.settings.catalogRefreshPending, + catalogRefreshPending: action.key === "codexDesktopAuthless" || action.key === "codexClientCompaction" + ? true + : state.settings.catalogRefreshPending, startupHealth: action.settings.startupHealth ?? state.settings.startupHealth, }, }; @@ -115,9 +116,6 @@ function dashboardSettingsReducer(state: DashboardSettingsState, action: Dashboa return state.beforeSave ? { ...state, settings: state.beforeSave } : state; case "save-finished": return { ...state, beforeSave: null }; - case "server-saved": - if (!state.settings) return state; - return { ...state, settings: { ...state.settings, server: action.server } }; case "applied": return state.settings ? { ...state, settings: { ...state.settings, catalogRefreshPending: false } } : state; } @@ -216,6 +214,7 @@ export function useDashboardData(apiBase: string) { const [updateError, setUpdateError] = useState<string | null>(null); const [updateJob, setUpdateJob] = useState<UpdateJob | null>(null); const [reconnecting, setReconnecting] = useState(false); + const [error, setError] = useState(false); const effortCapHelpTriggerRef = useRef<HTMLButtonElement>(null); const updateTriggerRef = useRef<HTMLButtonElement>(null); const maHelpTriggerRef = useRef<HTMLButtonElement>(null); @@ -281,9 +280,6 @@ export function useDashboardData(apiBase: string) { { pollMs: 5000 }, ); const overviewReady = health !== null || overviewPoll.data !== undefined; - // A cold failure replaces the page; later failures retain the last good overview. - const error = overviewPoll.error !== undefined && health === null && !overviewPoll.hasSucceeded; - const overviewReconnecting = overviewPoll.error !== undefined && !error; // Preferences that are just config — never gate on overview or injection. const maModePoll = useKeyedClientResource( @@ -374,6 +370,7 @@ export function useDashboardData(apiBase: string) { providers: data.providers, }); } + setError(data.error); }, [overviewPoll.data, apiBase]); useEffect(() => { @@ -682,36 +679,19 @@ export function useDashboardData(apiBase: string) { const data = await requireJson<SettingsData>(res, "save failed"); settingsMutationEpochRef.current += 1; dispatchSettings({ type: "save-succeeded", key, settings: data }); - if (key === "codexDesktopAuthless") await runSync(); - } catch (err) { + if (key === "codexDesktopAuthless" || key === "codexClientCompaction") await runSync(); + } catch { dispatchSettings({ type: "save-failed" }); - setSyncError(err instanceof Error ? err.message : String(err)); + setError(true); } finally { settingsMutationInFlightRef.current = false; dispatchSettings({ type: "save-finished" }); } }; - const saveServerSettings = async ( - server: NonNullable<SettingsData["server"]>["configured"], - ): Promise<NonNullable<SettingsData["server"]>> => { - settingsMutationInFlightRef.current = true; - try { - const res = await fetch(`${apiBase}/api/settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ server }), - }); - const data = await requireJson<{ server: NonNullable<SettingsData["server"]> }>(res, t("dash.serverSaveFailed")); - settingsMutationEpochRef.current += 1; - dispatchSettings({ type: "server-saved", server: data.server }); - return data.server; - } finally { - settingsMutationInFlightRef.current = false; - } - }; const toggleCodexAutoStart = () => toggleCodexSetting("codexAutoStart"); const toggleCodexDesktopAuthless = () => toggleCodexSetting("codexDesktopAuthless"); + const toggleCodexClientCompaction = () => toggleCodexSetting("codexClientCompaction"); // Clears the sync result/error in this hook. The dashboard toast owns its own dismissal // timer but must publish the dismissal here: syncResult/syncError live above the dashboard @@ -870,13 +850,12 @@ export function useDashboardData(apiBase: string) { effortCap, subagentEffortCap, effortCapSaving, setEffortCap, setSubagentEffortCap, setEffortCapSaving, syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, - updateCheck, updateError, updateJob, reconnecting, error, overviewReconnecting, - retryOverview: overviewPoll.refresh, + updateCheck, updateError, updateJob, reconnecting, error, effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, filteredGroups, sidecarModels, visionModels, saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, toggleCodexDesktopAuthless, - saveServerSettings, runSync, clearSyncFeedback, + toggleCodexClientCompaction, runSync, clearSyncFeedback, fetchUpdateCheck, closeUpdateDialog, openUpdateDialog, changeUpdateChannel, runUpdate, }; } diff --git a/gui/src/pages/use-subagent-delegation.ts b/gui/src/pages/use-subagent-delegation.ts index eb36e339b4..7c1dbbc5dc 100644 --- a/gui/src/pages/use-subagent-delegation.ts +++ b/gui/src/pages/use-subagent-delegation.ts @@ -22,11 +22,17 @@ export type DelegationPatch = { }; /** Ultra mode (Proactive delegation for every model/effort) via /api/v2. */ +export type UltraModeHintRecommendation = { + text: string; + revision: string; +}; + export type UltraModeState = { loaded?: boolean; keepNativeChatGptOnV1?: boolean; enabled: boolean; hintText: string | null; + recommendation: UltraModeHintRecommendation | null; multiAgentV2Enabled: boolean; /** The raw multi-agent mode; Subagents renders the v1/base/v2 switch from it. */ multiAgentMode: "v1" | "default" | "v2"; diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 7f7996a085..99f46a93c4 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -43,6 +43,14 @@ const PROVIDER_ICON_ALIASES: Record<string, string> = { "opencode-zen": "opencode.svg", openrouter: "openrouter-color.svg", qianfan: "qianfan-color.svg", + /* + * Qoder Global and Qoder CN are one brand on two operators (BRIGHT ZENITH + * PRIVATE LIMITED and 通义云启(杭州)信息技术有限公司), the meta-model/meta-muse + * shape. codebuddy / codebuddy-cn deliberately have no alias: see the + * provider-icons README for the terms clause that forbids the Tencent mark. + */ + qoder: "qoder.svg", + "qoder-cn": "qoder.svg", alibaba: "alibaba-color.svg", "alibaba-token-plan": "alibaba-color.svg", "alibaba-token-plan-intl": "alibaba-color.svg", @@ -61,6 +69,7 @@ const PROVIDER_ICON_ALIASES: Record<string, string> = { novita: "novita.svg", orcarouter: "orcarouter.svg", "orcarouter-oauth": "orcarouter.svg", + packycode: "packycode.svg", parallel: "parallel.svg", sambanova: "sambanova.svg", scaleway: "scaleway.svg", @@ -124,6 +133,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = { "opencode-zen": "OpenCode Zen", orcarouter: "OrcaRouter - API", "orcarouter-oauth": "OrcaRouter - Auth", + packycode: "PackyCode", mistral: "Mistral", groq: "Groq", "meta-model": "Meta Model API", @@ -141,6 +151,10 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = { "qwen-cloud": "Qwen Cloud", siliconflow: "SiliconFlow", "tencent-coding-plan": "Tencent Cloud Coding Plan", + codebuddy: "CodeBuddy", + "codebuddy-cn": "CodeBuddy CN", + qoder: "Qoder", + "qoder-cn": "Qoder CN", "vercel-ai-gateway": "Vercel AI Gateway", vllm: "vLLM", litellm: "LiteLLM", @@ -200,6 +214,7 @@ const MASKED_PROVIDER_ICONS: ReadonlySet<string> = new Set([ "neuralwatt.svg", "nous.svg", "novita.svg", + "packycode.svg", "siliconflow.svg", "synthetic.svg", "zenmux.svg", diff --git a/gui/src/styles-subagents-workspace.css b/gui/src/styles-subagents-workspace.css index a9f9466ce7..2e4bde01ed 100644 --- a/gui/src/styles-subagents-workspace.css +++ b/gui/src/styles-subagents-workspace.css @@ -88,8 +88,22 @@ margin-top: 3px; max-width: 72ch; line-height: 1.5; + overflow-wrap: anywhere; } +.swi-ultra-mode-editor { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: start; +} + +.swi-ultra-mode-textarea { + min-width: 0; + width: 100%; +} + +.swi-ultra-mode-editor > button { justify-self: end; } + .swi-delegation-controls { display: flex; align-items: center; @@ -587,4 +601,6 @@ @media (max-width: 640px) { .swi-fallback-editor { flex-direction: column; } .swi-fallback-controls { width: 100%; } + .swi-ultra-mode-editor { grid-template-columns: minmax(0, 1fr) auto; } + .swi-ultra-mode-textarea { grid-column: 1 / -1; } } diff --git a/gui/src/styles.css b/gui/src/styles.css index a5838be7e4..1a51a2f40e 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2508,6 +2508,45 @@ button.prov-account-row.active { cursor: default; } /* Top-align with Storage: centering against the taller filter chips dropped the "Usage" title. */ .usage-head { flex-wrap: wrap; align-items: flex-start; } .usage-filters { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } +/* Custom range: a disclosure, not a second filter row. It reuses the chip language of the + segmented presets above it so the page reads as one control area, and the panel is + `width: max-content` because a four-control form stretched to 1200px is what made the old + flex-end row look like a stray band across the page. */ +.usage-range { display: grid; justify-items: start; gap: 8px; margin: 10px 0 4px; } +.usage-range-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; max-width: 100%; } +.usage-range-toggle { + display: inline-flex; align-items: center; gap: 6px; + min-height: var(--control-sm); padding: 4px 12px; + border: 1px solid var(--border); border-radius: var(--radius-pill); + background: var(--surface); color: var(--muted); + font: inherit; font-size: var(--text-label); font-weight: var(--weight-medium); + white-space: nowrap; cursor: pointer; + transition: color var(--motion-fast), background var(--motion-fast), border-color var(--motion-fast); +} +.usage-range-toggle:hover { background: var(--raised); color: var(--text); } +.usage-range-toggle[aria-expanded="true"] { color: var(--text); } +.usage-range-toggle.is-active { border-color: var(--accent); color: var(--text); } +.usage-range-chevron { flex: 0 0 auto; transition: transform var(--motion-fast); } +.usage-range-toggle[aria-expanded="true"] .usage-range-chevron { transform: rotate(90deg); } +.usage-range-applied { margin: 0; min-width: 0; } +.usage-range-panel { + display: grid; gap: 10px; width: max-content; max-width: 100%; + padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); + background: var(--surface); +} +/* `align-items: end` is the fix for the old row: a label+input stack is twice the height of a + `btn-sm`, so centering left Apply and Clear floating beside the middle of the fields. + Every track is `auto` on purpose. A fixed 200px cap does not survive contact with a + `datetime-local` control: its intrinsic minimum is about 206px in Chrome at this font size, + and it grows again for locales whose date format is longer than `mm/dd/yyyy`. The fields + then overflow their tracks and eat the gap, which is the exact rhythm defect this block + exists to fix. */ +.usage-range-fields { display: grid; grid-template-columns: repeat(4, auto); justify-content: start; align-items: end; gap: 8px; } +.usage-range-field { display: grid; gap: 4px; min-width: 0; } +.usage-range-field .field-label { margin: 0; } +.usage-range-fields .input { height: var(--control-md); padding-block: 0; } +.usage-range-action { min-height: var(--control-md); } +.usage-range-panel > p { margin: 0; } .usage-segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 2px; gap: 2px; background: var(--surface); } .usage-segmented-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: none; background: transparent; color: var(--muted); padding: 4px 12px; border-radius: var(--radius-pill); cursor: pointer; font: inherit; white-space: nowrap; } .usage-segmented-btn.active { background: var(--raised); color: var(--text); font-weight: var(--weight-semibold); } @@ -2529,6 +2568,11 @@ button.prov-account-row.active { cursor: default; } @media (max-width: 640px) { .usage-source-btn .usage-source-label-collapsible { display: none; } + /* Two 200px columns plus both actions stop fitting a phone content width; the panel takes + the full row and every control keeps the same height it has on desktop. */ + .usage-range-panel { width: 100%; } + .usage-range-fields { grid-template-columns: minmax(0, 1fr); } + .usage-range-action { width: 100%; } } @media (max-width: 360px) { diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index cb5c14b15d..45f9b4d16b 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -605,7 +605,7 @@ /* Overview 2-column layout (Phase 030) */ .pws-overview-layout { display: grid; - grid-template-columns: 1fr 280px; + grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; align-items: start; } @@ -618,6 +618,7 @@ } .pws-overview-sidebar { + min-width: 0; display: flex; flex-direction: column; gap: 20px; @@ -678,11 +679,81 @@ } /* Notes section */ +.pws-sponsor { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 20px 32px; + margin-bottom: 28px; + padding: 22px 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.pws-sponsor-copy { flex: 1 1 320px; min-width: 0; } +.pws-sponsor-byline { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + font-size: 0.8rem; + font-weight: 600; +} +.pws-sponsor-badge { + padding: 2px 7px; + border-radius: var(--radius-2xs); + background: var(--raised); + color: var(--muted); + font-size: 0.7rem; + font-weight: 500; +} +.pws-sponsor h3 { + margin: 0 0 6px; + font-size: 1.08rem; + font-weight: 600; + line-height: 1.45; + text-wrap: balance; + word-break: keep-all; +} +.pws-sponsor p { + margin: 0; + max-width: 64ch; + color: var(--muted); + font-size: 0.85rem; + line-height: 1.65; + text-wrap: balance; + word-break: keep-all; +} +.pws-sponsor-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} +.pws-sponsor-actions a { gap: 8px; min-height: 36px; white-space: nowrap; } +.pws-sponsor-console { + display: inline-flex; + align-items: center; + font-size: 0.8rem; + text-decoration: underline; + text-underline-offset: 3px; +} +.pws-sponsor a:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } +@container provider-workspace (max-width: 600px) { + .pws-sponsor { padding: 18px; gap: 16px; } + .pws-sponsor-actions { align-items: flex-start; } + .pws-sponsor-actions a { min-height: 44px; } +} + .pws-notes-section { min-width: 0; } .pws-notes-display { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.65; display: block; width: 100%; text-align: left; @@ -779,6 +850,8 @@ } .pws-detail-tab { + flex-shrink: 0; + white-space: nowrap; appearance: none; background: none; border: none; diff --git a/gui/tests/multi-agent-guidance.test.tsx b/gui/tests/multi-agent-guidance.test.tsx index edf53cf21f..300e97fcf2 100644 --- a/gui/tests/multi-agent-guidance.test.tsx +++ b/gui/tests/multi-agent-guidance.test.tsx @@ -65,7 +65,7 @@ function props(overrides: Partial<Props> = {}): Props { guidanceEnabled: false, syncCodexDefaults: true, onSave: (patch) => { requests.push(patch); }, - ultraMode: { enabled: false, hintText: null, multiAgentV2Enabled: false, multiAgentMode: "default" }, + ultraMode: { enabled: false, hintText: null, recommendation: null, multiAgentV2Enabled: false, multiAgentMode: "default" }, fallback: [], fallbackPollMs: 60000, fallbackBusy: false, @@ -164,3 +164,32 @@ test("sends model clearing through the shared save path", async () => { expect(requests).toEqual([{ model: null, effort: "high" }]); }); + +test("a recommendation-only refresh preserves the draft until Restore is chosen", async () => { + const current = props({ + ultraMode: { + enabled: true, hintText: "stored custom", multiAgentV2Enabled: true, multiAgentMode: "v2", + recommendation: { text: "old recommendation", revision: "old" }, + }, + onUltraModeSave: patch => { requests.push(patch); }, + }); + await mount(current); + const textarea = host.querySelector<HTMLTextAreaElement>(".swi-ultra-mode-editor textarea")!; + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "unsaved custom draft"); + textarea.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + textarea.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + await act(async () => { + root!.render(<LanguageProvider><SubagentDelegationSection {...current} ultraMode={{ + ...current.ultraMode, recommendation: { text: "new recommendation", revision: "new" }, + }} /></LanguageProvider>); + }); + expect(textarea.value).toBe("unsaved custom draft"); + expect(requests).toHaveLength(0); + const restore = [...host.querySelectorAll<HTMLButtonElement>(".swi-ultra-mode-editor button")] + .find(button => button.textContent?.trim() === "Restore preset")!; + await act(async () => { restore.click(); }); + expect(textarea.value).toBe("new recommendation"); + expect(requests).toHaveLength(0); +}); diff --git a/gui/tests/provider-catalog-sponsor-pinning.test.ts b/gui/tests/provider-catalog-sponsor-pinning.test.ts new file mode 100644 index 0000000000..4db0c5c107 --- /dev/null +++ b/gui/tests/provider-catalog-sponsor-pinning.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { pinSponsors, type CatalogPreset } from "../src/components/provider-catalog/provider-presets"; + +/** + * SPONSORS.md promises a Standard sponsor "a built-in preset near the top of the provider + * picker". This is the function that keeps that promise, and the two properties that make + * it honest: sponsors come first in an order no sponsor can buy (Main before Standard, then + * alphabetical), and everything after them keeps the caller's usage/label order untouched. + */ + +const row = (id: string, label: string, sponsor?: CatalogPreset["sponsor"]): CatalogPreset => ({ + id, label, adapter: "openai-chat", baseUrl: `https://${id}.example/v1`, auth: "key", + ...(sponsor ? { sponsor, sponsorUrl: `https://${id}.example/?utm_source=opencodex` } : {}), +}); + +test("sponsors are pinned first, Main before Standard, alphabetical within a tier", () => { + const input = [ + row("zeta", "Zeta"), + row("packycode", "PackyCode", "standard"), + row("alpha", "Alpha"), + row("orcarouter", "OrcaRouter", "standard"), + row("moon", "Moon Labs", "main"), + ]; + expect(pinSponsors(input).map(p => p.id)).toEqual(["moon", "orcarouter", "packycode", "zeta", "alpha"]); +}); + +test("alphabetical among sponsors ignores registry position and case", () => { + // Ids run z, y, x against labels bravo, ALPHA, charlie: sorting by id instead of label fails here. + const input = [row("z", "bravo", "standard"), row("y", "ALPHA", "standard"), row("x", "charlie", "standard")]; + expect(pinSponsors(input).map(p => p.id)).toEqual(["y", "z", "x"]); +}); + +test("with no sponsors the input order is returned as-is", () => { + const input = [row("zeta", "Zeta"), row("alpha", "Alpha")]; + expect(pinSponsors(input)).toBe(input); +}); diff --git a/gui/tests/provider-icons.test.ts b/gui/tests/provider-icons.test.ts index b0609255b8..704c2978d6 100644 --- a/gui/tests/provider-icons.test.ts +++ b/gui/tests/provider-icons.test.ts @@ -92,3 +92,28 @@ test("both Meta provider ids resolve to the Meta mark", () => { expect(providerIconSrc("meta-model")).toBe("/provider-icons/meta.svg"); expect(providerIconSrc("meta-muse")).toBe("/provider-icons/meta.svg"); }); + +/* + * One brand, two operators. + * + * `qoder` (BRIGHT ZENITH PRIVATE LIMITED) and `qoder-cn` (通义云启(杭州)信息技术有限公司) + * share Qoder's declared site icon, the meta-model/meta-muse shape. Pinned explicitly + * for the same reason as the Meta pair: the generic wiring check only fires when an + * asset named after the id is committed, and `qoder-cn` is not `qoder`. + * + * CodeBuddy is the opposite decision and is pinned too. Tencent publishes a usable + * symbol, but §9.3 of the CodeBuddy service agreement forbids using Tencent brand + * features without written consent, so both ids keep the initials tile on purpose. + * Nothing else can tell "absent by decision" from "forgotten"; this can. + */ +test("both Qoder provider ids resolve to the Qoder mark", () => { + expect(providerIconSrc("qoder")).toBe("/provider-icons/qoder.svg"); + expect(providerIconSrc("qoder-cn")).toBe("/provider-icons/qoder.svg"); +}); + +test("CodeBuddy keeps the initials tile by decision, not by omission", () => { + expect(providerIconSrc("codebuddy")).toBeUndefined(); + expect(providerIconSrc("codebuddy-cn")).toBeUndefined(); + expect(existsSync(join(PUBLIC_DIR, "codebuddy.svg"))).toBe(false); + expect(existsSync(join(PUBLIC_DIR, "codebuddy-cn.svg"))).toBe(false); +}); diff --git a/gui/tests/provider-sponsor-overview.test.tsx b/gui/tests/provider-sponsor-overview.test.tsx new file mode 100644 index 0000000000..bdf0b30c49 --- /dev/null +++ b/gui/tests/provider-sponsor-overview.test.tsx @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { LanguageProvider } from "../src/i18n/provider"; +import ProviderOverview from "../src/components/provider-workspace/ProviderOverview"; +import ProviderSponsor from "../src/components/provider-workspace/ProviderSponsor"; +import { matchingWorkspacePreset, type CatalogPreset } from "../src/components/provider-catalog/provider-presets"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +const orca: CatalogPreset = { + id: "orcarouter", label: "OrcaRouter - API", adapter: "openai-chat", auth: "key", + baseUrl: "https://api.orcarouter.ai/v1", sponsor: "standard", + sponsorUrl: "https://www.orcarouter.ai/?utm_source=opencodex&utm_medium=readme", + dashboardUrl: "https://www.orcarouter.ai/console", +}; +const packy: CatalogPreset = { + id: "packycode", label: "PackyCode", adapter: "openai-chat", auth: "key", + baseUrl: "https://cf.api.fan/v1", sponsor: "standard", + sponsorUrl: "https://www.packyapi.com/register?aff=k5KT", + dashboardUrl: "https://www.packyapi.com/register?aff=k5KT", +}; +const configured = (preset: CatalogPreset): WorkspaceItem => ({ + name: preset.id, adapter: preset.adapter, baseUrl: preset.baseUrl, authMode: preset.auth, +}); +function render(preset?: CatalogPreset, item = configured(orca)) { + return renderToStaticMarkup(<LanguageProvider><ProviderSponsor preset={preset} item={item} /></LanguageProvider>); +} + +test("sponsor is matched by id, adapter and complete endpoint, tolerating trailing slash", () => { + const item = configured(orca); + const credentialEndpoint = new URL(item.baseUrl); + credentialEndpoint.username = "fixture-user"; + credentialEndpoint.password = "fixture-password"; + expect(matchingWorkspacePreset({ ...item, baseUrl: `${item.baseUrl}/` }, [orca])).toBe(orca); + for (const changed of [ + { name: "renamed" }, { adapter: "anthropic" }, { baseUrl: "https://other.example/v1" }, + { baseUrl: "https://api.orcarouter.ai/v2" }, { baseUrl: `${item.baseUrl}?key=secret` }, + { baseUrl: credentialEndpoint.href }, { baseUrl: "invalid" }, + ]) { + expect(matchingWorkspacePreset({ ...item, ...changed }, [orca])).toBeUndefined(); + expect(render(orca, { ...item, ...changed })).toBe(""); + } + expect(matchingWorkspacePreset(item, [])).toBeUndefined(); +}); + +test("key and OAuth sponsor presets render disclosed links, keeping affiliate parameters", () => { + // Only the key-auth `orcarouter` row carries `sponsor` in the registry today, so the oauth + // case is the property that an auth mode never suppresses the block — not a second pinned row. + for (const preset of [orca, { ...orca, id: "orcarouter-oauth", auth: "oauth" as const }]) { + const html = render(preset, configured(preset)); + expect(html).toContain("pws-sponsor-badge"); + expect(html).toContain("utm_source=opencodex&utm_medium=readme"); + expect(html).toContain('href="https://www.orcarouter.ai/console"'); + expect(html.match(/rel="noopener noreferrer"/g)).toHaveLength(2); + } +}); + +test("Packy preserves its affiliate link and does not repeat an identical console link", () => { + const html = render(packy, configured(packy)); + expect(html).toContain('href="https://www.packyapi.com/register?aff=k5KT"'); + expect(html.match(/<a /g)).toHaveLength(1); +}); + +test("missing and non-sponsor presets render nothing; non-web links never become anchors", () => { + expect(render()).toBe(""); + expect(render({ ...orca, sponsor: undefined })).toBe(""); + const html = render({ ...orca, sponsorUrl: "javascript:alert(1)", dashboardUrl: "data:text/html,bad" }); + expect(html).not.toContain("<a "); + expect(html).not.toContain("javascript:"); +}); + +test("overview keeps the complete note exactly once in the wider editable section", () => { + const note = "A provider limitation that must remain visible. https://example.test/details"; + const html = renderToStaticMarkup(<LanguageProvider><ProviderOverview item={{ ...configured(orca), note }} preset={orca} /></LanguageProvider>); + expect(html.split(note)).toHaveLength(2); + expect(html.indexOf("pws-notes-section")).toBeLessThan(html.indexOf("pws-overview-sidebar")); +}); diff --git a/gui/tests/subagents-ultra-mode.test.tsx b/gui/tests/subagents-ultra-mode.test.tsx index d0f164ce63..22cfc459be 100644 --- a/gui/tests/subagents-ultra-mode.test.tsx +++ b/gui/tests/subagents-ultra-mode.test.tsx @@ -1,9 +1,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import type { Root } from "react-dom/client"; import Subagents from "../src/pages/Subagents"; -import { ULTRA_MODE_PRESET } from "../src/components/subagents-workspace/SubagentDelegationSection"; import { LanguageProvider } from "../src/i18n/provider"; const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; @@ -14,12 +13,7 @@ let root: Root | null = null; let v2Responses: Array<{ ok: boolean; body: unknown; status?: number }> = []; let v2Call = 0; let requests: Array<{ url: string; init?: RequestInit }> = []; -let injectionAvailable: Array<{ provider: string; model: string; namespaced: string; canonical?: boolean }> = []; -let nativeOverrideServer = { enabled: false, model: null as string | null, active: false }; -let nativeOverridePutError: string | null = null; -let nativeOverrideAfterPut: Partial<typeof nativeOverrideServer> | null = null; -let routedBridgeServer = false; -let routedBridgePutError: string | null = null; +const recommendation = { text: "server-supplied proactive policy", revision: "test-policy-v1" }; function response(body: unknown, ok = true, status = 200): Response { return { @@ -45,12 +39,6 @@ beforeEach(() => { requests = []; v2Responses = []; v2Call = 0; - injectionAvailable = []; - nativeOverrideServer = { enabled: false, model: null, active: false }; - nativeOverridePutError = null; - nativeOverrideAfterPut = null; - routedBridgeServer = false; - routedBridgePutError = null; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { @@ -58,30 +46,13 @@ beforeEach(() => { const path = new URL(String(url), "http://localhost/").pathname; if (path === "/api/v2") { if (init?.method === "PUT") { - const body = JSON.parse(String(init.body ?? "{}")) as { v2NativeParentOverride?: { enabled?: boolean; model?: string | null }; v2RoutedDelegationBridge?: boolean }; - if (typeof body.v2RoutedDelegationBridge === "boolean") { - if (routedBridgePutError) return response({ error: routedBridgePutError }, false, 400); - routedBridgeServer = body.v2RoutedDelegationBridge; - return response({ ok: true, v2RoutedDelegationBridge: routedBridgeServer }); - } - if (body.v2NativeParentOverride) { - if (nativeOverridePutError) return response({ error: nativeOverridePutError }, false, 400); - nativeOverrideServer = { ...nativeOverrideServer, ...body.v2NativeParentOverride, ...nativeOverrideAfterPut }; - nativeOverrideAfterPut = null; - return response({ ok: true, v2NativeParentOverride: nativeOverrideServer }); - } const latest = v2Responses.at(-1)?.body ?? { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null }; return response(latest); } const next = v2Responses[Math.min(v2Call++, Math.max(v2Responses.length - 1, 0))]; - if (!next) return response({ enabled: false, v2NativeParentOverride: nativeOverrideServer }); - const body = next.body && typeof next.body === "object" && !Array.isArray(next.body) && !Object.hasOwn(next.body, "v2NativeParentOverride") && !Object.hasOwn(next.body, "v2RoutedDelegationBridge") - ? { ...next.body, v2NativeParentOverride: nativeOverrideServer, v2RoutedDelegationBridge: routedBridgeServer } - : next.body; - return response(body, next.ok, next.status ?? (next.ok ? 200 : 500)); + return next ? response(next.body, next.ok, next.status ?? (next.ok ? 200 : 500)) : response({ enabled: false }); } if (path === "/api/subagent-models") return response({ available: [], chosen: [] }); - if (path === "/api/injection-model") return response({ available: injectionAvailable, efforts: [] }); if (path === "/api/subagent-model-fallback") return response({ available: [], models: [], pollMs: 60_000 }); if (path === "/api/injection-model") return response({ available: [], efforts: [] }); return response({}); @@ -104,6 +75,7 @@ afterEach(async () => { }); async function mount(apiBase = "") { + const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(container); root.render( @@ -117,125 +89,186 @@ async function mount(apiBase = "") { function ultraSwitch(): HTMLButtonElement { const button = Array.from(container.querySelectorAll("button")) - .find(candidate => candidate.getAttribute("aria-label") === "Ultra mode"); - if (!button) throw new Error("Ultra mode switch not found"); + .find(candidate => candidate.getAttribute("aria-label") === "Always proactive delegation"); + if (!button) throw new Error("Always proactive delegation switch not found"); return button as HTMLButtonElement; } -function nativeParentSwitch(): HTMLButtonElement { - const button = Array.from(container.querySelectorAll("button")) - .find(candidate => candidate.getAttribute("aria-label") === "Route native V2 parents"); - if (!button) throw new Error("Native parent switch not found"); - return button as HTMLButtonElement; -} - -function nativeParentSelect(): HTMLButtonElement { - const button = container.querySelector<HTMLButtonElement>('button[role="combobox"][aria-label="Native parent target model"]'); - if (!button) throw new Error("Native parent model select not found"); - return button; -} - -function routedBridgeSwitch(): HTMLButtonElement { - const button = Array.from(container.querySelectorAll("button")) - .find(candidate => candidate.getAttribute("aria-label") === "Routed V2 delegation bridge"); - if (!button) throw new Error("Routed delegation bridge switch not found"); - return button as HTMLButtonElement; -} - -function routedBridgePuts(): unknown[] { - return requests - .filter(row => row.url.endsWith("/api/v2") && row.init?.method === "PUT") - .map(row => JSON.parse(String(row.init?.body ?? "{}"))) - .filter(body => Object.hasOwn(body, "v2RoutedDelegationBridge")); -} - -test("loads the routed delegation bridge off by default with an accessible switch", async () => { - v2Responses = [{ ok: true, body: { enabled: false, multiAgentMode: "default" } }]; +test("does not enable Ultra mode for the default surface even when V2 is enabled", async () => { + v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "default", multiAgentModeHintText: null, multiAgentModeHintRecommendation: recommendation } }]; await mount(); - expect(routedBridgeSwitch().getAttribute("aria-pressed")).toBe("false"); - expect(routedBridgeSwitch().disabled).toBe(false); + expect(ultraSwitch().disabled).toBe(true); + expect(ultraSwitch().getAttribute("aria-pressed")).toBe("false"); }); -test("saves only the routed delegation bridge scalar and refreshes server state", async () => { +test("clears the page load error after a successful Ultra mode retry", async () => { v2Responses = [ - { ok: true, body: { enabled: false, multiAgentMode: "default" } }, - { ok: true, body: { enabled: false, multiAgentMode: "default", v2RoutedDelegationBridge: false } }, + { ok: false, body: { error: "temporary failure" }, status: 503 }, + { ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, multiAgentModeHintRecommendation: recommendation } }, ]; await mount(); - await act(async () => { routedBridgeSwitch().click(); }); + expect(container.textContent).toContain("Failed to load proactive delegation settings"); + const ultraErrorRow = Array.from(container.querySelectorAll(".swi-delegation-row")) + .find(row => row.textContent?.includes("Failed to load proactive delegation settings")); + const retry = ultraErrorRow?.querySelector<HTMLButtonElement>("button"); + expect(retry).toBeTruthy(); + + await act(async () => { retry!.click(); }); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - expect(routedBridgePuts()).toEqual([{ v2RoutedDelegationBridge: true }]); - expect(routedBridgeSwitch().getAttribute("aria-pressed")).toBe("false"); + expect(v2Call).toBe(2); + expect(container.textContent).not.toContain("Failed to load proactive delegation settings"); + expect(ultraSwitch().disabled).toBe(false); }); -test("retains the loaded routed delegation bridge state after a failed save", async () => { - routedBridgeServer = true; - routedBridgePutError = "bridge update rejected"; - v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "v2" } }]; +test("enabling Ultra mode uses the server-supplied recommendation", async () => { + v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, multiAgentModeHintRecommendation: recommendation } }]; await mount(); - await act(async () => { routedBridgeSwitch().click(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + await act(async () => { ultraSwitch().click(); }); - expect(routedBridgeSwitch().getAttribute("aria-pressed")).toBe("true"); - expect(container.textContent).toContain("bridge update rejected"); + const request = requests.find(item => item.init?.method === "PUT" && new URL(item.url, "http://localhost/").pathname === "/api/v2"); + expect(JSON.parse(String(request?.init?.body))).toEqual({ multiAgentModeHintText: recommendation.text }); }); -test("shows armed-but-inactive guidance outside explicit V2", async () => { - routedBridgeServer = true; - v2Responses = [{ ok: true, body: { enabled: false, multiAgentMode: "default" } }]; +test("an older server without a recommendation disables only preset installation", async () => { + v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null } }]; await mount(); - expect(container.textContent).toContain("Armed; activates for eligible native V2 roots"); + expect(ultraSwitch().disabled).toBe(true); + expect(ultraSwitch().getAttribute("aria-pressed")).toBe("false"); }); -function nativeParentPuts(): Array<{ enabled: boolean; model: string | null }> { - return requests - .filter(row => row.url.endsWith("/api/v2") && row.init?.method === "PUT") - .map(row => JSON.parse(String(row.init?.body ?? "{}")).v2NativeParentOverride) - .filter(Boolean); -} - -test("does not enable Ultra mode for the default surface even when V2 is enabled", async () => { - v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "default", multiAgentModeHintText: null } }]; +test.each([ + { text: "", revision: "r1" }, + { text: "valid", revision: " " }, + { text: 42, revision: "r1" }, +])("malformed server recommendations cannot install a preset: %j", async malformed => { + v2Responses = [{ ok: true, body: { + enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, + multiAgentModeHintRecommendation: malformed, + } }]; await mount(); expect(ultraSwitch().disabled).toBe(true); - expect(ultraSwitch().getAttribute("aria-pressed")).toBe("false"); + await act(async () => { ultraSwitch().click(); }); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); }); -test("clears the page load error after a successful Ultra mode retry", async () => { - v2Responses = [ - { ok: false, body: { error: "temporary failure" }, status: 503 }, - { ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null } }, - ]; +test("an older server preserves an existing custom hint and still allows clearing it", async () => { + v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: "custom policy" } }]; await mount(); - expect(container.textContent).toContain("Failed to load Ultra mode settings"); - const ultraErrorRow = Array.from(container.querySelectorAll(".swi-delegation-row")) - .find(row => row.textContent?.includes("Failed to load Ultra mode settings")); - const retry = ultraErrorRow?.querySelector<HTMLButtonElement>("button"); - expect(retry).toBeTruthy(); + expect(ultraSwitch().disabled).toBe(false); + expect(ultraSwitch().getAttribute("aria-pressed")).toBe("true"); + await act(async () => { ultraSwitch().click(); }); - await act(async () => { retry!.click(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + const request = requests.find(item => item.init?.method === "PUT" && new URL(item.url, "http://localhost/").pathname === "/api/v2"); + expect(JSON.parse(String(request?.init?.body))).toEqual({ multiAgentModeHintText: null }); +}); - expect(v2Call).toBe(2); - expect(container.textContent).not.toContain("Failed to load Ultra mode settings"); - expect(ultraSwitch().disabled).toBe(false); +test.each([undefined, { text: "", revision: "r1" }])("custom hints remain editable without a valid recommendation: %j", async unavailable => { + v2Responses = [{ ok: true, body: { + enabled: true, multiAgentMode: "v2", multiAgentModeHintText: "custom policy", + multiAgentModeHintRecommendation: unavailable, + } }]; + await mount(); + const editor = container.querySelector(".swi-ultra-mode-editor")!; + const textarea = editor.querySelector("textarea")!; + const restore = [...editor.querySelectorAll("button")].find(button => button.textContent?.trim() === "Restore preset")!; + const save = [...editor.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!; + const custom = " my custom policy\nwith a preserved trailing space "; + expect(restore.disabled).toBe(true); + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, custom); + textarea.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + textarea.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); + await act(async () => { save.click(); }); + const puts = requests.filter(item => item.init?.method === "PUT"); + expect(puts).toHaveLength(1); + expect(JSON.parse(String(puts[0].init?.body))).toEqual({ multiAgentModeHintText: custom }); +}); + +test("a custom hint loads without writing and restore stays local until Save", async () => { + v2Responses = [{ ok: true, body: { + enabled: true, + multiAgentMode: "v2", + multiAgentModeHintText: "custom policy", + multiAgentModeHintRecommendation: recommendation, + } }]; + await mount(); + + const editor = container.querySelector(".swi-ultra-mode-editor"); + const textarea = editor?.querySelector("textarea") as HTMLTextAreaElement | null; + const restore = Array.from(editor?.querySelectorAll("button") ?? []) + .find(button => button.textContent?.trim() === "Restore preset"); + const save = Array.from(editor?.querySelectorAll("button") ?? []) + .find(button => button.textContent?.trim() === "Save"); + + expect(textarea?.value).toBe("custom policy"); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); + + await act(async () => { (restore as HTMLButtonElement).click(); }); + expect(textarea?.value).toBe(recommendation.text); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); + + await act(async () => { (save as HTMLButtonElement).click(); }); + const put = requests.find(item => item.init?.method === "PUT" && new URL(item.url, "http://localhost/").pathname === "/api/v2"); + expect(JSON.parse(String(put?.init?.body))).toEqual({ multiAgentModeHintText: recommendation.text }); }); -test("uses the complete canonical proactive delegation preset", () => { - expect(ULTRA_MODE_PRESET).toBe([ - "Proactive multi-agent delegation is active.", - "Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies.", - "Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently.", - "Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself.", - "This mode remains active until a later multi-agent mode developer message changes it.", - ].join(" ")); +test.each([ + ["missing", undefined], + ["malformed", { text: "", revision: "b1" }], + ["valid", { text: "server-B policy", revision: "b1" }], +] as const)("server switches cannot install or restore another server's preset (%s)", async (_kind, nextRecommendation) => { + let releaseNext!: (value: Response) => void; + const nextRead = new Promise<Response>(resolve => { releaseNext = resolve; }); + const nextState = { + enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, + multiAgentModeHintRecommendation: nextRecommendation, + }; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + requests.push({ url: String(url), init }); + const path = new URL(String(url), "http://localhost/").pathname; + if (path === "/old/api/v2") return response({ + enabled: true, multiAgentMode: "v2", multiAgentModeHintText: "custom-A policy", + multiAgentModeHintRecommendation: recommendation, + }); + if (path === "/new/api/v2") return init?.method === "PUT" ? response(nextState) : nextRead; + if (path.endsWith("/api/subagent-models")) return response({ available: [], chosen: [] }); + if (path.endsWith("/api/subagent-model-fallback")) return response({ available: [], models: [], pollMs: 60_000 }); + if (path.endsWith("/api/injection-model")) return response({ available: [], efforts: [] }); + return response({}); + }, + }); + await mount("/old"); + expect(container.querySelector<HTMLTextAreaElement>(".swi-ultra-mode-editor textarea")?.value).toBe("custom-A policy"); + await act(async () => { root!.render(<LanguageProvider><Subagents apiBase="/new" /></LanguageProvider>); }); + + expect(ultraSwitch().disabled).toBe(true); + expect(container.querySelector(".swi-ultra-mode-editor")).toBeNull(); + await act(async () => { ultraSwitch().click(); }); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); + + await act(async () => { releaseNext(response(nextState)); await nextRead; }); + const valid = Boolean(nextRecommendation?.text); + expect(ultraSwitch().disabled).toBe(!valid); + await act(async () => { ultraSwitch().click(); }); + const puts = requests.filter(item => item.init?.method === "PUT"); + if (valid) { + expect(puts).toHaveLength(1); + expect(puts[0]?.url).toBe("/new/api/v2"); + expect(JSON.parse(String(puts[0]?.init?.body))).toEqual({ multiAgentModeHintText: nextRecommendation!.text }); + } else { + expect(puts).toHaveLength(0); + } }); test("a save refresh from an old API server cannot overwrite a newer server", async () => { @@ -249,7 +282,7 @@ test("a save refresh from an old API server cannot overwrite a newer server", as if (path === "/old/api/v2") { if (init?.method === "PUT") return response({ ok: true }); oldGets++; - if (oldGets === 1) return response({ enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null }); + if (oldGets === 1) return response({ enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, multiAgentModeHintRecommendation: recommendation }); return oldRefresh; } if (path === "/new/api/v2") return response({ enabled: false, multiAgentMode: "default", multiAgentModeHintText: null }); @@ -276,212 +309,10 @@ test("a save refresh from an old API server cannot overwrite a newer server", as expect(ultraSwitch().disabled).toBe(true); await act(async () => { - releaseOldRefresh(response({ enabled: true, multiAgentMode: "v2", multiAgentModeHintText: ULTRA_MODE_PRESET })); + releaseOldRefresh(response({ enabled: true, multiAgentMode: "v2", multiAgentModeHintText: recommendation.text, multiAgentModeHintRecommendation: recommendation })); await oldRefresh; await new Promise(resolve => setTimeout(resolve, 10)); }); expect(ultraSwitch().disabled).toBe(true); expect(ultraSwitch().getAttribute("aria-pressed")).toBe("false"); }); - -test("hydrates native parent override off and filters canonical ChatGPT rows", async () => { - injectionAvailable = [ - { provider: "openai", model: "gpt-5.6-luna", namespaced: "gpt-5.6-luna", canonical: true }, - { provider: "alias", model: "parent-model", namespaced: "alias/parent-model", canonical: true }, - { provider: "relay", model: "parent-model", namespaced: "relay/parent-model" }, - ]; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "v2", - keepNativeChatGptOnV1: false, - v2NativeParentOverride: { enabled: false, model: null, active: false }, - } }, { ok: true, body: { enabled: true, multiAgentMode: "v2", keepNativeChatGptOnV1: false } }]; - await mount(); - - expect(nativeParentSwitch().getAttribute("aria-pressed")).toBe("false"); - expect(container.textContent).not.toContain("alias/parent-model"); - await act(async () => { nativeParentSelect().click(); }); - const options = [...testWindow.document.querySelectorAll('[role="option"]')]; - expect(options).toHaveLength(2); - expect(options.map(option => option.textContent).join(" ")).toContain("parent-model"); - expect(options.map(option => option.textContent).join(" ")).not.toContain("gpt-5.6-luna"); -}); - -test("persists a routed target while disabled with a complete atomic payload", async () => { - injectionAvailable = [{ provider: "relay", model: "parent-model", namespaced: "relay/parent-model" }]; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "v2", - keepNativeChatGptOnV1: false, - v2NativeParentOverride: { enabled: false, model: null, active: false }, - } }]; - await mount(); - - await act(async () => { nativeParentSelect().click(); }); - const option = [...testWindow.document.querySelectorAll<HTMLButtonElement>('[role="option"]')] - .find(candidate => candidate.textContent?.includes("parent-model")); - expect(option).toBeTruthy(); - await act(async () => { option!.click(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - - expect(nativeParentPuts()).toContainEqual({ enabled: false, model: "relay/parent-model" }); - expect(nativeParentSwitch().getAttribute("aria-pressed")).toBe("false"); -}); - -test("enables native parent routing with the selected model atomically", async () => { - injectionAvailable = [{ provider: "relay", model: "parent-model", namespaced: "relay/parent-model" }]; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "v2", - keepNativeChatGptOnV1: false, - v2NativeParentOverride: { enabled: false, model: "relay/parent-model", active: false }, - } }, { ok: true, body: { enabled: true, multiAgentMode: "v2", keepNativeChatGptOnV1: false } }]; - await mount(); - - expect(nativeParentSwitch().disabled).toBe(false); - await act(async () => { nativeParentSwitch().click(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - - expect(nativeParentPuts()).toContainEqual({ enabled: true, model: "relay/parent-model" }); - expect(nativeParentSwitch().getAttribute("aria-pressed")).toBe("true"); -}); - -test("rolls back the native parent controls after a failed update", async () => { - injectionAvailable = [ - { provider: "relay", model: "first-model", namespaced: "relay/first-model" }, - { provider: "relay", model: "second-model", namespaced: "relay/second-model" }, - ]; - nativeOverridePutError = "native parent target rejected"; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "v2", - keepNativeChatGptOnV1: false, - v2NativeParentOverride: { enabled: false, model: "relay/first-model", active: false }, - } }]; - await mount(); - - await act(async () => { nativeParentSelect().click(); }); - const option = [...testWindow.document.querySelectorAll<HTMLButtonElement>('[role="option"]')] - .find(candidate => candidate.textContent?.includes("second-model")); - await act(async () => { option!.click(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - - expect(nativeParentSelect().textContent).toContain("first-model"); - expect(container.textContent).toContain("native parent target rejected"); -}); - -test("uses the post-save GET as the source of truth", async () => { - injectionAvailable = [ - { provider: "relay", model: "first-model", namespaced: "relay/first-model" }, - { provider: "relay", model: "server-model", namespaced: "relay/server-model" }, - ]; - nativeOverrideAfterPut = { model: "relay/server-model", enabled: false, active: false }; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "v2", - keepNativeChatGptOnV1: false, - v2NativeParentOverride: { enabled: false, model: "relay/first-model", active: false }, - } }, { ok: true, body: { enabled: true, multiAgentMode: "v2", keepNativeChatGptOnV1: false } }]; - await mount(); - - await act(async () => { nativeParentSelect().click(); }); - const option = [...testWindow.document.querySelectorAll<HTMLButtonElement>('[role="option"]')] - .find(candidate => candidate.textContent?.includes("first-model")); - await act(async () => { option!.click(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - - expect(nativeParentSelect().textContent).toContain("server-model"); - expect(nativeParentSelect().textContent).not.toContain("first-model"); -}); - -test("gates activation on the explicit V2 and keep-native state while preserving accessibility", async () => { - injectionAvailable = [{ provider: "relay", model: "parent-model", namespaced: "relay/parent-model" }]; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "v2", - keepNativeChatGptOnV1: true, - v2NativeParentOverride: { enabled: false, model: "relay/parent-model", active: false }, - } }]; - await mount(); - - expect(nativeParentSwitch().disabled).toBe(true); - expect(nativeParentSwitch().getAttribute("aria-pressed")).toBe("false"); - expect(nativeParentSelect().getAttribute("aria-label")).toBe("Native parent target model"); - expect(container.textContent).toContain("Requires explicit V2"); - expect(container.textContent).toContain("repository context"); -}); - -test("shows inactive guidance when the upstream V2 flag is off", async () => { - injectionAvailable = [{ provider: "relay", model: "parent-model", namespaced: "relay/parent-model" }]; - v2Responses = [{ ok: true, body: { - enabled: false, - multiAgentMode: "v2", - keepNativeChatGptOnV1: false, - v2NativeParentOverride: { enabled: false, model: "relay/parent-model", active: false }, - } }]; - await mount(); - - expect(nativeParentSwitch().disabled).toBe(true); - expect(container.textContent).toContain("Requires explicit V2"); -}); - -test("keeps deactivation available for a persisted enabled but inactive conflict", async () => { - injectionAvailable = [{ provider: "relay", model: "parent-model", namespaced: "relay/parent-model" }]; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "default", - keepNativeChatGptOnV1: true, - v2NativeParentOverride: { enabled: true, model: "relay/parent-model", active: false }, - } }]; - await mount(); - - expect(nativeParentSwitch().disabled).toBe(false); - expect(nativeParentSwitch().getAttribute("aria-pressed")).toBe("true"); - await act(async () => { nativeParentSwitch().click(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - expect(nativeParentPuts()).toContainEqual({ enabled: false, model: "relay/parent-model" }); -}); - -test("clearing the selected model atomically disables native parent routing", async () => { - injectionAvailable = [{ provider: "relay", model: "parent-model", namespaced: "relay/parent-model" }]; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "v2", - keepNativeChatGptOnV1: false, - v2NativeParentOverride: { enabled: true, model: "relay/parent-model", active: true }, - } }]; - await mount(); - - await act(async () => { nativeParentSelect().click(); }); - const none = [...testWindow.document.querySelectorAll<HTMLButtonElement>('[role="option"]')] - .find(candidate => candidate.textContent?.trim() === "None"); - expect(none).toBeTruthy(); - await act(async () => { none!.click(); }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - - expect(nativeParentPuts()).toContainEqual({ enabled: false, model: null }); -}); - -test("ignores rapid native parent mutations while one save is pending", async () => { - injectionAvailable = [ - { provider: "relay", model: "first-model", namespaced: "relay/first-model" }, - { provider: "relay", model: "second-model", namespaced: "relay/second-model" }, - ]; - v2Responses = [{ ok: true, body: { - enabled: true, - multiAgentMode: "v2", - keepNativeChatGptOnV1: false, - v2NativeParentOverride: { enabled: false, model: "relay/first-model", active: false }, - } }]; - await mount(); - - await act(async () => { nativeParentSelect().click(); }); - const second = [...testWindow.document.querySelectorAll<HTMLButtonElement>('[role="option"]')] - .find(candidate => candidate.textContent?.includes("second-model")); - await act(async () => { - second!.click(); - nativeParentSwitch().click(); - }); - - expect(nativeParentPuts()).toEqual([{ enabled: false, model: "relay/second-model" }]); -}); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index cbd85847bb..ea4e98e51c 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -1,11 +1,11 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; +import { resolve } from "node:path"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import { clearClientResourceStoresForTests } from "../src/client-resource"; import Usage from "../src/pages/Usage"; -import { formatCalendarDate } from "../src/usage-calendar-series"; const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "ResizeObserver", "IS_REACT_ACT_ENVIRONMENT"] as const; const originalFetch = globalThis.fetch; @@ -19,9 +19,6 @@ type RequestGate = { url: string; resolve: (response: Response) => void }; let requests: RequestGate[]; beforeEach(() => { - since = new Date(2020, 8, 15, 10, 20, 0, 0).getTime(); - until = new Date(2020, 8, 15, 10, 21, 59, 999).getTime(); - boundsQuery = `since=${since}&until=${until}`; previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; clearClientResourceStoresForTests(); testWindow = new Window({ url: "http://localhost/" }); @@ -88,10 +85,13 @@ async function respond(index: number, marker: string, date?: string) { await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); }); } +const toggle = () => container.querySelector<HTMLButtonElement>(".usage-range-toggle")!; const form = () => container.querySelector<HTMLFormElement>('form[aria-label="Custom date range"]')!; const startInput = () => form().querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')[0]; const endInput = () => form().querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')[1]; -const interval = () => form().querySelector('[role="status"]')?.textContent; +// The applied interval lives beside the trigger rather than inside the panel: collapsing the +// controls must not hide which window the totals cover. +const interval = () => container.querySelector('.usage-range-bar [role="status"]')?.textContent; const error = () => form().querySelector('[role="alert"]')?.textContent; const preset = (name: string) => container.querySelector<HTMLButtonElement>(`button.usage-segmented-btn[aria-label="${name}"]`)!; @@ -100,7 +100,13 @@ async function click(button: HTMLButtonElement) { await act(async () => { button.click(); }); } +// The date fields are behind a closed-by-default disclosure, so every draft starts by opening it. +async function openRange() { + if (toggle().getAttribute("aria-expanded") !== "true") await click(toggle()); +} + async function enter(start: string, end: string) { + await openRange(); await act(async () => { for (const [input, value] of [[startInput(), start], [endInput(), end]] as const) { Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value); @@ -112,9 +118,9 @@ async function enter(start: string, end: string) { const apply = () => click(form().querySelector<HTMLButtonElement>('button[type="submit"]')!); const clear = () => click(form().querySelector<HTMLButtonElement>('button[type="button"]')!); -let since: number; -let until: number; -let boundsQuery: string; +const since = new Date(2020, 8, 15, 10, 20, 0, 0).getTime(); +const until = new Date(2020, 8, 15, 10, 21, 59, 999).getTime(); +const boundsQuery = `since=${since}&until=${until}`; function sessionEntries() { return Array.from({ length: sessionStorage.length }, (_, index) => { @@ -158,31 +164,49 @@ for (const connected of [false, true]) { } test("America/Santiago midnight DST retains final-day activity and tooltip", async () => { - const previous = process.env.TZ; - process.env.TZ = "America/Santiago"; - try { - expect(new Date(2026, 8, 6, 0).getHours()).toBe(1); - await mount(); - await respond(0, "preset-marker"); - await enter("2026-09-05T00:00", "2026-09-07T23:59"); - await apply(); - const gate = requests.at(-1)!; - const data = report(gate, "santiago-marker", "2026-09-07"); - data.days = ["2026-09-05", "2026-09-06", "2026-09-07"].map(date => ({ - date, requests: date === "2026-09-07" ? 7 : 0, measuredRequests: 0, reportedRequests: 0, - totalTokens: date === "2026-09-07" ? 700 : 0, models: [], - })); - await act(async () => gate.resolve(Response.json(data))); - const active = container.querySelector<HTMLElement>('.heatmap-grid .heatmap-cell:not(.heatmap-cell-0)'); - expect(active).not.toBeNull(); - await act(async () => active!.focus()); - expect(document.querySelector(".heatmap-tip-date")?.textContent).toBe(formatCalendarDate("2026-09-07", "en")); - expect(document.querySelector(".heatmap-tip")?.textContent).toContain("700"); - } finally { - if (previous === undefined) delete process.env.TZ; - else process.env.TZ = previous; + if (process.env.OCX_USAGE_SANTIAGO_CHILD !== "1") { + // Restoring an absent TZ can change Bun's effective timezone on Windows. + // Start the DST case in its timezone without mutating this suite's clock. + const timezone = { present: Object.hasOwn(process.env, "TZ"), value: process.env.TZ }; + const localTime = new Date(2020, 8, 15, 10, 20).getTime(); + const child = Bun.spawnSync([ + process.execPath, "test", import.meta.path, + "-t", "^America/Santiago midnight DST retains final-day activity and tooltip$", + "--timeout", "10000", + ], { + cwd: resolve(import.meta.dir, ".."), + env: { ...process.env, TZ: "America/Santiago", OCX_USAGE_SANTIAGO_CHILD: "1" }, + stdout: "pipe", stderr: "pipe", timeout: 12000, killSignal: "SIGKILL", + }); + const diagnostics = `${child.stdout.toString()}\n${child.stderr.toString()}`; + expect(child.exitedDueToTimeout, diagnostics).not.toBe(true); + expect(child.signalCode, diagnostics).toBeUndefined(); + expect(child.exitCode, diagnostics).toBe(0); + expect(child.stdout.toString().split(/\r?\n/), diagnostics).toContain("OCX_SANTIAGO_CASE_COMPLETED"); + expect({ present: Object.hasOwn(process.env, "TZ"), value: process.env.TZ }).toEqual(timezone); + expect(new Date(2020, 8, 15, 10, 20).getTime()).toBe(localTime); + return; } -}); + expect(process.env.TZ).toBe("America/Santiago"); + expect(new Date(2026, 8, 6, 0).getHours()).toBe(1); + await mount(); + await respond(0, "preset-marker"); + await enter("2026-09-05T00:00", "2026-09-07T23:59"); + await apply(); + const gate = requests.at(-1)!; + const data = report(gate, "santiago-marker", "2026-09-07"); + data.days = ["2026-09-05", "2026-09-06", "2026-09-07"].map(date => ({ + date, requests: date === "2026-09-07" ? 7 : 0, measuredRequests: 0, reportedRequests: 0, + totalTokens: date === "2026-09-07" ? 700 : 0, models: [], + })); + await act(async () => gate.resolve(Response.json(data))); + const active = container.querySelector<HTMLElement>('.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"); + if (process.env.OCX_USAGE_SANTIAGO_CHILD === "1") console.log("OCX_SANTIAGO_CASE_COMPLETED"); +}, process.env.OCX_USAGE_SANTIAGO_CHILD === "1" ? 10000 : 15000); test("Apply submits inclusive bounds once; Clear restores the held preset without custom cache entries", async () => { await mount(); @@ -210,10 +234,9 @@ test("Apply submits inclusive bounds once; Clear restores the held preset withou await act(async () => { await new Promise<void>(resolve => setTimeout(resolve, 0)); }); // 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<HTMLElement>(".heatmap-grid .heatmap-cell-1")!; - await act(async () => { activeCell.focus(); }); - expect(document.querySelector(".heatmap-tip-date")?.textContent).toBe(formatCalendarDate("2020-09-15", "en")); - expect(document.querySelector(".heatmap-tip")?.textContent).toContain(formatCalendarDate("2020-09-15", "en")); + 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 enter("2020-09-16T10:20", "2020-09-16T10:21"); expect(interval()).toBe(appliedInterval); expect(requests).toHaveLength(2); @@ -343,3 +366,70 @@ test("each preset clears custom, including the retained preset; 7d never replace expect(container.querySelectorAll(".heatmap-grid .heatmap-cell")).toHaveLength(7); expect(preset("7d").getAttribute("aria-pressed")).toBe("false"); }); + +test("the range panel is closed until asked for, and collapsing it keeps the applied interval readable", async () => { + await mount(); + await respond(0, "preset-report-marker"); + // Closed is the default: a page that opens on a report should not also open on two empty + // date fields, and the collapsed panel must leave no tab stops behind. + expect(toggle().getAttribute("aria-expanded")).toBe("false"); + expect(toggle().textContent).toContain("Custom date range"); + expect(container.querySelector('form[aria-label="Custom date range"]')).toBeNull(); + expect(container.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0); + expect(toggle().className).not.toContain("is-active"); + // Naming a panel that is not in the document would leave a dangling IDREF. + expect(toggle().hasAttribute("aria-controls")).toBe(false); + + await click(toggle()); + expect(toggle().getAttribute("aria-expanded")).toBe("true"); + expect(toggle().getAttribute("aria-controls")).toBe(form().id); + expect(container.querySelectorAll('input[type="datetime-local"]')).toHaveLength(2); + expect(requests).toHaveLength(1); + + await enter("2020-09-15T10:20", "2020-09-15T10:21"); + await apply(); + expect(requests[1].url).toBe(`${apiBase}/api/usage?range=30d&surface=all&${boundsQuery}`); + await respond(1, "custom-report-marker"); + const applied = interval(); + expect(applied).toContain("both inclusive"); + + // Collapsing hides the controls, never the state: the interval line and the marked trigger + // still say which window produced the numbers below. + await click(toggle()); + expect(toggle().getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0); + expect(interval()).toBe(applied); + expect(toggle().className).toContain("is-active"); + expect(container.textContent).toContain("custom-report-marker"); + expect(requests).toHaveLength(2); + + // Reopening restores the draft that produced the applied window rather than empty fields. + await click(toggle()); + expect(startInput().value).toBe("2020-09-15T10:20"); + expect(endInput().value).toBe("2020-09-15T10:21"); + await clear(); + expect(interval()).toBeUndefined(); + expect(toggle().className).not.toContain("is-active"); + expect(startInput().value).toBe(""); +}); + +test("closing the panel retires a validation error instead of parking it out of sight", async () => { + await mount(); + await respond(0, "held-report-marker"); + await enter("2020-09-16T10:20", "2020-09-15T10:20"); + await apply(); + expect(error()).toContain("The end must"); + expect(startInput().getAttribute("aria-invalid")).toBe("true"); + expect(startInput().getAttribute("aria-describedby")).toBe("usage-range-help usage-range-error"); + + // The alert only means something beside the fields that produced it, so it does not outlive + // the panel — but the draft that produced it does. + await click(toggle()); + await click(toggle()); + expect(error()).toBeUndefined(); + expect(startInput().value).toBe("2020-09-16T10:20"); + expect(startInput().getAttribute("aria-invalid")).toBe("false"); + expect(startInput().getAttribute("aria-describedby")).toBe("usage-range-help"); + expect(requests).toHaveLength(1); + expect(container.textContent).toContain("held-report-marker"); +}); diff --git a/gui/tests/vision-sidecar-dashboard.test.tsx b/gui/tests/vision-sidecar-dashboard.test.tsx index 37bcf40d65..fd41cc89fd 100644 --- a/gui/tests/vision-sidecar-dashboard.test.tsx +++ b/gui/tests/vision-sidecar-dashboard.test.tsx @@ -409,6 +409,75 @@ test("Desktop login switch defaults off, preserves explicit opt-in, and disables expect(host.textContent).toContain(en["codexAuth.catalogRefreshPending"]); }); +test("client compaction switch defaults off, preserves explicit opt-in, and invokes its handler", async () => { + const { d } = harness(); + let clicks = 0; + d.toggleCodexClientCompaction = async () => { clicks += 1; }; + d.settings = { codexAutoStart: true, port: 10100, hostname: "127.0.0.1" }; + await mount(d); + const toggle = () => host.querySelector<HTMLButtonElement>(`button[aria-label="${en["dash.codexClientCompaction"]}"]`)!; + expect(toggle().getAttribute("aria-pressed")).toBe("false"); + d.settings.codexClientCompaction = true; + await mount(d); + expect(toggle().getAttribute("aria-pressed")).toBe("true"); + await act(async () => { toggle().click(); }); + expect(clicks).toBe(1); +}); + +test("client compaction preference survives a successful save followed by sync failure", async () => { + const originalFetch = globalThis.fetch; + const writes: Array<{ path: string; body: unknown }> = []; + let latest: Dash | undefined; + let saved = false; + const apiBase = "/client-compaction-sync-failure"; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/api/settings")) { + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); + writes.push({ path, body }); + saved = body.codexClientCompaction; + return Response.json({ codexClientCompaction: saved, catalogRefreshPending: true }); + } + return Response.json({ + codexAutoStart: true, + codexClientCompaction: saved, + port: 10100, + hostname: "127.0.0.1", + }); + } + if (path.endsWith("/api/sync")) { + writes.push({ path, body: init?.body }); + return Response.json({ error: "sync unavailable" }, { status: 503 }); + } + return Response.json({}, { status: 503 }); + }) as typeof fetch; + function Harness() { + const data = useDashboardData(apiBase); + useEffect(() => { latest = data; }, [data]); + return null; + } + try { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(<LanguageProvider><Harness /></LanguageProvider>); + }); + await act(async () => { await latest!.toggleCodexClientCompaction(); }); + expect(writes).toEqual([ + { path: `${apiBase}/api/settings`, body: { codexClientCompaction: true } }, + { path: `${apiBase}/api/sync`, body: undefined }, + ]); + expect(latest?.settings?.codexClientCompaction).toBe(true); + expect(latest?.settings?.catalogRefreshPending).toBe(true); + expect(latest?.syncError).toBe("sync unavailable"); + } finally { + await act(async () => { root?.unmount(); }); + root = null; + globalThis.fetch = originalFetch; + } +}); + test.each([undefined, false, true])("Desktop login preference %s persists before full sync; sync failure keeps the saved preference", async (initial) => { const originalFetch = globalThis.fetch; @@ -428,7 +497,7 @@ test.each([undefined, false, true])("Desktop login preference %s persists before return Response.json({ codexAutoStart: body.codexAutoStart, catalogRefreshPending: false }); } if (path.endsWith("/api/sync")) { - writes.push({ path, body: null }); + writes.push({ path, body: init?.body }); return Response.json({ error: "sync unavailable" }, { status: 503 }); } if (path.endsWith("/api/settings")) { @@ -451,7 +520,7 @@ test.each([undefined, false, true])("Desktop login preference %s persists before await act(async () => { await latest!.toggleCodexDesktopAuthless(); }); expect(writes).toEqual([ { path: `${apiBase}/api/settings`, body: { codexDesktopAuthless: !initial } }, - { path: `${apiBase}/api/sync`, body: null }, + { path: `${apiBase}/api/sync`, body: undefined }, ]); expect(latest?.settings?.codexDesktopAuthless).toBe(!initial); expect(latest?.syncError).toBe("sync unavailable"); diff --git a/package.json b/package.json index 1363d22b77..ccc88087d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yansigit/opencodex", - "version": "2.47.1", + "version": "2.49.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", @@ -23,6 +23,8 @@ "assets/architecture.png", "assets/claude-code-models.gif", "assets/codex-app-picker.png", + "assets/sponsors/orcarouter.png", + "assets/sponsors/packycode.png", "README.md", "SPONSORS.md", "AGENTS_INSTALL.md", @@ -73,12 +75,12 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.4.0", + "bun": "1.4.2", "zod": "4.4.3" }, "devDependencies": { "@anthropic-ai/sdk": "0.122.0", - "@types/bun": "1.4.0", + "@types/bun": "1.4.2", "typescript": "7.0.2" }, "optionalDependencies": { diff --git a/scripts/build-release-changelog.ts b/scripts/build-release-changelog.ts index 7a7a07ad68..d9be0ba799 100644 --- a/scripts/build-release-changelog.ts +++ b/scripts/build-release-changelog.ts @@ -17,6 +17,7 @@ import { parseGeneratedNotes, rewriteTakeoverCredits, sanitizeCommitText, + stripPrEnforcementPrefix, } from "./release-notes"; export type AssociatedPullRequest = { @@ -232,7 +233,7 @@ function renderReleaseNotes(input: { const commits = input.entries.filter((entry): entry is CommitChange => entry.kind === "commit"); for (const pr of prs) { - changelog.push(`- #${pr.number} ${pr.title.trim()} @${pr.author || "unknown"}`); + changelog.push(`- #${pr.number} ${stripPrEnforcementPrefix(pr.title)} @${pr.author || "unknown"}`); } for (const commit of commits) { const short = commit.sha.slice(0, 8); diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index a5a335eee2..0b5a356f75 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -547,8 +547,14 @@ export function parseGeneratedNotes(body: string): ReleaseNoteCategory[] { const CONVENTIONAL_COMMIT_PREFIX = /^(?:feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert|merge|release)(?:\(([^)]+)\))?:\s*(.+)$/i; +export function stripPrEnforcementPrefix(title: string): string { + const text = title.trim(); + const prefix = "[WRONG BRANCH] "; + return text.startsWith(prefix) ? text.slice(prefix.length).trim() : text; +} + export function cleanPrTitle(title: string, prNumber: number | null = null): { scope: string | null; text: string } { - let text = title.trim(); + let text = stripPrEnforcementPrefix(title); let scope: string | null = null; const prefix = CONVENTIONAL_COMMIT_PREFIX.exec(text); if (prefix) { @@ -690,7 +696,7 @@ export function renderReleaseNotes(input: { changelog.push(`Full Changelog: https://github.com/${repo}/compare/${from}...${to}`, ""); } for (const pr of allPrs) { - changelog.push(`- #${pr.number} ${pr.title.trim()} @${pr.author}`); + changelog.push(`- #${pr.number} ${stripPrEnforcementPrefix(pr.title)} @${pr.author}`); } parts.push(changelog.join("\n")); } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 766d24d5f1..8587431f7d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -112,9 +112,6 @@ "^(?:desktop|omp|pi|prime|remote|sync)-" ] }, - "fork": { - "match": [] - }, "service": { "match": [ "^(?:autostart|crash|doctor|init|service|service\\.test\\.ts|shutdown|stale|stop|systemd|winsw\\.test\\.ts)-" @@ -167,40 +164,12 @@ } }, "explicit": { - "auto-release-workflow.test.ts": "fork", - "core-fork-preservation.test.ts": "responses", - "dev-auto-release-workflow.test.ts": "fork", - "dev-promotion-workflow.test.ts": "fork", - "promotion-backmerge.test.ts": "fork", - "register.test.ts": "fork", - "release-candidate-publish-workflow.test.ts": "fork", - "release-candidate-workflow.test.ts": "fork", - "release-pr-workflow.test.ts": "fork", - "sync-cli.test.ts": "fork", - "sync-contained.test.ts": "fork", - "sync-detect.test.ts": "fork", - "sync-generic-cli.test.ts": "fork", - "sync-generic-http.test.ts": "fork", - "sync-lane.test.ts": "fork", - "sync-notify.test.ts": "fork", - "sync-overlap.test.ts": "fork", - "sync-ownership.test.ts": "fork", - "sync-pin.test.ts": "fork", - "sync-pr-mergeable.test.ts": "fork", - "sync-prepare.test.ts": "fork", - "sync-preservation.test.ts": "fork", - "sync-publish.test.ts": "fork", - "sync-pull-request.test.ts": "fork", - "sync-vendor-atomic.test.ts": "fork", - "sync-webhook.test.ts": "fork", - "sync-workflow.test.ts": "fork", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", "acl-error-classification.test.ts": "lib", "active-registry-admission.test.ts": "codex-integration", - "actionlint-runner.test.ts": "ci-workflows", "adapter-buffered-tool-conformance.test.ts": "adapters", "adapter-error-inline.test.ts": "adapters", "adapter-event-oauth-failover.test.ts": "oauth", @@ -209,7 +178,6 @@ "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", - "agent-roles-sync.test.ts": "routing", "agent-task-recovery-cache.test.ts": "server", "agent-task-recovery-combo.test.ts": "server", "agent-task-recovery-fallback.test.ts": "server", @@ -254,6 +222,7 @@ "api-codex-log-guard.test.ts": "server", "api-debug.test.ts": "server", "api-key-attribution.test.ts": "server", + "api-key-selection-capture.test.ts": "providers", "api-keys-routes.test.ts": "server", "api-storage-cleanup.test.ts": "storage", "api-storage-policy-already-running.test.ts": "storage", @@ -269,22 +238,17 @@ "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", "aside-profile-identity.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", + "aside-profiles-routes.test.ts": "server", + "aside-profiles.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", - "aistudio-bridge-endpoint.test.ts": "adapters/google", - "aistudio-credentials.test.ts": "adapters/google", - "aistudio-extension.test.ts": "adapters/google", - "aistudio-login-cli.test.ts": "adapters/google", - "aistudio-login-flow.test.ts": "adapters/google", - "aistudio-native-webkit.test.ts": "adapters/google", - "aistudio-session-sync.test.ts": "adapters/google", - "audit-high.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", "azure-adapter.test.ts": "providers", "azure-model-router-tool-schema.test.ts": "providers", "baseten-provider.test.ts": "providers", "bearer-admission-routed-provider.test.ts": "codex-integration", - "benchmark-claude-tokens-script.test.ts": "claude-integration", "bounded-body.test.ts": "server", "bridge-legacy-shell-normalization.test.ts": "adapters", "bridge-lifecycle.test.ts": "adapters", @@ -301,6 +265,9 @@ "bun-stream-caps.test.ts": "lib", "cancel-body-on-abort.test.ts": "server", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-go-exact-efforts.test.ts": "codex-integration", + "catalog-hub-context-window.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", @@ -313,7 +280,6 @@ "chatgpt-device-auth.test.ts": "oauth", "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", - "check-hygiene.test.ts": "ci-workflows", "chutes-provider.test.ts": "providers", "ci-workflows.test.ts": "ci-workflows", "citation-markers.test.ts": "responses", @@ -328,20 +294,16 @@ "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", - "claude-certification.test.ts": "claude-integration", - "claude-client-version.test.ts": "claude-integration", - "claude-code-compatibility-manifest.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", + "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-native-context.test.ts": "claude-integration", "claude-desktop-policy.test.ts": "claude-integration", - "claude-directive-auth.test.ts": "claude-integration", - "claude-directive-fallback.test.ts": "claude-integration", - "claude-directive-lifecycle.test.ts": "claude-integration", + "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", @@ -353,14 +315,10 @@ "claude-models-discovery.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", - "claude-reasoning-roundtrip.test.ts": "claude-integration", - "claude-route-callback.test.ts": "claude-integration", - "claude-session.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", - "claude-token-benchmark.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", "cli-account-pool-verbs.test.ts": "cli", @@ -380,6 +338,7 @@ "cli-help.test.ts": "cli", "cli-json-contract.test.ts": "cli", "cli-management-auth.test.ts": "cli", + "cli-models-price.test.ts": "cli", "cli-models-reasoning.test.ts": "cli", "cli-models-runtime-dispatch.test.ts": "cli", "cli-models.test.ts": "cli", @@ -404,12 +363,16 @@ "client-export-modality-enum.test.ts": "clients", "client-fingerprint.test.ts": "clients", "client-hub-relay.test.ts": "clients", + "client-injection-guard.test.ts": "codex-integration", + "client-lifecycle-lock.test.ts": "clients", "client-machine-listener.test.ts": "clients", "cline-pass-deepseek-v4-tool-replay.test.ts": "providers", "cline-pass-provider.test.ts": "providers", "cline-pass-reasoning-efforts.test.ts": "providers", "cline-provider.test.ts": "providers", "closed-pr-branch-cleanup.test.ts": "ci-workflows", + "codebuddy-adapter.test.ts": "providers", + "codebuddy-protocol.test.ts": "providers", "codex-account-delete-atomicity.test.ts": "codex-integration", "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", @@ -497,11 +460,10 @@ "codex-prompt-lock.test.ts": "codex-integration", "codex-prompt-route.test.ts": "codex-integration", "codex-prompt-text-probe.test.ts": "codex-integration", - "codex-quota-parser-parity.test.ts": "codex-integration", - "codex-quota-auto-refresh.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", + "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", - "codex-quota.test.ts": "providers", "codex-quota-rejection.test.ts": "codex-integration", "codex-refresh.test.ts": "codex-integration", "codex-reset-credit-auto-redeem.test.ts": "codex-integration", @@ -557,7 +519,6 @@ "container-bootstrap.test.ts": "service", "context-cap-unknown-window.test.ts": "providers", "continuation-dedup.test.ts": "responses", - "conversation-progress.test.ts": "usage", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", "cost-scoring.test.ts": "usage", @@ -643,6 +604,7 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", @@ -661,12 +623,12 @@ "empty-completion-guard.test.ts": "responses", "empty-completion-hardening.test.ts": "responses", "empty-tool-output-annotation.test.ts": "adapters", - "exec-tool-result-normalize.test.ts": "adapters", "ensure-desired-integrations-race.test.ts": "cli", "error-fidelity.test.ts": "server", "errors-adapter-failure.test.ts": "server", "eventstream-decoder.test.ts": "responses", "exa-web-search.test.ts": "providers", + "exec-tool-result-normalize.test.ts": "adapters", "expand-user-path.test.ts": "config", "fast-row-ingress.test.ts": "providers", "fast-row-listing.test.ts": "codex-integration", @@ -729,10 +691,13 @@ "gui-static.test.ts": "gui", "health-scoring.test.ts": "server", "history-migration-guardian.test.ts": "codex-integration", + "history-ocx-compaction-recovery.test.ts": "codex-integration", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", "init-backup-cleanup.test.ts": "service", "init-eof.test.ts": "service", + "initial-model-selection.test.ts": "providers", + "initial-selection-write-fence.test.ts": "providers", "injection-model-api.test.ts": "codex-integration", "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", @@ -784,6 +749,7 @@ "lab-evidence-sanitization.test.ts": "lab", "lab-fabric-outcome-validation.test.ts": "lab", "lab-fabric-persistence-boundary.test.ts": "lab", + "lab-fabric-producer-deadline.test.ts": "lab", "lab-fabric-task.test.ts": "lab", "lab-installation-salt-cache.test.ts": "lab", "lab-ledger-mutation-lock.test.ts": "lab", @@ -818,10 +784,6 @@ "lab-public-wire-contract.test.ts": "lab", "lab-read-filter-validation.test.ts": "lab", "lab-read-surfaces.test.ts": "lab", - "live-inference-workflow.test.ts": "ci-workflows", - "live-smoke-ci.test.ts": "ci-workflows", - "live-smoke-cli.test.ts": "ci-workflows", - "live-smoke-report.test.ts": "ci-workflows", "legacy-shell-compat.test.ts": "responses", "local-management-attestation.test.ts": "server", "local-management-capability.test.ts": "server", @@ -829,19 +791,19 @@ "local-provider-reload-client.test.ts": "server", "local-token-detect.test.ts": "oauth", "logs-model-tier-confirmation.test.ts": "gui", - "logs-filter.test.ts": "gui", "logs-timezone.test.ts": "server", "loop-reasoning-replay.test.ts": "images", "loop.test.ts": "images", "loopback-listener-admission.test.ts": "server", "loopback-listener-integration.test.ts": "server", - "management-api-logs-metrics.test.ts": "server", + "macos-serial-lanes.test.ts": "ci-workflows", "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", "main-account-hard-lock-recovery.test.ts": "codex-integration", "main-quota-evidence-validation.test.ts": "codex-integration", "main-quota-provenance.test.ts": "codex-integration", "main-quota-window-observation.test.ts": "codex-integration", + "management-api-logs-metrics.test.ts": "server", "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", @@ -858,13 +820,17 @@ "minimax-reasoning-split.test.ts": "providers", "model-cache-generation-tombstone.test.ts": "codex-integration", "model-cache.test.ts": "codex-integration", + "model-costs-management-api.test.ts": "server", "model-discovery-management-api.test.ts": "server", "model-display-names-management-api.test.ts": "codex-integration", - "model-metadata-resolver.test.ts": "codex-integration", "model-metadata-sync.test.ts": "codex-integration", + "model-pinned-effort-config.test.ts": "config", + "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", + "model-selection-guidance.test.ts": "cli", "model-visibility-management-api.test.ts": "codex-integration", + "models-feedback-callback.test.ts": "gui", "models-page-groups.test.ts": "gui", "models-workspace-tabs.test.ts": "gui", "moonshot-endpoints.test.ts": "providers", @@ -897,9 +863,6 @@ "native-profile-startup.test.ts": "codex-integration", "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", - "initial-model-selection.test.ts": "providers", - "initial-selection-write-fence.test.ts": "providers", - "model-selection-guidance.test.ts": "cli", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", @@ -911,7 +874,6 @@ "oauth-callback-binds.test.ts": "oauth", "oauth-callback-server.test.ts": "oauth", "oauth-device-code-contract.test.ts": "oauth", - "oauth-failover-optout-security.test.ts": "oauth", "oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth", "oauth-log.test.ts": "oauth", @@ -929,7 +891,6 @@ "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", - "oauth-transport.test.ts": "oauth", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", @@ -972,9 +933,9 @@ "opencode-go-session-header.test.ts": "providers", "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", - "orcarouter-provider.test.ts": "providers", "openrouter-provider-routing.test.ts": "providers", "optional-shutdown-hooks.test.ts": "lib", + "orcarouter-provider.test.ts": "providers", "outbound-body-guard.test.ts": "server", "owned-service-home.test.ts": "server", "package-tree-integrity.test.ts": "ci-workflows", @@ -992,16 +953,13 @@ "policy-execution.test.ts": "routing", "port-reclaim.test.ts": "server", "ports.test.ts": "server", - "post-release-decision.test.cjs": "fork", "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", - "privacy-scan.test.ts": "ci-workflows", "privacy-scan-meta-key.test.ts": "ci-workflows", "process-control-graceful.test.ts": "lib", "process-control.test.ts": "lib", "process-state.test.ts": "service", "project-config-warnings.test.ts": "codex-integration", - "promotion-audit-reuse.test.ts": "ci-workflows", "provider-account-quota-persistence.test.ts": "providers", "provider-account-quota-routes.test.ts": "server", "provider-account-quota.test.ts": "providers", @@ -1024,13 +982,14 @@ "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", "provider-static-model-discovery.test.ts": "providers", - "provider-tls-profile.test.ts": "providers", "provider-workspace-auth.test.ts": "gui", "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", "provider-workspace-state.test.ts": "gui", "proxy-env.test.ts": "server", "proxy-liveness.test.ts": "server", + "qoder-adapter.test.ts": "providers", + "qoder-live-models.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", @@ -1044,17 +1003,6 @@ "quota-scoring.test.ts": "usage", "qwen-cloud-endpoints.test.ts": "gui", "qwen38-preserve-reasoning.test.ts": "providers", - "reserve-availability.test.ts": "codex-integration", - "reserve-auth-context.test.ts": "codex-integration", - "reserve-catalog.test.ts": "codex-integration", - "reserve-catalog-lifecycle.test.ts": "codex-integration", - "reserve-claude-policy.test.ts": "server", - "reserve-dispatch.test.ts": "codex-integration", - "reserve-dispatch-ws.test.ts": "responses", - "reserve-helper-boundary.test.ts": "codex-integration", - "reserve-ingress.test.ts": "server", - "reserve-passive-revocation.test.ts": "codex-integration", - "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", "raycast-client.test.ts": "clients", @@ -1069,9 +1017,6 @@ "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", - "replit-pair-install-response.test.ts": "providers", - "replit-provider-setup.test.ts": "providers", - "version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", "remove-tree-helper.test.ts": "lib", "repo-hygiene.test.ts": "ci-workflows", @@ -1082,6 +1027,17 @@ "request-log-estimate-cap.test.ts": "usage", "request-log.test.ts": "usage", "request-pacing.test.ts": "usage", + "reserve-auth-context.test.ts": "codex-integration", + "reserve-availability.test.ts": "codex-integration", + "reserve-catalog-lifecycle.test.ts": "codex-integration", + "reserve-catalog.test.ts": "codex-integration", + "reserve-claude-policy.test.ts": "server", + "reserve-dispatch-ws.test.ts": "responses", + "reserve-dispatch.test.ts": "codex-integration", + "reserve-helper-boundary.test.ts": "codex-integration", + "reserve-ingress.test.ts": "server", + "reserve-passive-revocation.test.ts": "codex-integration", + "reserve-quota-scope.test.ts": "codex-integration", "response-model-identity.test.ts": "server", "responses-account-label.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", @@ -1089,12 +1045,13 @@ "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", - "responses-forward-incomplete-quota.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", + "responses-forward-incomplete-quota.test.ts": "responses", "responses-forward-posit-continuation.test.ts": "responses", "responses-forward-prompt-envelope.test.ts": "responses", + "responses-function-tool-repair.test.ts": "responses", "responses-image-gen-repair.test.ts": "responses", "responses-inbound-store-default.test.ts": "responses", "responses-item-id-repair.test.ts": "responses", @@ -1126,6 +1083,7 @@ "retry-after-429.test.ts": "server", "route-decision-trace.test.ts": "server", "route-explainability.test.ts": "cli", + "routed-agent-messages.test.ts": "adapters", "router-combo-failover-classification.test.ts": "routing", "router-discarded-baseurl-warning.test.ts": "routing", "router-template-baseurl.test.ts": "routing", @@ -1149,10 +1107,12 @@ "selected-models.test.ts": "codex-integration", "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", + "server-agent-task-recovery-replay.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.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", @@ -1178,8 +1138,8 @@ "service.test.ts": "service", "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", - "settings-oauth-open-browser.test.ts": "config", "settings-main-account-hard-lock.test.ts": "config", + "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config", "settings-stream-mode.test.ts": "config", "shutdown-drain.test.ts": "service", @@ -1195,9 +1155,8 @@ "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", - "smoke-fingerprint-cache.test.ts": "ci-workflows", - "smoke-runner.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", + "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", "sse-decoder.test.ts": "responses", "sse-failed-tail.test.ts": "responses", @@ -1242,10 +1201,6 @@ "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", "tencent-siliconflow-providers.test.ts": "gui", - "telemetry-dispatcher.test.ts": "usage", - "telemetry-fingerprint.test.ts": "usage", - "telemetry-hook.test.ts": "usage", - "telemetry-ledger.test.ts": "usage", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", @@ -1291,11 +1246,13 @@ "usage-shape-extraction.test.ts": "usage", "usage-summary.test.ts": "usage", "usage-surfaces.test.ts": "usage", + "usage-time-range.test.ts": "usage", "user-cost-overlay-coderabbit-regressions.test.ts": "usage", "user-cost-overlay-live-reconcile.test.ts": "usage", "user-cost-overlay-provider-delete.test.ts": "usage", "v2-agent-message-failfast.test.ts": "server", "vercel-gateway-provider-routing.test.ts": "providers", + "version-line.test.ts": "ci-workflows", "vertex-catalog.test.ts": "adapters/google", "vision-anthropic.test.ts": "vision", "vision-backend-union.test.ts": "vision", @@ -1336,7 +1293,6 @@ "windows-tray.test.ts": "windows", "windows-user-principal-nonascii.test.ts": "windows", "windows-user-principal.test.ts": "windows", - "workflow-policy.test.ts": "ci-workflows", "winsw-stop-hardening.test.ts": "windows", "winsw.test.ts": "service", "ws-endpoint.test.ts": "responses", @@ -1357,28 +1313,7 @@ "zhipu-bigmodel-provider.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", - "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", - "aside-profiles-routes.test.ts": "server", - "aside-profiles.test.ts": "clients", - "aside-profile-paths.test.ts": "clients", - "aside-profile-sync-owner.test.ts": "clients", - "catalog-full-picker-order.test.ts": "codex-integration", - "catalog-go-exact-efforts.test.ts": "codex-integration", - "claude-desktop-discovery.test.ts": "claude-integration", - "claude-desktop-remote-hub.test.ts": "claude-integration", - "client-injection-guard.test.ts": "codex-integration", - "client-lifecycle-lock.test.ts": "clients", - "desktop-remote-store.test.ts": "clients", - "lab-fabric-producer-deadline.test.ts": "lab", - "opencode-go-agent-messages.test.ts": "providers", - "responses-function-tool-repair.test.ts": "responses", - "server-agent-task-recovery-replay.test.ts": "server", - "server-google-antigravity-oauth-401-replay.test.ts": "server", - "cli-models-price.test.ts": "cli", - "model-costs-management-api.test.ts": "server", - "usage-time-range.test.ts": "usage", - "model-pinned-effort.test.ts": "codex-integration", - "model-pinned-effort-config.test.ts": "config" + "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" }, "migrated": [ "adapters", @@ -1388,7 +1323,6 @@ "clients", "codex-integration", "config", - "fork", "gui", "lab", "lib", diff --git a/scripts/test.ts b/scripts/test.ts index 3218785383..c2c331f5fc 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -9,8 +9,6 @@ import { TEST_RUN_LOCK_PATH_ENV, TEST_RUN_LOCK_TOKEN_ENV, } from "./test-run-lock"; -import { resolveTrustedWindowsTaskkillExe } from "../src/lib/windows-elevation"; -import { DEDICATED_TEST_FILES, SERIAL_TEST_FILES } from "./ci/test-lanes"; export interface IsolatedTestEnvironment { root: string; @@ -295,8 +293,11 @@ function isFullSuiteRun(requested: string[]): boolean { * Default `bun test` argv for this repo. * * `--isolate` keeps a fresh global per file. Bounded parallelism is what makes the suite - * finishable: with isolate alone Bun re-evaluates the module graph once per file on a single core, - * while leaving Bun to select every host core makes deadline-sensitive tests fail under load. + * finishable: with isolate alone Bun re-evaluates + * the module graph once per file on a single core, so past ~900 files the run stops looking slow + * and starts looking hung — measured here at 1 h 29 m with zero output, ~57 % CPU and 8.5 MB RSS, + * against a few minutes for the identical suite with four workers. Leaving Bun to select all ten + * workers made deadline-sensitive tests fail under load, so the repository default is deterministic. * A caller-supplied `--parallel` or `--parallel=N` is left alone. */ export function resolveBunTestArgs( @@ -316,39 +317,26 @@ export function resolveBunTestArgs( if (!hasCliFlag(effectiveRequested, "--parallel")) { args.push(`--parallel=${DEFAULT_TEST_PARALLELISM}`); } - // Match the existing CI batch runner's 60s framework ceiling. The default - // 5s is too short for real subprocess-backed catalog tests under full-pool - // load. Focused runs and explicit caller/test deadlines remain unchanged. - if (isFullSuiteRun(effectiveRequested) && !hasCliFlag(effectiveRequested, "--timeout")) { - args.push("--timeout=60000"); - } args.push(...effectiveRequested); if (isFullSuiteRun(effectiveRequested)) args.push("./tests/"); return args; } -type SerialFullSuiteFile = (typeof SERIAL_TEST_FILES)[number] extends `tests/${infer File}` ? File : never; - -export const SERIAL_FULL_SUITE_FILES = SERIAL_TEST_FILES.map( - (file): SerialFullSuiteFile => file.slice("tests/".length) as SerialFullSuiteFile, -); - -type DedicatedFullSuiteFile = (typeof DEDICATED_TEST_FILES)[number] extends `tests/${infer File}` - ? File - : never; - -export const DEDICATED_FULL_SUITE_FILES = DEDICATED_TEST_FILES.map( - (file): DedicatedFullSuiteFile => file.slice("tests/".length) as DedicatedFullSuiteFile, -); - -type SerialLaneBasename = SerialFullSuiteFile extends infer P +// Paths relative to tests/. An entry moves with its file (scripts/test-layout/move.ts rewrites +// it); the lane label, the ignore glob, and the timeout table all key on the basename. +export const SERIAL_FULL_SUITE_FILES = [ + "codex-integration/codex-shim.test.ts", + "providers/cursor/cursor-native-exec-shell.test.ts", + "codex-integration/issue-452-empty-503.test.ts", + "adapters/openai/openai-provider-option-e2e.test.ts", + "ci-workflows/release-helper.test.ts", + "update/update-stop-first.test.ts", +] as const; + +type SerialLaneBasename = (typeof SERIAL_FULL_SUITE_FILES)[number] extends infer P ? P extends `${string}/${infer B}` ? B : P : never; const SERIAL_LANE_TIMEOUT_MS: Partial<Record<SerialLaneBasename, number>> = { - // The packaged first-start case has its own 4-minute budget and performs npm - // pack/install plus a real proxy lifecycle. Keep the outer lane able to - // contain that bounded work on cold runners. - "ocx-launcher-runtime.test.ts": 5 * 60 * 1000, // This file intentionally exercises 33 complete release-script subprocess trees. // It is ~90s on an idle machine and measured at ~170s under unrelated host load. "release-helper.test.ts": 5 * 60 * 1000, @@ -360,71 +348,6 @@ export interface BunTestLane { timeoutMs: number; } -export function terminationCommandForTests( - pid: number, - platform = process.platform, - resolveTaskkill: () => string = resolveTrustedWindowsTaskkillExe, -): string[] | null { - return platform === "win32" - ? [resolveTaskkill(), "/PID", String(pid), "/T", "/F"] - : null; -} - -export interface TestTerminationOptions { - pid: number; - platform: string; - signal?: NodeJS.Signals; - exited: Promise<number>; - signalGroup?: (signal: NodeJS.Signals) => void; - isAlive?: () => boolean; - resolveTaskkill?: () => string; - taskkill?: (command: string[]) => number; - graceMs?: number; - killGraceMs?: number; -} - -export interface TestTerminationGraceOptions { - graceMs?: number; - killGraceMs?: number; -} - -export async function terminateTestProcessForTests(options: TestTerminationOptions): Promise<void> { - if (!Number.isSafeInteger(options.pid) || options.pid <= 0) { - throw new Error(`[test] child pid must be a positive safe integer; received ${options.pid}`); - } - const graceMs = options.graceMs ?? 5_000; - const killGraceMs = options.killGraceMs ?? 2_000; - if (options.platform === "win32") { - const command = terminationCommandForTests(options.pid, "win32", options.resolveTaskkill)!; - const result = options.taskkill?.(command) ?? 0; - if (result !== 0) { - throw new Error(`[test] failed to terminate process tree with ${command[0]} (exit ${result})`); - } - if (await waitWithMonotonicTimeout(options.exited, killGraceMs) === null) { - throw new Error(`[test] process tree ${options.pid} did not terminate after taskkill`); - } - return; - } - const signal = options.signalGroup ?? (() => {}); - const alive = options.isAlive ?? (() => false); - signal(options.signal ?? "SIGTERM"); - if (await waitForProcessDeath(alive, graceMs)) return; - signal("SIGKILL"); - if (!await waitForProcessDeath(alive, killGraceMs)) { - throw new Error(`[test] process group ${options.pid} did not terminate after SIGKILL`); - } -} - -async function waitForProcessDeath(isAlive: () => boolean, timeoutMs: number): Promise<boolean> { - const deadline = performance.now() + timeoutMs; - while (isAlive()) { - const remaining = deadline - performance.now(); - if (remaining <= 0) return false; - await Bun.sleep(Math.min(10, remaining)); - } - return true; -} - function withoutParallelOverride(requested: string[]): string[] { return requested.filter(arg => arg !== "--parallel" && !arg.startsWith("--parallel=")); } @@ -442,13 +365,12 @@ export function resolveBunTestPlan(requested: string[], comparisonCommit?: strin const mainArgs = resolveBunTestArgs(requested, comparisonCommit); const rootIndex = mainArgs.lastIndexOf("./tests/"); - const isolatedFiles = [...SERIAL_FULL_SUITE_FILES, ...DEDICATED_FULL_SUITE_FILES]; - const ignores = isolatedFiles.flatMap(file => ["--path-ignore-patterns", `**/${basename(file)}`]); + const ignores = SERIAL_FULL_SUITE_FILES.flatMap(file => ["--path-ignore-patterns", `**/${basename(file)}`]); mainArgs.splice(rootIndex === -1 ? mainArgs.length : rootIndex, 0, ...ignores); const serialRequested = withoutParallelOverride(requested); return [ { label: "parallel suite", args: mainArgs, timeoutMs: 15 * 60 * 1000 }, - ...isolatedFiles.map(file => ({ + ...SERIAL_FULL_SUITE_FILES.map(file => ({ label: basename(file), args: resolveBunTestArgs(["--parallel=1", ...serialRequested, `./tests/${file}`]), timeoutMs: SERIAL_LANE_TIMEOUT_MS[basename(file) as SerialLaneBasename] ?? 3 * 60 * 1000, @@ -456,74 +378,96 @@ export function resolveBunTestPlan(requested: string[], comparisonCommit?: strin ]; } -export interface MonotonicTimeoutRuntime { - now(): number; - schedule(callback: () => void, delayMs: number): unknown; - clear(handle: unknown): void; -} - -const monotonicTimeoutRuntime: MonotonicTimeoutRuntime = { - now: () => performance.now(), - schedule: (callback, delayMs) => setTimeout(callback, delayMs), - clear: handle => clearTimeout(handle as ReturnType<typeof setTimeout>), -}; - -/** - * Bound a promise without trusting one wall-clock-sensitive timer wakeup. - * - * Some Bun/macOS combinations wake a long timer when the host clock jumps. Rechecking a - * monotonic deadline turns that wakeup into a harmless re-arm instead of killing healthy CI. - */ -export function waitWithMonotonicTimeout<T>( - promise: Promise<T>, - timeoutMs: number, - runtime: MonotonicTimeoutRuntime = monotonicTimeoutRuntime, -): Promise<T | null> { +function waitWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> { return new Promise((resolve, reject) => { - const deadline = runtime.now() + Math.max(0, timeoutMs); - let timer: unknown; - let settled = false; - - const finish = (callback: () => void) => { - if (settled) return; - settled = true; - if (timer !== undefined) runtime.clear(timer); - callback(); - }; - const arm = () => { - const remaining = Math.max(0, deadline - runtime.now()); - timer = runtime.schedule(() => { - timer = undefined; - if (runtime.now() < deadline) { - arm(); - return; - } - finish(() => resolve(null)); - }, remaining); - }; - - arm(); + const timer = setTimeout(() => resolve(null), timeoutMs); promise.then( - value => finish(() => resolve(value)), - error => finish(() => reject(error)), + value => { + clearTimeout(timer); + resolve(value); + }, + error => { + clearTimeout(timer); + reject(error); + }, ); }); } -export interface TestLaneRuntimeOptions extends TestTerminationGraceOptions { - command?: string[]; - createEnvironment?: typeof createIsolatedTestEnvironment; - terminateProcess?: (child: Bun.Subprocess, signal: NodeJS.Signals) => Promise<void>; +/** Read continuously so a timeout can still report output received before EOF. */ +export function captureTestOutput( + stdout: ReadableStream<Uint8Array>, + stderr: ReadableStream<Uint8Array>, +) { + const collect = (stream: ReadableStream<Uint8Array>) => { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let reading = true; + let complete = false; + const done = (async () => { + try { + while (reading) { + const chunk = await reader.read(); + if (!reading) break; + if (chunk.done) { + complete = true; + break; + } + text += decoder.decode(chunk.value, { stream: true }); + } + } catch { + // Retain the prefix without turning a pipe error into an unhandled rejection. + } finally { + if (reading) text += decoder.decode(); + reading = false; + reader.releaseLock(); + } + })(); + return { + done, + snapshot: () => ({ text, complete }), + cancel() { + if (!reading) return; + reading = false; + text += decoder.decode(); + // A descendant may own a pipe, or a stream's cancellation may never settle. + // Cancellation is best effort; neither it nor EOF may extend the drain bound. + void reader.cancel().catch(() => {}); + }, + }; + }; + const out = collect(stdout); + const err = collect(stderr); + return { + async finish(timeoutMs: number) { + const drained = await waitWithTimeout(Promise.all([out.done, err.done]), timeoutMs); + if (drained === null) { + out.cancel(); + err.cancel(); + } + const stdout = out.snapshot(); + const stderr = err.snapshot(); + return { + stdout: stdout.text, + stderr: stderr.text, + complete: drained !== null && stdout.complete && stderr.complete, + }; + }, + }; } -async function runTestLane( +export async function runTestLane( lane: BunTestLane, runId: string, inheritedLock: { lockPath: string; ownerToken: string } | undefined, capture = false, - options: TestLaneRuntimeOptions = {}, + writers = { + stdout: (value: string) => { process.stdout.write(value); }, + stderr: (value: string) => { process.stderr.write(value); }, + }, ): Promise<{ exitCode: number; output: string }> { - const isolated = (options.createEnvironment ?? createIsolatedTestEnvironment)({ + const isolated = createIsolatedTestEnvironment({ ...process.env, [TEST_RUN_ID_ENV]: runId, [TEST_RUN_LOCK_PATH_ENV]: inheritedLock?.lockPath, @@ -533,133 +477,57 @@ async function runTestLane( // See tests/helpers/ci-watchdog.ts `isolationBudgetMs`. OCX_TEST_FULL_SUITE: "1", }); - const startedAt = performance.now(); + const startedAt = Date.now(); let interrupted: NodeJS.Signals | null = null; - let termination: Promise<void> | null = null; - let child: Bun.Subprocess | null = null; - let resolveInterrupt!: (signal: NodeJS.Signals) => void; - const interruptRequested = new Promise<NodeJS.Signals>(resolve => { resolveInterrupt = resolve; }); - const terminate = options.terminateProcess ?? ((target: Bun.Subprocess, signal: NodeJS.Signals) => ( - terminateSpawnedTestProcessForTests(target, signal, options) - )); - const beginTermination = () => { - const target = child; - const signal = interrupted; - if (!target || !signal || termination) return; - termination = Promise.resolve().then(() => terminate(target, signal)); - }; + const child = Bun.spawn([process.execPath, "test", ...lane.args], { + env: isolated.env, + stdin: "inherit", + stdout: capture ? "pipe" : "inherit", + stderr: capture ? "pipe" : "inherit", + }); + const captured = capture ? captureTestOutput(child.stdout!, child.stderr!) : undefined; const forward = (signal: NodeJS.Signals) => { - if (interrupted) return; interrupted = signal; - beginTermination(); - resolveInterrupt(signal); + try { child.kill(signal); } catch { /* child already exited */ } }; const onInterrupt = () => forward("SIGINT"); const onTerminate = () => forward("SIGTERM"); - // Register before spawning. A fast child can publish readiness while this process is - // still returning from Bun.spawn; without handlers already installed, a supervisor's - // immediate signal takes the default path and can orphan that new process group. process.once("SIGINT", onInterrupt); process.once("SIGTERM", onTerminate); + const exited = child.exited; try { - child = Bun.spawn(options.command ?? [process.execPath, "test", ...lane.args], { - env: isolated.env, - stdin: "inherit", - stdout: capture ? "pipe" : "inherit", - stderr: capture ? "pipe" : "inherit", - detached: process.platform !== "win32", - }); - beginTermination(); - const target = child; - const stdoutP = capture ? new Response(target.stdout).text() : Promise.resolve(""); - const stderrP = capture ? new Response(target.stderr).text() : Promise.resolve(""); - const exited = target.exited; - const outcome = await Promise.race([ - waitWithMonotonicTimeout(exited, lane.timeoutMs).then(exitCode => ({ kind: "exit" as const, exitCode })), - interruptRequested.then(async signal => { - await termination; - return { kind: "interrupt" as const, signal }; - }), - ]); - if (outcome.kind === "interrupt") { - return { exitCode: outcome.signal === "SIGINT" ? 130 : 143, output: "" }; - } - const exitCode = outcome.exitCode; + let exitCode = await waitWithTimeout(exited, lane.timeoutMs); if (exitCode === null) { - console.error(`[test] ${lane.label} exceeded ${Math.round(lane.timeoutMs / 1000)}s; terminating pid ${target.pid}.`); - termination ??= Promise.resolve().then(() => terminate(target, "SIGTERM")); - await termination; - return { exitCode: 124, output: "" }; + console.error(`[test] ${lane.label} exceeded ${Math.round(lane.timeoutMs / 1000)}s; terminating pid ${child.pid}.`); + try { child.kill("SIGTERM"); } catch { /* child already exited */ } + const graceful = await waitWithTimeout(exited, 5_000); + if (graceful === null) { + try { child.kill("SIGKILL"); } catch { /* child already exited */ } + await waitWithTimeout(exited, 2_000); + } } - const [stdout, stderr] = await Promise.all([stdoutP, stderrP]); - if (stdout) process.stdout.write(stdout); - if (stderr) process.stderr.write(stderr); + // Process exit does not guarantee EOF when a descendant inherited the pipe. + const result = await captured?.finish(1_000); + const stdout = result?.stdout ?? ""; + const stderr = result?.stderr ?? ""; + if (stdout) writers.stdout(stdout); + if (stderr) writers.stderr(stderr); const output = stdout + "\n" + stderr; + if (result && !result.complete) { + console.error("[test] captured output is incomplete; collected output is shown above."); + if (exitCode === 0) exitCode = 1; + } + if (exitCode === null) return { exitCode: 124, output }; if (interrupted === "SIGINT") return { exitCode: 130, output }; if (interrupted === "SIGTERM") return { exitCode: 143, output }; - const seconds = ((performance.now() - startedAt) / 1000).toFixed(1); + const seconds = ((Date.now() - startedAt) / 1000).toFixed(1); console.warn(`[test] ${lane.label} finished in ${seconds}s (exit ${exitCode}).`); return { exitCode, output }; } finally { - try { - if (termination) await termination; - } finally { - process.off("SIGINT", onInterrupt); - process.off("SIGTERM", onTerminate); - isolated.cleanup(); - } - } -} - -export async function runTestLaneForTests( - lane: BunTestLane, - runId: string, - options: TestLaneRuntimeOptions = {}, -): Promise<number> { - return (await runTestLane(lane, runId, undefined, false, options)).exitCode; -} - -export async function terminateSpawnedTestProcessForTests( - child: { pid: number; exited: Promise<number>; kill(signal: NodeJS.Signals): void }, - signal: NodeJS.Signals, - grace: TestTerminationGraceOptions = {}, -): Promise<void> { - if (process.platform === "win32") { - return terminateTestProcessForTests({ - pid: child.pid, - platform: "win32", - signal, - exited: child.exited, - taskkill: command => Bun.spawnSync(command, { stdout: "ignore", stderr: "pipe" }).exitCode, - ...grace, - }); - } - - const signalGroup = (name: NodeJS.Signals) => { - try { - process.kill(-child.pid, name); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; - } - }; - return terminateTestProcessForTests({ - pid: child.pid, - platform: process.platform, - signal, - exited: child.exited, - signalGroup, - isAlive: () => processGroupAlive(child.pid), - ...grace, - }); -} - -function processGroupAlive(pid: number): boolean { - try { - process.kill(-pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "EPERM"; + process.off("SIGINT", onInterrupt); + process.off("SIGTERM", onTerminate); + isolated.cleanup(); } } @@ -742,7 +610,7 @@ if (import.meta.main) { ), onAcquiredAfterWait: elapsedMs => console.warn(`[test] acquired the user lock after ${Math.round(elapsedMs / 1000)}s.`), }); - const startedAt = performance.now(); + const startedAt = Date.now(); try { const inheritedLock = process.platform === "win32" && lockPath && lock.owner ? { lockPath, ownerToken: lock.owner.token } @@ -762,8 +630,8 @@ if (import.meta.main) { exitCode = 1; } } - const elapsedSeconds = Math.round((performance.now() - startedAt) / 1000); - if (isFullSuiteRun(requestedTests) && elapsedSeconds > 10 * 60) { + const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); + if (isFullSuiteRun(requestedTests) && elapsedSeconds > 600) { console.warn( `[test] the suite took ${elapsedSeconds}s; with --parallel=${DEFAULT_TEST_PARALLELISM} it should finish in a few minutes on an idle machine. ` + "Check for another test runner, a busy CPU, or a test that started polling something real.", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index f2b96f78ec..fa67b0fa14 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -581,16 +581,28 @@ function defaultReasoningEffort(provider: OcxProviderConfig, modelId: string): s return trimmed; } -function usageFromAnthropic(usage: Record<string, number> | undefined): OcxUsage | undefined { - if (!usage) return undefined; +function usageFromAnthropic(usage: unknown): OcxUsage | undefined { + if (!isAnthropicRecord(usage)) return undefined; + const tokens = (key: string): number | undefined => { + const value = usage[key]; + if (value === undefined) return 0; + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + }; + const input = tokens("input_tokens"); + const output = tokens("output_tokens"); + const read = tokens("cache_read_input_tokens"); + const write = tokens("cache_creation_input_tokens"); + // Invalid upstream usage is unreported, not a measured zero or a string that + // can pass through aggregation into a human-readable usage report. + if (input === undefined || output === undefined || read === undefined || write === undefined) return undefined; const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined; - const read = usage.cache_read_input_tokens ?? 0; - const write = usage.cache_creation_input_tokens ?? 0; // Anthropic reports input_tokens EXCLUSIVE of cache read/write; normalize to the // canonical inclusive convention (types.ts OcxUsage / devlog 070). + const inputTokens = input + read + write; + if (!Number.isFinite(inputTokens)) return undefined; return { - inputTokens: (usage.input_tokens ?? 0) + read + write, - outputTokens: usage.output_tokens ?? 0, + inputTokens, + outputTokens: output, ...(hasCache ? { cachedInputTokens: read, cacheReadInputTokens: read, @@ -599,15 +611,18 @@ function usageFromAnthropic(usage: Record<string, number> | undefined): OcxUsage }; } -function mergeAnthropicUsage( - base: Record<string, number> | undefined, - next: Record<string, number> | undefined, -): Record<string, number> | undefined { - if (!next) return base; - if (!base) return { ...next }; +type PendingAnthropicUsage = Record<string, unknown> | null | undefined; + +function mergeAnthropicUsage(base: PendingAnthropicUsage, next: unknown): PendingAnthropicUsage { + // null remembers an invalid observation. A later partial cumulative frame + // cannot re-establish the missing totals, while an absent update changes nothing. + if (base === null) return null; + if (next === undefined) return base; + if (!isAnthropicRecord(next)) return null; // Anthropic `message_delta.usage` values are CUMULATIVE; adding them to the // message_start snapshot double-counted output tokens. Later frames win per key. - return { ...base, ...next }; + const merged = { ...base, ...next }; + return usageFromAnthropic(merged) === undefined ? null : merged; } function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name: string) => string; fromWire: (name: string) => string } { @@ -1280,7 +1295,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti let currentToolCallId = ""; let currentToolCallName = ""; let currentToolCallJson = ""; - let pendingUsage: Record<string, number> | undefined; + let pendingUsage: PendingAnthropicUsage; let pendingStopReason: string | undefined; let emittedDone = false; let sawVisibleText = false; @@ -1334,7 +1349,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti switch (record.event || data.type) { case "message_start": { - const message = data.message as { usage?: Record<string, number> } | undefined; + const message = data.message as { usage?: unknown } | undefined; pendingUsage = mergeAnthropicUsage(pendingUsage, message?.usage); break; } @@ -1423,7 +1438,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti break; } case "message_delta": { - const usage = data.usage as Record<string, number> | undefined; + const usage = data.usage; pendingUsage = mergeAnthropicUsage(pendingUsage, usage); const delta = data.delta as { stop_reason?: unknown } | undefined; if (typeof delta?.stop_reason === "string") pendingStopReason = delta.stop_reason; diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts new file mode 100644 index 0000000000..234e06907e --- /dev/null +++ b/src/adapters/codebuddy/adapter.ts @@ -0,0 +1,85 @@ +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { AdapterRequest, ProviderAdapter } from "../base"; +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"; + +export type { SpawnFn } from "../coding-agent/turn"; +export type CodeBuddyAdapterDeps = CodingAgentDeps; + +/** + * Build the scoped child-process environment for a CodeBuddy turn (§六/§十四). + * + * The region switch and credential are layered on top of the shared base env, which never inherits a + * parent `CODEBUDDY_*`. `CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS=1` matches the vendor SDK's own + * single-shot behavior (a `-p` turn stops at the first result and cannot receive cross-turn + * background push-back). + */ +export function buildChildEnv(profile: CodeBuddyProfile, apiKey: string): Record<string, string> { + return { + ...baseScopedEnv(), + CODEBUDDY_API_KEY: apiKey, + CODEBUDDY_INTERNET_ENVIRONMENT: profile.internetEnvironment, + CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS: "1", + }; +} + +/** + * Build the headless CLI arguments (§七/§十一). + * + * Tool ownership stays with Codex: `--tools ""` disables every built-in tool and `--strict-mcp-config` + * (with no `--mcp-config`) blocks MCP tools, so the CLI can neither read, write, exec, nor browse the + * workspace. `-y/--dangerously-skip-permissions` is deliberately NOT passed, so any operation that + * would require authorization is blocked. The turn is a single text/reasoning pass over stream-json; + * Codex's tool catalog is not advertised in v1 (the control-protocol tool bridge is a fast-follow). + */ +export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] { + const args: string[] = [ + "-p", + "--output-format", "stream-json", + "--input-format", "stream-json", + "--include-partial-messages", + "--verbose", + "--no-session-persistence", + "--tools", "", + "--strict-mcp-config", + "--max-turns", "1", + "--model", parsed.modelId, + ]; + const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + if (effort) args.push("--effort", effort); + const system = buildSystemPrompt(parsed); + if (system) args.push("--append-system-prompt", system); + // profile is retained for symmetry with the region-isolated design and future per-region flags. + void profile; + return args; +} + +/** Create the shared CodeBuddy adapter: region profile selects Global vs CN, one turn runs tools-disabled. */ +export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBuddyAdapterDeps = {}): ProviderAdapter { + return { + name: "codebuddy", + + // runTurn owns the turn; buildRequest/parseStream are the disabled HTTP path (mirrors cursor). + buildRequest(): AdapterRequest { + return { url: provider.baseUrl, method: "POST", headers: {}, body: "" }; + }, + async *parseStream(): AsyncGenerator<AdapterEvent> { + yield { type: "error", message: "CodeBuddy adapter uses runTurn; the fetch/parseStream path is disabled." }; + }, + + async runTurn(parsed, incoming, emit): Promise<void> { + await runCodingAgentTurn({ + profiles: CODEBUDDY_PROFILES, + provider, + parsed, + incoming, + 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/profiles.ts b/src/adapters/codebuddy/profiles.ts new file mode 100644 index 0000000000..f06edb8ed5 --- /dev/null +++ b/src/adapters/codebuddy/profiles.ts @@ -0,0 +1,52 @@ +import { clearCodingAgentBinaryCache, type CodingAgentProviderProfile } from "../coding-agent/profile"; + +/** + * Region-isolated profiles for the official CodeBuddy Code CLI. + * + * CodeBuddy Global and CodeBuddy CN are SEPARATE credential destinations (§五/§十四/§十六). They + * share one adapter, one binary name, and the shared coding-agent stream-json parser; the region is + * fixed by the officially documented `CODEBUDDY_INTERNET_ENVIRONMENT` value (`public` for the + * overseas/global product, `internal` for the China product) — the vendor states: "使用 + * CODEBUDDY_API_KEY 时,必须根据版本正确配置 CODEBUDDY_INTERNET_ENVIRONMENT". A global key is never + * sent to the CN environment or vice versa. + * + * Evidence (verified 2026-09-03): npm `@tencent-ai/codebuddy-code` v2.143.0 (Tencent Cloud); + * keys https://www.codebuddy.ai/profile/keys (Global) / https://copilot.tencent.com/profile/keys (CN); + * headless https://www.codebuddy.ai/docs/cli/headless. + */ +export interface CodeBuddyProfile extends CodingAgentProviderProfile { + family: "codebuddy"; + /** Official `CODEBUDDY_INTERNET_ENVIRONMENT` value for this region. */ + internetEnvironment: "public" | "internal"; +} + +export const CODEBUDDY_GLOBAL_PROFILE: CodeBuddyProfile = { + providerId: "codebuddy", + family: "codebuddy", + region: "global", + label: "CodeBuddy", + internetEnvironment: "public", + canonicalBaseUrl: "https://www.codebuddy.ai", + binaryCandidates: ["codebuddy", "cbc", "codebuddy-code"], + tokenEnv: "CODEBUDDY_API_KEY", + installHint: "npm install -g @tencent-ai/codebuddy-code", + documentationUrl: "https://www.codebuddy.ai/docs/cli/headless", +}; + +export const CODEBUDDY_CN_PROFILE: CodeBuddyProfile = { + providerId: "codebuddy-cn", + family: "codebuddy", + region: "cn", + label: "CodeBuddy CN", + internetEnvironment: "internal", + canonicalBaseUrl: "https://www.codebuddy.cn", + binaryCandidates: ["codebuddy", "cbc", "codebuddy-code"], + tokenEnv: "CODEBUDDY_API_KEY", + installHint: "npm install -g @tencent-ai/codebuddy-code", + documentationUrl: "https://www.codebuddy.cn/docs/cli/headless", +}; + +export const CODEBUDDY_PROFILES: readonly CodeBuddyProfile[] = [CODEBUDDY_GLOBAL_PROFILE, CODEBUDDY_CN_PROFILE]; + +/** Binary-discovery cache is shared across coding-agent families; re-exported for test isolation. */ +export const clearCodeBuddyBinaryCache = clearCodingAgentBinaryCache; diff --git a/src/adapters/coding-agent/profile.ts b/src/adapters/coding-agent/profile.ts new file mode 100644 index 0000000000..7298469767 --- /dev/null +++ b/src/adapters/coding-agent/profile.ts @@ -0,0 +1,100 @@ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +/** + * One region-isolated official coding-agent CLI target (§三十一). + * + * A profile is the ONLY place a family encodes its per-region differences (binary, credential env + * var, canonical destination, install hint). Adapters stay profile-driven so there is no scattered + * `if (provider === "codebuddy-cn")` branching, and so a family's Global and CN variants share one + * adapter and one parser (§十三). + */ +export interface CodingAgentProviderProfile { + /** Canonical OpenCodex provider id this profile serves. */ + providerId: string; + /** Vendor family; selects the arg/env builder in the family adapter. */ + family: "codebuddy" | "qoder"; + /** Region; drives the vendor's own region switch and keeps credentials deterministic. */ + region: "global" | "cn"; + /** Human label for diagnostics/error copy (never sent upstream). */ + label: string; + /** + * Canonical upstream destination and region identity. The CLI performs the real transport, but + * this host selects the profile and fails closed when overridden, so a region-scoped credential is + * never handed to an unexpected environment (§十六). + */ + canonicalBaseUrl: string; + /** Executable names to resolve on PATH, in preference order. */ + binaryCandidates: readonly string[]; + /** Official credential environment variable consumed by the CLI. */ + tokenEnv: string; + /** Install command surfaced when the CLI is missing (§二十六). */ + installHint: string; + /** Official documentation for the automation surface. */ + documentationUrl: string; +} + +/** Test seam: report the resolved path of a candidate executable, or undefined. */ +export type WhichFn = (candidate: string) => string | undefined; + +const binaryCache = new Map<string, string>(); + +/** Reset the discovery cache (tests, or an explicit provider re-check). */ +export function clearCodingAgentBinaryCache(): void { + binaryCache.clear(); +} + +/** Default PATH scan: return the first existing executable path for a candidate name. */ +export function whichFromPath(candidate: string): string | undefined { + const pathVar = process.env.PATH ?? ""; + if (!pathVar) return undefined; + const extensions = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""]; + for (const dir of pathVar.split(delimiter)) { + if (!dir) continue; + for (const ext of extensions) { + const full = join(dir, `${candidate}${ext}`); + try { + if (existsSync(full)) return full; + } catch { + // An unreadable PATH entry must not abort discovery; skip it. + } + } + } + return undefined; +} + +/** + * Discover the CLI executable BEFORE a request is sent (§二十六), so a missing CLI is a clear + * pre-flight error rather than a mid-turn ENOENT. Only positive hits are cached (§三十): a CLI + * installed after startup is found on the next turn instead of being masked by a cached negative. + */ +export function resolveCodingAgentBinary( + profile: CodingAgentProviderProfile, + which: WhichFn = whichFromPath, +): string | undefined { + for (const candidate of profile.binaryCandidates) { + const cacheKey = `${profile.providerId}:${candidate}`; + const cached = binaryCache.get(cacheKey); + if (cached) return cached; + const resolved = which(candidate); + if (resolved) { + binaryCache.set(cacheKey, resolved); + return resolved; + } + } + return undefined; +} + +/** + * Resolve the profile whose canonical base URL matches the provider's configured destination. + * Returns undefined for any other host, so the adapter fails closed rather than sending a + * region-scoped credential to an unknown environment (§十六). + */ +export function resolveProfileByBaseUrl( + profiles: readonly CodingAgentProviderProfile[], + baseUrl: string | undefined, +): CodingAgentProviderProfile | undefined { + if (!baseUrl) return undefined; + const normalized = baseUrl.replace(/\/+$/, "").toLowerCase(); + return profiles.find(profile => normalized === profile.canonicalBaseUrl.toLowerCase()); +} diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts new file mode 100644 index 0000000000..27fefb581b --- /dev/null +++ b/src/adapters/coding-agent/protocol.ts @@ -0,0 +1,463 @@ +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../../types"; + +/** + * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code and Qoder CLI). + * + * The vendor speaks the Anthropic/Claude-Code `stream-json` protocol ("the naming and protocol + * align with Anthropic Claude Code v2.1.88"). A headless turn is a newline-delimited JSON stream on stdout: + * + * {"type":"system","subtype":"init", ...} + * {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta",...}}} (with --include-partial-messages) + * {"type":"assistant","message":{"role":"assistant","content":[{"type":"text"|"thinking"|"tool_use",...}]}} + * {"type":"result","subtype":"success","is_error":false,"usage":{...},"total_cost_usd":...,"session_id":...} + * + * Diagnostics ride stderr and are NOT protocol data. This module is pure: it never spawns a process + * and never touches the network, so it is unit-testable against captured fixtures. + */ + +/** Hard ceiling on a single buffered stdout line, so a runaway frame cannot exhaust memory. */ +export const MAX_STREAM_LINE_BYTES = 8 * 1024 * 1024; +/** Hard ceiling on the total stdout bytes consumed for one turn. */ +export const MAX_STREAM_TOTAL_BYTES = 64 * 1024 * 1024; +/** Hard ceiling on projected conversation history text (characters) to prevent runaway memory. */ +export const MAX_PROJECTED_HISTORY_CHARS = 200_000; + +export class CodingAgentStreamLimitError extends Error { + constructor(message: string) { + super(message); + this.name = "CodingAgentStreamLimitError"; + } +} + +export class CodingAgentProtocolError extends Error { + readonly code: string = "protocol_error"; + readonly status: number = 502; + constructor(message: string) { + super(message); + this.name = "CodingAgentProtocolError"; + } +} + +/** A parsed protocol frame. */ +export type StreamMessage = Record<string, unknown>; + +/** + * Split an async byte stream into JSONL frames. + * + * Handles the streaming hazards the task calls out (§二十五): fragmented JSON across chunks, split + * multi-byte UTF-8 (via the decoder's `stream` mode), partial trailing lines, and multiple frames in + * one chunk. A non-empty frame that does not parse to a JSON record fails closed so corrupted + * protocol output cannot be mistaken for a successful response. + */ +export async function* readJsonLines( + chunks: AsyncIterable<Uint8Array>, + limits: { maxLineBytes?: number; maxTotalBytes?: number } = {}, +): AsyncGenerator<StreamMessage> { + const maxLineBytes = limits.maxLineBytes ?? MAX_STREAM_LINE_BYTES; + const maxTotalBytes = limits.maxTotalBytes ?? MAX_STREAM_TOTAL_BYTES; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + let totalBytes = 0; + + const flushLine = function* (line: string): Generator<StreamMessage> { + if (encoder.encode(line).byteLength > maxLineBytes) { + throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling"); + } + const trimmed = line.trim(); + if (!trimmed) return; // Blank lines and whitespace-only lines are ignored as padding. + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " "); + throw new CodingAgentProtocolError( + `Malformed stream-json frame received from coding-agent CLI: ${snippet}`, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " "); + throw new CodingAgentProtocolError( + `Non-object stream-json frame received from coding-agent CLI: ${snippet}`, + ); + } + yield parsed as StreamMessage; + }; + + for await (const chunk of chunks) { + totalBytes += chunk.byteLength; + if (totalBytes > maxTotalBytes) { + throw new CodingAgentStreamLimitError("Coding-agent stream exceeded the total byte ceiling"); + } + buffer += decoder.decode(chunk, { stream: true }); + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + yield* flushLine(line); + newline = buffer.indexOf("\n"); + } + if (encoder.encode(buffer).byteLength > maxLineBytes) { + throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling"); + } + } + // Flush the decoder's trailing bytes and any final line without a newline terminator. + buffer += decoder.decode(); + if (buffer.trim()) yield* flushLine(buffer); +} + +function asRecord(value: unknown): Record<string, unknown> | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** Extract OpenCodex usage from a `result` frame's Anthropic-shaped usage object. */ +export function usageFromResult(message: StreamMessage): OcxUsage | undefined { + const usage = asRecord(message.usage); + if (!usage) return undefined; + const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; + const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; + const cachedInputTokens = typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined; + const cacheCreationInputTokens = + typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined; + if (inputTokens === 0 && outputTokens === 0 && cachedInputTokens === undefined) return undefined; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + ...(cachedInputTokens !== undefined ? { cachedInputTokens, cacheReadInputTokens: cachedInputTokens } : {}), + ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), + }; +} + +/** + * Mutable per-turn parse state shared across frames of one stream (§十二). + * Thinking and text states are strictly decoupled. + */ +export interface StreamParseState { + sawPartialText: boolean; + sawPartialThinking: boolean; + sawTerminalResult: boolean; + openToolCallId?: string; +} + +/** + * Map ONE protocol frame to zero or more AdapterEvents. + * + * Token-level streaming comes from `stream_event` frames (enabled by `--include-partial-messages`); + * the complete `assistant` frame is only used as a fallback when no partial deltas were seen, so text + * and thinking are never emitted twice. + */ +export function mapStreamMessageToEvents(message: StreamMessage, state: StreamParseState): AdapterEvent[] { + const type = asString(message.type); + const events: AdapterEvent[] = []; + + if (type === "stream_event") { + const event = asRecord(message.event); + if (event) events.push(...mapRawStreamEvent(event, state)); + return events; + } + + if (type === "assistant") { + // Fallback path: a complete assistant message. Surface text and thinking independently + // only when the partial delta stream did not already carry them (§十二). + const content = asRecord(message.message)?.content; + if (Array.isArray(content)) { + for (const block of content) { + const part = asRecord(block); + if (!part) continue; + const blockType = asString(part.type); + if (blockType === "text" && !state.sawPartialText) { + const text = asString(part.text); + if (text) events.push({ type: "text_delta", text }); + } else if (blockType === "thinking" && !state.sawPartialThinking) { + const thinking = asString(part.thinking); + if (thinking) events.push({ type: "thinking_delta", thinking }); + } + } + } + return events; + } + + if (type === "result") { + const isError = message.is_error === true || asString(message.subtype) === "error_during_execution"; + const usage = usageFromResult(message); + if (isError) { + const errors = Array.isArray(message.errors) + ? message.errors.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const detail = asString(message.result) || errors[0] || "Coding-agent CLI ended the turn with an execution error"; + const vendorCode = typeof message.error_code === "number" ? message.error_code : undefined; + // Qoder documents code 118 and emits the "credit usage limit" wording. Keep the + // match deliberately narrow so other coding-agent CLIs retain their established + // generic-upstream handling for ambiguous text such as "insufficient credits". + const insufficientQuota = vendorCode === 118 || /credit usage limit/i.test(detail); + // Anchor to credential verdicts. A bare "authentication" substring also matches upstream + // service-degradation text, and a false 401 drives reauth messaging and key-pool rotation. + const authentication = /not logged in|invalid (?:personal access )?token|authentication (?:failed|error|required)|unauthorized/i.test(detail); + const rateLimited = !insufficientQuota && /rate limit|too many requests/i.test(detail); + const modelUnavailable = /model (?:is )?(?:not found|unavailable|unsupported)|invalid model/i.test(detail); + events.push({ + type: "error", + message: detail, + status: insufficientQuota || rateLimited ? 429 : authentication ? 401 : modelUnavailable ? 400 : 502, + errorType: insufficientQuota + ? "insufficient_quota" + : rateLimited + ? "rate_limit_error" + : authentication + ? "authentication_error" + : modelUnavailable + ? "invalid_request_error" + : "upstream_error", + code: insufficientQuota + ? "insufficient_quota" + : rateLimited + ? "rate_limit_exceeded" + : authentication + ? "invalid_api_key" + : modelUnavailable + ? "model_not_found" + : "upstream_error", + retryable: rateLimited, + ...(usage ? { usage } : {}), + }); + return events; + } + state.sawTerminalResult = true; + events.push({ type: "done", ...(usage ? { usage } : {}), stopReason: "stop" }); + return events; + } + + // system/init, user echoes, task_* background events: not client-visible output. + return events; +} + +/** Map a raw Anthropic SSE event (carried inside a `stream_event` frame) to AdapterEvents. */ +function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): AdapterEvent[] { + const events: AdapterEvent[] = []; + const eventType = asString(event.type); + + if (eventType === "content_block_delta") { + const delta = asRecord(event.delta); + const deltaType = asString(delta?.type); + if (deltaType === "text_delta") { + const text = asString(delta?.text); + if (text) { + state.sawPartialText = true; + events.push({ type: "text_delta", text }); + } + } else if (deltaType === "thinking_delta") { + const thinking = asString(delta?.thinking); + if (thinking) { + state.sawPartialThinking = true; + events.push({ type: "thinking_delta", thinking }); + } + } else if (deltaType === "input_json_delta") { + // Tool-input streaming. Inert while tools are disabled (Codex's catalog is not advertised), + // but parsed so the seam is ready and an unexpected frame never crashes. + const partial = asString(delta?.partial_json); + if (partial && state.openToolCallId) events.push({ type: "tool_call_delta", arguments: partial }); + } + return events; + } + + if (eventType === "content_block_start") { + const block = asRecord(event.content_block); + if (asString(block?.type) === "tool_use") { + const id = asString(block?.id) ?? ""; + const name = asString(block?.name) ?? "tool"; + if (id) { + state.openToolCallId = id; + events.push({ type: "tool_call_start", id, name }); + } + } + return events; + } + + if (eventType === "content_block_stop") { + if (state.openToolCallId) { + state.openToolCallId = undefined; + events.push({ type: "tool_call_end" }); + } + return events; + } + + return events; +} + +/** One content part on the stream-json input wire (Anthropic message shape). */ +type WireContentPart = Record<string, unknown>; + +function textPart(text: string): WireContentPart { + return { type: "text", text }; +} + +/** Encode an OpenCodex image content part as an Anthropic base64/url image block; never drop it. */ +function imagePart(imageUrl: string): WireContentPart | undefined { + const match = /^data:([^;]+);base64,(.+)$/s.exec(imageUrl); + if (match) return { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }; + if (/^https?:\/\//i.test(imageUrl)) return { type: "image", source: { type: "url", url: imageUrl } }; + return undefined; +} + +function formatMessageForHistory(message: OcxMessage): string { + if (message.role === "user") { + const text = typeof message.content === "string" + ? message.content + : message.content.map(p => (p.type === "text" ? p.text : `[${p.type}]`)).join("\n"); + return `USER:\n${text}`; + } + if (message.role === "assistant") { + const parts: string[] = []; + for (const part of message.content) { + if (part.type === "text" && part.text.trim()) { + parts.push(part.text.trim()); + } else if (part.type === "thinking" && part.thinking.trim()) { + parts.push(`[Thinking: ${part.thinking.trim()}]`); + } else if (part.type === "toolCall") { + const args = JSON.stringify(part.arguments ?? {}); + parts.push(`[Tool call: ${part.name} (call_id: ${part.id}) with args: ${args}]`); + } + } + return `ASSISTANT:\n${parts.join("\n") || "(empty response)"}`; + } + if (message.role === "toolResult") { + const text = typeof message.content === "string" + ? message.content + : message.content.map(p => (p.type === "text" ? p.text : "[image]")).join(""); + const status = message.isError ? " (error)" : ""; + return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`; + } + return ""; +} + +/** + * Format an isolated OpenCodex message into stream-json user message input lines. + * + * In stream-json mode, the official CLI stdin parser (`StreamJsonUtils.parseUserMessage`) only + * accepts `type: "user"` frames. Writing undocumented `type: "assistant"` frames is rejected. + * Non-user messages are therefore projected into valid user frames. + */ +export function buildInputLines(message: OcxMessage): string[] { + if (message.role === "developer") return []; + + const content: WireContentPart[] = []; + if (message.role === "user") { + if (typeof message.content === "string") { + content.push(textPart(message.content)); + } else { + for (const part of message.content) { + if (part.type === "text") content.push(textPart(part.text)); + else if (part.type === "image") { + const image = imagePart(part.imageUrl); + if (image) content.push(image); + } else { + content.push(textPart("[video]")); + } + } + } + } else { + const formatted = formatMessageForHistory(message); + if (formatted) content.push(textPart(formatted)); + } + + return content.length > 0 ? [JSON.stringify({ type: "user", message: { role: "user", content } })] : []; +} + +/** Fold the request's system + developer prompts into one system-prompt string. */ +export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined { + const parts: string[] = []; + for (const line of parsed.context.systemPrompt ?? []) { + if (line && line.trim()) parts.push(line); + } + for (const message of parsed.context.messages) { + if (message.role !== "developer") continue; + const text = typeof message.content === "string" + ? message.content + : message.content.map(part => (part.type === "text" ? part.text : "")).join(""); + if (text.trim()) parts.push(text); + } + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + +/** + * Build the ordered stream-json input lines for a turn (Strategy C: Legal user-message projection). + * + * In stream-json mode, the vendor CLI stdin parser strictly accepts `type: "user"` frames + * (`{"type":"user","message":{"role":"user","content":...}}`). + * Undocumented `{"type":"assistant",...}` frames are dropped by the vendor parser. + * + * Multi-turn history (user, assistant, tool results) is projected into a legal user message: + * prior conversation turns are structured as bounded context text with tool results as text, + * clearly demarcated from the current user request. Codex retains tool control; vendor tools are never invoked. + */ +export function buildConversationInput(parsed: OcxParsedRequest): string[] { + const nonDev = parsed.context.messages.filter(m => m.role !== "developer"); + if (nonDev.length === 0) { + return [JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: "" }] } })]; + } + + if (nonDev.length === 1 && nonDev[0]!.role === "user") { + return buildInputLines(nonDev[0]!); + } + + // Multi-turn conversation or history with tool results: + const historyMessages = nonDev.slice(0, -1); + const currentMessage = nonDev[nonDev.length - 1]!; + + const imageBlocks: WireContentPart[] = []; + let currentRequestText = ""; + + if (currentMessage.role === "user") { + if (typeof currentMessage.content === "string") { + currentRequestText = currentMessage.content; + } else { + const textParts: string[] = []; + for (const part of currentMessage.content) { + if (part.type === "text") textParts.push(part.text); + else if (part.type === "image") { + const image = imagePart(part.imageUrl); + if (image) imageBlocks.push(image); + } else { + textParts.push("[video]"); + } + } + currentRequestText = textParts.join("\n"); + } + } else if (currentMessage.role === "toolResult") { + const text = typeof currentMessage.content === "string" + ? currentMessage.content + : currentMessage.content.map(p => (p.type === "text" ? p.text : "[image]")).join(""); + const status = currentMessage.isError ? " (error)" : ""; + currentRequestText = `TOOL RESULT (call_id: ${currentMessage.toolCallId})${status}:\n${text}\n\nPlease proceed based on the above tool result.`; + } else { + currentRequestText = formatMessageForHistory(currentMessage); + } + + // Also collect any images from history messages so multimodal attachments are never dropped: + for (const msg of historyMessages) { + if (msg.role === "user" && Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "image") { + const img = imagePart(part.imageUrl); + if (img) imageBlocks.push(img); + } + } + } + } + + let historyText = historyMessages.map(formatMessageForHistory).filter(Boolean).join("\n\n"); + if (historyText.length > MAX_PROJECTED_HISTORY_CHARS) { + historyText = `[Earlier conversation history truncated for length...]\n\n` + + historyText.slice(historyText.length - MAX_PROJECTED_HISTORY_CHARS); + } + + const combinedText = `Prior conversation context:\n\n${historyText}\n\nCurrent user request:\n\n${currentRequestText}`; + + const content: WireContentPart[] = [{ type: "text", text: combinedText }, ...imageBlocks]; + return [JSON.stringify({ type: "user", message: { role: "user", content } })]; +} diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts new file mode 100644 index 0000000000..c1ff73fa8d --- /dev/null +++ b/src/adapters/coding-agent/turn.ts @@ -0,0 +1,353 @@ +import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { commandInvocation } from "../../lib/win-exec"; +import type { IncomingMeta } from "../base"; +import { buildConversationInput, CodingAgentProtocolError, mapStreamMessageToEvents, readJsonLines, type StreamParseState } from "./protocol"; +import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile"; + +/** Injectable spawn for tests; production uses node:child_process. */ +export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; + +/** Per-turn injectables: spawn/which seams for tests plus wall-clock ceilings for timeout, kill grace, and bounded reap. */ +export interface CodingAgentDeps { + spawn?: SpawnFn; + which?: WhichFn; + /** Overall wall-clock ceiling for one turn (ms). */ + timeoutMs?: number; + /** Grace period between SIGTERM and SIGKILL (ms). */ + killGraceMs?: number; + /** Maximum time to wait for a child that never reports close after termination (ms). */ + reapTimeoutMs?: number; + /** Test seam for Windows command-shim invocation. */ + platform?: NodeJS.Platform; +} + +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_KILL_GRACE_MS = 2_000; +/** Bound captured stderr so an error message can never carry an unbounded (or secret) payload. */ +const MAX_STDERR_BYTES = 8 * 1024; + +/** Env keys a CLI needs to run; everything else is dropped so the child env is scoped and deterministic. */ +const INHERITED_ENV_KEYS = [ + "PATH", "HOME", "USERPROFILE", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP", + "SHELL", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", + "COMSPEC", "PATHEXT", "SYSTEMDRIVE", "USERNAME", "TZ", +] as const; + +/** + * Base scoped child-process environment (§六/§十四). + * + * Never mutates `process.env` (no cross-provider pollution under concurrency) and never inherits a + * parent vendor variable, so a stray region switch in the host shell cannot flip a provider's + * region: the profile is the sole authority. Family builders layer the credential + region vars on + * top of this. + */ +export function baseScopedEnv(): Record<string, string> { + const env: Record<string, string> = {}; + for (const key of INHERITED_ENV_KEYS) { + const value = process.env[key]; + if (typeof value === "string" && value.length > 0) env[key] = value; + } + return env; +} + +/** Redact the profile's credential and common secret shapes before surfacing diagnostics. */ +export function redactSecrets(text: string, tokenEnv: string, credential?: string): string { + const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + let redacted = text; + if (credential) redacted = redacted.split(credential).join("[redacted]"); + return redacted + .replace(new RegExp(`(${escaped}\\s*[:=]\\s*)\\S+`, "gi"), "$1[redacted]") + .replace(/(authorization\s*[:=]\s*)\S+/gi, "$1[redacted]") + .replace(/\b(sk-[A-Za-z0-9_-]{6,})\b/g, "[redacted]"); +} + +/** Inputs for one headless CLI turn: region profiles, request context, and family-specific arg/env builders. */ +export interface CodingAgentTurnInput { + /** Region profiles for this family; the turn fails closed if the base URL matches none. */ + profiles: readonly CodingAgentProviderProfile[]; + provider: OcxProviderConfig; + parsed: OcxParsedRequest; + incoming: IncomingMeta; + emit: (event: AdapterEvent) => void; + /** Family-specific headless argument builder (tools disabled, model, reasoning, system prompt). */ + buildArgs: (profile: CodingAgentProviderProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig) => string[]; + /** Family-specific scoped env builder (credential + region switch on top of baseScopedEnv). */ + buildEnv: (profile: CodingAgentProviderProfile, apiKey: string) => Record<string, string>; + deps: CodingAgentDeps; +} + +/** + * Run one headless coding-agent CLI turn as an OpenCodex `runTurn` (§七/§三十). + * + * Single transport for every official coding-agent CLI provider: fail closed on a non-canonical + * destination, pre-flight the credential and binary, spawn with a scoped env and tools disabled, feed + * the replayed conversation over stream-json, map the vendor's Anthropic-aligned frames to + * AdapterEvents, and always reap the process. Codex retains tool ownership: the CLI runs with its own + * tools disabled, so this turn yields text/reasoning (the control-protocol tool bridge is a + * documented fast-follow). + */ +export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<void> { + const { profiles, provider, parsed, incoming, emit, buildArgs, buildEnv, deps } = input; + const spawnFn = deps.spawn ?? nodeSpawn; + const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const killGraceMs = deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS; + const reapTimeoutMs = deps.reapTimeoutMs ?? (killGraceMs * 2 + 250); + + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: "Coding-agent turn was aborted before start." }); + return; + } + + // Fail closed on a non-canonical destination BEFORE any credential is placed in an env (§十六). + const profile = resolveProfileByBaseUrl(profiles, provider.baseUrl); + if (!profile) { + emit({ + type: "error", + message: "Provider base URL is not a canonical region destination; the credential was not sent.", + status: 400, + errorType: "invalid_request_error", + code: "non_canonical_destination", + retryable: false, + }); + return; + } + const apiKey = provider.apiKey; + if (!apiKey) { + emit({ + type: "error", + message: `${profile.label} credential missing — add an API key for this provider (${profile.tokenEnv}).`, + status: 401, + errorType: "authentication_error", + code: "missing_credential", + retryable: false, + }); + return; + } + // Pre-flight binary discovery so a missing CLI is a clear error, not a mid-turn ENOENT (§二十六). + const binary = resolveCodingAgentBinary(profile, deps.which); + if (!binary) { + emit({ + type: "error", + message: `${profile.label} CLI not found on PATH. Install it with: ${profile.installHint}`, + status: 500, + errorType: "upstream_error", + code: "cli_not_found", + retryable: false, + }); + return; + } + + const args = buildArgs(profile, parsed, provider); + const env = buildEnv(profile, apiKey); + const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env }); + + let child: ChildProcess; + try { + child = spawnFn(invocation.file, invocation.args, { + ...invocation.options, + env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + } catch (err) { + emit({ + type: "error", + message: redactSecrets(err instanceof Error ? err.message : String(err), profile.tokenEnv, apiKey), + status: 500, + errorType: "upstream_error", + code: "cli_spawn_failed", + retryable: false, + }); + return; + } + + // `spawn()` reports launch failures such as ENOENT asynchronously through `error`; they are not + // reliably thrown by the call above. Subscribe immediately and create the lifecycle promise now, + // before stdout can end, so neither a fast close nor a launch failure can be missed by the reap step. + let childProcessError: Error | undefined; + const processLifecycle = new Promise<void>(resolve => { + let settled = false; + const settle = (): void => { + if (settled) return; + settled = true; + resolve(); + }; + child.once("error", err => { + childProcessError = err; + // A launch failure has no process to reap and is not guaranteed to emit `close` on every runtime. + if (child.pid === undefined) settle(); + }); + child.once("close", settle); + if (child.exitCode !== null) settle(); + }); + + let terminalEmitted = false; + const emitOnce = (event: AdapterEvent): void => { + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + if (terminalEmitted) return; + terminalEmitted = true; + } + emit(event); + }; + + const stderrChunks: string[] = []; + let killed = false; + let killTimer: ReturnType<typeof setTimeout> | undefined; + const kill = (): void => { + if (killed || child.killed) return; + killed = true; + try { child.kill("SIGTERM"); } catch { /* already gone */ } + killTimer = setTimeout(() => { + try { child.kill("SIGKILL"); } catch { /* already gone */ } + }, killGraceMs); + }; + + const stopStream = (): void => { + try { child.stdout?.destroy(); } catch { /* already closed */ } + }; + const onAbort = (): void => { + kill(); + stopStream(); + }; + incoming.abortSignal?.addEventListener("abort", onAbort, { once: true }); + const timeoutTimer = setTimeout(() => { + kill(); + stopStream(); + emitOnce({ type: "error", message: `${profile.label} turn timed out.`, status: 504, errorType: "upstream_error", code: "timeout", retryable: true }); + }, timeoutMs); + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + if (stderrChunks.join("").length < MAX_STDERR_BYTES) stderrChunks.push(chunk); + }); + + const cleanup = (): void => { + clearTimeout(timeoutTimer); + incoming.abortSignal?.removeEventListener("abort", onAbort); + try { child.stdin?.destroy(); } catch { /* ignore */ } + // Termination is owned by the reap step below, not here: killing in cleanup would set + // `child.killed` and let the wait resolve before the process is actually reaped (§三十). + }; + + let streamProtocolError: string | undefined; + let turnError: string | undefined; + const state: StreamParseState = { + sawPartialText: false, + sawPartialThinking: false, + sawTerminalResult: false, + openToolCallId: undefined, + }; + + try { + // Write the replayed conversation, then close stdin so a single-shot turn can complete. + const stdin = child.stdin; + if (stdin) { + stdin.on("error", () => { /* EPIPE if the CLI exits early; surfaced via close/stderr */ }); + for (const line of buildConversationInput(parsed)) stdin.write(`${line}\n`); + stdin.end(); + } + const stdout = child.stdout; + if (!stdout) throw new CodingAgentProtocolError(`${profile.label} CLI produced no stdout stream`); + try { + for await (const message of readJsonLines(stdout)) { + if (incoming.abortSignal?.aborted) break; + for (const event of mapStreamMessageToEvents(message, state)) { + emitOnce(event.type === "error" + ? { ...event, message: redactSecrets(event.message, profile.tokenEnv, apiKey) } + : event); + } + if (terminalEmitted) break; + } + } catch (err) { + kill(); + streamProtocolError = err instanceof Error ? err.message : String(err); + } + } catch (err) { + kill(); + turnError = err instanceof Error ? err.message : String(err); + } finally { + cleanup(); + } + + // Reap the process so no zombie is left behind (§三十): wait for the real `close`, and + // force-terminate only if it lingers past the grace window after the stream ended. + const graceTimer = setTimeout(() => { kill(); }, killGraceMs); + let reapTimer: ReturnType<typeof setTimeout> | undefined; + await Promise.race([ + processLifecycle, + new Promise<void>(resolve => { + reapTimer = setTimeout(resolve, reapTimeoutMs); + }), + ]); + clearTimeout(graceTimer); + if (reapTimer) clearTimeout(reapTimer); + if (killTimer) clearTimeout(killTimer); + + if (!terminalEmitted) { + const stderr = redactSecrets(boundedStderr(stderrChunks), profile.tokenEnv, apiKey); + if (incoming.abortSignal?.aborted) { + emitOnce({ type: "error", message: `${profile.label} turn was aborted.`, retryable: false }); + } else if (childProcessError) { + emitOnce({ + type: "error", + message: `${profile.label} CLI failed to start: ${redactSecrets(childProcessError.message, profile.tokenEnv, apiKey)}`, + status: 500, + errorType: "upstream_error", + code: "cli_spawn_failed", + retryable: false, + }); + } else if (turnError) { + emitOnce({ + type: "error", + message: redactSecrets(turnError, profile.tokenEnv, apiKey), + status: 502, + errorType: "upstream_error", + }); + } else if (streamProtocolError) { + emitOnce({ + type: "error", + message: redactSecrets(streamProtocolError, profile.tokenEnv, apiKey), + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + } else if (child.exitCode !== null && child.exitCode !== 0) { + const exitMsg = stderr + ? `${profile.label} CLI exited with code ${child.exitCode}: ${stderr}` + : `${profile.label} CLI exited with non-zero exit code ${child.exitCode}`; + emitOnce({ + type: "error", + message: exitMsg, + status: 502, + errorType: "upstream_error", + code: "process_exit_error", + retryable: false, + }); + } else if (!state.sawTerminalResult) { + const msg = stderr + ? `${profile.label} CLI ended without a terminal result frame: ${stderr}` + : `${profile.label} CLI ended without a terminal result frame`; + emitOnce({ + type: "error", + message: msg, + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + } + } +} + +function boundedStderr(chunks: string[]): string { + let total = 0; + const kept: string[] = []; + for (const chunk of chunks) { + if (total >= MAX_STDERR_BYTES) break; + kept.push(chunk); + total += chunk.length; + } + return kept.join("").slice(0, MAX_STDERR_BYTES).trim(); +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 1001ce6085..5fd503d1f2 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -452,6 +452,18 @@ function messagesToGeminiFormat( } } + // Gemini API and Claude-on-Antigravity reject assistant-tail (model-tail in Gemini terms) + // histories. Gemini fails upstream with "Requests ending with a model turn are not supported" + // (HTTP 400), while Claude fails with "This model does not support assistant message prefill. + // The conversation must end with a user message." Context compaction, previous_response_id + // expansion, subagent orchestration, and interrupted-turn replay can all produce a + // model-tail history. Append a user "(continue)" nudge, mirroring the anthropic adapter's + // tail guard (src/adapters/anthropic.ts). + const lastTurn = contents.length > 0 ? (contents[contents.length - 1] as { role?: string }) : undefined; + if (!lastTurn || lastTurn.role === "model") { + contents.push({ role: "user", parts: [{ text: "(continue)" }] }); + } + return { systemInstruction, contents, replayedCallIds }; } @@ -979,17 +991,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // fills a first functionCall that replay could not sign. Outside the cache branch too, // because the turn still needs a signature when no session was ever recorded. applyAntigravityThoughtSignatureFallback(wireModelId, contents); - // Claude-on-Antigravity rejects assistant-tail (model-tail in Gemini terms) histories - // as prefill: "This model does not support assistant message prefill. The conversation - // must end with a user message." Context compaction, previous_response_id expansion, - // and interrupted-turn replay can all produce a model-tail history. Append a user - // "(continue)" nudge, mirroring the anthropic adapter's tail guard (src/adapters/anthropic.ts). - if (/claude/i.test(wireModelId)) { - const last = contents.length > 0 ? contents[contents.length - 1] as { role?: string } : undefined; - if (!last || last.role === "model") { - contents.push({ role: "user", parts: [{ text: "(continue)" }] }); - } - } + // The model-tail "(continue)" guard runs once, in messagesToGeminiFormat, so CCA, + // Vertex and AI Studio share one decision. A second check here would append a + // duplicate nudge whenever signature sanitization reshapes the tail afterwards. } const envelope = { model: wireModelId, diff --git a/src/adapters/mimo-free.ts b/src/adapters/mimo-free.ts index a257e7d4c9..d394423cb1 100644 --- a/src/adapters/mimo-free.ts +++ b/src/adapters/mimo-free.ts @@ -110,6 +110,7 @@ async function fetchJwt(signal?: AbortSignal): Promise<string> { const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; const response = await fetch(BOOTSTRAP_URL, { method: "POST", + redirect: "manual", headers: { "Content-Type": "application/json", "User-Agent": randomUserAgent(), @@ -249,6 +250,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap async fetchResponse(request: AdapterRequest, ctx): Promise<Response> { const response = await fetch(request.url, { method: request.method, + redirect: "manual", headers: request.headers as Record<string, string>, body: request.body, signal: ctx?.abortSignal, @@ -268,6 +270,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap }; return fetch(request.url, { method: request.method, + redirect: "manual", headers: retryHeaders, body: request.body, signal: ctx?.abortSignal, diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 0abb3277ae..4ba654487c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -27,7 +27,7 @@ import { type ResolvedFastPolicy, } from "../providers/fastwire"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; -import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; +import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "./responses-tool-schema"; import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter"; import { isXaiSchemaTarget, @@ -1331,7 +1331,7 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig : moonshotTarget ? normalizeMoonshotToolParameters(t.parameters) : ensureRootObjectType(t.parameters); - const parameters = stripResponsesOnlyEncryptedMarker(normalized); + const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker(normalized)); if (parameters === undefined) return []; return [{ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 24c112e8a9..ee1b7c9445 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,4 +1,6 @@ -import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; +import { normalizeRoutedAgentMessages } from "./routed-agent-messages"; +import { normalizeOpenCodeGoAdditionalTools } from "./opencode-go-additional-tools"; +import { isXaiResponsesDestination } from "../providers/xai-transport"; import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; @@ -23,6 +25,7 @@ import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-com import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; import { normalizeResponsesCodeMode } from "./responses-code-mode"; +import { stripUnicodePropertyPatterns } from "./responses-tool-schema"; import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { @@ -665,14 +668,18 @@ function mapRoutedResponsesReasoningEffort( function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { if (!isPlainObject(tool) || tool.type !== "function") return tool; + // Runs for every Responses destination, forward auth included: the ChatGPT backend is where + // the `\p{…}` rejection was observed, and it reaches this function through the same seam. + const compatible = stripUnicodePropertyPatterns(tool); + const source = isPlainObject(compatible) ? compatible : tool; if (xaiTarget) { - const parameters = normalizeXaiToolParameters(isPlainObject(tool.parameters) ? tool.parameters : {}); - return parameters === undefined ? undefined : { ...tool, parameters }; + const parameters = normalizeXaiToolParameters(isPlainObject(source.parameters) ? source.parameters : {}); + return parameters === undefined ? undefined : { ...source, parameters }; } - if (isPlainObject(tool.parameters) && tool.parameters.type === "object") return tool; + if (isPlainObject(source.parameters) && source.parameters.type === "object") return source; return { - ...tool, - parameters: { ...(isPlainObject(tool.parameters) ? tool.parameters : {}), type: "object" }, + ...source, + parameters: { ...(isPlainObject(source.parameters) ? source.parameters : {}), type: "object" }, }; } @@ -2142,12 +2149,15 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { */ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.3-contributor", + "muse-spark-1.3-contributor-free", "muse-spark-1.2-contributor", + "muse-spark-1.2-contributor-free", ]); const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([ "https://opencode.ai/zen/v1/responses", "https://opencode.ai/zen/go/v1/responses", + "https://api.meta.ai/v1/responses", ]); const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ @@ -2156,12 +2166,13 @@ const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ ] as const; /** - * OpenCode Zen / Go Muse Spark Responses gateway refuses a short list of Codex - * `web_search` fields. `web_search_preview` keeps its accepted shape, and Luna - * remains untouched. Match the exact effective request URL; malformed, credentialed, - * or parameterized destinations keep their original body instead of assuming this - * gateway contract. Keep the rejected names together so a newly identified field is - * a one-line compatibility update rather than another bespoke rewrite. + * OpenCode Zen / Go and the direct Meta Muse Spark Responses gateways refuse a + * short list of Codex `web_search` fields. `web_search_preview` keeps its accepted + * shape, and Luna remains untouched. Match the exact effective request URL; + * malformed, credentialed, or parameterized destinations keep their original body + * instead of assuming this gateway contract. Keep the rejected names together so a + * newly identified field is a one-line compatibility update rather than another + * bespoke rewrite. */ function stripMuseSparkUnsupportedWebSearchFields( body: unknown, @@ -2383,7 +2394,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); - if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); + if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { + allowStringContent: isXaiResponsesDestination(provider), + }); outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. @@ -2474,6 +2487,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } + if (!forward) outBody = normalizeOpenCodeGoAdditionalTools(outBody, url); // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would // let a noncanonical custom forward provider skip this rewrite while the server still routes // it as a summarizer turn (#422). The compaction body build removes the tool surface and must @@ -2523,6 +2537,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed.modelId, ); if (isCanonicalOpenAiForwardProvider(provider)) { + // Spark closes Responses Lite streams before a terminal completion. Select compatibility + // from the final wire model so aliases cannot leave the caller or a static header enabled. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; + } + } const routingHeaders = new Headers(headers); applyCodexRoutingHint(routingHeaders, finalBody); // Static headers may use mixed casing. Remove every stale spelling diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts new file mode 100644 index 0000000000..35e3aaa3e4 --- /dev/null +++ b/src/adapters/opencode-go-additional-tools.ts @@ -0,0 +1,35 @@ +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ +export function normalizeOpenCodeGoAdditionalTools(body: unknown, responseUrl: string): unknown { + let destination: URL; + try { + destination = new URL(responseUrl); + } catch { + return body; + } + if (destination.origin !== "https://opencode.ai" + || destination.pathname !== "/zen/go/v1/responses" + || destination.username || destination.password + || destination.href.includes("?") || destination.href.includes("#")) return body; + if (!isRecord(body) || !Array.isArray(body.input)) return body; + // Do not replace a malformed top-level catalog with a partial promoted one. + if (body.tools !== undefined && !Array.isArray(body.tools)) return body; + + const input: unknown[] = []; + const promoted: unknown[] = []; + let changed = false; + for (const item of body.input) { + if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + changed = true; + // Custom/search/namespace lowering already owns identity and deduplication. This pass + // only moves declarations, including hosted tools that intentionally have no name. + for (const tool of item.tools) promoted.push(tool); + } else { + input.push(item); + } + } + return changed ? { ...body, input, tools: [...(body.tools ?? []), ...promoted] } : body; +} diff --git a/src/adapters/opencode-go.ts b/src/adapters/opencode-go.ts deleted file mode 100644 index 94055a292a..0000000000 --- a/src/adapters/opencode-go.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** Match the Go destination, including user-renamed provider entries. */ -export function isOpenCodeGo(baseUrl: string): boolean { - try { - const url = new URL(baseUrl); - return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1"; - } catch { return false; } -} - -/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */ -export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown { - if (!body || typeof body !== "object" || Array.isArray(body)) return body; - const record = body as Record<string, unknown>; - if (!Array.isArray(record.input)) return body; - let changed = false; - const input = record.input.map((item: unknown) => { - if (!item || typeof item !== "object" || Array.isArray(item)) return item; - const message = item as Record<string, unknown>; - if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item; - // Genuine ciphertext and unknown part types must retain their existing fail-closed path. - if (!message.content.every(part => part && typeof part === "object" - && ["input_text", "input_image", "input_file"].includes(part.type))) return item; - const identities = Object.fromEntries(["author", "recipient"] - .filter(key => typeof message[key] === "string") - .map(key => [key, message[key]])); - changed = true; - return { - type: "message", role: "user", - content: [ - ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), - ...message.content, - ], - }; - }); - return changed ? { ...record, input } : body; -} diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts new file mode 100644 index 0000000000..20c0b5581b --- /dev/null +++ b/src/adapters/qoder/adapter.ts @@ -0,0 +1,70 @@ +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { AdapterRequest, ProviderAdapter } from "../base"; +import { mapReasoningEffort } from "../../reasoning-effort"; +import { buildSystemPrompt } from "../coding-agent/protocol"; +import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn"; +import { QODER_PROFILES, type QoderProfile } from "./profiles"; + +export type QoderAdapterDeps = CodingAgentDeps; + +export function buildQoderChildEnv(profile: QoderProfile, apiKey: string): Record<string, string> { + return { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey }; +} + +/** Single-shot, tools-disabled Qoder CLI invocation; Codex remains the tool owner. */ +export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] { + const args = [ + "-p", + "--output-format", "stream-json", + "--input-format", "stream-json", + "--tools", "", + "--strict-mcp-config", + "--setting-sources", "", + "--max-turns", "1", + "--no-session-persistence", + "--model", parsed.modelId, + ]; + const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + if (effort) args.push("--reasoning-effort", effort); + const system = buildSystemPrompt(parsed); + if (system) args.push("--append-system-prompt", system); + return args; +} + +export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapterDeps = {}): ProviderAdapter { + return { + name: "qoder", + buildRequest(): AdapterRequest { + return { url: provider.baseUrl, method: "POST", headers: {}, body: "" }; + }, + async *parseStream(): AsyncGenerator<AdapterEvent> { + yield { type: "error", message: "Qoder adapter uses runTurn; the fetch/parseStream path is disabled." }; + }, + async runTurn(parsed, incoming, emit): Promise<void> { + const hasImage = parsed.context.messages.some(message => + Array.isArray(message.content) && message.content.some(part => part.type === "image"), + ); + if (hasImage) { + emit({ + type: "error", + message: "Qoder image input is not enabled because the CLI provider route has no verified multimodal contract.", + status: 400, + errorType: "invalid_request_error", + code: "unsupported_input_modality", + retryable: false, + }); + return; + } + await runCodingAgentTurn({ + profiles: QODER_PROFILES, + provider, + parsed, + incoming, + emit, + buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov), + buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey), + deps, + }); + }, + }; +} diff --git a/src/adapters/qoder/live-models.ts b/src/adapters/qoder/live-models.ts new file mode 100644 index 0000000000..06d10408bf --- /dev/null +++ b/src/adapters/qoder/live-models.ts @@ -0,0 +1,89 @@ +import { execFile } from "node:child_process"; +import { commandInvocation } from "../../lib/win-exec"; +import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits"; +import { baseScopedEnv, redactSecrets } from "../coding-agent/turn"; +import { resolveCodingAgentBinary, type WhichFn } from "../coding-agent/profile"; +import type { QoderProfile } from "./profiles"; + +const MAX_OUTPUT_BYTES = 256 * 1024; +const MAX_MODELS = 256; + +export type QoderModelsResult = + | { ok: true; models: string[] } + | { ok: false; error: "auth" | "cli_not_found" | "timeout" | "process" | "invalid_output" | "empty" | "too_large"; detail?: string }; + +export interface QoderExecResult { stdout: string; stderr: string } +export type QoderExecFn = ( + command: string, + args: readonly string[], + options: { env: Record<string, string>; timeout: number; maxBuffer: number; windowsHide: boolean; windowsVerbatimArguments?: boolean }, +) => Promise<QoderExecResult>; + +export interface QoderModelsDeps { + which?: WhichFn; + platform?: NodeJS.Platform; + timeoutMs?: number; + exec?: QoderExecFn; +} + +type QoderModelsFetcher = (profile: QoderProfile, apiKey: string) => QoderModelsResult | Promise<QoderModelsResult>; +let qoderModelsFetcherForTests: QoderModelsFetcher | null = null; + +export function setFetchQoderModelsForTests(next: QoderModelsFetcher | null): void { + qoderModelsFetcherForTests = next; +} + +export function parseQoderModelList(stdout: string): QoderModelsResult { + if (Buffer.byteLength(stdout) > MAX_OUTPUT_BYTES) return { ok: false, error: "too_large" }; + const lines = stdout.split(/\r?\n/); + const header = lines.findIndex(raw => /^model$/i.test(raw.trim())); + if (header < 0) return { ok: false, error: "invalid_output", detail: "Qoder model list header is missing" }; + const models: string[] = []; + const seen = new Set<string>(); + for (const raw of lines.slice(header + 1)) { + const id = raw.trim(); + if (!id || seen.has(id) || !isValidModelDiscoveryModelId(id)) continue; + seen.add(id); + models.push(id); + if (models.length >= MAX_MODELS) break; + } + return models.length > 0 ? { ok: true, models } : { ok: false, error: "empty" }; +} + +function execQoder(command: string, args: readonly string[], options: Parameters<QoderExecFn>[2]): Promise<QoderExecResult> { + return new Promise((resolve, reject) => { + execFile(command, [...args], { ...options, encoding: "utf8" }, (error, stdout, stderr) => { + if (error) { + reject(Object.assign(error, { stdout, stderr })); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +/** Discover the roster exposed to this exact PAT via the documented `--list-models` command. */ +export async function fetchQoderModels(profile: QoderProfile, apiKey: string, deps: QoderModelsDeps = {}): Promise<QoderModelsResult> { + if (qoderModelsFetcherForTests) return qoderModelsFetcherForTests(profile, apiKey); + const binary = resolveCodingAgentBinary(profile, deps.which); + if (!binary) return { ok: false, error: "cli_not_found", detail: profile.installHint }; + const env = { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey }; + const invocation = commandInvocation(binary, ["--list-models"], deps.platform ?? process.platform, { env }); + try { + const result = await (deps.exec ?? execQoder)(invocation.file, invocation.args, { + ...invocation.options, + env, + timeout: deps.timeoutMs ?? 8_000, + maxBuffer: MAX_OUTPUT_BYTES, + windowsHide: true, + }); + return parseQoderModelList(result.stdout); + } catch (error) { + const failure = error as NodeJS.ErrnoException & { killed?: boolean; stderr?: string }; + const stderr = redactSecrets(failure.stderr ?? failure.message ?? String(error), profile.tokenEnv, apiKey).trim().slice(0, 512); + if (failure.killed || failure.code === "ETIMEDOUT") return { ok: false, error: "timeout", detail: "Qoder model discovery timed out" }; + if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return { ok: false, error: "too_large" }; + const auth = /not logged in|invalid (?:personal access )?token|authentication/i.test(stderr); + return { ok: false, error: auth ? "auth" : "process", ...(stderr ? { detail: stderr } : {}) }; + } +} diff --git a/src/adapters/qoder/profiles.ts b/src/adapters/qoder/profiles.ts new file mode 100644 index 0000000000..a90a274f43 --- /dev/null +++ b/src/adapters/qoder/profiles.ts @@ -0,0 +1,36 @@ +import { clearCodingAgentBinaryCache, resolveProfileByBaseUrl, type CodingAgentProviderProfile } from "../coding-agent/profile"; + +/** Official Qoder CLI profile. Region variants are separate profiles and credentials. */ +export interface QoderProfile extends CodingAgentProviderProfile { + family: "qoder"; +} + +export const QODER_GLOBAL_PROFILE: QoderProfile = { + providerId: "qoder", + family: "qoder", + region: "global", + label: "Qoder", + canonicalBaseUrl: "https://qoder.com", + binaryCandidates: ["qoder", "qodercli"], + tokenEnv: "QODER_PERSONAL_ACCESS_TOKEN", + installHint: "npm install -g @qoder-ai/qodercli", + documentationUrl: "https://docs.qoder.com/cli/authentication", +}; + +export const QODER_CN_PROFILE: QoderProfile = { + providerId: "qoder-cn", + family: "qoder", + region: "cn", + label: "Qoder CN", + canonicalBaseUrl: "https://qoder.cn", + binaryCandidates: ["qodercn", "qoderclicn"], + tokenEnv: "QODERCN_PERSONAL_ACCESS_TOKEN", + installHint: "npm install -g @qodercn-ai/qoderclicn", + documentationUrl: "https://docs.qoder.cn/en/cli/authentication", +}; + +export const QODER_PROFILES: readonly QoderProfile[] = [QODER_GLOBAL_PROFILE, QODER_CN_PROFILE]; +export function resolveQoderProfile(baseUrl: string | undefined): QoderProfile | undefined { + return resolveProfileByBaseUrl(QODER_PROFILES, baseUrl) as QoderProfile | undefined; +} +export const clearQoderBinaryCache = clearCodingAgentBinaryCache; diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 81fdbf99a4..d8edbead92 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -2,6 +2,8 @@ import { createAnthropicAdapter } from "./anthropic"; import { createAzureAdapter } from "./azure"; import type { ProviderAdapter } from "./base"; import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay"; +import { createCodeBuddyAdapter } from "./codebuddy/adapter"; +import { createQoderAdapter } from "./qoder/adapter"; import { createCommandCodeAdapter } from "./command-code"; import { createCursorAdapter } from "./cursor"; import { createGoogleAdapter } from "./google"; @@ -20,6 +22,7 @@ export interface AdapterFactoryContext { } export type AdapterWire = + | "codebuddy" | "command-code" | "openai-chat" | "ollama-native" @@ -53,6 +56,11 @@ type InheritedAdapterDefinition = { type AdapterDefinition = DirectAdapterDefinition | InheritedAdapterDefinition; export const ADAPTER_REGISTRY = { + codebuddy: { + wire: "codebuddy", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCodeBuddyAdapter(provider), + }, "command-code": { wire: "command-code", mutation: "codex-owned", @@ -108,6 +116,10 @@ export const ADAPTER_REGISTRY = { contractParent: "openai-chat", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), }, + qoder: { + contractParent: "codebuddy", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createQoderAdapter(provider), + }, } as const satisfies Record<string, AdapterDefinition>; export type AdapterId = keyof typeof ADAPTER_REGISTRY; diff --git a/src/adapters/responses-tool-schema.ts b/src/adapters/responses-tool-schema.ts index 0c02c589aa..7d10b9beec 100644 --- a/src/adapters/responses-tool-schema.ts +++ b/src/adapters/responses-tool-schema.ts @@ -1,8 +1,7 @@ -// Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on -// collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an -// annotation for the ChatGPT backend only, so translated provider schemas must -// drop it without removing properties or definitions literally named `encrypted`. -const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([ +// Keys whose *children's names* are caller-chosen rather than schema keywords, and keys whose +// values are literal payloads rather than schemas. Shared by both strippers below: each one has +// to tell "the keyword `x`" apart from "a property someone named `x`". +const SCHEMA_NAME_BAG_KEYS = new Set([ "properties", "patternProperties", "$defs", @@ -11,9 +10,21 @@ const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([ "dependentSchemas", "dependentRequired", ]); -const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]); +const SCHEMA_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]); + +// These subtrees depend on property evaluation, polarity, branch selection or references. +// Preserve their complete argument contract rather than guessing whether a local relaxation +// remains a relaxation in the containing schema. The destination reports unsupported regexes. +const PRESERVED_PATTERN_SUBTREES = new Set([ + "patternProperties", "not", "oneOf", "if", "contains", "$defs", "definitions", +]); /** + * Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on collaboration tool + * schemas (openai/codex 5f4d06ef; issue #85). It is an annotation for the ChatGPT backend only, + * so translated provider schemas must drop it without removing properties or definitions + * literally named `encrypted`. + * * The schema is caller-supplied, so its nesting depth is attacker-influenced. Native recursion * would turn a deep schema into a stack overflow that takes down the request path, so this walks * an explicit stack instead: depth costs heap, which is bounded and recoverable. @@ -52,11 +63,11 @@ export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = fal // Inside a name bag every key is a caller-chosen name, so `encrypted` here is data. stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } }); } else if (key !== "encrypted") { - if (ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)) { + if (SCHEMA_LITERAL_VALUE_KEYS.has(key)) { // Literal payloads are values, not schemas: an `encrypted` key inside them is data. out[key] = value; } else { - const childInNameBag = ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key); + const childInNameBag = SCHEMA_NAME_BAG_KEYS.has(key); stack.push({ node: value, inNameBag: childInNameBag, assign: v => { out[key] = v; } }); } } @@ -65,3 +76,97 @@ export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = fal return result; } + +/** + * `\p{…}` is an escape only when the backslash introducing it is itself unescaped: in `\\p{2}` + * the pair is a literal backslash and the `p{2}` that follows is an ordinary quantified `p`, + * which Python compiles fine. Scanning for the raw substring would misread that as a property + * escape and discard a working pattern. + */ +function usesUnicodePropertyEscape(pattern: string): boolean { + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] !== "\\") continue; + const next = pattern[i + 1]; + if (next === "\\") { + i++; + continue; + } + if ((next === "p" || next === "P") && pattern[i + 2] === "{") return true; + } + return false; +} + +/** + * Remove unsupported Unicode property escapes from scalar `pattern` constraints in ordinary + * positive schema positions. This keeps built-in Artifact tools usable on Python-re backends; + * the omitted constraint is not enforced by this proxy and tools must validate their inputs. + * + * Regex-keyed objects are preserved. Removing a matcher can lose evaluated-property annotations + * needed by an ancestor's unevaluatedProperties, even when the local object appears open. + * Negation, exclusive alternatives, conditions, contains and reusable definitions are also + * preserved: loosening a nested constraint can instead reject an input in those contexts. + * Unsupported patterns there remain the destination's validation responsibility. + * + * Returns `node` itself when nothing was dropped. Uses an explicit stack for caller-controlled + * nesting depth; the separate Responses-only encrypted-marker normalization is unchanged. + */ +export function stripUnicodePropertyPatterns(node: unknown, inNameBag = false): unknown { + type Assign = (value: unknown) => void; + interface Frame { node: unknown; inNameBag: boolean; assign: Assign } + + let result: unknown; + let dropped = 0; + const stack: Frame[] = [{ node, inNameBag, assign: value => { result = value; } }]; + + while (stack.length > 0) { + const frame = stack.pop()!; + const current = frame.node; + + if (Array.isArray(current)) { + const out: unknown[] = new Array(current.length); + frame.assign(out); + // Array items are schemas in their own right, never a name bag. + for (let i = current.length - 1; i >= 0; i--) { + stack.push({ node: current[i], inNameBag: false, assign: value => { out[i] = value; } }); + } + continue; + } + if (!current || typeof current !== "object") { + frame.assign(current); + continue; + } + + // A schema name may be `__proto__`; a null-prototype record keeps it as data. + const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>; + frame.assign(out); + + for (const [key, value] of Object.entries(current as Record<string, unknown>)) { + if (frame.inNameBag) { + // Inside a name bag every key is a caller-chosen name, so `pattern` here is a property + // name; its value is still a schema and is walked as one. + stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } }); + continue; + } + if (PRESERVED_PATTERN_SUBTREES.has(key)) { + out[key] = value; + continue; + } + if (key === "pattern" && typeof value === "string" && usesUnicodePropertyEscape(value)) { + dropped++; + continue; + } + if (SCHEMA_LITERAL_VALUE_KEYS.has(key)) { + // Literal payloads are values, not schemas: a `pattern` key inside them is data. + out[key] = value; + continue; + } + stack.push({ + node: value, + inNameBag: SCHEMA_NAME_BAG_KEYS.has(key), + assign: v => { out[key] = v; }, + }); + } + } + + return dropped === 0 ? node : result; +} diff --git a/src/adapters/routed-agent-messages.ts b/src/adapters/routed-agent-messages.ts new file mode 100644 index 0000000000..2ca67162a7 --- /dev/null +++ b/src/adapters/routed-agent-messages.ts @@ -0,0 +1,46 @@ +/** + * `agent_message` is Codex's private multi-agent input item: it exists only in the ChatGPT + * Codex backend's schema. Codex replays every sub-agent reply in the history it sends, so + * once a thread has used sub-agents, a routed Responses destination answers the whole body + * with `422 unknown item type "agent_message"` and every later turn of that thread fails the + * same way. Rewrite the item as the public user message it already is. + * + * Genuine ciphertext and unknown part types keep their existing fail-closed path: the + * encrypted v2 task surface owns those, through `unreadable_encrypted_agent_task` and the + * opt-in recovery route. Providers using `authMode: "forward"` never reach this function. + */ +export function normalizeRoutedAgentMessages( + body: unknown, + { allowStringContent = false }: { allowStringContent?: boolean } = {}, +): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record<string, unknown>; + if (!Array.isArray(record.input)) return body; + let changed = false; + const input = record.input.map((item: unknown) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const message = item as Record<string, unknown>; + if (message.type !== "agent_message") return item; + // xAI rejects the private item even when a complete child result is a plain string. + // Trimming decides emptiness only; the original result bytes remain caller-owned. + const content = allowStringContent && typeof message.content === "string" && message.content.trim().length > 0 + ? [{ type: "input_text", text: message.content }] + : message.content; + if (!Array.isArray(content) || content.length === 0) return item; + // Genuine ciphertext and unknown part types must retain their existing fail-closed path. + if (!content.every(part => part && typeof part === "object" + && ["input_text", "input_image", "input_file"].includes(part.type))) return item; + const identities = Object.fromEntries(["author", "recipient"] + .filter(key => typeof message[key] === "string") + .map(key => [key, message[key]])); + changed = true; + return { + type: "message", role: "user", + content: [ + ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), + ...content, + ], + }; + }); + return changed ? { ...record, input } : body; +} diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index f6b762aa83..0b59a93073 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -29,6 +29,12 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined { name: raw.name, ...(typeof raw.description === "string" ? { description: raw.description } : {}), parameters: raw.input_schema as Record<string, unknown>, + // Anthropic opts into strict tool use explicitly, while Responses reads an + // omitted strict as permission to normalize the schema into strict mode. That + // turns an optional input_schema parameter into a required one and breaks the + // call, so carry the source intent instead of the destination default. A + // non-boolean value is not a valid Anthropic opt-in and must not become one. + strict: typeof raw.strict === "boolean" ? raw.strict : false, }); continue; } diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 79ee487b70..c0d28b5814 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -25,6 +25,7 @@ import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { isJsonOption, takeFlag } from "./runtime-api"; import type { ClientConnectionState } from "../client/state"; +import { OCX_NATIVE_REPLAY_RECOVERY_NOTE } from "../responses/compaction"; export interface CliDispatchDeps { args: string[]; @@ -199,6 +200,7 @@ const commandRunners: Record<string, CommandRunner> = { } if (r.success) { console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back"); + console.log(`Note: ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}`); } else { console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex."); } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 2656477f7b..ef731c9ca5 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -48,6 +48,9 @@ import { probeCodexCoordinatorNamespace, resolveEffectiveUserIdentity, } from "../codex/user-identity"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; +import type { OcxProviderConfig } from "../types/provider"; +import { routedProviderConfig } from "../router"; import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings"; import { collectLegacyCodexConfigKeyDiagnostics, @@ -1039,6 +1042,41 @@ export function proxyDownRestartHint(input: { return `The ocx proxy is not running. ${uncleanExit}Codex/Claude clients pinned to 127.0.0.1:${input.port} fail with errors like "error sending request for url (http://127.0.0.1:${input.port}/v1/responses)". ${restart}`; } +/** Explain the expected channel and latency trade-off for native ChatGPT routing. */ +export function chatgptPublicEndpointHint( + providers: Record<string, unknown> | undefined, +): string | null { + const openai = providers?.openai; + if (!openai || typeof openai !== "object") { + return null; + } + // A disabled row never routes, so it must not be described as the route in use. + const configured = openai as OcxProviderConfig; + if (configured.disabled === true) { + return null; + } + // Classify the destination the router resolves, not the raw config text. Two things follow + // from the registry entry for the built-in `openai` id: a row that omits `authMode` still + // forwards, and a row carrying some other `baseUrl` has it discarded in favour of the + // canonical ChatGPT endpoint. Both keep using the public endpoint, so both want this hint; + // reading the raw row would have suppressed the first and misjudged the second. + // + // `routedProviderConfig` throws for an unresolved URL only when the registry entry allows a + // baseUrl override, which this entry does not, so no input reaches that path today. The guard + // is here because doctor is read-only diagnostics: a later registry change must not turn a + // diagnostic into a crash. + let routed: OcxProviderConfig; + try { + routed = routedProviderConfig("openai", configured); + } catch { + return null; + } + if (!isCanonicalOpenAiForwardProvider(routed)) { + return null; + } + return "ChatGPT-family requests use the public ChatGPT endpoint through this proxy, in both Pool and Direct modes. Eligible streaming turns dial the ChatGPT websocket transport (the same responses_websockets lane Codex CLI defaults to) and fall back to SSE over HTTP when a turn is not eligible - an unsupported Bun runtime, an oversized create frame, or a proxy route that cannot carry the socket - and local provider pacing can hold a request before it is dispatched at all. This hint classifies configuration only and measures nothing, so upstream queueing is one possible contributor to a slow first output: compare actual transport, pacing, network, and provider observations before concluding. service_tier=priority is a request preference: this backend can echo service_tier \"default\" even on turns it scheduled as priority (#2558), so the echoed response tier in request logs stays an observation with confirmation \"assumed\" and cannot confirm or deny the granted tier."; +} + export async function runDoctor(args: string[] = []): Promise<void> { if (args.includes("--fix-codex-runtime")) { const resolved = resolveCodexRuntime(); @@ -1409,6 +1447,8 @@ export async function runDoctor(args: string[] = []): Promise<void> { // Hints, not fixes. const hints: string[] = []; + const chatgptHint = chatgptPublicEndpointHint(doctorConfig.providers); + if (chatgptHint) hints.push(chatgptHint); const proxyDown = proxyDownRestartHint({ proxyRunning: Boolean(live), port: live?.port ?? doctorConfig.port ?? 10100, diff --git a/src/cli/effort.ts b/src/cli/effort.ts index 4451f7867a..3e7650d5ef 100644 --- a/src/cli/effort.ts +++ b/src/cli/effort.ts @@ -2,6 +2,7 @@ import { loadConfig, mutatePersistedConfig } from "../config"; import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, + isCodexReasoningEffort, isDeclaredReasoningEffort, mapReasoningEffort, reasoningEffortMapFor, @@ -21,7 +22,7 @@ import { export const EFFORT_USAGE = `Usage: ocx effort [status] [--json] - ocx effort <low|medium|high|xhigh|max|ultra|none|minimal|-> [--json] + ocx effort <low|medium|high|xhigh|max|ultra|-> [--json] ocx effort set [--main <level|->] [--subagent <level|->] [--injection <level|->] [--json] ocx effort clear [--json] ocx effort model <provider/model|model> [--json] @@ -33,13 +34,18 @@ function clearable(value: string | undefined): string | null | undefined { return value === "-" ? null : value; } -function validateEffortLevel(level: string | null | undefined, label: string): string | null | undefined { +function validateEffortLevel( + level: string | null | undefined, + label: string, + kind: "cap" | "injection", +): string | null | undefined { if (level === undefined || level === null) return level; const trimmed = level.trim(); if (trimmed === "-" || trimmed === "") return null; - if (!isDeclaredReasoningEffort(trimmed)) { + const valid = kind === "cap" ? isCodexReasoningEffort(trimmed) : isDeclaredReasoningEffort(trimmed); + if (!valid) { throw new CliUsageError( - `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}, none, minimal, -)`, + `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}${kind === "injection" ? ", none, minimal" : ""}, -)`, EFFORT_USAGE, ); } @@ -114,6 +120,15 @@ async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise<void> { data = getOfflineStatus(); } + // Report the stored/runtime value exactly as the enforcement layer evaluates it. + // An ignored subagent field does not disable a valid main cap on that child. + const warnings = ([ ["effortCap", "--main"], ["subagentEffortCap", "--subagent"] ] as const) + .flatMap(([key, flag]) => { + const value = data[key]; + if (value === null || isCodexReasoningEffort(value)) return []; + return [`${key}=${JSON.stringify(value)} is invalid and is not applied. Use: ocx effort set ${flag} <${CODEX_REASONING_LEVELS.map(l => l.effort).join("|")}|->.`]; + }); + const lines = [ `Reasoning effort status (${data.source === "runtime" ? "live proxy" : "offline config"}):`, ` Main agent effort cap: ${data.effortCap ?? "(unset — no cap)"}`, @@ -122,9 +137,10 @@ async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise<void> { "", "Supported Codex reasoning effort ladder:", ...CODEX_REASONING_LEVELS.map(l => ` - ${l.effort.padEnd(8)} ${l.description}`), + ...(warnings.length ? ["", "Warnings:", ...warnings.map(warning => ` ${warning}`)] : []), ]; - printData(data, wantsJson, lines); + printData({ ...data, warnings }, wantsJson, lines); } async function setEffort( @@ -136,9 +152,9 @@ async function setEffort( wantsJson: boolean, deps: RuntimeApiDeps, ): Promise<void> { - const validatedMain = validateEffortLevel(options.main, "--main"); - const validatedSubagent = validateEffortLevel(options.subagent, "--subagent"); - const validatedInjection = validateEffortLevel(options.injection, "--injection"); + const validatedMain = validateEffortLevel(options.main, "--main", "cap"); + const validatedSubagent = validateEffortLevel(options.subagent, "--subagent", "cap"); + const validatedInjection = validateEffortLevel(options.injection, "--injection", "injection"); if (validatedMain === undefined && validatedSubagent === undefined && validatedInjection === undefined) { throw new CliUsageError("at least one effort option (--main, --subagent, or --injection) is required", EFFORT_USAGE); diff --git a/src/cli/help.ts b/src/cli/help.ts index c1a0ae13ac..ed24c54111 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -33,6 +33,8 @@ Usage: ocx restore back Re-point codex at the running proxy (undo restore) ocx recover-history --legacy-openai --yes Force all user-message opencodex rows to OpenAI (legacy recovery) + ocx recover-history --ocx-compaction <thread-id> --yes + Back up and make one ocx1-compacted thread replayable by native Codex ocx uninstall Remove service/shim/config and restore native Codex (alias: remove) ocx service [sub] Run as a background service (default: install/update/start) ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall|remove) diff --git a/src/cli/index.ts b/src/cli/index.ts index c900ce13da..b985fd3d71 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1458,8 +1458,31 @@ async function handleStatus() { } async function handleRecoverHistory() { + if (args[1] === "--ocx-compaction") { + const threadId = args[2]; + if (args.length !== 4 || !threadId || args[3] !== "--yes") { + console.error("Usage: ocx recover-history --ocx-compaction <thread-id> --yes"); + console.error("This rewrites one rollout after saving a private byte-for-byte backup. Close that Codex thread before retrying."); + process.exit(1); + } + console.error("WARNING: this converts OpenCodeX-owned ocx1 compaction state into a plain summary for native Codex replay."); + try { + const { recoverOcxCompactionHistory } = await import("../codex/ocx-compaction-history"); + const result = recoverOcxCompactionHistory({ threadId }); + if (result.replaced === 0) { + console.log(`Thread ${threadId} has no repairable ocx1 compaction history; no files changed.`); + return; + } + console.log(`Recovered ${result.replaced} ocx1 compaction item(s) in thread ${threadId}.`); + console.log(`Backup: ${result.backupPath}`); + return; + } catch (error) { + console.error(`Recovery failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } if (args[1] !== "--legacy-openai") { - console.error("Usage: ocx recover-history --legacy-openai --yes"); + console.error("Usage: ocx recover-history (--legacy-openai | --ocx-compaction <thread-id>) --yes"); console.error("This force-relabels every user-message opencodex row to OpenAI, including legitimate dedicated-provider history. Back up first and use it only for pre-backup legacy recovery."); process.exit(1); } diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index d2f24d0b8d..6477694a49 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -204,7 +204,8 @@ async function presets(argv: string[], deps: RuntimeApiDeps): Promise<void> { const rows = Array.isArray(result) ? result : result.providers ?? []; printData(result, wantsJson, rows.map(row => { const record = row as Record<string, unknown>; - return `${String(record.id ?? record.name ?? "?")} ${String(record.label ?? record.adapter ?? "")}`.trimEnd(); + const sponsor = record.sponsor ? ` (sponsor: ${String(record.sponsor)})` : ""; + return `${String(record.id ?? record.name ?? "?")} ${String(record.label ?? record.adapter ?? "")}${sponsor}`.trimEnd(); })); } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 5836d837d6..12c6644b02 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -36,8 +36,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "recover-history", - usage: "ocx recover-history --legacy-openai --yes", - summary: "Force all user-message opencodex rows to OpenAI for legacy recovery.", + usage: "ocx recover-history (--legacy-openai | --ocx-compaction <thread-id>) --yes", + summary: "Recover legacy provider metadata or one OpenCodeX-compacted thread for native replay.", }, { name: "uninstall", diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index 7811e7d432..03eb900887 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -14,7 +14,7 @@ import { const USAGE = `Usage: ocx system [status] [--json] ocx system settings [--auto-start <on|off>] [--stream-mode <auto|legacy-tee|eager-relay>] - [--desktop-authless <on|off>] [--json] + [--desktop-authless <on|off>] [--client-compaction <on|off>] [--json] ocx system startup <health|install-service|install-shim> [--json] ocx system diagnostics [--json] ocx system sync [--json] @@ -23,7 +23,11 @@ const USAGE = `Usage: ocx system codex-cli-update check [--json] ocx system update check [--channel <latest|preview>] [--json] ocx system update run [--channel <latest|preview>] [--restart <on|off>] --yes [--json] - ocx system update status <job-id> [--json]`; + ocx system update status <job-id> [--json] + +--client-compaction favors native replay portability for future compactions while +keeping OpenCodeX routing active; the configured provider may process summaries +and consume its quota.`; async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> { const args = [...argv]; @@ -44,8 +48,10 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> { const autoStart = takeBooleanOption(args, "--auto-start"); const streamMode = takeOption(args, "--stream-mode"); const desktopAuthless = takeBooleanOption(args, "--desktop-authless"); + const clientCompaction = takeBooleanOption(args, "--client-compaction"); rejectArgs(args, USAGE); - if (autoStart === undefined && streamMode === undefined && desktopAuthless === undefined) { + if (autoStart === undefined && streamMode === undefined + && desktopAuthless === undefined && clientCompaction === undefined) { const result = await runtimeRequest("/api/settings", {}, deps); printData(result, wantsJson, summaryLines(result)); return; @@ -54,6 +60,7 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> { ...(autoStart !== undefined ? { codexAutoStart: autoStart } : {}), ...(streamMode !== undefined ? { streamMode } : {}), ...(desktopAuthless !== undefined ? { codexDesktopAuthless: desktopAuthless } : {}), + ...(clientCompaction !== undefined ? { codexClientCompaction: clientCompaction } : {}), }; const result = await runtimeRequest("/api/settings", { method: "PUT", body: JSON.stringify(body) }, deps); printData(result, wantsJson, ["System settings updated."]); diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts index 6b684277c0..3311a781a1 100644 --- a/src/cli/usage-report.ts +++ b/src/cli/usage-report.ts @@ -59,8 +59,11 @@ interface UsageReportInput { const MAX_MODEL_ROWS = 10; -function terminalText(value: string): string { - return value.replace(/[\x00-\x1f\x7f-\x9f]/g, character => { +function terminalText(value: unknown): string { + const text = typeof value === "string" ? value + : value === null || value === undefined ? "" + : typeof value === "number" || typeof value === "boolean" ? String(value) : "[invalid]"; + return text.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, character => { const code = character.charCodeAt(0); return code <= 0x7f ? `\\x${code.toString(16).padStart(2, "0")}` @@ -69,7 +72,8 @@ function terminalText(value: string): string { } function count(value: number | undefined): string { - return (value ?? 0).toLocaleString("en-US"); + if (value === undefined || value === null) return "0"; + return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("en-US") : "—"; } /** @@ -111,7 +115,7 @@ export function formatUsageReport(data: UsageReportInput): string[] { .filter(Boolean).join(" and "); lines.push(`No usage recorded for ${terminalText(what)} in this range.`); lines.push("Check the spelling against `ocx usage --json`, or widen --range."); - return lines; + return lines.map(terminalText); } const tokenSplit = [ @@ -185,5 +189,5 @@ export function formatUsageReport(data: UsageReportInput): string[] { lines.push(""); lines.push("Not a billing receipt. Subscription usage or provider credits may apply instead."); - return lines; + return lines.map(terminalText); } diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 703e08f247..88208bfb1b 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -7,12 +7,13 @@ import { } from "../config"; import { removeCodexAccountCredential } from "./account-store"; import { clearAccountNeedsReauth } from "./account-runtime-state"; -import { getMainChatgptAccountId } from "./auth-collision"; +import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; -import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaIdentity } from "./main-account-cache"; +import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache"; +import { extractAccountIdClaims } from "../oauth/chatgpt"; import { forgetCodexAccountPause } from "./account-pause"; import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority"; import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state"; @@ -75,6 +76,38 @@ export function reconcileMainCodexAccountRuntimeState(): boolean { return true; } +/** + * Rebuild the memory-only policy binding from a startup-owned, recovered auth path. + * The caller holds the native owner and exclusive claim; an incoming bearer is never evidence. + * A failed read creates no binding and cannot revoke a prior verified observation or its block. + * Only a valid replacement observation or confirmed account transition supersedes that evidence. + */ +export function initializeMainAccountPolicyBinding(authPath: string): boolean { + // Startup observes the pinned owned path inside the exclusive claim: bound the read so a + // replaced non-regular or oversized file cannot stall startup inside that claim. + const result = readCodexTokensResult(authPath, { bounded: true }); + if (result.status !== "ok") return false; + const { tokens } = result; + if (typeof tokens.access_token !== "string" || !tokens.access_token + || typeof tokens.account_id !== "string" || !tokens.account_id) return false; + if (tokens.id_token != null && typeof tokens.id_token !== "string") return false; + const accountId = tokens.account_id; + // An owned file may contain an opaque bearer, but every decoded identity must agree — + // including the two account-id encodings within a single token. + const idTokenClaims = extractAccountIdClaims(tokens.id_token); + const accessTokenClaims = extractAccountIdClaims(tokens.access_token); + if (idTokenClaims.conflict || accessTokenClaims.conflict) return false; + const idTokenAccountId = idTokenClaims.accountId; + const accessTokenAccountId = accessTokenClaims.accountId; + if ((idTokenAccountId !== undefined && idTokenAccountId !== accountId) + || (accessTokenAccountId !== undefined && accessTokenAccountId !== accountId)) return false; + const previousAccountId = observedMainChatgptAccountId; + observedMainChatgptAccountId = accountId; + if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState(); + observeMainQuotaIdentity(accountId); + return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined; +} + /** * Apply a transaction-confirmed physical native-login change without waiting for * a later auth.json observation. The caller owns credential commit/rollback. diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 6768c4fa09..1a1e66d88b 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -40,6 +40,10 @@ import { } from "./account-priority"; import { claimDueCodexQuotaRecoveryProbes, + claimManualResetCooldowns, + settleManualResetCooldown, + type ManualResetCooldownClaim, + type ManualResetRefreshLineage, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, @@ -98,6 +102,8 @@ import { getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, + isMainQuotaWriterLive, + type MainQuotaWriter, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, @@ -235,12 +241,15 @@ function codexAccountPersistenceConflict( } /** - * The exact label `parseUsageQuota` emits for the Codex Spark window (quota.ts). + * The exact labels `parseUsageQuota` emits for the Codex Spark windows (quota.ts). * Matching on the label rather than on "is a custom window" is load-bearing: the same array * carries Cursor's First-party models / API usage, Anthropic's Fable / Opus / Sonnet, * Antigravity's Gem / Cla, Kimi's subscription credits and a dozen dynamic provider meters. */ -const CODEX_SPARK_WINDOW_LABEL = "GPT-5.3-Codex-Spark Weekly"; +const CODEX_SPARK_WINDOW_LABELS = new Set([ + "GPT-5.3-Codex-Spark 5h", + "GPT-5.3-Codex-Spark Weekly", +]); /** * Drop the Spark window unless the operator asked for it (default hidden). @@ -258,7 +267,7 @@ export function withSparkVisibility<T extends Omit<StoredAccountQuota, "updatedA ): T { if (!quota?.customWindows?.length) return quota; if (loadConfig().showCodexSparkQuota === true) return quota; - const kept = quota.customWindows.filter(window => window.label !== CODEX_SPARK_WINDOW_LABEL); + const kept = quota.customWindows.filter(window => !CODEX_SPARK_WINDOW_LABELS.has(window.label)); if (kept.length === quota.customWindows.length) return quota; // An empty list is dropped rather than serialized: an absent field and an empty array should // not be two different ways of saying "no custom windows" on the wire. @@ -387,6 +396,8 @@ interface ResetCreditAuth { chatgptAccountId: string; nativeMainLease?: AdmissionLease; nativeMainSharedClaimHeld?: true; + poolGeneration?: number; + mainProof?: MainResetQuotaProof; } async function withResetCreditAuth<T>( @@ -407,10 +418,15 @@ async function withResetCreditAuth<T>( if (!tokens) { return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) }; } + reconcileMainCodexAccountRuntimeState(); + const physicalId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id; + const writer = physicalId === tokens.account_id + ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; return { ok: true, value: await operation({ isMain: true, + ...(writer ? { mainProof: { writer, credentialGeneration: getMainQuotaCredentialGeneration() } } : {}), accessToken: tokens.access_token, chatgptAccountId: tokens.account_id, nativeMainLease, @@ -439,6 +455,7 @@ async function withResetCreditAuth<T>( ok: true, value: await operation({ isMain: false, + poolGeneration: cred.generation, accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId, }), @@ -776,8 +793,14 @@ async function readMainAuthErrorCode(resp: Response): Promise<unknown> { } } +interface MainResetQuotaProof { + writer: MainQuotaWriter; + credentialGeneration: number; +} + interface MainAccountInfoFetchResult { info: MainAccountInfo; + resetRecoveryProof?: MainResetQuotaProof & { dispatchSequence: number }; /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ quotaRefresh?: CodexQuotaRefreshOutcome; /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */ @@ -914,6 +937,7 @@ async function fetchMainAccountInfoWhileOwned( let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; let quotaRefreshGeneration = captureMainAccountIdentityGeneration(); try { + const dispatchSequence = ++quotaDispatchSequence; const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, signal: quotaSignal, @@ -923,6 +947,10 @@ async function fetchMainAccountInfoWhileOwned( const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; + if (dispatchSequence < mainQuotaPublishedSequence) { + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, hasCredential: true }; + } if (terminalAuthFailure) { // Account for this attempt's own synchronous invalidation, never prior external drift. const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration); @@ -945,6 +973,12 @@ async function fetchMainAccountInfoWhileOwned( if (data === null || typeof data !== "object" || Array.isArray(data)) { throw new Error("Invalid WHAM usage object"); } + // Check after body/retry awaits and before any cache, credits, policy or + // Reserve publication. Returning cached state supplies no fresh recovery proof. + if (dispatchSequence < mainQuotaPublishedSequence) { + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, hasCredential: true }; + } quotaPhase = "publish"; // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, // even in the same workspace or after an A→B→A credential transition. @@ -985,6 +1019,7 @@ async function fetchMainAccountInfoWhileOwned( if (result.quota) { setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota); } + mainQuotaPublishedSequence = dispatchSequence; return { info: result, quotaRefresh: { status: quota ? "ok" : "not_reported" }, @@ -992,6 +1027,11 @@ async function fetchMainAccountInfoWhileOwned( credentialChecked: true, hasCredential: true, ...(quota ? { freshQuota: quota } : {}), + ...(quota && mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter) + && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() + && matchesMainQuotaCredential(tokens.access_token, tokens.account_id) + ? { resetRecoveryProof: { writer: mainQuotaWriter, credentialGeneration: mainQuotaCredentialGeneration, dispatchSequence } } + : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch (error) { @@ -1011,6 +1051,8 @@ async function fetchMainAccountInfoWhileOwned( } interface PoolQuotaResult { + /** Actual refresh result attached only to the successful usage replay. */ + resetRefreshLineage?: ManualResetRefreshLineage; quota: StoredAccountQuota | null; needsReauth: boolean; /** Credential generation whose cache or network result this DTO state belongs to. */ @@ -1025,15 +1067,25 @@ interface PoolQuotaResult { freshResetCredits?: number; quotaProbeSkipped?: true; /** Positive evidence captured immediately before an upstream WHAM dispatch. */ - quotaProbeAttempted?: { at: number; credentialGeneration: number }; + quotaProbeAttempted?: { at: number; credentialGeneration: number; dispatchSequence: number }; } +// Process-local ordering, never a timestamp or a serialized account identifier. +let quotaDispatchSequence = 0; +// Shared native-main ownership permits concurrent usage readers. Only a later +// successfully published response advances this fence; failed reads do not win. +let mainQuotaPublishedSequence = 0; + interface PoolQuotaProbeEvidence { + onDispatch?: (sequence: number) => void; + mayPublish?: () => boolean; attempted?: NonNullable<PoolQuotaResult["quotaProbeAttempted"]>; } function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void { - evidence.attempted = { at: Date.now(), credentialGeneration }; + const dispatchSequence = ++quotaDispatchSequence; + evidence.attempted = { at: Date.now(), credentialGeneration, dispatchSequence }; + evidence.onDispatch?.(dispatchSequence); } function withQuotaProbeEvidence( @@ -1045,6 +1097,8 @@ function withQuotaProbeEvidence( interface PoolQuotaRefreshFlight { state: { + dispatchSequence?: number; + superseded?: boolean; startCredentialGeneration?: number; resolvedCredentialGeneration?: number; }; @@ -1280,9 +1334,18 @@ async function recoverPoolQuotaFrom401(ctx: { } return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; } - return await commitPoolQuotaResponse(replay, { + const result = await commitPoolQuotaResponse(replay, { accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, + mayPublish: ctx.quotaProbeEvidence.mayPublish, }); + return result.freshCredentialGeneration === refreshed.generation ? { + ...result, + resetRefreshLineage: { + fromGeneration: rejectedGeneration, + toGeneration: refreshed.generation, + provenance: refreshed.provenance, + }, + } : result; } /** Backoff after a refresh failure that proved nothing about the credential. */ @@ -1314,10 +1377,14 @@ async function commitPoolQuotaResponse( configuredPlan: string | undefined; generation: number; writerGeneration: number; + mayPublish?: () => boolean; }, ): Promise<PoolQuotaResult> { const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx; const data = (await resp.json()) as WhamUsageResponse; + if (ctx.mayPublish?.() === false) { + return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation }; + } const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); const freshResetCredits = quota?.resetCredits; @@ -1350,10 +1417,10 @@ async function fetchFreshPoolAccountQuota( configuredPlan?: string, onCredentialGeneration?: (generation: number) => void, getValidToken: typeof getValidCodexToken = getValidCodexToken, + quotaProbeEvidence: PoolQuotaProbeEvidence = {}, ): Promise<PoolQuotaResult> { const writerGeneration = captureConfigGeneration(); let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation; - const quotaProbeEvidence: PoolQuotaProbeEvidence = {}; try { const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId); requestCredentialGeneration = generation; @@ -1387,6 +1454,7 @@ async function fetchFreshPoolAccountQuota( } const committed = await commitPoolQuotaResponse(resp, { accountId, existing, configuredPlan, generation, writerGeneration, + mayPublish: quotaProbeEvidence.mayPublish, }); return withQuotaProbeEvidence(committed, quotaProbeEvidence); } catch (e) { @@ -1417,9 +1485,10 @@ async function fetchPoolAccountQuota( forceRefresh = false, configuredPlan?: string, getValidToken: typeof getValidCodexToken = getValidCodexToken, + afterDispatchSequence?: number, ): Promise<PoolQuotaResult> { const existing = getAccountQuota(accountId); - if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) { + if (afterDispatchSequence === undefined && !forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) { return { quota: existing, needsReauth: false, @@ -1434,11 +1503,18 @@ async function fetchPoolAccountQuota( const current = flights && [...flights].find(flight => { const generation = flight.state.resolvedCredentialGeneration ?? flight.state.startCredentialGeneration; - return generation !== undefined && isCodexAccountGenerationLive(accountId, generation); + return !flight.state.superseded + && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence) + && generation !== undefined && isCodexAccountGenerationLive(accountId, generation); }); if (current) return current.promise; if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError(); + // A post-reset request must not let an older same-account response overwrite its evidence. + // Flags live only as long as the bounded flights; no retained per-account sequence map. + if (afterDispatchSequence !== undefined) { + for (const flight of flights ?? []) flight.state.superseded = true; + } const state: PoolQuotaRefreshFlight["state"] = { startCredentialGeneration: record?.generation, }; @@ -1448,6 +1524,10 @@ async function fetchPoolAccountQuota( configuredPlan, generation => { state.resolvedCredentialGeneration = generation; }, getValidToken, + { + onDispatch: sequence => { state.dispatchSequence = sequence; }, + mayPublish: () => state.superseded !== true, + }, ); const flight: PoolQuotaRefreshFlight = { state, promise: refresh }; const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>(); @@ -1463,6 +1543,74 @@ async function fetchPoolAccountQuota( } } +function manualResetAuthStillLive(accountId: string, auth: ResetCreditAuth): boolean { + if (!auth.isMain) { + const record = readCodexAccountRecord(accountId); + return auth.poolGeneration !== undefined + && isCodexAccountGenerationLive(accountId, auth.poolGeneration) + && record?.credential?.chatgptAccountId === auth.chatgptAccountId; + } + const tokens = readCodexTokens(); + return !!auth.mainProof && !!tokens + && tokens.access_token === auth.accessToken && tokens.account_id === auth.chatgptAccountId + && isMainQuotaWriterLive(auth.mainProof.writer) + && auth.mainProof.credentialGeneration === getMainQuotaCredentialGeneration() + && matchesMainQuotaCredential(auth.accessToken, auth.chatgptAccountId); +} + +/** A confirmed spend remains successful even when its optional usage observation fails. */ +async function refreshAfterManualReset( + config: OcxConfig, + accountId: string, + auth: ResetCreditAuth, + claims: ManualResetCooldownClaim[], + didReset: boolean, +): Promise<number | undefined> { + const afterDispatchSequence = quotaDispatchSequence; + try { + if (!manualResetAuthStillLive(accountId, auth)) return undefined; + if (auth.isMain) { + const result = await fetchMainAccountInfoAttempt(true, 1, auth.nativeMainLease, + auth.nativeMainSharedClaimHeld === true, false); + const proof = result.resetRecoveryProof; + const recovered = didReset && manualResetAuthStillLive(accountId, auth) + && !!proof && !!auth.mainProof + && proof.dispatchSequence > afterDispatchSequence + && proof.credentialGeneration === auth.mainProof.credentialGeneration + && proof.writer.identityKey === auth.mainProof.writer.identityKey + && proof.writer.identityGeneration === auth.mainProof.writer.identityGeneration + && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.info.plan); + for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered); + return manualResetAuthStillLive(accountId, auth) ? result.freshResetCredits : undefined; + } + const account = configuredPoolAccount(getRuntimeConfig(config), accountId); + if (!account) return undefined; + // Reuse the just-authenticated consume credential for the first usage request. + // getValidCodexToken can silently advance a generation without exposing refresh + // provenance. A 401 here instead uses the existing classified refresh/replay path. + const resetToken: typeof getValidCodexToken = async () => { + if (auth.poolGeneration === undefined || !manualResetAuthStillLive(accountId, auth)) { + throw new CodexCredentialGenerationConflictError(); + } + return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration }; + }; + const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken, + didReset ? afterDispatchSequence : undefined); + const record = readCodexAccountRecord(accountId); + const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId + && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence + && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan); + for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered, { + credentialGeneration: result.freshCredentialGeneration, + refreshLineage: result.resetRefreshLineage, + }); + return record?.credential?.chatgptAccountId === auth.chatgptAccountId ? result.freshResetCredits : undefined; + } catch { + // The upstream reset already happened. A failed refresh must not invite another spend. + return undefined; + } +} + let primeInFlight: Promise<void> | null = null; /** * Last prime attempt per pool account. A failed WHAM lookup stores no quota, so @@ -2341,7 +2489,7 @@ export async function handleCodexAuthAPI( const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { // The ledger keys manual operations by the *physical* ChatGPT account, which is // only known after the auth wrapper resolves credentials. Open here, not earlier. - const identity = requestedOperationId === undefined + let identity = requestedOperationId === undefined ? undefined : { accountId, @@ -2377,75 +2525,74 @@ export async function handleCodexAuthAPI( return response; } // Canonical id, which an alias join may map to an earlier caller id. + identity = { ...identity, operationId: opened.operationId }; idempotencyKey = opened.operationId; } else { idempotencyKey = crypto.randomUUID(); } - let resp: Response; + const claims = manualResetAuthStillLive(accountId, auth) + ? claimManualResetCooldowns(getRuntimeConfig(config), accountId, Date.now(), auth.poolGeneration) : []; try { - resp = await fetch( - "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", - { - method: "POST", - headers: { - Authorization: `Bearer ${auth.accessToken}`, - "ChatGPT-Account-Id": auth.chatgptAccountId, - "Content-Type": "application/json", + let resp: Response; + try { + resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: idempotencyKey }), + signal: AbortSignal.timeout(10_000), }, - body: JSON.stringify({ redeem_request_id: idempotencyKey }), - signal: AbortSignal.timeout(10_000), - }, - ); - } catch (error) { - // Dispatch outcome unknown: the credit may or may not have been spent. - // Mark ambiguous so a replay of this same id is never treated as new. - if (identity) markManualResetCreditOperationAmbiguous(identity); - throw error; - } - if (!resp.ok) { - await resp.body?.cancel().catch(() => {}); - if (identity) markManualResetCreditOperationAmbiguous(identity); - return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); - } - const result = safeResetCreditConsumeDto(await resp.json()); - if (identity) { - // Narrow explicitly rather than casting: `safeResetCreditConsumeDto` - // normalizes anything unrecognized to "unknown", and settling that - // would come back as a mismatch and leave the row pending anyway. - // Settlement failure never downgrades the user-visible outcome: the - // spend already happened upstream, and reporting failure would invite - // a manual retry -- the exact double-spend this unit removes. - if (result.code === "reset" || result.code === "already_redeemed" - || result.code === "nothing_to_reset" || result.code === "no_credit") { - settleManualResetCreditOperation(identity, result.code); - } else { - markManualResetCreditOperationAmbiguous(identity); + ); + } catch (error) { + // Dispatch outcome unknown: the credit may or may not have been spent. + // Mark ambiguous so a replay of this same id is never treated as new. + if (identity) markManualResetCreditOperationAmbiguous(identity); + throw error; } - } - // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage - // and return remaining only when that refresh freshly parsed available_count. - // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). - if (result.code === "reset" || result.code === "already_redeemed") { - let freshResetCredits: number | undefined; - if (auth.isMain) { - ({ freshResetCredits } = await fetchMainAccountInfoAttempt( - true, - 1, - auth.nativeMainLease, - auth.nativeMainSharedClaimHeld === true, - )); - } else { - const account = configuredPoolAccount(getRuntimeConfig(config), accountId); - ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan)); + if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); + if (identity) markManualResetCreditOperationAmbiguous(identity); + return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); } - return jsonResponse({ - code: result.code, - ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) - ? { remaining: freshResetCredits } - : {}), - }); + const result = safeResetCreditConsumeDto(await resp.json()); + if (identity) { + // Narrow explicitly rather than casting: `safeResetCreditConsumeDto` + // normalizes anything unrecognized to "unknown", and settling that + // would come back as a mismatch and leave the row pending anyway. + // Settlement failure never downgrades the user-visible outcome: the + // spend already happened upstream, and reporting failure would invite + // a manual retry -- the exact double-spend this unit removes. + if (result.code === "reset" || result.code === "already_redeemed" + || result.code === "nothing_to_reset" || result.code === "no_credit") { + settleManualResetCreditOperation(identity, result.code); + } else { + markManualResetCreditOperationAmbiguous(identity); + } + } + // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage + // and return remaining only when that refresh freshly parsed available_count. + // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). + if (result.code === "reset" || result.code === "already_redeemed") { + const freshResetCredits = await refreshAfterManualReset( + config, accountId, auth, claims, result.code === "reset", + ); + return jsonResponse({ + code: result.code, + ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) + ? { remaining: freshResetCredits } + : {}), + }); + } + return jsonResponse(result); + } finally { + // Release only this invocation's leases, including every ambiguous/error outcome. + for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, false); } - return jsonResponse(result); }); return operation.ok ? operation.value : operation.response; } catch (e) { diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index 52c9242e8c..1dc17e897e 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -6,6 +6,7 @@ import { resolveCodexHomeDir } from "./home"; import { extractAccountId } from "../oauth/chatgpt"; import { isSelectableCodexPoolAccount } from "./account-id"; import { codexPlanKey } from "./plan"; +import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store"; export interface CodexTokens { access_token: string; @@ -31,12 +32,21 @@ function hasErrnoCode(error: unknown, code: string): boolean { /** * Reads the Codex CLI credential file and classifies the outcome. Reads once instead of doing an * `existsSync` pre-check, so a file replaced between check and read cannot be misread as absent. + * An already-owned lifecycle may supply its pinned auth path instead of resolving ambient home. + * `bounded` opts into the native-profile bounded reader (regular file, size-capped, no-follow, + * non-blocking) for startup observation paths that run inside the owner claim; bounded violations + * classify as `unreadable`. Legacy callers keep the unbounded read. * Never returns or logs the raw error or any token material. */ -export function readCodexTokensResult(): CodexTokenReadResult { +export function readCodexTokensResult( + authPath = join(resolveCodexHomeDir(), "auth.json"), + options?: { bounded?: boolean }, +): CodexTokenReadResult { let raw: string; try { - raw = readFileSync(join(resolveCodexHomeDir(), "auth.json"), "utf-8"); + raw = options?.bounded === true + ? readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8") + : readFileSync(authPath, "utf-8"); } catch (error) { return { status: hasErrnoCode(error, "ENOENT") ? "missing" : "unreadable" }; } diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 2f319b3144..bcb5b15134 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,9 +1,10 @@ -import { createHmac, randomBytes } from "node:crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, + getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, } from "./account-store"; @@ -21,7 +22,7 @@ import { isMainAccountTokenLive, type NativeMainRefreshDependencies, } from "./main-account"; -import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup"; +import { isMainAccountPolicyBindingPending, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup"; import type { NativeMainStartupBlockReason } from "./native-profile-startup"; import { codexQuotaScopeForModel, @@ -49,7 +50,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; import { retainedUtf8Bytes } from "../lib/admission"; -import { extractAccountId } from "../oauth/chatgpt"; +import { extractAccountId, extractEmail } from "../oauth/chatgpt"; import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock"; import { captureMainAccountIdentityGeneration, @@ -62,7 +63,7 @@ import { } from "./main-account-cache"; import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target"; import type { DataPlaneAdmission } from "../server/auth-cors"; -import { getMainReserveAuthorization, isMainReserveAuthorizationLive, type MainReserveAuthorization } from "./reserve-availability"; +import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability"; import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; @@ -455,6 +456,54 @@ function callerMatchesObservedMain(headers: Headers): boolean { return matchesMainQuotaCredential(bearer, effectiveAccountId); } +/** Constant-time digest comparison so bearer bytes never drive branch timing. */ +function sameCredentialMaterial(a: string, b: string): boolean { + return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest()); +} + +/** + * The early-cooldown caller-main fallback must not resurrect the subscription that is cooling + * down. Fail closed on ambiguity: an unreadable caller identity cannot be distinguished from the + * cooled account. A distinct workspace account id is always safe; an exact materialized + * bearer + account tuple marks the same subscription. Beyond that, the stable native user id is + * the strongest available evidence: it survives an email change and a token rotation, and it + * separates members who share one workspace account id even when neither credential carries an + * email. Email remains the fallback when no comparable user id exists on both sides. Coexisting + * personal/business registrations with the same email and account id over-deny during the + * cooldown — the safe direction. + */ +function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean { + const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (!bearer) return true; + const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); + if (callerAccountId === undefined) return true; + if (accountId === MAIN_CODEX_ACCOUNT_ID) { + // No physical-main read to identify the caller: the observed-main equality tag suffices. + return callerMatchesObservedMain(headers); + } + const stored = getCodexAccountCredential(accountId); + const entry = config.codexAccounts?.find(account => account.id === accountId); + const cooledAccountId = stored?.chatgptAccountId || entry?.chatgptAccountId; + if (!cooledAccountId) return true; + if (cooledAccountId !== callerAccountId) return false; + if (stored?.accessToken && sameCredentialMaterial(bearer, stored.accessToken)) return true; + // Same namespace on both sides, never `sub`: this is the ChatGPT per-user identity the reserve + // path already trusts. A credential whose own two encodings of it disagree cannot identify + // anyone, so it fails closed even when the disagreement is on the stored side. + const callerUser = nativeUserIdClaims(bearer); + const cooledUser = stored?.accessToken + ? nativeUserIdClaims(stored.accessToken) + : { userId: undefined, conflict: false }; + if (callerUser.conflict || cooledUser.conflict) return true; + if (callerUser.userId !== undefined && cooledUser.userId !== undefined) { + return callerUser.userId === cooledUser.userId; + } + const callerEmail = extractEmail(undefined, bearer)?.trim().toLowerCase() || undefined; + const cooledEmail = entry?.email?.trim().toLowerCase() || undefined; + if (callerEmail !== undefined && cooledEmail !== undefined) return callerEmail === cooledEmail; + return true; +} + function captureObservedMainWriter(): MainQuotaWriter | undefined { const identityKey = getObservedMainQuotaIdentityKey(); return identityKey === undefined ? undefined : { @@ -598,20 +647,31 @@ export async function resolveCodexAuthContext( throw new CodexReserveUnavailableError(); } const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId; - const preserveRequestOwnedMainPin = requestScopedMainCredential + const requestOwnedMainPinCandidate = requestScopedMainCredential && fixedAccountId === undefined && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID && isEffectiveCodexAccountPinned(config) && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) - && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)) && requestOwnedMainPinHasQuotaHeadroom(config); + // During an owned startup, equality cannot be established until recovery and the + // memory-only policy binding finish. This read-only fence never probes a foreign home. + if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) { + throw new CodexMainProfileDrainingError(); + } + const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate + && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)); if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } const resolveCallerOwnedMainContext = async (): Promise<CodexAuthContext> => { - if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); const substituteStoredMain = options.substituteMainCredentialForDirect === true; + // An internal route change can strip the admission bearer before this point. + // Trusted substitution still has to claim and validate stored main below. + if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); if (!substituteStoredMain) { + if (policy.codexMainAccountHardLock === true && isMainAccountPolicyBindingPending()) { + throw new CodexMainProfileDrainingError(); + } if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); if (reserve) { const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy }); @@ -885,6 +945,14 @@ export async function resolveCodexAuthContext( ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined : tryAcquireCodexQuotaProbeLease(accountId) ?? undefined; if (!probeLeaseId) { + // The selector can retain the configured Pool account when no stored + // alternate is eligible. A validated caller may still serve this request, + // just as it can after an upstream rejection, without changing Pool state. + if (requestScopedMainCredential && fixedAccountId === undefined + && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID + && !callerIsCooledPoolAccount(headers, config, accountId)) { + return await resolveCallerOwnedMainContext(); + } throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope); } } diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 97e32307a5..f52fad8852 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -52,6 +52,8 @@ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -952,16 +954,21 @@ function comboMemberVendorMetadata(provider: string, modelId: string): ModelMeta */ function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined { const metadataProvider = resolveMetadataProvider(target.provider); - const metadata = metadataProvider ? comboMemberVendorMetadata(metadataProvider, target.model) : undefined; + // Custom OpenAI-compatible routes commonly retain the canonical OpenAI model id + // while using a provider name that has no metadata alias. Reuse only its effort + // ladder below; context/modality rows remain provider-owned. + const metadata = metadataProvider + ? comboMemberVendorMetadata(metadataProvider, target.model) + : comboMemberVendorMetadata("openai", target.model); if (!metadata) return undefined; return { - ...(typeof metadata.contextWindow === "number" && metadata.contextWindow > 0 + ...(metadataProvider && typeof metadata.contextWindow === "number" && metadata.contextWindow > 0 ? { contextWindow: metadata.contextWindow } : {}), - ...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 + ...(metadataProvider && typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 ? { maxOutputTokens: metadata.maxTokens } : {}), - ...(Array.isArray(metadata.input) && metadata.input.length > 0 + ...(metadataProvider && Array.isArray(metadata.input) && metadata.input.length > 0 ? { inputModalities: [...metadata.input] } : {}), ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}), @@ -1038,15 +1045,21 @@ export function resolveComboCatalogMember( && typeof existing.contextWindow === "number" && existing.contextWindow > 0 ) { - const capped = applyProviderContextCap(existing.contextWindow, contextCap); + // Live discovery can explicitly say text-only even when configured routing + // supplies a vision sidecar. Apply the same provider hints used for thin + // rows before deriving a combo from this complete row. + const hinted = prov && isModelVisionSidecarConsumer(prov, existing.id) + ? applyProviderConfigHints(target.provider, prov, existing, contextCap, metadataModelIdCaseFold) + : existing; + const capped = applyProviderContextCap(hinted.contextWindow, contextCap); if (capped === undefined || capped === existing.contextWindow) { - return withFallbackMetadata(existing); + return withFallbackMetadata(hinted); } - const maxInput = typeof existing.maxInputTokens === "number" && existing.maxInputTokens > 0 - ? Math.min(existing.maxInputTokens, capped) + const maxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0 + ? Math.min(hinted.maxInputTokens, capped) : Math.min(fallback?.maxInputTokens ?? capped, capped); return withFallbackMetadata({ - ...existing, + ...hinted, contextWindow: capped, maxInputTokens: maxInput, contextCap, @@ -1419,6 +1432,13 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid plainRecord(item.meta)?.n_ctx, item.default_context_size, plainRecord(item.meta)?.n_ctx_train, + // A chained OpenCodex hub (and other re-serving gateways) reports the per-model + // window on the same capability record this function already reads for + // `max_output_tokens` below (#4032). Without it every routed row fell through to + // the 128k compatibility floor in parsing.ts while local forward rows kept their + // real values. Appended after the recognized fields for the same reason as the + // llama.cpp entries above: no provider that already resolves changes behavior. + capabilityRecord?.context_length, ); const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens); const maxOutputTokens = positiveSafeInteger( @@ -1589,6 +1609,50 @@ async function fetchProviderModelsWithAuth( ? [...models, vertexDefaultSeed] : models ); + if (prov.adapter === "qoder") { + if (!apiKey) return observed(configured, "degraded"); + const profile = resolveQoderProfile(prov.baseUrl); + if (!profile) return observed(configured, "degraded"); + // Qoder's model list is entitlement-specific. Bind cache reads/writes to an irreversible PAT + // fingerprint so an account switch cannot observe another account's roster, even if a caller + // bypasses the normal config mutation path that clears provider caches. + const authorityIdentity = createHash("sha256").update(apiKey).digest("hex"); + const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); + if (fresh) { + return observed(withConfiguredRetention( + applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + ), "authoritative"); + } + const scopedStale = getStaleCached(name, authorityIdentity); + if (isModelsFetchCoolingDown(name) && scopedStale) { + return observed(withConfiguredRetention( + applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + ), "degraded"); + } + const live = await fetchQoderModels(profile, apiKey); + if (live.ok) { + const discovered = live.models.map(id => ({ + id, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + })); + const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + markProviderDiscoveryOk(name, live.models.length); + return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); + } + if (isCurrentCacheGeneration()) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: "provider" }); + console.warn(`[opencodex] Qoder model discovery for "${name}" failed [${live.error}]${live.detail ? `: ${live.detail}` : ""}; using stale/static catalog degradation.`); + } + const stale = getStaleCached(name, authorityIdentity); + return observed(withConfiguredRetention( + stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured, + ), "degraded"); + } if (prov.adapter === "cursor") { if (!apiKey) return observed(configured, "degraded"); // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed diff --git a/src/codex/features.ts b/src/codex/features.ts index 9875a6ea2c..a52e9fc46e 100644 --- a/src/codex/features.ts +++ b/src/codex/features.ts @@ -38,6 +38,7 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWri import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl"; import { CODEX_CONFIG_PATH } from "./paths"; import { resolveAndPersistCodexRuntime } from "./runtime"; +import { canonicalizeOpenCodexModeHint } from "./multi-agent-mode-policy"; /** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */ export const DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY = "default_mode_request_user_input"; @@ -1091,7 +1092,8 @@ export function setMultiAgentModeHintText(value: string | null, configPath?: str }; } } - return setV2StringField("multi_agent_mode_hint_text", value, configPath); + const canonicalValue = value === null ? null : canonicalizeOpenCodexModeHint(value); + return setV2StringField("multi_agent_mode_hint_text", canonicalValue, configPath); } export const MODE_HINT_CAPABILITY_CACHE_MAX_ENTRIES = 8; diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 0f86867a8c..b43e8076de 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -31,6 +31,7 @@ import { resolveEffectiveUserIdentity, } from "./user-identity"; import { + hasUnverifiedJournalBaseline, markJournalInjectedState, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl, @@ -73,7 +74,6 @@ import { tomlString, } from "./paths"; import { resolveEffectiveProjectModelProvider } from "./project-config-warnings"; -import { syncCodexAgentRoles } from "./agent-roles-sync"; import { transformManagedSubagentDefaults, type ManagedSubagentDefaults, @@ -175,6 +175,8 @@ export interface CodexRoutingTarget { * and is never weakened by this flag. */ desktopAuthless?: boolean; + /** Select the dedicated provider identity so Codex owns compaction locally. */ + clientCompaction?: boolean; } function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTarget { @@ -198,35 +200,34 @@ function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTar return { ...target, baseUrl: `${parsed.origin}/v1` }; } -function normalizePublicOriginToBaseUrl(publicOrigin: string): string { - let trimmed = publicOrigin; - while (trimmed.endsWith("/")) trimmed = trimmed.slice(0, -1); - return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; -} - -/** Provider-table form is used for non-loopback admission and for the authless Desktop opt-in. */ +/** Provider-table form is used when auth, admission, or compaction policy needs a dedicated provider. */ function usesProviderTable(target: CodexRoutingTarget): boolean { - return target.requiresAdmissionToken || target.desktopAuthless === true; + return target.requiresAdmissionToken + || target.desktopAuthless === true + || target.clientCompaction === true; } export function standaloneCodexRoutingTarget( port: number, - config?: Pick<OcxConfig, "hostname" | "tls" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless">, + config?: Pick< + OcxConfig, + "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction" + >, ): CodexRoutingTarget { const loopback = config?.unauthenticatedLoopbackListener; const effectivePort = loopback?.enabled ? loopback.port : port; const hostname = loopback?.enabled ? undefined : config?.hostname; - const publicOrigin = loopback?.enabled ? undefined : config?.tls?.publicOrigin; const requiresAdmissionToken = loopback?.enabled ? false : shouldInjectApiAuthHeader(config); return { - baseUrl: publicOrigin - ? normalizePublicOriginToBaseUrl(publicOrigin) - : `http://${providerBaseHost(hostname)}:${effectivePort}/v1`, + baseUrl: `http://${providerBaseHost(hostname)}:${effectivePort}/v1`, requiresAdmissionToken, tokenEnv: "OPENCODEX_API_AUTH_TOKEN", ...(config?.codexDesktopAuthless === true && !requiresAdmissionToken ? { desktopAuthless: true } : {}), + ...(config?.codexClientCompaction === true && !requiresAdmissionToken + ? { clientCompaction: true } + : {}), }; } @@ -281,7 +282,6 @@ export function buildProviderTableBlock( supportsWebsockets?: boolean, includeApiAuthHeader?: boolean, hostname?: string, - publicOrigin?: string, ): string; export function buildProviderTableBlock( target: CodexRoutingTarget, @@ -292,12 +292,10 @@ export function buildProviderTableBlock( supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string, - publicOrigin?: string, ): string { - const normalizedPublicOrigin = publicOrigin ? normalizePublicOriginToBaseUrl(publicOrigin) : undefined; const target = typeof portOrTarget === "number" ? validateCodexRoutingTarget({ - baseUrl: normalizedPublicOrigin ?? `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, + baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, requiresAdmissionToken: includeApiAuthHeader, tokenEnv: "OPENCODEX_API_AUTH_TOKEN", }) @@ -335,21 +333,15 @@ function buildProviderTableBlockForTarget( export function buildOpenaiBaseUrlLine( port: number, hostname?: string, - publicOrigin?: string, ): string; export function buildOpenaiBaseUrlLine(target: CodexRoutingTarget): string; export function buildOpenaiBaseUrlLine( portOrTarget: number | CodexRoutingTarget, hostname?: string, - publicOrigin?: string, ): string { - if (typeof portOrTarget !== "number") { - return buildOpenaiBaseUrlLineForTarget(validateCodexRoutingTarget(portOrTarget)); - } - const baseUrl = publicOrigin - ? normalizePublicOriginToBaseUrl(publicOrigin) - : `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`; - return `openai_base_url = ${tomlString(baseUrl)}`; + return typeof portOrTarget === "number" + ? `openai_base_url = "http://${providerBaseHost(hostname)}:${portOrTarget}/v1"` + : buildOpenaiBaseUrlLineForTarget(validateCodexRoutingTarget(portOrTarget)); } function buildOpenaiBaseUrlLineForTarget(target: CodexRoutingTarget): string { @@ -380,7 +372,6 @@ export function setRootOpenaiBaseUrl( content: string, port: number, hostname?: string, - publicOrigin?: string, ): { content: string; keptUserBaseUrl: boolean }; export function setRootOpenaiBaseUrl( content: string, @@ -390,7 +381,6 @@ export function setRootOpenaiBaseUrl( content: string, portOrTarget: number | CodexRoutingTarget, hostname?: string, - publicOrigin?: string, ): { content: string; keptUserBaseUrl: boolean } { if (typeof portOrTarget !== "number") { return setRootOpenaiBaseUrlForTarget(content, validateCodexRoutingTarget(portOrTarget)); @@ -398,7 +388,7 @@ export function setRootOpenaiBaseUrl( const lines = content.split("\n"); const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = buildOpenaiBaseUrlLine(portOrTarget, hostname, publicOrigin); + const key = buildOpenaiBaseUrlLine(portOrTarget, hostname); for (let i = 0; i < rootEnd; i++) { if (!isRootOpenaiBaseUrlLine(lines[i])) continue; @@ -819,7 +809,7 @@ function stripOpencodexCatalogPath(content: string): string { .join("\n"); } -export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets?: boolean, includeApiAuthHeader?: boolean, hostname?: string, fastMode?: boolean, publicOrigin?: string): string; +export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets?: boolean, includeApiAuthHeader?: boolean, hostname?: string, fastMode?: boolean): string; export function buildProfileFile(target: CodexRoutingTarget, catalogPath?: string | null, supportsWebsockets?: boolean, fastMode?: boolean): string; export function buildProfileFile( portOrTarget: number | CodexRoutingTarget, @@ -828,13 +818,10 @@ export function buildProfileFile( includeApiAuthHeaderOrFastMode?: boolean, hostname?: string, fastMode?: boolean, - publicOrigin?: string, ): string { const target = typeof portOrTarget === "number" ? validateCodexRoutingTarget({ - baseUrl: publicOrigin - ? normalizePublicOriginToBaseUrl(publicOrigin) - : `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, + baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, requiresAdmissionToken: includeApiAuthHeaderOrFastMode === true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN", }) @@ -857,7 +844,7 @@ function buildProfileFileForTarget( const host = new URL(origin).host; // Design B (loopback): the reference/fallback file documents the root override form. // Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry - // the x-opencodex-api-key env header); the authless Desktop opt-in shares that shape. + // the x-opencodex-api-key env header); explicit Desktop policies share that shape. if (!usesProviderTable(target)) { const lines = [ "# OpenCodex proxy fallback config (Design B)", @@ -902,7 +889,6 @@ export interface CodexInjectResult { status?: "skipped"; skippedReason?: "desired_disabled" | "desired_enabled"; nativeSubagentDefaultsWarning?: string; - agentRolesSyncWarning?: string; } export async function injectCodexConfig( @@ -927,16 +913,6 @@ export async function injectCodexConfig( } catch (error) { return { success: false, message: error instanceof Error ? error.message : "Invalid Codex routing target" }; } - // Role files are independent of config.toml. Skip when the caller omitted - // config so an unknown catalog cannot prune owned files. The sync fail-closes - // internally; do not wrap this call in an empty catch. - let agentRolesSyncWarning: string | undefined; - if (!options.validateOnly && config) { - const roleSync = syncCodexAgentRoles(config); - if (roleSync.warnings.length > 0) agentRolesSyncWarning = roleSync.warnings.join(" "); - } - const withRoleWarning = <T extends CodexInjectResult>(result: T): T => - agentRolesSyncWarning ? { ...result, agentRolesSyncWarning } : result; if (!existsSync(CODEX_CONFIG_PATH)) { return { success: false, @@ -962,7 +938,7 @@ export async function injectCodexConfig( ) ? `Native Codex sub-agent defaults were not injected: external model_provider ${tomlString(activeProvider)} owns config.toml.` : undefined; - return withRoleWarning({ + return { success: true, ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } @@ -973,7 +949,7 @@ export async function injectCodexConfig( ` Configure that provider for Responses passthrough at ${routingTarget.baseUrl}` + `${routingTarget.requiresAdmissionToken ? ` with x-opencodex-api-key from ${routingTarget.tokenEnv}` : ""}.\n` + ` For direct injection, switch to the built-in openai provider, remove any user-owned root openai_base_url, and rerun 'ocx start'.`, - }); + }; } // Marker-owned native defaults are OpenCodex residue, never part of the @@ -1049,11 +1025,37 @@ export async function injectCodexConfig( ? setRootModelCatalogPath(content, catalogPath) : stripOpencodexCatalogPath(content); - // Provider-table form: non-loopback admission (legacy) or the authless Desktop opt-in (#1107). - const legacyMode = usesProviderTable(routingTarget); + // Provider-table form: non-loopback admission or an explicit Desktop policy. + const providerTableMode = usesProviderTable(routingTarget); + // Client compaction is the one table form that must not orphan existing threads. It changes + // the DEFAULT provider to `opencodex`, but a thread already tagged `openai` keeps resolving + // to Codex's built-in entry, and without the root override that entry is api.openai.com — + // the thread would resume outside this proxy and outside configured routing. Keeping the + // marker-owned root override alongside the table fixes that at the source: codex builds its + // provider map as merge_configured_model_providers(built_in_model_providers(openai_base_url), + // model_providers), so the override lands on the built-in `openai` entry when the map is + // built, independent of which id is the default, and the merge leaves that entry alone for + // every id except the two Amazon Bedrock ones. With the managed override in place both + // entries point at this proxy. That is a guarantee about the line we own: when the user owns + // the root line we inject nothing, and the built-in entry keeps whatever destination they + // chose, so an `openai`-tagged thread follows their configuration rather than this proxy. + // + // Re-tagging history was the alternative and it cannot be made durable: the length-preserving + // first-line repair cannot grow "openai" into "opencodex" without pre-existing padding, and + // codex re-appends that stale first line whenever it writes git or memory-mode metadata. + // + // Authless is excluded on purpose: its whole point is a provider that carries + // requires_openai_auth = false, and admission-token forms cannot use the root key at all. + // Those two forms therefore keep their existing behaviour, forward-tagging resume history with + // originals backed up, and that includes the case where a user enables authless and client + // compaction together. Only the compaction-only form skips the history unit. + const keepRootOverrideAlongsideTable = providerTableMode + && routingTarget.clientCompaction === true + && routingTarget.desktopAuthless !== true + && routingTarget.requiresAdmissionToken !== true; let keptUserBaseUrl = false; let keptUserRealtimeWsBaseUrl = false; - if (legacyMode) { + if (providerTableMode) { // Legacy (non-loopback) injection: the built-in openai provider cannot carry the // x-opencodex-api-key env header, so keep the opencodex provider table + root re-tag. // The authless opt-in needs the same table because only a dedicated provider can carry @@ -1065,6 +1067,14 @@ export async function injectCodexConfig( content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})); + // 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules + // are the Design B ones: a user's own root line is never replaced. + if (keepRootOverrideAlongsideTable) { + content = stripInjectedOpenaiBaseUrl(content); + const rootFallback = setRootOpenaiBaseUrlForTarget(content, routingTarget); + content = rootFallback.content; + keptUserBaseUrl = rootFallback.keptUserBaseUrl; + } } else { // Design B (loopback): a single root override; codex keeps its native `openai` provider id // so thread history is never remapped. Any legacy form was already stripped above. @@ -1184,6 +1194,24 @@ export async function injectCodexConfig( }; } + const journalBaselineIsNative = (): boolean => { + // Value evidence survives an app rewrite that removes the ownership comments. + const journaledBaseUrl = journaledInjectedOpenaiBaseUrl({ readOnly: true }); + const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl({ readOnly: true }); + const looksInjectedByValue = + (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl) + || (journaledRealtimeWsBaseUrl !== null + && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); + return !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue; + }; + const readCurrentProfile = (): string | null => existsSync(CODEX_PROFILE_PATH) + ? readFileSync(CODEX_PROFILE_PATH, "utf-8") + : null; + const unverifiedJournalMessage = "Codex configuration was not written: the journal has no verified baseline for the current config/profile. Current files and the journal were preserved."; + if (!journalBaselineIsNative() && hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) { + return { success: false, message: unverifiedJournalMessage }; + } + if (options.validateOnly) { return { success: true, @@ -1192,31 +1220,29 @@ export async function injectCodexConfig( } const applyNativeArtifacts = (): void => { - // #1798 again: a Codex app rewrite keeps values and drops the ownership comments, so - // marker evidence alone would classify our own routed config as the user's native - // baseline and replace the real original snapshot. Value evidence from the journal - // (the URLs the last injection recorded writing) blocks that misclassification. - const journaledBaseUrl = journaledInjectedOpenaiBaseUrl(); - const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl(); - const looksInjectedByValue = - (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl) - || (journaledRealtimeWsBaseUrl !== null - && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); writeJournal({ - currentStateIsNative: !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue, + currentStateIsNative: journalBaselineIsNative(), configContent: baselineContent, owner: options.journalOwner, }); + // A native snapshot may have been refreshed above. An older hashless routed snapshot + // must not gain the new injection's hash and later overwrite preserved user edits. + if (hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage); atomicWriteFile(CODEX_CONFIG_PATH, content); atomicWriteFile(CODEX_PROFILE_PATH, profileContent); markJournalInjectedState(content, profileContent, { - // A root override is ours only in loopback Design B when no user-owned value won. - injectedOpenaiBaseUrl: legacyMode || keptUserBaseUrl + // A root override is ours whenever we wrote one and no user-owned value won. That is + // loopback Design B, and now also the client-compaction form, which keeps the same + // marker-owned root line beside its provider table. Journaling it matters because the + // marker comment is not durable: the Codex app can reserialize config.toml and drop + // comments, and restore then has only the journaled value to tell our line from a user's + // (#1798). The other table forms never write the key, so they still record null. + injectedOpenaiBaseUrl: (providerTableMode && !keepRootOverrideAlongsideTable) || keptUserBaseUrl ? null : rootTomlString(content, "openai_base_url"), // The sideband override is ours only when we wrote it this pass (never in legacy mode, // never when the user owns either key). - injectedRealtimeWsBaseUrl: legacyMode || keptUserBaseUrl || keptUserRealtimeWsBaseUrl + injectedRealtimeWsBaseUrl: providerTableMode || keptUserBaseUrl || keptUserRealtimeWsBaseUrl ? null : rootTomlString(content, REALTIME_WS_BASE_URL_KEY), // This is the catalog artifact selected for this injection, even when config.toml @@ -1353,7 +1379,11 @@ export async function injectCodexConfig( } // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs // the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore - // machinery; cheap no-op when there is nothing to migrate). + // machinery; cheap no-op when there is nothing to migrate). The client-compaction opt-in keeps + // the root override alongside its table precisely so it does NOT have to touch history: an + // existing `openai`-tagged thread still reaches this proxy through the built-in entry. So it + // skips this unit, and future-only means what it says — no provider metadata is rewritten and + // no `ocx1:` payload is touched. // History runs in a Worker under H, not on this thread. // // The three surfaces it touches — the SQLite rows, the backup manifest, and the @@ -1366,8 +1396,8 @@ export async function injectCodexConfig( expectedDesiredEnabled: true, operation: deriveCodexHistoryOperation({ direction: "apply", - resumeHistory: config?.syncResumeHistory !== false, - legacyMode, + resumeHistory: config?.syncResumeHistory !== false && !keepRootOverrideAlongsideTable, + legacyMode: providerTableMode, }), }); // A blocked or failed unit is reported, not silently counted as zero work: @@ -1398,19 +1428,43 @@ export async function injectCodexConfig( const ejected = (history as { ejectedRows?: number }).ejectedRows ?? 0; const migratedRows = (history.rows ?? 0) + ejected; const historyMessage = - config?.syncResumeHistory === false + keepRootOverrideAlongsideTable + ? (keptUserBaseUrl + ? ` Codex resume history: left unchanged; threads already tagged openai follow your own root openai_base_url, not the proxy.\n` + : ` Codex resume history: left unchanged; existing threads keep reaching the proxy through the retained openai_base_url override.\n`) + : config?.syncResumeHistory === false ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n` : history.failed - ? formatApplyHistoryFailure(historyOutcome, legacyMode) - : legacyMode + ? formatApplyHistoryFailure(historyOutcome, providerTableMode) + : providerTableMode ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n` : migratedRows > 0 ? ` Codex resume history: restored original provider metadata for ${migratedRows} manifest-backed thread(s) (one-time).\n` : ` Codex resume history: no backed-up metadata pending; untracked routed history left unchanged.\n`; - // A user-owned root openai_base_url means we did NOT install routing — say so honestly + // A user-owned root openai_base_url means we did NOT install root routing — say so honestly // instead of claiming the proxy route is active (catalog/fast_mode were still written). + // + // The client-compaction form writes a provider table as well, so "nothing was injected" would + // misdescribe the file it just produced: new threads do use the injected table. Report that + // mixed result on its own terms, and never tell the operator to delete a setting of theirs. + if (keptUserBaseUrl && keepRootOverrideAlongsideTable) { + return { + success: true, + ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), + message: + `Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n` + + ` Your root openai_base_url was left exactly as you set it, so opencodex did not add its own.\n` + + catalogMessage + + historyMessage + + managedDefaultsMessage + + ` New threads use the injected opencodex provider and route through the proxy.\n` + + ` Threads already tagged openai resolve through Codex's built-in provider, which your root openai_base_url points at.\n` + + ` Remove that line and rerun 'ocx start' only if you want those threads on the proxy too.\n` + + ` Fallback: codex --profile opencodex (same behavior)`, + }; + } if (keptUserBaseUrl) { - return withRoleWarning({ + return { success: true, ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } @@ -1422,14 +1476,16 @@ export async function injectCodexConfig( managedDefaultsMessage + ` To route plain codex through the proxy, remove your openai_base_url line from ~/.codex/config.toml and rerun 'ocx start'.\n` + ` Reference config: ${CODEX_PROFILE_PATH}`, - }); + }; } const headline = routingTarget.desktopAuthless === true ? `Injected opencodex as default provider into Codex config (authless Desktop mode: requires_openai_auth = false).\n` - : legacyMode + : routingTarget.clientCompaction === true + ? `Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n` + : providerTableMode ? `Injected opencodex as default provider into Codex config.\n` : `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url + realtime sideband override).\n`; - return withRoleWarning({ + return { success: true, ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), message: @@ -1440,10 +1496,10 @@ export async function injectCodexConfig( ` All models now route through opencodex proxy (like OpenRouter).\n` + ` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` + ` Custom models route to their configured providers.\n` + - (legacyMode + (providerTableMode ? ` Fallback: codex --profile opencodex (same behavior)` : ` Fallback reference: ${CODEX_PROFILE_PATH}`), - }); + }; } /** @@ -1768,6 +1824,12 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex function restoreCodexConfigInline(): CodexRestoreConfigResult { try { const journal = restoreJournalState(); + if (journal.unverified) { + return { + state: "failed", changed: false, action: "failed", + message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.", + }; + } const restored = journal.configRestored ? { success: true, message: "Codex config restored from opencodex journal." } : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); diff --git a/src/codex/journal.ts b/src/codex/journal.ts index b2ec89eef0..8aafb19202 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -62,12 +62,40 @@ export interface RestoreJournalResult { configChanged: boolean; profileChanged: boolean; complete: boolean; + /** A changed artifact has no recorded injected hash, so snapshot ownership is unknown. */ + unverified: boolean; } function sha256(content: string | null): string | null { return content === null ? null : createHash("sha256").update(content).digest("hex"); } +function compareJournalState(journal: Journal, config: string | null, profile: string | null) { + const originalConfig = Buffer.from(journal.originalConfig, "base64").toString("utf-8"); + const originalProfile = journal.originalProfile === null + ? null + : Buffer.from(journal.originalProfile, "base64").toString("utf-8"); + const configAlreadyOriginal = config === originalConfig; + const profileAlreadyOriginal = profile === originalProfile; + const configHashKnown = typeof journal.injectedConfigHash === "string" && journal.injectedConfigHash.length > 0; + const profileHashKnown = journal.injectedProfileHash !== undefined; + return { + originalConfig, + originalProfile, + configAlreadyOriginal, + profileAlreadyOriginal, + configUnchanged: configAlreadyOriginal || (configHashKnown && sha256(config) === journal.injectedConfigHash), + profileUnchanged: profileAlreadyOriginal || (profileHashKnown && sha256(profile) === (journal.injectedProfileHash ?? null)), + unverified: (!configHashKnown && !configAlreadyOriginal) || (!profileHashKnown && !profileAlreadyOriginal), + }; +} + +/** Read-only check of the pre-injection snapshot input, never the newly injected bytes. */ +export function hasUnverifiedJournalBaseline(config: string | null, profile: string | null): boolean { + const journal = readJournal(false); + return journal !== null && compareJournalState(journal, config, profile).unverified; +} + export interface WriteJournalOptions { /** * The caller's verdict on the config it is about to transform: false when @@ -114,7 +142,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void { const journal: Journal = { version: 1, originalConfig: Buffer.from(config).toString("base64"), - originalProfile: profile ? Buffer.from(profile).toString("base64") : null, + originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null, pid: process.pid, owner: options.owner?.kind === "client" ? { kind: "client", apiKeyId: options.owner.apiKeyId } @@ -208,22 +236,34 @@ export function journalOwner(): JournalOwner | null { export function restoreJournalState(): RestoreJournalResult { const journal = readJournal(); if (!journal) { - return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false }; + return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false }; } - const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : ""; + const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null; const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; - const configUnchanged = !journal.injectedConfigHash || sha256(currentConfig) === journal.injectedConfigHash; - const profileUnchanged = journal.injectedProfileHash === undefined || sha256(currentProfile) === (journal.injectedProfileHash ?? null); + const comparison = compareJournalState(journal, currentConfig, currentProfile); + const { configUnchanged, profileUnchanged } = comparison; + // A legacy record or interruption before markJournalInjectedState is not proof that + // later bytes belong to OpenCodex. Keep the whole pair and its recovery evidence intact. + if (comparison.unverified) { + return { + configRestored: comparison.configAlreadyOriginal, + profileRestored: comparison.profileAlreadyOriginal, + configChanged: !configUnchanged, + profileChanged: !profileUnchanged, + complete: false, + unverified: true, + }; + } - let configRestored = false; - let profileRestored = false; - if (configUnchanged) { - atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8")); + let configRestored = comparison.configAlreadyOriginal; + let profileRestored = comparison.profileAlreadyOriginal; + if (configUnchanged && !configRestored) { + atomicWriteFile(CODEX_CONFIG_PATH, comparison.originalConfig); configRestored = true; } - if (profileUnchanged) { - if (journal.originalProfile !== null) { - atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8")); + if (profileUnchanged && !profileRestored) { + if (comparison.originalProfile !== null) { + atomicWriteFile(CODEX_PROFILE_PATH, comparison.originalProfile); profileRestored = true; } else if (existsSync(CODEX_PROFILE_PATH)) { // "There was no profile before, so remove the one we generated." Claiming success @@ -249,6 +289,7 @@ export function restoreJournalState(): RestoreJournalResult { configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete, + unverified: false, }; } @@ -267,6 +308,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean if (owner?.kind === "client") { if (options.activeClientApiKeyId === owner.apiKeyId) return false; const restored = restoreJournalState(); + if (restored.unverified) { + console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); + return false; + } if (!restored.configRestored && !restored.profileRestored) return false; console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`); return true; @@ -281,6 +326,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean } } const restored = restoreJournalState(); + if (restored.unverified) { + console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); + return false; + } if (!restored.configRestored && !restored.profileRestored) return false; console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`); return true; diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index 067c4195ca..fe790715fe 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -18,6 +18,8 @@ interface CacheEntry { models: CatalogModel[]; fetchedAt: number; sizeBytes: number; + /** Irreversible credential/account identity for entitlement-sensitive catalogs. */ + authorityIdentity?: string; } export type ProviderModelDiscoveryFailureReason = @@ -149,15 +151,19 @@ export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_F } /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */ -export function getFreshCached(provider: string, ttlMs: number, now = Date.now()): CatalogModel[] | null { +export function getFreshCached(provider: string, ttlMs: number, now = Date.now(), authorityIdentity?: string): CatalogModel[] | null { const entry = cache.get(provider); if (!entry) return null; + if (authorityIdentity !== undefined && entry.authorityIdentity !== authorityIdentity) return null; return now - entry.fetchedAt < ttlMs ? entry.models : null; } /** Last-known-good models regardless of age — the fallback when a live fetch fails. */ -export function getStaleCached(provider: string): CatalogModel[] | null { - return cache.get(provider)?.models ?? null; +export function getStaleCached(provider: string, authorityIdentity?: string): CatalogModel[] | null { + const entry = cache.get(provider); + if (!entry) return null; + if (authorityIdentity !== undefined && entry.authorityIdentity !== authorityIdentity) return null; + return entry.models; } /** Capture the cache generation before an asynchronous provider discovery starts. */ @@ -181,12 +187,13 @@ export function setCached( models: CatalogModel[], now = Date.now(), generation?: string, + authorityIdentity?: string, ): boolean { if (generation !== undefined && !isModelCacheGenerationCurrent(provider, generation)) return false; deleteCachedProvider(provider); const sizeBytes = modelCacheEncoder.encode(provider).byteLength + modelCacheEncoder.encode(JSON.stringify(models)).byteLength; - cache.set(provider, { models, fetchedAt: now, sizeBytes }); + cache.set(provider, { models, fetchedAt: now, sizeBytes, ...(authorityIdentity ? { authorityIdentity } : {}) }); cacheBytes += sizeBytes; if (oldestCachedAt === null || now < oldestCachedAt) { oldestCachedProvider = provider; diff --git a/src/codex/multi-agent-mode-policy.ts b/src/codex/multi-agent-mode-policy.ts new file mode 100644 index 0000000000..395dc72507 --- /dev/null +++ b/src/codex/multi-agent-mode-policy.ts @@ -0,0 +1,24 @@ +export const MULTI_AGENT_MODE_HINT_RECOMMENDATION = { + revision: "proactive-trigger-v1", + text: [ + "Proactive multi-agent delegation is active.", + "Only the delegation trigger changes: a separate explicit request is no longer required.", + "All existing user, authority, task-scope, and collaboration-tool rules continue to apply.", + "Delegate eligible independent work when parallel execution could materially improve speed or quality.", + "User requests override this hint.", + "This mode remains active until a later multi-agent mode developer message changes it.", + ].join(" "), +} as const; + +/** Byte-exact presets previously written by OpenCodex dashboard releases. */ +export const LEGACY_OPENCODEX_MODE_HINTS = [ + "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.", + "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it.", +] as const; + +/** Upgrade only known OpenCodex-owned values; user-authored variants stay byte-identical. */ +export function canonicalizeOpenCodexModeHint(text: string): string { + return LEGACY_OPENCODEX_MODE_HINTS.some(legacy => legacy === text) + ? MULTI_AGENT_MODE_HINT_RECOMMENDATION.text + : text; +} diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index 25f59ba23e..3e2211031d 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -1,4 +1,6 @@ import { NativeProfileManager } from "./native-profile-manager"; +import { loadConfig } from "../config"; +import { initializeMainAccountPolicyBinding } from "./account-lifecycle"; import { clearAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { @@ -73,6 +75,7 @@ interface StartupEntry { owner: NativeMainOwnerReference; unsubscribe: () => void; recoveryStarted: boolean; + policyBindingPending: boolean; settled: Promise<NativeMainStartupGateSnapshot>; resolveAcquisition?: (value: NativeMainStartupGateSnapshot) => void; deps: NativeMainStartupGateDeps; @@ -172,17 +175,21 @@ async function runOwnedStageSweep(entry: StartupEntry): Promise<boolean> { } function scheduleStageSweep(entry: StartupEntry): void { - if (entry.sweepStopping || entry.sweepTimer || startupEntries.get(entry.homeId) !== entry) return; + if (entry.sweepStopping || entry.sweepTimer || entry.sweepInFlight || entry.policyBindingPending + || startupEntries.get(entry.homeId) !== entry) return; const intervalMs = Math.max(10, entry.deps.stageSweepIntervalMs ?? NATIVE_STAGE_SWEEP_INTERVAL_MS); entry.sweepTimer = setTimeout(() => { entry.sweepTimer = undefined; if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return; + const sweepEpoch = entry.epoch; entry.sweepInFlight = (async () => { const safe = await runOwnedStageSweep(entry); - if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return; + if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry + || entry.epoch !== sweepEpoch || entry.policyBindingPending) return; if (!safe) snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" }; else if (snapshot.homeId === entry.homeId && snapshot.status === "blocked" && snapshot.reason === "stage-cleanup-required") { - snapshot = ready(entry.homeId); + if (loadConfig().codexMainAccountHardLock === true) rearmOwnedMainPolicyBinding(entry); + else snapshot = ready(entry.homeId); } })().finally(() => { entry.sweepInFlight = undefined; @@ -218,8 +225,29 @@ function convergeOwnedStartup(entry: StartupEntry): void { )); const stageSweepSafe = recoveryState === "none" ? await runOwnedStageSweep(entry) : false; if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none" && stageSweepSafe) { - clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); - snapshot = ready(entry.homeId); + if (loadConfig().codexMainAccountHardLock === true) { + await withNativeMainOwnerOperation(entry.manager.context, () => withNativeMainExclusiveClaim( + entry.manager.context, + async () => { + if (startupEntries.get(entry.homeId) !== entry || entry.epoch !== currentEpoch) return; + if (probe(entry.manager.context) !== "none") { + snapshot = { status: "blocked", homeId: entry.homeId, reason: "manual-recovery" }; + return; + } + // The HMAC is deliberately not persisted. Bind only the pinned owned home, + // after recovery/cleanup, and before caller-owned admission can observe ready. + if (loadConfig().codexMainAccountHardLock === true) { + initializeMainAccountPolicyBinding(entry.manager.context.authPath); + } + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + snapshot = ready(entry.homeId); + }, + { waitMs: 10_000 }, + )); + } else { + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + snapshot = ready(entry.homeId); + } } else if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none") { snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" }; } else if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch) { @@ -230,12 +258,35 @@ function convergeOwnedStartup(entry: StartupEntry): void { snapshot = { status: "blocked", homeId: entry.homeId, reason: "manual-recovery" }; } } + entry.policyBindingPending = false; if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch) scheduleStageSweep(entry); return snapshot; })(); if (acquisitionWaiter) void entry.settled.then(acquisitionWaiter); } +/** Join an active startup, or rearm its held owner before publishing another ready transition. */ +function rearmOwnedMainPolicyBinding(entry: StartupEntry): boolean { + if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return false; + const owner = entry.owner.snapshot(); + if (entry.policyBindingPending && (owner.status === "held" || owner.status === "acquiring")) { + snapshot = { status: "blocked", homeId: entry.homeId, reason: "recovery-pending" }; + settled = entry.settled; + return true; + } + if (owner.status !== "held") { + snapshot = { status: "blocked", homeId: entry.homeId, reason: ownerBlockedReason(owner) }; + return false; + } + if (entry.sweepTimer) clearTimeout(entry.sweepTimer); + entry.sweepTimer = undefined; + entry.epoch = ++epoch; + entry.policyBindingPending = true; + entry.recoveryStarted = false; + convergeOwnedStartup(entry); + return true; +} + function observeOwner(entry: StartupEntry, owner: NativeMainOwnerSnapshot): void { if (startupEntries.get(entry.homeId) !== entry) return; if (owner.status === "acquiring") { @@ -283,6 +334,7 @@ export function startNativeMainStartupLifecycle( owner, unsubscribe: () => {}, recoveryStarted: false, + policyBindingPending: true, settled: acquisition, resolveAcquisition, deps, @@ -291,6 +343,12 @@ export function startNativeMainStartupLifecycle( }; startupEntries.set(homeId, entry); entry.unsubscribe = owner.subscribe(ownerState => observeOwner(entry!, ownerState)); + } else if (!entry.policyBindingPending + && snapshot.status === "ready" && snapshot.homeId === homeId + && loadConfig().codexMainAccountHardLock === true) { + // A new same-process listener can enable protection or follow a credential replacement. + // Re-read its pinned home through the held owner before admitting caller-owned main. + rearmOwnedMainPolicyBinding(entry); } entry.refs += 1; let released = false; @@ -602,6 +660,8 @@ export function blockNativeMainRecovery( export function completeNativeMainRecovery(homeId: string): boolean { if (snapshot.status !== "blocked" || snapshot.homeId !== homeId) return false; + const entry = startupEntries.get(homeId); + if (entry && loadConfig().codexMainAccountHardLock === true) return rearmOwnedMainPolicyBinding(entry); epoch += 1; clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); snapshot = ready(homeId); @@ -615,6 +675,13 @@ export function nativeMainStartupGateSnapshot(): NativeMainStartupGateSnapshot { return { ...snapshot }; } +/** Read-only: caller-owned credentials must not trigger physical-main ownership reprobes. */ +export function isMainAccountPolicyBindingPending(): boolean { + const current = nativeMainStartupGateSnapshot(); + return current.status === "blocked" && current.reason === "recovery-pending" + && current.homeId !== null && startupEntries.get(current.homeId)?.policyBindingPending === true; +} + export function waitForNativeMainStartupGate(): Promise<NativeMainStartupGateSnapshot> { const reason = activeServiceOwnershipBlockReason(); if (reason) return Promise.resolve(serviceOwnershipSnapshot(reason)); diff --git a/src/codex/native-profile-store.ts b/src/codex/native-profile-store.ts index 794e8ea006..bbd1b5d82e 100644 --- a/src/codex/native-profile-store.ts +++ b/src/codex/native-profile-store.ts @@ -41,7 +41,7 @@ const KEYRING_SERVICE = "opencodex.native-main-profile.v1"; const SHARED_METADATA_DIR = ".opencodex-native-main-profiles"; const INSTANCE_STAGING_DIR = "native-main-profile-staging"; const LEGACY_METADATA_DIR = "native-main-profiles"; -const MAX_AUTH_BYTES = 4 * 1024 * 1024; +export const MAX_AUTH_BYTES = 4 * 1024 * 1024; export const MAX_NATIVE_PROFILE_METADATA_BYTES = 4 * 1024 * 1024; export const MAX_NATIVE_PROFILE_JOURNAL_BYTES = 17 * 1024 * 1024; export const MAX_NATIVE_PROFILES = 32; @@ -376,7 +376,7 @@ export function resolveNativeProfileContext(options: { codexHome?: string; confi }; } -function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { +export function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { let fd: number | undefined; let failed = false; try { diff --git a/src/codex/ocx-compaction-history.ts b/src/codex/ocx-compaction-history.ts new file mode 100644 index 0000000000..7cab765c4f --- /dev/null +++ b/src/codex/ocx-compaction-history.ts @@ -0,0 +1,226 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + truncateSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { getConfigDir } from "../config"; +import { hardenSecretPath } from "../lib/windows-secret-acl"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { + decodeCompactionSummary, + isCompactionItemType, + SUMMARY_PREFIX, +} from "../responses/compaction"; +import { resolveCodexHomeDir } from "./home"; +import { resolveCodexStateDbPath } from "./paths"; + +export interface OcxCompactionRewriteResult { + content: string; + replaced: number; +} + +export interface OcxCompactionHistoryRecoveryResult { + rolloutPath: string; + backupPath: string | null; + replaced: number; +} + +export interface OcxCompactionHistoryRecoveryOptions { + threadId: string; + codexHome?: string; + stateDbPath?: string; + backupRoot?: string; + now?: () => Date; +} + +const THREAD_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function digest(content: string | Buffer): string { + return createHash("sha256").update(content).digest("hex"); +} + +function pathInside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function resolveOwnedRolloutPath(codexHome: string, rawPath: string): string { + const candidate = resolve(isAbsolute(rawPath) ? rawPath : join(codexHome, rawPath)); + const roots = [join(codexHome, "sessions"), join(codexHome, "archived_sessions")] + .filter(existsSync) + .map(root => realpathSync.native(root)); + const entry = lstatSync(candidate); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error("the referenced rollout is not a regular file"); + } + const canonical = realpathSync.native(candidate); + if (!roots.some(root => pathInside(root, canonical))) { + throw new Error("the referenced rollout is outside Codex session storage"); + } + return canonical; +} + +function writePrivateFile(path: string, content: string): void { + const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + try { + if (process.platform !== "win32") chmodSync(path, 0o600); + else hardenSecretPath(path, { required: true, timeoutMemoKey: path }); + writeFileSync(fd, content, "utf8"); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function safeRemovePrivateFile(path: string): void { + try { truncateSync(path, 0); } catch { /* best effort before unlink */ } + try { unlinkSync(path); } catch { /* caller reports the original failure */ } +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function lowerCompactionItem(item: unknown): { item: unknown; changed: boolean } { + if (!isRecord(item) || !isCompactionItemType(item.type)) { + return { item, changed: false }; + } + if (typeof item.encrypted_content !== "string") { + return { item, changed: false }; + } + const summary = decodeCompactionSummary(item.encrypted_content); + if (summary === null) return { item, changed: false }; + return { + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n${summary}` }], + }, + changed: true, + }; +} + +function rewriteJsonlLine(line: string): { line: string; replaced: number } { + let record: unknown; + try { + record = JSON.parse(line); + } catch { + return { line, replaced: 0 }; + } + if (!isRecord(record) || record.type !== "compacted" || !isRecord(record.payload)) { + return { line, replaced: 0 }; + } + const history = record.payload.replacement_history; + if (!Array.isArray(history)) return { line, replaced: 0 }; + + let replaced = 0; + const replacementHistory = history.map(item => { + const lowered = lowerCompactionItem(item); + if (lowered.changed) replaced += 1; + return lowered.item; + }); + if (replaced === 0) return { line, replaced: 0 }; + + return { + line: JSON.stringify({ + ...record, + payload: { ...record.payload, replacement_history: replacementHistory }, + }), + replaced, + }; +} + +/** + * Convert OpenCodeX-owned `ocx1:` compaction items into ordinary replayable user messages. + * + * Only the authoritative `compacted.payload.replacement_history` snapshot is changed. Earlier + * response-item events are historical output and are deliberately preserved byte-for-byte. + * Native opaque compactions are also untouched because OpenCodeX cannot decode them safely. + */ +export function rewriteOcxCompactionsForNativeReplay(content: string): OcxCompactionRewriteResult { + const parts = content.split(/(\r?\n)/); + let replaced = 0; + for (let index = 0; index < parts.length; index += 2) { + const line = parts[index]; + if (!line) continue; + const rewritten = rewriteJsonlLine(line); + if (rewritten.replaced === 0) continue; + parts[index] = rewritten.line; + replaced += rewritten.replaced; + } + return replaced === 0 + ? { content, replaced: 0 } + : { content: parts.join(""), replaced }; +} + +/** + * Repair one explicitly selected Codex rollout for direct native replay. + * + * The original bytes are copied to an owner-private backup before the rollout is atomically + * replaced. A last-moment digest check refuses a concurrent Codex append instead of losing it. + */ +export function recoverOcxCompactionHistory( + options: OcxCompactionHistoryRecoveryOptions, +): OcxCompactionHistoryRecoveryResult { + if (!THREAD_ID_RE.test(options.threadId)) throw new Error("thread id must be a UUID"); + const codexHome = realpathSync.native(options.codexHome ?? resolveCodexHomeDir()); + const stateDbPath = options.stateDbPath ?? resolveCodexStateDbPath({ codexHome }); + if (!existsSync(stateDbPath)) throw new Error("Codex state database was not found"); + + const db = new Database(stateDbPath, { readonly: true }); + let rawRolloutPath: string | undefined; + try { + db.exec("PRAGMA busy_timeout = 1000"); + rawRolloutPath = db.query<{ rollout_path: string }, [string]>( + "SELECT rollout_path FROM threads WHERE id = ? LIMIT 1", + ).get(options.threadId)?.rollout_path; + } finally { + db.close(); + } + if (!rawRolloutPath) throw new Error("thread was not found in the Codex state database"); + + const rolloutPath = resolveOwnedRolloutPath(codexHome, rawRolloutPath); + const originalBytes = readFileSync(rolloutPath); + const original = originalBytes.toString("utf8"); + if (!Buffer.from(original, "utf8").equals(originalBytes)) { + throw new Error("the rollout is not valid UTF-8 and cannot be repaired safely"); + } + const rewritten = rewriteOcxCompactionsForNativeReplay(original); + if (rewritten.replaced === 0) { + return { rolloutPath, backupPath: null, replaced: 0 }; + } + + const stamp = (options.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, "-"); + const backupDir = resolve(options.backupRoot ?? join(getConfigDir(), "history-recovery-backups", options.threadId)); + mkdirSync(backupDir, { recursive: true, mode: 0o700 }); + const backupPath = join(backupDir, `${basename(rolloutPath)}.${stamp}.bak`); + writePrivateFile(backupPath, original); + + const tempPath = `${rolloutPath}.ocx-repair-${process.pid}-${crypto.randomUUID()}.tmp`; + try { + writePrivateFile(tempPath, rewritten.content); + if (digest(readFileSync(rolloutPath)) !== digest(originalBytes)) { + throw new Error("the rollout changed while it was being repaired; close Codex and retry"); + } + renameAtomicFile(tempPath, rolloutPath); + } catch (error) { + safeRemovePrivateFile(tempPath); + throw error; + } + return { rolloutPath, backupPath, replaced: rewritten.replaced }; +} diff --git a/src/codex/project-config-warnings.ts b/src/codex/project-config-warnings.ts index 47b6afee27..50721a689d 100644 --- a/src/codex/project-config-warnings.ts +++ b/src/codex/project-config-warnings.ts @@ -69,7 +69,9 @@ function multilineCloseIndex( backslashes += 1; } if (backslashes % 2 === 0) break; - index = line.indexOf(delimiter, index + delimiter.length); + // An escaped quote can overlap the real terminator (backslash plus four quotes). + // Keep overlapping candidates instead of skipping the entire rejected delimiter. + index = line.indexOf(delimiter, index + 1); } return index; } diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index b067e778aa..6c06de6ae9 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -4,9 +4,6 @@ export type StoredAccountQuota = { monthlyPercent?: number; weeklyResetAt?: number; monthlyResetAt?: number; - /** Legacy explicit alias for the sub-day burst reading; retained for API compatibility. */ - fiveHourPercent?: number; - fiveHourResetAt?: number; /** Sub-day burst window, independent of the weekly window; duration supplies its meaning. */ shortPercent?: number; shortResetAt?: number; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 859ce7352f..351f5fb994 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -65,17 +65,16 @@ export const CODEX_UNKNOWN_USAGE_SCORE = 101; export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; export function isCodexQuotaExhausted( - quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "fiveHourPercent" | "shortPercent"> | null, + quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null, plan?: unknown, ): boolean { if (!quota) return false; // The burst window counts on EVERY plan. It is upstream-enforced independently, so an // account at 100% there is blocked regardless of which longer window governs its plan; // omitting it would route traffic straight into a 429 (#1791). - const burst = quota.fiveHourPercent ?? quota.shortPercent; const values = codexQuotaWindowForPlan(plan) === "monthly" - ? [quota.monthlyPercent, burst] - : [quota.weeklyPercent, quota.monthlyPercent, burst]; + ? [quota.monthlyPercent, quota.shortPercent] + : [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent]; return values.some(value => typeof value === "number" && Number.isFinite(value) && value >= CODEX_EXHAUSTED_USAGE_PERCENT); @@ -101,7 +100,7 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" { } export function isCompleteCodexQuotaRecoverySnapshot( - quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "fiveHourPercent" | "shortPercent"> | null, + quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent"> | null, plan?: unknown, ): boolean { if (!quota || isCodexQuotaExhausted(quota, plan)) return false; @@ -163,7 +162,7 @@ function normalizeResetAt(value: unknown): number | undefined { } function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boolean { - return [quota.weeklyPercent, quota.monthlyPercent, quota.fiveHourPercent, quota.shortPercent] + return [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent] .some(value => typeof value === "number" && Number.isFinite(value)) // Known short-window shape with unknown usage still selects that window for policy. || snapshotHasShort(quota) @@ -219,9 +218,7 @@ function snapshotHasMonthly(quota: Omit<StoredAccountQuota, "updatedAt">): boole function snapshotHasShort(quota: Omit<StoredAccountQuota, "updatedAt">): boolean { return quota.shortPercent !== undefined || quota.shortResetAt !== undefined - || quota.shortWindowSeconds !== undefined - || quota.fiveHourPercent !== undefined - || quota.fiveHourResetAt !== undefined; + || quota.shortWindowSeconds !== undefined; } function snapshotHasCustom(quota: Omit<StoredAccountQuota, "updatedAt">): boolean { @@ -285,8 +282,6 @@ function mergeAccountQuota( if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent; if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt; if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; - if (existing?.fiveHourPercent !== undefined) next.fiveHourPercent = existing.fiveHourPercent; - if (existing?.fiveHourResetAt !== undefined) next.fiveHourResetAt = existing.fiveHourResetAt; if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent; if (existing?.shortObservedAt !== undefined) next.shortObservedAt = existing.shortObservedAt; if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt; @@ -330,14 +325,8 @@ function mergeAccountQuota( if (Number.isFinite(quota.shortPercent)) next.shortObservedAt = next.updatedAt; } if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt; - if (quota.fiveHourPercent !== undefined) next.fiveHourPercent = quota.fiveHourPercent; - if (quota.fiveHourResetAt !== undefined) next.fiveHourResetAt = quota.fiveHourResetAt; if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds; } else { - // Header and reset-credit updates are partial snapshots. Preserve the last full WHAM - // burst tuple when those updates do not carry enough window metadata to replace it. - if (existing?.fiveHourPercent !== undefined) next.fiveHourPercent = existing.fiveHourPercent; - if (existing?.fiveHourResetAt !== undefined) next.fiveHourResetAt = existing.fiveHourResetAt; // Unknown usage is not a lower reading. Retain the entire known tuple: pairing // its percentage with new metadata would silently extend or shorten its reset. if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent; @@ -347,6 +336,9 @@ function mergeAccountQuota( } if (snapshotHasCustom(quota)) next.customWindows = quota.customWindows; + // Ordinary response headers omit model-specific windows reported by WHAM. + // Absence is a partial update; an explicit list (including []) still replaces it. + else if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows; if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits; else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits; @@ -362,7 +354,7 @@ function mergeAccountQuota( * applyAccountQuotaFromUpstreamHeaders runs it once per pooled response. A static import * would load the observer, its config resolution, and its sink registry into every install, * including installs that never enable the feature — the same failure mode - * tests/lab/core-lab-boundary.test.ts exists to prevent for src/lab/. + * tests/core-lab-boundary.test.ts exists to prevent for src/lab/. * * The previous snapshot is deliberately NOT passed. The observer keeps its own persisted * baseline (see swapLastObservedWindows), so every committed write must reach it — including @@ -454,14 +446,8 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt; } } else if (primaryIsShort) { - if (primaryPercent !== undefined) { - quota.shortPercent = primaryPercent; - quota.fiveHourPercent = primaryPercent; - } - if (primaryResetAt !== undefined) { - quota.shortResetAt = primaryResetAt; - quota.fiveHourResetAt = primaryResetAt; - } + if (primaryPercent !== undefined) quota.shortPercent = primaryPercent; + if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt; const minutes = windowMinutes_(primaryWindowMinutes); if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60); // The burst window vacates the primary slot, so the weekly reading is the secondary — which @@ -497,13 +483,10 @@ export function applyAccountQuotaFromUpstreamHeaders( ): void { const quota = parseUpstreamQuotaHeaders(headers); if (!quota) return; - // Header consumers historically persist the Codex pool window under `short*`; keep that - // storage shape while the parser exposes the canonical five-hour alias to direct callers. - const { fiveHourPercent: _fiveHourPercent, fiveHourResetAt: _fiveHourResetAt, ...legacyQuota } = quota; const policyQuota = [ "x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent", ].some(name => isInvalidPolicyUsagePercent(headers.get(name))) ? null : filterMainPolicyMonthlyQuota(quota); - setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota); + setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota); } export function updateAccountQuota( @@ -532,8 +515,6 @@ export function updateAccountQuota( : {}), ...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}), ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}), - ...(existing?.fiveHourPercent !== undefined ? { fiveHourPercent: existing.fiveHourPercent } : {}), - ...(existing?.fiveHourResetAt !== undefined ? { fiveHourResetAt: existing.fiveHourResetAt } : {}), ...(existing?.shortPercent !== undefined ? { shortPercent: existing.shortPercent } : {}), ...(existing?.shortObservedAt !== undefined ? { shortObservedAt: existing.shortObservedAt } : {}), ...(existing?.shortResetAt !== undefined ? { shortResetAt: existing.shortResetAt } : {}), @@ -736,14 +717,7 @@ function filterMainPolicyMonthlyQuota( export function parseMainPolicyUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null { const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null; - const primary = data.rate_limit?.primary_window; - // Go/Free normally use monthly quota, so a tertiary-only payload may be their only - // governing reading. Once upstream explicitly declares a monthly primary, however, its - // missing/unreadable usage is unknown evidence: the tertiary window is supplementary and - // must not stand in for the unreadable primary or release a retained block. - const monthlyOnlyFallback = isThirtyDayOnlyCodexPlan(data.plan_type) - && !isExplicitMonthlyWindow(primary); - return filterMainPolicyMonthlyQuota(parseUsageQuota(data), monthlyOnlyFallback); + return filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type)); } export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null { @@ -785,19 +759,11 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot const primaryIsShort = isExplicitShortWindow(primaryWindow); const weeklyCandidatePercent = primaryIsShort ? undefined : primaryPercent; const weeklyCandidateResetAt = primaryIsShort ? undefined : primaryResetAt; - // Keep the burst reading instead of dropping it on the floor: it is a real limit, and - // the account is blocked when it fills even though the weekly window is fine (#1791). // Retain the declared burst tuple even when its usage is unknown. Its shape selects // the short-window policy; a missing reading is not permission to fall back to weekly. if (primaryIsShort) { - if (primaryPercent !== undefined) { - quota.shortPercent = primaryPercent; - quota.fiveHourPercent = primaryPercent; - } - if (primaryResetAt !== undefined) { - quota.shortResetAt = primaryResetAt; - quota.fiveHourResetAt = primaryResetAt; - } + if (primaryPercent !== undefined) quota.shortPercent = primaryPercent; + if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt; const seconds = primaryWindow?.limit_window_seconds; if (typeof seconds === "number" && Number.isFinite(seconds)) quota.shortWindowSeconds = seconds; } @@ -832,6 +798,10 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot }); const sparkWindows = [spark?.rate_limit?.primary_window, spark?.rate_limit?.secondary_window] .filter((window): window is WhamUsageWindow => !!window); + const sparkShort = sparkWindows.find(window => { + const percent = normalizeUsagePercent(window.used_percent); + return percent !== undefined && isExplicitShortWindow(window); + }); const sparkWeekly = sparkWindows.find(window => { const percent = normalizeUsagePercent(window.used_percent); const seconds = window.limit_window_seconds; @@ -840,16 +810,19 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot && !isExplicitMonthlyWindow(window) && (seconds === undefined || seconds >= WEEKLY_WINDOW_MIN_SECONDS); }); - const sparkPercent = normalizeUsagePercent(sparkWeekly?.used_percent); - if (sparkPercent !== undefined) { - const sparkWindow: { label: string; percent: number; resetAt?: number } = { - label: "GPT-5.3-Codex-Spark Weekly", - percent: sparkPercent, - }; - const resetAt = normalizeResetAt(sparkWeekly?.reset_at); + const sparkCustomWindows: Array<{ label: string; percent: number; resetAt?: number }> = []; + for (const [label, window] of [ + ["GPT-5.3-Codex-Spark 5h", sparkShort], + ["GPT-5.3-Codex-Spark Weekly", sparkWeekly], + ] as const) { + const percent = normalizeUsagePercent(window?.used_percent); + if (percent === undefined) continue; + const sparkWindow: { label: string; percent: number; resetAt?: number } = { label, percent }; + const resetAt = normalizeResetAt(window?.reset_at); if (resetAt !== undefined) sparkWindow.resetAt = resetAt; - quota.customWindows = [sparkWindow]; + sparkCustomWindows.push(sparkWindow); } + if (sparkCustomWindows.length > 0) quota.customWindows = sparkCustomWindows; if (resetCredits !== undefined) quota.resetCredits = resetCredits; return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null; diff --git a/src/codex/reserve-availability.ts b/src/codex/reserve-availability.ts index 7d03e840ac..b96b9e8153 100644 --- a/src/codex/reserve-availability.ts +++ b/src/codex/reserve-availability.ts @@ -44,14 +44,30 @@ function owned(token: Token, writer: MainQuotaWriter): boolean { function record(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); } -function userId(token: string): string | undefined { +/** + * The ChatGPT per-user identity carried by a native credential, plus whether the token's own two + * encodings of it disagree. Precedence stays on the RAW claims, so an empty or non-string + * `chatgpt_user_id` still blocks the `user_id` fallback exactly as before; `conflict` is a + * separate observation for callers that must fail closed on an ambiguous identity. + */ +export function nativeUserIdClaims(token: string): { userId: string | undefined; conflict: boolean } { + const none = { userId: undefined, conflict: false }; try { const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")); const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined; - if (!record(auth)) return; - const value = auth.chatgpt_user_id ?? auth.user_id; - return typeof value === "string" && value.length > 0 ? value : undefined; - } catch { return; } + if (!record(auth)) return none; + const named = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + const primary = named(auth.chatgpt_user_id); + const secondary = named(auth.user_id); + return { + userId: named(auth.chatgpt_user_id ?? auth.user_id), + conflict: primary !== undefined && secondary !== undefined && primary !== secondary, + }; + } catch { return none; } +} +function userId(token: string): string | undefined { + return nativeUserIdClaims(token).userId; } function identityMatches(data: WhamUsageResponse, token: Token): boolean { if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false; diff --git a/src/codex/reset-credit-auto-redeem.ts b/src/codex/reset-credit-auto-redeem.ts index 19b6d3ae0e..2c6292368a 100644 --- a/src/codex/reset-credit-auto-redeem.ts +++ b/src/codex/reset-credit-auto-redeem.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { ConfigMutationLockError, withConfigMutationLockSync } from "../config"; import { atomicWriteFile } from "../config/atomic-write"; import { getConfigDir } from "../config/paths"; import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; @@ -91,9 +92,9 @@ function readJournal(path: string): Journal { } } -function writeJournal(path: string, journal: Journal): void { +function writeJournal(path: string, journal: Journal, now: number): void { // Keep only entries whose credit could still matter: settled ones older than a week are noise. - const cutoff = Date.now() - 7 * 24 * 60 * 60_000; + const cutoff = now - 7 * 24 * 60 * 60_000; journal.entries = journal.entries.filter(e => e.state !== "settled" || e.updatedAt > cutoff); atomicWriteFile(path, JSON.stringify(journal, null, 2)); } @@ -112,6 +113,7 @@ export interface AutoRedeemDeps { now?: () => number; setTimer?: (fn: () => void, ms: number) => unknown; clearTimer?: (handle: unknown) => void; + /** Callers sharing an overridden journal must also share the OPENCODEX_HOME mutation coordinator. */ journalFile?: string; log?: (line: string) => void; /** Upper bound on one sleep so a laptop sleep or clock jump re-checks rather than trusting a stale plan. */ @@ -155,15 +157,38 @@ export function createResetCreditAutoRedeemer(deps: AutoRedeemDeps): ResetCredit handle = setTimer(() => { handle = null; void tick(); }, Math.max(0, Math.min(ms, maxSleepMs))); }; + const retryJournal = (error: unknown): void => { + const cause = error instanceof ConfigMutationLockError ? error.cause : error; + const code = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : ""; + const busy = code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" + || (cause instanceof Error && /database (?:is|table is) locked/i.test(cause.message)); + schedule(busy ? 1_000 : idleRecheckMs); + }; + const dispatch = async (plan: AutoRedeemPlan): Promise<AutoRedeemOutcome> => { - const journal = readJournal(path); - let entry = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt); - if (entry?.state === "settled") return { kind: "skipped", reason: "credit-gone" }; - if (!entry) { - entry = { accountKey, grantedAt: plan.grantedAt, expiresAt: plan.expiresAt, redeemRequestId: randomUUID(), state: "dispatched", updatedAt: now() }; - journal.entries.push(entry); - // Journal BEFORE the network call: a crash after this line replays the same request id. - writeJournal(path, journal); + // Reserve under the shared config-mutation lock. `inFlight` only serializes ticks inside + // ONE process; two servers on the same config dir would otherwise both read a journal with + // no entry, each mint a different `redeem_request_id`, and spend two credits for one plan. + let entry: JournalEntry; + try { + entry = withConfigMutationLockSync(() => { + const journal = readJournal(path); + const existing = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt); + if (existing) return existing; + const created: JournalEntry = { accountKey, grantedAt: plan.grantedAt, expiresAt: plan.expiresAt, redeemRequestId: randomUUID(), state: "dispatched", updatedAt: now() }; + journal.entries.push(created); + // Journal BEFORE the network call: a crash after this line replays the same request id. + writeJournal(path, journal, created.updatedAt); + return created; + }); + } catch (error) { + // Only contention gets a short retry; persistent storage failures must not spin. + retryJournal(error); + return { kind: "error", message: error instanceof Error ? error.message : "journal reservation failed" }; + } + if (entry.state === "settled") { + schedule(idleRecheckMs); + return { kind: "skipped", reason: "credit-gone" }; } log(`[opencodex] reset-credit auto-redeem: dispatching for account ${accountKey} (credit expires ${plan.expiresAt})`); let result: { code: string }; @@ -174,9 +199,25 @@ export function createResetCreditAutoRedeemer(deps: AutoRedeemDeps): ResetCredit schedule(60_000); return { kind: "ambiguous", redeemRequestId: entry.redeemRequestId }; } - entry.state = "settled"; - entry.updatedAt = now(); - writeJournal(path, journal); + // Re-read under the lock: a peer may have appended its own entries since the reservation, + // and writing a stale in-memory journal would drop them. + try { + withConfigMutationLockSync(() => { + const journal = readJournal(path); + const current = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt); + if (!current || current.redeemRequestId !== entry.redeemRequestId) { + throw new Error("auto-redeem journal reservation changed before settlement"); + } + current.state = "settled"; + current.updatedAt = now(); + writeJournal(path, journal, current.updatedAt); + }); + } catch (error) { + // Upstream answered, but settlement could not be committed. Preserve any reservation; + // a later dispatch must reuse its request id. A vanished credit may never dispatch again. + retryJournal(error); + return { kind: "error", message: error instanceof Error ? error.message : "journal settlement failed" }; + } log(`[opencodex] reset-credit auto-redeem: upstream answered ${result.code} for account ${accountKey}`); schedule(idleRecheckMs); return { kind: "dispatched", code: result.code, redeemRequestId: entry.redeemRequestId }; diff --git a/src/codex/routing.ts b/src/codex/routing.ts index c9e0c36f9e..5d8cc17d15 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { saveConfigPreservingClaudeCode } from "../config"; -import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; +import { isCodexAccountGenerationLive, readCodexAccountRecord, type CodexRefreshProvenance } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; import { NATIVE_RESERVE_MODEL } from "./catalog/native-models"; import { isCodexAccountPaused } from "./account-pause"; @@ -373,8 +373,6 @@ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void { export function computeCodexUsageScore(quota: { weeklyPercent?: number; monthlyPercent?: number; - fiveHourPercent?: number; - fiveHourResetAt?: number; shortPercent?: number; shortResetAt?: number; shortObservedAt?: number; @@ -396,28 +394,10 @@ export function computeCodexUsageScore(quota: { // right now, whatever its monthly position turns out to be. Unknown-means-selectable is // correct for uncertainty and wrong for a measured refusal: the account stays selected, // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential. - // `fiveHour*` is the public compatibility alias for the canonical `short*` tuple. - // Some callers and hydrated snapshots legitimately carry only that alias, so collapse both - // shapes before applying the shared freshness rule rather than silently discarding evidence. - const burst = { - shortPercent: quota.fiveHourPercent ?? quota.shortPercent, - shortResetAt: quota.fiveHourResetAt ?? quota.shortResetAt, - shortObservedAt: quota.shortObservedAt, - }; if (knownLong.length === 0) { - return isTerminalShortWindow(burst, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE; - } - // A terminal burst reading is useful only while its own freshness evidence says the - // window is still live. This applies even when a weekly/monthly bar is also known: - // otherwise a canonical short-primary + weekly-secondary snapshot remains scored at - // 100 forever after the short reset, and both Pool and subagent routing strand a - // recovered account. Non-terminal short readings retain their historical refinement - // behaviour; only a measured refusal needs the stricter freshness gate. - const shortPercent = finite(burst.shortPercent) - && (burst.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT || isTerminalShortWindow(burst, now)) - ? burst.shortPercent - : undefined; - const values = shortPercent !== undefined ? [...knownLong, shortPercent] : knownLong; + return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE; + } + const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong; return Math.max(...values); } @@ -662,6 +642,80 @@ export function claimDueCodexQuotaRecoveryProbes( }); } +type CooldownRecoveryLease = Pick<CodexQuotaRecoveryProbeClaim, + "accountId" | "scope" | "leaseId" | "cooldownGeneration">; + +export type ManualResetCooldownClaim = + | { kind: "pool"; probe: CodexQuotaRecoveryProbeClaim } + | { kind: "main"; probe: CooldownRecoveryLease }; + +function manualResetAccountEligible(config: OcxConfig, accountId: string): boolean { + return !isCodexAccountPaused(config, accountId) && !isAccountNeedsReauth(accountId) + && (accountId === MAIN_CODEX_ACCOUNT_ID + || (config.codexAccounts ?? []).some(account => account.id === accountId && isSelectableCodexPoolAccount(account))); +} + +/** Explicit reset bypasses probe pacing, never another owner's lease or quota scope. */ +export function claimManualResetCooldowns( + config: OcxConfig, + accountId: string, + now = Date.now(), + expectedPoolGeneration?: number, +): ManualResetCooldownClaim[] { + if (!manualResetAccountEligible(config, accountId)) return []; + const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId); + if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return []; + if (record && expectedPoolGeneration !== undefined && record.generation !== expectedPoolGeneration) return []; + const claims: ManualResetCooldownClaim[] = []; + for (const scope of [undefined, "shared"] as const) { + const health = scope ? scopedHealthFor(accountId, scope) : upstreamHealth.get(accountId); + if (!health || health.cooldownSource !== "reset-derived" || health.probeLeaseId !== undefined + || !Number.isFinite(health.cooldownUntil) || !(health.cooldownUntil! > now)) continue; + const leaseId = randomUUID(); + const cooldownGeneration = health.cooldownGeneration ?? 0; + const next = { ...health, probeLeaseId: leaseId, probeLeaseGeneration: cooldownGeneration, lastProbeAt: now }; + if (scope) setScopedHealth(accountId, scope, next); + else upstreamHealth.set(accountId, next); + const probe = { accountId, scope, leaseId, cooldownGeneration }; + claims.push(record ? { kind: "pool", probe: { + ...probe, credentialGeneration: record.generation, credentialReplacedAt: record.replacedAt, + } } : { kind: "main", probe }); + } + return claims; +} + +export type ManualResetRefreshLineage = Readonly<{ + fromGeneration: number; + toGeneration: number; + provenance: CodexRefreshProvenance; +}>; + +type ManualResetQuotaProof = CodexQuotaRecoveryProbeProof & { + refreshLineage?: ManualResetRefreshLineage; +}; + +/** Main proof is checked by the already-owned auth operation, never by a Pool record. */ +export function settleManualResetCooldown( + config: OcxConfig, + claim: ManualResetCooldownClaim, + recovered: boolean, + proof: ManualResetQuotaProof = {}, + now = Date.now(), +): boolean { + if (!recovered) return settleCooldownRecoveryLease(claim.probe, false, now); + const eligible = manualResetAccountEligible(config, claim.probe.accountId); + if (claim.kind === "main") return settleCooldownRecoveryLease(claim.probe, eligible, now); + const lineage = proof.refreshLineage; + // Equal wall-clock replacement stamps do not establish ancestry. Manual +1 + // recovery additionally needs the actual forced-refresh result for this edge. + const ownedGeneration = proof.credentialGeneration === claim.probe.credentialGeneration + || (proof.credentialGeneration === claim.probe.credentialGeneration + 1 + && lineage?.fromGeneration === claim.probe.credentialGeneration + && lineage.toGeneration === proof.credentialGeneration + && (lineage.provenance === "self-refresh" || lineage.provenance === "joined-lineage")); + return settleCodexQuotaRecoveryProbe(claim.probe, eligible && ownedGeneration, proof, now); +} + /** Settle one background recovery claim without mutating account-wide outcome state. */ export function settleCodexQuotaRecoveryProbe( claim: CodexQuotaRecoveryProbeClaim, @@ -685,9 +739,16 @@ export function settleCodexQuotaRecoveryProbe( : proofGeneration === claim.credentialGeneration + 1 && currentRecord?.replacedAt === claim.credentialReplacedAt && isCodexAccountGenerationLive(claim.accountId, proofGeneration)); - const fenced = (health.cooldownGeneration ?? 0) === claim.cooldownGeneration - && (health.probeLeaseGeneration ?? 0) === claim.cooldownGeneration - && generationFenced; + return settleCooldownRecoveryLease(claim, recovered && generationFenced, now); +} + +function settleCooldownRecoveryLease(claim: CooldownRecoveryLease, recovered: boolean, now: number): boolean { + const health = claim.scope ? scopedHealthFor(claim.accountId, claim.scope) : upstreamHealth.get(claim.accountId); + if (!health || health.probeLeaseId !== claim.leaseId) return false; + const fenced = (claim.scope === undefined || claim.scope === "shared") + && health.cooldownSource === "reset-derived" + && (health.cooldownGeneration ?? 0) === claim.cooldownGeneration + && (health.probeLeaseGeneration ?? 0) === claim.cooldownGeneration; if (!recovered || !fenced) { const released = withProbeLeaseReleased(health, now); if (claim.scope) setScopedHealth(claim.accountId, claim.scope, released); @@ -1653,26 +1714,24 @@ function releaseDrainedCodexAccountPin( "nativeMainSelectionOnly" | "isMainAccountTokenLive" >, now: number = Date.now(), - persist = true, -): boolean { +): void { const pinned = pinnedCodexAccountId(config); - if (pinned === undefined) return false; + if (pinned === undefined) return; const knownUnavailable = isAccountNeedsReauth(pinned) || isCodexAccountPaused(config, pinned); if (knownUnavailable) { clearCodexAccountPin(config); - if (persist) saveConfigPreservingClaudeCode(config); - return true; + saveConfigPreservingClaudeCode(config); + return; } // Temporary drain deliberately forbids every native-main read. A pin on main // cannot be classified by credential liveness or quota until the fenced profile // is readable. Cached reauth and configured pause state were handled above. - if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return false; + if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return; const drained = !isCodexAccountUsable(config, pinned, selectionOptions) || !hasCodexQuotaHeadroom(config, pinned, selectionOptions, now); - if (!drained) return false; + if (!drained) return; clearCodexAccountPin(config); - if (persist) saveConfigPreservingClaudeCode(config); - return true; + saveConfigPreservingClaudeCode(config); } function applyQuotaAutoSwitch( @@ -1968,15 +2027,6 @@ export function resolveCodexAccountForThreadDetailed( selectionOptions?: CodexAccountUsabilityOptions, modelId?: string, ): CodexThreadResolution { - const persistedActiveBeforePinRelease = config.activeCodexAccountId; - const releasedDrainedPin = !isIndependentCodexQuotaScope(quotaScope) - && releaseDrainedCodexAccountPin( - config, - sharedStateSelectionOptions(selectionOptions), - now, - false, - ); - try { // An entitlement roster constrains only this model request. It must not rewrite // the operator's shared active/pin choice or the task's ordinary-model affinity. const modelScopedSelection = selectionOptions?.modelEligibleAccountIds !== undefined; @@ -1988,6 +2038,9 @@ export function resolveCodexAccountForThreadDetailed( // keeps its account below, but the operator's tier ceiling must not silently // revive after quota resets. Independent model scopes must never persist a // change to shared routing state. + if (!isIndependentCodexQuotaScope(quotaScope)) { + releaseDrainedCodexAccountPin(config, sharedStateSelectionOptions(selectionOptions), now); + } const sharedActiveBeforeSelection = getEffectiveActiveCodexAccountId(config); const preserveSharedSelectionForModelDetour = modelScopedSelection && ( sharedActiveBeforeSelection === undefined @@ -2222,13 +2275,6 @@ export function resolveCodexAccountForThreadDetailed( } } return { status: "selected", accountId: active }; - } finally { - // A later quota/failure promotion persists both mutations in one write. If routing kept the - // same persisted account, this deferred write is the sole pin retirement write. - if (releasedDrainedPin && config.activeCodexAccountId === persistedActiveBeforePinRelease) { - saveConfigPreservingClaudeCode(config); - } - } } export function recordCodexUpstreamOutcome( diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 51150e6aa7..9a12395808 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -86,6 +86,8 @@ export interface PersistedCodexRuntimeState { const PERSIST_FILE = "codex-runtime.json"; const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json"; +/** Probe rejection for an absolute candidate whose file is gone. Matched when retiring a dead pin (#4035). */ +const PATH_MISSING_REASON = "path does not exist"; function cloneAndDeepFreeze<T>(value: T): DeepReadonly<T> { const clone = (current: unknown): unknown => { @@ -283,6 +285,31 @@ export function persistCodexRuntime( atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`); } +/** + * Delete `codex-runtime.json`. Used to retire a pin whose path no longer exists, so a + * later resolve stops re-probing it (#4035). + * + * Invalidates the process resolve memo the same way `persistCodexRuntime` does: the memo + * folds the persisted `updatedAt` into its key, and a removed file has no stamp to fold. + */ +export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): void { + const configDir = deps.configDir ?? getConfigDir(); + clearCodexRuntimeResolveCache(); + try { + unlinkSync(codexRuntimeStatePath(configDir)); + } catch (error) { + // An already-missing file is the success case: the pin is gone, which is the point. + // Anything else means the pin SURVIVES and stays authoritative, so every later + // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once. + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "ENOENT") return; + console.warn( + `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}` + + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`, + ); + } +} + function probeVersion( command: string, deps: ResolveCodexRuntimeDeps, @@ -290,7 +317,7 @@ function probeVersion( const platform = deps.platform ?? process.platform; if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) { const exists = deps.existsSync ?? existsSync; - if (!exists(command)) return { ok: false, reason: "path does not exist" }; + if (!exists(command)) return { ok: false, reason: PATH_MISSING_REASON }; if (!isSpawnableCodexCandidate(command, platform)) { return { ok: false, reason: "not a spawnable Codex launcher on this platform" }; } @@ -654,6 +681,23 @@ export function resolveAndPersistCodexRuntime( return cloneAndDeepFreeze({ ...result, persistError }); } } + // A pin whose path has vanished must be RETIRED, not merely skipped. A Codex App update + // replaces the hashed plugin directory the pin names, the probe rejects it with + // "path does not exist", nothing else resolves, and the selection degrades to `fallback` — + // which the write guard above declines. The dead entry then survived every later resolve + // and each one re-probed a path that cannot exist (#4035). Bound narrowly: only when the + // degraded result is `fallback`, only for the persisted command, and only for the + // path-does-not-exist rejection, so a present-but-unusable binary is left for the operator. + else if (result.runtime.source === "fallback" && persistedRuntime?.command) { + const pinVanished = result.failures.some( + // Exact comparison, not `sameRuntimeCommand`: that helper lowercases, and on a + // case-sensitive filesystem `/plugins/Codex` and `/plugins/codex` are different + // files. A missing lowercase path must not retire a live uppercase pin. + failure => failure.command.trim() === persistedRuntime.command.trim() + && failure.reason === PATH_MISSING_REASON, + ); + if (pinVanished) clearPersistedCodexRuntime(deps); + } return result; } diff --git a/src/codex/shim.ts b/src/codex/shim.ts index a63c48fb7a..57f46cfb0d 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1069,6 +1069,7 @@ export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cl const valueOptionChecks = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => `if /I "%~1"=="${option}" goto skip_option_value`).join("\r\n"); return `@echo off\r rem ${SHIM_MARKER}\r +setlocal\r ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r ${windowsBatchSet("OCX_BUN", bunPath)}\r ${windowsBatchSet("OCX_CLI", cliPath)}\r @@ -1115,6 +1116,9 @@ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: const tokenFile = serviceApiTokenFilePath(); return `#!/usr/bin/env pwsh # ${SHIM_MARKER} +$hadApiAuthToken = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN +$priorApiAuthToken = $env:OPENCODEX_API_AUTH_TOKEN +try { if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) { $env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim() } @@ -1147,7 +1151,12 @@ if (-not $skipEnsure) { } } & ${psString(realCodexPath)} @args -exit $LASTEXITCODE +$codexExitCode = $LASTEXITCODE +} finally { + if ($hadApiAuthToken) { $env:OPENCODEX_API_AUTH_TOKEN = $priorApiAuthToken } + else { Remove-Item Env:\\OPENCODEX_API_AUTH_TOKEN -ErrorAction SilentlyContinue } +} +exit $codexExitCode `; } diff --git a/src/config.ts b/src/config.ts index 6ebc3bd5aa..d74faec061 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1241,6 +1241,7 @@ const configSchema = z.object({ ]).optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), codexDesktopAuthless: z.boolean().optional().catch(undefined), + codexClientCompaction: z.boolean().optional().catch(undefined), pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined), codexAccountNamespaces: codexAccountNamespacesSchema.optional(), diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 0ec0831c4c..69bc112146 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -1,7 +1,6 @@ import { chmodSync, closeSync, - constants, fchmodSync, fstatSync, lstatSync, @@ -121,7 +120,7 @@ function writePrivateTempFile( timeoutMemoKey: string, onCreated: () => void, ): void { - const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + const descriptor = openSync(path, "wx", 0o600); onCreated(); try { if (process.platform === "win32") { @@ -142,7 +141,7 @@ async function writePrivateTempFileAsync( timeoutMemoKey: string, onCreated: () => void, ): Promise<void> { - const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + const descriptor = openSync(path, "wx", 0o600); onCreated(); try { if (process.platform === "win32") { diff --git a/src/config/initialize.ts b/src/config/initialize.ts index 0ee8e0d56b..1e95c63595 100644 --- a/src/config/initialize.ts +++ b/src/config/initialize.ts @@ -1,5 +1,5 @@ import { - closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, + closeSync, fchmodSync, fstatSync, linkSync, lstatSync, openSync, unlinkSync, writeFileSync, } from "node:fs"; import { dirname } from "node:path"; @@ -15,10 +15,12 @@ export class InitialConfigPublicationError extends Error { readonly publication: PublicationState, readonly residualTemp: boolean, readonly hardLinkUnavailable: boolean, - options?: ErrorOptions, + options?: ErrorOptions & { hardeningFailed?: boolean }, ) { - super(hardLinkUnavailable - ? "Initial config requires hard-link publication; the filesystem or its permissions denied it." + super(options?.hardeningFailed + ? "Initial config permissions could not be secured. Choose an OPENCODEX_HOME location that supports private file permissions (NTFS ACLs on Windows), then rerun `ocx init`." + : hardLinkUnavailable + ? "Initial config requires hard-link publication; the filesystem or its permissions denied it. Inspect the config directory before retrying. Choose an OPENCODEX_HOME location that supports hard links and private file permissions, then rerun `ocx init`." : "Initial config publication did not finish.", options); this.name = "InitialConfigPublicationError"; } @@ -104,10 +106,13 @@ export function publishInitialConfigNoReplace( let failure: unknown; let failed = false; let hardLinkUnavailable = false; + let hardeningFailed = false; let residualTemp = false; try { - fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + fd = openSync(temp, "wx", 0o600); + hardeningFailed = true; (io.harden ?? hardenInitialConfig)(fd, temp, target); + hardeningFailed = false; verifyPrivateTemp(fd, temp); (io.write ?? ((descriptor: number, value: string) => writeFileSync(descriptor, value, { encoding: "utf8" })))(fd, bytes); verifyPrivateTemp(fd, temp); @@ -143,7 +148,7 @@ export function publishInitialConfigNoReplace( } } if (failed || residualTemp) { - throw new InitialConfigPublicationError(publication, residualTemp, hardLinkUnavailable, { cause: failure }); + throw new InitialConfigPublicationError(publication, residualTemp, hardLinkUnavailable, { cause: failure, hardeningFailed }); } return !collided; } diff --git a/src/images/loop.ts b/src/images/loop.ts index f3db122b54..0ea4f46d95 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -542,6 +542,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons // hop-by-hop header alone (oven-sh/bun#20492). return requestFetch(request.url, applyUpstreamRecoveryInit({ method: request.method, + redirect: "manual", headers: h, body: request.body, signal: headerDeadline.signal, diff --git a/src/images/xai-video-client.ts b/src/images/xai-video-client.ts index 1a98433414..ec9ea013ba 100644 --- a/src/images/xai-video-client.ts +++ b/src/images/xai-video-client.ts @@ -80,6 +80,7 @@ export async function submitVideoJob( const resp = await fetch(`${auth.baseUrl}/videos/generations`, { method: "POST", + redirect: "manual", headers: { "Authorization": `Bearer ${auth.token}`, "Content-Type": "application/json", @@ -118,6 +119,7 @@ export async function pollVideoJob( const resp = await fetch(`${auth.baseUrl}/videos/${encodeURIComponent(requestId)}`, { method: "GET", + redirect: "manual", headers: { "Authorization": `Bearer ${auth.token}`, }, diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index f5780f4f98..8d67ac83a1 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -190,6 +190,7 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS id: "hermes", configPath: (env = process.env, home = homedir()) => hermesConfigPath(env, home), detectDir: (env = process.env, home = homedir()) => hermesHomeDir(env, home), + sourcePreservingYaml: { path: ["providers", "opencodex"] }, }, openclaw: { id: "openclaw", diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index feae55db68..0fd78d8bb6 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -66,12 +66,30 @@ export function gracefulStopHost(hostname: string | undefined): string { } /** - * Outcome of a graceful stop attempt. `"refused"` is distinct from failure: the proxy answered - * that it must NOT be stopped from here, so callers must not escalate to a forced kill. + * `"refused"` forbids forced stop. `"teardown-unconfirmed"` means the process exited, + * but its assigned shared teardown was not confirmed; callers must not kill it again. */ -export type GracefulStopResult = boolean | "refused"; +export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed"; -/** A proxy declined shutdown because a service under another home owns it (HTTP 409). */ +/** + * The server's own explanation for the most recent 409, captured so `stopProxy` can report + * the real reason. There is more than one: a scheduler wrapper under another home, or the + * proxy being the installed service itself (#4023). Module-scoped because + * `GracefulStopResult` is a public contract with several callers, and widening it to carry + * the text would change every one of them for a message only this file reports. + */ +let lastRefusalMessage: string | null = null; + +/** The server's explanation for the most recent 409, or `null` when it sent none. */ +export function lastStopRefusalMessage(): string | null { + return lastRefusalMessage; +} + +/** + * A proxy declined shutdown (HTTP 409). There is more than one reason it can say no — a + * scheduler wrapper under another home, or the proxy being the installed service itself + * (#4023) — so the server's own message is carried through rather than guessed at. + */ export class ProxyOwnershipRefusedError extends Error {} /** @@ -82,6 +100,8 @@ export class ProxyOwnershipRefusedError extends Error {} * chance to run its shutdown handlers. Returns false when the proxy can't be reached * or doesn't exit in time — callers fall back to {@link killProxy}. Returns `"refused"` * when the proxy declines the stop (HTTP 409), which callers must NOT force past. + * True requires the expected shared-teardown response and an observed exit. It does not + * attest the process exit code or completion of every drain/shutdown hook. */ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> { const readRuntime = io.readRuntime ?? readRuntimePort; @@ -92,6 +112,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv); if (token) headers["x-opencodex-api-key"] = token; const fetchFn = io.fetchFn ?? fetch; + let sharedTeardownConfirmed = false; try { // `ocx stop` asks the proxy NOT to restore shared client config: it does that itself, // after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting @@ -113,8 +134,23 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): // would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to // SIGTERM here would run the daemon's cleanup and strip shared config out from under the // still-running service. Report the refusal instead of forcing. - if (res.status === 409) return "refused"; + if (res.status === 409) { + lastRefusalMessage = await res.json() + .then(body => { + const message = (body as { message?: unknown } | null)?.message; + return typeof message === "string" && message.trim() ? message.trim() : null; + }) + .catch(() => null); + return "refused"; + } if (!res.ok) return false; + const body: unknown = await res.json().catch(() => null); + const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed"; + sharedTeardownConfirmed = body !== null + && typeof body === "object" + && !Array.isArray(body) + && "success" in body && body.success === true + && "sharedTeardown" in body && body.sharedTeardown === expectedTeardown; } catch { return false; } @@ -122,7 +158,8 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): // Honor the server's own drain window: /api/stop answers 200 first, then drains for // config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain. const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs(); - return waitExit(pid, exitTimeoutMs); + if (!waitExit(pid, exitTimeoutMs)) return false; + return sharedTeardownConfirmed ? true : "teardown-unconfirmed"; } function drainDeadlineMs(): number { @@ -142,10 +179,17 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<b // The proxy refused on purpose (foreign service owns it). Forcing would strip shared // config while that service keeps the proxy alive. throw new ProxyOwnershipRefusedError( - "The running proxy refused to stop: a service installed under a different " - + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.", + lastRefusalMessage + ?? "The running proxy refused to stop: a service installed under a different " + + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.", ); } + if (graceful === "teardown-unconfirmed") { + // Exit was observed, so do not enter the forced-stop fallback. Returning false keeps + // shared restoration with `ocx stop` instead of claiming that the proxy completed it. + await waitForStoppedPort(runtime, pid); + return false; + } if (graceful) { await waitForStoppedPort(runtime, pid); return true; diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 433a8989b4..13a22bfce0 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -21,6 +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 { listLiveComboTargetKeys } from "../combos/types"; import { listLiveConfigOwnershipRoots, @@ -111,6 +112,7 @@ 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: "guardian-backoff", reconcileGeneration: reconcileGuardianBackoff }, { name: "codex-reauth", reconcileGeneration: reconcileCodexReauthState }, { name: "oauth-reauth", reconcileGeneration: reconcileOAuthReauthState }, diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 4d1097be0a..596a42c495 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -226,6 +226,7 @@ export async function fetchWithAttemptDeadline( return await executor(url, { ...init, headers, + redirect: "manual", signal: attemptTimeout.signal, }); } finally { diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index 1ae616a005..2dcddd1811 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -41,6 +41,62 @@ export function extractAccountId(idToken?: string, accessToken?: string): string return undefined; } +/** + * Three-way answer to "is this token marked as belonging to the ChatGPT account domain". + * Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the + * https://api.openai.com/auth namespace claim. A generic organizations claim is NOT domain + * evidence. JWT claims are decoded locally as routing markers, never as authenticity proof. + * + * absent — no JWT, a payload that is not a JSON object, or an object carrying neither + * marker key: the token may be a foreign credential and legacy foreign handling + * applies. This function is total; it never throws on an attacker-shaped token. + * invalid — a marker key is present but yields no usable account id (non-string, blank, + * namespace that is not an object, namespace without the claim) or the two + * markers disagree. Presence is decided by the KEY, not by its shape, so a token + * that claims this domain can never fall through to foreign handling just + * because its marker is malformed. + * valid — one consistent, non-blank ChatGPT account id. + */ +export type ChatGptDomainClaim = + | { kind: "absent" } + | { kind: "invalid" } + | { kind: "valid"; accountId: string }; + +const CHATGPT_AUTH_NAMESPACE = "https://api.openai.com/auth"; + +/** A usable account id is a non-blank string; blank or non-string values are malformed. */ +function usableAccountId(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim { + const payload: unknown = decodeJwtPayload(token); + // decodeJwtPayload returns whatever the payload segment parses to, which may be a + // primitive or an array. Those carry no marker and must not reach the key lookups, + // where `in`/hasOwn would throw and take the whole request down. + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { kind: "absent" }; + const claims = payload as Record<string, unknown>; + // Presence is the KEY being there, as an own key. A reserved namespace that is null, a + // primitive, an array, or an object without the claim is a present-but-broken marker, so + // it stays invalid instead of being treated as a foreign token. + const topPresent = Object.hasOwn(claims, "chatgpt_account_id"); + const nsPresent = Object.hasOwn(claims, CHATGPT_AUTH_NAMESPACE); + if (!topPresent && !nsPresent) return { kind: "absent" }; + const topId = topPresent ? usableAccountId(claims.chatgpt_account_id) : undefined; + if (topPresent && !topId) return { kind: "invalid" }; + let nsId: string | undefined; + if (nsPresent) { + const ns = claims[CHATGPT_AUTH_NAMESPACE]; + const nsObj = ns !== null && typeof ns === "object" && !Array.isArray(ns) + ? ns as Record<string, unknown> : undefined; + nsId = nsObj ? usableAccountId(nsObj.chatgpt_account_id) : undefined; + if (!nsId) return { kind: "invalid" }; + } + if (topId && nsId && topId !== nsId) return { kind: "invalid" }; + const accountId = topId ?? nsId; + return accountId ? { kind: "valid", accountId } : { kind: "invalid" }; +} + export function extractEmail(idToken?: string, accessToken?: string): string | undefined { for (const token of [idToken, accessToken]) { if (!token) continue; @@ -51,6 +107,33 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u return undefined; } +/** + * Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the + * existing extractAccountId precedence (top-level, then namespaced, then organizations[0]). + * `conflict` is true only when the two chatgpt_account_id encodings are both present and + * disagree — organizations entries are workspace memberships, not identity, so they never + * participate. Never logs token material. + */ +export function extractAccountIdClaims(token?: string): { accountId: string | undefined; conflict: boolean } { + if (!token) return { accountId: undefined, conflict: false }; + const payload = decodeJwtPayload(token); + if (!payload) return { accountId: undefined, conflict: false }; + const top = typeof payload.chatgpt_account_id === "string" ? payload.chatgpt_account_id : undefined; + const ns = payload["https://api.openai.com/auth"]; + const namespaced = ns && typeof ns === "object" + && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string" + ? (ns as Record<string, unknown>).chatgpt_account_id as string + : undefined; + const orgs = payload.organizations; + const org = Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string" + ? orgs[0].id as string + : undefined; + return { + accountId: top ?? namespaced ?? org, + conflict: top !== undefined && namespaced !== undefined && top !== namespaced, + }; +} + export function credsFromToken(data: Record<string, unknown>): OAuthCredentials { const idToken = typeof data.id_token === "string" ? data.id_token : undefined; // This parses a response from an external boundary, so the access token is diff --git a/src/oauth/xai.ts b/src/oauth/xai.ts index 3bdb5774d8..f1669c7576 100644 --- a/src/oauth/xai.ts +++ b/src/oauth/xai.ts @@ -1,8 +1,8 @@ /** xAI OAuth flow (Grok account login). Ported from jawcode oauth/xai.ts. */ +import { abortError, sleepWithAbort } from "../lib/upstream-retry"; import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; import { generatePKCE } from "./pkce"; import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types"; -import { oauthFetch } from "./transport"; const XAI_OAUTH_ISSUER = "https://auth.x.ai"; export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`; @@ -12,6 +12,9 @@ const XAI_OAUTH_CALLBACK_PORT = 56121; const XAI_OAUTH_CALLBACK_PATH = "/callback"; const XAI_OAUTH_REFRESH_SKEW_MS = 2 * 60 * 1000; const TOKEN_REQUEST_TIMEOUT_MS = 30_000; +const RETRY_AFTER_MAX_DELAY_MS = 60_000; +const JITTER_DELAY_CAP_MS = 2_000; +const XAI_TRUSTED_AUTH_HOSTS = new Set(["auth.x.ai", "accounts.x.ai"]); export const XAI_LOCAL_CLI_DETACH_WARNING = "[oauth:xai] Grok CLI credential was stale; refreshed into OpenCodex ownership. Grok CLI may require login again."; @@ -46,21 +49,32 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal { } function validateXaiEndpoint(rawUrl: string): string { - const parsed = new URL(rawUrl); + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error("xAI OAuth discovery returned an unparseable endpoint URL"); + } const host = parsed.hostname.toLowerCase(); - if (parsed.protocol !== "https:" || (host !== "x.ai" && !host.endsWith(".x.ai"))) { - throw new Error(`xAI OAuth discovery returned an unexpected endpoint: ${rawUrl}`); + if ( + parsed.protocol !== "https:" + || parsed.username !== "" + || parsed.password !== "" + || parsed.port !== "" + || !XAI_TRUSTED_AUTH_HOSTS.has(host) + ) { + throw new Error(`xAI OAuth discovery returned an unexpected endpoint (host: ${host || "none"})`); } return parsed.toString(); } export async function discoverXaiOAuthEndpoints(signal?: AbortSignal): Promise<XaiDiscovery> { - const response = await oauthFetch(XAI_OAUTH_DISCOVERY_URL, { + const response = await fetch(XAI_OAUTH_DISCOVERY_URL, { headers: { Accept: "application/json" }, signal: requestSignal(signal), }); if (!response.ok) { - throw new Error(`xAI OAuth discovery failed: ${response.status}`); + throw new Error(`xAI OAuth discovery failed: ${response.status} ${await response.text()}`); } const payload = (await response.json()) as XaiDiscoveryPayload; @@ -95,16 +109,136 @@ function getTokenIdentity(accessToken: string, idToken: string | undefined): { a export class XaiTokenRequestError extends Error { constructor(public readonly status?:number,public readonly oauthError?:string,message="xAI token request failed",options?:{cause?:unknown}){super(message,options);this.name="XaiTokenRequestError";} } export interface XaiTokenRetryDeps { sleep?:(ms:number)=>Promise<void>; random?:()=>number } -function isAbortError(error:unknown):boolean{return error instanceof DOMException&&error.name==="AbortError";} -function retryDelay(attempt:number,retryAfter:string|null,random:()=>number):number{const base=attempt===1?100:250,j=Math.round(base*(.75+random()*.5)),seconds=retryAfter!==null&&/^\d+$/.test(retryAfter)?Number(retryAfter):0;return Math.min(2000,Math.max(j,seconds*1000));} -async function readTokenError(response:Response):Promise<XaiTokenRequestError>{let oauthError:string|undefined;try{const body=await response.json() as {error?:unknown};if(typeof body.error==="string"&&/^(?:access_denied|expired_token|invalid_client|invalid_grant|invalid_request|temporarily_unavailable|unauthorized_client|unsupported_grant_type)$/.test(body.error))oauthError=body.error;}catch{oauthError=undefined;}return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}`);} +const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; +const RFC850_DATE_RE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; +const ASCTIME_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/i; +const HTTP_MONTH_INDEX: Record<string, number> = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +function parseUtcDateParts( + year: number, + monthName: string, + day: number, + hour: number, + minute: number, + second: number, +): number | undefined { + const month = HTTP_MONTH_INDEX[monthName.toLowerCase()]; + if (month === undefined) return undefined; + const timestamp = Date.UTC(year, month, day, hour, minute, second); + const parsed = new Date(timestamp); + return parsed.getUTCFullYear() === year + && parsed.getUTCMonth() === month + && parsed.getUTCDate() === day + && parsed.getUTCHours() === hour + && parsed.getUTCMinutes() === minute + && parsed.getUTCSeconds() === second + ? timestamp + : undefined; +} + +function parseHttpDateMs(value: string, now: number): number | undefined { + const match = IMF_FIXDATE_RE.exec(value); + if (match) { + return parseUtcDateParts( + Number(match[3]), match[2]!, Number(match[1]), + Number(match[4]), Number(match[5]), Number(match[6]), + ); + } + const rfc850 = RFC850_DATE_RE.exec(value); + if (rfc850) { + // Two-digit years more than 50 years in the future are in the past (RFC 9110). + const currentYear = new Date(now).getUTCFullYear(); + let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[3]); + const candidateTimeOfYear = Date.UTC( + 2000, HTTP_MONTH_INDEX[rfc850[2]!.toLowerCase()]!, Number(rfc850[1]), + Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]), + ); + const current = new Date(now); + const currentTimeOfYear = Date.UTC( + 2000, current.getUTCMonth(), current.getUTCDate(), + current.getUTCHours(), current.getUTCMinutes(), current.getUTCSeconds(), + current.getUTCMilliseconds(), + ); + const yearDelta = year - currentYear; + if (yearDelta < -50 || (yearDelta === -50 && candidateTimeOfYear < currentTimeOfYear)) { + year += 100; + } else if (yearDelta > 50 || (yearDelta === 50 && candidateTimeOfYear > currentTimeOfYear)) { + year -= 100; + } + return parseUtcDateParts( + year, rfc850[2]!, Number(rfc850[1]), + Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]), + ); + } + const asctime = ASCTIME_DATE_RE.exec(value); + if (!asctime) return undefined; + return parseUtcDateParts( + Number(asctime[6]), asctime[1]!, Number(asctime[2]), + Number(asctime[3]), Number(asctime[4]), Number(asctime[5]), + ); +} + +function parseRetryAfterMs(retryAfter: string | null): number | undefined { + const text = retryAfter?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const ms = Math.ceil(Number(text) * 1000); + return ms > 0 ? ms : undefined; + } + const now = Date.now(); + const timestamp = parseHttpDateMs(text, now); + if (timestamp === undefined) return undefined; + const delay = timestamp - now; + return delay > 0 ? delay : undefined; +} + +function jitterDelay(attempt: number, random: () => number): number { + const base = attempt === 1 ? 100 : 250; + return Math.min(JITTER_DELAY_CAP_MS, Math.round(base * (0.75 + random() * 0.5))); +} + +/** + * Delay before the next attempt, or undefined when the server asked for a wait + * beyond the retry budget — retrying earlier than Retry-After would hammer the + * token endpoint, so the caller fails the request instead of clamping. + */ +function retryDelay(attempt: number, retryAfter: string | null, random: () => number): number | undefined { + const serverMs = parseRetryAfterMs(retryAfter); + if (serverMs === undefined) return jitterDelay(attempt, random); + return serverMs <= RETRY_AFTER_MAX_DELAY_MS ? serverMs : undefined; +} + +async function sleepAbortable( + ms: number, + sleep: (ms: number) => Promise<void>, + signal: AbortSignal | undefined, +): Promise<void> { + if (!signal) return sleep(ms); + if (signal.aborted) throw abortError(signal); + let onAbort!: () => void; + try { + await Promise.race([ + sleep(ms), + new Promise<never>((_, reject) => { + onAbort = () => reject(abortError(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} +async function readTokenError(response:Response):Promise<XaiTokenRequestError>{let oauthError:string|undefined,detail="";try{const body=await response.json() as {error?:unknown;error_description?:unknown};if(typeof body.error==="string")oauthError=body.error;if(typeof body.error_description==="string")detail=body.error_description;}catch{/* non-JSON error body: fall through to the generic message */}const suffix=detail?`: ${detail}`:oauthError?`: ${oauthError}`:"";return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}${suffix}`);} export async function postXaiToken( tokenEndpoint: string, body: Record<string, string>, signal?: AbortSignal, deps:XaiTokenRetryDeps={}, ): Promise<XaiTokenPayload> { - const sleep=deps.sleep??(ms=>Bun.sleep(ms)),random=deps.random??Math.random;let last:unknown; - for(let attempt=1;attempt<=3;attempt++){let response:Response;try{response=await oauthFetch(tokenEndpoint, { + const sleep=deps.sleep??((ms:number)=>sleepWithAbort(ms,signal)),random=deps.random??Math.random;let last:unknown; + for(let attempt=1;attempt<=3;attempt++){let response:Response;try{response=await fetch(tokenEndpoint, { method: "POST", headers: { Accept: "application/json", @@ -112,7 +246,15 @@ export async function postXaiToken( }, body: new URLSearchParams(body).toString(), signal: requestSignal(signal), - });}catch(error){if(isAbortError(error)&&signal?.aborted)throw error;last=error;if(attempt===3)throw new XaiTokenRequestError(undefined,undefined,"xAI token request failed: network error",{cause:error});await sleep(retryDelay(attempt,null,random));continue;}if(response.ok)return await response.json() as XaiTokenPayload;const error=await readTokenError(response);last=error;if(!(response.status===429||response.status>=500)||attempt===3)throw error;await sleep(retryDelay(attempt,response.headers.get("retry-after"),random));}throw last; + });}catch(error){ + if(signal?.aborted)throw error; + const name=(error as {name?:string}|undefined)?.name; + if(name==="AbortError"||name==="TimeoutError")throw error; + last=error; + if(attempt===3)throw new XaiTokenRequestError(undefined,undefined,"xAI token request failed: network error",{cause:error}); + await sleepAbortable(jitterDelay(attempt,random),sleep,signal); + continue; + }if(response.ok)return await response.json() as XaiTokenPayload;const error=await readTokenError(response);last=error;if(!(response.status===429||response.status>=500)||attempt===3)throw error;if(signal?.aborted)throw error;const delay=retryDelay(attempt,response.headers.get("retry-after"),random);if(delay===undefined)throw error;await sleepAbortable(delay,sleep,signal);}throw last; } function credentialsFromTokenPayload(payload: XaiTokenPayload, refreshFallback = ""): OAuthCredentials { diff --git a/src/providers/api-key-selection-capture.ts b/src/providers/api-key-selection-capture.ts new file mode 100644 index 0000000000..302f540969 --- /dev/null +++ b/src/providers/api-key-selection-capture.ts @@ -0,0 +1,10 @@ +import type { OcxProviderConfig } from "../types"; +import type { ProviderApiKeySelection } from "../types/provider"; + +export function captureProviderApiKeySelection(provider: OcxProviderConfig): ProviderApiKeySelection { + return { + entryId: provider.apiKeyPool?.find(entry => entry.key === provider.apiKey)?.id, + reference: provider.apiKey, + revision: provider.apiKeySelectionRevision, + }; +} diff --git a/src/providers/api-key-selection.ts b/src/providers/api-key-selection.ts index d132cdb488..8652672393 100644 --- a/src/providers/api-key-selection.ts +++ b/src/providers/api-key-selection.ts @@ -7,14 +7,9 @@ import type { ProviderApiKeySelection } from "../types/provider"; import { routedProviderConfig } from "../router"; import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport"; import { resolveProviderTransport, XAI_GROK_COMPATIBILITY, type OcxProviderTransport } from "./xai-transport"; +import { captureProviderApiKeySelection } from "./api-key-selection-capture"; -export function captureProviderApiKeySelection(provider: OcxProviderConfig): ProviderApiKeySelection { - return { - entryId: provider.apiKeyPool?.find(entry => entry.key === provider.apiKey)?.id, - reference: provider.apiKey, - revision: provider.apiKeySelectionRevision, - }; -} +export { captureProviderApiKeySelection } from "./api-key-selection-capture"; function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean { const current = captureProviderApiKeySelection(provider); diff --git a/src/providers/caller-authorization.ts b/src/providers/caller-authorization.ts new file mode 100644 index 0000000000..9ba8b193a5 --- /dev/null +++ b/src/providers/caller-authorization.ts @@ -0,0 +1,36 @@ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { inspectChatGptDomainClaim } from "../oauth/chatgpt"; +import { isProxyAdmissionSecret } from "../server/auth-cors"; +import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; + +/** The caller's own ChatGPT-domain credential as plain Direct forwarding would use it. */ +export type CallerDirectAuth = Readonly<{ authorization: string; chatgptAccountId?: string }>; + +/** Whether this transport can consume the request's Authorization as its upstream credential. */ +export function providerConsumesCallerAuthorization(provider: OcxProviderConfig): boolean { + return isCanonicalOpenAiForwardProvider(provider) + || (provider.adapter === "cursor" && provider.authMode !== "oauth" && !provider.apiKey?.trim()); +} + +/** + * Capture the caller's Direct credential for a canonical-route restore after an internal + * rewrite. This restore is intentionally STRICTER than plain unchanged-route Direct + * forwarding: only a bearer with a VALID ChatGPT-domain claim qualifies (a clean single + * non-proxy JWT whose ChatGPT-specific account marker is well-formed and unambiguous, with + * any explicit account header matching it). An opaque bearer, a foreign JWT carrying only a + * generic organizations claim, and a ChatGPT-marked but malformed/conflicting token are all + * rejected: after a shadow/thread rewrite a self-asserted header cannot distinguish a + * caller-owned main credential from a foreign source-route token, so those cases stay + * fail-closed. Claims are decoded locally as routing markers, not authenticity proof, and + * unchanged-route Direct forwarding is governed by its own legacy rules. + */ +export function captureCallerDirectAuth(incomingHeaders: Headers, config: OcxConfig): CallerDirectAuth | null { + const raw = incomingHeaders.get("authorization")?.trim(); + const bearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(raw ?? "")?.[1]; + if (!bearer || isProxyAdmissionSecret(bearer, config)) return null; + const claim = inspectChatGptDomainClaim(bearer); + if (claim.kind !== "valid") return null; + const headerAccount = incomingHeaders.get("chatgpt-account-id")?.trim(); + if (headerAccount && headerAccount !== claim.accountId) return null; + return { authorization: `Bearer ${bearer}`, chatgptAccountId: claim.accountId }; +} diff --git a/src/providers/codebuddy-models.ts b/src/providers/codebuddy-models.ts new file mode 100644 index 0000000000..edd6a30415 --- /dev/null +++ b/src/providers/codebuddy-models.ts @@ -0,0 +1,184 @@ +/** + * Curated CodeBuddy model catalogs, transcribed from the OFFICIAL model manifest bundled with the + * vendor CLI (`@tencent-ai/codebuddy-code` v2.143.0: `product.json` for the global/`public` + * environment, `product.internal.json` for the China/`internal` environment) and cross-checked + * against the CLI's own `--model` accept-list. Verified 2026-09-03. + * + * Global and CN are deliberately NOT the same roster (§八). Context windows, output caps, vision + * and reasoning ladders are filled ONLY where the official manifest states them; a model with no + * published figure is omitted rather than guessed (§二十八/§二十九). CodeBuddy exposes no documented + * third-party live `/v1/models` endpoint, so these providers seed a static catalog + * (`liveModels: false`) exactly like the Kiro and Command Code entries. + */ + +/** Global (`public`) session models accepted by `codebuddy --model`. */ +export const CODEBUDDY_GLOBAL_MODELS = [ + "default-model", + "fast-model", + "balanced-model", + "primary-model", + "deep-model", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex", + "gemini-3.5-flash", + "glm-5.3", + "glm-5.2", + "kimi-k3", + "kimi-k2.6", + "minimax-m3", +]; + +/** China (`internal`) session models from the official internal manifest (text/chat models only). */ +export const CODEBUDDY_CN_MODELS = [ + "default", + "deepseek-v4-pro", + "deepseek-v4-flash", + "minimax-m3", + "minimax-m2.7", + "glm-5.2", + "glm-5.1", + "glm-5.0", + "glm-5.0-turbo", + "glm-5v-turbo", + "glm-4.7", + "kimi-k3-1", + "kimi-k2.7", + "kimi-k2.6", + "kimi-k2.5", + "deepseek-v3-2-volc", + "hy3", + "hunyuan-chat", +]; + +/** + * The CLI documents a single `--effort` ladder (minimal, low, medium, high, xhigh, max). The Codex + * reasoning ladder overlaps it at low..max; `minimal`/`none` are Codex sentinels normalized by + * `mapReasoningEffort`, and `ultra` folds to `max`. Declared provider-wide, then narrowed per model + * where the official manifest publishes a smaller `supportedEfforts`. + */ +export const CODEBUDDY_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + +export const CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS: Record<string, number> = { + "default-model": 176_000, + "fast-model": 200_000, + "balanced-model": 256_000, + "primary-model": 272_000, + "deep-model": 176_000, + "gpt-5.6-sol": 1_000_000, + "gpt-5.6-terra": 1_000_000, + "gpt-5.6-luna": 1_000_000, + "gpt-5.5": 1_000_000, + "gpt-5.4": 272_000, + "gpt-5.3-codex": 272_000, + "gemini-3.5-flash": 1_000_000, + "glm-5.3": 1_000_000, + "glm-5.2": 1_000_000, + "kimi-k3": 1_000_000, + "kimi-k2.6": 256_000, + "minimax-m3": 512_000, +}; + +export const CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = { + "default-model": 24_000, + "fast-model": 32_000, + "balanced-model": 32_000, + "primary-model": 72_000, + "deep-model": 24_000, + "gpt-5.6-sol": 128_000, + "gpt-5.6-terra": 128_000, + "gpt-5.6-luna": 128_000, + "gpt-5.5": 72_000, + "gpt-5.4": 128_000, + "gpt-5.3-codex": 128_000, + "gemini-3.5-flash": 65_536, + "glm-5.3": 48_000, + "glm-5.2": 48_000, + "kimi-k3": 32_000, + "kimi-k2.6": 32_000, + "minimax-m3": 128_000, +}; + +/** Per-model ladders narrowed from the official manifest's `reasoning.supportedEfforts`. */ +export const CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS: Record<string, string[]> = { + "gpt-5.6-sol": ["low", "medium", "high", "xhigh"], + "gpt-5.6-terra": ["low", "medium", "high", "xhigh"], + "gpt-5.6-luna": ["low", "medium", "high", "xhigh"], + "glm-5.3": ["low", "high", "max"], + "glm-5.2": ["high", "xhigh"], +}; + +export const CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS: Record<string, string> = { + "gpt-5.6-sol": "high", + "gpt-5.6-terra": "high", + "gpt-5.6-luna": "high", + "glm-5.3": "high", + "glm-5.2": "high", +}; + +export const CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS: Record<string, number> = { + "default": 200_000, + "deepseek-v4-pro": 1_000_000, + "deepseek-v4-flash": 1_000_000, + "minimax-m3": 512_000, + "minimax-m2.7": 200_000, + "glm-5.2": 1_000_000, + "glm-5.1": 200_000, + "glm-5.0": 200_000, + "glm-5.0-turbo": 200_000, + "glm-5v-turbo": 200_000, + "glm-4.7": 200_000, + "kimi-k3-1": 1_000_000, + "kimi-k2.7": 256_000, + "kimi-k2.6": 256_000, + "kimi-k2.5": 164_000, + "deepseek-v3-2-volc": 96_000, + "hy3": 192_000, + "hunyuan-chat": 200_000, +}; + +export const CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = { + "default": 24_000, + "deepseek-v4-pro": 50_000, + "deepseek-v4-flash": 50_000, + "minimax-m3": 128_000, + "minimax-m2.7": 48_000, + "glm-5.2": 48_000, + "glm-5.1": 48_000, + "glm-5.0": 48_000, + "glm-5.0-turbo": 48_000, + "glm-5v-turbo": 64_000, + "glm-4.7": 48_000, + "kimi-k3-1": 32_000, + "kimi-k2.7": 32_000, + "kimi-k2.6": 32_000, + "kimi-k2.5": 32_000, + "deepseek-v3-2-volc": 32_000, + "hy3": 64_000, + "hunyuan-chat": 8_192, +}; + +export const CODEBUDDY_CN_MODEL_REASONING_EFFORTS: Record<string, string[]> = { + "hy3": ["low", "high"], +}; + +export const CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS: Record<string, string> = { + "hy3": "high", +}; + +/** + * Text-only models (official manifest `supportsImages: false`). Images for any OTHER model are + * passed through natively; a model listed here has its images routed through the proxy's vision + * sidecar rather than being silently dropped (§二十九). + */ +export const CODEBUDDY_CN_NO_VISION_MODELS = [ + "default", + "glm-5.0", + "glm-5.0-turbo", + "glm-4.7", + "deepseek-v3-2-volc", + "hunyuan-chat", +]; diff --git a/src/providers/default-aliases.ts b/src/providers/default-aliases.ts index b11078bedc..dffbd9eb3c 100644 --- a/src/providers/default-aliases.ts +++ b/src/providers/default-aliases.ts @@ -15,7 +15,10 @@ export function effectiveProviderAlias( if (config?.providers) { const lower = regAlias.toLowerCase(); const claimedByOther = Object.entries(config.providers).some(([name, p]) => - name !== providerName && typeof p.alias === "string" && p.alias.trim().toLowerCase() === lower + name !== providerName && ( + name.toLowerCase() === lower + || (typeof p.alias === "string" && p.alias.trim().toLowerCase() === lower) + ) ); if (claimedByOther) return undefined; } diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 5de0edcfe3..7186367366 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -80,6 +80,10 @@ export interface DerivedProviderPreset { keyOptional?: boolean; /** Free pricing (may still require a key). */ freeTier?: boolean; + /** Sponsor tier from SPONSORS.md; the picker pins and labels these rows. */ + sponsor?: "main" | "standard"; + /** Sponsor landing URL (with its tracking parameters), for the picker's row link. */ + sponsorUrl?: string; /** * Endpoint picker rows (token plan / payg / custom). When present, the add-provider * form shows a dropdown; `custom` reveals a free-text base URL field. @@ -628,6 +632,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset { ...(entry.note ? { note: entry.note } : {}), ...(entry.keyOptional ? { keyOptional: true } : {}), ...(entry.freeTier ? { freeTier: true } : {}), + ...(entry.sponsor ? { sponsor: entry.sponsor.tier, sponsorUrl: entry.sponsor.url } : {}), ...(entry.baseUrlChoices ? { baseUrlChoices: entry.baseUrlChoices.map(c => ({ ...c })) } : {}), }; } diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts index ab6e9b2389..c378f16146 100644 --- a/src/providers/free-directory.ts +++ b/src/providers/free-directory.ts @@ -19,7 +19,7 @@ export const FREE_PROVIDER_ACCESS_GROUPS = { "recurring-credit": ["bytez", "nous-research"], "signup-credit": [ "agentrouter", "ai21", "baichuan", "baseten", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn", - "hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder", + "hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder", "qoder-cn", "scaleway", "sensenova", "stepfun", "together", "vertex", ], } as const satisfies Record<ProviderAccessGroup, readonly string[]>; @@ -140,6 +140,30 @@ const CONNECTABLE: Record<string, ConnectableOverride> = { nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.nscale.com/docs/use-cases/chat", modelsUrl: "https://inference.api.nscale.com/v1/models", lastVerified: "2026-08-03" }), nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), publicai: openAi("https://api.publicai.co/v1", "https://publicai.co"), + qoder: { + baseUrl: "https://qoder.com", + dashboardUrl: "https://qoder.com/account/integrations", + adapter: "qoder", + authKind: "key", + supportLevel: "supported", + verification: "official", + documentationUrl: "https://docs.qoder.com/cli/authentication", + discovery: "live", + liveModels: true, + lastVerified: "2026-09-03", + }, + "qoder-cn": { + baseUrl: "https://qoder.cn", + dashboardUrl: "https://qoder.cn/account/integrations", + adapter: "qoder", + authKind: "key", + supportLevel: "supported", + verification: "official", + documentationUrl: "https://docs.qoder.cn/en/cli/authentication", + discovery: "live", + liveModels: true, + lastVerified: "2026-09-03", + }, scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { supportLevel: "supported", verification: "official", documentationUrl: "https://www.scaleway.com/en/docs/generative-apis/api-cli/using-generative-apis/", modelsUrl: "https://api.scaleway.ai/v1/models", lastVerified: "2026-08-01" }), sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }), stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }), @@ -160,7 +184,7 @@ const LABELS: Record<string, string> = { ai21: "AI21", baichuan: "Baichuan", deepinfra: "DeepInfra", deepseek: "DeepSeek", doubao: "Doubao", "freemodel-dev": "FreeModel.dev", sambanova: "SambaNova Cloud", nebius: "Nebius Token Factory", novita: "Novita", nscale: "Nscale", nvidia: "NVIDIA NIM", - publicai: "PublicAI", qoder: "Qoder", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI", + publicai: "PublicAI", qoder: "Qoder", "qoder-cn": "Qoder CN", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI", }; const referenceNote = "Reference entry only: no safe documented API integration is enabled. Configure it manually only with provider documentation; consumer-web cookies and anti-bot bypasses are intentionally unsupported."; diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 8551aa6f97..3b98a2b521 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -10,7 +10,7 @@ import { type CodexAuthPolicyConfig, } from "../codex/auth-context"; import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome } from "../codex/routing"; -import { extractAccountId } from "../oauth/chatgpt"; +import { inspectChatGptDomainClaim } from "../oauth/chatgpt"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "../server/auth-cors"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { @@ -77,21 +77,45 @@ export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiFor }]; } -function directSidecarHeaders( - incomingHeaders: Headers, - config: CodexAuthPolicyConfig, - admission?: Pick<DataPlaneAdmission, "source">, -): Headers | undefined { - const bearer = incomingHeaders.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); - if (!bearer) return undefined; - const derivedAccountId = extractAccountId(undefined, bearer); - if (!derivedAccountId) return undefined; +/** An explicit caller bearer/account pair for canonical OpenAI destinations; never persist. */ +export type ExplicitOpenAiCallerAuth = Readonly<{ authorization: string; chatgptAccountId: string }>; + +function explicitSidecarAuth(incomingHeaders: Headers): ExplicitOpenAiCallerAuth | null { + // Combined Authorization values must not smuggle a second credential into a snapshot, + // and only a well-formed ChatGPT-specific account marker is domain evidence — a generic + // organizations claim is not. + const bearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(incomingHeaders.get("authorization")?.trim() ?? "")?.[1]; + if (!bearer) return null; + const claim = inspectChatGptDomainClaim(bearer); + if (claim.kind !== "valid") return null; + const derivedAccountId = claim.accountId; const requestedAccountId = incomingHeaders.get("chatgpt-account-id")?.trim(); // JWT payloads are decoded locally but not signature-verified. Requiring the caller's // explicit account header, and checking it against the token claim, makes forwarding an // intentional ChatGPT-auth operation instead of silently reclassifying any JWT-shaped // provider credential as a Codex bearer. - if (!requestedAccountId || requestedAccountId !== derivedAccountId) return undefined; + if (!requestedAccountId || requestedAccountId !== derivedAccountId) return null; + return { authorization: incomingHeaders.get("authorization")!, chatgptAccountId: requestedAccountId }; +} + +export function captureExplicitOpenAiCallerAuth(incomingHeaders: Headers, config: OcxConfig): ExplicitOpenAiCallerAuth | null { + const auth = explicitSidecarAuth(incomingHeaders); + if (!auth) return null; + try { + validateForwardAdmissionCredential(incomingHeaders, config); + } catch (error) { + if (error instanceof ForwardAdmissionCredentialError) return null; + throw error; + } + return auth; +} + +function directSidecarHeaders( + incomingHeaders: Headers, + config: CodexAuthPolicyConfig, + admission?: Pick<DataPlaneAdmission, "source">, +): Headers | undefined { + if (!explicitSidecarAuth(incomingHeaders)) return undefined; const selected = headersForCodexAuthContext(incomingHeaders, { kind: "main", accountId: null }, config, undefined, admission); return selected; } diff --git a/src/providers/qoder-models.ts b/src/providers/qoder-models.ts new file mode 100644 index 0000000000..0f8d8c4350 --- /dev/null +++ b/src/providers/qoder-models.ts @@ -0,0 +1,25 @@ +/** + * Cold-start fallback from the official Qoder Global model documentation, verified 2026-09-03. + * The account-specific `qoder --list-models` result is authoritative whenever discovery succeeds. + */ +export const QODER_GLOBAL_MODELS = [ + "Qwen3.8-Max", + "Qwen3.7-Max", + "Qwen3.7-Plus", + "Kimi-K3", + "Kimi-K2.7-Code", + "GLM-5.3", + "GLM-5.2", + "DeepSeek-V4-Pro", +] as const; + +/** Live Qoder CN roster captured from the official CLI on 2026-09-03. */ +export const QODER_CN_MODELS = [ + "Qwen3.8-Max", + "Qwen3.8-Flash", + "Qwen3.7-Max", + "Qwen3.7-Plus", + "Qwen3.7-Flash", +] as const; + +export const QODER_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 03049752d4..7388ff373e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -21,6 +21,21 @@ import { import { cursorFastCapableBases } from "../adapters/cursor/catalog"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; import { isCanonicalOpenRouterTarget } from "./openrouter-routing"; +import { + CODEBUDDY_CN_MODELS, + CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, + CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, + CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, + CODEBUDDY_CN_MODEL_REASONING_EFFORTS, + CODEBUDDY_CN_NO_VISION_MODELS, + CODEBUDDY_GLOBAL_MODELS, + CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, + CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, + CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, + CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, + CODEBUDDY_REASONING_EFFORTS, +} from "./codebuddy-models"; +import { QODER_CN_MODELS, QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "./qoder-models"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -160,6 +175,13 @@ export interface ProviderRegistryEntry { staticHeaders?: Record<string, string>; modelSuffixBracketStrip?: boolean; featured?: boolean; + /** + * Paid provider sponsorship under SPONSORS.md. `main` is reserved for model developers, + * `standard` for relays and gateways. The picker pins sponsor rows first (alphabetical among + * themselves) and labels them; nothing else reads this field. Routing, failover, quota, and + * defaults never consult it — that boundary is what SPONSORS.md promises users. + */ + sponsor?: { tier: "main" | "standard"; url: string }; dashboardPreset?: boolean; note?: string; dashboardUrl?: string; @@ -1540,8 +1562,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelDiscovery: { // Resolves against effectiveBaseUrl (registry baseUrl .../v1) to the same // canonical endpoint https://inference-api.nousresearch.com/v1/models. + // Nous returns a mixed paid/free catalog whose JSON can exceed 256 KiB; + // keep the provider-specific limit below the process-wide 4 MiB ceiling. path: "models", - maxResponseBytes: 262_144, + maxResponseBytes: 1_048_576, maxModels: 512, }, note: "Nous Research subscription gateway. OAuth device login with your own Portal account; mixed paid + :free models discovered live (fallback seed 2026-08-10: tencent/hy3:free, poolside/laguna-s-2.1:free, stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free).", @@ -1675,6 +1699,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Zen Go can close a Chat stream after a fully assembled function call without sending // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON. openaiChatEofTolerance: true, + // Go rejects reasoning.encrypted_content with previous_response_id (#3838). + // Use explicit replay history and the existing stateless Responses policy. + statelessResponses: true, /* [Decision Log] - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617). - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative. @@ -1899,6 +1926,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console", // The catalog is public, so a successful /models probe cannot validate a submitted key. apiKeyValidation: "unknown", + // Standard sponsor under SPONSORS.md (agreement signed 2026-09-07). Pins the row in the + // picker and adds the chip; nothing about routing or defaults changes. + sponsor: { tier: "standard", url: "https://www.orcarouter.ai/?utm_source=opencodex&utm_medium=readme" }, defaultModel: "openai/gpt-5.5", models: ORCAROUTER_MODELS, liveModels: true, @@ -1911,6 +1941,25 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS, note: "OpenAI-compatible adaptive router. Models and multimodal capabilities are discovered live from the public chat catalog. Use the OrcaRouter account entry for PKCE login.", }, + { + // PackyCode: API relay (packyapi.com) for Claude Code, Codex, Gemini and more. Codex traffic + // uses the OpenAI-compatible host from their Codex/Kimi Code guides (docs.packyapi.com): + // https://cf.api.fan/v1 — GET /v1/models answers 401 without a key, so the host is live and + // discovery narrows to what the key's token group allows. Model ids are bare OpenAI-style + // ids (the Codex token group lists gpt-5.5 / gpt-5.1-codex). + // Standard sponsor under SPONSORS.md; the dashboardUrl carries their affiliate code. + id: "packycode", label: "PackyCode", adapter: "openai-chat", baseUrl: "https://cf.api.fan/v1", + authKind: "key", dashboardUrl: "https://www.packyapi.com/register?aff=k5KT", + sponsor: { tier: "standard", url: "https://www.packyapi.com/register?aff=k5KT" }, + defaultModel: "gpt-5.5", + models: ["gpt-5.5", "gpt-5.1-codex"], + liveModels: true, + // New key preset: opt into collision preservation so a row named `packycode` that a user + // points at a different PackyCode host keeps its own destination instead of being pulled + // back onto the Codex endpoint below. + preserveCustomDestination: true, + note: "API relay for Claude Code, Codex, Gemini and more. Create a Codex-group token at packyapi.com; live discovery lists what the token group allows.", + }, { // BizRouter: Korean enterprise LLM gateway (api.bizrouter.ai). Model ids are // vendor-namespaced (`<vendor>/<model>`) and pass through to the upstream as-is. @@ -3155,6 +3204,101 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, // FREEZE 2026-07-10: no public OpenAI-compatible endpoint is documented. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, + { + // Official Qoder Global CLI automation surface. The canonical URL is an identity boundary; + // inference and model discovery are performed only by the installed vendor CLI. Authentication + // uses the documented PAT environment variable and never imports desktop/session credentials. + id: "qoder", + label: "Qoder (Global)", + adapter: "qoder", + baseUrl: "https://qoder.com", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://qoder.com/account/integrations", + defaultModel: "Qwen3.8-Max", + models: [...QODER_GLOBAL_MODELS], + liveModels: true, + reasoningEfforts: [...QODER_REASONING_EFFORTS], + noVisionModels: [...QODER_GLOBAL_MODELS], + note: "Official Qoder Global CLI using QODER_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qoder --list-models`; the documented roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qoder-ai/qodercli`.", + }, + { + // Qoder CN is a separate credential, executable, destination, entitlement cache, and health + // domain. It deliberately does not reuse the OAuth/private-protocol design from #3010. + id: "qoder-cn", + label: "Qoder CN", + adapter: "qoder", + baseUrl: "https://qoder.cn", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://qoder.cn/account/integrations", + defaultModel: "Qwen3.8-Max", + models: [...QODER_CN_MODELS], + liveModels: true, + reasoningEfforts: [...QODER_REASONING_EFFORTS], + noVisionModels: [...QODER_CN_MODELS], + note: "Official Qoder CN CLI using QODERCN_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qodercn --list-models`; the verified roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qodercn-ai/qoderclicn`.", + }, + { + // Official CodeBuddy Code CLI provider (Tencent Cloud), GLOBAL / `public` environment. + // Transport is the vendor-documented headless CLI automation surface + // (`codebuddy -p --output-format stream-json --tools ""`) authenticated with the official + // `CODEBUDDY_API_KEY` (https://www.codebuddy.ai/profile/keys). It does NOT read desktop + // session files, import desktop bearer tokens, impersonate the desktop client, or call the + // private console endpoint — the approach closed in #687 and left in draft in #2244. + // baseUrl is the canonical region identity: the adapter fails closed if it is overridden, so a + // global key is never sent to the CN environment (that is the separate `codebuddy-cn` entry). + // v1 runs tools-disabled so Codex keeps tool ownership; this provider is text/reasoning only + // until the control-protocol tool bridge lands (see docs). Free/trial/promotional/subscription + // credits draw from the same official API-key pool. Requires the CLI: `npm i -g @tencent-ai/codebuddy-code`. + // GOVERNANCE: whether routing this vendor automation surface behind a proxy for a third-party + // agent satisfies CodeBuddy's AUP is an open question flagged for maintainer security review. + id: "codebuddy", + label: "CodeBuddy (Global)", + adapter: "codebuddy", + baseUrl: "https://www.codebuddy.ai", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://www.codebuddy.ai/profile/keys", + defaultModel: "default-model", + models: CODEBUDDY_GLOBAL_MODELS, + liveModels: false, + modelContextWindows: CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, + modelMaxOutputTokens: CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, + defaultMaxOutputTokens: 32_000, + reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, + modelReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, + modelDefaultReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, + note: "Official CodeBuddy Code CLI (Tencent Cloud), global/public environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy-cn. v1 disables CLI tools (--tools \"\") so Codex retains tool ownership: text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", + }, + { + // Official CodeBuddy Code CLI provider, CHINA / `internal` environment. Identical adapter and + // binary as `codebuddy`; the region is fixed by the profile's CODEBUDDY_INTERNET_ENVIRONMENT + // and this canonical baseUrl. CN key: https://copilot.tencent.com/profile/keys. The CN model + // roster differs from Global (see codebuddy-models.ts) and is seeded separately (§八). + id: "codebuddy-cn", + label: "CodeBuddy (CN)", + adapter: "codebuddy", + baseUrl: "https://www.codebuddy.cn", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://copilot.tencent.com/profile/keys", + defaultModel: "default", + models: CODEBUDDY_CN_MODELS, + liveModels: false, + modelContextWindows: CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, + modelMaxOutputTokens: CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, + defaultMaxOutputTokens: 32_000, + reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, + modelReasoningEfforts: CODEBUDDY_CN_MODEL_REASONING_EFFORTS, + modelDefaultReasoningEfforts: CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, + noVisionModels: CODEBUDDY_CN_NO_VISION_MODELS, + note: "Official CodeBuddy Code CLI (Tencent Cloud), China/internal environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy (Global); credentials are never exchanged across regions. v1 disables CLI tools (--tools \"\"): text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", + }, ]; export function providerRegistryFastWireError( diff --git a/src/responses/compaction.ts b/src/responses/compaction.ts index f3fba7a033..b067e7c858 100644 --- a/src/responses/compaction.ts +++ b/src/responses/compaction.ts @@ -17,6 +17,10 @@ export const OCX_COMPACTION_PREFIX = "ocx1:"; +export const OCX_NATIVE_REPLAY_RECOVERY_NOTE = + "Threads compacted through a routed provider can contain OpenCodeX-owned ocx1 state. " + + "Before resuming one through native Codex, run `ocx recover-history --ocx-compaction <thread-id> --yes`."; + /** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction). */ export const COMPACT_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. diff --git a/src/responses/task-input.ts b/src/responses/task-input.ts index e72973ab90..44c636b6c6 100644 --- a/src/responses/task-input.ts +++ b/src/responses/task-input.ts @@ -20,9 +20,29 @@ function supportedBlock(value: unknown): value is TaskInputBlock { return value.detail === undefined || (typeof value.detail === "string" && imageDetails.has(value.detail)); } +/** + * Does this item carry a pairing key? A tool result is paired by `call_id`; a seed is not. + * + * Presence of the FIELD is not presence of a KEY (#3807). Codex desktop seeds a sub-agent + * thread with a lone `function_call_output` that some client builds emit with an explicit + * `call_id: null` or `""` rather than omitting it. Those values can never pair with a + * `function_call`, so treating them as a paired result sent the item to the guard in + * core.ts and answered 400 for a turn that is really external task input. + * + * A wrong-typed key (number, object) is NOT relaxed: that is malformed input rather than + * the absent-pairing seed shape, and it keeps the #3259 rejection. + */ +function hasPairingKey(item: Record<string, unknown>): boolean { + if (!("call_id" in item)) return false; + const callId = item.call_id; + if (callId === null) return false; + if (typeof callId === "string") return callId.trim().length > 0; + return true; +} + /** Recognize Codex external task input without repairing ordinary orphaned tool results. */ export function externalTaskInputContent(item: unknown): string | OcxContentPart[] | undefined { - if (!isObj(item) || item.type !== "function_call_output" || "call_id" in item) return undefined; + if (!isObj(item) || item.type !== "function_call_output" || hasPairingKey(item)) return undefined; if (!nonBlank(item.id) || !nonBlank(item.name) || !nonBlank(item.namespace)) return undefined; const output = item.output; if (typeof output === "string") return nonBlank(output) ? output : undefined; diff --git a/src/router.ts b/src/router.ts index 6ea49570a1..98f5d065fa 100644 --- a/src/router.ts +++ b/src/router.ts @@ -10,7 +10,7 @@ import { import type { NormalizedComboConfig } from "./combos/types"; import { hasOwnProvider } from "./config/provider-name"; import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./providers/key-store"; -import { captureProviderApiKeySelection } from "./providers/api-key-selection"; +import { captureProviderApiKeySelection } from "./providers/api-key-selection-capture"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { @@ -706,13 +706,16 @@ function routeModelInternal( throw new Error("provider alias '" + requestedProvider + "' is ambiguous: " + configuredMatches.map(([n]) => n).sort().join(", ")); } else { // Pass 2: built-in registry aliases, only for providers that do NOT have an explicit alias override - // and whose registry alias has not been claimed by another configured provider (#3531 review) + // and whose registry alias has not been claimed by another configured provider name or alias const registryMatches = Object.entries(config.providers).filter(([name, provider]) => { if (provider.alias !== undefined) return false; const regAlias = PROVIDER_REGISTRY.find(e => e.id === name)?.alias; if (!regAlias || regAlias.toLowerCase() !== requestedLower) return false; const claimedByOther = Object.entries(config.providers).some(([otherName, p]) => - otherName !== name && typeof p.alias === "string" && p.alias.trim().toLowerCase() === requestedLower + otherName !== name && ( + otherName.toLowerCase() === requestedLower + || (typeof p.alias === "string" && p.alias.trim().toLowerCase() === requestedLower) + ) ); return !claimedByOther; }); diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 7e69010636..8ee5f52a49 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -38,6 +38,9 @@ import { } from "./request-log"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; +import { providerConsumesCallerAuthorization } from "../providers/caller-authorization"; +import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar"; +import { captureCallerDirectAuth } from "../providers/caller-authorization"; import type { AdmissionLease } from "../lib/admission"; import type { DataPlaneAdmission } from "./auth-cors"; import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission"; @@ -133,7 +136,8 @@ async function handleChatCompletionsWithBudget( // it registers (extra_headers, sent verbatim by upstream Grok). Dashboard usage // bucketing only — never an auth or billing signal. if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok"; - let directRoute = false; + let callerAuthorizationRoute = false; + let routeMayChangeCredentialDomain = false; let settledRoute: ReturnType<typeof routeModel> | null = null; let chatNativeRoute: ReturnType<typeof routeModel> | null = null; try { @@ -150,9 +154,9 @@ async function handleChatCompletionsWithBudget( logCtx.provider = route.providerName; logCtx.routeDecision = route.routeDecision; settledRoute = route; - if (route.provider.adapter === "openai-responses") { - directRoute = route.codexAccountMode === "direct"; - } + routeMayChangeCredentialDomain = route.combo !== undefined || route.routeKind === "policy"; + callerAuthorizationRoute = !routeMayChangeCredentialDomain + && providerConsumesCallerAuthorization(route.provider); if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") { const parts: string[] = []; if (chatBody.messages !== undefined) parts.push(JSON.stringify(chatBody.messages)); @@ -240,17 +244,23 @@ async function handleChatCompletionsWithBudget( && isCodexReserveHelperUnsupported(config, settledRoute.modelId, logIds?.admission, visionDescribeTerminal)) { return chatCompletionsErrorResponse(400, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, "invalid_request_error"); } + const nativeCallerAuth = captureExplicitOpenAiCallerAuth(req.headers, config); + // Caller-owned only: stored-main enrichment below is sidecar authority, never Direct authority. + const callerDirectAuth = captureCallerDirectAuth(req.headers, config); + let openAiSidecarAuth = nativeCallerAuth; const headers = new Headers({ "content-type": "application/json" }); // Internal bridge metadata; the Go resolver scopes and hashes it before upstream use. const openCodeSession = req.headers.get("x-opencode-session"); if (openCodeSession) headers.set("x-opencode-session", openCodeSession); for (const name of FORWARD_HEADERS) { - if (name === "authorization" && !directRoute) continue; + if (routeMayChangeCredentialDomain && (name === "authorization" || name === "chatgpt-account-id")) continue; + if (name === "authorization" && !callerAuthorizationRoute) continue; const value = req.headers.get(name); if (value) headers.set(name, value); } - // Prefer main ChatGPT auth so OpenAI-backed sidecars remain reachable on routed turns. - if (!directRoute) { + // A noncanonical caller-auth route can use stored main auth only through a sidecar snapshot. + // Later shadow/thread rewrites strip primary credentials at the actual Responses boundary. + if (!callerAuthorizationRoute || (settledRoute && !isCanonicalOpenAiForwardProvider(settledRoute.provider))) { // This enrichment is optional for routed/non-main providers. If native main // is fenced, omit it and let auth-context reject only a final physical-main // selection while healthy pool/provider routes continue. @@ -259,8 +269,12 @@ async function handleChatCompletionsWithBudget( const { getMainAccountToken } = await import("../codex/main-account"); const token = getMainAccountToken(); if (token) { - headers.set("authorization", `Bearer ${token.accessToken}`); - headers.set("chatgpt-account-id", token.chatgptAccountId); + const mainHeaders = new Headers({ authorization: `Bearer ${token.accessToken}`, "chatgpt-account-id": token.chatgptAccountId }); + openAiSidecarAuth ??= captureExplicitOpenAiCallerAuth(mainHeaders, config); + if (!callerAuthorizationRoute && !routeMayChangeCredentialDomain) { + headers.set("authorization", `Bearer ${token.accessToken}`); + headers.set("chatgpt-account-id", token.chatgptAccountId); + } } } catch { /* optional */ @@ -299,6 +313,9 @@ async function handleChatCompletionsWithBudget( addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); }; const upstream = await handleResponses(internalReq, config, logCtx, { + openAiSidecarAuth, + nativeCallerAuth, + callerDirectAuth, ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}), // #1686: the Chat surface translates its body and replays here, so the admission fact has // to ride along or a bearer-admitted Chat caller would still be refused by Direct. diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 6a9b41b227..7ff76f0f2e 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -11,23 +11,15 @@ import { jsonUtf8Bytes } from "../lib/json-byte-size"; import { sseFieldValue } from "../lib/sse-decoder"; import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; -import { AnthropicRequestError, DesktopModelMappingUnavailableError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, verifyAndExtractDirectives, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; -import { getOrCreateDirectiveSigningKey } from "../claude/directive-key"; -import { isAllowedLegacyDirective } from "../claude/agents-inject"; +import { AnthropicRequestError, DesktopModelMappingUnavailableError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { isKnownDesktop3pModelId, resolveDesktop3pAlias } from "../claude/desktop-3p"; import { resolveAlias, claudeCodeNativeAlias } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; -import { annotateClaudeInboundDecision, captureClaudeInbound } from "../claude/inbound-debug"; +import { captureClaudeInbound } from "../claude/inbound-debug"; +import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; import { resolveClientRetryAfter } from "../lib/retry-after"; -import { createHash } from "node:crypto"; -import { - analyzeClaudeCompatibility, - collectClaudeFeatureCodes, - isToleratedClaudeFeatureCode, - resolveClaudeCompatibilityMode, -} from "../claude/compatibility"; import { anthropicErrorBody, anthropicErrorResponse, @@ -37,12 +29,14 @@ import { } from "../claude/outbound"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; -import { modelInList } from "../types/tools"; -import type { ClaudeSourceEnvelope, OcxConfig, OcxUsage } from "../types"; +import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; +import { registryEntryForProviderDestination } from "../providers/registry"; +import { evidenceFromBody } from "../routing/request-evidence"; +import { resolveWireProtocolOverride } from "./adapter-resolve"; +import type { OcxConfig } from "../types"; import { readJsonRequestBody } from "./request-decompress"; import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; -import { conversationIdFromClaudeMetadata } from "./request-log-conversation"; -import { normalizeClaudeCompatibilityUsageLog } from "../usage/log"; +import { conversationIdFromClaudeMetadata, normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; import { @@ -70,47 +64,9 @@ import { parseSyntheticRowId, type ParsedFastRowId, } from "./fast-row"; -import { supportedLadderFor } from "./effort-policy"; -import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; -import { POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile"; -import { evidenceFromBody } from "../routing/request-evidence"; type Rec = Record<string, unknown>; -function resolveClaudePolicySelector( - config: OcxConfig, - model: string, -): { decodedModel: string; isPolicy: boolean } { - // Claude Code sends the readable aliases published by /v1/models, not necessarily the - // underlying route (`claude-ocx-policy--daily` -> `policy/daily`). Policy detection must - // use the same identity the Messages translator will route, while preserving an exact - // operator alias such as `claude-smart` when it has no Claude model-map entry. - const decodedModel = resolveInboundModel(model, config.claudeCode); - return { - decodedModel, - isPolicy: resolvePolicyProfileId(config, decodedModel) !== null - || decodedModel.startsWith(`${POLICY_NAMESPACE}/`), - }; -} - -function isLocalPolicyRoutingError( - status: number, - message: string, - logCtx: Pick<RequestLogContext, "requestedModel" | "routeDecision">, -): boolean { - if (status !== 404) return false; - if ( - logCtx.routeDecision?.routeKind === "policy" - && logCtx.routeDecision.selected.reason === "no-eligible-candidate" - ) { - return true; - } - return ( - logCtx.requestedModel?.startsWith(`${POLICY_NAMESPACE}/`) === true - && message.startsWith("Unknown routing policy:") - ); -} - /** * Decode a Claude selector that may carry the fast marker. * @@ -166,25 +122,6 @@ export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig { }; } -/** - * Phase 4 plan 04-02: benchmark-only raw-usage observation. - * - * The optional benchmark observer receives a sanitized structural record only — - * final adapter kind, resolved model id, and the raw OcxUsage reported before - * Anthropic wire normalization. It never receives request bodies, headers, - * provider names/aliases, endpoint, account identity, raw provider response, or - * error text. Standard Messages behavior is unchanged when omitted. - */ -export interface ClaudeBenchmarkRawUsage { - adapterKind: string; - modelId: string; - usage: OcxUsage | undefined; -} - -export interface ClaudeBenchmarkObserverOptions { - onRawUsage?: (observation: ClaudeBenchmarkRawUsage) => void; -} - function claudeInboundDisabled(config: OcxConfig): Response | null { if (config.claudeCode?.enabled === false) { return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error"); @@ -192,150 +129,6 @@ function claudeInboundDisabled(config: OcxConfig): Response | null { return null; } -// ── Claude source envelope & session precedence (ingress slice) ──────────────── - -/** - * Capture sanitized immutable envelope before destructive translation. - * Charges the same TranslatorBudget for the retained copy + header bytes. - */ -export function captureClaudeSourceEnvelope( - req: Request, - rawBody: unknown, - budget: TranslatorBudget, -): ClaudeSourceEnvelope { - const beta = req.headers.get("anthropic-beta")?.trim() || undefined; - const rawVersion = req.headers.get("anthropic-version"); - const version = rawVersion === null ? undefined : rawVersion.trim(); - if (!isRec(rawBody)) throw new AnthropicRequestError("Anthropic request body must be an object"); - const bodyBytes = jsonUtf8Bytes(rawBody); - let headerBytes = 0; - if (beta) headerBytes += new TextEncoder().encode(beta).byteLength; - if (version) headerBytes += new TextEncoder().encode(version).byteLength; - budget.chargeRetained(bodyBytes + headerBytes, { kind: "request_copies" }); - const bodyClone = structuredClone(rawBody); - return { - body: bodyClone, - headers: { - ...(beta ? { "anthropic-beta": beta } : {}), - ...(version !== undefined ? { "anthropic-version": version } : {}), - }, - }; -} - -/** - * Session precedence: x-claude-code-session-id header > metadata.user_id > system cohort. - * Returns the canonical session id string or null when none is determinable before translation. - * Agent/parent IDs are NOT session ids — they are only HMAC8 debug tags (see inbound-debug). - */ -export function claudeSessionIdFromRequest(req: Request, body: unknown): string | null { - const headerSid = req.headers.get("x-claude-code-session-id")?.trim(); - if (headerSid) return headerSid; - if (body && typeof body === "object" && !Array.isArray(body)) { - const rec = body as Record<string, unknown>; - const metadata = rec.metadata; - if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { - const uid = (metadata as Record<string, unknown>).user_id; - if (typeof uid === "string" && uid.trim().length > 0) return uid.trim(); - } - } - return null; -} - -function claudeAgentIdsFromRequest(req: Request): { agentId?: string; parentAgentId?: string } { - const out: { agentId?: string; parentAgentId?: string } = {}; - const headerAgent = req.headers.get("x-claude-code-agent-id")?.trim(); - const headerParent = req.headers.get("x-claude-code-parent-agent-id")?.trim(); - if (headerAgent) out.agentId = headerAgent; - if (headerParent) out.parentAgentId = headerParent; - return out; -} - -/** - * Compute prompt_cache_key from a session id (header precedence path). - * Uses same sha256 hex slice as inbound.ts (32 hex chars) for per-session keys. - * Returns null when sessionId is null (caller should keep translation's system cohort key). - */ -export function promptCacheKeyForSession(sessionId: string | null): string | null { - if (!sessionId) return null; - return createHash("sha256").update(sessionId).digest("hex").slice(0, 32); -} - -/** Idempotent, synchronous work for every effective Responses route resolution. */ -export function claudeFinalRouteHandler( - parsed: { options: Record<string, unknown>; modelId: string; _rawBody?: unknown; _promptCacheKeyIsSharedCohort?: boolean }, - route: { provider: OcxConfig["providers"][string]; providerName: string; modelId: string }, - ctx: { - sourceEnvelope: ClaudeSourceEnvelope; - cacheKeySource: ClaudeCacheKeySource; - config: OcxConfig; - logCtx: RequestLogContext; - }, -): { adapter: string; decision: ReturnType<typeof analyzeClaudeCompatibility>["decision"]; featureCodes: string[] } { - const adapter = route.provider.adapter; - // Route-aware fail-closed gate for auto-only models (e.g. muse-spark-1.2-contributor on opencode-go). - // Must run before any upstream; uses the routed modelId + provider list so a forced/named - // tool_choice never silently downgrades to auto (Responses core would do that for openai-chat). - { - const tc = (ctx.sourceEnvelope.body as Record<string, unknown>).tool_choice as unknown; - const tcType = tc && typeof tc === "object" && !Array.isArray(tc) && typeof (tc as Record<string, unknown>).type === "string" ? (tc as Record<string, unknown>).type as string : undefined; - if ((tcType === "any" || tcType === "tool") && modelInList((route.provider as { autoToolChoiceOnlyModels?: string[] }).autoToolChoiceOnlyModels, route.modelId)) { - throw new AnthropicRequestError( - `tool_choice type '${tcType}' is not supported for model "${route.modelId}" (provider ${route.providerName}): this model supports only tool_choice auto or none. Remove tool_choice, use {"type":"auto"} or {"type":"none"}, or choose a different model.`, - ); - } - } - // Idempotent sampling strip for openai-responses forward (native ChatGPT pierce) - if (adapter === "openai-responses") { - const raw = parsed._rawBody as Record<string, unknown> | undefined; - if (raw) { - // _rawBody is snake_case Responses JSON; be idempotent for both snake and camel keys - delete raw.max_output_tokens; - delete (raw as Record<string, unknown>).maxOutputTokens; - delete raw.temperature; - delete raw.top_p; - delete (raw as Record<string, unknown>).topP; - delete raw.stop; - delete (raw as Record<string, unknown>).stopSequences; - delete raw.user; - } - // OcxRequestOptions is camelCase; _rawBody snake is already handled above. Strip both forms idempotently. - delete (parsed.options as Record<string, unknown>).max_output_tokens; - delete (parsed.options as Record<string, unknown>).maxOutputTokens; - delete (parsed.options as Record<string, unknown>).temperature; - delete (parsed.options as Record<string, unknown>).top_p; - delete (parsed.options as Record<string, unknown>).topP; - delete (parsed.options as Record<string, unknown>).stop; - delete (parsed.options as Record<string, unknown>).stopSequences; - delete (parsed.options as Record<string, unknown>).user; - } - // Usage estimate only for cursor/kiro (estimated-usage adapters) - if (adapter === "cursor" || adapter === "kiro") { - try { - const bodyRec = ctx.sourceEnvelope.body as Record<string, unknown>; - const model = typeof bodyRec.model === "string" ? bodyRec.model : ctx.config.claudeCode?.model; - ctx.logCtx.usageLogInputTokens = estimateClaudeRequestTokens(bodyRec as { system?: unknown; messages?: unknown; tools?: unknown }, model); - } catch { - // ignore estimation failures - } - } - // Opus-shaped aliases can make every routed model look reasoning-capable to Claude - // clients. Strip a forced effort only when the final route explicitly has no ladder. - if (parsed.options.reasoning !== undefined) { - const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); - if (ladder !== undefined && ladder.length === 0) delete parsed.options.reasoning; - } - // Compatibility evaluation before network (enforce mode may reject) - const mode = resolveClaudeCompatibilityMode(ctx.config.claudeCode); - const anthropicBeta = ctx.sourceEnvelope.headers["anthropic-beta"]; - const result = analyzeClaudeCompatibility(ctx.sourceEnvelope.body, { mode, adapter, anthropicBeta }); - if (result.decision === "reject") { - ctx.logCtx.errorCode = "claude_compatibility_unsupported"; - throw new AnthropicRequestError(result.reason ?? "incompatible features for routed adapter"); - } - return { adapter, decision: result.decision, featureCodes: result.shadowFeatureCodes ?? result.featureCodes }; -} - - async function readAnthropicBody(req: Request, budget: TranslatorBudget): Promise<unknown> { try { return await readJsonRequestBody(req, budget); @@ -810,7 +603,7 @@ export async function fetchWithHeaderDeadline( ): Promise<HeaderDeadlineFetchResult> { const deadline = makeDeadline(timeoutMs, parent); try { - const upstream = await fetchImpl(input, { ...init, signal: deadline.signal, timeout: 0 }); + const upstream = await fetchImpl(input, { ...init, redirect: "manual", signal: deadline.signal, timeout: 0 }); return { kind: "response", upstream }; } catch (error) { if (deadline.didExpire()) return { kind: "timeout" }; @@ -826,12 +619,11 @@ export async function handleClaudeMessages( logCtx: RequestLogContext, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, requestPolicy: RequestPolicyView = config, - benchmark?: ClaudeBenchmarkObserverOptions, ): Promise<Response> { const translatorBudget = createTranslatorBudget(); try { return finalizeTranslatorBudgetResponse( - await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy, benchmark), + await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy), translatorBudget, ); } catch (error) { @@ -847,7 +639,6 @@ async function handleClaudeMessagesWithBudget( translatorBudget: TranslatorBudget, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, requestPolicy: RequestPolicyView = config, - benchmark?: ClaudeBenchmarkObserverOptions, ): Promise<Response> { logCtx.surface = "claude"; const disabled = claudeInboundDisabled(config); @@ -863,11 +654,6 @@ async function handleClaudeMessagesWithBudget( let effortRow: ParsedEffortRowId | null = null; let fastRow: ParsedFastRowId | null = null; let requestedModel = ""; - let sourceEnvelope: ClaudeSourceEnvelope | null = null; - let headerSessionId: string | null = null; - let featureCodesEarly: string[] = []; - let agentIds: { agentId?: string; parentAgentId?: string } = {}; - let debugCaptureId: number | undefined; try { anthropicBody = await readAnthropicBody(req, translatorBudget); // Defensive [1m] strip (devlog 138): clients normally remove the context-variant @@ -876,21 +662,15 @@ async function handleClaudeMessagesWithBudget( if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { anthropicBody.model = stripOneMillionMarker(anthropicBody.model); } - // ocx-route override (devlog 072 + TRUST-01..05): injected agent bodies pin their model via a + // ocx-route override (devlog 072): injected agent bodies pin their model via a // system-prompt directive because 2.1.207 ignores custom ids in agent // frontmatter. Must run BEFORE the native-passthrough branch — the CLI sends // these subagent turns under a fallback claude model id. if (isRec(anthropicBody)) { - const directives = verifyAndExtractDirectives( - anthropicBody, - getOrCreateDirectiveSigningKey(), - (route, effort) => isAllowedLegacyDirective(route, effort, config), - ); - if (directives.route && typeof anthropicBody.model === "string") { - anthropicBody.model = stripOneMillionMarker(directives.route); - if (directives.effort) { - effortOverride = directives.effort; - } + const routeOverride = extractOcxRouteDirective(anthropicBody); + if (routeOverride && typeof anthropicBody.model === "string") { + anthropicBody.model = stripOneMillionMarker(routeOverride); + effortOverride = extractOcxEffortDirective(anthropicBody); } } if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { @@ -913,25 +693,15 @@ async function handleClaudeMessagesWithBudget( } if (fastRow) anthropicBody.model = fastRow.baseId; } - headerSessionId = claudeSessionIdFromRequest(req, anthropicBody); - agentIds = claudeAgentIdsFromRequest(req); - featureCodesEarly = collectClaudeFeatureCodes(anthropicBody, req.headers.get("anthropic-beta") ?? undefined); // Debug capture (opt-in allowlist scalars) BEFORE the passthrough branch so // native, routed, and disabled-alias paths are all observable (devlog 130 B1). - // Extended ring carries featureCodes/adapter/decision + HMAC8 session/agent tags. - debugCaptureId = captureClaudeInbound( + captureClaudeInbound( "messages", anthropicBody, isRec(anthropicBody) && typeof anthropicBody.model === "string" ? resolveInboundModel(anthropicBody.model, config.claudeCode) : undefined, req.headers.get("anthropic-beta") ?? undefined, - { - ...(headerSessionId ? { sessionId: headerSessionId } : {}), - ...(agentIds.agentId ? { agentId: agentIds.agentId } : {}), - ...(agentIds.parentAgentId ? { parentAgentId: agentIds.parentAgentId } : {}), - ...(featureCodesEarly.length > 0 ? { featureCodes: featureCodesEarly } : {}), - }, ); // Client surface discrimination: Desktop 3P aliases resolve through the // desktop registry; Code uses readable aliases or direct model names. @@ -949,19 +719,35 @@ async function handleClaudeMessagesWithBudget( // A fast row blocks passthrough, unlike the chat case: this path forwards to Anthropic's // own API, whose wire has no service_tier field and whose FastWire kind has an empty // adapter set by design, so the tier would be silently dropped. - const policySelector = isRec(anthropicBody) && typeof anthropicBody.model === "string" - ? resolveClaudePolicySelector(config, anthropicBody.model) - : null; - if ( - !effortRow - && !fastRow - && policySelector?.isPolicy !== true - && isRec(anthropicBody) - && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model) - ) { - annotateClaudeInboundDecision(debugCaptureId, "anthropic", "native", featureCodesEarly); + if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } + // Capture source semantics before effort rewriting or translation drops fields. + // This policy is uniform across translated targets, including later fallback attempts. + const compatibilityMode: unknown = config.claudeCode?.compatibility; + if (compatibilityMode !== undefined) { + if (!isClaudeCompatibilityMode(compatibilityMode)) { + logCtx.errorCode = "claude_compatibility_configuration"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 503, { closeReason: "non_stream" }); + return anthropicErrorResponse(503, "Invalid claudeCode.compatibility setting", "api_error"); + } + const compatibility = analyzeClaudeCompatibility(anthropicBody, { + mode: compatibilityMode, + anthropicBeta: req.headers.get("anthropic-beta") ?? undefined, + }); + if (compatibility.decision === "reject") { + logCtx.errorCode = "claude_compatibility_unsupported"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); + return anthropicErrorResponse(400, compatibility.reason!, "invalid_request_error"); + } + if (compatibility.decision === "shadow") { + logCtx.claudeCompatibility = { + decision: "shadow", + featureCodes: compatibility.featureCodes, + reason: compatibility.reason, + }; + } + } if (isRec(anthropicBody) && effortOverride) { anthropicBody.output_config = { ...(isRec(anthropicBody.output_config) ? anthropicBody.output_config : {}), @@ -969,9 +755,6 @@ async function handleClaudeMessagesWithBudget( }; delete anthropicBody.thinking; } - // Routed requests retain the post-directive source body. Native passthrough never - // pays for or observes this clone, preserving its existing byte-for-byte path. - sourceEnvelope = captureClaudeSourceEnvelope(req, anthropicBody, translatorBudget); const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode, translatorBudget); internalBody = translation.body; // The Anthropic translator builds its body from model/input/store/stream plus sampling @@ -979,22 +762,7 @@ async function handleClaudeMessagesWithBudget( // inbound one. if (fastRow) internalBody.service_tier = "priority"; translatorBudget.chargeRetained(jsonUtf8Bytes(internalBody), { kind: "request_copies" }); - // Session header precedence feeds prompt_cache_key (header > metadata > system cohort). - // When the x-claude-code-session-id header is present, it replaces the - // translation's per-session/system key with a stable per-header sha256 key - // and promotes cacheKeySource to "metadata" so downstream affinity/session_id - // logic treats it as a real per-session key (not the shared system cohort). - if (headerSessionId) { - const sessionCacheKey = promptCacheKeyForSession(headerSessionId); - if (sessionCacheKey) { - internalBody.prompt_cache_key = sessionCacheKey; - cacheKeySource = "metadata"; - } else { - cacheKeySource = translation.cacheKeySource; - } - } else { - cacheKeySource = translation.cacheKeySource; - } + cacheKeySource = translation.cacheKeySource; } catch (err) { const overflow = isTranslatorBudgetExceededError(err); const unavailable = err instanceof DesktopModelMappingUnavailableError; @@ -1015,53 +783,61 @@ async function handleClaudeMessagesWithBudget( // the translated Anthropic SSE into a message JSON for non-streaming clients. internalBody.stream = true; - // ── Final-route callback (post-route, pre-network) ─────────────────────────────────────── - // Adapter-specific work runs only after Responses core owns the final route. - const claudeOnResolvedRoute = (info: import("./responses/core").ResolvedRouteInfo): void => { - if (!sourceEnvelope) return; - // Each attempt owns its admission evidence; a later native route must not - // inherit a translated shadow decision from an earlier fallback target. - delete logCtx.claudeCompatibility; - const result = claudeFinalRouteHandler( - info.parsed as unknown as Parameters<typeof claudeFinalRouteHandler>[0], - { provider: { ...info.provider, adapter: info.adapterName }, providerName: info.route.providerName, modelId: info.modelId } as Parameters<typeof claudeFinalRouteHandler>[1], - { - sourceEnvelope, - cacheKeySource, - config, - logCtx, - }, - ); - if (result.decision === "shadow") { - // Persisted shadow evidence must be backed by the final per-attempt - // evaluation: every non-tolerated code comes from result.featureCodes, - // so an adapter-tolerated feature (e.g. deferred_tools on - // openai-responses) can never be relabeled unsupported. Early pre-route - // codes only contribute tolerated diagnostics that the effort override - // legitimately removed before the final evaluation. - logCtx.claudeCompatibility = normalizeClaudeCompatibilityUsageLog({ - decision: "shadow", - featureCodes: [...new Set([ - ...result.featureCodes, - ...featureCodesEarly.filter(isToleratedClaudeFeatureCode), - ])], - }); + // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped + // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", + // verified live 2026-07-11). Strip them for that route; routed providers keep them. + let nativeRoute = false; + let opencodeGoRoute = false; + try { + const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); + // Match the fixed key-auth destination before per-model wire overrides, including + // renamed Go providers without treating custom or lookalike URLs as Go. + opencodeGoRoute = registryEntryForProviderDestination(route.provider)?.id === "opencode-go"; + // Settle the wire once so the sampling decision below reads the effective + // adapter rather than the provider-wide default (#404). + route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); + logCtx.routeDecision = route.routeDecision; + if (route.provider.adapter === "openai-responses") { + nativeRoute = true; + delete internalBody.max_output_tokens; + delete internalBody.temperature; + delete internalBody.top_p; + delete internalBody.stop; + delete internalBody.user; } - // Session_id header synthesis: only for openai-responses and only for a - // real per-session prompt_cache_key (metadata), never the system-hash - // cohort. Idempotent: check headers.has before set. - { - const pck = (info.parsed.options as Rec).promptCacheKey ?? (info.parsed.options as Rec).prompt_cache_key ?? ((info.parsed as unknown as Rec)._rawBody as Rec | undefined)?.prompt_cache_key; - if (info.adapterName === "openai-responses" && cacheKeySource === "metadata" && typeof pck === "string" && !info.headers.has("session_id")) { - info.headers.set("session_id", uuidFromHex(pck as string)); - } else if (info.adapterName !== "openai-responses") { - info.headers.delete("session_id"); - } + // Estimated-usage adapters (cursor/kiro) report no per-turn input tokens; stash a + // request-side estimate so the log's in:0 rows get a floor. NEVER set this for + // accurate-usage adapters — the request-log merge is max(reported, estimate) and + // would overwrite real usage (audit 133 R1#7). + if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") { + logCtx.usageLogInputTokens = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel); } - annotateClaudeInboundDecision(debugCaptureId, result.adapter, result.decision, result.featureCodes); - }; + // Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make + // every routed model look like a reasoning model to Claude clients, so a forced + // effort (CLAUDE_CODE_ALWAYS_ENABLE_EFFORT) would leak reasoning params to routes + // that affirmatively expose NO effort control. Strip only on a definitive [] from + // supportedLadderFor; unknown (undefined) passes through untouched. + if (internalBody.reasoning !== undefined) { + const { supportedLadderFor } = await import("./effort-policy"); + const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); + if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning; + } + } catch (err) { + if (err instanceof UnknownRoutingPolicyError) { + logCtx.requestedModel = requestedModel; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); + return anthropicErrorResponse(404, err.message, "invalid_request_error"); + } + if (err instanceof NoEligiblePolicyCandidateError) { + logCtx.routeDecision = err.trace; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); + return anthropicErrorResponse(404, err.message, "invalid_request_error"); + } + /* unknown model: let handleResponses shape the 404 */ + } const headers = new Headers({ "content-type": "application/json" }); + let trustedClaudeMainAuth: { authorization: string; chatgptAccountId?: string } | undefined; for (const name of FORWARD_HEADERS) { // The caller's bearer is the proxy admission token (ocx claude placeholder), never a // ChatGPT credential — forwarding it upstream turns into {"detail":"Unauthorized"}. @@ -1077,8 +853,37 @@ async function handleClaudeMessagesWithBudget( const { getMainAccountToken } = await import("../codex/main-account"); const token = getMainAccountToken(); if (token) { - headers.set("authorization", `Bearer ${token.accessToken}`); + const authorization = `Bearer ${token.accessToken}`; + headers.set("authorization", authorization); headers.set("chatgpt-account-id", token.chatgptAccountId); + trustedClaudeMainAuth = { + authorization, + ...(token.chatgptAccountId ? { chatgptAccountId: token.chatgptAccountId } : {}), + }; + } + } + if (opencodeGoRoute) { + const session = req.headers.get("x-opencode-session"); + if (session) headers.set("x-opencode-session", session); + } + const hasExplicitGoSession = opencodeGoRoute + && (sessionLaneIdFromRequest(headers) !== undefined + || normalizeLogConversationId(headers.get("x-opencode-session")) !== undefined); + const synthesizeGoSession = opencodeGoRoute && !hasExplicitGoSession + && isRec(anthropicBody) + && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined; + // Go can also use the Responses adapter; its eligibility gate must win on both wires. + if (opencodeGoRoute ? synthesizeGoSession : nativeRoute) { + // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex + // clients always send their session uuid; devlog 090 follow-up: body-level + // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends + // the header, so synthesize a stable per-session uuid from the same cache key. + // Routed Go requests need this lane too for their x-opencode-session affinity — + // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback + // key is shared across Desktop conversations, and a shared session_id's backend + // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there. + if (cacheKeySource === "metadata" && (synthesizeGoSession || !headers.has("session_id")) && typeof internalBody.prompt_cache_key === "string") { + headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); } } let internalReq: Request; @@ -1124,19 +929,14 @@ async function handleClaudeMessagesWithBudget( // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", stripClaudeMainAuthForNoncanonicalForward: true, + ...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}), + // Claude's internal stored-main enrichment is not an original caller credential. + nativeCallerAuth: null, + callerDirectAuth: null, translatorBudget, - // Forward the sanitized immutable source envelope (charged to the same budget) - // so core can perform fidelity-preserving work. The envelope is the - // pre-translation clone (body + anthropic-beta/version headers only). - ...(sourceEnvelope ? { claudeSourceEnvelope: sourceEnvelope } : {}), - // Adapter-aware final-route gate: idempotent strip / session_id synthesis / - // usage estimate / compatibility enforce. Core should invoke this after - // each routeModel+resolveWireProtocolOverride (see note above). - onResolvedRoute: claudeOnResolvedRoute, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), - ...(benchmark?.onRawUsage ? { claudeBenchmarkObserver: benchmark.onRawUsage } : {}), }); const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; @@ -1175,10 +975,7 @@ async function handleClaudeMessagesWithBudget( && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE; const transient = !nativeMainFence && isTransientUpstreamStatus(response.status); const outStatus = nativeMainFence ? 503 : transient ? 529 : response.status; - const errorType = isLocalPolicyRoutingError(response.status, message, logCtx) - ? "invalid_request_error" - : undefined; - const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message, errorType)), { + const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), { status: outStatus, headers: { "Content-Type": "application/json", @@ -1361,9 +1158,6 @@ export async function handleClaudeCountTokens( } catch (err) { if (err instanceof DesktopModelMappingUnavailableError) return desktopMappingUnavailableResponse(err); if (err instanceof AnthropicRequestError) return anthropicErrorResponse(400, err.message); - if (isTranslatorBudgetExceededError(err)) { - return anthropicErrorResponse(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); - } return anthropicErrorResponse(500, err instanceof Error ? err.message : String(err)); } finally { translatorBudget.dispose(); } if (!body || typeof body !== "object" || Array.isArray(body)) { @@ -1374,75 +1168,40 @@ export async function handleClaudeCountTokens( return anthropicErrorResponse(400, "model is required"); } try { - let model = raw.model; - // Case-insensitive [1m] strip (audit 021 #7 — the CLI matches /\[1m\]/i). - const stripped = stripOneMillionMarker(model); - if (stripped !== model) { - model = stripped; - raw.model = model; - } - // ocx-route override (devlog 072 + TRUST-01..05): keep count_tokens consistent with messages. - let directives; - try { - directives = verifyAndExtractDirectives( - raw, - getOrCreateDirectiveSigningKey(), - (route, effort) => isAllowedLegacyDirective(route, effort, config), - ); - } catch (err) { - if (err instanceof AnthropicRequestError) { - return anthropicErrorResponse(400, err.message, "invalid_request_error"); + let model = raw.model; + // Case-insensitive [1m] strip (audit 021 #7 — the CLI matches /\[1m\]/i). + const stripped = stripOneMillionMarker(model); + if (stripped !== model) { + model = stripped; + raw.model = model; } - throw err; - } - if (directives.route) { - model = stripOneMillionMarker(directives.route); - raw.model = model; - } - model = decodeFablePickerAlias(model, config.claudeCode); - raw.model = model; - // Fast-only count requests carry a synthetic selector but never parsed an effort row. - // Normalize the identity before native passthrough or estimation; no tier is sent here. - const countFastRow = parseFastOnlyRowId( - config, () => decodeClaudeFastSelector(model, config.claudeCode), - ); - if (countFastRow) { - model = countFastRow.baseId; + // ocx-route override (devlog 072): keep count_tokens consistent with messages. + const countRoute = extractOcxRouteDirective(raw); + if (countRoute) { + model = stripOneMillionMarker(countRoute); + raw.model = model; + } + model = decodeFablePickerAlias(model, config.claudeCode); raw.model = model; - } - const policySelector = resolveClaudePolicySelector(config, model); - let resolvedPolicy = false; - if (policySelector.isPolicy) { - try { - const route = routeModel(config, policySelector.decodedModel, evidenceFromBody(raw)); - model = route.modelId; + // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a + // token estimate and sends no tier, so only the IDENTITY is corrected - without this the + // synthetic id reaches native passthrough as a model Anthropic has never heard of. + const countFastRow = parseFastOnlyRowId( + config, () => decodeClaudeFastSelector(model, config.claudeCode), + ); + if (countFastRow) { + model = countFastRow.baseId; raw.model = model; - resolvedPolicy = true; - } catch (err) { - if (err instanceof UnknownRoutingPolicyError || err instanceof NoEligiblePolicyCandidateError) { - return anthropicErrorResponse(404, err.message, "invalid_request_error"); - } - throw err; } - } - const ctHeaderSessionId = claudeSessionIdFromRequest(req, raw); - const ctAgentIds = claudeAgentIdsFromRequest(req); - const ctAnthropicBeta = req.headers.get("anthropic-beta") ?? undefined; - const ctFeatureCodes = collectClaudeFeatureCodes(raw, ctAnthropicBeta); - captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), ctAnthropicBeta, { - ...(ctHeaderSessionId ? { sessionId: ctHeaderSessionId } : {}), - ...(ctAgentIds.agentId ? { agentId: ctAgentIds.agentId } : {}), - ...(ctAgentIds.parentAgentId ? { parentAgentId: ctAgentIds.parentAgentId } : {}), - ...(ctFeatureCodes.length > 0 ? { featureCodes: ctFeatureCodes } : {}), - }); - if (!resolvedPolicy && wantsNativePassthrough(req, config, requestPolicy, model)) { - return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); - } - const inputTokens = estimateClaudeRequestTokens(raw, model); - return new Response(JSON.stringify({ input_tokens: inputTokens }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined); + if (wantsNativePassthrough(req, config, requestPolicy, model)) { + return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); + } + const inputTokens = estimateClaudeRequestTokens(raw, model); + return new Response(JSON.stringify({ input_tokens: inputTokens }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); } catch (error) { if (error instanceof DesktopModelMappingUnavailableError) return desktopMappingUnavailableResponse(error); if (error instanceof AnthropicRequestError) return anthropicErrorResponse(400, error.message); diff --git a/src/server/images.ts b/src/server/images.ts index ade4c8348e..02e56fcacf 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -288,6 +288,7 @@ async function tryCcaImageGeneration( try { upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { method: "POST", + redirect: "manual", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, diff --git a/src/server/live.ts b/src/server/live.ts index 91caec1f66..6d983ce0aa 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -86,39 +86,29 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [ * * When `OCX_LIVE_FRAME_LOG` is set to a file path, every relayed sideband frame appends one * JSONL record: direction, frame kind, byte length, and whether the payload contains U+FFFD. - * Privacy: full frame payloads are never written — only when U+FFFD is present, a short - * excerpt around the first replacement character is included so the corruption point can be - * attributed (upstream vs relay vs client). Disabled entirely when the env var is unset. + * Privacy: no frame content is written, including excerpts around replacement characters. + * For binary frames, U+FFFD may also be introduced by UTF-8 decoding; the flag alone does not + * identify the source of corruption. Disabled entirely when the env var is unset. */ export const LIVE_FRAME_LOG_ENV = "OCX_LIVE_FRAME_LOG"; -const LIVE_FRAME_LOG_CONTEXT_CHARS = 24; - -function fffdContext(text: string): string | undefined { - const idx = text.indexOf("\uFFFD"); - if (idx < 0) return undefined; - const start = Math.max(0, idx - LIVE_FRAME_LOG_CONTEXT_CHARS); - const end = Math.min(text.length, idx + LIVE_FRAME_LOG_CONTEXT_CHARS); - return text.slice(start, end); -} - export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void { const logPath = process.env[LIVE_FRAME_LOG_ENV]; if (!logPath) return; try { let kind: "text" | "binary" = "binary"; let bytes = 0; - let context: string | undefined; + let fffd = false; if (typeof data === "string") { kind = "text"; bytes = Buffer.byteLength(data); - context = fffdContext(data); + fffd = data.includes("\uFFFD"); } else if (data instanceof ArrayBuffer) { bytes = data.byteLength; - context = fffdContext(new TextDecoder().decode(new Uint8Array(data))); + fffd = new TextDecoder().decode(new Uint8Array(data)).includes("\uFFFD"); } else if (ArrayBuffer.isView(data)) { const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); bytes = data.byteLength; - context = fffdContext(new TextDecoder().decode(view)); + fffd = new TextDecoder().decode(view).includes("\uFFFD"); } else { return; } @@ -127,8 +117,7 @@ export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void { dir, kind, bytes, - fffd: context !== undefined, - ...(context !== undefined ? { context } : {}), + fffd, }; appendFileSync(logPath, `${JSON.stringify(record)}\n`); } catch { diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 46369be341..b3b9334b97 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -314,6 +314,20 @@ export async function handleManagementAPI( message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.", }, 409, req, config); } + if (respawnRisk === "self-unload") { + // This proxy IS the launchd/systemd job, so stopping the manager below would + // terminate the handler before the shared teardown at the end of this route restores + // the native Codex keys — the dashboard Stop button left `openai_base_url`, + // `experimental_realtime_ws_base_url` and `model_catalog_json` pointed at a dead + // proxy (#4023). Refuse before touching anything, like the Windows branch above. + // `ocx stop` is safe because it runs outside this process and owns the teardown + // through its receipt, which is why the receipt-backed caller never reaches here. + return jsonResponse({ + success: false, + code: "self_unload_service", + message: "This proxy is running as the installed service, so stopping the manager from inside it would end this process before native Codex is restored. Run `ocx stop`, which stops the service from outside and completes the restore. Nothing was changed.", + }, 409, req, config); + } if (respawnRisk === "unknown") { // Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop // failure, so that advice would be a loop. The scheduler query itself is what needs diff --git a/src/server/management/account-selection-stream.ts b/src/server/management/account-selection-stream.ts index e04e8a3e14..b51e5653ce 100644 --- a/src/server/management/account-selection-stream.ts +++ b/src/server/management/account-selection-stream.ts @@ -3,6 +3,7 @@ import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks" const MAX_SELECTION_STREAMS = 64; const HEARTBEAT_MS = 15_000; +const STREAM_QUEUE_HIGH_WATER_MARK = 16; const encoder = new TextEncoder(); const connections = new Set<() => void>(); @@ -36,9 +37,17 @@ export function accountSelectionStream(request: Request, validate: () => boolean const send = (frame: string) => { if (closed) return; if (!authorized()) { - // Error clears queued frames as well, so a revoked consumer cannot drain them. - try { controller.error(new DOMException("Management session is no longer authorized", "NotAllowedError")); } - finally { close(); } + // A revoked consumer must not drain frames queued before revocation: error() is + // what discards a non-empty queue. When nothing is queued — the common expired-session + // path, including the heartbeat — close() alone terminates quietly, so an expired + // dashboard session does not dump an expected DOMException into the server console. + // desiredSize equals the high water mark exactly when the queue is empty. + if (controller.desiredSize !== null && controller.desiredSize < STREAM_QUEUE_HIGH_WATER_MARK) { + try { controller.error(new DOMException("Management session is no longer authorized", "NotAllowedError")); } + finally { close(); } + } else { + close(); + } return; } // Reconnection sends a ready event, so a slow reader can reconcile without an @@ -61,7 +70,7 @@ export function accountSelectionStream(request: Request, validate: () => boolean heartbeat.unref?.(); }, cancel() { cleanup(); }, - }, { highWaterMark: 16 }); + }, { highWaterMark: STREAM_QUEUE_HIGH_WATER_MARK }); return new Response(body, { headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 098365b8ae..6f2998204d 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -38,6 +38,7 @@ import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../codex/multi-agent-mode-policy"; import { readUsageEntries } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; @@ -373,6 +374,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise agentsMaxDepth: getAgentsMaxDepth(), subagentDeveloperInstructions: getSubagentDeveloperInstructions(), multiAgentModeHintText: getMultiAgentModeHintText(), + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, // max_depth is V1-only upstream; this is the global-flag statement, derived // server-side so no client can present it as an effective V2 limit. agentsMaxDepthAppliesWhenV2Disabled: !enabled, @@ -728,6 +730,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise agentsMaxDepth: getAgentsMaxDepth(), subagentDeveloperInstructions: getSubagentDeveloperInstructions(), multiAgentModeHintText: getMultiAgentModeHintText(), + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, agentsMaxDepthAppliesWhenV2Disabled: !enabled, v2NativeParentOverride: v2NativeParentOverrideDto(config, enabled), v2RoutedDelegationBridge: config.v2RoutedDelegationBridge === true, diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 21fbf9cc43..527178ba0f 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -13,9 +13,8 @@ import { multiAgentGuidanceEnabled, providerBaseUrlConfigError, providerHeadersConfigError, - validateConfigCandidate, + saveConfigPreservingClaudeCode, } from "../../config"; -import { canonicalServerOrigin, serverTlsConfigError } from "../../lib/server-tls"; import { clearLoginState, getLoginStatus, @@ -105,7 +104,7 @@ import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; -import { assertServerAuthConfig, configuredApiAuthToken, isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { withProviderServiceTierDTO } from "./provider-capability-config"; import { applySystemEnvToggle } from "../system-env"; import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; @@ -115,7 +114,7 @@ import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortCla import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; -import { mutateManagementConfig, saveManagementConfig, type ManagementContext } from "./context"; +import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; function quotaAutoRefreshSettings(config: OcxConfig) { @@ -152,40 +151,6 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{ return { model, reasoning, models }; } -function aiStudioExtensionOrigin(config: Pick<OcxConfig, "corsAllowOrigins">): string | null { - return config.corsAllowOrigins?.find(origin => origin.startsWith("chrome-extension://")) ?? null; -} - -function serverSettings( - config: OcxConfig, - activeOrigin?: string, - activeConfig?: Pick<OcxConfig, "hostname" | "port" | "tls">, -) { - const active = activeOrigin ?? canonicalServerOrigin(config, config.port); - const configuredOrigin = canonicalServerOrigin(config, config.port); - const configuredListener = { - hostname: config.hostname ?? "127.0.0.1", - port: config.port, - tls: config.tls ?? null, - }; - const activeListener = activeConfig && { - hostname: activeConfig.hostname ?? "127.0.0.1", - port: activeConfig.port, - tls: activeConfig.tls ?? null, - }; - return { - configured: { - ...configuredListener, - aiStudioOrigin: aiStudioExtensionOrigin(config), - }, - activeOrigin: active, - credentialConfigured: !!configuredApiAuthToken(config) - || (config.apiKeys ?? []).some(entry => !!entry.key.trim()), - restartRequired: active !== configuredOrigin - || !!activeListener && JSON.stringify(activeListener) !== JSON.stringify(configuredListener), - }; -} - /** One client's outcome from a fan-out sync. Absent from the list means "left alone". */ interface ClientIntegrationSyncOutcome { readonly client: "grok" | "claude-desktop" | IntegrationClientId; @@ -362,8 +327,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon // Absent means the historical auto-open, so the GUI can render the toggle // without having to know that `undefined` and `true` mean the same thing. oauthOpenBrowser: config.oauthOpenBrowser !== false, - server: serverSettings(config, deps.activeServerOrigin, deps.activeServerConfig), + // Absent means off (today's Design B injection), so the GUI/CLI render a plain switch. codexDesktopAuthless: config.codexDesktopAuthless === true, + // Absent keeps Design B remote compaction; true selects the dedicated provider identity. + codexClientCompaction: config.codexClientCompaction === true, startupHealth: await readStartupHealth(config), codexRuntime: { path: displayCodexRuntimePath(resolved.runtime.command), @@ -451,10 +418,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon codexQuotaAutoRefresh?: unknown; oauthOpenBrowser?: unknown; showCodexSparkQuota?: unknown; - server?: unknown; ultraFastTier?: unknown; codexMainAccountHardLock?: unknown; codexDesktopAuthless?: unknown; + codexClientCompaction?: unknown; }; if (body.codexAutoStart === undefined && body.streamMode === undefined @@ -463,11 +430,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon && body.codexQuotaAutoRefresh === undefined && body.oauthOpenBrowser === undefined && body.showCodexSparkQuota === undefined - && body.server === undefined && body.ultraFastTier === undefined && body.codexMainAccountHardLock === undefined - && body.codexDesktopAuthless === undefined) { - return jsonResponse({ error: "provide a supported settings field" }, 400); + && body.codexDesktopAuthless === undefined + && body.codexClientCompaction === undefined) { + return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, showCodexSparkQuota, ultraFastTier, codexMainAccountHardLock, codexDesktopAuthless, or codexClientCompaction" }, 400); } if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") { return jsonResponse({ error: "codexAutoStart boolean is required" }, 400); @@ -494,29 +461,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon if (body.codexDesktopAuthless !== undefined && typeof body.codexDesktopAuthless !== "boolean") { return jsonResponse({ error: "codexDesktopAuthless boolean is required" }, 400); } - let nextServer: { - hostname: string; - port: number; - tls: OcxConfig["tls"]; - aiStudioOrigin: string | null; - } | undefined; - if (body.server !== undefined) { - if (!isPlainRecord(body.server)) return jsonResponse({ error: "server must be an object" }, 400); - const raw = body.server; - if (typeof raw.hostname !== "string" || !raw.hostname.trim()) return jsonResponse({ error: "server.hostname must be a non-empty string" }, 400); - if (typeof raw.port !== "number" || !Number.isInteger(raw.port) || raw.port < 0 || raw.port > 65535) return jsonResponse({ error: "server.port must be an integer from 0 to 65535" }, 400); - const tlsError = raw.tls === null ? null : serverTlsConfigError(raw.tls); - if (tlsError) return jsonResponse({ error: tlsError }, 400); - if (raw.aiStudioOrigin !== null && ( - typeof raw.aiStudioOrigin !== "string" - || !/^chrome-extension:\/\/[a-z]{32}$/.test(raw.aiStudioOrigin) - )) return jsonResponse({ error: "server.aiStudioOrigin must be an exact chrome-extension origin or null" }, 400); - nextServer = { - hostname: raw.hostname.trim(), - port: raw.port, - tls: raw.tls === null ? undefined : raw.tls as OcxConfig["tls"], - aiStudioOrigin: raw.aiStudioOrigin, - }; + if (body.codexClientCompaction !== undefined && typeof body.codexClientCompaction !== "boolean") { + return jsonResponse({ error: "codexClientCompaction boolean is required" }, 400); } let quotaAutoRefreshChange: { id: string; window: "fiveHour" | "weekly"; enabled: boolean } | undefined; if (body.codexQuotaAutoRefresh !== undefined) { @@ -564,94 +510,68 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon hasOauthOpenBrowser: Object.hasOwn(config, "oauthOpenBrowser"), showCodexSparkQuota: config.showCodexSparkQuota, hasShowCodexSparkQuota: Object.hasOwn(config, "showCodexSparkQuota"), - hostname: config.hostname, - hasHostname: Object.hasOwn(config, "hostname"), - port: config.port, - tls: config.tls, - hasTls: Object.hasOwn(config, "tls"), - corsAllowOrigins: config.corsAllowOrigins, - hasCorsAllowOrigins: Object.hasOwn(config, "corsAllowOrigins"), ultraFastTier: config.ultraFastTier, hasUltraFastTier: Object.hasOwn(config, "ultraFastTier"), codexMainAccountHardLock: config.codexMainAccountHardLock, hasCodexMainAccountHardLock: Object.hasOwn(config, "codexMainAccountHardLock"), codexDesktopAuthless: config.codexDesktopAuthless, hasCodexDesktopAuthless: Object.hasOwn(config, "codexDesktopAuthless"), + codexClientCompaction: config.codexClientCompaction, + hasCodexClientCompaction: Object.hasOwn(config, "codexClientCompaction"), }; const pickerWasEnabled = codexAccountPickerEnabled(config); + let pickerIsEnabled = pickerWasEnabled; const authlessWasEnabled = config.codexDesktopAuthless === true; - const applySettings = (target: OcxConfig): boolean => { + const clientCompactionWasEnabled = config.codexClientCompaction === true; + try { if (typeof body.codexAutoStart === "boolean") { - target.codexAutoStart = body.codexAutoStart; + config.codexAutoStart = body.codexAutoStart; } if (body.streamMode !== undefined) { if (body.streamMode === "auto") { - deleteConfigTopLevelKey(target, "streamMode"); + deleteConfigTopLevelKey(config, "streamMode"); } else { - target.streamMode = body.streamMode as "legacy-tee" | "eager-relay"; + config.streamMode = body.streamMode as "legacy-tee" | "eager-relay"; } } if (typeof body.appOwnedMemoryBudgetMb === "number") { - target.appOwnedMemoryBudgetMb = body.appOwnedMemoryBudgetMb; + config.appOwnedMemoryBudgetMb = body.appOwnedMemoryBudgetMb; } if (body.codexAccountPickerEnabled === true) { - target.codexAccountPickerEnabled = true; - initializeDefaultCodexAccountNamespaces(target); + config.codexAccountPickerEnabled = true; + initializeDefaultCodexAccountNamespaces(config); } else if (body.codexAccountPickerEnabled === false) { - target.codexAccountPickerEnabled = false; + config.codexAccountPickerEnabled = false; } if (typeof body.oauthOpenBrowser === "boolean") { - target.oauthOpenBrowser = body.oauthOpenBrowser; + config.oauthOpenBrowser = body.oauthOpenBrowser; } if (typeof body.showCodexSparkQuota === "boolean") { - target.showCodexSparkQuota = body.showCodexSparkQuota; + config.showCodexSparkQuota = body.showCodexSparkQuota; } - // Off deletes opt-in keys rather than persisting false, preserving the documented - // absent-is-default representation used by the rest of the settings surface. - if (body.ultraFastTier === true) target.ultraFastTier = true; - else if (body.ultraFastTier === false) deleteConfigTopLevelKey(target, "ultraFastTier"); - if (body.codexMainAccountHardLock === true) target.codexMainAccountHardLock = true; - else if (body.codexMainAccountHardLock === false) deleteConfigTopLevelKey(target, "codexMainAccountHardLock"); - if (body.codexDesktopAuthless === true) target.codexDesktopAuthless = true; - else if (body.codexDesktopAuthless === false) deleteConfigTopLevelKey(target, "codexDesktopAuthless"); + // Off deletes the key rather than persisting `false`: absent is the documented + // default, and a written `false` would survive as a decision nobody made. + if (body.ultraFastTier === true) config.ultraFastTier = true; + else if (body.ultraFastTier === false) deleteConfigTopLevelKey(config, "ultraFastTier"); + if (body.codexMainAccountHardLock === true) config.codexMainAccountHardLock = true; + else if (body.codexMainAccountHardLock === false) deleteConfigTopLevelKey(config, "codexMainAccountHardLock"); + if (body.codexDesktopAuthless === true) config.codexDesktopAuthless = true; + else if (body.codexDesktopAuthless === false) deleteConfigTopLevelKey(config, "codexDesktopAuthless"); + if (body.codexClientCompaction === true) config.codexClientCompaction = true; + else if (body.codexClientCompaction === false) deleteConfigTopLevelKey(config, "codexClientCompaction"); if (quotaAutoRefreshChange) { const { id, window, enabled } = quotaAutoRefreshChange; - const setting = { ...(target.codexQuotaAutoRefresh?.[id] ?? {}) }; + const setting = { ...(config.codexQuotaAutoRefresh?.[id] ?? {}) }; if (enabled) setting[window] = true; else delete setting[window]; - const all = { ...(target.codexQuotaAutoRefresh ?? {}) }; + const all = { ...(config.codexQuotaAutoRefresh ?? {}) }; if (Object.keys(setting).length > 0) all[id] = setting; else delete all[id]; - if (Object.keys(all).length > 0) target.codexQuotaAutoRefresh = all; - else deleteConfigTopLevelKey(target, "codexQuotaAutoRefresh"); + if (Object.keys(all).length > 0) config.codexQuotaAutoRefresh = all; + else deleteConfigTopLevelKey(config, "codexQuotaAutoRefresh"); } - if (nextServer) { - target.hostname = nextServer.hostname; - target.port = nextServer.port; - if (nextServer.tls) target.tls = nextServer.tls; - else deleteConfigTopLevelKey(target, "tls"); - const origins = (target.corsAllowOrigins ?? []).filter(origin => !origin.startsWith("chrome-extension://")); - if (nextServer.aiStudioOrigin) origins.push(nextServer.aiStudioOrigin); - if (origins.length) target.corsAllowOrigins = origins; - else deleteConfigTopLevelKey(target, "corsAllowOrigins"); - } - return codexAccountPickerEnabled(target); - }; - if (nextServer) { - const candidate = structuredClone(config); - applySettings(candidate); - const validation = validateConfigCandidate(candidate); - if (!validation.ok) return jsonResponse({ error: validation.error }, 400); - try { - assertServerAuthConfig(candidate); - } catch (error) { - return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); - } - } - let pickerIsEnabled = pickerWasEnabled; - try { - pickerIsEnabled = applySettings(config); - saveManagementConfig(deps, config); + pickerIsEnabled = codexAccountPickerEnabled(config); + (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config); } catch (error) { if (previousSettings.hasCodexAutoStart) config.codexAutoStart = previousSettings.codexAutoStart; else deleteConfigTopLevelKey(config, "codexAutoStart"); @@ -675,13 +595,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon if (previousSettings.hasShowCodexSparkQuota) { config.showCodexSparkQuota = previousSettings.showCodexSparkQuota; } else deleteConfigTopLevelKey(config, "showCodexSparkQuota"); - if (previousSettings.hasHostname) config.hostname = previousSettings.hostname; - else deleteConfigTopLevelKey(config, "hostname"); - config.port = previousSettings.port; - if (previousSettings.hasTls) config.tls = previousSettings.tls; - else deleteConfigTopLevelKey(config, "tls"); - if (previousSettings.hasCorsAllowOrigins) config.corsAllowOrigins = previousSettings.corsAllowOrigins; - else deleteConfigTopLevelKey(config, "corsAllowOrigins"); if (previousSettings.hasUltraFastTier) { config.ultraFastTier = previousSettings.ultraFastTier; } else deleteConfigTopLevelKey(config, "ultraFastTier"); @@ -691,14 +604,22 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon if (previousSettings.hasCodexDesktopAuthless) { config.codexDesktopAuthless = previousSettings.codexDesktopAuthless; } else deleteConfigTopLevelKey(config, "codexDesktopAuthless"); + if (previousSettings.hasCodexClientCompaction) { + config.codexClientCompaction = previousSettings.codexClientCompaction; + } else deleteConfigTopLevelKey(config, "codexClientCompaction"); throw error; } if (typeof body.appOwnedMemoryBudgetMb === "number") { configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb)); enforceAppOwnedMemoryBudget(); } + // Both Desktop compatibility switches change the injected config.toml shape, so converge now + // rather than waiting for the next start; the injector re-reads config and rewrites the form. const authlessIsEnabled = config.codexDesktopAuthless === true; - const catalogRefresh = pickerWasEnabled !== pickerIsEnabled || authlessWasEnabled !== authlessIsEnabled + const clientCompactionIsEnabled = config.codexClientCompaction === true; + const catalogRefresh = pickerWasEnabled !== pickerIsEnabled + || authlessWasEnabled !== authlessIsEnabled + || clientCompactionWasEnabled !== clientCompactionIsEnabled ? await convergeCodexCatalog() : undefined; const catalogRefreshPending = catalogRefresh @@ -717,10 +638,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon catalogRefreshPending, showCodexSparkQuota: config.showCodexSparkQuota === true, codexDesktopAuthless: authlessIsEnabled, + codexClientCompaction: clientCompactionIsEnabled, codexMainAccountHardLock: config.codexMainAccountHardLock === true, mainAccountHardLock: getMainAccountHardLockStatus(config), startupHealth: await readStartupHealth(config), - server: serverSettings(config, deps.activeServerOrigin, deps.activeServerConfig), }); } @@ -907,7 +828,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon : normalizeVisionReasoningForModel(model, sourceReasoning); } - const nextConfig = structuredClone(config); if (body.webSearch) { const pairTouched = body.webSearch.model !== undefined || body.webSearch.backend !== undefined; // Validate against the backend the caller SUBMITTED, across the whole @@ -933,7 +853,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon return jsonResponse(webSearchModelRejection("webSearch.model", effectiveBackend, effectiveModel, candidates), 400); } } - const webSearchCandidate = { ...nextConfig.webSearchSidecar }; + const webSearchCandidate = { ...config.webSearchSidecar }; if (typeof body.webSearch.model === "string") { if (body.webSearch.model === "") delete webSearchCandidate.model; else webSearchCandidate.model = body.webSearch.model; @@ -1003,63 +923,36 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon if (body.webSearch.streamRoutedModelOutput) webSearchCandidate.streamRoutedModelOutput = true; else delete webSearchCandidate.streamRoutedModelOutput; } - nextConfig.webSearchSidecar = webSearchCandidate; + config.webSearchSidecar = webSearchCandidate; } if (body.vision) { - nextConfig.visionSidecar = { ...nextConfig.visionSidecar }; + config.visionSidecar = { ...config.visionSidecar }; if (typeof body.vision.model === "string") { - if (body.vision.model === "") delete nextConfig.visionSidecar.model; - else nextConfig.visionSidecar.model = body.vision.model; + if (body.vision.model === "") delete config.visionSidecar.model; + else config.visionSidecar.model = body.vision.model; } - if (body.vision.backend === null) delete nextConfig.visionSidecar.backend; + if (body.vision.backend === null) delete config.visionSidecar.backend; else if (body.vision.backend === "openai" || body.vision.backend === "anthropic" || body.vision.backend === "routed") { - nextConfig.visionSidecar.backend = body.vision.backend; + config.visionSidecar.backend = body.vision.backend; } if (typeof body.vision.maxDescriptionsPerTurn === "number") { - nextConfig.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn; + config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn; } if (typeof body.vision.enabled === "boolean") { // `true` is the default — drop the key so disable/re-enable does not rewrite the file. - if (body.vision.enabled) delete nextConfig.visionSidecar.enabled; - else nextConfig.visionSidecar.enabled = false; + if (body.vision.enabled) delete config.visionSidecar.enabled; + else config.visionSidecar.enabled = false; } if (typeof body.vision.timeoutMs === "number") { - nextConfig.visionSidecar.timeoutMs = body.vision.timeoutMs; + config.visionSidecar.timeoutMs = body.vision.timeoutMs; } if (visionReasoningTouched) { - if (normalizedVisionReasoning === undefined) delete nextConfig.visionSidecar.reasoning; - else nextConfig.visionSidecar.reasoning = normalizedVisionReasoning; + if (normalizedVisionReasoning === undefined) delete config.visionSidecar.reasoning; + else config.visionSidecar.reasoning = normalizedVisionReasoning; } } - let committedWebSearch: OcxConfig["webSearchSidecar"]; - let committedVision: OcxConfig["visionSidecar"]; - const persisted = mutateManagementConfig(deps, disk => { - if (body.webSearch) { - const latest = { ...disk.webSearchSidecar }; - for (const key of ["model", "backend", "reasoning", "streamRoutedModelOutput", "exaApiKey", "xSearch"] as const) { - if (!Object.hasOwn(body.webSearch, key)) continue; - if (Object.hasOwn(nextConfig.webSearchSidecar ?? {}, key)) latest[key] = nextConfig.webSearchSidecar![key] as never; - else delete latest[key]; - } - disk.webSearchSidecar = latest; - committedWebSearch = structuredClone(latest); - } - if (body.vision) { - const latest = { ...disk.visionSidecar }; - for (const key of ["model", "backend", "reasoning", "maxDescriptionsPerTurn", "enabled", "timeoutMs"] as const) { - if (!Object.hasOwn(body.vision, key) && !(key === "reasoning" && visionReasoningTouched)) continue; - if (Object.hasOwn(nextConfig.visionSidecar ?? {}, key)) latest[key] = nextConfig.visionSidecar![key] as never; - else delete latest[key]; - } - disk.visionSidecar = latest; - committedVision = structuredClone(latest); - } - return { changed: true, value: true }; - }); - if (persisted.status === "unavailable") return jsonResponse({ error: "management persistence unavailable" }, 500, req, config); - if (body.webSearch) config.webSearchSidecar = committedWebSearch; - if (body.vision) config.visionSidecar = committedVision; + saveConfigPreservingClaudeCode(config); const ws = config.webSearchSidecar ?? {}; const vision = await sidecarVisionResponseSettings(config); const savedWebSearchCandidates = await webSearchCandidateRows(config); @@ -1114,7 +1007,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon if (body.model === "") delete config.shadowCallIntercept.model; else config.shadowCallIntercept.model = body.model; } - saveManagementConfig(deps, config); + saveConfigPreservingClaudeCode(config); const sci = config.shadowCallIntercept; return jsonResponse({ ok: true, diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index c86c7f0562..104b5fb13c 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -419,6 +419,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res bytes: result.bytes, ...(result.trashDir ? { trashDir: result.trashDir } : {}), removedPaths: result.removedPaths, + ...(result.skippedReferencedPaths?.length ? { skippedReferencedPaths: result.skippedReferencedPaths } : {}), }); } catch { return jsonResponse({ diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 03693e80f3..f62cce990a 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -367,13 +367,14 @@ async function handleCodexToggle(ctx: ManagementContext): Promise<Response> { } } const { restoreNativeCodexAsync } = await import("../../codex/inject"); + const { OCX_NATIVE_REPLAY_RECOVERY_NOTE } = await import("../../responses/compaction"); const restored = await restoreNativeCodexAsync({ revalidateDesiredState: true }); return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: restored.success ? "absent" : "unsafe", desiredEnabled: enabled, message: restored.success - ? "Codex restored to its native path; the proxy is still serving other clients" + ? `Codex restored to its native path; the proxy is still serving other clients. ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}` : `Codex intent saved, but restoring the native path did not complete: ${restored.message}`, ...(restored.success ? (durable ? {} : { reason: "not_durable" }) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 45ab012f8f..0fce336631 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -12,12 +12,14 @@ import { isValidProviderName, modelDisplayNamesConfigError, multiAgentGuidanceEnabled, + mutatePersistedConfig, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, providerBaseUrlConfigError, providerHeadersConfigError, requestPacingConfigError, readConfigAdmissionSnapshot, + saveConfigPreservingClaudeCode, upstreamHttpVersionConfigError, validateConfigCandidate, withConfigMutationLockSync, @@ -31,17 +33,15 @@ import { submitManualLoginCode, upsertOAuthProvider, } from "../../oauth"; +import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError } from "../../config/provider-validation"; import { replaceProviderAccountSet } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { - antigravityOAuthDestinationConfigError, - isAntigravityOAuthProvider, -} from "../../lib/provider-tls-profile"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { DAILY_ANTIGRAVITY_HOST, PROD_ANTIGRAVITY_HOST } from "../../adapters/google-antigravity-hosts"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; @@ -60,12 +60,11 @@ import { clearKeyCooldowns } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; -import { comboPublicModelId } from "../../combos/types"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache"; import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; +import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, selectedProviderContextCaps, forgetProviderContextCap, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; @@ -99,12 +98,7 @@ import { type ProviderEditorProviderDTO, } from "../auth-cors"; import { providerServiceTierConfigError } from "./provider-capability-config"; -import { - maxWsFrameBytesConfigError, - providerEmptyToolOutputConfigError, - wsUpstreamConfigError, -} from "../../config/provider-validation"; -import { getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; +import { providerEmptyToolOutputConfigError } from "../../config/provider-validation"; import { applySystemEnvToggle } from "../system-env"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, @@ -118,14 +112,11 @@ import { xaiResponsesOptInState, } from "../../providers/xai-responses-opt-in"; import { dropProviderCustomModels } from "../../providers/provider-id-rewrite"; -import { apiKeyPoolEntryId } from "../../providers/api-keys"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; -import { mutateManagementConfig, saveManagementConfig, type ManagementContext } from "./context"; +import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; -import { resolveAiStudioCredentials } from "../../oauth/aistudio-credentials"; -import { buildAiStudioHeaders, parseGoogleCookieJar } from "../../oauth/google-aistudio-auth"; type ProviderPatchApplication = | { error: string } @@ -137,111 +128,6 @@ type ProviderPatchApplication = headersTouched: boolean; }; -type ProviderMutationValue = - | { config: OcxConfig; fallbackDefault?: string; droppedCustomModels?: number } - | { error: string; code?: string; status?: number; combos?: string[]; routingProfiles?: string[] }; - -function reconcileSubmittedApiKey(provider: OcxProviderConfig): string | undefined { - if (!provider.apiKey || !provider.apiKeyPool) return; - const key = provider.apiKey.trim(); - if (!key || /[\r\n]/.test(key)) return; - if (provider.apiKeyPool.some(entry => entry.key === key)) { - provider.apiKey = key; - return; - } - const id = apiKeyPoolEntryId(key); - if (provider.apiKeyPool.some(entry => entry.id === id)) return "API-key pool ID collision"; - provider.apiKeyPool.push({ id, key, addedAt: Date.now() }); - provider.apiKey = key; -} - -function providerNamespaceCollisionError(config: OcxConfig, name: string): string | undefined { - const accountCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); - if (accountCollision) return accountCollision; - const comboCollision = (name === "combo" && Object.keys(config.combos ?? {}).length > 0) - || Object.entries(config.combos ?? {}).some(([id, combo]) => { - const publicId = comboPublicModelId(id, combo); - return id === name || publicId === name || publicId.startsWith(`${name}/`); - }); - if (comboCollision) return "provider name must not collide with a configured combo namespace"; - const profileCollision = Object.values(config.routingProfiles ?? {}).some(profile => { - const alias = profile.alias?.trim(); - return alias === name || alias?.startsWith(`${name}/`); - }); - return profileCollision - ? "provider name must not collide with a configured routing profile namespace" - : undefined; -} - -function adoptCommittedConfig(target: OcxConfig, source: OcxConfig): void { - for (const key of Object.keys(target)) delete (target as unknown as Record<string, unknown>)[key]; - Object.assign(target, structuredClone(source)); -} - -function unavailableMutationResponse(reason: "missing" | "invalid" | "conflict", req: Request, config: OcxConfig): Response { - const message = reason === "conflict" ? "config changed while applying this update; retry" : `config is ${reason}`; - return jsonResponse({ error: message }, reason === "conflict" ? 409 : 500, req, config); -} - -const AI_STUDIO_REAUTH_ERROR = "Session expired or missing — re-authentication required"; -const AI_STUDIO_PROBE_TIMEOUT_MS = 8_000; -const AI_STUDIO_PROBE_MODEL = "gemini-2.5-flash"; -const AI_STUDIO_ORIGIN = "https://aistudio.google.com"; - -let aiStudioProbeFetchForTests: typeof fetch | undefined; - -export function setAiStudioProbeFetchForTests(fetchImpl?: typeof fetch): void { - aiStudioProbeFetchForTests = fetchImpl; -} - -function isAiStudioHtmlSignIn(text: string): boolean { - const lower = text.trim().toLowerCase(); - return lower.startsWith("<!doctype") || lower.startsWith("<html") || lower.includes("accounts.google.com/v3/signin"); -} - -async function probeAiStudioLiveSession( - name: string, - prov: OcxProviderConfig, -): Promise<{ ok: boolean; latencyMs: number; authState?: "connected"; message?: string; error?: string }> { - const base = (prov.baseUrl || "https://alkalimakersuite-pa.clients6.google.com").replace(/\/+$/, ""); - const url = base + "/v1internal:generateContent"; - const credentials = resolveAiStudioCredentials(prov, undefined, url); - if (credentials.kind !== "ready") return { ok: false, latencyMs: 0, error: AI_STUDIO_REAUTH_ERROR }; - const jar = parseGoogleCookieJar(credentials.cookieHeader); - const headers = await buildAiStudioHeaders(jar, AI_STUDIO_ORIGIN); - const body = JSON.stringify({ - model: AI_STUDIO_PROBE_MODEL, - contents: [{ role: "user", parts: [{ text: "ping" }] }], - generationConfig: { maxOutputTokens: 1 }, - }); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), AI_STUDIO_PROBE_TIMEOUT_MS); - const started = Date.now(); - try { - const outboundProvider = aiStudioProbeFetchForTests ? { ...prov, fetch: aiStudioProbeFetchForTests } : prov; - const response = await providerOutboundPost(name, outboundProvider, url, { headers, body, signal: controller.signal }); - const latencyMs = Date.now() - started; - const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); - const text = await response.text().catch(() => ""); - if ((response.status >= 300 && response.status < 400) || response.status === 401 || response.status === 403) { - return { ok: false, latencyMs, error: AI_STUDIO_REAUTH_ERROR }; - } - if (contentType.includes("text/html") || isAiStudioHtmlSignIn(text)) { - return { ok: false, latencyMs, error: AI_STUDIO_REAUTH_ERROR }; - } - if (response.status !== 200) return { ok: false, latencyMs, error: "AI Studio connection probe failed" }; - try { JSON.parse(text); } catch { return { ok: false, latencyMs, error: "AI Studio connection probe failed" }; } - return { ok: true, latencyMs, authState: "connected", message: "AI Studio session verified" }; - } catch (error) { - if (error instanceof ProviderOutboundPolicyError && /\breturned 3\d\d redirect\b/.test(error.message)) { - return { ok: false, latencyMs: Date.now() - started, error: AI_STUDIO_REAUTH_ERROR }; - } - return { ok: false, latencyMs: Date.now() - started, error: "AI Studio connection probe failed" }; - } finally { - clearTimeout(timer); - } -} - const PROVIDER_ALIAS_OVERLAY_FIELDS = ["alias", "modelAliases", "defaultAliases"] as const; type ProviderAliasOverlayField = typeof PROVIDER_ALIAS_OVERLAY_FIELDS[number]; @@ -386,7 +272,7 @@ function providerEditorCandidate( candidate.providers = providers; for (const name of removedProviders) { dropProviderCustomModels(candidate, name); - setProviderContextCap(candidate, name, false); + forgetProviderContextCap(candidate, name); } const validated = validateConfigCandidate(candidate); if (!validated.ok) { @@ -412,6 +298,8 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo else live.customModels = structuredClone(persisted.customModels); if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; else live.providerContextCaps = structuredClone(persisted.providerContextCaps); + if (persisted.providerContextCapValues === undefined) delete live.providerContextCapValues; + else live.providerContextCapValues = structuredClone(persisted.providerContextCapValues); if (persisted.disabledModels === undefined) delete live.disabledModels; else live.disabledModels = [...persisted.disabledModels]; if (persisted.modelDiscovery === undefined) delete live.modelDiscovery; @@ -506,24 +394,6 @@ function applyProviderPatchFields( return { error: "apiKeyTransport must be x-api-key, bearer, or empty to clear" }; } } - if (Object.hasOwn(rawBody, "azureCredential")) { - const value = rawBody.azureCredential; - if (value === null) { - delete next.azureCredential; - } else { - if (!isPlainRecord(value)) return { error: "azureCredential must be an object or null" }; - const credential = structuredClone(value) as Record<string, unknown>; - if (typeof credential.managedIdentityClientId === "string") { - credential.managedIdentityClientId = credential.managedIdentityClientId.trim(); - } - next.azureCredential = credential as OcxProviderConfig["azureCredential"]; - // Selecting Azure identity is an explicit credential replacement. A stale key or - // pool must not survive underneath the new keyless identity mode. - delete next.apiKey; - delete next.apiKeyPool; - } - touched = true; - } if (Object.hasOwn(rawBody, "note")) { if (typeof rawBody.note !== "string") return { error: "note must be a string" }; const note = rawBody.note.trim(); @@ -552,28 +422,6 @@ function applyProviderPatchFields( } touched = true; } - if (Object.hasOwn(rawBody, "wsUpstream")) { - const value = rawBody.wsUpstream; - if (value === null) { - delete next.wsUpstream; - } else { - const error = wsUpstreamConfigError(value); - if (error) return { error }; - next.wsUpstream = value as boolean; - } - touched = true; - } - if (Object.hasOwn(rawBody, "maxWsFrameBytes")) { - const value = rawBody.maxWsFrameBytes; - if (value === null) { - delete next.maxWsFrameBytes; - } else { - const error = maxWsFrameBytesConfigError(value); - if (error) return { error }; - next.maxWsFrameBytes = value as number; - } - touched = true; - } if (Object.hasOwn(rawBody, "xaiResponsesOptIn")) { if (name !== "xai") return { error: "xaiResponsesOptIn is valid only for provider xai" }; if (typeof rawBody.xaiResponsesOptIn !== "boolean") { @@ -603,17 +451,6 @@ function applyProviderPatchFields( } touched = true; } - if (Object.hasOwn(rawBody, "tlsProfile")) { - const value = rawBody.tlsProfile; - if (value === null || value === "") { - delete next.tlsProfile; - } else if (value === "antigravity-browser") { - next.tlsProfile = value; - } else { - return { error: "tlsProfile must be antigravity-browser or null" }; - } - touched = true; - } if (Object.hasOwn(rawBody, "upstreamHttpVersion")) { const value = rawBody.upstreamHttpVersion; if (value === null || value === "") { @@ -627,28 +464,6 @@ function applyProviderPatchFields( } touched = true; } - if (Object.hasOwn(rawBody, "wsUpstream")) { - const value = rawBody.wsUpstream; - if (value === null) { - delete next.wsUpstream; - } else { - const error = wsUpstreamConfigError(value); - if (error) return { error }; - next.wsUpstream = value as boolean; - } - touched = true; - } - if (Object.hasOwn(rawBody, "maxWsFrameBytes")) { - const value = rawBody.maxWsFrameBytes; - if (value === null) { - delete next.maxWsFrameBytes; - } else { - const error = maxWsFrameBytesConfigError(value); - if (error) return { error }; - next.maxWsFrameBytes = value as number; - } - touched = true; - } if (Object.hasOwn(rawBody, "upstreamWebsocket")) { if (typeof rawBody.upstreamWebsocket !== "boolean") return { error: "upstreamWebsocket must be a boolean" }; next.upstreamWebsocket = rawBody.upstreamWebsocket; @@ -904,7 +719,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp // Presence only (#959 review): header names and values never leave the process. hasHeaders: !!p.headers && Object.keys(p.headers).length > 0, allowPrivateNetwork: p.allowPrivateNetwork === true, - replayTransientFailures: p.replayTransientFailures === true, liveModels: p.liveModels !== false, requestPacing: p.requestPacing, models: p.models ?? [], @@ -913,8 +727,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp pinnedReasoningEffort: p.pinnedReasoningEffort, modelPinnedReasoningEfforts: p.modelPinnedReasoningEfforts, modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits, - wsUpstream: p.wsUpstream, - maxWsFrameBytes: p.maxWsFrameBytes, modelSupportsServiceTier: p.modelSupportsServiceTier, noStructuredOutputModels: p.noStructuredOutputModels, retainModels: p.retainModels, @@ -923,8 +735,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp upstreamWebsocket: p.upstreamWebsocket === true, authMode: p.authMode, apiKeyTransport: p.apiKeyTransport, - tlsProfile: p.tlsProfile, - tlsProfileStatus: p.tlsProfile === undefined ? "disabled" : getProviderTlsProfileStatus(name, p), disabled: p.disabled === true, codexAccountMode: providerCodexAccountMode(name, p), ...(name === "xai" ? { xaiResponsesOptInState: xaiResponsesOptInState(p) } : {}), @@ -1050,7 +860,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp if (resolvedError) return jsonResponse({ error: resolvedError, code: "invalid_provider_destination" }, 400); } - const outcome = mutateManagementConfig<ProviderEditorMutationValue>(deps, persisted => { + const outcome = mutatePersistedConfig<ProviderEditorMutationValue>(persisted => { if (!isDeepStrictEqual(providerEditorConfigDTO(persisted), baselineResult.value)) { return { changed: false, @@ -1078,7 +888,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp persisted.modelDiscovery = candidate.config.modelDiscovery; for (const name of candidate.removedProviders) { dropProviderCustomModels(persisted, name); - setProviderContextCap(persisted, name, false); + forgetProviderContextCap(persisted, name); } return { changed: true, @@ -1118,13 +928,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } const name = typeof body.name === "string" ? body.name.trim() : ""; if (!isPlainRecord(body.provider)) return jsonResponse({ error: "provider must be a plain object" }, 400); - const submittedProvider = body.provider as Record<string, unknown>; - const submittedCredential = isPlainRecord(submittedProvider.azureCredential) - ? submittedProvider.azureCredential as Record<string, unknown> - : undefined; - if (typeof submittedCredential?.managedIdentityClientId === "string") { - submittedCredential.managedIdentityClientId = submittedCredential.managedIdentityClientId.trim(); - } const existing = config.providers[name]; const aliasOwnershipError = providerAliasOverlayOwnershipError(body.provider, existing); if (aliasOwnershipError) return jsonResponse({ error: aliasOwnershipError }, 400); @@ -1145,9 +948,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp // reached disk and the next loadConfig() refused it. Canonicalize to absent, which is what // "clear" means everywhere else. if (prov && prov.upstreamHttpVersion === null) delete prov.upstreamHttpVersion; - if (prov && prov.wsUpstream === null) delete prov.wsUpstream; - if (prov && (prov as unknown as Record<string, unknown>).upstreamWebsocket === null) delete (prov as unknown as Record<string, unknown>).upstreamWebsocket; - if (prov && (prov as unknown as Record<string, unknown>).maxWsFrameBytes === null) delete prov.maxWsFrameBytes; if (!name || !prov?.adapter || !prov?.baseUrl) { return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400); } @@ -1156,7 +956,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp if (!isValidProviderName(name)) { return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400); } - const namespaceCollision = providerNamespaceCollisionError(config, name); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); if (namespaceCollision) { return jsonResponse({ error: namespaceCollision }, 409); } @@ -1185,18 +985,17 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp const submittedModelDisplayNames = Object.hasOwn(prov, "modelDisplayNames"); const submittedRequestPacing = Object.hasOwn(prov, "requestPacing"); const submittedUpstreamWebsocket = Object.hasOwn(prov, "upstreamWebsocket"); - const submittedWsUpstream = Object.hasOwn(prov, "wsUpstream"); - const submittedMaxWsFrameBytes = Object.hasOwn(prov, "maxWsFrameBytes"); // Same trap, one more field: DeepSeek carries a registry default of `true` for // annotateEmptyToolOutputs, so enrichment cannot distinguish "the client omitted it" // from "the registry supplied it" either. Without this sample, an unrelated edit that // omits the key resurrects the registry default over an operator's explicit `false`. const submittedAnnotateEmptyToolOutputs = Object.hasOwn(prov, "annotateEmptyToolOutputs"); enrichProviderFromCatalog(name, prov); + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); // Overwriting an existing provider must not drop its multi-key pool: carry it over, then // let the (possibly new) apiKey join the pool as the active entry. const existingPool = config.providers[name]?.apiKeyPool; - if (existingPool && !prov.apiKeyPool && !prov.azureCredential) prov.apiKeyPool = existingPool; + if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool; // The same rule applies to user-configured price overlays: the dashboard's // add/edit form does not send modelCosts, so an overwrite must not silently // erase hand-edited per-model prices from Logs/Usage estimates. @@ -1226,12 +1025,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp if (!submittedAnnotateEmptyToolOutputs && existing?.annotateEmptyToolOutputs !== undefined) { prov.annotateEmptyToolOutputs = existing.annotateEmptyToolOutputs; } - if (!submittedWsUpstream && existing?.wsUpstream !== undefined) { - prov.wsUpstream = existing.wsUpstream; - } - if (!submittedMaxWsFrameBytes && existing?.maxWsFrameBytes !== undefined) { - prov.maxWsFrameBytes = existing.maxWsFrameBytes; - } // The provider add/edit form may omit this transport option. Preserve the stored value // during a full overwrite; PATCH remains the explicit mutation path, and `!== undefined` // keeps an operator's explicit false from being treated as absent. @@ -1256,68 +1049,65 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp // completed during that wait remains authoritative instead of being overwritten by the // older ownership snapshot used to admit this POST. restorePersistedAliasOverlays(prov, config.providers[name]); - const submitted = stripRegistryOnlyStaticHeaders(name, prov); - if (typeof submitted.apiKey === "string" && submitted.apiKey.trim() === "") { - delete submitted.apiKey; - } - const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => { - const namespaceCollision = providerNamespaceCollisionError(fresh, name); - if (namespaceCollision) { - return { changed: false, value: { error: namespaceCollision, status: 409 } }; + // The add/edit form omits wire choices. Read after DNS so a concurrent switch + // remains authoritative, including the marker that protects it on the next boot. + if (name === "xai") { + const latest = config.providers[name]; + if (!Object.hasOwn(body.provider, "modelAdapters") && latest?.modelAdapters) { + prov.modelAdapters = { ...latest.modelAdapters }; } - const persisted = fresh.providers[name]; - const nextSubmitted = structuredClone(submitted); - // The editor omits dedicated alias and xAI wire-choice state. Re-read it under - // the persistence mutation so a concurrent switch remains authoritative. - restorePersistedAliasOverlays(nextSubmitted, persisted); - // Pins were applied to the pre-await snapshot for admission; re-apply them to - // the latest live row under the persistence mutation so a concurrent pin edit - // remains authoritative instead of being overwritten by the older snapshot. - const latestPinError = applyProviderPinFields(nextSubmitted, rawProvider, persisted); - if (latestPinError) return { changed: false, value: { error: latestPinError, status: 400 } }; - if (name === "xai") { - if (!Object.hasOwn(body.provider as object, "modelAdapters") && persisted?.modelAdapters) { - nextSubmitted.modelAdapters = { ...persisted.modelAdapters }; - } - if (persisted?.xaiResponsesDefaultVersion !== undefined) { - nextSubmitted.xaiResponsesDefaultVersion = persisted.xaiResponsesDefaultVersion; - } + if (latest?.xaiResponsesDefaultVersion !== undefined) { + prov.xaiResponsesDefaultVersion = latest.xaiResponsesDefaultVersion; } - initializeProviderModelSelection(name, nextSubmitted, persisted, fresh); - const committed = { - ...(persisted ? structuredClone(persisted) : {}), - ...nextSubmitted, - } as OcxProviderConfig; - // The credential-preserving spread must not resurrect explicit pin clears. - const committedPinError = applyProviderPinFields(committed, rawProvider, persisted); - if (committedPinError) return { changed: false, value: { error: committedPinError, status: 400 } }; - if (nextSubmitted.azureCredential) { - // Switching identity modes is an explicit credential replacement. The atomic merge - // preserves omitted secrets generally, but Azure identity cannot coexist with a stale - // key or key pool from the prior row. - delete committed.apiKey; - delete committed.apiKeyPool; + } + // Reapply pins to the latest live row after DNS/import awaits, then validate the + // complete draft before adopting any provider/default state. + const latest = config.providers[name]; + const latestPinError = applyProviderPinFields(prov, body.provider, latest); + if (latestPinError) return jsonResponse({ error: latestPinError }, 400); + const pinsOwned = Object.hasOwn(body.provider, "pinnedReasoningEffort") + || Object.hasOwn(body.provider, "modelPinnedReasoningEfforts") + || latest?.pinnedReasoningEffort !== undefined || latest?.modelPinnedReasoningEfforts !== undefined; + // New registration also edits discovery/disabled-model state; stage those + // side effects with the pin draft instead of mutating live state before validation. + const registrationDraft = pinsOwned && !latest ? { + ...config, + ...(config.modelDiscovery === undefined ? {} : { modelDiscovery: structuredClone(config.modelDiscovery) }), + } : undefined; + initializeProviderModelSelection(name, prov, latest, registrationDraft ?? config); + const candidate = stripRegistryOnlyStaticHeaders(name, prov); + if (pinsOwned) { + const draft = { ...(registrationDraft ?? config), providers: { ...config.providers, [name]: candidate }, + ...(body.setDefault === true ? { defaultProvider: name } : {}) }; + const validation = validateConfigCandidate(draft); + if (!validation.ok) return jsonResponse({ error: validation.error }, 400); + } + const previous = Object.getOwnPropertyDescriptor(config.providers, name); + const rollback = pinsOwned ? captureConfigTopLevelRollback(config, ["defaultProvider", "modelDiscovery", "disabledModels"]) : undefined; + try { + if (registrationDraft) { + for (const key of ["modelDiscovery", "disabledModels"] as const) { + if (Object.hasOwn(registrationDraft, key)) Object.defineProperty(config, key, { + value: registrationDraft[key], writable: true, enumerable: true, configurable: true, + }); + } } - const keyCollision = reconcileSubmittedApiKey(committed); - if (keyCollision) return { changed: false, value: { error: keyCollision, status: 409 } }; - fresh.providers[name] = committed; - if (body.setDefault === true) fresh.defaultProvider = name; - const pinsOwned = Object.hasOwn(rawProvider, "pinnedReasoningEffort") - || Object.hasOwn(rawProvider, "modelPinnedReasoningEfforts") - || committed.pinnedReasoningEffort !== undefined - || committed.modelPinnedReasoningEfforts !== undefined; - if (pinsOwned) { - const validation = validateConfigCandidate(structuredClone(fresh)); - if (!validation.ok) return { changed: false, value: { error: validation.error, status: 400 } }; + config.providers[name] = candidate; + if (body.setDefault === true) config.defaultProvider = name; + (deps.saveConfigPreservingClaudeCode ?? save)(config); + } catch (error) { + if (rollback) { + if (previous) Object.defineProperty(config.providers, name, previous); + else delete config.providers[name]; + rollback(); } - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) { - return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 409, req, config); + throw error; } - adoptCommittedConfig(config, outcome.value.config); reconcileLiveStateStores(); + if (prov.apiKey && prov.apiKeyPool) { + const { addProviderApiKey } = await import("../../providers/api-keys"); + addProviderApiKey(config, name, prov.apiKey); + } const { clearModelCache } = await import("../../codex/model-cache"); clearModelCache(name); const catalogRefresh = await convergeCodexCatalog(); @@ -1338,9 +1128,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp const canonicalBudgetOnly = name === "openai" && keys.length === 1 && keys[0] === "modelAutoCompactTokenLimits"; - const canonicalEmptyToolOutputOnly = name === "openai" - && keys.length === 1 - && keys[0] === "annotateEmptyToolOutputs"; // codexAccountMode keeps its dedicated side-effect path (quota cache clear, thread map // clear, pool prime) and is mutually exclusive with every other patch field. @@ -1357,17 +1144,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp if (!provider || !isCanonicalOpenAiForwardProvider(provider)) { return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400); } - const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => { - const persisted = fresh.providers.openai; - if (!persisted || !isCanonicalOpenAiForwardProvider(persisted)) { - return { changed: false, value: { error: "provider openai must be the canonical built-in provider", status: 409 } }; - } - fresh.providers.openai = { ...persisted, codexAccountMode: mode }; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 409); - adoptCommittedConfig(config, outcome.value.config); + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + config.providers.openai = { ...provider, codexAccountMode: mode }; + save(config); reconcileLiveStateStores(); (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)(); (deps.clearThreadAccountMap ?? clearThreadAccountMap)(); @@ -1392,19 +1171,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp if (config.providers[name]!.disabled) { return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400); } - const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => { - if (!hasOwnProvider(fresh.providers, name)) { - return { changed: false, value: { error: "unknown provider", status: 404 } }; - } - if (fresh.providers[name]!.disabled) { - return { changed: false, value: { error: "cannot set a disabled provider as default", code: "default_provider_disabled", status: 400 } }; - } - fresh.defaultProvider = name; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse(outcome.value, outcome.value.status ?? 409); - adoptCommittedConfig(config, outcome.value.config); + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + config.defaultProvider = name; + save(config); reconcileLiveStateStores(); return jsonResponse({ success: true, name, defaultProvider: name }); } @@ -1421,17 +1190,15 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp const pacingOnly = keys.every(key => key === "requestPacing"); if (applied.editorTouched && !pacingOnly) { - const providerError = canonicalEmptyToolOutputOnly - ? providerEmptyToolOutputConfigError(name, next) - : canonicalBudgetOnly - ? canonicalOpenAiBudgetPatchError(next, rawBody, keys, config) - : providerManagementConfigError( + const providerError = canonicalBudgetOnly + ? canonicalOpenAiBudgetPatchError(next, rawBody, keys, config) + : providerManagementConfigError( name, providerTransportValidationCandidate(next as unknown as Record<string, unknown>), ) - ?? providerEmptyToolOutputConfigError(name, next); + ?? providerEmptyToolOutputConfigError(name, next); if (providerError) return jsonResponse({ error: providerError }, 400); - if (!canonicalBudgetOnly && !canonicalEmptyToolOutputOnly) { + if (!canonicalBudgetOnly) { const serviceTierError = providerServiceTierConfigError(name, next); if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400); // Same DNS gate as POST and re-enable: the canonical built-in OpenAI forward @@ -1458,52 +1225,59 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp // mask onto the newest provider under the mutation lock right before saving, so two // concurrent PATCHes updating different fields/headers both survive instead of the // later save clobbering the earlier snapshot. - const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => { - if (!hasOwnProvider(fresh.providers, name)) { - return { changed: false, value: { error: "unknown provider", status: 404 } }; - } - const replay = applyProviderPatchFields(name, fresh.providers[name]!, rawBody, keys, fresh); + let replayError: string | undefined; + withConfigMutationLockSync(() => { + const replay = applyProviderPatchFields(name, config.providers[name]!, rawBody, keys, config); if ("error" in replay) { - return { changed: false, value: { error: replay.error, status: 409 } }; + replayError = replay.error; + return; } if (replay.editorTouched && !pacingOnly) { - const syncError = canonicalEmptyToolOutputOnly - ? providerEmptyToolOutputConfigError(name, replay.next) - : canonicalBudgetOnly - ? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, fresh) - : providerManagementConfigError( + const syncError = canonicalBudgetOnly + ? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, config) + : providerManagementConfigError( name, providerTransportValidationCandidate(replay.next as unknown as Record<string, unknown>), ) - ?? providerEmptyToolOutputConfigError(name, replay.next); + ?? providerEmptyToolOutputConfigError(name, replay.next); if (syncError) { - return { changed: false, value: { error: syncError, status: 409 } }; + replayError = syncError; + return; } - if (!canonicalBudgetOnly && !canonicalEmptyToolOutputOnly) { + if (!canonicalBudgetOnly) { const serviceTierError = providerServiceTierConfigError(name, replay.next); if (serviceTierError) { - return { changed: false, value: { error: serviceTierError, status: 409 } }; + replayError = serviceTierError; + return; } } } else if (replay.enablingOpenAi && !isCanonicalOpenAiForwardProvider(replay.next)) { - return { changed: false, value: { error: "provider openai must be the canonical built-in provider", status: 409 } }; + replayError = "provider openai must be the canonical built-in provider"; + return; } // A PATCH that managed headers owns the resulting block: the clear path restores // registry static headers, so exact-match stripping must not erase them again. const candidate = replay.headersTouched ? replay.next : stripRegistryOnlyStaticHeaders(name, replay.next); const pinsTouched = Object.hasOwn(rawBody, "pinnedReasoningEffort") || Object.hasOwn(rawBody, "modelPinnedReasoningEfforts"); if (pinsTouched) { - const draft = structuredClone(fresh); - draft.providers[name] = candidate; - const validation = validateConfigCandidate(draft); - if (!validation.ok) return { changed: false, value: { error: validation.error, status: 400 } }; + const validation = validateConfigCandidate({ ...config, providers: { ...config.providers, [name]: candidate } }); + if (!validation.ok) { replayError = validation.error; return; } + } + const previous = Object.getOwnPropertyDescriptor(config.providers, name); + const rollback = pinsTouched ? captureConfigTopLevelRollback(config, []) : undefined; + try { + config.providers[name] = candidate; + (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config); + } catch (error) { + if (rollback) { + if (previous) Object.defineProperty(config.providers, name, previous); + else delete config.providers[name]; + rollback(); + } + throw error; } - fresh.providers[name] = candidate; - return { changed: replay.touched, value: { config: structuredClone(fresh) } }; }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 409); - adoptCommittedConfig(config, outcome.value.config); + if (replayError !== undefined) return jsonResponse({ error: replayError }, 409); reconcileLiveStateStores(); if (applied.editorTouched && !pacingOnly) { const { clearModelCache } = await import("../../codex/model-cache"); @@ -1535,8 +1309,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp if (prov.disabled) { return jsonResponse({ ok: false, error: "Provider is disabled", latencyMs: 0 }); } - const antigravityError = antigravityOAuthDestinationConfigError(name, prov); - if (antigravityError) return jsonResponse({ ok: false, error: antigravityError, latencyMs: 0 }, 400); if (prov.authMode === "forward") { return jsonResponse({ ok: true, @@ -1544,9 +1316,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).", }); } - if (prov.googleMode === "ai-studio-web" || name === "google-aistudio") { - return jsonResponse(await probeAiStudioLiveSession(name, prov)); - } if (prov.liveModels === false) { // A static catalog has no live discovery endpoint to test. This is neither // positive connectivity evidence nor an outage, and it must stay before @@ -1554,12 +1323,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp return jsonResponse({ applicable: false, reason: "static_catalog", latencyMs: 0 }); } const { buildModelsRequest, getValidAccessTokenSnapshot, resolveModelsAuthToken } = await import("../../oauth"); - const antigravity = isAntigravityOAuthProvider(name, prov); + const antigravity = effectiveGoogleMode(name, prov) === "cloud-code-assist"; const snapshot = antigravity ? await getValidAccessTokenSnapshot(name).catch(() => undefined) : undefined; const apiKey = snapshot?.accessToken ?? await resolveModelsAuthToken(name, prov); - if ((prov.authMode === "oauth" || antigravity) && !apiKey) { + if (prov.authMode === "oauth" && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); } if (prov.adapter === "cursor") { @@ -1583,7 +1352,29 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp message: `Connected. ${live.models.length} models.`, }); } - const project = antigravity ? snapshot?.projectId : prov.project; + if (prov.adapter === "qoder") { + const started = Date.now(); + const profile = resolveQoderProfile(prov.baseUrl); + if (!profile) { + return jsonResponse({ ok: false, latencyMs: 0, error: "qoder discovery refused a non-canonical destination" }); + } + const live = await fetchQoderModels(profile, apiKey ?? ""); + const latencyMs = Date.now() - started; + if (!live.ok) { + return jsonResponse({ + ok: false, + latencyMs, + error: `qoder discovery ${live.error}${live.detail ? `: ${live.detail}` : ""}`, + }); + } + return jsonResponse({ + ok: true, + latencyMs, + models: live.models.length, + message: `Connected. ${live.models.length} models.`, + }); + } + const project = prov.project ?? snapshot?.projectId; if (antigravity && !project) { return jsonResponse({ ok: false, latencyMs: 0, error: "Antigravity project unavailable — re-run `ocx login google-antigravity`" }); } @@ -1657,15 +1448,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp }); } const models = ccaModels?.length ?? ("items" in extracted! ? extracted!.items.length : extracted!.rows.length); - const antigravityProdWarning = antigravity - && prov.baseUrl.replace(/\/+$/, "") === PROD_ANTIGRAVITY_HOST - ? ` Warning: consumer Google Antigravity accounts may receive 429 RESOURCE_EXHAUSTED on ${PROD_ANTIGRAVITY_HOST}; use ${DAILY_ANTIGRAVITY_HOST}. Keep the production endpoint only for enterprise/GCP accounts.` - : ""; return jsonResponse({ ok: true, latencyMs, models, - message: `Connected — ${models} model${models === 1 ? "" : "s"} available.${antigravityProdWarning}`, + message: `Connected — ${models} model${models === 1 ? "" : "s"} available.`, }); } catch (err) { return jsonResponse({ @@ -1708,41 +1495,13 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp combos: dependentCombos, }, 409); } - const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => { - if (!hasOwnProvider(fresh.providers, name)) { - return { changed: false, value: { error: "unknown provider", status: 404 } }; - } - const routingProfiles = Object.entries(fresh.routingProfiles ?? {}) - .filter(([, profile]) => profile.candidates.some(candidate => candidate.provider === name)) - .map(([id]) => id) - .sort((a, b) => a.localeCompare(b)); - if (routingProfiles.length > 0) { - return { changed: false, value: { error: `cannot delete provider "${name}" while routing profiles depend on it`, code: "provider_has_dependent_routing_profiles", routingProfiles, status: 409 } }; - } - const combos = Object.entries(fresh.combos ?? {}) - .filter(([, combo]) => combo.targets.some(target => target.provider === name)) - .map(([id]) => id) - .sort((a, b) => a.localeCompare(b)); - if (combos.length > 0) { - return { changed: false, value: { error: `cannot delete provider "${name}" while combos depend on it`, code: "provider_has_dependent_combos", combos, status: 409 } }; - } - const persistedFallback = name === fresh.defaultProvider - ? Object.entries(fresh.providers).find(([id, provider]) => id !== name && provider.disabled !== true)?.[0] - : undefined; - if (name === fresh.defaultProvider && !persistedFallback) { - return { changed: false, value: { error: "cannot delete the default provider when no enabled replacement remains", code: "last_provider", status: 409 } }; - } - if (persistedFallback) fresh.defaultProvider = persistedFallback; - delete fresh.providers[name]; - const droppedCustomModels = dropProviderCustomModels(fresh, name); - setProviderContextCap(fresh, name, false); - return { changed: true, value: { config: structuredClone(fresh), fallbackDefault: persistedFallback, droppedCustomModels } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse(outcome.value, outcome.value.status ?? 409); - adoptCommittedConfig(config, outcome.value.config); - const committedFallbackDefault = outcome.value.fallbackDefault; - const droppedCustomModels = outcome.value.droppedCustomModels ?? 0; + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + if (fallbackDefault) config.defaultProvider = fallbackDefault; + delete config.providers[name]; + const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite"); + const droppedCustomModels = dropProviderCustomModels(config, name); + forgetProviderContextCap(config, name); + save(config); await replaceProviderAccountSet(name, null); reconcileLiveStateStores(); const { clearModelCache: clearCache } = await import("../../codex/model-cache"); @@ -1750,14 +1509,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp const catalogRefresh = await convergeCodexCatalog(); return jsonResponse({ success: true, - ...(committedFallbackDefault ? { defaultProvider: committedFallbackDefault } : {}), + ...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}), ...(droppedCustomModels > 0 ? { droppedCustomModels } : {}), catalogRefresh, }); } if (url.pathname === "/api/provider-context-caps" && req.method === "GET") { - return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) }); + return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config), values: selectedProviderContextCaps(config) }); } if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") { @@ -1767,12 +1526,13 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp // property access, with the route's consistent 400 response. if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider-context-caps body must be a plain object" }, 400); const body = rawBody as { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown }; + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); const { clearModelCache } = await import("../../codex/model-cache"); const respond = (catalogRefresh: Awaited<ReturnType<typeof convergeCodexCatalog>>) => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), - caps: providerContextCaps(config), + caps: providerContextCaps(config), values: selectedProviderContextCaps(config), catalogRefresh, }); @@ -1791,8 +1551,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp // Branch 1: per-provider toggle (checked first: a per-provider request may carry an // explicit `value`, which must never fall through to the global-value branch). Enable - // writes the current global default unless an explicit per-provider value is supplied; - // that value is never copied to other providers. + // restores the selected provider value, then the global default, unless an explicit + // per-provider value is supplied; that value is never copied to other providers. if (typeof body.provider === "string" && typeof body.enabled === "boolean") { const provider = body.provider.trim(); if (!isValidProviderName(provider)) { @@ -1812,7 +1572,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp return jsonResponse({ error: "value must be a positive number" }, 400); } setProviderContextCap(config, provider, body.enabled, perProviderValue); - saveManagementConfig(deps, config); + save(config); reconcileLiveStateStores(); clearModelCache(provider); const catalogRefresh = await convergeCodexCatalog(); @@ -1838,7 +1598,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp const affected = Object.keys(providerContextCaps(config)); const applyToAll = body.setAll === true; setGlobalContextCapValue(config, normalizedValue, applyToAll); - saveManagementConfig(deps, config); + save(config); reconcileLiveStateStores(); if (applyToAll) { for (const provider of affected) clearModelCache(provider); @@ -1855,7 +1615,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp const before = Object.keys(providerContextCaps(config)); const names = Object.keys(config.providers); setAllProviderContextCaps(config, names, body.setAll); - saveManagementConfig(deps, config); + save(config); reconcileLiveStateStores(); for (const provider of new Set([...before, ...names])) clearModelCache(provider); const catalogRefresh = await convergeCodexCatalog(); diff --git a/src/server/port-reclaim.ts b/src/server/port-reclaim.ts index d4023b2ddc..0bd0660ff7 100644 --- a/src/server/port-reclaim.ts +++ b/src/server/port-reclaim.ts @@ -2,11 +2,9 @@ * Reclaim a listen port after stop/update so restart can stay on the configured * port instead of hopping to an ephemeral one (Windows CLOSE_WAIT / leftover ocx). * - * Killing is never the default. A process may be killed only when the caller - * sets `killOcxHolders` and either supplies a non-empty `onlyKillPids` allowlist - * (trusted teardown PIDs, including allowlisted holders that fail ocx revalidate) - * or enables `killAllOcxOnPort` for revalidated ocx listeners. Unknown foreign - * (non-ocx, non-allowlisted) processes are never killed. + * Killing is never the default. It requires `killOcxHolders`, an allowed PID or + * `killAllOcxOnPort`, and successful ocx verification. A historical PID allowlist + * never overrides a rejected verifier result; rejected live holders stay protected. */ import { execFileSync } from "node:child_process"; import { verifyPidIdentity } from "../config/process-state"; @@ -29,6 +27,9 @@ export type ReclaimListenPortOptions = WaitForPortOptions & { /** * Explicit PIDs the caller just stopped / hard-killed. An omitted or empty * list means no process may be killed — unless {@link killAllOcxOnPort} is set. + * The allowlist only narrows kill candidates: every candidate, allowlisted or + * not, still requires verifier acceptance (`verifyOcxFn(pid) === pid`) on each + * scan, and a rejected live holder is never killed or TCP-row dropped. */ onlyKillPids?: number[]; /** @@ -36,8 +37,7 @@ export type ReclaimListenPortOptions = WaitForPortOptions & { * killed (re-checked each scan). Used by post-update restart so a Windows * service wrapper that respawns a *new* bun PID mid-reclaim cannot stay * protected just because it was absent from the pre-wait allowlist snapshot. - * Never kills foreign (non-ocx) processes — only allowlisted teardown PIDs - * and revalidated ocx listeners. + * Every candidate still requires ocx verifier acceptance before termination. */ killAllOcxOnPort?: boolean; /** @@ -174,8 +174,8 @@ export function listListenPids(port: number): number[] { * Never kills a process unless `killOcxHolders === true` and either * `onlyKillPids` is a non-empty allowlist or `killAllOcxOnPort` is set — then * revalidates immediately before each kill. - * Never kills foreign processes. Never drops TCP rows while a live foreign or - * protected ocx listener owns the port, or when the listener scan failed. + * Never overrides a rejected ocx verifier result. Never drops TCP rows while a + * rejected live or protected ocx listener owns the port, or when the scan failed. */ export async function reclaimListenPort( port: number, @@ -231,24 +231,9 @@ export async function reclaimListenPort( } const isOcx = verifyOcxFn(pid) === pid; const allowlisted = allowedKillPids.has(pid); - // Pre-update PIDs can fail verify while still LISTENing (dead owner still - // listed, or cmdline probe raced). Allowlisted teardown PIDs may be killed; - // unknown foreign claimants must remain fail-closed. if (!isOcx) { - if (mayKill && allowlisted) { - if (!killed.has(pid)) { - try { - killFn(pid); - killed.add(pid); - } catch { - // Kill failed: never SetTcpEntry while the process may still own the port. - protectedOcxListener = true; - } - } - if (!isAliveFn(pid)) killed.delete(pid); - else protectedOcxListener = true; - continue; - } + // A saved PID narrows eligible candidates; it cannot override verifier rejection. + // Dead ghost owners have already been skipped by the liveness check above. foreignLive = true; continue; } diff --git a/src/server/request-log.ts b/src/server/request-log.ts index d984e93c8b..c68daa4208 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -23,6 +23,8 @@ import { isKnownAgentKind, isKnownAdmissionKind, isKnownInboundProtocol, + isKnownTerminalSource, + isKnownTransportPhase, isKnownUsageSurface, isCodexUsageAccountLogLabel, isValidReasoningWireValue, @@ -325,6 +327,8 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(entry.usage ? { usage: entry.usage } : {}), ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), + ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}), + ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}), ...(routeDecision ? { routeDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), }; @@ -447,6 +451,8 @@ export function addRequestLog(entry: RequestLogEntry) { ...(entry.usage ? { usage: entry.usage } : {}), ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), + ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}), + ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}), ...failureDiagnostics, ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}), diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 6d7b8fd8a0..ab6a9b55c9 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -8,6 +8,7 @@ import { } from "../../config"; import { parseRequest } from "../../responses/parser"; import { externalTaskInputContent } from "../../responses/task-input"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../codex/multi-agent-mode-policy"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; @@ -77,7 +78,6 @@ import { registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle" import { redactSecretString } from "../../lib/redact"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import { supportedLadderFor } from "../effort-policy"; -import { resolveNativeDefaultState, type NativeDefaultState } from "../../codex/subagent-defaults"; import { beginRequestAttempt, catalogModelSupportsServiceTier, @@ -234,13 +234,10 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato -export const PROACTIVE_MULTI_AGENT_MODE_TEXT = [ - "Proactive multi-agent delegation is active.", - "Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies.", - "Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently.", - "Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself.", - "This mode remains active until a later multi-agent mode developer message changes it.", -].join(" "); +export const PROACTIVE_MULTI_AGENT_MODE_TEXT = MULTI_AGENT_MODE_HINT_RECOMMENDATION.text; + +const OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG = "<opencodex_subagent_guidance>"; +const OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG = "</opencodex_subagent_guidance>"; export function isV1CollabSurface(parsed: OcxParsedRequest): boolean { return collabSurface(parsed) === "v1"; @@ -281,18 +278,11 @@ export interface MultiAgentGuidanceOptions { subagentModels?: string[]; subagentModelFallback?: string[]; injectionPrompt?: string; - nativeDefaultState?: NativeDefaultState; - syncCodexSubagentDefaults?: boolean; } export interface MultiAgentGuidanceDeps { - resolveNativeDefaultState?: (config: { - injectionModel?: string; - injectionEffort?: string; - syncCodexSubagentDefaults?: boolean; - }) => NativeDefaultState | Promise<NativeDefaultState>; resolveEffectiveSubagentRoster?: ( configuredModels: readonly string[], surface: SpawnAgentSurface, @@ -379,8 +369,6 @@ export async function multiAgentGuidanceText( subagentModels, subagentModelFallback, injectionPrompt, - nativeDefaultState: configuredNativeDefaultState, - syncCodexSubagentDefaults, } = options; const activeAccountNamespace = codexAccountNamespace?.length ? codexAccountNamespace @@ -413,10 +401,6 @@ export async function multiAgentGuidanceText( if (catalogState.state === "stale" || catalogState.state === "unknown") { return null; } - const nativeDefaultState = configuredNativeDefaultState - ?? await (deps.resolveNativeDefaultState ?? resolveNativeDefaultState)({ - injectionModel, injectionEffort, syncCodexSubagentDefaults, - }); // codex-rs supplies the Proactive text on v2; the proxy only adds model-designation // guidance, and only when there is something concrete to designate: a configured // injectionModel and/or a roster entry that resolves in the injected catalog. @@ -482,22 +466,15 @@ export async function multiAgentGuidanceText( // fallback only for explicit routed/account-qualified ids. const promptModel = preferred?.model ?? (injectionModel?.includes("/") ? injectionModel : undefined); - return `<multi_agent_mode>${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance, nativeDefaultState)}</multi_agent_mode>`; + return `${OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG}${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance)}${OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG}`; } if (!preferred && roster === "" && fallbackGuidance === "") return null; - let text = "When the active spawn_agent tool supports optional \"model\" or \"reasoning_effort\" overrides, " - + "use only models listed for this collaboration surface. " - + "When setting either override, set fork_turns to \"none\" " - + "(or a positive turn count such as \"3\"; full-history forks reject overrides) " - + "and make the task message self-contained. " - + "When specifying model overrides, preserve any caller-provided agent_type; do not replace it with \"worker\". " - + "Subagent exec runs in a pure V8 isolate without require('fs'); " - + "escape nested template literals in tools.apply_patch to prevent JavaScript syntax errors (e.g., write \\` and \\\${var})."; + let text = "OpenCodex sub-agent routing metadata for this collaboration surface. " + + "This metadata does not override Codex delegation or model-selection rules."; if (preferred) { text += ` Preferred sub-agent: model "${preferred.model}"` + (injectionEffort ? `, reasoning_effort "${injectionEffort}"` : "") - + `; nativeDefaultState: ${nativeDefaultState}.` - + " — use it unless the user names another. Confirm a different listed model for one spawn only; do not persist the exception."; + + "."; } text += fallbackGuidance; text += roster; @@ -505,27 +482,26 @@ export async function multiAgentGuidanceText( // Roster is the only unbounded part — drop it before breaking the budget. text = text.slice(0, text.length - roster.length); } - return `<multi_agent_mode>${text}</multi_agent_mode>`; + return `${OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG}${text}${OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG}`; } const effort = parsed.options.reasoning; - // v1 keeps only the upstream-parity behavior: Proactive text at the top tier - // (ultra arrives as max on the wire). No designation/roster payload here. + // v1 changes only the delegation trigger at the top tier; other rules still apply. + // Ultra arrives as max on the wire. No designation/roster payload here. if (effort !== "max" && effort !== "ultra") return null; return `<multi_agent_mode>${PROACTIVE_MULTI_AGENT_MODE_TEXT}</multi_agent_mode>`; } -export const V2_GUIDANCE_CHAR_BUDGET = 1200; +export const V2_GUIDANCE_CHAR_BUDGET = 700; -export function applyInjectionPlaceholders(prompt: string, model?: string, effort?: string, roster?: string, fallback?: string, nativeDefaultState?: NativeDefaultState): string { +export function applyInjectionPlaceholders(prompt: string, model?: string, effort?: string, roster?: string, fallback?: string): string { return prompt .replaceAll("{{model}}", model ?? "") .replaceAll("{{effort}}", effort ?? "") .replaceAll("{{roster}}", roster ?? "") - .replaceAll("{{fallback}}", fallback ?? "") - .replaceAll("{{nativeDefaultState}}", nativeDefaultState ?? ""); + .replaceAll("{{fallback}}", fallback ?? ""); } @@ -563,6 +539,17 @@ function isGeneratedDeveloperItem(item: unknown, text: string): boolean { return generatedDeveloperText(item) === text; } +function generatedGuidanceFamily(text: string): "multi_agent_mode" | "opencodex_subagent_guidance" | undefined { + if (text.startsWith("<multi_agent_mode>") && text.endsWith("</multi_agent_mode>")) { + return "multi_agent_mode"; + } + if (text.startsWith(OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG) + && text.endsWith(OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG)) { + return "opencodex_subagent_guidance"; + } + return undefined; +} + function isDeveloperPrefixItem(item: unknown): boolean { if (!isRecord(item)) return false; if (item.type === "additional_tools") return item.role === "developer"; @@ -602,13 +589,13 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] }; if (rawInput) { const replayPrefix = rawInput.slice(0, replayPrefixLen); - const taggedGuidance = text.startsWith("<multi_agent_mode>") && text.endsWith("</multi_agent_mode>"); - const lastTaggedGuidance = taggedGuidance + const guidanceFamily = generatedGuidanceFamily(text); + const lastTaggedGuidance = guidanceFamily ? replayPrefix.map(generatedDeveloperText) - .filter(item => item?.startsWith("<multi_agent_mode>") && item.endsWith("</multi_agent_mode>")) + .filter(item => item !== undefined && generatedGuidanceFamily(item) === guidanceFamily) .at(-1) : undefined; - if (taggedGuidance ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) { + if (guidanceFamily ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) { return; } } diff --git a/src/server/responses/combo-session-recall.ts b/src/server/responses/combo-session-recall.ts new file mode 100644 index 0000000000..84dfd8d466 --- /dev/null +++ b/src/server/responses/combo-session-recall.ts @@ -0,0 +1,89 @@ +/** Process-local recall of the last completed combo response on an explicit session lane. */ +import { getCombo, targetKey } from "../../combos/types"; +import { captureConfigGeneration, type GenerationContext } from "../../lib/state-store-sweeper"; +import type { OcxConfig, OcxComboTarget } from "../../types"; + +interface ComboRecallEntry { + comboId: string; + target: Pick<OcxComboTarget, "provider" | "model">; + responseModel: string; + at: number; +} + +const RECALL_CAPACITY = 256; +const RECALL_TTL_MS = 30 * 60 * 1000; +const recall = new Map<string, ComboRecallEntry>(); +let lastReconciledGeneration = 0; +let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined; + +function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean { + return context.comboIds.has(entry.comboId) + && context.providerNames.has(entry.target.provider) + && context.comboTargets.has(`${entry.comboId}::${targetKey(entry.target)}`); +} + +export function rememberComboForLane( + lane: string | undefined, + comboId: string, + target: Pick<OcxComboTarget, "provider" | "model">, + responseModel: string, + writerGeneration: number, +): void { + if (!lane || !comboId || !responseModel.trim()) return; + // Reject even a same-named recreated owner: its previous in-flight turn is obsolete. + if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return; + const entry = { comboId, target: { provider: target.provider, model: target.model }, responseModel, at: Date.now() }; + if (liveOwners && !ownsEntry(liveOwners, entry)) return; + recall.delete(lane); + recall.set(lane, entry); + while (recall.size > RECALL_CAPACITY) { + const oldest = recall.keys().next().value; + if (oldest === undefined) break; + recall.delete(oldest); + } +} + +export function recallComboForLane( + config: OcxConfig, + lane: string | undefined, + model: string, +): string | undefined { + if (!lane || !model || model.includes("/")) return undefined; + const entry = recall.get(lane); + if (!entry) return undefined; + const combo = getCombo(config, entry.comboId); + const provider = config.providers[entry.target.provider]; + if (Date.now() - entry.at >= RECALL_TTL_MS + || !Object.hasOwn(config.providers, entry.target.provider) + || !provider || provider.disabled === true + || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) { + recall.delete(lane); + return undefined; + } + return entry.responseModel === model ? entry.comboId : undefined; +} + +export function reconcileComboRecall(context: GenerationContext): number { + if (context.generation <= lastReconciledGeneration) return 0; + lastReconciledGeneration = context.generation; + liveOwners = { + comboIds: new Set(context.comboIds), + comboTargets: new Set(context.comboTargets), + providerNames: new Set(context.providerNames), + }; + let removed = 0; + for (const [lane, entry] of recall) { + if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) { + recall.delete(lane); + removed += 1; + } + } + return removed; +} + +/** Test-only reset, alongside the combo rotation/cooldown resets. */ +export function clearComboRecallForTests(): void { + recall.clear(); + lastReconciledGeneration = 0; + liveOwners = undefined; +} diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 4a8590c30d..56d487b8bf 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -18,6 +18,7 @@ import { comboIdFromRawBody, concreteComboRequestBody, getCombo, + resolveComboId, isComboTargetInCooldown, NoAvailableComboTargetsError, noteComboSuccess, @@ -153,6 +154,7 @@ import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; import { decideV2NativeParentOverride } from "./v2-native-parent-override"; import { sessionLaneIdFromRequest } from "../request-log-conversation"; +import { recallComboForLane } from "./combo-session-recall"; export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024; @@ -537,7 +539,7 @@ export async function handleResponsesCompact( // a local rather than written back to `raw.model`: assigning to the property widens it out // of the `string` narrowing the guard above just established. const compactFastRow = parseFastOnlyRowId(config, () => raw.model as string); - const compactModel = compactFastRow ? compactFastRow.baseId : raw.model; + let compactModel = compactFastRow ? compactFastRow.baseId : raw.model; if (compactFastRow) (raw as Record<string, unknown>).model = compactModel; // The client's own selector, kept for the request log: `raw.model` is rewritten to the // base id above, and logCtx.requestedModel is assigned from it further down, so without @@ -545,6 +547,22 @@ export async function handleResponsesCompact( const compactRequestedModel = compactFastRow ? compactFastRow.baseId + "--fast" : raw.model; const requestedModel = raw.model; + // Recall the last completed client-visible bare model after a combo switch (#3891). + // Configured selectors take precedence over this implicit session hint. + if (typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow + && !resolveComboId(config, compactModel)) { + const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), compactModel); + if (recalledComboId) { + (raw as Record<string, unknown>).model = `combo/${recalledComboId}`; + // Keep the routed identity in sync: the bare model can 404 outright (no + // canonical openai provider) or resolve straight onto a native-compact + // provider, both bypassing combo failover. The combo selector resolves + // through tryPickComboModel, whose route.combo skips the native compact + // endpoint. + compactModel = `combo/${recalledComboId}`; + } + } + let route; try { // Compact requests route through the same policy evaluation as normal diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 17a5c65984..9b8bbe7d9a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -15,6 +15,7 @@ import { nativeContextLimits } from "../../codex/catalog"; import { describeUpstreamConnectFailure } from "./upstream-error"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; import { applyAccountQuotaFromUpstreamHeaders as applyCapturedCodexQuota } from "../../codex/quota"; +import { isCodexAccountGenerationLive } from "../../codex/account-store"; import { isCodexWsQuotaObservedResponse } from "./ws-upstream"; import { multiAgentGuidanceEnabled, @@ -62,6 +63,10 @@ import { providerContinuationRouteScope, sameProviderContinuationOwner, } from "../../responses/provider-continuation"; +import { + rememberComboForLane, + recallComboForLane, +} from "./combo-session-recall"; import { comboRouteDecisionTrace, NoEligiblePolicyCandidateError, @@ -83,6 +88,7 @@ import { comboRequestHasImageInput, concreteComboRequestBody, getCombo, + resolveComboId, isComboTargetInCooldown, NoAvailableComboTargetsError, noteComboSuccess, @@ -233,11 +239,14 @@ import { import { ForwardAdmissionCredentialError, hasForwardableCodexBearer, + isProxyAdmissionSecret, validateForwardAdmissionCredential, } from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; -import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; +import { captureExplicitOpenAiCallerAuth, listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ExplicitOpenAiCallerAuth, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; +import { inspectChatGptDomainClaim } from "../../oauth/chatgpt"; +import { captureCallerDirectAuth, providerConsumesCallerAuthorization, type CallerDirectAuth } from "../../providers/caller-authorization"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported } from "../../codex/loopback-target"; import { providerContextCap } from "../../providers/context-cap"; @@ -341,6 +350,7 @@ import { consumeForInspection, consumeForResponseLogMetadata, createSseInspector, + terminalStatusFromParsed, isEagerRelaySseResponse, isNativePassthroughSseResponse, markEagerRelaySseResponse, @@ -371,6 +381,7 @@ import { } from "../responses-item-id-repair"; import { createReasoningSummaryChannelPayloadRewrite, + rewriteReasoningSummaryInJson, rewriteReasoningSummaryInJsonString, routeUsesContentChannelReasoning, } from "../responses-reasoning-summary-rewrite"; @@ -1072,8 +1083,12 @@ export function usesCodexForwardPoolAuth( function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig): CodexWsQuotaObserver | undefined { if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined; const { accountId, writerGeneration } = authCtx; + const credentialGeneration = authCtx.kind === "pool" ? authCtx.generation : undefined; const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; - return headers => applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); + return headers => { + if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return; + applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); + }; } export function preAuthUpstreamHostCircuitKey( @@ -1177,7 +1192,8 @@ export async function shouldRetryCodexPoolAccountQuota( } interface CodexPoolAccountRetryArgs { - req: Request; + /** Sanitized caller input, before any selected Pool credential was materialized. */ + callerAuthHeaders: Headers; config: OcxConfig; route: { providerName: string; modelId: string; provider: OcxProviderConfig }; parsed: OcxParsedRequest; @@ -1343,7 +1359,7 @@ async function retryCodexPoolOnAlternateAccount( args: CodexPoolAccountRetryArgs, ): Promise<CodexPoolAccountRetryResult> { const { - req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, + callerAuthHeaders, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, outcomeStatus, upstream, connectMs, passthroughEstimate, stream, } = args; const inboundWire = options.inboundWire ?? "responses"; @@ -1377,7 +1393,7 @@ async function retryCodexPoolOnAlternateAccount( } try { retryAuthCtx ??= await resolveCodexAuthContext( - req.headers, + callerAuthHeaders, config, "pool", { @@ -1385,7 +1401,7 @@ async function retryCodexPoolOnAlternateAccount( admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, modelId: route.modelId, - requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), + requestScopedMainCredential: hasForwardableCodexBearer(callerAuthHeaders, config), beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), resolveCodexModelEntitlements: entitlementResolver, }, @@ -1455,7 +1471,7 @@ async function retryCodexPoolOnAlternateAccount( // Only a combo reset-derived outcome is deferred. Retry-After, defaults, and // ordinary requests must block the first account before the alternate send. if (!deferFirstOutcome) recordFirstOutcome(); - const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission); + const retryHeaders = headersForCodexAuthContext(callerAuthHeaders, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission); const retryProvider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(route.provider), retryAuthCtx, @@ -1729,6 +1745,8 @@ export interface HandleResponsesOptions { onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void; /** Internal deterministic seam for account-gated native fallback tests. */ resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; + /** Internal: validated final client-visible model, after completed terminal success only. */ + onResponseComplete?: (model: string) => void; recordTerminalOutcomes?: boolean; setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void; onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void; @@ -1757,9 +1775,17 @@ export interface HandleResponsesOptions { /** * Claude replay may add native-main auth so OpenAI sidecars remain available. * Strip only that internal credential when the final route is a noncanonical - * forward destination; final routing can differ from Claude's preflight route. + * forward/caller-auth destination; final routing can differ from Claude's preflight route. */ stripClaudeMainAuthForNoncanonicalForward?: boolean; + /** In-memory credential proven by Claude's native-main turn claim; never persist or log. */ + trustedClaudeMainAuth?: { authorization: string; chatgptAccountId?: string }; + /** Sidecar-only auth captured before route changes; null means no usable original pair. */ + openAiSidecarAuth?: ExplicitOpenAiCallerAuth | null; + /** Original caller-owned native pair; separate from any claimed sidecar enrichment. */ + nativeCallerAuth?: ExplicitOpenAiCallerAuth | null; + /** Caller Direct credential under Direct\'s own predicate; restored only for the canonical OpenAI final route. */ + callerDirectAuth?: CallerDirectAuth | null; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; /** Internal combo handoff for one parent-validated continuation snapshot. */ @@ -1933,36 +1959,59 @@ export function createChildPassthroughCallbackGate(options: HandleResponsesOptio let state: "pending" | "committed" | "discarded" = "pending"; let pending: Pending | undefined; let accepted = false; + let pendingModel: string | undefined; + let completionAccepted = false; + let completionRejected = false; const publish = (value: Pending): void => { if (value.kind === "terminal") options.onNativePassthroughTerminal?.(value.status); else options.onNativePassthroughCancel?.(); }; + const publishCompletion = (): void => { + if (state !== "committed" || completionRejected || pendingModel === undefined) return; + const model = pendingModel; + pendingModel = undefined; + options.onResponseComplete?.(model); + }; const receive = (value: Pending): void => { if (state === "discarded" || accepted) return; accepted = true; + if (value.kind === "cancel" || value.status !== "completed") { + completionRejected = true; + pendingModel = undefined; + } if (state === "committed") return publish(value); pending ??= value; }; return { onTerminal: (status: ResponsesTerminalStatus) => receive({ kind: "terminal", status }), onCancel: () => receive({ kind: "cancel" }), + onResponseComplete: (model: string) => { + if (state === "discarded" || completionRejected || completionAccepted || !model.trim()) return; + completionAccepted = true; + pendingModel = model; + publishCompletion(); + }, commit: () => { if (state !== "pending") return; state = "committed"; if (pending) publish(pending); pending = undefined; + publishCompletion(); }, discard: () => { state = "discarded"; pending = undefined; + pendingModel = undefined; }, }; } - export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers { const childHeaders = new Headers(parentHeaders); + // A provisional caller credential is not authoritative for a Combo child. + childHeaders.delete("authorization"); + childHeaders.delete("chatgpt-account-id"); // Combo children re-serialize already-decoded JSON. Keeping transport metadata from // the parent would make the child decoder treat plain JSON as compressed bytes. childHeaders.delete("content-length"); @@ -2047,7 +2096,7 @@ function canPassThroughEncryptedV2AgentTask( } type ResponsesAuthResolution = - | { ok: true; authCtx: CodexAuthContext; headers: Headers; substituteMainCredential: boolean } + | { ok: true; authCtx: CodexAuthContext; headers: Headers; callerAuthHeaders: Headers; substituteMainCredential: boolean } | { ok: false; response: Response }; /** @@ -2059,8 +2108,75 @@ async function resolveResponsesCodexAuth( config: OcxConfig, route: RouteResult, options: HandleResponsesOptions, + credentialDomainWasRewritten = false, ): Promise<ResponsesAuthResolution> { try { + const routeMayChangeCredentialDomain = options.comboAttempt === true + || route.routeKind === "policy" + || credentialDomainWasRewritten; + const trustedClaudeMainForFinalRoute = options.stripClaudeMainAuthForNoncanonicalForward === true + && isCanonicalOpenAiForwardProvider(route.provider) + ? options.trustedClaudeMainAuth : undefined; + let authInputHeaders = req.headers; + // Route-changing recursion retains typed admission, never an unscoped raw + // caller credential. Bearer admission is substituted or stripped below. + if (routeMayChangeCredentialDomain && options.admission?.source !== "bearer" + && !trustedClaudeMainForFinalRoute) { + authInputHeaders = new Headers(req.headers); + authInputHeaders.delete("authorization"); + authInputHeaders.delete("chatgpt-account-id"); + } + if (trustedClaudeMainForFinalRoute) { + authInputHeaders = new Headers(authInputHeaders); + authInputHeaders.set("authorization", trustedClaudeMainForFinalRoute.authorization); + if (trustedClaudeMainForFinalRoute.chatgptAccountId) { + authInputHeaders.set("chatgpt-account-id", trustedClaudeMainForFinalRoute.chatgptAccountId); + } else { + authInputHeaders.delete("chatgpt-account-id"); + } + } + // A caller-auth transport that is not canonical OpenAI (keyless Cursor) consumes the + // caller's Authorization as its own upstream token. Keep that contract only for a clean + // single bearer with NO ChatGPT-domain marker. A bearer marked for the ChatGPT domain — + // whether its marker is valid or malformed/conflicting — a combined/malformed value, or + // the captured explicit OpenAI pair is never a Cursor token; a foreign JWT carrying only + // a generic organizations claim is not ChatGPT-marked and keeps the legacy contract. + // chatgpt-account-id has no meaning outside the ChatGPT domain. + if (!isCanonicalOpenAiForwardProvider(route.provider) + && providerConsumesCallerAuthorization(route.provider)) { + const rawAuth = authInputHeaders.get("authorization")?.trim(); + const singleBearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(rawAuth ?? "")?.[1]; + const domainClaim = singleBearer ? inspectChatGptDomainClaim(singleBearer) : { kind: "absent" as const }; + const dropBearer = options.nativeCallerAuth != null || domainClaim.kind !== "absent" + || (rawAuth !== undefined && singleBearer === undefined); + if (dropBearer || authInputHeaders.has("chatgpt-account-id")) { + const scoped = new Headers(authInputHeaders); + if (dropBearer) scoped.delete("authorization"); + scoped.delete("chatgpt-account-id"); + authInputHeaders = scoped; + } + } + // The caller's own Direct credential may cross an internal route change only to the + // canonical OpenAI transport, under a predicate deliberately STRICTER than plain + // unchanged-route Direct forwarding: a clean non-proxy bearer whose ChatGPT-domain + // marker is valid, with any explicit account header matching that marker. Unchanged + // routes keep their legacy rules; sidecar enrichment grants no primary authority. + if (options.callerDirectAuth && isCanonicalOpenAiForwardProvider(route.provider)) { + const directHeaders = new Headers({ + authorization: options.callerDirectAuth.authorization, + ...(options.callerDirectAuth.chatgptAccountId + ? { "chatgpt-account-id": options.callerDirectAuth.chatgptAccountId } : {}), + }); + if (captureCallerDirectAuth(directHeaders, config)) { + authInputHeaders = new Headers(authInputHeaders); + authInputHeaders.set("authorization", options.callerDirectAuth.authorization); + if (options.callerDirectAuth.chatgptAccountId) { + authInputHeaders.set("chatgpt-account-id", options.callerDirectAuth.chatgptAccountId); + } else { + authInputHeaders.delete("chatgpt-account-id"); + } + } + } // #1686: a caller that proved admission with a BEARER presented one of our own secrets. // Refusing it here is what made the codex-cli `env_key` contract unusable against Direct. // Admitting it is only safe because the stored main credential is substituted below, so @@ -2084,15 +2200,16 @@ async function resolveResponsesCodexAuth( // no-ChatGPT-login install keeps working. const substituteMainCredential = options.admission?.source === "bearer" && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider)); + const stripAuthorization = options.admission?.source === "bearer" && !substituteMainCredential; const requestScopedMainCredential = route.codexAccountMode !== undefined && !substituteMainCredential - && hasForwardableCodexBearer(req.headers, config); + && hasForwardableCodexBearer(authInputHeaders, config); if (route.codexAccountMode === "direct" && !substituteMainCredential) { - validateForwardAdmissionCredential(req.headers, config); + validateForwardAdmissionCredential(authInputHeaders, config); } let authCtx: CodexAuthContext; if (route.codexAccountMode) { - authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + authCtx = await resolveCodexAuthContext(authInputHeaders, config, route.codexAccountMode, { admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, accountId: route.codexAccountId, @@ -2128,7 +2245,7 @@ async function resolveResponsesCodexAuth( // (custom-named canonical-forward providers must retain the same protection). const mainPolicyConfig = isCanonicalOpenAiForwardProvider(route.provider) ? options.codexAuthPolicy ?? config : undefined; - const headers = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, { + const headers = await materializeCodexUpstreamAuthAsync(authInputHeaders, authCtx, { admission: options.admission, config: mainPolicyConfig, modelId: route.modelId, @@ -2147,10 +2264,27 @@ async function resolveResponsesCodexAuth( response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), }; } + if (stripAuthorization) { + headers.delete("authorization"); + headers.delete("chatgpt-account-id"); + } + if (providerConsumesCallerAuthorization(route.provider) && options.admission?.source !== undefined + && options.admission.source !== "loopback") { + validateForwardAdmissionCredential(headers, config); + } else { + // Even adapters that ignore caller auth must not retain a proxy secret for + // a later internal hop or a future transport change. + const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (bearer && isProxyAdmissionSecret(bearer, config)) { + headers.delete("authorization"); + headers.delete("chatgpt-account-id"); + } + } return { ok: true, authCtx, headers, + callerAuthHeaders: new Headers(authInputHeaders), substituteMainCredential, }; } catch (err) { @@ -2479,7 +2613,6 @@ async function applyFinalRouteRequestNormalization(args: { subagentModels: config.subagentModels, subagentModelFallback: config.subagentModelFallback, injectionPrompt: config.injectionPrompt, - syncCodexSubagentDefaults: config.syncCodexSubagentDefaults === true, }); if (guidance) { injectDeveloperMessage(parsed, guidance); @@ -2791,9 +2924,22 @@ export async function handleComboResponses( (logCtx.attempts ??= []).push(attempt); attemptRetained = true; }; + const completedTarget = { provider: pick.target.provider, model: pick.target.model }; + const writerGeneration = pick.writerGeneration; let consumedChildFailure: ConsumedComboFailure | undefined; const callbackGate = createChildPassthroughCallbackGate({ ...options, + onResponseComplete: model => { + // The live config can change while the child is streaming. Never retain credentials. + const currentCombo = getCombo(config, comboId); + const provider = config.providers[completedTarget.provider]; + if (Object.hasOwn(config.providers, completedTarget.provider) + && provider && provider.disabled !== true + && currentCombo?.targets.some(target => targetKey(target) === targetKey(completedTarget))) { + rememberComboForLane(sessionLaneIdFromRequest(req.headers), comboId, completedTarget, model, writerGeneration); + } + options.onResponseComplete?.(model); + }, onNativePassthroughTerminal: status => { // A committed stream can acquire terminal metadata after preflight copied // the child log. Publish it before the outer logger finalizes, but only @@ -2834,6 +2980,7 @@ export async function handleComboResponses( onStoredPool401ReplayDispatched: () => { storedPool401ReplayDispatched = true; }, onNativePassthroughTerminal: callbackGate.onTerminal, onNativePassthroughCancel: callbackGate.onCancel, + onResponseComplete: callbackGate.onResponseComplete, }); } catch (error) { callbackGate.discard(); @@ -3138,6 +3285,12 @@ export async function handleResponses( try { const response = await handleResponsesInner(req, config, logCtx, { ...options, + openAiSidecarAuth: options.openAiSidecarAuth === undefined + ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth, + nativeCallerAuth: options.nativeCallerAuth === undefined + ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.nativeCallerAuth, + callerDirectAuth: options.callerDirectAuth === undefined + ? captureCallerDirectAuth(req.headers, config) : options.callerDirectAuth, // Capture before combo replay rebuilds the Request headers; children carry options. visionDescribeTerminal: options.visionDescribeTerminal === true || req.headers.get("x-opencodex-vision-describe") === "1", @@ -3273,6 +3426,23 @@ async function handleResponsesInner( effort: comboEffortRow.effort, }; } + // Compaction may send the last client-visible bare model after a combo switch. + // Configured selectors take precedence; otherwise recall before combo dispatch (#3891). + if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) { + const rawModel = (body as { model?: unknown }).model; + const rawInput = (body as { input?: unknown }).input; + const isCompactionTrigger = Array.isArray(rawInput) + && rawInput.some((item: unknown) => + typeof item === "object" && item !== null && (item as { type?: string }).type === "compaction_trigger"); + if (typeof rawModel === "string" && !rawModel.includes("/") && isCompactionTrigger + && !comboRows.fastRow && !comboEffortRow + && !resolveComboId(config, rawModel)) { + const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), rawModel); + if (recalledComboId) { + (body as Record<string, unknown>).model = `combo/${recalledComboId}`; + } + } + } const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null; if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) { options.onRequestBodyRead?.(); @@ -3445,6 +3615,7 @@ async function handleResponsesInner( let shadowIntercepted = false; let route: RouteResult; + let credentialDomainWasRewritten = false; try { // A `compaction_trigger` turn may name a bare native model the operator has // no canonical OpenAI route for (#2901). Only the initial compaction route @@ -3467,6 +3638,7 @@ async function handleResponsesInner( const targetRoute = resolveRoute(_sci.model); if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) { shadowIntercepted = true; + credentialDomainWasRewritten = true; const _sciOriginal = parsed.modelId; parsed.modelId = _sci.model; if (parsed._rawBody && typeof parsed._rawBody === "object") { @@ -3625,6 +3797,7 @@ async function handleResponsesInner( if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { try { route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)); + credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { @@ -3761,6 +3934,7 @@ async function handleResponsesInner( if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { try { route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)); + credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { @@ -3801,14 +3975,16 @@ async function handleResponsesInner( // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream // I/O instead of stripping the id and silently forwarding a context-free delta (#702). + // Codex recognizes previous_response_not_found on WebSocket errors and reconnects with its + // full input. A generic invalid_request_error instead terminates the task after cache expiry. if ( hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider) ) { return formatErrorResponse( 400, - "invalid_request_error", - "OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.", + "previous_response_not_found", + "OpenAI forward continuation state is unavailable or expired; resend the full conversation without previous_response_id.", ); } @@ -3927,11 +4103,13 @@ async function handleResponsesInner( } let substituteMainCredential = false; + let callerAuthHeaders: Headers; { - const finalAuth = await resolveResponsesCodexAuth(req, config, route, options); + const finalAuth = await resolveResponsesCodexAuth(req, config, route, options, credentialDomainWasRewritten); if (!finalAuth.ok) return finalAuth.response; authCtx = finalAuth.authCtx; selectedForwardHeaders = finalAuth.headers; + callerAuthHeaders = finalAuth.callerAuthHeaders; substituteMainCredential = finalAuth.substituteMainCredential; } @@ -4258,7 +4436,8 @@ async function handleResponsesInner( const ownsBearer = snapshot !== undefined && sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}` && !sentHeaders?.has("x-api-key"); - const response = await fetchImpl(destination, dispatchInit); + // Reselection can choose a provider override instead of the supplied executor. + const response = await fetchImpl(destination, { ...dispatchInit, redirect: "manual" }); // Observe each physical response before retries replace it. The binding belongs to // this dispatch, so a manual switch cannot file A's headers against B. Header // overrides and credential replacement make ownership unprovable: skip those writes. @@ -4516,9 +4695,9 @@ async function handleResponsesInner( ); let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true - && adapterProvider.adapter === "openai-responses" - && adapterProvider.authMode === "forward" - && !isCanonicalOpenAiForwardProvider(adapterProvider); + && !isCanonicalOpenAiForwardProvider(adapterProvider) + && ((adapterProvider.adapter === "openai-responses" && adapterProvider.authMode === "forward") + || providerConsumesCallerAuthorization(adapterProvider)); if (stripClaudeMainAuth) { releaseCodexAuthContextProbeLease(authCtx); authCtx = { kind: "main", accountId: null }; @@ -4649,9 +4828,18 @@ async function handleResponsesInner( const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough); if (needsOpenAiVision || needsOpenAiSearch) { try { + // Preserve explicit OpenAI helper auth across route changes without returning it to + // primary-provider headers or alternate-main retry. The resolver revalidates scope. + const sidecarHeaders = new Headers(req.headers); + sidecarHeaders.delete("authorization"); + sidecarHeaders.delete("chatgpt-account-id"); + if (options.openAiSidecarAuth) { + sidecarHeaders.set("authorization", options.openAiSidecarAuth.authorization); + sidecarHeaders.set("chatgpt-account-id", options.openAiSidecarAuth.chatgptAccountId); + } openAiSidecar = await resolveFirstUsableOpenAiSidecar( listOpenAiForwardSidecarCandidates(config), - req.headers, + sidecarHeaders, config, { admission: options.admission, @@ -4708,6 +4896,17 @@ async function handleResponsesInner( } const recordTerminalOutcomes = options.recordTerminalOutcomes !== false; + let responseCompletionNotified = false; + let responseCompletionCancelled = false; + const cancelResponseCompletion = (): void => { responseCompletionCancelled = true; }; + const notifyResponseComplete = (response: { status?: unknown; model?: unknown }): void => { + if (responseCompletionNotified || responseCompletionCancelled + || options.abortSignal?.aborted || req.signal.aborted + || response.status !== "completed" + || typeof response.model !== "string" || !response.model.trim()) return; + responseCompletionNotified = true; + options.onResponseComplete?.(response.model); + }; const continuationStateForResponse = ( emitted?: OcxProviderContinuationState, @@ -4992,9 +5191,26 @@ async function handleResponsesInner( // check sees nothing undeclared, and the refused turn enters continuation state anyway. So the // rejection is sticky for the whole turn, set from every parsed payload on the inspection side. let inspectionSawUndeclaredTool = false; + let inspectedTerminal: ResponsesTerminalStatus | null = null; + let inspectedCompletionSeen = false; + let firstTerminalAllowsRecall = false; const passiveQuotaObserved = hasPassiveAccountQuota(route.providerName) && route.provider.authMode === "oauth"; const noteInspectedPayload = (payload: unknown) => { + // First terminal stays authoritative even in metadata-only inspection, which + // intentionally continues parsing after a failed/incomplete terminal. + const terminal = terminalStatusFromParsed(payload); + if (inspectedTerminal === null && terminal !== null) { + inspectedTerminal = terminal; + // The client boundary accepts a terminal by event type, even without a + // response object. Such a terminal must permanently decline recall. + if (terminal === "completed" && payload && typeof payload === "object" + && "response" in payload && payload.response && typeof payload.response === "object" + && !Array.isArray(payload.response) && "model" in payload.response) { + firstTerminalAllowsRecall = typeof payload.response.model === "string" + && payload.response.model.trim().length > 0; + } + } // Meta reports subscription usage ONLY as an in-stream event; there is no endpoint // to poll (003 §E probed 17 paths, all 404). Observed here rather than behind a // dedicated inspector handler because onParsedPayload already reaches every @@ -5019,8 +5235,7 @@ async function handleResponsesInner( // Gated on the same flag as the guard itself: with no readable catalog (or a forward-auth // provider) every name looks undeclared, and flipping this would stop recording continuation // state for exactly the passthrough traffic the guard deliberately stands down for. - if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return; - if (undeclaredToolCallName( + if (undeclaredToolGuardActive && !inspectionSawUndeclaredTool && undeclaredToolCallName( restoreAuthorizedBareNamespaceToolCalls(payload), declaredWireToolNames, declaredNamelessClientCallTypes, @@ -5028,33 +5243,61 @@ async function handleResponsesInner( ) !== undefined) { inspectionSawUndeclaredTool = true; } + // The snapshot callback opts the inspector into output reconstruction. Compaction + // has no continuation cache, so use the parsed terminal here without adding retention. + if (!rememberPassthroughResponse && payload && typeof payload === "object" + && "type" in payload && payload.type === "response.completed" + && "response" in payload && payload.response && typeof payload.response === "object" + && !Array.isArray(payload.response)) { + rememberPassthroughResponseChecked(payload.response as Record<string, unknown>); + } }; - const rememberPassthroughResponseChecked = rememberPassthroughResponse - ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { - if (inspectionSawUndeclaredTool) return; - const restored = restoreRoutedCustomCalls( - restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), - routedCustomToolNames, - routedCustomToolRepairNames, + const rememberPassthroughResponseChecked = ( + response: { id?: unknown; output?: unknown; status?: unknown; model?: unknown }, + ) => { + if (inspectionSawUndeclaredTool) return; + const restored = restoreRoutedCustomCalls( + restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), + routedCustomToolNames, + routedCustomToolRepairNames, + declaredWireToolNames, + ).value; + const restoredResponse = (functionRepairSchemas.size > 0 + ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) + : restored) as { id?: unknown; output?: unknown; status?: unknown }; + // Replay overlap compares the items the client echoes, including visible reasoning shape. + const replayResponse = parsed.options.hideThinkingSummary !== true + && routeUsesContentChannelReasoning(route.provider, route.modelId) + ? rewriteReasoningSummaryInJson(restoredResponse) as typeof restoredResponse + : restoredResponse; + if ( + undeclaredToolGuardActive + && undeclaredToolCallNameInResponse( + restoredResponse, declaredWireToolNames, - ).value; - const restoredResponse = (functionRepairSchemas.size > 0 - ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) - : restored) as { id?: unknown; output?: unknown; status?: unknown }; - if ( - undeclaredToolGuardActive - && undeclaredToolCallNameInResponse( - restoredResponse, - declaredWireToolNames, - declaredNamelessClientCallTypes, - providerExecutedCallTypes, - ) !== undefined - ) { - return; + declaredNamelessClientCallTypes, + providerExecutedCallTypes, + ) !== undefined + ) { + return; + } + rememberPassthroughResponse?.(replayResponse); + const firstCompletion = !inspectedCompletionSeen; + inspectedCompletionSeen = true; + if (firstCompletion && (inspectedTerminal === null || firstTerminalAllowsRecall)) { + // A model-less first completion permanently declines recall; later terminal + // frames are hidden by the client boundary and cannot supply its identity. + // Native inspection sees the pre-rewrite model. Only an actual terminal + // model can seed recall; an absent model never falls back to the pick. + if (typeof response.model === "string" && response.model.trim()) { + notifyResponseComplete({ + status: response.status, + model: parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId + ? parsed._responseModelId : response.model, + }); } - rememberPassthroughResponse(restoredResponse); } - : undefined; + }; recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); const actualHostKey = upstreamHostHealthKey( @@ -5705,7 +5948,7 @@ async function handleResponsesInner( // justify because this flag already produced the identical result. const storedReplaySpent = codex401ReplayKind === "stored"; const retry = await retryCodexPoolOnAlternateAccount({ - req, + callerAuthHeaders, config, route, parsed, @@ -6217,7 +6460,7 @@ async function handleResponsesInner( const inspector = createSseInspector({ onTerminal: reportNativeTerminal, logCtx, - onCompletedResponse: rememberPassthroughResponseChecked, + onCompletedResponse: rememberPassthroughResponse ? rememberPassthroughResponseChecked : undefined, onParsedPayload: noteInspectedPayload, onFirstOutput: options.onFirstOutput, pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled, @@ -6247,7 +6490,10 @@ async function handleResponsesInner( reportNativeTerminal("failed", 502); } }, - onClientCancel: () => options.onNativePassthroughCancel?.(), + onClientCancel: () => { + responseCompletionCancelled = true; + options.onNativePassthroughCancel?.(); + }, onDone: () => unregisterTurn(turnAc), }, { clientGoneSignal: options.abortSignal, @@ -6309,8 +6555,11 @@ async function handleResponsesInner( turnAc.signal, () => unregisterTurn(turnAc), logCtx, - () => options.onNativePassthroughCancel?.(), - rememberPassthroughResponseChecked, + () => { + responseCompletionCancelled = true; + options.onNativePassthroughCancel?.(); + }, + rememberPassthroughResponse ? rememberPassthroughResponseChecked : undefined, options.onFirstOutput, inspectionConsumerOptions, ); @@ -6320,7 +6569,7 @@ async function handleResponsesInner( logCtx, turnAc.signal, () => unregisterTurn(turnAc), - rememberPassthroughResponseChecked, + rememberPassthroughResponse ? rememberPassthroughResponseChecked : undefined, options.onFirstOutput, inspectionConsumerOptions, ); @@ -6335,7 +6584,10 @@ async function handleResponsesInner( const clientBody = relaySseWithFailedTail( rewrittenBody, upstream, - reason => clientGone.abort(reason), + reason => { + responseCompletionCancelled = true; + clientGone.abort(reason); + }, { upstreamError: logCtx.upstreamError }, ); return markNativePassthroughSseResponse(new Response(clientBody, { @@ -6419,13 +6671,11 @@ async function handleResponsesInner( } } commitReasoningReplayServingRoute(); - if (rememberPassthroughResponseChecked) { - try { - rememberPassthroughResponseChecked( - JSON.parse(clientJson) as { id?: unknown; output?: unknown; status?: unknown }, - ); - } catch { /* non-JSON despite content-type; recording is best-effort */ } - } + try { + rememberPassthroughResponseChecked( + JSON.parse(clientJson) as { id?: unknown; output?: unknown; status?: unknown; model?: unknown }, + ); + } catch { /* non-JSON despite content-type; recording is best-effort */ } // #875: the transport-neutral reliability policy forced a bounded JSON // upstream for a client that asked for SSE. Reframe the completed JSON // as the canonical terminal SSE sequence (created → output_item.done → @@ -6776,10 +7026,12 @@ async function handleResponsesInner( continuationStateForResponse(providerState), responseStateOptions(adapterNeedsForcedContinuation(adapter.name)), ); + notifyResponseComplete(response); }, }); if (imgResponse.body) { const imgTurnAc = new AbortController(); + imgTurnAc.signal.addEventListener("abort", cancelResponseCompletion, { once: true }); return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc, undefined, options.turnAdmissionLease), { status: imgResponse.status, headers: imgResponse.headers, @@ -6855,12 +7107,16 @@ async function handleResponsesInner( streamRoutedModelOutput: wsPlan.streamRoutedModelOutput, on429: rotateSidecarProviderOn429, retryOn429Policy: rateLimitRetryPolicyFor(route.provider), - onCompletedResponse: commitReasoningReplayServingRoute, + onCompletedResponse: response => { + commitReasoningReplayServingRoute(); + notifyResponseComplete(response); + }, }); // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts) // in-flight web-search turns instead of skipping them during graceful shutdown. if (wsResponse.body) { const wsTurnAc = new AbortController(); + wsTurnAc.signal.addEventListener("abort", cancelResponseCompletion, { once: true }); return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc, undefined, options.turnAdmissionLease), { status: wsResponse.status, headers: wsResponse.headers, @@ -7092,6 +7348,7 @@ async function handleResponsesInner( const sseStream = bridgeToResponsesSSE( guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { + cancelResponseCompletion(); runTurnAbort.abort(); queue.close(); }, 2_000, @@ -7130,6 +7387,7 @@ async function handleResponsesInner( responseStateOptions(adapterNeedsForcedContinuation(adapter.name)), ); } + notifyResponseComplete(response); }, }, ); @@ -7208,6 +7466,7 @@ async function handleResponsesInner( if (adapterResponseReachedServingTerminal(events, json)) { commitReasoningReplayServingRoute(); } + notifyResponseComplete(json); return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); } @@ -7255,10 +7514,11 @@ async function handleResponsesInner( toolBridgeMaps.toolNsMap, toolBridgeMaps.freeformToolNames, toolBridgeMaps.toolSearchToolNames, - undefined, + cancelResponseCompletion, 2_000, { translatorBudget, + onCompletedResponse: notifyResponseComplete, ...(options.forceEmptyResponseId ? { responseId: "" } : {}), ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), }, @@ -7279,12 +7539,9 @@ async function handleResponsesInner( }, ); } - return new Response( - JSON.stringify(buildResponseJSON(terminalEvents, parsed._responseModelId ?? parsed.modelId, { - translatorBudget, - })), - { headers: { "Content-Type": "application/json" } }, - ); + const json = buildResponseJSON(terminalEvents, parsed._responseModelId ?? parsed.modelId, { translatorBudget }); + notifyResponseComplete(json); + return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); } // One request-scoped transient-retry budget owner, declared here so BOTH the initial send // and the later recovery refetches (429, key/account rotation, OAuth replay) share it. A @@ -8638,7 +8895,7 @@ async function handleResponsesInner( const benchmarkUsageGate = { done: false }; const sseStream = bridgeToResponsesSSE( guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, - () => upstream.abort(), 2_000, + () => { cancelResponseCompletion(); upstream.abort(); }, 2_000, { translatorBudget, replayCacheScope: parsed._reasoningReplayScope, @@ -8675,6 +8932,7 @@ async function handleResponsesInner( responseStateOptions(activeAdapter.name === "kiro"), ); } + notifyResponseComplete(response); }, }, ); @@ -8756,6 +9014,7 @@ async function handleResponsesInner( if (adapterResponseReachedServingTerminal(events, json)) { commitReasoningReplayServingRoute(); } + notifyResponseComplete(json); return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); } diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 55f97baf48..e637abe1f7 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -9,9 +9,6 @@ import type { OcxProviderConfig } from "../../types"; import type { WsData } from "../ws-bridge"; import { waitForProviderRequestSlot } from "../../providers/request-pacing"; import { withUpstreamHttpVersion } from "../../lib/upstream-http-version"; -import { providerTlsFetch } from "../../lib/provider-tls-profile"; -import { testProviderFetch } from "../../lib/test-provider-fetch"; -import { runtimeProviderFetch } from "../../lib/provider-runtime-fetch"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; export { withUpstreamHttpVersion }; @@ -68,8 +65,6 @@ export interface ProviderFetchOptions { modelId?: string; /** One pacing slot was acquired immediately before this fetch wrapper was created. */ pacingSlotAcquired?: boolean; - /** Explicit test/integration executor; never read from serialized provider config. */ - fetch?: typeof globalThis.fetch; /** Captured selected-account observer, attached before the native WS send. */ onCodexWsQuota?: CodexWsQuotaObserver; /** Synchronous admission at actual credential dispatch, after pacing/backoff. */ @@ -83,24 +78,24 @@ export function providerFetch( runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), options: ProviderFetchOptions = {}, ): ProviderFetch { - const base = options.fetch - ?? testProviderFetch(provider) - ?? runtimeProviderFetch(provider, options.providerName) - ?? (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch - ?? globalThis.fetch; + const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => { base.preconnect?.(...args); }; - const transport = options.providerName - ? providerTlsFetch(options.providerName, provider, base) - : base; + // Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends. + // Return the original 3xx so the response owner retains its retry/health/relay contract. + const dispatch = Object.assign( + (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => + base(input, { ...init, redirect: "manual" }), + { preconnect }, + ) as typeof globalThis.fetch; const httpFetch = Object.assign( async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => { options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }; return options.dispatchOverride - ? options.dispatchOverride(input, dispatchInit, transport) - : transport(input, dispatchInit); + ? options.dispatchOverride(input, dispatchInit, dispatch) + : dispatch(input, dispatchInit); }, { preconnect }, ) as typeof globalThis.fetch; @@ -109,19 +104,12 @@ export function providerFetch( // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details. const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => { const upstreamWebsocket = provider.upstreamWebsocket === true; - const wsOpts = { - // Keep the canonical ChatGPT fast lane independent: upstreamWebsocket opts a - // configured HTTPS /responses endpoint in, but must not silently enable Codex WS. - wsUpstream: provider.wsUpstream, - maxWsFrameBytes: provider.maxWsFrameBytes, - upstreamWebsocket, - }; - if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, wsOpts)) { + if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { // The fallback has to be the same HTTP fetch the non-WS branch would have // used, protocol pin included: a WS turn that falls back is serving the // request over HTTP, and dropping the provider's `upstreamHttpVersion` // there would silently negotiate a transport the operator ruled out. - return codexWsUpstreamFetch(input, init, httpFetch, runtime, wsOpts, options.onCodexWsQuota, options.beforeDispatch); + return codexWsUpstreamFetch(input, init, httpFetch, runtime, options.onCodexWsQuota, options.beforeDispatch); } return httpFetch(input, init); }; @@ -190,6 +178,10 @@ export function storedPoolReplayDispatchNotifier( }) as ProviderFetch; } +/** + * Fetch through the header deadline with redirects always manual. + * @param _manualRedirect Ignored; retained for call compatibility. Even false uses manual. + */ export async function fetchWithHeaderTimeout( url: string, init: Omit<RequestInit, "signal">, @@ -197,7 +189,8 @@ export async function fetchWithHeaderTimeout( timeoutMs: number, preferIdentityEncoding = false, executor: typeof globalThis.fetch = globalThis.fetch, - _manualRedirect = true, + // Retained for existing callers; credential-bearing transport no longer opts out. + _manualRedirect = false, ): Promise<Response> { const pacing = executor as ProviderFetch; await pacing.waitForPacing?.(abortSignal); @@ -216,9 +209,9 @@ export async function fetchWithHeaderTimeout( const response = await fetchExecutor(url, { ...init, headers, - // Upstream URLs are configuration, not navigation. Refuse every redirect - // so POST bodies and provider headers are never replayed to another hop. - redirect: "manual" as const, + // Never replay provider credentials or request bodies to a redirect destination. + // Preserve the 3xx for the owner's existing response/health policy (#914, #1471). + redirect: "manual", signal: AbortSignal.any([abortSignal, timeout.signal]), timeout: 0, }); diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index a4f06d0fa6..a6f1d425d8 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -6,6 +6,8 @@ import type { OcxConfig } from "../../types"; import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace"; import { handleResponses as handleResponsesCore } from "./core"; import { requestPacingOverloadResponse } from "./pacing-overload"; +import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar"; +import { captureCallerDirectAuth } from "../../providers/caller-authorization"; type CoreHandler = typeof handleResponsesCore; type CoreOptions = Parameters<CoreHandler>[3]; @@ -47,6 +49,10 @@ function requestWithCandidate( candidate: Pick<RouteCandidateTrace, "provider" | "model">, ): Request { const headers = new Headers(req.headers); + // The next candidate owns a different physical credential domain. Typed + // admission and any claimed Claude snapshot stay in caller-owned CoreOptions. + headers.delete("authorization"); + headers.delete("chatgpt-account-id"); headers.delete("content-encoding"); headers.delete("content-length"); headers.set("content-type", "application/json"); @@ -119,6 +125,12 @@ export async function handleResponsesWithPolicyFallback( let storedPool401ReplayDispatched = false; const coreOptions: CoreOptions = { ...options, + openAiSidecarAuth: options.openAiSidecarAuth === undefined + ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth, + nativeCallerAuth: options.nativeCallerAuth === undefined + ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.nativeCallerAuth, + callerDirectAuth: options.callerDirectAuth === undefined + ? captureCallerDirectAuth(req.headers, config) : options.callerDirectAuth, ...(options.onRequestBodyRead ? { onRequestBodyRead: () => { if (requestBodyReadNotified) return; diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index 6199a11dd6..87b3767d2b 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -4,8 +4,8 @@ // a measurably faster queue than the plain SSE POST path. Measured 2026-08-12 // KST (same account, same payload, strictly sequential): gpt-5.6-luna TTFT p50 // ~1.0s over WS vs ~3.9s over SSE. Codex CLI itself defaults to the WS -// transport; opencodex keeps HTTP/SSE as its reliable default and allows -// operators to opt into WS when the lower latency is worth the risk. +// transport; opencodex previously always POSTed SSE, which is where its extra +// 2-3s of TTFT came from. // // The wrapper only swaps the transport. It dials wss:// with the same headers, // sends the JSON body as a single `response.create` frame, and re-encodes the @@ -19,11 +19,12 @@ import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, import { codexWsExchange } from "./codex-ws-exchange"; import { CodexWsSession } from "./codex-ws-session"; import { codexWsPool, codexWsReuseIdentity } from "./codex-ws-pool"; -import { CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit } from "./codex-ws-wire"; +import { codexWsCreateFrameExceedsLimit } from "./codex-ws-wire"; export { CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, MAX_CODEX_WS_CREATE_FRAME_BYTES, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit, isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./codex-ws-wire"; export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0"; + /** * Dial URL for a request URL. The canonical ChatGPT backend keeps its constant; * an operator-opted OpenAI-compatible upstream swaps https for wss on the same @@ -32,7 +33,7 @@ export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0"; * a provider WS handshake would otherwise send credentials and request data * without transport encryption. */ -export function wsUpstreamUrlFor(httpUrl: string): string { +function wsUpstreamUrlFor(httpUrl: string): string { if (httpUrl === CODEX_RESPONSES_HTTP_URL) return CODEX_RESPONSES_WS_URL; return httpUrl.replace(/^http(s?):/, "ws$1:"); } @@ -43,14 +44,15 @@ export function wsUpstreamUrlFor(httpUrl: string): string { * and every downstream consumer (adapter parsers, usage sniffing, SSE relay) * assumes that wire. Other paths (chat completions, images, search) stay HTTP. */ -export function isResponsesWebsocketEligibleUrl(url: string): boolean { +function isResponsesWebsocketEligibleUrl(url: string): boolean { let parsed: URL; try { parsed = new URL(url); } catch { return false; } - return parsed.protocol === "https:" && parsed.pathname.endsWith("/responses"); + return parsed.protocol === "https:" + && parsed.pathname.endsWith("/responses"); } export type BunRuntimeIdentity = { version: string; @@ -59,29 +61,6 @@ export type BunRuntimeIdentity = { export type BunRuntimeGateInput = string | BunRuntimeIdentity; -export interface CodexWsUpstreamOptions { - wsUpstream?: boolean; - maxWsFrameBytes?: number; - upstreamWebsocket?: boolean; -} - -export function isCodexWsUpstreamDisabled(options?: CodexWsUpstreamOptions): boolean { - if (options?.wsUpstream !== undefined) return options.wsUpstream !== true; - const env = process.env.OCX_CODEX_WS_UPSTREAM; - return env !== "true" && env !== "1"; -} - -export function resolveCodexWsMaxFrameBytes(options?: CodexWsUpstreamOptions): number { - if (typeof options?.maxWsFrameBytes === "number" && Number.isFinite(options.maxWsFrameBytes) && options.maxWsFrameBytes > 0) { - return Math.min(options.maxWsFrameBytes, CODEX_WS_CREATE_FRAME_LIMIT_BYTES); - } - const envVal = process.env.OCX_CODEX_WS_MAX_FRAME_BYTES; - if (envVal) { - const parsed = Number.parseInt(envVal, 10); - if (Number.isFinite(parsed) && parsed > 0) return Math.min(parsed, CODEX_WS_CREATE_FRAME_LIMIT_BYTES); - } - return CODEX_WS_CREATE_FRAME_LIMIT_BYTES; -} export function currentBunRuntimeIdentity(): BunRuntimeIdentity { return { version: Bun.version, @@ -122,26 +101,11 @@ export function shouldUseCodexWsUpstream( url: string, init?: RequestInit, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), - options?: CodexWsUpstreamOptions | boolean, + upstreamWebsocketConfigured = false, ): boolean { - // Union: accept boolean legacy (vendor upstreamWebsocketConfigured) and object (fork CodexWsUpstreamOptions). - let opts: CodexWsUpstreamOptions | undefined; - let upstreamWebsocketConfigured = false; - if (typeof options === "boolean") { - upstreamWebsocketConfigured = options; - } else { - opts = options; - upstreamWebsocketConfigured = opts?.upstreamWebsocket === true; - } if (!bunSupportsBoundedCodexWsRelay(runtime)) return false; - if (url === CODEX_RESPONSES_HTTP_URL) { - // A custom-upstream opt-in never enables the canonical ChatGPT fast lane. - if (typeof options !== "boolean" && isCodexWsUpstreamDisabled(opts)) return false; - } else if (upstreamWebsocketConfigured) { - if (!isResponsesWebsocketEligibleUrl(url)) return false; - } else { - return false; - } + if (url !== CODEX_RESPONSES_HTTP_URL && !upstreamWebsocketConfigured) return false; + if (upstreamWebsocketConfigured && !isResponsesWebsocketEligibleUrl(url)) return false; if ((init?.method ?? "GET").toUpperCase() !== "POST") return false; const body = init?.body; if (typeof body !== "string") return false; @@ -163,18 +127,15 @@ export function codexWsUpstreamFetch( init: RequestInit, sseFallback: typeof globalThis.fetch, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), - options?: CodexWsUpstreamOptions | boolean, onQuota?: CodexWsQuotaObserver, beforeDispatch?: (headers: Headers) => void, ): Promise<Response> { - const opts = typeof options === "boolean" ? undefined : options; - const customUpstream = options === true || opts?.upstreamWebsocket === true; - if ((!customUpstream && isCodexWsUpstreamDisabled(opts)) || !bunSupportsBoundedCodexWsRelay(runtime)) { - return sseFallback(url, prepareCodexHttpInit(url, init)); - } const prepared = prepareCodexWsRequest(url, init); if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init)); init = prepared.httpInit; + if (!bunSupportsBoundedCodexWsRelay(runtime)) { + return sseFallback(url, init); + } const signal = init.signal ?? undefined; if (signal?.aborted) { return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); @@ -186,8 +147,7 @@ export function codexWsUpstreamFetch( // streaming Response, so the oversized close can only be surfaced as a stream // error — and a resend at that point could double-generate. Measuring the // frame we are about to send keeps the whole failure mode unreachable. - const maxFrameBytes = resolveCodexWsMaxFrameBytes(opts); - if (codexWsCreateFrameExceedsLimit(frameText, maxFrameBytes)) { + if (codexWsCreateFrameExceedsLimit(frameText)) { return sseFallback(url, init); } @@ -195,7 +155,6 @@ export function codexWsUpstreamFetch( const proxyRoute = resolveProxyRoute(new URL(wsUrl)); if (proxyRoute.kind === "fallback") return sseFallback(url, init); const proxy = proxyRoute.kind === "proxy" ? proxyRoute.proxy : undefined; - // A genuine caller `originator` is already in these headers via the forward // set. Never fabricate one here: pool/forward traffic must not impersonate // Codex CLI, per the metadata-integrity contract. (The backend's fast lane diff --git a/src/service.ts b/src/service.ts index fa8770ec55..1fa97c424d 100644 --- a/src/service.ts +++ b/src/service.ts @@ -16,6 +16,13 @@ import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject"; import { stripGrokConfig } from "./grok/inject"; import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime"; + +/** + * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in + * for it: `ocx claude` and `ocx opencode` set that on the proxies they spawn to borrow its + * routing-preservation meaning, so a proxy carrying it is not necessarily the managed job. + */ +export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED"; import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime"; import { isProcessAlive, stopProxy } from "./lib/process-control"; import { serviceApiTokenFilePath } from "./lib/service-secrets"; @@ -508,6 +515,11 @@ export function buildPlist( const opencodexHome = process.env.OPENCODEX_HOME?.trim(); const envLines = [ ` <key>OCX_SERVICE</key><string>1</string>`, + // OCX_SERVICE alone cannot identify the managed job: `ocx claude` and `ocx opencode` + // also set it on the proxies they spawn, to borrow its routing-preservation meaning + // (src/cli/index.ts preserveRouting). Only the wrapper writes this second marker, so + // the dashboard-stop refusal below can tell a real launchd job from an ordinary child. + ` <key>${SERVICE_MANAGED_ENV}</key><string>1</string>`, ...(launcher ? [] : [ ` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`, ` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`, @@ -3328,6 +3340,7 @@ export function buildUnit( const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim()); const envLines = [ systemdEnvironmentAssignment("OCX_SERVICE", "1"), + systemdEnvironmentAssignment(SERVICE_MANAGED_ENV, "1"), ...(launcher ? [] : [ systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun), @@ -3860,10 +3873,31 @@ export async function installFreshWindowsSchedulerSafely( export function installedServiceRespawnRisk( probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask, platform: NodeJS.Platform = process.platform, -): "none" | "respawnable" | "unknown" { + io: { env?: NodeJS.ProcessEnv; exists?: (path: string) => boolean } = {}, +): "none" | "respawnable" | "unknown" | "self-unload" { // launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler // wrapper survives its task ending (#764). - if (platform !== "win32") return "none"; + // + // "Down when they report stopped" answers the RESPAWN question but not the SELF-UNLOAD + // one (#4023). When the proxy is itself the managed job, `launchctl unload` / + // `systemctl stop` terminate this very process, so the manager stop can kill the request + // handler before the shared teardown restores the native Codex config keys — leaving + // `openai_base_url`, `experimental_realtime_ws_base_url` and `model_catalog_json` + // pointed at a proxy that is gone. Reordering teardown ahead of the manager stop is not + // available here: the #3008 contract requires the manager to be proven stopped first. + // So refuse, exactly as Windows does, and send the operator to `ocx stop`, which stops + // the proxy from the outside and owns the teardown through its receipt. + if (platform !== "win32") { + const env = io.env ?? process.env; + // Discriminate on the wrapper-only marker, not on OCX_SERVICE: `ocx claude` and + // `ocx opencode` set OCX_SERVICE=1 on the proxies they spawn (for preserveRouting), + // and refusing their dashboard stop would break a proxy that no manager supervises. + if (env[SERVICE_MANAGED_ENV] !== "1") return "none"; + const exists = io.exists ?? existsSync; + if (platform === "darwin") return exists(plistPath()) ? "self-unload" : "none"; + if (platform === "linux") return exists(unitPath()) ? "self-unload" : "none"; + return "none"; + } try { // `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries // fail — it does not throw — so testing for "present" let an unanswerable probe diff --git a/src/storage/cleanup.ts b/src/storage/cleanup.ts index e44f7d7b5c..adefc25443 100644 --- a/src/storage/cleanup.ts +++ b/src/storage/cleanup.ts @@ -96,6 +96,7 @@ export interface CleanupResult { trashDir?: string; error?: CleanupErrorCode; removedPaths: string[]; + skippedReferencedPaths?: string[]; } const STATE_DB_FILE = /^state_(\d+)\.sqlite$/; @@ -684,52 +685,59 @@ function loadMatchingThreads(db: Database, candidates: ArchivedCandidate[], code } /** - * True when any matched thread is still linked to a thread outside the delete set - * (spawn edges) or uses paginated history that other live threads may depend on via fork. - * Throws real DB errors (busy/corruption) so callers can refuse cleanup. + * Partition matched threads into deletable and referenced snapshots. Linked spawn/fork + * history and paginated histories stay in the skipped set. Throws real DB errors. */ -function findReferencedHistory( +function filterReferencedHistory( db: Database, threads: ThreadSnapshot[], -): boolean { - if (threads.length === 0) return false; - const ids = threads.map(t => t.id); - const idSet = new Set(ids); - - // Paginated history keeps durable projections tied to the rollout — refuse cleanup. - if (threads.some(t => (t.history_mode ?? "").toLowerCase() === "paginated")) { - return true; - } - - // Spawn edges that cross the delete boundary keep history reachable. - if (tableExists(db, "thread_spawn_edges")) { - for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK)) { +): { safe: ThreadSnapshot[]; skipped: ThreadSnapshot[] } { + let safe = threads.filter(t => (t.history_mode ?? "").toLowerCase() !== "paginated"); + const skipped = new Map(threads + .filter(t => (t.history_mode ?? "").toLowerCase() === "paginated") + .map(t => [t.id, t])); + + while (safe.length > 0) { + const idSet = new Set(safe.map(t => t.id)); + const unsafeIds = new Set<string>(); + + // Spawn edges that cross the delete boundary keep history reachable. + if (tableExists(db, "thread_spawn_edges")) { + for (const chunk of chunkIds([...idSet], SQLITE_ID_CHUNK)) { const placeholders = chunk.map(() => "?").join(","); const edges = db.query<{ parent_thread_id: string; child_thread_id: string }, string[]>( `SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`, ).all(...chunk, ...chunk); for (const edge of edges) { - if (!idSet.has(edge.parent_thread_id) || !idSet.has(edge.child_thread_id)) { - return true; + if (!idSet.has(edge.parent_thread_id)) unsafeIds.add(edge.child_thread_id); + if (!idSet.has(edge.child_thread_id)) unsafeIds.add(edge.parent_thread_id); + } + } + } + + // Other threads that list one of ours as forked_from / parent (when columns exist). + for (const column of ["forked_from_id", "parent_thread_id", "source_thread_id"] as const) { + if (!columnExists(db, "threads", column)) continue; + for (const chunk of chunkIds([...idSet], SQLITE_ID_CHUNK * 2)) { + const placeholders = chunk.map(() => "?").join(","); + const rows = db.query<{ id: string; ref: string }, string[]>( + `SELECT id, ${column} AS ref FROM threads WHERE ${column} IN (${placeholders})`, + ).all(...chunk); + for (const row of rows) { + if (!idSet.has(row.id)) unsafeIds.add(row.ref); } } } - } - // Other threads that list one of ours as forked_from / parent (when columns exist). - for (const column of ["forked_from_id", "parent_thread_id", "source_thread_id"] as const) { - if (!columnExists(db, "threads", column)) continue; - for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK * 2)) { - const placeholders = chunk.map(() => "?").join(","); - const rows = db.query<{ id: string }, string[]>( - `SELECT id FROM threads WHERE ${column} IN (${placeholders})`, - ).all(...chunk); - if (rows.some(r => !idSet.has(r.id))) return true; + if (unsafeIds.size === 0) break; + for (const thread of safe) { + if (unsafeIds.has(thread.id)) skipped.set(thread.id, thread); } + safe = safe.filter(thread => !unsafeIds.has(thread.id)); } - return false; + return { safe, skipped: [...skipped.values()] }; } function tableExists(db: Database, name: string): boolean { @@ -778,6 +786,7 @@ function deleteThreadsAndDependents(db: Database, threadIds: string[]): void { interface ReconcileOk { ok: true; threads: ThreadSnapshot[]; + skipped: ThreadSnapshot[]; } interface ReconcileErr { ok: false; @@ -1463,14 +1472,14 @@ function withWritableDb( } } -/** Load matching archived threads and refuse referenced history — no deletes yet. */ +/** Load matching archived threads and retain referenced history — no deletes yet. */ function loadThreadsForCleanup( stateDbPath: string, candidates: ArchivedCandidate[], codexHome: string, busyTimeoutMs: number, ): ReconcileOk | ReconcileErr { - if (!stateDbPath || !existsSync(stateDbPath)) return { ok: true, threads: [] }; + if (!stateDbPath || !existsSync(stateDbPath)) return { ok: true, threads: [], skipped: [] }; let db: Database | undefined; try { db = openDbWritable(stateDbPath, busyTimeoutMs); @@ -1478,10 +1487,8 @@ function loadThreadsForCleanup( if (threads.some(t => Number(t.is_pinned ?? 0) === 1)) { return { ok: false, error: "pinned_thread" }; } - if (findReferencedHistory(db, threads)) { - return { ok: false, error: "referenced_history" }; - } - return { ok: true, threads }; + const filtered = filterReferencedHistory(db, threads); + return { ok: true, threads: filtered.safe, skipped: filtered.skipped }; } catch (error) { return { ok: false, error: mapDbError(error) }; } finally { @@ -1504,7 +1511,7 @@ function reconcileDeletedThreads( stageDir: string, hooks?: ReconcileTestHooks, ): ReconcileOk | ReconcileErr { - if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [] }; + if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [], skipped: [] }; if (hooks?.beforeReconcileLock) hooks.beforeReconcileLock(); @@ -1546,7 +1553,7 @@ function reconcileDeletedThreads( stateDb.exec("ROLLBACK"); return { ok: false, error: "pinned_thread" }; } - if (findReferencedHistory(stateDb, threads)) { + if (filterReferencedHistory(stateDb, threads).safe.length !== threads.length) { stateDb.exec("ROLLBACK"); return { ok: false, error: "referenced_history" }; } @@ -1582,7 +1589,7 @@ function reconcileDeletedThreads( if (hooks?.afterSatelliteMutations) hooks.afterSatelliteMutations(); // Re-check under the same lock before committing state deletes. - if (findReferencedHistory(stateDb, threads)) { + if (filterReferencedHistory(stateDb, threads).safe.length !== threads.length) { stateDb.exec("ROLLBACK"); return failWithRestore("referenced_history"); } @@ -1590,7 +1597,7 @@ function reconcileDeletedThreads( if (hooks?.failBeforeStateCommit) throw new Error("test_fail_before_state_commit"); stateDb.exec("COMMIT"); // Keep satellite-backup.json for quarantine restore; permanent purge removes the stage. - return { ok: true, threads }; + return { ok: true, threads, skipped: [] }; } catch (error) { if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks); throw error; @@ -1895,7 +1902,30 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR const normalized = normalizeArchivedRolloutPath(thread.rollout_path, codexHome); if (normalized) threadByRelPath.set(normalized, thread); } - const manifestEntries: CleanupManifestEntry[] = preview.candidates.map(candidate => { + const skippedReferencedPaths = loaded.skipped + .map(thread => normalizeArchivedRolloutPath(thread.rollout_path, codexHome)) + .filter((path): path is string => path !== null); + const matchedPaths = new Set([ + ...threadByRelPath.keys(), + ...skippedReferencedPaths, + ]); + const candidates = preview.candidates.filter(candidate => { + return !matchedPaths.has(candidate.relPath) || threadByRelPath.has(candidate.relPath); + }); + if (candidates.length === 0) { + removeStageIfEmpty(stageDir, []); + removeEmptyTrashRoot(codexHome); + return { + ok: true, + mode, + percent, + count: 0, + bytes: 0, + removedPaths: [], + ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}), + }; + } + const manifestEntries: CleanupManifestEntry[] = candidates.map(candidate => { const thread = threadByRelPath.get(candidate.relPath); return { relPath: candidate.relPath, @@ -1936,7 +1966,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR return fail(mode, percent, "fs_failed"); } - const stageResult = stageCandidates(codexHome, preview.candidates, stageDir, { + const stageResult = stageCandidates(codexHome, candidates, stageDir, { blockDestBasenames: blockStageDest.size > 0 ? blockStageDest : undefined, }); if (!stageResult.ok) { @@ -1956,7 +1986,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR const deleted = reconcileDeletedThreads( paths, - preview.candidates, + candidates, codexHome, busyTimeoutMs, stageDir, @@ -1973,8 +2003,8 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR return fail(mode, percent, deleted.error, keepTrash ? { trashDir } : undefined); } - const removedPaths = preview.candidates.map(c => c.relPath); - const bytes = preview.candidates.reduce((sum, c) => sum + c.bytes, 0); + const removedPaths = candidates.map(c => c.relPath); + const bytes = candidates.reduce((sum, c) => sum + c.bytes, 0); if (mode === "quarantine") { return { @@ -1985,6 +2015,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR bytes, trashDir, removedPaths, + ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}), }; } @@ -2038,6 +2069,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR count: removedPaths.length, bytes, removedPaths, + ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}), }; } diff --git a/src/types/config.ts b/src/types/config.ts index 5a33419985..f0ef5dff0b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -506,8 +506,8 @@ export interface OcxConfig { */ syncCodexSubagentDefaults?: boolean; /** - * Optional reasoning effort the delegation prompt tells the agent to pass in spawn_agent calls - * (`reasoning_effort` argument). Only meaningful while `injectionModel` is set; validated against + * Optional reasoning effort reported as advisory metadata in v2 sub-agent guidance. + * It does not prescribe spawn overrides. Only meaningful while `injectionModel` is set; validated against * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary. */ injectionEffort?: string; @@ -550,7 +550,7 @@ export interface OcxConfig { streamMode?: "auto" | "legacy-tee" | "eager-relay"; /** * Custom override for the injected v2 multi-agent guidance body (the text inside - * the <multi_agent_mode> tags). After guidance is enabled and the v2 surface and + * the <opencodex_subagent_guidance> tags). After guidance is enabled and the v2 surface and * catalog-state gates pass, a configured injectionModel is sufficient to render it; * otherwise an eligible roster or fallback is required. Placeholders: `{{model}}` -> the * effective preferred model for the request (a bare native model is account-qualified @@ -736,6 +736,12 @@ export interface OcxConfig { * non-loopback binds, whose admission token contract is unchanged. */ codexDesktopAuthless?: boolean; + /** + * Opt into Codex-owned client compaction while keeping OpenCodex routing. On an authenticated + * loopback bind, inject the dedicated `opencodex` model provider instead of overriding the + * built-in `openai` provider, so Codex does not select native remote compaction. Default off. + */ + codexClientCompaction?: boolean; /** * Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active * so Codex App can show old OpenAI chats and opencodex-created exec chats under its default @@ -796,7 +802,7 @@ export interface OcxConfig { */ codexAccountPickerEnabled?: boolean; /** - * Show the GPT-5.3-Codex-Spark weekly window on Codex quota surfaces. Default false. + * Show the GPT-5.3-Codex-Spark 5-hour and weekly windows on Codex quota surfaces. Default false. * * Spark is a single-model window that reads 0% for most operators, and on a multi-account * pool it doubles the bar count for information almost nobody acts on. Hidden by default and diff --git a/src/usage/log.ts b/src/usage/log.ts index 6868a66490..18b281faac 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -10,7 +10,6 @@ import type { AttemptTierOutcome, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; import type { AgentKind } from "../server/effort-policy"; -import type { TurnProgressTelemetry } from "../types/progress"; import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility"; export interface PersistedClaudeCompatibilityLog { @@ -31,22 +30,29 @@ export function normalizeClaudeCompatibilityUsageLog(value: unknown): PersistedC } export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; +/** + * A persisted account label: a Codex pool account (`main`/`p<hex6>`) or a non-Codex OAuth + * provider account (`o<hex6>`, #2699). + * + * The old name `CodexUsageAccountLogLabel` is kept as an alias because it is exported and used + * across modules; the two predicates below are what callers should choose between. + */ export type UsageAccountLogLabel = "main" | `p${string}` | `o${string}`; export type CodexUsageAccountLogLabel = UsageAccountLogLabel; -export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^[a-f0-9]{8,64}(?:-\d+)?$/; - -export function isPersistableAccountLogLabel(value: unknown): value is string { - return ( - typeof value === "string" - && (ACCOUNT_LOG_LABEL_RE.test(value) || OAUTH_ACCOUNT_LOG_LABEL_RE.test(value)) - ); -} - +/** + * Accepts EITHER label family. This is the predicate the persistence writers use, so widening + * it here is what stops six separate call sites from silently dropping an `o`-label -- including + * two in the live request path (`request-log.ts:972` and `:1187`). + * + * The name is unchanged deliberately: renaming it would touch every call site for no behavior, + * and the widened contract is what every one of those sites wanted. + */ export function isCodexUsageAccountLogLabel(value: unknown): value is UsageAccountLogLabel { - return typeof value === "string" && ACCOUNT_LOG_LABEL_RE.test(value); + return value === "main" || (typeof value === "string" && ACCOUNT_LOG_LABEL_RE.test(value)); } +/** Strictly a Codex pool label. Use when the Codex-only distinction actually matters. */ export function isCodexPoolAccountLogLabel(value: unknown): value is "main" | `p${string}` { return value === "main" || (typeof value === "string" && CODEX_ACCOUNT_LOG_LABEL_RE.test(value)); } @@ -68,12 +74,7 @@ export type AttemptRecoveryKind = | "oauth-account-429" | "image-413" | "opaque-blob-rejection" - | "empty-completion" - | "cursor-envelope-echo" - | "cursor-routing-commentary" - | "cursor-duplicate-tool-call" - | "cursor-overflow-remint" - | "cursor-invalid-argument"; + | "empty-completion"; /** Request-time upstream credential class, never a credential or account identifier. */ export type UsageCredentialSource = "grok-oauth" | "xai-api-key"; @@ -105,8 +106,8 @@ export interface PersistedUsageAttempt { * one of them that was missed. Absent on ordinary attempts so old rows keep their exact shape. */ locallyAnswered?: boolean; - /** Stable non-PII identity for the account that served this attempt. */ - accountLogLabel?: string; + /** Stable non-PII identity for the Codex pool account that served this attempt. */ + accountLogLabel?: CodexUsageAccountLogLabel; inputTokenEstimate?: number; usage?: OcxUsage; totalTokens?: number; @@ -137,16 +138,10 @@ export interface PersistedUsageEntry { admissionKind?: "configured" | "environment" | "loopback"; /** The inbound wire, not the client product — see `surface`. */ inboundProtocol?: "responses" | "chat" | "messages"; - v2BridgeScope?: "root" | "child"; - v2BridgeDecision?: "active" | "disabled" | "not_v2" | "non_native_route" - | "maintenance_turn" | "no_collaboration_catalog" | "combo" | "compaction" | "shadow_route"; - v2BridgeStateDurability?: "standard" | "encrypted" | "memory-only"; - /** Stable non-PII identity for the account that served this request. */ - accountLogLabel?: string; + /** Stable non-PII identity for Codex Pool usage; absent for Direct/non-Codex traffic. */ + accountLogLabel?: CodexUsageAccountLogLabel; /** Best-effort chat/session correlation for Logs grouping (#330). */ conversationId?: string; - /** Protocol-only per-call progress counters; contains no text, arguments, or identifiers. */ - turnProgress?: TurnProgressTelemetry; resolvedModel?: string; requestedModel?: string; /** Original bare helper model when the opt-in shadow-call route rewrote this request. */ @@ -182,6 +177,10 @@ export interface PersistedUsageEntry { closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow"; /** Already redacted + capped at capture (request-log.ts redactSecretString().slice(0,500)). */ upstreamError?: string; + /** Where the terminal/failure was observed; absent on historic rows. */ + transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; + /** Whether the terminal came from upstream or a proxy-generated tail. */ + terminalSource?: "upstream" | "synthetic"; /** * Bounded route-decision trace (RI-01): why this provider/model/account was * selected. Additive field; old rows without it parse unchanged. Never @@ -216,14 +215,6 @@ const KNOWN_ADMISSION_KINDS = new Set<NonNullable<PersistedUsageEntry["admission const KNOWN_INBOUND_PROTOCOLS = new Set<NonNullable<PersistedUsageEntry["inboundProtocol"]>>([ "responses", "chat", "messages", ]); -const KNOWN_V2_BRIDGE_SCOPES = new Set<NonNullable<PersistedUsageEntry["v2BridgeScope"]>>(["root", "child"]); -const KNOWN_V2_BRIDGE_DECISIONS = new Set<NonNullable<PersistedUsageEntry["v2BridgeDecision"]>>([ - "active", "disabled", "not_v2", "non_native_route", "maintenance_turn", - "no_collaboration_catalog", "combo", "compaction", "shadow_route", -]); -const KNOWN_V2_BRIDGE_DURABILITIES = new Set<NonNullable<PersistedUsageEntry["v2BridgeStateDurability"]>>([ - "standard", "encrypted", "memory-only", -]); const KNOWN_AGENT_KINDS = new Set<AgentKind>(["main", "subagent", "internal"]); export function isKnownAgentKind(value: unknown): value is AgentKind { @@ -240,16 +231,20 @@ export function isKnownInboundProtocol(value: unknown): value is NonNullable<Per return typeof value === "string" && KNOWN_INBOUND_PROTOCOLS.has(value as NonNullable<PersistedUsageEntry["inboundProtocol"]>); } -export function isKnownV2BridgeScope(value: unknown): value is NonNullable<PersistedUsageEntry["v2BridgeScope"]> { - return typeof value === "string" && KNOWN_V2_BRIDGE_SCOPES.has(value as NonNullable<PersistedUsageEntry["v2BridgeScope"]>); -} +const KNOWN_TRANSPORT_PHASES = new Set<NonNullable<PersistedUsageEntry["transportPhase"]>>([ + "pre_headers", "mid_stream", "terminal_sse", +]); -export function isKnownV2BridgeDecision(value: unknown): value is NonNullable<PersistedUsageEntry["v2BridgeDecision"]> { - return typeof value === "string" && KNOWN_V2_BRIDGE_DECISIONS.has(value as NonNullable<PersistedUsageEntry["v2BridgeDecision"]>); +export function isKnownTransportPhase(value: unknown): value is NonNullable<PersistedUsageEntry["transportPhase"]> { + return typeof value === "string" && KNOWN_TRANSPORT_PHASES.has(value as NonNullable<PersistedUsageEntry["transportPhase"]>); } -export function isKnownV2BridgeStateDurability(value: unknown): value is NonNullable<PersistedUsageEntry["v2BridgeStateDurability"]> { - return typeof value === "string" && KNOWN_V2_BRIDGE_DURABILITIES.has(value as NonNullable<PersistedUsageEntry["v2BridgeStateDurability"]>); +const KNOWN_TERMINAL_SOURCES = new Set<NonNullable<PersistedUsageEntry["terminalSource"]>>([ + "upstream", "synthetic", +]); + +export function isKnownTerminalSource(value: unknown): value is NonNullable<PersistedUsageEntry["terminalSource"]> { + return typeof value === "string" && KNOWN_TERMINAL_SOURCES.has(value as NonNullable<PersistedUsageEntry["terminalSource"]>); } export function usageLogPath(configDir?: string): string { @@ -328,11 +323,6 @@ const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([ "image-413", "opaque-blob-rejection", "empty-completion", - "cursor-envelope-echo", - "cursor-routing-commentary", - "cursor-duplicate-tool-call", - "cursor-overflow-remint", - "cursor-invalid-argument", ]); const USAGE_STATUSES = new Set<UsageStatus>([ "reported", @@ -483,7 +473,7 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { sendCount: attempt.sendCount as number, recoveryKinds, usageStatus: attempt.usageStatus as UsageStatus, - ...(isPersistableAccountLogLabel(attempt.accountLogLabel) + ...(isCodexUsageAccountLogLabel(attempt.accountLogLabel) ? { accountLogLabel: attempt.accountLogLabel } : {}), ...(isNonNegativeFiniteNumber(attempt.inputTokenEstimate) @@ -536,64 +526,6 @@ function normalizedAttempts(raw: unknown): PersistedUsageAttempt[] { .filter((attempt): attempt is PersistedUsageAttempt => attempt !== null); } -function normalizeTurnProgress(raw: unknown): TurnProgressTelemetry | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - const value = raw as Record<string, unknown>; - const integer = (key: keyof TurnProgressTelemetry): number | undefined => { - const candidate = value[key]; - return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0 - ? candidate - : undefined; - }; - const required = { - logicalCallOrdinal: integer("logicalCallOrdinal"), - consecutive429sBeforeCall: integer("consecutive429sBeforeCall"), - logicalCallsSinceToolCompletion: integer("logicalCallsSinceToolCompletion"), - textDeltaCount: integer("textDeltaCount"), - textBytes: integer("textBytes"), - commentaryTextBytes: integer("commentaryTextBytes"), - finalTextBytes: integer("finalTextBytes"), - thinkingDeltaCount: integer("thinkingDeltaCount"), - toolCallsStarted: integer("toolCallsStarted"), - toolCallsCompleted: integer("toolCallsCompleted"), - assistantBoundaries: integer("assistantBoundaries"), - terminalEvents: integer("terminalEvents"), - }; - if (Object.values(required).some(candidate => candidate === undefined)) return undefined; - return { - logicalCallOrdinal: required.logicalCallOrdinal!, - consecutive429sBeforeCall: required.consecutive429sBeforeCall!, - logicalCallsSinceToolCompletion: required.logicalCallsSinceToolCompletion!, - textDeltaCount: required.textDeltaCount!, - textBytes: required.textBytes!, - commentaryTextBytes: required.commentaryTextBytes!, - finalTextBytes: required.finalTextBytes!, - preToolTextBytes: integer("preToolTextBytes") ?? 0, - thinkingDeltaCount: required.thinkingDeltaCount!, - toolCallsStarted: required.toolCallsStarted!, - toolCallsCompleted: required.toolCallsCompleted!, - assistantBoundaries: required.assistantBoundaries!, - terminalEvents: required.terminalEvents!, - ...(typeof value.exactOutputRepeat === "boolean" ? { exactOutputRepeat: value.exactOutputRepeat } : {}), - ...(typeof value.normalizedCommentaryRepeat === "boolean" - ? { normalizedCommentaryRepeat: value.normalizedCommentaryRepeat } - : {}), - ...(typeof value.normalizedPreToolTextRepeat === "boolean" - ? { normalizedPreToolTextRepeat: value.normalizedPreToolTextRepeat } - : {}), - ...(typeof value.repeatedPreToolNarration === "boolean" - ? { repeatedPreToolNarration: value.repeatedPreToolNarration } - : {}), - ...(typeof value.suppressedRepeatedPreToolText === "boolean" - ? { suppressedRepeatedPreToolText: value.suppressedRepeatedPreToolText } - : {}), - ...(typeof value.commentaryOnlyRound === "boolean" ? { commentaryOnlyRound: value.commentaryOnlyRound } : {}), - ...(typeof value.emptyProtocolRound === "boolean" ? { emptyProtocolRound: value.emptyProtocolRound } : {}), - ...(typeof value.rateLimitCircuitOpen === "boolean" ? { rateLimitCircuitOpen: value.rateLimitCircuitOpen } : {}), - ...(typeof value.repetitionCircuitOpen === "boolean" ? { repetitionCircuitOpen: value.repetitionCircuitOpen } : {}), - }; -} - const MAX_METADATA_STRING_LEN = 64; function capMetadataString(s: string): string { return s.length > MAX_METADATA_STRING_LEN ? s.slice(0, MAX_METADATA_STRING_LEN) : s; @@ -611,16 +543,16 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const responseServiceTier = sanitizeLogMetadataString(entry.responseServiceTier); const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); + const transportPhase = isKnownTransportPhase(entry.transportPhase) ? entry.transportPhase : undefined; + const terminalSource = isKnownTerminalSource(entry.terminalSource) ? entry.terminalSource : undefined; const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; - const turnProgress = normalizeTurnProgress(entry.turnProgress); return { requestId: entry.requestId, timestamp: entry.timestamp, provider: entry.provider, model: entry.model, - ...(isKnownAgentKind(entry.agentKind) ? { agentKind: entry.agentKind } : {}), ...(isKnownUsageSurface(entry.surface) ? { surface: entry.surface } : {}), ...(typeof entry.apiKeyId === "string" && entry.apiKeyId.trim() // Deliberately NOT capped. `capMetadataString` protects free-form metadata @@ -631,18 +563,12 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { : {}), ...(isKnownAdmissionKind(entry.admissionKind) ? { admissionKind: entry.admissionKind } : {}), ...(isKnownInboundProtocol(entry.inboundProtocol) ? { inboundProtocol: entry.inboundProtocol } : {}), - ...(isKnownV2BridgeScope(entry.v2BridgeScope) ? { v2BridgeScope: entry.v2BridgeScope } : {}), - ...(isKnownV2BridgeDecision(entry.v2BridgeDecision) ? { v2BridgeDecision: entry.v2BridgeDecision } : {}), - ...(isKnownV2BridgeStateDurability(entry.v2BridgeStateDurability) - ? { v2BridgeStateDurability: entry.v2BridgeStateDurability } - : {}), - ...(isPersistableAccountLogLabel(entry.accountLogLabel) + ...(isCodexUsageAccountLogLabel(entry.accountLogLabel) ? { accountLogLabel: entry.accountLogLabel } : {}), ...(typeof entry.conversationId === "string" && entry.conversationId.trim() ? { conversationId: entry.conversationId.trim().slice(0, 128) } : {}), - ...(turnProgress ? { turnProgress } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}), @@ -687,6 +613,8 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}), ...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}), ...(Array.isArray(entry.attempts) ? { attempts } : {}), + ...(transportPhase ? { transportPhase } : {}), + ...(terminalSource ? { terminalSource } : {}), ...(entry.errorCode ? { errorCode: entry.errorCode } : {}), ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}), ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), @@ -771,15 +699,6 @@ let managementUsageReadInflight: { abort: AbortController; } | null = null; -/** Test seam for the same-ledger shrink branch without mutating an open Windows file. */ -export function setManagementUsageReadOpenedSizeForTests(openedSize: number): void { - if (!Number.isSafeInteger(openedSize) || openedSize < 0) { - throw new RangeError("management usage opened size must be a non-negative safe integer"); - } - if (!managementUsageReadInflight) throw new Error("no management usage read is in flight"); - managementUsageReadInflight.openedSize = openedSize; -} - /** * Append-tolerant snapshot of the last management read. * diff --git a/src/vision/anthropic-describe.ts b/src/vision/anthropic-describe.ts index 280096f033..4ca6ae00fa 100644 --- a/src/vision/anthropic-describe.ts +++ b/src/vision/anthropic-describe.ts @@ -200,6 +200,7 @@ export async function describeImageAnthropic( const res = await fetchWithResetRetry( recovery => fetch(`${base}/v1/messages`, applyUpstreamRecoveryInit({ method: "POST", + redirect: "manual", headers, body: JSON.stringify(body), signal: linkedSignal.signal, diff --git a/src/web-search/anthropic-executor.ts b/src/web-search/anthropic-executor.ts index cd3893900c..4b62702f0e 100644 --- a/src/web-search/anthropic-executor.ts +++ b/src/web-search/anthropic-executor.ts @@ -210,6 +210,7 @@ export async function runAnthropicWebSearch( // ignored a bare `Connection: close` (oven-sh/bun#20492). recovery => fetch(url, applyUpstreamRecoveryInit({ method: "POST", + redirect: "manual", headers, body: JSON.stringify(body), signal: linkedSignal.signal, diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index bd88717f31..21f660ff15 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -483,6 +483,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons // replay on this leg eligible for the same dead socket the reset came from. return requestFetch(request.url, applyUpstreamRecoveryInit({ method: request.method, + redirect: "manual", headers: h, body: request.body, signal: headerDeadline.signal, diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 1601e5762b..7e529ea503 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -16,6 +16,7 @@ | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | | `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. | +| `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes and route resolution. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. | @@ -79,6 +80,12 @@ Callers must not replace the latter with the former merely to avoid the Windows probe. Expected-PID and snapshot removal helpers are the TOCTOU boundary when a replacement proxy can write new state during a probe. +Port reclamation must honor a rejected OCX verifier result even for a PID captured before stop or +update. A rejected live holder prevents both termination and TCP-row deletion for that scan; later +scans may proceed if verification succeeds or the holder exits. The allowlist narrows termination +eligibility and supplies no identity evidence by itself. This contract uses the existing verifier; +it does not add process-instance proof or change the classification cache. + [Decision Log] - 목적과 의도: Separate proxy process ownership from persisted configuration without changing lifecycle behavior. - 기존 구현 및 제약 조건: `src/config.ts` mixed config transactions with cross-platform PID identity, runtime-port attestation, and stale-state cleanup; process writes still require the same config-home and atomic-write protections. diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 9478343d19..9ab5cf2bba 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -20,6 +20,16 @@ $CODEX_HOME/.opencodex-native-main-profiles/ Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on the resolved `CODEX_HOME`. +Journal restoration compares config and profile independently against their saved originals and +recorded injected hashes. If either changed artifact lacks its injected hash, the config/profile +pair and journal remain untouched and the result is explicitly unverified; callers must not +convert that refusal into successful fallback cleanup. Already-original bytes need no rewrite, +and absence is distinct from an empty file. The injector checks a retained hashless journal against +the same `baselineContent` it snapshots, plus the current profile, before writing or assigning a new +injected hash. Native content can establish a fresh snapshot; routed content cannot promote an +unverified older original. Existing hash-backed edit preservation and external-provider opt-out +remain separate paths. + The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and `ocx-state` respectively, retaining a read-only root. The image creates owner-only @@ -284,6 +294,12 @@ and publication followed by a later failure can leave a complete config or priva foreign winner's ownership under future uninstall; the existing ownership manifest and global CLI shim preflight keep their separate contracts. +Initial publication diagnostics distinguish required permission-hardening failures from denied +hard-link publication without exposing raw filesystem causes. Both identify `OPENCODEX_HOME` +as the supported-location recovery path; uncertain publication and cleanup warnings remain in +the CLI. The quickstart documents inspection before retry, private-permission requirements, +and fresh-location examples. Diagnostics do not introduce a fallback or alter file I/O ordering. + `src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes the config directory and `config.json` path and retains the existing cache rule: a relative home is resolved once for each distinct raw environment value, so a later working-directory change cannot diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index b64cb4bce1..9769012e1d 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -346,6 +346,11 @@ wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhig (`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting the request, and they never raise it. +The `ocx effort` CLI accepts only the same canonical cap ladder before live probing or persistence. +Its status output preserves unsupported legacy cap values and reports that those fields are ignored; +the read does not normalize or migrate them, and an ignored subagent field does not disable a valid +main cap. Injection-effort input remains a separate contract. + Operator-owned `pinnedReasoningEffort`, `modelPinnedReasoningEfforts`, and root `modelPinnedEfforts` resolve before applicable effort caps at the final destination. Provider model pins precede provider-wide pins, then global selector/destination pins. @@ -465,6 +470,33 @@ cause delegation. The TOML edit owns only marker-tagged values, preserves existi user-owned `[agents]` defaults rather than overwriting them, and rejects ambiguous table shapes without changing the file. +V2 proxy guidance uses `<opencodex_subagent_guidance>` for both built-in metadata and +custom `injectionPrompt` bodies. The built-in text reports the resolved preferred model, +effort, roster and fallback chain without prescribing delegation, spawn overrides or +`fork_turns`. Custom bodies retain their placeholder behavior. The guidance switch and +catalog-state gates still apply; stale or unknown catalog state suppresses proxy guidance. +V1 uses the shared `MULTI_AGENT_MODE_HINT_RECOMMENDATION.text` inside `<multi_agent_mode>` +at `max` or `ultra`. Only the separate explicit delegation-request trigger changes; user, +authority, task-scope and collaboration-tool rules remain applicable. This is guidance, +not an enforcement mechanism or a change to native settings or tool access. + +Replay deduplication compares the latest exact generated developer text separately for +each tag family, preserving built-in → custom → built-in transitions without duplicating +unchanged proxy metadata after a native policy change. Native and legacy-tagged history +remain intact: tags do not establish historical authorship or revoke old instructions, +and mixed-version transition detection is not guaranteed. + +The native mode hint is separate from proxy guidance and native `[agents]` defaults. +`src/codex/multi-agent-mode-policy.ts` owns the proactive recommendation; the dashboard +obtains it from `/api/v2` rather than maintaining its own preset. An explicit dashboard, +API or CLI hint write passes through `setMultiAgentModeHintText`, which replaces only +the two byte-exact released OpenCodex presets with the current recommendation. Other +valid custom text, including whitespace variants, is preserved. Reads, unrelated writes +and upgrades do not migrate stored hints. The writer retains its native capability check +and stores only `features.multi_agent_v2.multi_agent_mode_hint_text` in Codex TOML; +`null` removes that key. The hint affects new native Codex sessions when their v2 surface +is active, without changing reasoning effort or the proxy guidance switch. + Claude Code `ocx-*` agent definitions consume the same effective `claudeCode.blockedSkills` policy as inbound bundle elision. When the list is non-empty (default: `claude-api`), generated definitions whose marker-stripped model resolves to a routed id receive a preventive instruction not to invoke diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 256bd1eae5..b16fa96b5b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -90,6 +90,17 @@ executor contract. Main-request migration must not treat that branch as fixed-tr provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to Responses-compatible streaming output. +### Credential-bearing HTTP redirects + +Credential/body-bearing HTTP sends use `redirect: "manual"` at the final executor boundary, +including dispatch overrides and adapter/sidecar retries. `fetchWithHeaderTimeout` retains its +legacy final argument for callers but no longer permits default-follow sends. Both same-origin +and cross-origin redirects remain observable responses: retry helpers must not synthesize a 502 +before the owning route can apply its existing response and health policy. Native Responses and +compact retain their 3xx/Location relay contract; image and search sidecar owners consume 3xx +through their existing upstream-error path without relaying Location. This server policy does not govern client-side +redirect following; providers requiring a redirect must be configured with their final API URL. + ### Fetch-helper import boundary `src/server/responses/fetch-helpers.ts` is a transport leaf shared by Responses, compact, and native @@ -242,6 +253,13 @@ alone never opt a gateway in. and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. +Combo compaction recall uses accepted completed-response callbacks to record the final client-visible +model and originating combo target. The existing child callback gate defers publication until an +attempt is accepted and drops discarded/failed attempts. Both compaction entry points preserve +explicit configured selectors before consulting bounded lane state. The existing state-store +reconciliation owns removal of obsolete targets and generation fencing; core imports no registration +composition root or Lab code. Recall retains routing identity only, never account credentials. + [Decision Log] - 목적과 의도: Complete Cursor turns at the protocol terminal instead of waiting for a separate HTTP-body EOF that may never arrive. - 기존 구현 및 제약 조건: Cursor can send turnEnded followed by a clean Connect END_STREAM envelope while RunSSE remains open or later closes through an abort-shaped transport error. The adapter logged the clean envelope but did not settle its terminal owner, so a completed-looking turn could remain open until the Responses stall watchdog. @@ -366,6 +384,22 @@ have no exec-result seam today and are not annotated. - 다른 대안 대신 이 방식을 선택한 이유: One-field stripping exposes the next schema mismatch and turning `external_web_access:false` into xAI live search widens the caller's network policy; destination scoping leaves custom gateways and canonical OpenAI byte-shape native. - 장점, 단점 및 영향: Grok 4.5/4.6 no longer fail every default Codex turn with an unsupported-argument 400; live search remains available when explicitly enabled, while cached search degrades to no hosted search on xAI rather than silently going live. +### xAI string agent-message continuation + +`normalizeRoutedAgentMessages` owns raw Responses `agent_message` lowering. Its existing +nonempty all-readable array behavior remains shared by non-forward destinations. The optional +`allowStringContent` argument defaults to false and is enabled only by the non-forward adapter +call when `isXaiResponsesDestination` recognizes HTTPS `api.x.ai` or `cli-chat-proxy.grok.com` +on the standard port. A nonblank string becomes one `input_text` part with the original text; +the same author/recipient attribution is retained and the private transport item id is removed. + +This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, +malformed, ciphertext-only and mixed unknown/encrypted content retains the existing fail-closed +path. Forward destinations never enable the option. The parser and encrypted-task recovery +owners are unchanged, and no broad content-schema validation or adapter-wide string conversion +is introduced. Mocked server fixtures cover parent, child, and parent-result continuation over +SSE and JSON while preserving actual tool-call/result pairs. + OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and keeps the provider-wide `openai-chat` default for other non-pinned models. This endpoint correction @@ -373,6 +407,32 @@ does not set `modelResponsesUpstreamStreaming`: client `stream: true` remains re streaming until a current-runtime reproduction justifies a separate bounded-JSON compatibility policy. +Go's non-forward Responses request path moves valid `additional_tools` wrappers into top-level +`tools` through `src/adapters/opencode-go-additional-tools.ts`. Placement runs after existing +custom/search/namespace lowering and before code-mode, compaction and final hosted-tool pruning. +It does not recalculate wire identities or response aliases. The matcher reads the constructed +send URL, resolving it with URL semantics, and requires HTTPS `opencode.ai`, the standard port +and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split `responsesPath` +configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. +Credentials, query, fragment, foreign hosts and other resource paths are excluded. The existing +URL constructor canonicalizes trailing base slashes before this check. Malformed wrappers remain unchanged and +the shared mixed-ciphertext agent-message gate remains fail-closed. + +The canonical `opencode-go` registry entry defaults to `statelessResponses: true` because Go +rejects reasoning ciphertext combined with `previous_response_id` (#3838). Existing derive +logic fills absent values and preserves explicit false; renamed custom configurations receive +no new destination-based migration. The existing stateless pass sets `store: false`, removes +stored continuation parameters, and repairs orphan calls/results without claiming execution +success. A local replay-cache hit supplies history; a miss cannot reconstruct it, so callers +must resend complete history without `previous_response_id`. This flag also enables the existing +visible content-to-summary rewrite for SSE and JSON; summary-channel items and opaque reasoning +blobs keep their existing response handling. The shared recording callback applies the same +reasoning rewrite under the exact client-visible predicate before caching output, after tool +restoration and function normalization. This keeps full-content replay fingerprints comparable +for both full-history-plus-ID and delta continuations without weakening identity checks. Hidden +summaries and opaque blobs keep their existing cache representation. It does not change streaming selection or Chat +model routes. Go fixtures cover Luna, Grok and Muse against both response formats. + The canonical OpenCode Go transport also derives `x-opencode-session` from the existing hashed session lane before per-model wire selection. One conversation keeps one opaque affinity value across Responses, Chat, retries, and key rotation, while sibling subagents remain distinct. An @@ -1778,3 +1838,13 @@ The field is omitted when no classified recovery result exists, and existing com branches that return the original target failure keep that response. `recovery_unavailable` includes cache/singleflight capacity and does not prove an upstream request was attempted. No retry or broader envelope acceptance is enabled. + +## Voice diagnostic metadata + +`src/server/live.ts` owns optional `OCX_LIVE_FRAME_LOG` diagnostics for both sideband directions. +The JSONL schema contains only `ts`, `dir`, `kind`, `bytes`, and `fffd`. It never stores frame +content or transcript excerpts, and logging failures do not affect transparent frame delivery. +Binary detection decodes only the supplied buffer view; malformed UTF-8 can itself produce U+FFFD, +so the flag does not identify the peer responsible for corruption. Existing diagnostic files are +not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain +client responsibilities. diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index c1562df4ec..2c21897855 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -126,7 +126,7 @@ this document owns is which module holds which area and what invariant that area | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | -| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | +| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | @@ -319,6 +319,14 @@ and catalog invariants documented in this folder rather than inventing parallel ## Dashboard surfaces +Provider Overview consumes the existing shared `add-provider-presets` resource for sponsor +presentation. `matchingWorkspacePreset` requires the configured id, adapter and normalized +endpoint to match; a custom endpoint or absent sponsor metadata suppresses the introduction. +`ProviderSponsor` keeps localized promotional copy and outbound HTTP(S) links separate from +operator notes. Notes remain complete and editable once in the main column; stats and current +account quota remain in the side column. This presentation does not write provider configuration +or participate in routing. + The sidebar exposes eleven pages (`gui/src/App.tsx` `NAV`). Several are workspace shells rather than single forms, and the shell pattern is the part worth keeping stable: @@ -377,6 +385,20 @@ Subagent fallback settings load independently of the main roster. Their failure fallback controls and provides a retry; available fallback options come from that endpoint's availability list while already-configured stale values remain editable. +Subagents → Advanced uses the current API server's recommendation for **Always proactive +delegation** (formerly Ultra mode). Enabling requires the native v2 flag, explicit v2 mode +and a recommendation with nonblank string text and revision. Missing or malformed +recommendations disable preset installation and restoration while existing custom hints +remain editable and clearable. Restore changes only the editor draft; Save writes it. +Recommendation-only refreshes preserve unsaved drafts. Switching API servers hides the +previous hint and blocks mode writes until the new server's settings arrive. + +An explicit `multiAgentModeHintText` write canonicalizes only the two byte-exact legacy +OpenCodex presets; other valid custom text keeps its bytes. GET, unrelated PUTs and upgrades +leave stored hints unchanged. `null` clears the hint, blank strings are rejected, and the +existing native capability check still precedes writes. The text and revision recommendation +is supplied independently of stored TOML and is not evidence of native runtime support. + Account quota discovery is capability-based. Cheap OAuth and provider-key lists include `quotaMode` (`probe`, `passive`, or `unsupported`) without contacting upstream quota APIs. `GET /api/oauth/accounts?provider=..."a=1` and diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index 92b25c3601..a840d8909c 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -221,15 +221,19 @@ separate channel-aware invariant and release-note baseline design. ### Release notes -Release notes are rendered OpenAI-Codex-style by `scripts/release-notes.ts render` inside -`.github/workflows/release.yml`: `## New Features` / `## Bug Fixes` / `## Documentation` / -`## Chores` / `## Other Changes` sections with prefix-free, scope-grouped summary bullets -(`- Providers: Add X; Add Y (#1, #2)`), followed by a `## Changelog` section listing every PR -as `- #N <title> @author`; when a comparison baseline exists, that section also includes a -compare link. Carried preview changelogs and the since-preview delta feed the same renderer, -so stable notes are the aggregate of their preview train. The raw commit dump is -intentionally gone — non-PR commits stay reachable via the Full Changelog compare link when -that link is available. +The release workflow invokes `scripts/build-release-changelog.ts`, which builds notes from +the actual Git range and uses generated PR notes as enrichment. Its categorized summaries +contain one bullet per PR or direct commit, followed by `## Changelog` entries retaining PR +titles and authors or sanitized direct-commit text. A comparison baseline adds a compare link. +Preview notes are incremental; stable notes cover the range since the previous stable tag. +The standalone `scripts/release-notes.ts render` command retains its separate scope-grouped +summary and carried-preview rendering behavior. + +Both renderers strip the exact leading `[WRONG BRANCH]` marker followed by one ASCII space +from PR summary bullets and full-changelog titles. Other bracketed text is preserved. +Summary bullets remove conventional commit prefixes; PR changelog entries keep those prefixes, +PR numbers, and author attribution. This normalization does not change category selection, +direct-commit coverage, or PR-target enforcement. The deterministic renderer produces the structure but not curated prose. Maintainers who want the OpenAI-style grouped summaries can run the optional local polish step against the rendered diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 834cbd46ee..f53cdb9b0c 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,39 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +Caller credentials stay scoped to the selected physical route. Typed proxy admission survives +Combo/policy recursion, but raw Authorization and ChatGPT account headers are removed from +rebuilt requests at those selections or actual shadow/thread-spawn rewrites. An original caller's +Direct credential — a clean non-proxy bearer carrying a locally decoded ChatGPT account claim +(routing evidence, not signature verification), with any explicit account header matching that +claim — is captured separately and may be +restored only for the final canonical OpenAI route, under the existing Direct/Pool, native-main +claim, and entitlement rules. This restore is deliberately stricter than unchanged-route Direct +forwarding, which keeps its legacy rules. The stricter explicit-pair snapshot (JWT with matching account +header) additionally feeds optional OpenAI sidecars and is also +withheld from an unchanged keyless Cursor route; an independently supplied Cursor bearer +remains supported. A noncanonical caller-auth transport keeps only a clean single bearer with +no ChatGPT account claim: a bearer carrying a ChatGPT account claim, a combined or malformed +Authorization value, and the chatgpt-account-id header are withheld from it. Key-auth and noncanonical routes use +their own configured key or provider-owned OAuth credential. Canonical unqualified `openai` +forwarding preserves the sanitized caller/main-login bearer in Direct mode and may select a +stored native credential in Pool mode. An explicit account-qualified sidecar may select its +stored account even when the provider default is Direct. A thread-spawn marker without a rewrite +preserves the caller credential. Bearer admission can still select stored native credentials under +the existing turn claim. Claude replay may reconstruct its claimed main snapshot only for a final canonical +ChatGPT target. Alternate-account retry retains the sanitized caller input separately from the +selected Pool headers, so neither a discarded source bearer nor a Pool token becomes caller-main +authority during retry. + +Explicit OpenAI sidecar authentication is retained separately in request-local memory before +Combo or policy headers are rewritten. Only the canonical sidecar resolver can restore that +single bearer and matching explicit account pair; it revalidates the existing credential and +destination rules. A recorded absence is not recaptured from a later provider request, and +combined Authorization values are rejected. This snapshot never becomes primary-provider or +alternate-main retry authentication; the original caller's native snapshot is separate. +Optional Chat/Claude stored-main enrichment still requires +the native-main turn claim. + The two routes also keep separate request-compatibility contracts. The canonical ChatGPT Codex forward destination removes public `prompt_cache_options` because that backend rejects the field before inference; `prompt_cache_key` remains supported. `openai-apikey` and noncanonical/custom @@ -72,6 +105,20 @@ requests keep their captured credential. An all-paused pool fails closed. The dashboard's bulk pause action refreshes all account quotas and mutates only accounts whose plan-relevant window is freshly confirmed at exactly 100%; unknown and failed refreshes are skipped. +A confirmed manual reset-credit consumption may immediately reconcile that account's +eligible pre-existing ordinary reset-derived cooldown after a complete, non-exhausted usage +observation started after the reset. Paused or reauthentication-required accounts and +cooldowns held by another in-flight probe remain excluded; their cooldowns are retained. +Recovery owns the specific cooldown and authenticates +main and added Pool accounts through their respective credential contracts. Main usage +publication keeps the latest successfully published observation authoritative. Pool recovery +across a credential refresh requires the actual self/joined refresh lineage, not matching +replacement timestamps. It preserves +newer failures, independent Spark/Reserve scopes, explicit Retry-After, pause, pin and +selection state. Replay and `already_redeemed` are not new-reset evidence. Failed usage +recovery leaves the cooldown in place and preserves the confirmed consume success; +retrying usage must not require another credit. + `codexQuotaAutoRefresh` is a separate default-off spending intent. For each explicitly enabled account/window, the one-minute state sweep compares the cached upstream reset timestamp, sends the existing minimal non-stored warmup through that exact account once the timestamp is due, then @@ -146,6 +193,17 @@ workspace already observed under native ownership; an unrelated or unmatched key is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. +When protection is enabled, owned startup rebuilds this binding from its pinned auth path under +the native owner and exclusive claim, after journal recovery and stage cleanup, before publishing +ready. Caller-owned Direct, exact-main, fallback, and main-pin admission stays temporarily fenced +during that initialization; stored Pool alternatives remain eligible. Foreign/unknown service-home +paths neither initialize the binding nor trigger an ownership reprobe from caller-owned admission. +A new listener with protection enabled rearms the same guarded path on an existing ready lifecycle, +including when the physical credential was replaced after the earlier listener started. +Failed initialization creates no new binding. A previously verified same-process binding and its +safety state remain until a valid replacement observation or confirmed account transition; malformed +or conflicting input alone is not replacement evidence. + This is not a reservation of the last 1%: already-admitted, parallel, unmatched-keyring, or direct upstream traffic can still reach exhaustion. While blocked, main cannot use Luna reserve either. Keeping ordinary usage below exhaustion may prevent Reserve activation; the policy never changes diff --git a/tests/adapters/adapter-buffered-tool-conformance.test.ts b/tests/adapters/adapter-buffered-tool-conformance.test.ts index 81a736bee9..fd56fc6e25 100644 --- a/tests/adapters/adapter-buffered-tool-conformance.test.ts +++ b/tests/adapters/adapter-buffered-tool-conformance.test.ts @@ -23,6 +23,7 @@ const WIRE_MODELS: Record<AdapterWire, string> = { kiro: "claude-sonnet-4.5", "openai-responses": "deepseek-v4-flash", cursor: "cursor/auto", + codebuddy: "glm-5.3", }; function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { @@ -35,6 +36,7 @@ function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfi kiro: "https://runtime.us-east-1.kiro.dev", "openai-responses": "https://api.deepseek.com", cursor: "https://api2.cursor.sh", + codebuddy: "https://www.codebuddy.ai", }; const baseUrl = adapterId === "mimo-free" ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 3d19f8132e..d7bac03afe 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -10,6 +10,7 @@ import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; const EXPECTED_ADAPTER_NAMES = { + codebuddy: "codebuddy", "command-code": "command-code", "openai-chat": "openai-chat", "ollama-native": "ollama-native", @@ -21,6 +22,7 @@ const EXPECTED_ADAPTER_NAMES = { "azure-openai": "azure-openai", cursor: "cursor", "mimo-free": "mimo-free", + qoder: "qoder", } as const; function provider(adapter: string): OcxProviderConfig { @@ -30,11 +32,15 @@ function provider(adapter: string): OcxProviderConfig { // adapter accepts the placeholder URL. baseUrl: adapter === "mimo-free" ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" - // ollama-native refuses a bare /v1 path on a host it does not recognise, rather than - // guessing that an arbitrary destination speaks Ollama's compatibility surface. - : adapter === "ollama-native" - ? "https://example.invalid/api" - : "https://example.invalid/v1", + : adapter === "codebuddy" + ? "https://www.codebuddy.ai" + : adapter === "qoder" + ? "https://qoder.com" + // ollama-native refuses a bare /v1 path on a host it does not recognise, rather than + // guessing that an arbitrary destination speaks Ollama's compatibility surface. + : adapter === "ollama-native" + ? "https://example.invalid/api" + : "https://example.invalid/v1", authMode: "key", apiKey: "test-key", defaultMaxOutputTokens: 4096, diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 8ebfad627f..91fd5a399b 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -34,6 +34,7 @@ const WIRE_MODELS: Record<AdapterWire, string> = { kiro: "claude-sonnet-4.5", "openai-responses": "deepseek-v4-flash", cursor: "cursor/auto", + codebuddy: "glm-5.3", }; function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { @@ -46,6 +47,7 @@ function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfi kiro: "https://runtime.us-east-1.kiro.dev", "openai-responses": "https://api.deepseek.com", cursor: "https://api2.cursor.sh", + codebuddy: "https://www.codebuddy.ai", }; // Semantic wrappers with provider-specific URL shapes must override the wire-family default here. const baseUrl = adapterId === "mimo-free" @@ -417,8 +419,11 @@ describe("registry-derived routed tool conformance", () => { } }); + const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); + test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, codeModeParsed(contract.wire)); const advertised = advertisedToolNames(contract.wire, body); @@ -432,6 +437,7 @@ describe("registry-derived routed tool conformance", () => { test("tool_choice none disables every registered adapter's callable tool surface", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const enabledBody = await outbound(adapterId, toolChoiceParsed(contract.wire)); expect(advertisedToolNames(contract.wire, enabledBody).length, `${adapterId}:enabled`).toBeGreaterThan(0); @@ -442,6 +448,7 @@ describe("registry-derived routed tool conformance", () => { test("every parsed streaming wire restores hostile freeform input exactly", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; if (!driver.streamingToolCall) { @@ -456,6 +463,7 @@ describe("registry-derived routed tool conformance", () => { test("every buffered adapter preserves same-name tools from different namespaces", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); if (contract.wire === "openai-responses" || contract.wire === "cursor") { // Native Responses passthrough and Cursor's protobuf transport do not use the routed @@ -470,6 +478,7 @@ describe("registry-derived routed tool conformance", () => { test("every routed adapter fails closed for an ambiguous bare selector", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); if (contract.wire === "openai-responses" || contract.wire === "cursor") continue; const parsed = namespacedCollisionParsed(contract.wire); @@ -495,6 +504,7 @@ describe("registry-derived routed tool conformance", () => { test("every streaming adapter restores namespaced custom/function collisions distinctly", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; if (!driver.streamingToolCall || !driver.extractWireToolName) { @@ -537,6 +547,7 @@ describe("registry-derived routed tool conformance", () => { test("every registered adapter replays the exact apply_patch input on continuation", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, continuationParsed(contract.wire)); expect(continuationInput(contract.wire, body), adapterId).toBe(PATCH); diff --git a/tests/adapters/anthropic/anthropic-error-stop-reason.test.ts b/tests/adapters/anthropic/anthropic-error-stop-reason.test.ts index b30cb9641d..52a0a8309b 100644 --- a/tests/adapters/anthropic/anthropic-error-stop-reason.test.ts +++ b/tests/adapters/anthropic/anthropic-error-stop-reason.test.ts @@ -13,6 +13,70 @@ const provider: OcxProviderConfig = { apiKey: "test-key", }; +describe("Anthropic usage numeric boundary", () => { + test("preserves empty usage and absent usage as distinct states", async () => { + for (const usage of [{}, undefined]) { + const events = await createAnthropicAdapter(provider).parseResponse!(Response.json({ + content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", ...(usage ? { usage } : {}), + })) as AdapterEvent[]; + const done = events.find(event => event.type === "done"); + expect(done && "usage" in done ? done.usage : undefined) + .toEqual(usage ? { inputTokens: 0, outputTokens: 0 } : undefined); + } + }); + + test("preserves inclusive cache input and cumulative streaming output", async () => { + const frames = [ + { type: "message_start", message: { usage: { input_tokens: 10, cache_read_input_tokens: 3, cache_creation_input_tokens: 2 } } }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 4 } }, + { type: "message_stop" }, + ].map(frame => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`).join(""); + const events: AdapterEvent[] = []; + for await (const event of createAnthropicAdapter(provider).parseStream(new Response(frames))) events.push(event); + const done = events.find(event => event.type === "done"); + expect(done && "usage" in done ? done.usage : undefined).toEqual({ + inputTokens: 15, outputTokens: 4, cachedInputTokens: 3, cacheReadInputTokens: 3, cacheCreationInputTokens: 2, + }); + }); + + test.each(["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"])( + "does not emit malformed %s as reported usage", async key => { + for (const invalid of ["\x1b[2J", "42", null, -1, true, {}, []]) { + const response = Response.json({ content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 4, [key]: invalid } }); + const events = await createAnthropicAdapter(provider).parseResponse!(response) as AdapterEvent[]; + const done = events.find(event => event.type === "done"); + expect(done).toBeDefined(); + expect(done && "usage" in done ? done.usage : undefined).toBeUndefined(); + } + }, + ); + + test("rejects an overflowing inclusive input total", async () => { + const response = Response.json({ content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", + usage: { input_tokens: Number.MAX_VALUE, cache_read_input_tokens: Number.MAX_VALUE, output_tokens: 4 } }); + const events = await createAnthropicAdapter(provider).parseResponse!(response) as AdapterEvent[]; + const done = events.find(event => event.type === "done"); + expect(done && "usage" in done ? done.usage : undefined).toBeUndefined(); + }); + + test("streaming rejects a malformed cumulative update without changing content", async () => { + const frames = [ + { type: "message_start", message: { usage: { input_tokens: 10 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: "\x1b[2J" } }, + { type: "message_stop" }, + ].map(frame => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`).join(""); + const events: AdapterEvent[] = []; + for await (const event of createAnthropicAdapter(provider).parseStream(new Response(frames))) events.push(event); + const done = events.find(event => event.type === "done"); + expect(done).toBeDefined(); + expect(done && "usage" in done ? done.usage : undefined).toBeUndefined(); + expect(JSON.stringify(buildResponseJSON(events, "anthropic/claude-test"))).toContain("ok"); + }); +}); + /** * These drive the REAL adapter parsers. An earlier version of this suite constructed the * downstream error event by hand, so it stayed green while the adapter itself still emitted a diff --git a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts index 46b557142f..301822a8fe 100644 --- a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts +++ b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts @@ -1,5 +1,5 @@ /** Physical response attribution through the real adapter and response/search loops. */ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,6 +12,27 @@ import { handleResponses } from "../../../src/server/responses"; import type { OcxConfig, OcxProviderConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; +const actualResolver = await import("../../../src/server/adapter-resolve"); +const actualResolveAdapter = actualResolver.resolveAdapter; +let adapterRequestsFollow = false; +mock.module("../../../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(...args: Parameters<typeof actualResolveAdapter>) { + const adapter = actualResolveAdapter(...args); + if (!adapterRequestsFollow) return adapter; + return { + ...adapter, + // Exercise the production OAuth dispatch callback with adapter-owned request options. + // Keep the real request builder/parser; only this caller asks for default-follow. + fetchResponse: (request: Parameters<NonNullable<typeof adapter.fetchResponse>>[0], context: Parameters<NonNullable<typeof adapter.fetchResponse>>[1]) => + context!.executor!(request.url, { + method: request.method, headers: request.headers, body: request.body, + signal: context?.abortSignal, redirect: "follow", + }), + }; + }, +})); + const originalHome = process.env.OPENCODEX_HOME; let originalFetch: typeof globalThis.fetch; let unexpectedGlobalFetches = 0; @@ -19,6 +40,7 @@ let home: string; let sent: { authorization: string | null; apiKey: string | null; body: Record<string, unknown> }[]; beforeEach(() => { + adapterRequestsFollow = false; home = ""; originalFetch = globalThis.fetch; unexpectedGlobalFetches = 0; @@ -38,6 +60,7 @@ beforeEach(() => { }); afterEach(() => { + adapterRequestsFollow = false; try { // Provider code may catch the guard's rejection; the attempted network call still fails the test. expect(unexpectedGlobalFetches).toBe(0); @@ -145,6 +168,43 @@ function deferred<T>() { return { promise, resolve }; } +test.each([307, 308])("OAuth provider override pins manual dispatch after adapter init for %i", async status => { + await seed(1); + adapterRequestsFollow = true; + let targetHits = 0; + let originHits = 0; + const redirects: Array<RequestRedirect | undefined> = []; + const statuses: number[] = []; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return answer(false); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + const config = configFor(() => { throw new Error("unused canned transport"); }); + (config.providers.anthropic as OcxProviderConfig & { fetch: typeof fetch }).fetch = (async (input, init) => { + expect(new URL(String(input)).hostname).toBe("anthropic-quota.test"); + redirects.push(init?.redirect); + // Remap only the URL; the production callback must supply the safe request options. + const result = await originalFetch(`http://127.0.0.1:${origin.port}/messages`, init); + statuses.push(result.status); + return result; + }) as typeof fetch; + try { + const response = await post(config); + await response.text(); + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(redirects).toEqual(["manual"]); + expect(statuses).toEqual([status]); + } finally { + await origin.stop(true); + await target.stop(true); + } +}); + test("main A429 -> B200 records both physical responses against their sending accounts", async () => { const [a, b] = await seed(); const config = configFor(body => { diff --git a/tests/adapters/google/google-claude-prefill-guard.test.ts b/tests/adapters/google/google-claude-prefill-guard.test.ts index 72dbd1d0e4..bbaf30bf73 100644 --- a/tests/adapters/google/google-claude-prefill-guard.test.ts +++ b/tests/adapters/google/google-claude-prefill-guard.test.ts @@ -78,14 +78,39 @@ describe("google claude prefill guard", () => { expect(JSON.stringify(contents.at(-1))).not.toContain("(continue)"); }); - test("does not append nudge for non-Claude models on Antigravity", async () => { + test("appends a user continue nudge when Gemini context ends with model turn", async () => { const contents = await envelopeContents(parsed([ { role: "user", content: "start", timestamp: 0 }, { role: "assistant", content: [{ type: "text", text: "answer" }], model: "gemini", timestamp: 0 }, ], "gemini-3.7-flash")); - // Gemini natively accepts model-tail; no nudge - expect(contents.at(-1)!.role).toBe("model"); - expect(JSON.stringify(contents)).not.toContain("(continue)"); + // Google Gemini strictly rejects requests ending with a model turn with HTTP 400 + // "Requests ending with a model turn are not supported." A user continue nudge is required. + expect(contents.at(-1)).toEqual({ role: "user", parts: [{ text: "(continue)" }] }); + }); + + test("appends a user continue nudge for Gemini 3.8 Flash on Antigravity", async () => { + const contents = await envelopeContents(parsed([ + { role: "user", content: "start", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "answer" }], model: "gemini", timestamp: 0 }, + ], "gemini-3.8-flash")); + + expect(contents.at(-1)).toEqual({ role: "user", parts: [{ text: "(continue)" }] }); + }); + + test("appends a user continue nudge in AI Studio mode", async () => { + const aiStudioProvider = { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "key-123", + } as OcxProviderConfig; + + const { body } = await createGoogleAdapter(aiStudioProvider).buildRequest(parsed([ + { role: "user", content: "hello", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "assistant reply" }], model: "gemini", timestamp: 0 }, + ], "gemini-2.5-flash")); + + const payload = JSON.parse(body); + expect(payload.contents.at(-1)).toEqual({ role: "user", parts: [{ text: "(continue)" }] }); }); }); diff --git a/tests/adapters/openai/openai-chat-hardening.test.ts b/tests/adapters/openai/openai-chat-hardening.test.ts index c5050a3ced..2279e4937e 100644 --- a/tests/adapters/openai/openai-chat-hardening.test.ts +++ b/tests/adapters/openai/openai-chat-hardening.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../../../src/adapters/openai-chat"; -import { stripResponsesOnlyEncryptedMarker } from "../../../src/adapters/responses-tool-schema"; +import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "../../../src/adapters/responses-tool-schema"; import { getDebugLogEntries, resetDebugLogBufferForTests } from "../../../src/lib/debug-log-buffer"; import { resetDebugSettingsForTests } from "../../../src/lib/debug-settings"; import { routeModel } from "../../../src/router"; @@ -286,6 +286,336 @@ describe("openai-chat request hardening", () => { }); }); +describe("unicode property-escape pattern stripping", () => { + // Claude Code 2.1.265 ships this on the `field` parameter of its built-in Artifact tool. + const artifactFieldPattern = '^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}"\\\\./[\\]]{1,200}$'; + + test("drops a pattern Python `re` cannot compile and keeps one it can", () => { + const stripped = stripUnicodePropertyPatterns({ + type: "object", + properties: { + field: { type: "string", pattern: artifactFieldPattern, description: "keep me" }, + // Python `re` supports lookaheads, so this one is compilable and must survive. + collection: { type: "string", pattern: "^(?!\\.\\.?(?:/|$))[A-Za-z0-9_\\-.~:@+]{1,200}$" }, + plain: { type: "string", pattern: "^[a-z0-9_-]{1,64}$" }, + }, + }) as Record<string, Record<string, Record<string, unknown>>>; + + expect(stripped.properties.field.pattern).toBeUndefined(); + expect(stripped.properties.field.type).toBe("string"); + expect(stripped.properties.field.description).toBe("keep me"); + expect(stripped.properties.collection.pattern).toBe("^(?!\\.\\.?(?:/|$))[A-Za-z0-9_\\-.~:@+]{1,200}$"); + expect(stripped.properties.plain.pattern).toBe("^[a-z0-9_-]{1,64}$"); + }); + + test("an escaped backslash before `p{` is a literal, not a property escape", () => { + // `\\p{2}` is a literal backslash followed by a quantified `p`; Python compiles it, so a + // substring scan for `\p{` would throw away a working pattern. + const before = { type: "string", pattern: "^\\\\p{2}$" }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("`\\P{…}` is dropped as well as `\\p{…}`", () => { + const stripped = stripUnicodePropertyPatterns({ type: "string", pattern: "^\\P{L}+$" }) as Record<string, unknown>; + expect(stripped.pattern).toBeUndefined(); + expect(stripped.type).toBe("string"); + }); + + test("a property or literal payload named `pattern` is data, not a keyword", () => { + const before = { + type: "object", + properties: { + // A caller-chosen property name that happens to be `pattern`: its schema survives whole. + pattern: { type: "string", pattern: "^[a-z]+$" }, + }, + $defs: { pattern: { type: "string" } }, + patternProperties: { "^x-": { type: "string" } }, + const: { pattern: artifactFieldPattern }, + default: { pattern: artifactFieldPattern }, + enum: [{ pattern: artifactFieldPattern }], + examples: [{ pattern: artifactFieldPattern }], + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("returns the input itself when nothing is dropped", () => { + const before = { type: "object", properties: { a: { type: "string" } } }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("patternProperties matchers and their value schemas are preserved", () => { + // Matcher keys determine evaluation and may be referenced by ancestor closures. + // Keep them even when this local object appears open. + const stripped = stripUnicodePropertyPatterns({ + type: "object", + patternProperties: { + "^\\p{L}+$": { type: "string" }, + "^\\P{N}+$": { type: "string" }, + // Python `re` compiles these, so they survive with their schemas intact. + "^x-": { type: "string", description: "keep me" }, + "^(?!__).+$": { type: "number" }, + }, + }) as Record<string, Record<string, Record<string, unknown>>>; + + // Key order is not part of the schema contract, so compare the set. The point is which + // matchers survive and that their schemas come through intact. + expect(Object.keys(stripped.patternProperties).sort()).toEqual(["^\\p{L}+$", "^\\P{N}+$", "^(?!__).+$", "^x-"].sort()); + expect(stripped.patternProperties["^x-"].description).toBe("keep me"); + expect(stripped.patternProperties["^(?!__).+$"].type).toBe("number"); + }); + + test("an ordinary name bag keeps a property literally named like a property escape", () => { + // Only `patternProperties` keys are matchers. Elsewhere the key is just a name, so a + // property called `\\p{L}` is data and must survive. + const before = { + type: "object", + properties: { "\\p{L}": { type: "string" } }, + $defs: { "\\p{L}": { type: "string" } }, + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("nested patternProperties preserve every matcher", () => { + const stripped = stripUnicodePropertyPatterns({ + type: "object", + properties: { + nested: { + type: "object", + patternProperties: { "^\\p{Lu}$": { type: "string" }, "^ok$": { type: "string" } }, + }, + }, + }) as Record<string, Record<string, Record<string, Record<string, unknown>>>>; + + expect(Object.keys(stripped.properties.nested.patternProperties).sort()).toEqual(["^\\p{Lu}$", "^ok$"].sort()); + expect(stripped.properties.nested.patternProperties["^ok$"].type).toBe("string"); + }); + + test("a closed object keeps its regex matcher, because dropping it would narrow the object", () => { + // `additionalProperties: false` means the matcher decides which keys exist at all. Dropping + // it would forbid every key it covered, and with `minProperties: 1` the object could then + // admit nothing — a dictionary tool turned into an empty-object-only tool. Leaving it alone + // keeps the schema valid on a destination that compiles ECMA regexes, and lets one that + // cannot report the regex itself. + const before = { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: false, + minProperties: 1, + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("`additionalProperties` as a schema also blocks the drop", () => { + // The covered keys would have to satisfy that schema instead of their own value schema, + // which is a different constraint rather than a relaxed one. + const before = { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: { type: "number" }, + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("`unevaluatedProperties: false` closes the object the same way", () => { + const before = { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + unevaluatedProperties: false, + }; + expect(stripUnicodePropertyPatterns(before)).toBe(before); + }); + + test("an explicitly open object also preserves its matchers", () => { + // Local openness does not establish the meaning of this schema under composition. + const stripped = stripUnicodePropertyPatterns({ + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" }, "^x-": { type: "string" } }, + additionalProperties: true, + }) as Record<string, Record<string, unknown>>; + + expect(Object.keys(stripped.patternProperties).sort()).toEqual(["^\\p{L}+$", "^x-"].sort()); + expect(stripped.additionalProperties).toBe(true); + }); + + test("open and closed sibling objects both retain their matchers", () => { + const stripped = stripUnicodePropertyPatterns({ + type: "object", + properties: { + closed: { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: false, + }, + open: { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" }, "^ok$": { type: "string" } }, + }, + }, + }) as Record<string, Record<string, Record<string, Record<string, unknown>>>>; + + expect(Object.keys(stripped.properties.closed.patternProperties)).toEqual(["^\\p{L}+$"]); + expect(Object.keys(stripped.properties.open.patternProperties).sort()).toEqual(["^\\p{L}+$", "^ok$"].sort()); + }); + + test.each(["not", "oneOf", "if", "contains", "$defs", "definitions"] as const)( + "preserves scalar patterns in %s without changing an ordinary sibling repair", + key => { + const pattern = "^\\p{L}+$"; + const nested = key === "oneOf" ? [{ type: "string", pattern }, { type: "number" }] + : key === "$defs" || key === "definitions" ? { Value: { type: "string", pattern } } + : { type: "string", pattern }; + const before = { type: "object", [key]: nested, properties: { ordinary: { type: "string", pattern } } }; + const original = JSON.stringify(before); + const result = stripUnicodePropertyPatterns(before) as Record<string, unknown>; + expect(result[key]).toEqual(JSON.parse(original)[key]); + expect(result.properties).toEqual({ ordinary: { type: "string" } }); + expect(JSON.stringify(before)).toBe(original); + }, + ); + + test("a deeply nested schema is stripped without exhausting the stack", () => { + // Same reasoning as the encrypted-marker walk: schema depth is caller-controlled. + const depth = 50_000; + const root: Record<string, unknown> = { type: "object", pattern: artifactFieldPattern }; + let cursor = root; + for (let i = 0; i < depth; i++) { + const child: Record<string, unknown> = { type: "object", pattern: artifactFieldPattern }; + cursor.properties = { pattern: child }; + cursor = child; + } + + const stripped = stripUnicodePropertyPatterns(root) as Record<string, unknown>; + expect(stripped.pattern).toBeUndefined(); + let walk = stripped; + for (let i = 0; i < depth; i++) { + // Each level keeps the property literally named `pattern` and drops the keyword. + walk = (walk.properties as Record<string, Record<string, unknown>>).pattern; + expect(walk.pattern).toBeUndefined(); + expect(walk.type).toBe("object"); + } + }); + + test("the chat wire drops the uncompilable pattern and keeps the compilable sibling", () => { + // The tests above call the helper directly, so they stay green even if the + // chat serializer stops calling it. This one goes through buildRequest and + // asserts the bytes a provider would receive, which is the seam that was + // actually broken: toolsToChatFormat in src/adapters/openai-chat.ts. + const compilableSibling = "^(?!\\.\\.?(?:/|$))[A-Za-z0-9_\\-.~:@+]{1,200}$"; + const request = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + context: { + messages: [{ role: "user", content: "make an artifact", timestamp: 0 }], + tools: [{ + name: "Artifact", + namespace: "collaboration", + description: "Create an artifact", + parameters: { + type: "object", + properties: { + field: { type: "string", pattern: artifactFieldPattern, description: "Artifact field" }, + collection: { type: "string", pattern: compilableSibling }, + }, + required: ["field"], + }, + }], + }, + }); + + const body = JSON.parse(request.body) as { + tools: Array<{ function: { parameters: { properties: Record<string, Record<string, unknown>>; required: string[] } } }>; + }; + const wire = body.tools[0].function.parameters; + + expect(wire.properties.field.pattern).toBeUndefined(); + expect(wire.properties.field.type).toBe("string"); + expect(wire.properties.field.description).toBe("Artifact field"); + expect(wire.properties.collection.pattern).toBe(compilableSibling); + expect(wire.required).toEqual(["field"]); + }); + + test("chat wire: a closed dictionary tool passes through, never becoming an empty-object tool", () => { + // This seam normalizes for every destination, including ones that compile ECMA regexes. + // Dropping the matcher would leave `additionalProperties: false` forbidding the keys it + // covered, and `minProperties: 1` would then admit nothing at all. + const parameters = { + type: "object", + description: "Arbitrary letter-keyed labels", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: false, + minProperties: 1, + }; + const request = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + context: { + messages: [{ role: "user", content: "label it", timestamp: 0 }], + tools: [{ name: "Label", namespace: "collaboration", description: "Label things", parameters }], + }, + }); + + const body = JSON.parse(request.body) as { tools: Array<{ function: { parameters: unknown } }> }; + // Byte-identical to what the caller supplied: matcher, closure and lower bound all intact. + expect(body.tools[0].function.parameters).toEqual(parameters); + }); + + test("chat wire: an open dictionary tool preserves its matcher contract", () => { + const request = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + context: { + messages: [{ role: "user", content: "label it", timestamp: 0 }], + tools: [{ + name: "Label", + namespace: "collaboration", + description: "Label things", + parameters: { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" }, "^x-": { type: "string" } }, + minProperties: 1, + }, + }], + }, + }); + + const body = JSON.parse(request.body) as { + tools: Array<{ function: { parameters: { patternProperties: Record<string, unknown>; minProperties: number } } }>; + }; + const wire = body.tools[0].function.parameters; + // Retaining the matcher also preserves its value constraint and evaluation annotation. + expect(Object.keys(wire.patternProperties).sort()).toEqual(["^\\p{L}+$", "^x-"].sort()); + expect(wire.minProperties).toBe(1); + }); + test.each(["allOf", "not", "oneOf"] as const)("chat wire preserves composed %s argument constraints", kind => { + const matcher = "^\\p{L}+$"; + const parameters = kind === "allOf" ? { + type: "object", minProperties: 1, unevaluatedProperties: false, + allOf: [{ patternProperties: { [matcher]: { type: "string" } } }], + } : kind === "not" ? { + type: "object", required: ["value"], + properties: { value: { not: { type: "string", pattern: matcher } } }, + } : { + type: "object", required: ["value"], + properties: { value: { oneOf: [{ type: "string", pattern: matcher }, { const: "123" }] } }, + }; + const original = JSON.stringify(parameters); + // Witnesses are accepted before normalization: {name:"ok"} evaluates its sole key in + // allOf; {value:"123"} fails the letter pattern, satisfying not or exactly one branch. + expect(new RegExp(matcher, "u").test("name")).toBe(true); + expect(new RegExp(matcher, "u").test("123")).toBe(false); + + const request = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + context: { + messages: [{ role: "user", content: "keep the argument contract", timestamp: 0 }], + tools: [{ name: "Composed", description: "Composed schema", parameters }], + }, + }); + const wire = JSON.parse(request.body) as { tools: Array<{ function: { parameters: unknown } }> }; + expect(wire.tools).toHaveLength(1); + expect(wire.tools[0].function.parameters).toEqual(JSON.parse(original)); + expect(JSON.stringify(parameters)).toBe(original); + }); + +}); + describe("openai-chat non-stream response hardening", () => { test("surfaces an upstream error envelope message", async () => { const adapter = createOpenAIChatAdapter(provider()); diff --git a/tests/providers/opencode-go-agent-messages.test.ts b/tests/adapters/routed-agent-messages.test.ts similarity index 50% rename from tests/providers/opencode-go-agent-messages.test.ts rename to tests/adapters/routed-agent-messages.test.ts index f79f529a5e..2a44d11862 100644 --- a/tests/providers/opencode-go-agent-messages.test.ts +++ b/tests/adapters/routed-agent-messages.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; -import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; +import { normalizeRoutedAgentMessages } from "../../src/adapters/routed-agent-messages"; import { parseRequest } from "../../src/responses/parser"; import { routeModel } from "../../src/router"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; @@ -26,17 +26,113 @@ test("Responses converts plaintext task and peer messages without mutating repla test("ciphertext and unknown content are never reclassified as plaintext", () => { for (const part of [{ type: "encrypted_content", encrypted_content: "opaque" }, { type: "future_type", text: "opaque" }]) { const raw = { input: [{ type: "agent_message", content: [part] }] }; - expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); + } +}); + +test("string agent messages require an explicit opt-in and preserve exact text", () => { + const text = " Child result\nwith a trailing line.\n "; + const message = Object.freeze({ type: "agent_message", id: "amsg_string", content: text }); + const raw = Object.freeze({ input: Object.freeze([message]) }); + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: false })).toBe(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: true })).toEqual({ input: [{ + type: "message", role: "user", content: [{ type: "input_text", text }], + }] }); + expect(raw.input[0]).toBe(message); + expect(message.content).toBe(text); +}); + +for (const baseUrl of ["https://api.x.ai/v1", "https://cli-chat-proxy.grok.com/v1"]) { + test.each(["key", "oauth"] as const)(`${baseUrl} lowers string child results with %s auth`, async authMode => { + const raw = { model: "grok-4.6", stream: true, input: [{ + type: "agent_message", id: "amsg_string", author: "/root/worker", recipient: "/root", + content: " Complete child result\nSecond line.\n ", + }] }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl, authMode }).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + const sent = JSON.parse(request.body as string); + expect(sent.input).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: 'Agent message {"author":"/root/worker","recipient":"/root"}' }, + { type: "input_text", text: original.input[0]!.content }, + ], + }]); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } + }); +} + +test.each([ + { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { baseUrl: "https://api.x.ai/v1", authMode: "forward" as const }, + { baseUrl: "https://cli-chat-proxy.grok.com/v1", authMode: "forward" as const }, + { baseUrl: "https://custom.test/v1", authMode: "forward" as const }, + { baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key" as const }, + { baseUrl: "https://example.test/v1", authMode: "key" as const }, + { baseUrl: "https://api.x.ai.evil.test/v1", authMode: "key" as const }, + { baseUrl: "https://cli-chat-proxy.grok.com.evil.test/v1", authMode: "key" as const }, + { baseUrl: "http://api.x.ai/v1", authMode: "key" as const }, + { baseUrl: "https://api.x.ai:444/v1", authMode: "key" as const }, +])("preserves string messages for $authMode at $baseUrl", async destination => { + const raw = { model: "grok-4.6", input: [{ type: "agent_message", content: "Child result" }] }; + const original = structuredClone(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, ...destination }).buildRequest(parseRequest(raw), { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input).toEqual(original.input); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +test.each([ + "", " \n\t", null, 42, { text: "not a content string" }, [], + [{ type: "encrypted_content", encrypted_content: "opaque" }], + [{ type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }], + [{ type: "input_text", text: "Known prefix" }, { type: "future_type", text: "Unknown suffix" }], +].map(content => ({ content })))("xAI string opt-in leaves incomplete or unreadable content unchanged: %j", async ({ content }) => { + const raw = { model: "grok-4.6", input: [{ type: "agent_message", content }] }; + const original = structuredClone(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: true })).toBe(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://api.x.ai/v1" }).buildRequest(parseRequest(raw), { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input).toEqual(original.input); + expect(raw).toEqual(original); + } finally { + budget.dispose(); } }); test("image parts stay intact beside the assignment", () => { const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }; const raw = { input: [{ type: "agent_message", content: [{ type: "input_text", text: "Inspect image" }, image] }] }; - const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw; + const result = normalizeRoutedAgentMessages(raw) as typeof raw; expect(result.input[0]!.content[1]).toBe(image); }); +test("a body with no agent messages keeps its exact reference", () => { + const raw = { input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }] }; + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); + for (const shape of [null, "a string", [1, 2], { input: "not an array" }]) { + expect(normalizeRoutedAgentMessages(shape)).toBe(shape); + } +}); + test("native forward keeps agent_message and auth/session headers unchanged", async () => { const budget = createTranslatorBudget(); const provider = { ...base, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }; @@ -48,14 +144,43 @@ test("native forward keeps agent_message and auth/session headers unchanged", as budget.dispose(); }); -test("other destinations do not get Go normalization or session identity", async () => { +test("an arbitrary routed destination converts too, and gains no session identity", async () => { + // The 422 this guards against is not destination-specific: `agent_message` is private to + // the ChatGPT Codex backend, so any routed Responses target rejects the whole body. const budget = createTranslatorBudget(); - const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget }); - expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message"); + const raw = body(); + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parsed, { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); budget.dispose(); }); +test("an OAuth destination converts as well, since the gate reads authMode rather than the URL", async () => { + // The reported xAI/Grok failure (#3907) is an OAuth pool destination, not a key-auth + // one, and the gate is `authMode !== "forward"`. Pin a mode other than key/forward so + // a future narrowing of the gate back toward key-only cannot pass unnoticed. + const budget = createTranslatorBudget(); + const raw = body(); + const original = structuredClone(raw); + try { + const request = await createResponsesPassthroughAdapter({ + ...base, baseUrl: "https://api.x.ai/v1", authMode: "oauth" as const, + }).buildRequest(parseRequest(raw), { headers: new Headers(), translatorBudget: budget }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + test("canonical Go forward auth preserves private agent messages and the raw replay body", async () => { const raw = body(); const original = structuredClone(raw); @@ -108,18 +233,21 @@ test.each([ "http://opencode.ai/zen/go/v1", "https://opencode.ai/zen/v1", "https://opencode.ai/zen/go/v10", -])("Go-like destination %s preserves private agent messages", async baseUrl => { +])("Go-like destination %s converts without inheriting any Go-specific handling", async baseUrl => { + // A spoofed or malformed Go URL is just another routed destination now. It gets the same + // conversion every routed target gets, and still no Go session identity. const raw = body(); const original = structuredClone(raw); const parsed = parseRequest(raw); const budget = createTranslatorBudget(); try { const request = await createResponsesPassthroughAdapter({ ...base, baseUrl }).buildRequest(parsed, { - headers: new Headers(), translatorBudget: budget, - }); - expect(JSON.parse(request.body as string).input[0]).toMatchObject({ - type: "agent_message", content: original.input[0]!.content, + headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget, }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); + expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); expect(parsed._rawBody).toBe(raw); expect(raw).toEqual(original); } finally { @@ -127,12 +255,7 @@ test.each([ } }); -test.each(["not a URL", "https://", "/zen/go/v1"])( - "malformed destination %s is not classified as Go", - baseUrl => expect(isOpenCodeGo(baseUrl)).toBe(false), -); - -test("Go conversion preserves file payloads beside text without mutating raw replay", async () => { +test("conversion preserves file payloads beside text without mutating raw replay", async () => { const file = { type: "input_file", filename: "assignment.txt", file_data: "data:text/plain;base64,SGVsbG8=" }; const message = body().input[0]!; const raw = { ...body(), input: [{ ...message, content: [...message.content, file] }] }; @@ -161,10 +284,10 @@ for (const { name, content } of [ { name: "text mixed with ciphertext", content: [ { type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }, ] }, -]) test(`Go preserves ${name} without partially converting it`, async () => { +]) test(`routed destinations preserve ${name} without partially converting it`, async () => { const raw = { ...body(), input: [{ ...body().input[0]!, content }] }; const original = structuredClone(raw); - expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); const parsed = parseRequest(raw); const budget = createTranslatorBudget(); try { diff --git a/tests/ci-workflows/build-release-changelog.test.ts b/tests/ci-workflows/build-release-changelog.test.ts index c749c978e6..886e935c76 100644 --- a/tests/ci-workflows/build-release-changelog.test.ts +++ b/tests/ci-workflows/build-release-changelog.test.ts @@ -41,6 +41,42 @@ const generatedBugFix = [ "**Full Changelog**: https://github.com/lidge-jun/opencodex/compare/v1.0.0...v1.1.0", ].join("\n"); +describe("active release builder enforcement markers", () => { + test.each(["generated", "associated"])("normalizes summary and changelog titles from %s PRs", source => { + const title = "[WRONG BRANCH] fix(api): preserve release coverage"; + const result = buildReleaseNotes({ + version: "1.1.0", tags: ["v1.0.0"], npmMetadata: "", repository: "lidge-jun/opencodex", + generatedNotes: source === "generated" + ? `## What's Changed\n### Bug Fixes\n* ${title} by @alice in https://github.com/lidge-jun/opencodex/pull/10` + : "", + commits: [commit("a", "fix(api): preserve release coverage (#10)", [ + { number: 10, title, author: "alice", labels: ["bug"], merged: true }, + ])], + }); + expect(result.errors).toEqual([]); + expect(result.releasableCommitCount).toBe(1); + expect(result.body).toContain("- Preserve release coverage (#10)"); + expect(result.body).toContain("- #10 fix(api): preserve release coverage @alice"); + expect(result.body).not.toContain("[WRONG BRANCH]"); + }); + + test.each([ + "[Preview] fix(api): retain this marker", + "fix(api): explain [WRONG BRANCH] markers", + "[WRONG BRANCH]ish: retain this title", + ])("retains meaningful changelog title text: %s", title => { + const result = buildReleaseNotes({ + version: "1.1.0", tags: ["v1.0.0"], npmMetadata: "", repository: "lidge-jun/opencodex", + generatedNotes: "", + commits: [commit("a", "fix(api): preserve release coverage (#10)", [ + { number: 10, title, author: "alice", labels: ["bug"], merged: true }, + ])], + }); + expect(result.errors).toEqual([]); + expect(result.body).toContain(`- #10 ${title} @alice`); + }); +}); + describe("selectReleaseBaseline", () => { test("skips a newer release that is not reachable from the target", () => { // A preview lives on its own lineage. Selecting the newest tag regardless of diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index c78d56f3d9..66ec5981e4 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -65,10 +65,10 @@ describe("install scripts", () => { expect(pkg.main).toBe("./bin/package-main.mjs"); expect(pkg.exports?.["."]?.bun).toBe("./src/index.ts"); expect(pkg.exports?.["."]?.default).toBe("./bin/package-main.mjs"); - expect(pkg.dependencies?.bun).toBe("1.4.0"); + expect(pkg.dependencies?.bun).toBe("1.4.2"); expect(pkg.dependencies?.zod).toBe("4.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); - expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); + expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.2"); expect(pkg.scripts?.dev).toBe("bun run src/cli/index.ts start"); expect(pkg.scripts?.["dev:proxy"]).toBe("bun run src/cli/index.ts start"); expect(pkg.scripts?.["dev:gui"]).toBe("cd gui && bun run dev"); diff --git a/tests/ci-workflows/release-notes.test.ts b/tests/ci-workflows/release-notes.test.ts index 11196108d7..d270360081 100644 --- a/tests/ci-workflows/release-notes.test.ts +++ b/tests/ci-workflows/release-notes.test.ts @@ -455,6 +455,20 @@ describe("rewriteTakeoverCredits", () => { }); describe("cleanPrTitle", () => { + test("removes the enforcement marker before extracting scope and sentence casing", () => { + expect(cleanPrTitle(" [WRONG BRANCH] chore(release): promote validated 2.45.0 to main (#3813) ", 3813)).toEqual({ + scope: "release", + text: "Promote validated 2.45.0 to main", + }); + }); + + test.each([ + ["[Preview] chore(release): keep this marker", "[Preview] chore(release): keep this marker"], + ["fix: document [WRONG BRANCH] markers", "Document [WRONG BRANCH] markers"], + ["[WRONG BRANCH]ish: keep this title", "[WRONG BRANCH]ish: keep this title"], + ])("preserves meaningful title text: %s", (title, text) => { + expect(cleanPrTitle(title).text).toBe(text); + }); test("strips conventional prefix, keeps scope, and sentence-cases the title", () => { expect(cleanPrTitle("feat(providers): add Baseten Model APIs preset", 653)).toEqual({ scope: "providers", @@ -488,6 +502,55 @@ describe("cleanPrTitle", () => { }); describe("renderReleaseNotes", () => { + test.each(["delta", "carried"])("removes the bot marker from summaries and full changelogs (%s)", source => { + const body = [ + "## What's Changed", + "### Chores", + "* [WRONG BRANCH] chore(release): promote validated 2.45.0 to main by @lidge-jun in https://github.com/lidge-jun/opencodex/pull/3813", + ].join("\n"); + const notes = renderReleaseNotes({ + npmMetadata: "", + ...(source === "delta" ? { deltaPrNotes: body } : { carriedPreviewNotes: [ + "## Chores", "", + "- [WRONG BRANCH] chore(release): promote validated 2.45.0 to main (#3813)", "", + "## Changelog", "", + "- #3813 [WRONG BRANCH] chore(release): promote validated 2.45.0 to main @lidge-jun", + ].join("\n") }), + }); + expect(notes).toBe([ + "## Chores", "", + "- Promote validated 2.45.0 to main (#3813)", "", + "## Changelog", "", + "- #3813 chore(release): promote validated 2.45.0 to main @lidge-jun", "", + ].join("\n")); + }); + + test("groups a bot-prefixed title with ordinary titles of the same scope", () => { + const notes = renderReleaseNotes({ + npmMetadata: "", + deltaPrNotes: [ + "## What's Changed", "### Chores", + "* [WRONG BRANCH] chore(release): promote verified version by @maintainer in https://github.com/lidge-jun/opencodex/pull/10", + "* chore(release): update notes by @contributor in https://github.com/lidge-jun/opencodex/pull/11", + ].join("\n"), + }); + expect(notes).toContain("- Release: Promote verified version; Update notes (#10, #11)"); + expect(notes).toContain("- #10 chore(release): promote verified version @maintainer"); + expect(notes).toContain("- #11 chore(release): update notes @contributor"); + expect(notes).not.toContain("[WRONG BRANCH]"); + }); + + test.each([ + "[Preview] chore(release): retain the preview marker", + "fix: document [WRONG BRANCH] markers (#99)", + "[WRONG BRANCH]ish: retain this title", + ])("preserves meaningful full-changelog title text: %s", title => { + const notes = renderReleaseNotes({ + npmMetadata: "", + deltaPrNotes: `## What's Changed\n### Chores\n* ${title} by @contributor in https://github.com/lidge-jun/opencodex/pull/12`, + }); + expect(notes).toContain(`- #12 ${title} @contributor`); + }); const carried = [ "<!-- Release notes generated using configuration in .github/release.yml at abc -->", "", diff --git a/tests/ci-workflows/test-home-guard.test.ts b/tests/ci-workflows/test-home-guard.test.ts index 5764e234eb..47369ed663 100644 --- a/tests/ci-workflows/test-home-guard.test.ts +++ b/tests/ci-workflows/test-home-guard.test.ts @@ -9,8 +9,8 @@ * * Incident: devlog/_fin/260730_codex_rs_upstream_v2_live_handoff/070. */ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -18,6 +18,8 @@ import { assertNotRealHomeUnderTest, isTestHomeGuardArmed, protectedHomeForTests import { getConfigDir } from "../../src/config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; +import { watchdogMs } from "../helpers/ci-watchdog"; +import { captureTestOutput } from "../../scripts/test"; /** * Two different things are needed from the repo root, and conflating them is @@ -38,46 +40,211 @@ const REPO_ROOT = repoRoot(); */ const REPO_ROOT_URL = pathToFileURL(repoRoot() + "/").href; -/** - * Run a probe in a child process so we control OCX_REAL_HOME at STARTUP — the guard - * captures its protected path at module load, which is exactly the property under test. - */ -function runProbe(source: string, env: Record<string, string | undefined>): { code: number; stdout: string; stderr: string } { - const dir = mkdtempSync(join(tmpdir(), "ocx-guard-probe-")); - const file = join(dir, "probe.ts"); - writeFileSync(file, source, "utf8"); - const childEnv: Record<string, string> = {}; - for (const [key, value] of Object.entries({ ...process.env, ...env })) { - if (value !== undefined) childEnv[key] = value; +// Scale only execution: cleanup retains room below CI's existing 60-second ceiling. +const PROBE_EXECUTION_MS = watchdogMs(5_000); +const PROBE_TERM_MS = 5_000; +const PROBE_REAP_MS = 2_000; +const PROBE_DRAIN_MS = 1_000; + +function beginProbe(id: string): string { + console.warn(`[home-guard:${id}] 01 fixture setup`); + return id; +} + +async function waitForProbe(promise: Promise<void>, timeoutMs: number): Promise<boolean> { + let timer: ReturnType<typeof setTimeout> | undefined; + try { + return await Promise.race([ + promise.then(() => true), + new Promise<boolean>(resolve => { timer = setTimeout(() => resolve(false), timeoutMs); }), + ]); + } finally { + clearTimeout(timer); } - const result = Bun.spawnSync([process.execPath, "run", file], { cwd: REPO_ROOT, env: childEnv, stdout: "pipe", stderr: "pipe" }); - return { - code: result.exitCode ?? 1, - stdout: new TextDecoder().decode(result.stdout), - stderr: new TextDecoder().decode(result.stderr), - }; } -function runShardProbe(testSource: string, env: Record<string, string | undefined>): { code: number; stdout: string; stderr: string } { - const dir = mkdtempSync(join(tmpdir(), "ocx-shard-guard-probe-")); - const file = join(dir, "probe.test.ts"); - writeFileSync(file, testSource, "utf8"); - const childEnv: Record<string, string> = {}; - for (const [key, value] of Object.entries({ ...process.env, ...env })) { - if (value !== undefined) childEnv[key] = value; +type ProbeOutcome = { + pid: number | null; + code: number | null; + signal: NodeJS.Signals | null; + reaped: boolean; + complete: boolean; + stdout: string; + stderr: string; + root: string | undefined; +}; + +class ProbeFailure extends Error { + constructor(readonly id: string, readonly failures: string[], readonly outcome: ProbeOutcome) { + super(`[home-guard:${id}] ${failures.join(", ")}; pid=${outcome.pid} exit=${outcome.code} signal=${outcome.signal} reaped=${outcome.reaped} complete=${outcome.complete}`); + this.name = "ProbeFailure"; } - const result = Bun.spawnSync( - [process.execPath, "run", join(REPO_ROOT, "scripts/test.ts"), "--shard=1/1", file], - { cwd: REPO_ROOT, env: childEnv, stdout: "pipe", stderr: "pipe" }, - ); - rmSync(dir, { recursive: true, force: true }); - return { - code: result.exitCode ?? 1, - stdout: new TextDecoder().decode(result.stdout), - stderr: new TextDecoder().decode(result.stderr), +} + +/** Keep the startup home contract; own execution, reaping and pipe draining separately. */ +async function runProbe(id: string, source: string, env: Record<string, string | undefined>): Promise<ProbeOutcome> { + const outcome: ProbeOutcome = { + pid: null, code: null, signal: null, reaped: false, complete: false, + stdout: "", stderr: "", root: undefined, }; + const failures: string[] = []; + let child: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined; + let exited: Promise<void> | undefined; + let capture: ReturnType<typeof captureTestOutput> | undefined; + const stage = (message: string) => console.warn(`[home-guard:${id}] ${message}`); + try { + stage("02 probe file setup"); + outcome.root = mkdtempSync(join(tmpdir(), "ocx-guard-probe-")); + const file = join(outcome.root, "probe.ts"); + writeFileSync(file, source, "utf8"); + const childEnv: Record<string, string> = {}; + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) childEnv[key] = value; + } + stage("03 spawn requested"); + child = Bun.spawn([process.execPath, "run", file], { cwd: REPO_ROOT, env: childEnv, stdout: "pipe", stderr: "pipe" }); + const owned = child; + outcome.pid = owned.pid; + stage(`04 pid=${owned.pid}`); + // Rejection is an observation failure, never evidence that the process was reaped. + exited = owned.exited.then(code => { + outcome.code = code; + outcome.signal = owned.signalCode ?? null; + outcome.reaped = true; + stage(`08 exit pid=${owned.pid} code=${code} signal=${outcome.signal}`); + }, () => { + failures.push("exit-observation-failed"); + stage(`08 exit observation failed pid=${owned.pid}`); + }); + capture = captureTestOutput(owned.stdout, owned.stderr); + if (!await waitForProbe(exited, PROBE_EXECUTION_MS)) { + failures.push("execution-timeout"); + stage(`05 execution timeout pid=${owned.pid}`); + } + } catch { + failures.push("setup-or-observation-failed"); + } finally { + if (child && !outcome.reaped) { + stage(`06 TERM pid=${child.pid}`); + try { child.kill("SIGTERM"); } catch { stage("06 TERM request failed"); } + if (exited) await waitForProbe(exited, PROBE_TERM_MS); + if (!outcome.reaped) { + stage(`07 KILL pid=${child.pid}`); + try { child.kill("SIGKILL"); } catch { stage("07 KILL request failed"); } + if (exited) await waitForProbe(exited, PROBE_REAP_MS); + } + if (!outcome.reaped) failures.push("reap-timeout"); + } + if (capture) { + try { Object.assign(outcome, await capture.finish(PROBE_DRAIN_MS)); } + catch { failures.push("capture-failed"); } + stage(`09 capture complete=${outcome.complete}`); + if (!outcome.complete) failures.push("incomplete-output"); + } + if (outcome.root && (!child || outcome.reaped)) { + try { + removeTreeWithRetry(outcome.root); + stage("10 probe files removed"); + } catch { + failures.push("cleanup-failed"); + stage("10 probe cleanup failed"); + } + } else if (outcome.root) { + stage(`10 probe files retained: child unreaped pid=${outcome.pid}`); + } + } + if (outcome.code !== 0) failures.push("nonzero-exit"); + if (outcome.signal !== null) failures.push("signal-exit"); + // A timeout remains a failure even if TERM subsequently permits a natural exit 0. + if (failures.length) throw new ProbeFailure(id, failures, { ...outcome }); + return outcome; +} + +async function probeFailure(pending: Promise<ProbeOutcome>): Promise<ProbeFailure> { + const failure: unknown = await pending.then(() => undefined, error => error); + expect(failure).toBeInstanceOf(ProbeFailure); + if (!(failure instanceof ProbeFailure)) throw new Error("Expected a failed guard probe"); + return failure; +} + +function expectOwnedProbeGone(outcome: ProbeOutcome): void { + expect(outcome.reaped).toBe(true); + if (outcome.pid === null || outcome.root === undefined) throw new Error("Probe never spawned"); + expect(outcome.pid).toBeGreaterThan(0); + let code: string | undefined; + try { process.kill(outcome.pid, 0); } + catch (error) { code = (error as NodeJS.ErrnoException).code; } + expect(code).toBe("ESRCH"); + expect(existsSync(outcome.root)).toBe(false); } +describe("guard probe lifecycle", () => { + test("nonzero exit retains output, reports failure and reaps the owned child", async () => { + const failure = await probeFailure(runProbe(beginProbe("control-nonzero"), ` + console.log("OCX_GUARD_NONZERO"); + process.exitCode = 23; + `, {})); + expect(failure.failures).toEqual(["nonzero-exit"]); + expect(failure.outcome.code).toBe(23); + expect(failure.outcome.signal).toBeNull(); + expect(failure.outcome.complete).toBe(true); + expect(failure.outcome.stdout.trim()).toBe("OCX_GUARD_NONZERO"); + expectOwnedProbeGone(failure.outcome); + }); + + test("a referenced handle times out and is reaped even if TERM permits exit zero", async () => { + const failure = await probeFailure(runProbe(beginProbe("control-hanging"), ` + const keepAlive = setInterval(() => {}, 1000); + const stop = () => { + clearInterval(keepAlive); + process.off("SIGTERM", stop); + }; + process.on("SIGTERM", stop); + console.log("OCX_GUARD_HANG_READY"); + `, {})); + expect(failure.failures).toContain("execution-timeout"); + expect(failure.failures).not.toContain("reap-timeout"); + expect(failure.outcome.stdout.trim()).toBe("OCX_GUARD_HANG_READY"); + expect(failure.outcome.complete).toBe(true); + // POSIX can handle TERM and exit naturally; Windows may terminate directly. + if (process.platform !== "win32") { + expect(failure.outcome.code).toBe(0); + expect(failure.outcome.signal).toBeNull(); + } + expectOwnedProbeGone(failure.outcome); + }, 60_000); // Match the existing CI ceiling; include bounded TERM/reap/drain locally too. + + test("exit zero with an open output pipe is incomplete, never a successful probe", async () => { + let cancelled = false; + const stdout = new ReadableStream<Uint8Array>({ + start(controller) { controller.enqueue(new TextEncoder().encode("OCX_GUARD_PARTIAL\n")); }, + cancel() { cancelled = true; }, + }); + const stderr = new ReadableStream<Uint8Array>({ start(controller) { controller.close(); } }); + // Exercise runProbe's integration with real capture; no unmanaged descendant is needed. + const spawn = spyOn(Bun, "spawn").mockReturnValue({ + pid: 0, stdout, stderr, exited: Promise.resolve(0), signalCode: null, + kill() { throw new Error("Exited synthetic child must not be killed"); }, + } as unknown as ReturnType<typeof Bun.spawn>); + try { + const pending = runProbe(beginProbe("control-open-pipe"), "", {}); + spawn.mockRestore(); // runProbe spawns synchronously before its first await. + const failure = await probeFailure(pending); + expect(failure.failures).toEqual(["incomplete-output"]); + expect(failure.outcome.code).toBe(0); + expect(failure.outcome.signal).toBeNull(); + expect(failure.outcome.reaped).toBe(true); + expect(failure.outcome.complete).toBe(false); + expect(failure.outcome.stdout).toBe("OCX_GUARD_PARTIAL\n"); + expect(cancelled).toBe(true); + expect(failure.outcome.root).toBeDefined(); + expect(existsSync(failure.outcome.root!)).toBe(false); + } finally { + spawn.mockRestore(); + } + }); +}); + /** A fake "real home" the guard will protect, so no deny case aims at the true one. */ function sentinelHome(): { realHome: string; opencodexHome: string; codexHome: string } { const realHome = mkdtempSync(join(tmpdir(), "ocx-sentinel-home-")); @@ -90,39 +257,6 @@ function sentinelHome(): { realHome: string; opencodexHome: string; codexHome: s describe("real-home write guard", () => { - // This deliberately starts a nested copy of the repository test wrapper. Keep it in the - // main worker lane so the child can join the active run lock, and allow startup headroom - // when the other workers are compiling large suites. - test("the shard wrapper arms the real writer guard without inherited preload state", () => { - const { realHome, opencodexHome } = sentinelHome(); - const configPath = join(opencodexHome, "config.json"); - const sentinel = '{"providers":{"openai":{"name":"sentinel"}},"defaultProvider":"openai"}'; - writeFileSync(configPath, sentinel, "utf8"); - const probe = runShardProbe(` - import { test } from "bun:test"; - test("refuses the protected config", async () => { - process.env.OPENCODEX_HOME = process.env.OCX_REAL_HOME + "/.opencodex"; - const { saveConfig } = await import("${REPO_ROOT_URL}src/config"); - try { - saveConfig({ providers: {}, defaultProvider: "openai", port: 10100 } as never); - throw new Error("writer unexpectedly succeeded"); - } catch (error) { - if (!String(error).includes("refusing to write the real OpenCodex home")) throw error; - } - }); - `, { - // The probe intentionally starts a second wrapper while its parent wrapper owns the - // user-level test lock. Declare that overlap explicitly so it cannot queue behind itself. - OCX_TEST_NO_QUEUE: "1", - OCX_TEST_HOME_GUARD: undefined, - OCX_TEST_PRELOAD_PID: undefined, - OCX_REAL_HOME: realHome, - }); - - expect(probe.code).toBe(0); - expect(readFileSync(configPath, "utf8")).toBe(sentinel); - }, 15_000); - /** * Windows without Developer Mode or admin cannot create symlinks (EPERM). The * escape cases below need a real link to prove the guard resolves through one, so @@ -140,9 +274,10 @@ const canSymlink = (() => { removeTreeWithRetry(probeDir); } })(); - test("armed + the protected home: all three writers throw", () => { + test("armed + the protected home: all three writers throw", async () => { + const probeId = beginProbe("01-protected-writers"); const { realHome, opencodexHome } = sentinelHome(); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { saveConfig } from "${REPO_ROOT_URL}src/config"; import { mutateStore } from "${REPO_ROOT_URL}src/oauth/store"; import { saveCodexAccountCredential } from "${REPO_ROOT_URL}src/codex/account-store"; @@ -168,9 +303,10 @@ const canSymlink = (() => { expect(() => readFileSync(join(opencodexHome, "codex-accounts.json"))).toThrow(); }); - test("armed native credential writes reject the protected Codex home", () => { + test("armed native credential writes reject the protected Codex home", async () => { + const probeId = beginProbe("02-native-credentials"); const { realHome, codexHome } = sentinelHome(); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { assertNotRealCodexHomeUnderTest } from "${REPO_ROOT_URL}src/lib/test-home-guard"; try { // JSON.stringify, not raw interpolation: a Windows temp path is @@ -189,7 +325,8 @@ const canSymlink = (() => { expect(probe.stdout).not.toContain("WRITE_ALLOWED"); }); - test.skipIf(!canSymlink)("armed + a symlink escaping a temp home into the protected home: refused", () => { + test.skipIf(!canSymlink)("armed + a symlink escaping a temp home into the protected home: refused", async () => { + const probeId = beginProbe("03-symlink-file"); // Atomic writes resolve their destination through symlinks, so a temp home whose // config.json points into the protected home would otherwise pass the caller's // dir-level check and then write the real file anyway. @@ -199,7 +336,7 @@ const canSymlink = (() => { const dir = mkdtempSync(join(tmpdir(), "ocx-escape-home-")); symlinkSync(protectedFile, join(dir, "config.json")); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { saveConfig } from "${REPO_ROOT_URL}src/config"; const REFUSAL = "refusing to write the real OpenCodex home"; try { @@ -215,10 +352,11 @@ const canSymlink = (() => { expect(readFileSync(protectedFile, "utf8")).toBe('{"sentinel":true}'); }); - test("armed + an unregistered temp home: writers succeed", () => { + test("armed + an unregistered temp home: writers succeed", async () => { + const probeId = beginProbe("04-unregistered-home"); // The 54 suites that mkdtemp their own home must keep working with no opt-in. const dir = mkdtempSync(join(tmpdir(), "ocx-plain-home-")); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { saveConfig } from "${REPO_ROOT_URL}src/config"; saveConfig({ providers: {}, defaultProvider: "openai", port: 10100 } as never); console.log("wrote"); @@ -228,7 +366,8 @@ const canSymlink = (() => { expect(JSON.parse(readFileSync(join(dir, "config.json"), "utf8")).port).toBe(10100); }); - test.skipIf(!canSymlink)("armed + a first write beneath a symlinked PARENT escaping into the protected home: refused", () => { + test.skipIf(!canSymlink)("armed + a first write beneath a symlinked PARENT escaping into the protected home: refused", async () => { + const probeId = beginProbe("05-symlink-parent"); // The file does not exist yet, so resolveWriteTarget returns the literal // path and target === path; the guard must resolve the parent directory // instead of skipping (review: symlinked config dir + absent destination). @@ -238,7 +377,7 @@ const canSymlink = (() => { symlinkSync(opencodexHome, linkDir); const modeBefore = statSync(opencodexHome).mode; - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { atomicWriteFile, writePid } from "${REPO_ROOT_URL}src/config"; const REFUSAL = "refusing to write the real OpenCodex home"; try { @@ -268,9 +407,10 @@ const canSymlink = (() => { expect(statSync(opencodexHome).mode).toBe(modeBefore); }); - test("disarmed: the protected home is allowed (production stays inert)", () => { + test("disarmed: the protected home is allowed (production stays inert)", async () => { + const probeId = beginProbe("06-disarmed"); const { realHome, opencodexHome } = sentinelHome(); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { saveConfig } from "${REPO_ROOT_URL}src/config"; saveConfig({ providers: {}, defaultProvider: "openai", port: 10100 } as never); console.log("wrote"); @@ -279,12 +419,13 @@ const canSymlink = (() => { expect(probe.stdout).toContain("wrote"); }); - test("the protected path comes from OCX_REAL_HOME, not the sandboxed HOME", () => { + test("the protected path comes from OCX_REAL_HOME, not the sandboxed HOME", async () => { + const probeId = beginProbe("07-captured-home"); // The inversion this guards against: if the guard read homedir() after the harness // replaced HOME, it would protect the sandbox and leave the real home writable. const { realHome } = sentinelHome(); const decoyHome = mkdtempSync(join(tmpdir(), "ocx-decoy-home-")); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { protectedHomeForTests } from "${REPO_ROOT_URL}src/lib/test-home-guard"; console.log(protectedHomeForTests()); `, { OCX_TEST_HOME_GUARD: "1", OCX_REAL_HOME: realHome, HOME: decoyHome }); @@ -293,12 +434,13 @@ const canSymlink = (() => { expect(probe.stdout).not.toContain("ocx-decoy-home-"); }); - test.skipIf(!canSymlink)("a symlink pointing at the protected home is rejected", () => { + test.skipIf(!canSymlink)("a symlink pointing at the protected home is rejected", async () => { + const probeId = beginProbe("08-symlink-home"); const { realHome, opencodexHome } = sentinelHome(); const linkDir = mkdtempSync(join(tmpdir(), "ocx-symlink-")); const link = join(linkDir, "looks-like-temp"); symlinkSync(opencodexHome, link); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { assertNotRealHomeUnderTest } from "${REPO_ROOT_URL}src/lib/test-home-guard"; try { assertNotRealHomeUnderTest(${JSON.stringify(link)}); console.log("allowed"); } catch { console.log("rejected"); } @@ -307,12 +449,13 @@ const canSymlink = (() => { expect(probe.stdout.trim()).toBe("rejected"); }); - test("/var and /private/var spellings of one path agree", () => { + test("/var and /private/var spellings of one path agree", async () => { + const probeId = beginProbe("09-path-alias"); // macOS hands out /var/folders/... whose realpath is /private/var/folders/...; // a lexical comparison would disagree with itself across those two spellings. const { realHome } = sentinelHome(); const aliased = realHome.startsWith("/var/") ? join("/private", realHome) : realHome.replace(/^\/private/, ""); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { assertNotRealHomeUnderTest } from "${REPO_ROOT_URL}src/lib/test-home-guard"; const results: string[] = []; for (const path of [${JSON.stringify(join(realHome, ".opencodex"))}, ${JSON.stringify(join(aliased, ".opencodex"))}]) { @@ -380,9 +523,10 @@ const canSymlink = (() => { * And the guard has to hold for a process that never reached the lock at all, which is the * state the timed-out worker was actually in. */ - test("a process that arms the guard is protected even with no lock and a real HOME", () => { + test("a process that arms the guard is protected even with no lock and a real HOME", async () => { + const probeId = beginProbe("10-no-lock"); const { realHome } = sentinelHome(); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { assertNotRealHomeUnderTest, isTestHomeGuardArmed } from "${REPO_ROOT_URL}src/lib/test-home-guard"; let rejected = false; try { assertNotRealHomeUnderTest(${JSON.stringify(join(realHome, ".opencodex"))}); } catch { rejected = true; } diff --git a/tests/ci-workflows/test-runner.test.ts b/tests/ci-workflows/test-runner.test.ts index edc0fe8617..90bcb7bb4b 100644 --- a/tests/ci-workflows/test-runner.test.ts +++ b/tests/ci-workflows/test-runner.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path"; import { changedSelectionFailure, + captureTestOutput, createIsolatedTestEnvironment, DEDICATED_FULL_SUITE_FILES, ensureGuiDependencies, @@ -12,6 +13,7 @@ import { runTestLaneForTests, resolveBunTestArgs, resolveBunTestPlan, + runTestLane, selectChangedComparisonRef, SERIAL_FULL_SUITE_FILES, terminateTestProcessForTests, @@ -137,6 +139,150 @@ function initChangedRunFixture(): { cwd: string; base: string } { return { cwd, base }; } +describe("test runner captured output", () => { + test("preserves both streams and UTF-8 characters split across chunks", async () => { + const bytes = new TextEncoder().encode("before 한글 after\n"); + const stdout = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(bytes.slice(0, 8)); + controller.enqueue(bytes.slice(8)); + controller.close(); + }, + }); + const stderr = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode("diagnostic\n")); + controller.close(); + }, + }); + expect(await captureTestOutput(stdout, stderr).finish(1_000)).toEqual({ + stdout: "before 한글 after\n", stderr: "diagnostic\n", complete: true, + }); + }); + + test.each(["pending", "rejected"] as const)( + "bounds an open pipe even when cancellation is %s", + async cancellation => { + let controller!: ReadableStreamDefaultController<Uint8Array>; + let cancelled = false; + const stdout = new ReadableStream<Uint8Array>({ + start(value) { + controller = value; + value.enqueue(new TextEncoder().encode("retained prefix\n")); + }, + cancel() { + cancelled = true; + return cancellation === "pending" + ? new Promise<void>(() => {}) + : Promise.reject(new Error("fixture cancellation failure")); + }, + }); + const stderr = new ReadableStream<Uint8Array>({ start(value) { value.close(); } }); + let timer: ReturnType<typeof setTimeout> | undefined; + try { + const result = await Promise.race([ + captureTestOutput(stdout, stderr).finish(20), + new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), 2_000); }), + ]); + expect(result).toEqual({ stdout: "retained prefix\n", stderr: "", complete: false }); + expect(cancelled).toBe(true); + } finally { + clearTimeout(timer); + try { controller.close(); } catch { /* cancellation already closed it */ } + } + }, + ); + + test("retains a prefix when reading the pipe fails", async () => { + let reads = 0; + const stdout = new ReadableStream<Uint8Array>({ + pull(controller) { + if (reads++ === 0) controller.enqueue(new TextEncoder().encode("before error\n")); + else controller.error(new Error("fixture read failure")); + }, + }); + const stderr = new ReadableStream<Uint8Array>({ start(controller) { controller.close(); } }); + expect(await captureTestOutput(stdout, stderr).finish(1_000)).toEqual({ + stdout: "before error\n", stderr: "", complete: false, + }); + }); + + test("an exited child with an open pipe reports incomplete capture instead of success", async () => { + let cancelled = false; + const stdout = new ReadableStream<Uint8Array>({ + start(controller) { controller.enqueue(new TextEncoder().encode("partial output\n")); }, + cancel() { cancelled = true; }, + }); + const stderr = new ReadableStream<Uint8Array>({ start(controller) { controller.close(); } }); + const spawn = spyOn(Bun, "spawn").mockReturnValue({ + pid: 0, + stdout, + stderr, + exited: Promise.resolve(0), + kill() { throw new Error("the fixture child already exited"); }, + } as unknown as ReturnType<typeof Bun.spawn>); + const emitted: string[] = []; + try { + const pending = runTestLane( + { label: "open pipe fixture", args: [], timeoutMs: 2_000 }, + "capture-fixture", + undefined, + true, + { stdout: value => { emitted.push(value); }, stderr: value => { emitted.push(value); } }, + ); + // Only the synchronous spawn is mocked; no other test or later subprocess uses it. + spawn.mockRestore(); + expect(await pending).toEqual({ exitCode: 1, output: "partial output\n\n" }); + expect(emitted).toEqual(["partial output\n"]); + expect(cancelled).toBe(true); + } finally { + spawn.mockRestore(); + } + }); + + test.each(["pass", "fail", "timeout"] as const)( + "returns and prints a %s lane's output exactly once", + async outcome => { + const root = mkdtempSync(join(tmpdir(), "opencodex-capture-lane-")); + const fixture = join(root, "capture.test.ts"); + const stdout: string[] = []; + const stderr: string[] = []; + writeFileSync(fixture, ` + import { test } from "bun:test"; + test("capture fixture", async () => { + process.stdout.write("OCX_CAPTURE_STDOUT_MARKER\\n"); + process.stderr.write("OCX_CAPTURE_STDERR_MARKER\\n"); + ${outcome === "timeout" ? "await new Promise(() => {});" : ""} + ${outcome === "fail" ? 'throw new Error("fixture assertion failure");' : ""} + }, 60_000); + `); + try { + const runId = process.env[TEST_RUN_ID_ENV]!; + const result = await runTestLane( + { label: "capture fixture", args: [fixture], timeoutMs: INTERNAL_DEADLINE_MS }, + runId, + resolveInheritedTestRunLock({ wrappedRunId: runId, env: process.env }), + true, + { stdout: value => { stdout.push(value); }, stderr: value => { stderr.push(value); } }, + ); + expect(result.exitCode).toBe(outcome === "timeout" ? 124 : outcome === "fail" ? 1 : 0); + expect(result.output).toContain("OCX_CAPTURE_STDOUT_MARKER\n"); + expect(result.output).toContain("OCX_CAPTURE_STDERR_MARKER\n"); + // A failed Bun assertion may quote the fixture source containing the marker. + // Count emitted marker lines, not mentions inside the error's code frame. + expect(stdout.join("").split(/\r?\n/).filter(line => line === "OCX_CAPTURE_STDOUT_MARKER")) + .toHaveLength(1); + expect(stderr.join("").split(/\r?\n/).filter(line => line === "OCX_CAPTURE_STDERR_MARKER")) + .toHaveLength(1); + expect(result.output).toBe(stdout.join("") + "\n" + stderr.join("")); + } finally { + removeTreeWithRetry(root); + } + }, + { timeout: SPAWN_BUDGET_MS }, + ); +}); + describe("test runner isolation", () => { test("redirects user homes to a disposable root", () => { const isolated = createIsolatedTestEnvironment({ PATH: "/test/bin", HOME: "/real/home" }); diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 97bea29ecf..d3081e1c09 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -5,6 +5,9 @@ import { repoPath } from "../helpers/repo-root"; import { AnthropicRequestError, anthropicToResponsesBody, anthropicToResponsesTranslation, effortForThinkingBudget, extractOcxEffortDirective, resolveInboundModel } from "../../src/claude/inbound"; import { parseRequest } from "../../src/responses/parser"; import { responsesRequestSchema } from "../../src/responses/schema"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import type { OcxProviderConfig } from "../../src/types"; // Full Claude Code-shaped request: system array, tool cycle, image, thinking, options. function claudeCodeRequest(): Record<string, unknown> { @@ -80,6 +83,7 @@ describe("claude inbound translation", () => { expect(tools[0]).toEqual({ type: "function", name: "Read", description: "Read a file", parameters: { type: "object", properties: { file_path: { type: "string" } }, required: ["file_path"] }, + strict: false, }); expect(tools[1]).toEqual({ type: "web_search" }); @@ -795,3 +799,97 @@ test("inbound leaves preserve the tool_choice error identity and avoid facade ba .not.toMatch(/from\s+["']\.\/inbound["']/); } }); + + +/** + * #3922: Anthropic enables strict tool use by setting strict: true, while Responses + * reads an omitted strict as permission to normalize the schema into strict mode. + * Translating without the field therefore made every optional input_schema parameter + * behave as required upstream, so a call that omitted one failed. The translated tool + * now carries the source intent, and the value has to survive to the serialized wire + * body rather than only to the translator's return. + */ +describe("#3922 translated tools carry the source strict intent", () => { + const schema = { + type: "object", + properties: { + prompt: { type: "string" }, + isolation: { type: "string", enum: ["worktree", "remote"] }, + options: { type: "object", properties: { enabled: { type: "boolean" } } }, + }, + required: ["prompt"], + additionalProperties: false, + }; + const request = (tool: Record<string, unknown>) => ({ + model: "openai/gpt-5.4", + max_tokens: 32, + messages: [{ role: "user", content: "Run a local agent." }], + tools: [tool], + }); + const agent = (extra: Record<string, unknown> = {}) => ({ + name: "Agent", description: "Run an agent", input_schema: schema, ...extra, + }); + const translatedTool = (tool: Record<string, unknown>) => + (anthropicToResponsesBody(request(tool)).tools as Record<string, unknown>[])[0]!; + + test("an omitted strict becomes an explicit false instead of an implicit strict request", () => { + expect(translatedTool(agent()).strict).toBe(false); + }); + + test("an explicit strict survives in both directions", () => { + expect(translatedTool(agent({ strict: true })).strict).toBe(true); + expect(translatedTool(agent({ strict: false })).strict).toBe(false); + }); + + test("a non-boolean strict cannot opt the tool into strict mode", () => { + expect(translatedTool(agent({ strict: "true" })).strict).toBe(false); + }); + + test("the source input_schema is forwarded unchanged", () => { + for (const extra of [{}, { strict: true }, { strict: false }]) { + const tool = agent(extra); + // Compare against a detached copy: the expected value must not be the very + // object under test, or an in-place mutation would move both sides together. + const expectedSchema = structuredClone(tool.input_schema); + expect(translatedTool(tool).parameters).toEqual(expectedSchema); + expect(tool.input_schema).toEqual(expectedSchema); + } + }); + + test("hosted web_search gains no strict field", () => { + const body = anthropicToResponsesBody(request({ type: "web_search_20250305", name: "web_search" })); + expect((body.tools as Record<string, unknown>[])[0]).toEqual({ type: "web_search" }); + }); + + test("strict intent and schema survive into the serialized Responses body", async () => { + // parsed._rawBody is the translator's own object, so reading it back proves + // nothing about the wire. Build the actual outbound request instead. + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + adapter: "openai-responses", + authMode: "key", + baseUrl: "https://api.openai.com/v1", + apiKey: "test-key", + } as OcxProviderConfig)); + + for (const [tool, expected] of [ + [agent(), false], + [agent({ strict: true }), true], + [agent({ strict: false }), false], + ] as const) { + const expectedSchema = structuredClone(tool.input_schema); + const parsed = parseRequest({ ...anthropicToResponsesBody(request(tool)), model: "gpt-5.4" }); + expect(parsed.context.tools?.[0]?.strict).toBe(expected); + + const outbound = await adapter.buildRequest(parsed); + try { + const wire = JSON.parse(String(outbound.body)) as { tools: { strict?: boolean; parameters?: unknown }[] }; + expect(wire.tools).toHaveLength(1); + expect(wire.tools[0]?.strict).toBe(expected); + expect(wire.tools[0]?.parameters).toEqual(expectedSchema); + expect(tool.input_schema).toEqual(expectedSchema); + } finally { + outbound.releaseBodyObservation?.(); + } + } + }); +}); diff --git a/tests/cli/cli-effort.test.ts b/tests/cli/cli-effort.test.ts index cb558d42b6..09c4282474 100644 --- a/tests/cli/cli-effort.test.ts +++ b/tests/cli/cli-effort.test.ts @@ -114,6 +114,112 @@ describe("ocx effort offline config operations", () => { expect(parsed.subagentEffortCap).toBeNull(); expect(parsed.efforts).toContain("low"); expect(parsed.efforts).toContain("ultra"); + expect(parsed.warnings).toEqual([]); + }); + + for (const value of ["none", "minimal"]) { + for (const target of ["shorthand", "--main", "--subagent"]) { + test(`rejects unsupported cap ${value} through ${target} before probing or saving`, async () => { + const args = target === "shorthand" ? [value] : ["set", target, value]; + const { deps, logs, errors } = fakeDeps(args); + const configBefore = readFileSync(join(tempHome!, "config.json"), "utf8"); + let probes = 0; + deps.findLiveProxy = async () => { probes += 1; return null; }; + expect(await handleEffortCommand(args, deps)).toBe(2); + expect(errors.join("\n")).toContain('unknown reasoning effort "' + value + '"'); + expect(errors.join("\n")).toContain("allowed: low, medium, high, xhigh, max, ultra, -"); + expect(probes).toBe(0); + expect(logs).toEqual([]); + expect(readFileSync(join(tempHome!, "config.json"), "utf8")).toBe(configBefore); + }); + } + + test(`offline injection still accepts ${value} without treating it as a cap`, async () => { + const { deps } = fakeDeps(); + expect(await handleEffortCommand(["set", "--injection", value], deps)).toBe(0); + expect(readTestConfig().injectionEffort).toBe(value); + expect(readTestConfig().effortCap).toBeUndefined(); + expect(readTestConfig().subagentEffortCap).toBeUndefined(); + }); + } + + test("rejects unsupported cap spelling without advertising sentinel cap values", async () => { + const { deps, errors } = fakeDeps(); + expect(await handleEffortCommand(["set", "--main", "bogus"], deps)).toBe(2); + expect(errors.join("\n")).toContain("allowed: low, medium, high, xhigh, max, ultra, -"); + expect(errors.join("\n")).not.toContain("ultra, none, minimal"); + }); + + for (const source of ["config", "runtime"] as const) { + for (const wantsJson of [false, true]) { + test(`legacy unsupported cap diagnostics preserve ${source} values (${wantsJson ? "json" : "human"})`, async () => { + const conf = { ...readTestConfig(), effortCap: "none", subagentEffortCap: "minimal", injectionEffort: "none" }; + const configPath = join(tempHome!, "config.json"); + writeFileSync(configPath, JSON.stringify(conf, null, 2), "utf8"); + const configBefore = readFileSync(configPath, "utf8"); + const { deps, logs } = fakeDeps(); + const methods: string[] = []; + const main = source === "config" ? "none" : "minimal"; + const subagent = source === "config" ? "minimal" : "none"; + const runtime = source === "runtime" ? { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (url: string | URL | Request, init?: RequestInit) => { + methods.push(init?.method ?? "GET"); + const body = new URL(url.toString()).pathname === "/api/effort-caps" + ? { effortCap: main, subagentEffortCap: subagent } + : { effort: "none" }; + return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); + }, + } : {}; + const args = wantsJson ? ["status", "--json"] : ["status"]; + expect(await handleEffortCommand(args, { ...deps, ...runtime })).toBe(0); + let warnings: string[]; + if (wantsJson) { + const result = JSON.parse(logs.join("\n")); + expect(result.source).toBe(source); + expect(result.effortCap).toBe(main); + expect(result.subagentEffortCap).toBe(subagent); + expect(result.injectionEffort).toBe("none"); + expect(result.warnings).toHaveLength(2); + warnings = result.warnings; + } else { + expect(logs.join("\n")).toContain(`Main agent effort cap: ${main}`); + warnings = logs; + } + expect(warnings.join("\n")).toContain(`effortCap="${main}" is invalid and is not applied`); + expect(warnings.join("\n")).toContain(`subagentEffortCap="${subagent}" is invalid and is not applied`); + expect(warnings.join("\n")).toContain("ocx effort set --main"); + expect(warnings.join("\n")).toContain("ocx effort set --subagent"); + expect(methods).toEqual(source === "runtime" ? ["GET", "GET"] : []); + expect(readFileSync(configPath, "utf8")).toBe(configBefore); + }); + } + } + + test("invalid legacy cap diagnostics do not normalize stored whitespace or casing", async () => { + const conf = { ...readTestConfig(), effortCap: " high ", subagentEffortCap: "HIGH" }; + const configPath = join(tempHome!, "config.json"); + writeFileSync(configPath, JSON.stringify(conf, null, 2), "utf8"); + const before = readFileSync(configPath, "utf8"); + const { deps, logs } = fakeDeps(); + expect(await handleEffortCommand(["status", "--json"], deps)).toBe(0); + const result = JSON.parse(logs.join("\n")); + expect(result.effortCap).toBe(" high "); + expect(result.subagentEffortCap).toBe("HIGH"); + expect(result.warnings).toHaveLength(2); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + test("an ignored subagent cap warning preserves the valid main cap", async () => { + const conf = { ...readTestConfig(), effortCap: "high", subagentEffortCap: "minimal" }; + writeFileSync(join(tempHome!, "config.json"), JSON.stringify(conf, null, 2), "utf8"); + const { deps, logs } = fakeDeps(); + expect(await handleEffortCommand(["status", "--json"], deps)).toBe(0); + const result = JSON.parse(logs.join("\n")); + expect(result.effortCap).toBe("high"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].startsWith('subagentEffortCap="minimal"')).toBe(true); + expect(readTestConfig()).toEqual(conf); }); test("ocx effort <level> sets main effort cap offline", async () => { @@ -233,6 +339,26 @@ describe("ocx effort offline config operations", () => { }); describe("ocx effort online live-proxy integration & negative regressions", () => { + for (const value of ["none", "minimal"]) { + test(`invalid cap values reject a mixed live update before any request (${value})`, async () => { + const { deps, logs } = fakeDeps(); + const before = readFileSync(join(tempHome!, "config.json"), "utf8"); + let requests = 0; + let probes = 0; + const code = await handleEffortCommand(["set", "--main", "high", "--subagent", value, "--injection", "medium"], { + ...deps, + baseUrl: "http://127.0.0.1:10100", + findLiveProxy: async () => { probes += 1; return null; }, + fetchImpl: async () => { requests += 1; return new Response("{}"); }, + }); + expect(code).toBe(2); + expect(probes).toBe(0); + expect(requests).toBe(0); + expect(logs).toEqual([]); + expect(readFileSync(join(tempHome!, "config.json"), "utf8")).toBe(before); + }); + } + test("live status read failures never substitute offline config", async () => { const { logs, errors } = fakeDeps(["status", "--json"]); const configBefore = readTestConfig(); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 45289517ff..f7a822d25b 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -13,12 +13,30 @@ import { handleProviderRuntimeCommand } from "../../src/cli/provider-runtime"; import { providerQuotaLine } from "../../src/cli/account-extended"; import { formatAccountTable } from "../../src/cli/account"; import { handleConnectCommand } from "../../src/cli/connect"; +import { handleSystemCommand } from "../../src/cli/system-command"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; type Recorded = { path: string; method: string; body: unknown }; const servers: Array<ReturnType<typeof Bun.serve>> = []; +describe("ocx system settings client compaction", () => { + test("persists the explicit boolean through the shared settings endpoint", async () => { + const { requests, deps } = fakeRuntime((_req, body) => ({ ok: true, ...body })); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleSystemCommand(["settings", "--client-compaction", "on"], deps)).toBe(0); + expect(requests).toEqual([{ + path: "/api/settings", + method: "PUT", + body: { codexClientCompaction: true }, + }]); + } finally { + logSpy.mockRestore(); + } + }); +}); + describe("ocx agent sidecar --list (#2188)", () => { test("web --list prints the server's webSearchModels — the GUI's exact list", async () => { const { requests, deps } = fakeRuntime(req => { diff --git a/tests/cli/cli-help.test.ts b/tests/cli/cli-help.test.ts index 93e0b11627..6851ad15d0 100644 --- a/tests/cli/cli-help.test.ts +++ b/tests/cli/cli-help.test.ts @@ -1,6 +1,6 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -305,8 +305,8 @@ describe("CLI subcommand help", () => { expectSpawnFinished(result, "ocx recover-history --help"); expect(result.status).toBe(0); - expect(result.stdout).toContain("Usage: ocx recover-history --legacy-openai --yes"); - expect(result.stdout).toContain("Force all user-message opencodex rows to OpenAI"); + expect(result.stdout).toContain("Usage: ocx recover-history (--legacy-openai | --ocx-compaction <thread-id>) --yes"); + expect(result.stdout).toContain("Recover legacy provider metadata or one OpenCodeX-compacted thread"); expect(result.stdout).not.toContain("Recovered"); expect(result.stderr).toBe(""); expect(existsSync(statePath)).toBe(false); @@ -362,6 +362,48 @@ describe("CLI subcommand help", () => { } }); + test("recover-history repairs one explicitly selected ocx1-compacted thread", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-recover-compaction-")); + const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-recover-compaction-state-")); + try { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + const threadId = "01a018e6-242f-7801-81b8-ffc0a5c6d589"; + const rolloutDir = join(codexHome, "sessions", "2026", "09", "07"); + mkdirSync(rolloutDir, { recursive: true }); + const rollout = join(rolloutDir, `rollout-fixture-${threadId}.jsonl`); + const summary = `ocx1:${Buffer.from("portable summary", "utf8").toString("base64")}`; + writeFileSync(rollout, `${JSON.stringify({ + type: "compacted", + payload: { + replacement_history: [{ type: "compaction", id: "cmp_fixture", encrypted_content: summary }], + }, + })}\n`, "utf8"); + const statePath = join(codexHome, "state_5.sqlite"); + const db = new Database(statePath, { create: true }); + db.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL)"); + db.query("INSERT INTO threads (id, rollout_path) VALUES (?, ?)").run(threadId, rollout); + db.close(); + + const result = runCli( + ["recover-history", "--ocx-compaction", threadId, "--yes"], + { CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, CI: "1" }, + ); + + expectSpawnFinished(result, "ocx recover-history --ocx-compaction"); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Recovered 1 ocx1 compaction item(s)"); + expect(readFileSync(rollout, "utf8")).toContain("portable summary"); + expect(readFileSync(rollout, "utf8")).not.toContain("ocx1:"); + const backupDir = join(opencodexHome, "history-recovery-backups", threadId); + const backups = readdirSync(backupDir); + expect(backups).toHaveLength(1); + expect(readFileSync(join(backupDir, backups[0]), "utf8")).toContain("ocx1:"); + } finally { + removeTreeWithRetry(opencodexHome); + removeTreeWithRetry(codexHome); + } + }); + test("start rejects unknown and partially numeric port arguments", () => { const cases = [ { args: ["start", "--port", "123abc"], expected: "Invalid port number" }, diff --git a/tests/cli/cli-management-auth.test.ts b/tests/cli/cli-management-auth.test.ts index 24f90bf33d..263af330a5 100644 --- a/tests/cli/cli-management-auth.test.ts +++ b/tests/cli/cli-management-auth.test.ts @@ -65,7 +65,7 @@ describe("CLI management authentication", () => { }, fetchFn: async (_input, init) => { token = new Headers(init?.headers).get("x-opencodex-api-key"); - return new Response(null, { status: 200 }); + return Response.json({ success: true, sharedTeardown: "performed" }); }, }); expect(result).toBe(true); diff --git a/tests/cli/cli-restart-health.test.ts b/tests/cli/cli-restart-health.test.ts index ac2dfae50d..3b9498707f 100644 --- a/tests/cli/cli-restart-health.test.ts +++ b/tests/cli/cli-restart-health.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { watchdogMs } from "../helpers/ci-watchdog"; +import { captureTestOutput } from "../../scripts/test"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); @@ -16,20 +17,144 @@ const cliPath = join(repoRoot, "src", "cli", "index.ts"); * network/no-proxy/argument-validation ready tests live as injected tests in * tests/cli/cli-ready.test.ts (no real loopback/home). */ -function runCli(args: string[], env: Record<string, string> = {}) { - return spawnSync(process.execPath, [cliPath, ...args], { - cwd: repoRoot, - env: { ...process.env, ...env }, - encoding: "utf8", - timeout: 10000, - }); +// These are correctness watchdogs, not startup latency assertions. Scale only execution. +const CLI_BUDGET = { execution: watchdogMs(10_000), term: 5_000, reap: 2_000, drain: 1_000 }; +const CLI_TEST_TIMEOUT = CLI_BUDGET.execution + CLI_BUDGET.term + CLI_BUDGET.reap + CLI_BUDGET.drain + 3_000; +type CliChild = Pick<Bun.Subprocess<"ignore", "pipe", "pipe">, "pid" | "exited" | "signalCode" | "stdout" | "stderr" | "kill">; +type CliSpawn = (argv: string[], options: { + cwd: string; env: NodeJS.ProcessEnv; stdout: "pipe"; stderr: "pipe"; +}) => CliChild; +type CliState = { + id: string; startedAt: number; pid: number | null; reaped: boolean; + status: number | null; signal: NodeJS.Signals | null; + stdout: string; stderr: string; complete: boolean; +}; +const cliHomes = new Map<string, CliState>(); + +function cliStage(state: CliState, stage: string): void { + console.warn(`[cli-probe:${state.id}] ${stage} elapsedMs=${Date.now() - state.startedAt} pid=${state.pid}`); +} + +function errorTag(error: unknown): string { + const name = error instanceof Error ? error.name : "UnknownError"; + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + // Error messages can contain argv or environment. Log only conventional name/code tags. + return `${/^[A-Za-z]+$/.test(name) ? name : "Error"}${/^[A-Z0-9_]+$/.test(code) ? `:${code}` : ""}`; +} + +class CliHarnessError extends Error { + constructor(readonly failures: string[], readonly outcome: CliState) { + super(`[cli-probe:${outcome.id}] ${failures.join(", ")} pid=${outcome.pid} status=${outcome.status} signal=${outcome.signal} reaped=${outcome.reaped} complete=${outcome.complete}`); + this.name = "CliHarnessError"; + } +} + +async function waitForCliExit(exited: Promise<void>, milliseconds: number): Promise<boolean> { + let timer: ReturnType<typeof setTimeout> | undefined; + try { + return await Promise.race([ + exited.then(() => true), + new Promise<boolean>(resolve => { timer = setTimeout(() => resolve(false), milliseconds); }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function runCli(args: string[], env: Record<string, string> = {}, control?: { + spawn: CliSpawn; budget: typeof CLI_BUDGET; +}): Promise<{ status: number; stdout: string; stderr: string }> { + const state = cliHomes.get(env.OPENCODEX_HOME); + if (!state) throw new Error("CLI probe requires an owned isolated home"); + const budget = control?.budget ?? CLI_BUDGET; + const spawn: CliSpawn = control?.spawn ?? ((argv, options) => Bun.spawn(argv, options)); + const failures: string[] = []; + let child: CliChild | undefined; + let exited: Promise<void> | undefined; + let capture: ReturnType<typeof captureTestOutput> | undefined; + let boundary = "spawn"; + try { + cliStage(state, "03 spawn requested"); + child = spawn([process.execPath, cliPath, ...args], { + cwd: repoRoot, env: { ...process.env, ...env }, stdout: "pipe", stderr: "pipe", + }); + state.pid = child.pid; // Establish ownership before any observation or capture can fail. + const owned = child; + exited = owned.exited.then(status => { + state.reaped = true; + state.status = status; + state.signal = owned.signalCode ?? null; + cliStage(state, `08 exit status=${status} signal=${state.signal}`); + }).catch(error => { + failures.push(`exit-observation-error:${errorTag(error)}`); + cliStage(state, `08 ${failures[failures.length - 1]}`); + }); + cliStage(state, "04 child owned"); + boundary = "capture"; + capture = captureTestOutput(owned.stdout, owned.stderr); + boundary = "execution"; + if (!await waitForCliExit(exited, budget.execution)) { + failures.push("execution-timeout"); + cliStage(state, "05 execution timeout"); + } + } catch (error) { + failures.push(`${boundary}-error:${errorTag(error)}`); + } finally { + if (child && !state.reaped) { + cliStage(state, "06 TERM"); + try { child.kill("SIGTERM"); } catch (error) { cliStage(state, `06 TERM error=${errorTag(error)}`); } + if (exited) await waitForCliExit(exited, budget.term); + if (!state.reaped) { + cliStage(state, "07 KILL"); + try { child.kill("SIGKILL"); } catch (error) { cliStage(state, `07 KILL error=${errorTag(error)}`); } + if (exited) await waitForCliExit(exited, budget.reap); + } + if (!state.reaped) failures.push("reap-timeout"); + } + if (capture) { + try { Object.assign(state, await capture.finish(budget.drain)); } + catch (error) { failures.push(`capture-error:${errorTag(error)}`); } + cliStage(state, `09 capture complete=${state.complete}`); + if (!state.complete) failures.push("incomplete-output"); + } + } + if (!state.reaped || state.status === null || !Number.isInteger(state.status)) failures.push("exit-not-observed"); + if (state.signal !== null) failures.push("signal-exit"); + // Never turn timeout/incomplete capture into status 1: health legitimately expects 1. + if (failures.length) throw new CliHarnessError([...failures], { ...state }); + return { status: state.status!, stdout: state.stdout, stderr: state.stderr }; } function isolatedHome(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)); + const state: CliState = { + id: prefix, startedAt: Date.now(), pid: null, reaped: false, + status: null, signal: null, stdout: "", stderr: "", complete: false, + }; + cliStage(state, "01 home setup"); + const dir = mkdtempSync(join(tmpdir(), prefix)); + cliHomes.set(dir, state); + return dir; +} + +function cleanupCliHome(dir: string, primaryFailed = false): void { + const state = cliHomes.get(dir); + if (!state) throw new Error("Cannot clean an unowned CLI home"); + if (state.pid !== null && !state.reaped) { + cliStage(state, "10 home retained: child unreaped"); + return; + } + try { + removeTreeWithRetry(dir); + cliHomes.delete(dir); + cliStage(state, "10 home removed"); + } catch (error) { + cliStage(state, `10 cleanup error=${errorTag(error)}`); + if (!primaryFailed) throw error; + } } function writeIsolatedConfig(dir: string): void { + cliStage(cliHomes.get(dir)!, "02 config setup"); writeFileSync(join(dir, "config.json"), JSON.stringify({ port: 19999, providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, @@ -38,104 +163,227 @@ function writeIsolatedConfig(dir: string): void { }), "utf8"); } +describe("CLI subprocess lifecycle", () => { + const budget = { execution: 10, term: 10, reap: 10, drain: 10 }; + const scenarios: Array<{ + name: string; mode: "exit" | "timeout" | "unreaped" | "spawn-error" | "exit-error"; + status: number | null; signal?: NodeJS.Signals; open?: boolean; + failures: string[]; signals: NodeJS.Signals[]; reaped: boolean; retained?: boolean; + }> = [ + { name: "returns exit 0", mode: "exit", status: 0, failures: [], signals: [], reaped: true }, + { name: "returns health exit 1", mode: "exit", status: 1, failures: [], signals: [], reaped: true }, + { name: "preserves exit 23", mode: "exit", status: 23, failures: [], signals: [], reaped: true }, + { name: "timeout stays failed after TERM yields exit 0", mode: "timeout", status: 0, + failures: ["execution-timeout"], signals: ["SIGTERM"], reaped: true }, + { name: "open output after exit 0 fails", mode: "exit", status: 0, open: true, + failures: ["incomplete-output"], signals: [], reaped: true }, + { name: "open output after exit 1 fails", mode: "exit", status: 1, open: true, + failures: ["incomplete-output"], signals: [], reaped: true }, + { name: "unreaped child retains its home after TERM and KILL", mode: "unreaped", status: null, + failures: ["execution-timeout", "reap-timeout", "exit-not-observed"], + signals: ["SIGTERM", "SIGKILL"], reaped: false, retained: true }, + { name: "spawn error is not command exit 1", mode: "spawn-error", status: null, + failures: ["spawn-error:Error:ENOENT", "exit-not-observed"], signals: [], reaped: false }, + { name: "rejected observation is not reaping", mode: "exit-error", status: null, + failures: ["exit-observation-error:Error:EPIPE", "reap-timeout", "exit-not-observed"], + signals: ["SIGTERM", "SIGKILL"], reaped: false, retained: true }, + { name: "signal exit is not a completed command", mode: "exit", status: 0, signal: "SIGTERM", + failures: ["signal-exit"], signals: [], reaped: true }, + ]; + + for (const scenario of scenarios) test(scenario.name, async () => { + const dir = isolatedHome(`ocx-cli-control-${scenario.name.replace(/[^a-z0-9]+/gi, "-")}-`); + const state = cliHomes.get(dir)!; + let resolveExit!: (status: number) => void; + let rejectExit!: (error: Error) => void; + const exited = new Promise<number>((resolve, reject) => { resolveExit = resolve; rejectExit = reject; }); + const signals: Array<NodeJS.Signals | number | undefined> = []; + let cancelled = false; + const child: CliChild = { + pid: 424242, exited, signalCode: scenario.signal ?? null, + stdout: new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode("CLI_CONTROL_STDOUT\n")); + if (!scenario.open) controller.close(); + }, + cancel() { cancelled = true; }, + }), + stderr: new ReadableStream<Uint8Array>({ + start(controller) { controller.enqueue(new TextEncoder().encode("CLI_CONTROL_STDERR\n")); controller.close(); }, + }), + kill(signal) { + signals.push(signal); + if (scenario.mode === "timeout") resolveExit(0); + }, + }; + const spawn: CliSpawn = (argv, options) => { + expect(argv).toEqual([process.execPath, cliPath, "health"]); + expect(options.cwd).toBe(repoRoot); + expect(options.env.OPENCODEX_HOME).toBe(dir); + if (scenario.mode === "spawn-error") throw Object.assign(new Error("fixture"), { code: "ENOENT" }); + if (scenario.mode === "exit-error") rejectExit(Object.assign(new Error("fixture"), { code: "EPIPE" })); + if (scenario.mode === "exit") resolveExit(scenario.status!); + return child; + }; + try { + const result: unknown = await runCli(["health"], { OPENCODEX_HOME: dir }, { spawn, budget }) + .then(value => value, error => error); + if (scenario.failures.length) { + expect(result).toBeInstanceOf(CliHarnessError); + if (!(result instanceof CliHarnessError)) throw new Error("Expected CLI harness failure"); + expect(result.failures).toEqual(scenario.failures); + if (scenario.open) expect(result.outcome.stdout).toBe("CLI_CONTROL_STDOUT\n"); + } else { + expect(result).toEqual({ status: scenario.status, stdout: "CLI_CONTROL_STDOUT\n", stderr: "CLI_CONTROL_STDERR\n" }); + } + expect(state.status).toBe(scenario.status); + expect(state.pid).toBe(scenario.mode === "spawn-error" ? null : 424242); + expect(state.signal).toBe(scenario.signal ?? null); + expect(state.reaped).toBe(scenario.reaped); + expect(state.complete).toBe(scenario.mode !== "spawn-error" && !scenario.open); + expect(signals).toEqual(scenario.signals); + expect(cancelled).toBe(Boolean(scenario.open)); + cleanupCliHome(dir, scenario.failures.length > 0); + expect(existsSync(dir)).toBe(Boolean(scenario.retained)); + expect(cliHomes.has(dir)).toBe(Boolean(scenario.retained)); + } finally { + // The seam never launched an OS process; only this test owns the retained fake home. + cliHomes.delete(dir); + removeTreeWithRetry(dir); + } + }); +}); + describe("ocx restart", () => { - test("restart --help prints usage", () => { + test("restart --help prints usage", async () => { const dir = isolatedHome("ocx-restart-help-"); + let failed = false; try { - const result = runCli(["restart", "--help"], { OPENCODEX_HOME: dir }); + const result = await runCli(["restart", "--help"], { OPENCODEX_HOME: dir }); expect(result.status).toBe(0); expect(result.stdout).toContain("ocx restart"); + } catch (error) { + failed = true; + throw error; } finally { - removeTreeWithRetry(dir); + cleanupCliHome(dir, failed); } - }); + }, CLI_TEST_TIMEOUT); - test("help restart shows restart help entry", () => { + test("help restart shows restart help entry", async () => { const dir = isolatedHome("ocx-restart-help-entry-"); + let failed = false; try { - const result = runCli(["help", "restart"], { OPENCODEX_HOME: dir }); + const result = await runCli(["help", "restart"], { OPENCODEX_HOME: dir }); expect(result.status).toBe(0); expect(result.stdout).toContain("Stop the proxy and restart"); + } catch (error) { + failed = true; + throw error; } finally { - removeTreeWithRetry(dir); + cleanupCliHome(dir, failed); } - }); + }, CLI_TEST_TIMEOUT); }); describe("ocx health", () => { - test("health --help prints usage", () => { + test("health --help prints usage", async () => { const dir = isolatedHome("ocx-health-help-"); + let failed = false; try { - const result = runCli(["health", "--help"], { OPENCODEX_HOME: dir }); + const result = await runCli(["health", "--help"], { OPENCODEX_HOME: dir }); expect(result.status).toBe(0); expect(result.stdout).toContain("ocx health"); + } catch (error) { + failed = true; + throw error; } finally { - removeTreeWithRetry(dir); + cleanupCliHome(dir, failed); } - }); + }, CLI_TEST_TIMEOUT); - test("help health shows health help entry", () => { + test("help health shows health help entry", async () => { const dir = isolatedHome("ocx-health-help-entry-"); + let failed = false; try { - const result = runCli(["help", "health"], { OPENCODEX_HOME: dir }); + const result = await runCli(["help", "health"], { OPENCODEX_HOME: dir }); expect(result.status).toBe(0); expect(result.stdout).toContain("Check proxy health"); + } catch (error) { + failed = true; + throw error; } finally { - removeTreeWithRetry(dir); + cleanupCliHome(dir, failed); } - }); + }, CLI_TEST_TIMEOUT); - test("health exits 1 with no proxy running (isolated home)", () => { + test("health exits 1 with no proxy running (isolated home)", async () => { const dir = isolatedHome("ocx-health-"); - writeIsolatedConfig(dir); + let failed = false; try { - const result = runCli(["health"], { OPENCODEX_HOME: dir }); + writeIsolatedConfig(dir); + const result = await runCli(["health"], { OPENCODEX_HOME: dir }); expect(result.status).toBe(1); expect(result.stdout).toContain("not healthy"); + } catch (error) { + failed = true; + throw error; } finally { - removeTreeWithRetry(dir); + cleanupCliHome(dir, failed); } - }); + }, CLI_TEST_TIMEOUT); - test("health --json exits 1 with valid JSON when no proxy", () => { + test("health --json exits 1 with valid JSON when no proxy", async () => { const dir = isolatedHome("ocx-health-json-"); - writeIsolatedConfig(dir); + let failed = false; try { - const result = runCli(["health", "--json"], { OPENCODEX_HOME: dir }); + writeIsolatedConfig(dir); + const result = await runCli(["health", "--json"], { OPENCODEX_HOME: dir }); expect(result.status).toBe(1); const parsed = JSON.parse(result.stdout); expect(parsed.ok).toBe(false); expect(parsed.pid).toBeNull(); + } catch (error) { + failed = true; + throw error; } finally { - removeTreeWithRetry(dir); + cleanupCliHome(dir, failed); } - }); + }, CLI_TEST_TIMEOUT); }); describe("ocx ready", () => { // Only the help-routing subprocess checks live here. The default-probe, // --json, --wait, --timeout, and argument-validation cases are injected tests // in tests/cli/cli-ready.test.ts (no real loopback/home). - test("ready --help prints usage (exit 0)", () => { + test("ready --help prints usage (exit 0)", async () => { const dir = isolatedHome("ocx-ready-help-"); + let failed = false; try { - const result = runCli(["ready", "--help"], { OPENCODEX_HOME: dir }); + const result = await runCli(["ready", "--help"], { OPENCODEX_HOME: dir }); expect(result.status).toBe(0); expect(result.stdout).toContain("ocx ready"); expect(result.stdout).toContain("--wait"); + } catch (error) { + failed = true; + throw error; } finally { - removeTreeWithRetry(dir); + cleanupCliHome(dir, failed); } - }); + }, CLI_TEST_TIMEOUT); - test("help ready shows the ready help entry", () => { + test("help ready shows the ready help entry", async () => { const dir = isolatedHome("ocx-ready-help-entry-"); + let failed = false; try { - const result = runCli(["help", "ready"], { OPENCODEX_HOME: dir }); + const result = await runCli(["help", "ready"], { OPENCODEX_HOME: dir }); expect(result.status).toBe(0); expect(result.stdout).toContain("post-sync readiness"); + } catch (error) { + failed = true; + throw error; } finally { - removeTreeWithRetry(dir); + cleanupCliHome(dir, failed); } - }); + }, CLI_TEST_TIMEOUT); }); diff --git a/tests/cli/cli-restore-back.test.ts b/tests/cli/cli-restore-back.test.ts index 04051a4b10..a750ae8ee9 100644 --- a/tests/cli/cli-restore-back.test.ts +++ b/tests/cli/cli-restore-back.test.ts @@ -45,6 +45,7 @@ describe("ocx restore back", () => { expect(result.status).toBe(0); expect(JSON.parse(readFileSync(join(ocxHome, "config.json"), "utf8")).clientIntegrations.codex).toBe(false); expect(`${result.stdout}\n${result.stderr}`).toContain("Codex integration is OFF and plain `codex` now runs natively."); + expect(result.stdout).toContain("ocx recover-history --ocx-compaction <thread-id> --yes"); } finally { removeTreeWithRetry(codexHome); removeTreeWithRetry(ocxHome); diff --git a/tests/cli/cli-start-journal-order.test.ts b/tests/cli/cli-start-journal-order.test.ts index e565451e89..2b3264be9a 100644 --- a/tests/cli/cli-start-journal-order.test.ts +++ b/tests/cli/cli-start-journal-order.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -85,6 +86,8 @@ function arrangeRecoverableJournal(fx: Fixture): { original: string; injected: s version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, pid: 999_999, timestamp: new Date().toISOString(), })); @@ -185,6 +188,8 @@ describe("start and ensure journal ownership (#1230)", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999_999, timestamp: new Date().toISOString(), diff --git a/tests/cli/cli-usage-report.test.ts b/tests/cli/cli-usage-report.test.ts index 3342aab2ea..5399137a57 100644 --- a/tests/cli/cli-usage-report.test.ts +++ b/tests/cli/cli-usage-report.test.ts @@ -52,6 +52,38 @@ async function run(argv: string[], body: unknown): Promise<{ code: number; out: } describe("formatUsageReport", () => { + test("keeps malformed token counts and every human line inert", () => { + const control = "before\x1b[2J\x07\u2028after\u2029"; + const body = payload({ + range: control, + summary: { requests: 1, totalTokens: control, inputTokens: Infinity, outputTokens: control }, + providers: [{ provider: null, requests: 1, totalTokens: control }], + accounts: [{ accountLogLabel: control, requests: 1, totalTokens: control }], + }); + const lines = formatUsageReport(body as never); + expect(lines.every(line => !/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(line))).toBe(true); + expect(lines.join("\n")).toContain("before\\x1b[2J\\x07\\u2028after\\u2029"); + expect(lines.find(line => line.startsWith("Tokens"))).toBe("Tokens — (in — / out —)"); + expect(body.summary).toEqual({ requests: 1, totalTokens: control, inputTokens: Infinity, outputTokens: control }); + }); + + test("escapes Unicode line separators on the no-match return too", () => { + const lines = formatUsageReport(payload({ + filter: { provider: "before\u2028after", model: null, matched: false, comboOverlap: false }, + }) as never); + expect(lines.every(line => !/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(line))).toBe(true); + expect(lines.join("\n")).toContain("before\\u2028after"); + }); + + test("preserves ordinary per-account totals and keeps JSON unchanged", async () => { + const body = payload({ accounts: [{ accountLogLabel: "account-1", requests: 12, totalTokens: 345, estimatedCostUsd: 0.125 }] }); + expect(formatUsageReport(body as never).join("\n")).toMatch(/account-1\s+12\s+345\s+~\$0\.1250/); + const malformed = payload({ summary: { requests: 1, outputTokens: "\x1b[2J" } }); + const { code, out } = await run(["usage", "--json"], malformed); + expect(code).toBe(0); + expect(JSON.parse(out)).toEqual(malformed); + }); + test("prints per-provider and per-model cost, not an item count", () => { const out = formatUsageReport(payload() as never).join("\n"); expect(out).toContain("~$12.3456"); diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index 66bf5d4bf9..3835f463bc 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -14,7 +14,7 @@ import { import { handleConnectCommand } from "../../src/cli/connect"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot as findRepoRoot } from "../helpers/repo-root"; -import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; const repoRoot = findRepoRoot(); @@ -316,7 +316,10 @@ describe("remote hub client boundary", () => { /** A catalog the user already had before ever connecting. */ const PRIOR_CATALOG_BYTES = '{"models":[{"slug":"local/only-model"}]}'; -function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog" | "coordinator") { +function runTransactionScenario( + stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog" | "coordinator", + options: { script?: string; timeoutMs?: number } = {}, +) { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-home-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-codex-")); const configPath = join(opencodexHome, "config.json"); @@ -335,7 +338,7 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co const { mkdirSync } = require("node:fs") as typeof import("node:fs"); mkdirSync(join(opencodexHome, "config-mutation.sqlite")); } - const script = ` + const script = options.script ?? ` const { existsSync, readFileSync } = require("node:fs"); const { createHash } = require("node:crypto"); const { connectClient, disconnectClient } = require("./src/client/connect"); @@ -396,26 +399,110 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co console.log(JSON.stringify({ connected, error, beforeDisconnect, artifacts, disconnected, catalogAfter, after: readClientConnectionState(), calls, commitFaultTriggered })); })(); `; - const result = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, - encoding: "utf8", - }); - const output = result.stdout.trim().split("\n").at(-1) ?? "{}"; - const parsed = JSON.parse(output) as Record<string, any>; - return { - status: result.status, - stderr: result.stderr, - parsed, - configBytes: readFileSync(configPath, "utf8"), - cleanup: () => { - removeTreeWithRetry(opencodexHome); - removeTreeWithRetry(codexHome); - }, + const cleanup = () => { + const failures: unknown[] = []; + for (const home of [opencodexHome, codexHome]) { + try { removeTreeWithRetry(home); } + catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "Could not clean client transaction homes"); }; + try { + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, + encoding: "utf8", + timeout: options.timeoutMs ?? INTERNAL_DEADLINE_MS, + killSignal: "SIGKILL", + }); + if (result.error || result.status !== 0 || result.signal !== null) { + throw new ClientStateProbeError(result.pid, result.status, result.signal, (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"); + } + let parsed: Record<string, any>; + try { parsed = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } + catch { throw new ClientStateProbeError(result.pid, result.status, result.signal, false); } + return { status: result.status, stderr: result.stderr, parsed, configBytes: readFileSync(configPath, "utf8"), cleanup }; + } catch (error) { + try { cleanup(); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], "Client transaction failed and fixture cleanup failed"); } + throw error; + } } describe("connect transaction and offline disconnect", () => { + test("transaction fixture stops a child retained after valid output", async () => { + const proofHome = mkdtempSync(join(tmpdir(), "ocx-transaction-child-proof-")); + const markerPath = join(proofHome, "child-started.json"); + const naturalExitPath = join(proofHome, "natural-exit"); + const script = ` + const fs = require("node:fs"); + fs.writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ + pid: process.pid, home: process.env.OPENCODEX_HOME, codexHome: process.env.CODEX_HOME, + })); + fs.writeSync(1, '{"ok":true}\\n'); + setTimeout(() => { fs.writeFileSync(${JSON.stringify(naturalExitPath)}, "exited"); }, 5_000); + `; + let run: Awaited<ReturnType<typeof runTransactionScenario>> | undefined; + try { + let failure: unknown; + const startedAt = performance.now(); + try { run = await runTransactionScenario("coordinator", { script, timeoutMs: 2_000 }); } + catch (error) { failure = error; } + expect(performance.now() - startedAt).toBeLessThan(10_000); + expect(failure).toBeInstanceOf(ClientStateProbeError); + if (!(failure instanceof ClientStateProbeError)) throw new Error("Expected bounded transaction child failure"); + expect(failure.timedOut).toBe(true); + expect(existsSync(naturalExitPath)).toBe(false); + const proof = JSON.parse(readFileSync(markerPath, "utf8")) as { pid: number; home: string; codexHome: string }; + expect(proof.pid).toBe(failure.pid); + expect(existsSync(proof.home)).toBe(false); + expect(existsSync(proof.codexHome)).toBe(false); + let exitCode: string | undefined; + try { process.kill(proof.pid, 0); } + catch (error) { exitCode = (error as NodeJS.ErrnoException).code; } + expect(exitCode).toBe("ESRCH"); + } finally { + run?.cleanup(); + removeTreeWithRetry(proofHome); + } + }, SPAWN_BUDGET_MS); + + for (const [mode, output, status] of [ + ["nonzero exit", '{"ok":true}', 7], + ["invalid JSON", "private-child-output", 0], + ] as const) { + test(`transaction fixture cleans homes after ${mode}`, () => { + const proofHome = mkdtempSync(join(tmpdir(), "ocx-transaction-child-proof-")); + const markerPath = join(proofHome, "child-started.json"); + const script = ` + const fs = require("node:fs"); + fs.writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ + pid: process.pid, home: process.env.OPENCODEX_HOME, codexHome: process.env.CODEX_HOME, + })); + fs.writeSync(1, ${JSON.stringify(output)}); + process.exit(${status}); + `; + let run: ReturnType<typeof runTransactionScenario> | undefined; + try { + let failure: unknown; + try { run = runTransactionScenario("coordinator", { script }); } + catch (error) { failure = error; } + expect(failure).toBeInstanceOf(ClientStateProbeError); + if (!(failure instanceof ClientStateProbeError)) throw new Error("Expected transaction child failure"); + expect(failure.status).toBe(status); + expect(failure.timedOut).toBe(false); + expect(failure.message).not.toContain(output); + const proof = JSON.parse(readFileSync(markerPath, "utf8")) as { pid: number; home: string; codexHome: string }; + expect(failure.pid).toBe(proof.pid); + expect(existsSync(proof.home)).toBe(false); + expect(existsSync(proof.codexHome)).toBe(false); + } finally { + try { run?.cleanup(); } + finally { removeTreeWithRetry(proofHome); } + } + }, SPAWN_BUDGET_MS); + } + test("an unavailable config coordinator refuses before issuing any hub key", () => { const run = runTransactionScenario("coordinator"); try { @@ -493,6 +580,7 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c const fingerprint = createHash("sha256").update(token).digest("hex"); const catalog = '{"models":[]}'; const catalogFingerprint = createHash("sha256").update(catalog).digest("base64url"); + const injected = 'model_provider = "opencodex"\n'; const isDisconnect = mode === "disconnect-conflict" || mode === "disconnect-process-journal"; const selectedClients = isDisconnect ? ["codex"] : ["claude"]; writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ @@ -517,13 +605,15 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); writeFileSync(join(codexHome, "opencodex-catalog.json"), catalog, "utf8"); writeFileSync(join(codexHome, "config.toml"), isDisconnect - ? 'model_provider = "opencodex"\n' + ? injected : 'model_provider = "openai"\n', "utf8"); if (mode === "disconnect-conflict") { writeFileSync(join(codexHome, "opencodex-journal.json"), JSON.stringify({ version: 1, originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "different-key" }, pid: 999_999, timestamp: "2026-08-28T00:00:00.000Z", @@ -538,6 +628,8 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c version: 1, originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "process", pid: 999_999 }, pid: 999_999, timestamp: "2026-08-28T00:00:00.000Z", @@ -890,6 +982,8 @@ function runDesktopLifecycleScenario(mode: string) { fs.writeFileSync(path.join(process.env.CODEX_HOME, "config.toml"), 'model_provider = "opencodex"'); fs.writeFileSync(journal.JOURNAL_PATH, JSON.stringify({ version: 1, originalConfig: Buffer.from('model_provider = "openai"').toString("base64"), originalProfile: null, + injectedConfigHash: hash(fs.readFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "utf8")), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: owner.apiKeyId }, })); const actualRestore = journal.restoreJournalState; diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 56093b3dd6..b2d4f530cc 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -699,12 +699,31 @@ describe("ownership is scoped to recorded fragments", () => { expect(result).toEqual({ state: "stale" }); }); + test("Hermes also ignores whole-file edits outside its registry-declared fragment", () => { + // Hermes declares sourcePreservingYaml: { path: ["providers", "opencodex"] }. + const contribution = { ...ownedContribution, clientId: "hermes" as const }; + const clientRecord: OwnershipRecord = { + ...record, + clientId: "hermes", + configPath: "/tmp/hermes-config.yaml", + blockFingerprint: fingerprint(canonicalContribution(contribution)), + }; + const result = classifyIntegration({ + fileText: textWithExtra, + fileIsRegular: true, + parsed: documentWithExtra, + record: clientRecord, + contribution, + }); + expect(result).toEqual({ state: "current" }); + }); + // Re-serializing a whole document in these formats would drop any comments // the user keeps next to our block, so file-level drift stays a hard // conflict for every one of them — a regression that narrowed the condition // (say, to yaml only) must fail here, not in a user's config. for (const { clientId, configPath } of [ - { clientId: "hermes" as const, configPath: "/tmp/hermes-config.yaml" }, + { clientId: "gajae" as const, configPath: "/tmp/gajae-models.yaml" }, { clientId: "openclaw" as const, configPath: "/tmp/openclaw.json5" }, { clientId: "kimi" as const, configPath: "/tmp/kimi-config.toml" }, ]) { diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index de2f164710..f2f69f4267 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -117,6 +117,14 @@ function installOpencode(): string { return configPath; } +function installGajae(): string { + const spec = INTEGRATION_CLIENTS.gajae; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; +} + function input(overrides: Partial<IntegrationWriteInput> = {}): IntegrationWriteInput { return { clientId: "hermes", @@ -683,11 +691,11 @@ describe("apply", () => { }); test("yaml clients still refuse a sibling edit rather than risk user comments", () => { - const configPath = installHermes(); - expect(applyIntegration(input()).ok).toBe(true); + const configPath = installGajae(); + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); writeFileSync(configPath, `${readFileSync(configPath, "utf8")}unknown_top: added-later\n`); - const result = applyIntegration(input()); + const result = applyIntegration(input({ clientId: "gajae" })); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("conflict"); expect(readFileSync(configPath, "utf8")).toContain("unknown_top: added-later"); @@ -992,6 +1000,40 @@ describe("DSH source preservation", () => { }); }); +describe("Hermes source preservation", () => { + test("preserves defaults, providers, comments, and formatting through refresh and disable", () => { + const configPath = installHermes(); + const original = [ + "# user header", + "model:", + " default: meituan/LongCat-2.0:free", + "providers:", + " commandcode-oauth: # keep provider comment", + " models:", + " - meituan/LongCat-2.0:free", + "", + ].join("\n"); + writeFileSync(configPath, original); + + expect(applyIntegration(input({ clientId: "hermes" })).ok).toBe(true); + const applied = readFileSync(configPath, "utf8"); + expect(applied).toContain("commandcode-oauth:"); + expect(applied).toContain("opencodex:"); + expect(applied).toContain("# keep provider comment"); + expect(applied).toContain("default: meituan/LongCat-2.0:free"); + + expect(disableIntegration(input({ clientId: "hermes" })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + + test("disables a generated Hermes config without leaving its created container", () => { + const configPath = installHermes(); + expect(applyIntegration(input({ clientId: "hermes" })).ok).toBe(true); + expect(disableIntegration(input({ clientId: "hermes" })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(""); + }); +}); + describe("restore", () => { test("undoes an apply back to the exact prior bytes", () => { const configPath = installHermes(); @@ -1017,29 +1059,29 @@ describe("restore", () => { }); test("refuses to replace post-operation edits without confirmation", () => { - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - expect(applyIntegration(input()).ok).toBe(true); - const opId = store.listOperations("hermes")[0]!.opId; + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); + const opId = store.listOperations("gajae")[0]!.opId; writeFileSync(configPath, `${readFileSync(configPath, "utf8")}# later edit\n`); - const refused = restoreIntegration({ ...input(), opId }); + const refused = restoreIntegration({ ...input({ clientId: "gajae" }), opId }); expect(refused.ok).toBe(false); if (!refused.ok) expect(refused.reason).toBe("drift_requires_confirm"); expect(readFileSync(configPath, "utf8")).toContain("# later edit"); }); test("a confirmed drift-restore keeps the replaced version recoverable", () => { - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - expect(applyIntegration(input()).ok).toBe(true); - const opId = store.listOperations("hermes")[0]!.opId; + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); + const opId = store.listOperations("gajae")[0]!.opId; writeFileSync(configPath, `${readFileSync(configPath, "utf8")}# later edit\n`); - const restored = restoreIntegration({ ...input(), opId, confirmDrift: true }); + const restored = restoreIntegration({ ...input({ clientId: "gajae" }), opId, confirmDrift: true }); expect(restored.ok).toBe(true); // The edit we replaced is in the newest snapshot, so nothing was lost. - const newest = store.listOperations("hermes")[0]!; + const newest = store.listOperations("gajae")[0]!; expect(newest.kind).toBe("restore"); const snapshot = store.readSnapshot(newest); expect(snapshot.kind).toBe("stored"); @@ -1047,14 +1089,14 @@ describe("restore", () => { }); test("refuses an operation whose snapshot was collected", () => { - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - expect(applyIntegration(input()).ok).toBe(true); - const row = store.listOperations("hermes")[0]!; + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); + const row = store.listOperations("gajae")[0]!; // Simulate GC having removed the bytes. - rmSync(join(storeRoot, "snapshots", "hermes", row.opId), { force: true }); + rmSync(join(storeRoot, "snapshots", "gajae", row.opId), { force: true }); - const result = restoreIntegration({ ...input(), opId: row.opId }); + const result = restoreIntegration({ ...input({ clientId: "gajae" }), opId: row.opId }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("snapshot_expired"); }); @@ -1073,7 +1115,7 @@ describe("nothing leaks", () => { }); test("a failed record write rolls the file back and says so", () => { - const configPath = installHermes(); + const configPath = installGajae(); const original = "providers: {}\n"; writeFileSync(configPath, original); const io: IntegrationIO = { @@ -1083,7 +1125,7 @@ describe("nothing leaks", () => { dropRecord: clientId => store.dropRecord(clientId), }; - const result = applyIntegration(input({ io })); + const result = applyIntegration(input({ clientId: "gajae", io })); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("write_failed"); @@ -1091,11 +1133,11 @@ describe("nothing leaks", () => { } // The file is back to what it was; no half-applied state survives. expect(readFileSync(configPath, "utf8")).toBe(original); - expect(store.listOperations("hermes")).toHaveLength(0); + expect(store.listOperations("gajae")).toHaveLength(0); }); test("a failed journal append rolls back and leaves no phantom row", () => { - const configPath = installHermes(); + const configPath = installGajae(); const original = "providers: {}\n"; writeFileSync(configPath, original); const io: IntegrationIO = { @@ -1105,17 +1147,17 @@ describe("nothing leaks", () => { dropRecord: clientId => store.dropRecord(clientId), }; - const result = applyIntegration(input({ io })); + const result = applyIntegration(input({ clientId: "gajae", io })); expect(result.ok).toBe(false); expect(readFileSync(configPath, "utf8")).toBe(original); // The row is written last precisely so this cannot leave one behind. - expect(store.listOperations("hermes")).toHaveLength(0); + expect(store.listOperations("gajae")).toHaveLength(0); // And the record it wrote first is gone again. - expect(store.readRecords().hermes).toBeUndefined(); + expect(store.readRecords().gajae).toBeUndefined(); }); test("when compensation itself fails, the result says residual instead of claiming a rollback", () => { - installHermes(); + installGajae(); let writes = 0; const io: IntegrationIO = { ...fileIO(), @@ -1129,10 +1171,10 @@ describe("nothing leaks", () => { putRecord: record => store.putRecord(record), dropRecord: clientId => store.dropRecord(clientId), }; - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - const result = applyIntegration(input({ io })); + const result = applyIntegration(input({ clientId: "gajae", io })); expect(result.ok).toBe(false); if (!result.ok) { expect(result.residual).toBe(true); @@ -1193,10 +1235,10 @@ describe("nothing leaks", () => { test("an empty container the user wrote survives disable", () => { // `providers: {}` is the user's line, not ours. Pruning it because it went // empty would delete something we never owned. - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - expect(applyIntegration(input()).ok).toBe(true); - expect(disableIntegration(input()).ok).toBe(true); + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); + expect(disableIntegration(input({ clientId: "gajae" })).ok).toBe(true); const doc = Bun.YAML.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>; expect(doc).toEqual({ providers: {} }); diff --git a/tests/codex-integration/bearer-admission-routed-provider.test.ts b/tests/codex-integration/bearer-admission-routed-provider.test.ts index e74cd0ce21..5b87318626 100644 --- a/tests/codex-integration/bearer-admission-routed-provider.test.ts +++ b/tests/codex-integration/bearer-admission-routed-provider.test.ts @@ -1,9 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; +import http2 from "node:http2"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { clearComboTargetCooldowns } from "../../src/combos/failover"; import { startServer } from "../../src/server"; +import { noteSubagentModelFailure, resetSubagentModelFallbackStateForTests } from "../../src/codex/subagent-model-fallback"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { acquireNativeMainProfileDrain, getNativeMainProfileRequestCount, @@ -14,6 +18,8 @@ import type { NativeProfileManager } from "../../src/codex/native-profile-manage import type { OcxConfig } from "../../src/types"; import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { resetVisionDescriptionCache } from "../../src/vision"; /** * Issue #2132: bearer admission must not require a stored ChatGPT credential. @@ -34,11 +40,13 @@ const originalFetch = globalThis.fetch; const previousOcxHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousCursorTestToken = process.env.OPENCODEX_CURSOR_TEST_TOKEN; let ocxHome = ""; let codexHome = ""; let routedAuth: Array<string | null> = []; let nativeAuth: Array<string | null> = []; +let nativeAccountIds: Array<string | null> = []; const ADMISSION_SECRET = "ocx_data_2132secret"; const ROUTED_KEY = "sk-routed-provider-key"; @@ -83,7 +91,71 @@ function mixedConfig(): OcxConfig { } as OcxConfig; } +function cursorForwardConfig(baseUrl: string, apiKey?: string): OcxConfig { + return { + port: 0, + hostname: "0.0.0.0", + defaultProvider: "cursorcustom", + providers: { + cursorcustom: { + adapter: "cursor", + baseUrl, + allowPrivateNetwork: true, + authMode: "forward", + ...(apiKey ? { apiKey } : {}), + liveModels: false, + models: ["auto"], + defaultModel: "auto", + }, + }, + apiKeys: [ + { id: "env-key", name: "env_key", key: ADMISSION_SECRET, createdAt: "2026-08-20T00:00:00.000Z" }, + ], + } as OcxConfig; +} + +async function withCursorCaptureServer<T>( + run: (baseUrl: string, capturedAuth: Array<string | null>) => Promise<T>, +): Promise<T> { + const capturedAuth: Array<string | null> = []; + const sessions = new Set<http2.ServerHttp2Session>(); + const server = http2.createServer(); + server.on("session", session => { + sessions.add(session); + session.once("close", () => sessions.delete(session)); + }); + server.on("stream", (stream, headers) => { + const auth = headers.authorization; + capturedAuth.push(typeof auth === "string" ? auth : null); + stream.respond({ + ":status": typeof auth === "string" ? 200 : 401, + "content-type": "application/connect+proto", + }); + stream.end(); + }); + await new Promise<void>((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Cursor capture fixture did not bind"); + try { + return await run(`http://127.0.0.1:${address.port}`, capturedAuth); + } finally { + for (const session of sessions) session.destroy(); + await new Promise<void>(resolve => server.close(() => resolve())); + } +} + beforeEach(() => { + resetVisionDescriptionCache(); + clearComboTargetCooldowns(); + resetSubagentModelFallbackStateForTests(); + delete process.env.OPENCODEX_CURSOR_TEST_TOKEN; ocxHome = mkdtempSync(join(tmpdir(), "ocx-2132-home-")); codexHome = mkdtempSync(join(tmpdir(), "ocx-2132-codex-")); process.env.OPENCODEX_HOME = ocxHome; @@ -91,6 +163,7 @@ beforeEach(() => { delete process.env.OPENCODEX_API_AUTH_TOKEN; routedAuth = []; nativeAuth = []; + nativeAccountIds = []; globalThis.fetch = (async (input, init) => { const raw = input instanceof Request ? input.url : String(input); const url = new URL(raw); @@ -107,6 +180,7 @@ beforeEach(() => { } if (url.hostname === "chatgpt.com" || url.hostname === "api.openai.com") { nativeAuth.push(headers.get("authorization")); + nativeAccountIds.push(headers.get("chatgpt-account-id")); return Response.json({ id: "resp_2132", object: "response", status: "completed", output: [] }); } return originalFetch(input, init); @@ -114,6 +188,12 @@ beforeEach(() => { }); afterEach(() => { + resetVisionDescriptionCache(); + closeRequestHistoryIndex(); + clearComboTargetCooldowns(); + resetSubagentModelFallbackStateForTests(); + if (previousCursorTestToken === undefined) delete process.env.OPENCODEX_CURSOR_TEST_TOKEN; + else process.env.OPENCODEX_CURSOR_TEST_TOKEN = previousCursorTestToken; globalThis.fetch = originalFetch; if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOcxHome; @@ -127,14 +207,60 @@ afterEach(() => { codexHome = ""; }); -async function postResponses(url: string | URL, model: string): Promise<Response> { +async function postResponses( + url: string | URL, + model: string, + authHeaders: HeadersInit = { authorization: `Bearer ${ADMISSION_SECRET}` }, +): Promise<Response> { + const headers = new Headers(authHeaders); + headers.set("content-type", "application/json"); return originalFetch(new URL("/v1/responses", url), { method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${ADMISSION_SECRET}` }, + headers, body: JSON.stringify({ model, input: "hi", stream: false }), }); } +async function postChatCompletions( + url: string | URL, + model: string, + authHeaders: HeadersInit, +): Promise<Response> { + const headers = new Headers(authHeaders); + headers.set("content-type", "application/json"); + return originalFetch(new URL("/v1/chat/completions", url), { + method: "POST", + headers, + body: JSON.stringify({ model, messages: [{ role: "user", content: "hi" }], stream: false }), + }); +} + +async function postClaudeMessages( + url: string | URL, + model: string, +): Promise<Response> { + return originalFetch(new URL("/v1/messages", url), { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": ADMISSION_SECRET, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + stream: false, + }), + }); +} + +async function startOwnedServer(): Promise<ReturnType<typeof startServer>> { + const server = startServer(0, { inspectNativeCodexOwnership }); + await waitForNativeMainStartupGate(); + return server; +} + describe("#2132 bearer admission does not require a ChatGPT credential for routed providers", () => { test("a key-authenticated route is served with no stored main credential", async () => { saveConfig(mixedConfig()); @@ -198,6 +324,698 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route }); }); +describe("bearer admission is not reused as a Cursor upstream credential", () => { + test("a bearer admission secret is stripped before Cursor token fallback", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const response = await postResponses(server.url, "cursorcustom/auto"); + // The runTurn adapter reports its pre-dispatch failure in a Responses terminal. + expect(await response.json()).toMatchObject({ status: "failed" }); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test("dedicated admission preserves a separate Cursor bearer", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + await postResponses(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + }); + expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); + } finally { + await server.stop(true); + } + }); + }); + + test("bearer admission still uses a configured Cursor credential", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl, "cursor-configured-token")); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + await postResponses(server.url, "cursorcustom/auto"); + expect(capturedAuth).toEqual(["Bearer cursor-configured-token"]); + } finally { + await server.stop(true); + } + }); + }); + + test("dedicated admission refuses another proxy secret as Cursor auth", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const response = await postResponses(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${ADMISSION_SECRET}`, + }); + expect(response.status).toBe(401); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test("configured Cursor auth still wins when dedicated admission carries a proxy bearer", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl, "cursor-configured-token")); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + await postResponses(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${ADMISSION_SECRET}`, + }); + expect(capturedAuth).toEqual(["Bearer cursor-configured-token"]); + } finally { + await server.stop(true); + } + }); + }); + + test("Chat dedicated admission preserves its separate Cursor bearer over stored main auth", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + await postChatCompletions(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + }); + expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); + } finally { + await server.stop(true); + } + }); + }); + + test.each(["owned", "fenced"])("Chat Cursor keeps stored vision auth off its primary wire (%s)", async ownership => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.providers.cursorcustom!.noVisionModels = ["auto"]; + config.providers.openai = { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "direct", + }; + // Keep this auth fixture independent of the legacy sidecar model migration. + config.visionSidecar = { enabled: true, backend: "openai", model: "gpt-5.6-luna" }; + saveConfig(config); + const stored = fakeChatGptJwt({ chatgpt_account_id: "stored_main_acc", exp: Math.floor(Date.now() / 1000) + 3600 }); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: stored, account_id: "stored_main_acc" }, + })); + const sidecar: Array<{ authorization: string | null; account: string | null; claimed: boolean }> = []; + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "chatgpt.com") { + const headers = new Headers(input instanceof Request ? input.headers : init?.headers); + sidecar.push({ authorization: headers.get("authorization"), account: headers.get("chatgpt-account-id"), + claimed: getNativeMainProfileRequestCount() > 0 }); + return new Response(`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "A red square." })}\n\ndata: [DONE]\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + const server = ownership === "owned" ? await startOwnedServer() : startServer(0, { + inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "fixture owned by another service" }), + }); + try { + if (ownership === "fenced") expect(await waitForNativeMainStartupGate()).toMatchObject({ status: "blocked" }); + const response = await originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token" }, + body: JSON.stringify({ model: "cursorcustom/auto", stream: false, messages: [{ role: "user", content: [ + { type: "text", text: "Describe this image" }, + { type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM=" } }, + ] }] }), + }); + await response.text(); + // The capture-only Cursor fixture ends without a completion frame. + expect(response.status).toBe(502); + expect(sidecar).toEqual(ownership === "owned" + ? [{ authorization: `Bearer ${stored}`, account: "stored_main_acc", claimed: true }] : []); + expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); + } finally { + await server.stop(true); + } + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + }); + + test("Chat never falls back from missing Cursor auth to stored main auth", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + }); + expect(response.status).not.toBe(200); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test("Chat bearer admission is stripped before Cursor token fallback", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "cursorcustom/auto", { + authorization: `Bearer ${ADMISSION_SECRET}`, + }); + expect(response.status).not.toBe(200); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test.each(["Responses", "Chat"])("%s keeps an explicit OpenAI pair off an unchanged Cursor route", async surface => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + const server = await startOwnedServer(); + try { + const headers = { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "caller-openai" })}`, + "chatgpt-account-id": "caller-openai", + }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "cursorcustom/auto", headers) + : await postResponses(server.url, "cursorcustom/auto", headers); + if (surface === "Responses") { + expect(await response.json()).toMatchObject({ status: "failed" }); + } else { + expect(response.status).not.toBe(200); + await response.text(); + } + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test.each(["Responses", "Chat"])("%s never treats a ChatGPT-claimed or combined bearer as a Cursor token", async surface => { + const chatGptJwt = fakeChatGptJwt({ chatgpt_account_id: "caller-openai" }); + const cases: Array<Record<string, string>> = [ + // A ChatGPT JWT without the matching account header is still the ChatGPT domain. + { authorization: `Bearer ${chatGptJwt}` }, + // A mismatched explicit account does not reclassify the token. + { authorization: `Bearer ${chatGptJwt}`, "chatgpt-account-id": "other-account" }, + // A combined value is not a single Cursor token. + { authorization: `Bearer ${chatGptJwt}, Bearer other` }, + // Conflicting ChatGPT markers are ChatGPT-marked but untrustworthy, not foreign-allowed. + { authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "caller-openai", "https://api.openai.com/auth": { chatgpt_account_id: "other-claim" } })}` }, + // A malformed ChatGPT marker is still ChatGPT-marked. + { authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: 123 })}` }, + // A blank account id is not a usable id. + { authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: " " })}` }, + // The reserved namespace is a marker by its presence, whatever shape it carries: + // a primitive, null, an array, or an object without the claim all stay ChatGPT-marked. + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": "not-an-object" })}` }, + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": null })}` }, + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": [] })}` }, + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": {} })}` }, + { authorization: `Bearer ${fakeChatGptJwt({ "https://api.openai.com/auth": { user_id: "u_1" } })}` }, + ]; + for (const extra of cases) { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + const server = await startOwnedServer(); + try { + const headers = { "x-opencodex-api-key": ADMISSION_SECRET, ...extra }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "cursorcustom/auto", headers) + : await postResponses(server.url, "cursorcustom/auto", headers); + if (surface === "Responses") { + expect(await response.json()).toMatchObject({ status: "failed" }); + } else { + expect(response.status).not.toBe(200); + await response.text(); + } + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + } + }); + + test.each(["Responses", "Chat"])("%s keeps an unmarked JWT as the Cursor credential", async surface => { + // Neither a generic organizations claim nor a payload that is not a JSON object is + // ChatGPT-domain evidence: the legacy keyless Cursor contract keeps forwarding such a + // bearer (the account header, a ChatGPT-only header, is still dropped). The primitive + // payload also proves the domain inspector stays total instead of throwing. + const orgJwt = fakeChatGptJwt({ organizations: [{ id: "org-foreign" }] }); + const primitivePayloadJwt = `eyJhbGciOiJub25lIn0.${Buffer.from("true").toString("base64url")}.fakesig`; + for (const [bearer, extra] of [ + [orgJwt, {}], + [orgJwt, { "chatgpt-account-id": "org-foreign" }], + [primitivePayloadJwt, {}], + ] as Array<[string, Record<string, string>]>) { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + const server = await startOwnedServer(); + try { + const headers = { "x-opencodex-api-key": ADMISSION_SECRET, authorization: `Bearer ${bearer}`, ...extra }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "cursorcustom/auto", headers) + : await postResponses(server.url, "cursorcustom/auto", headers); + await response.text(); + expect(capturedAuth).toEqual([`Bearer ${bearer}`]); + } finally { + await server.stop(true); + } + }); + } + }); + + test.each([false, true])("Chat combos never assign caller auth to a Cursor target (OpenAI pair: %s)", async openAiPair => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.combos = { + free: { strategy: "failover", targets: [{ provider: "cursorcustom", model: "auto" }] }, + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "combo/free", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${openAiPair ? fakeChatGptJwt({ chatgpt_account_id: "caller-openai" }) : "cursor-upstream-token"}`, + ...(openAiPair ? { "chatgpt-account-id": "caller-openai" } : {}), + }); + expect(response.status).not.toBe(200); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + test.each([false, true])("Responses combos never assign caller auth to a Cursor target (OpenAI pair: %s)", async openAiPair => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.combos = { + free: { strategy: "failover", targets: [{ provider: "cursorcustom", model: "auto" }] }, + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const response = await postResponses(server.url, "combo/free", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${openAiPair ? fakeChatGptJwt({ chatgpt_account_id: "caller-openai" }) : "cursor-upstream-token"}`, + ...(openAiPair ? { "chatgpt-account-id": "caller-openai" } : {}), + }); + expect(response.status).not.toBe(200); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); + + // A present reserved namespace carries the marker whatever its shape, so every broken + // shape is untrustworthy rather than foreign, and is denied restore. + const brokenNamespaces: Record<string, unknown> = { + "namespace-empty-object": {}, + "namespace-primitive": "not-an-object", + "namespace-null": null, + "namespace-array": [], + "namespace-without-claim": { user_id: "u_1" }, + }; + + const callerBearers: Record<string, string> = { + "opaque-with-account": "opaque-caller-direct-token", + // The namespaced marker alone is a valid ChatGPT-domain claim. + "ns-claim-only": fakeChatGptJwt({ "https://api.openai.com/auth": { chatgpt_account_id: "caller-openai" } }), + // A generic organizations claim is not ChatGPT-domain evidence. + "org-only-jwt": fakeChatGptJwt({ organizations: [{ id: "org-foreign" }] }), + "conflicting-claims": fakeChatGptJwt({ chatgpt_account_id: "caller-openai", "https://api.openai.com/auth": { chatgpt_account_id: "other-claim" } }), + "blank-account-id": fakeChatGptJwt({ chatgpt_account_id: " " }), + "numeric-account-id": fakeChatGptJwt({ chatgpt_account_id: 123 }), + ...Object.fromEntries(Object.entries(brokenNamespaces) + .map(([name, shape]) => [name, fakeChatGptJwt({ "https://api.openai.com/auth": shape })])), + }; + + test("a bearer-admitted Responses combo still substitutes stored main on its final Direct target", async () => { + const config = mixedConfig(); + config.combos = { + native: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + const stored = liveJwt(); + saveConfig(config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postResponses(server.url, "combo/native"); + expect(response.status).toBe(200); + expect(nativeAuth).toEqual([`Bearer ${stored}`]); + expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); + } finally { + await server.stop(true); + } + }); + + test.each(["jwt-only", "jwt-with-account", "ns-claim-only", "opaque-with-account", "jwt-mismatched-account", "org-only-jwt", "conflicting-claims", "namespace-empty-object", "namespace-primitive", "namespace-null", "namespace-array", "namespace-without-claim", "blank-account-id", "numeric-account-id"])( + "a dedicated-admission Responses combo scopes caller auth (%s) to its final Direct target", + async form => { + const config = mixedConfig(); + config.combos = { + native: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + const callerBearer = callerBearers[form] ?? fakeChatGptJwt({ chatgpt_account_id: "caller-openai" }); + const accountHeader = form === "jwt-only" || form === "ns-claim-only" ? undefined + : form === "jwt-mismatched-account" ? "other-account" + : form === "org-only-jwt" ? "org-foreign" + : "caller-openai"; + + const server = await startOwnedServer(); + try { + const response = await postResponses(server.url, "combo/native", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: `Bearer ${callerBearer}`, + ...(accountHeader ? { "chatgpt-account-id": accountHeader } : {}), + }); + const body = await response.json() as { status?: string }; + if (form === "jwt-only" || form === "jwt-with-account" || form === "ns-claim-only") { + expect(response.status).toBe(200); + expect(body).toMatchObject({ status: "completed" }); + expect(nativeAuth).toEqual([`Bearer ${callerBearer}`]); + expect(nativeAccountIds).toEqual(["caller-openai"]); + } else { + expect(response.status >= 400 || body.status === "failed").toBe(true); + expect(nativeAuth).toEqual([]); + expect(nativeAccountIds).toEqual([]); + } + } finally { + await server.stop(true); + } + }, + ); + + test("Chat thread-spawn fallback never carries the provisional Cursor bearer into Direct", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + defaultModel: "gpt-5.6-luna", + }; + config.subagentModelFallback = ["gpt-5.6-luna"]; + const stored = liveJwt(); + saveConfig(config); + noteSubagentModelFailure("cursorcustom/auto", "429", config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + "x-openai-subagent": "collab_spawn", + }); + expect(capturedAuth).toEqual([]); + expect(nativeAuth).toEqual([]); + expect(response.status).toBe(401); + } finally { + await server.stop(true); + } + }); + }); + + test("Responses thread-spawn fallback never carries the provisional Cursor bearer into Direct", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + defaultModel: "gpt-5.6-luna", + }; + config.subagentModelFallback = ["gpt-5.6-luna"]; + const stored = liveJwt(); + saveConfig(config); + noteSubagentModelFailure("cursorcustom/auto", "429", config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postResponses(server.url, "cursorcustom/auto", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + "x-openai-subagent": "collab_spawn", + }); + expect(capturedAuth).toEqual([]); + expect(nativeAuth).toEqual([]); + expect(response.status).toBe(401); + } finally { + await server.stop(true); + } + }); + }); + + test("Chat thread marker without a route rewrite preserves the caller's native credential", async () => { + saveConfig(mixedConfig()); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = await startOwnedServer(); + try { + const response = await postChatCompletions(server.url, "gpt-5.6-luna", { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer caller-native-token", + "x-openai-subagent": "collab_spawn", + }); + expect(response.status).toBe(200); + expect(nativeAuth).toEqual(["Bearer caller-native-token"]); + expect(routedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + for (const surface of ["Chat", "Responses"] as const) { + test(`${surface} policy routes never assign one provisional bearer to a selected provider`, async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.routingProfiles = { + cursor: { candidates: [{ provider: "cursorcustom", model: "auto" }] }, + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const authHeaders = { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "policy/cursor", authHeaders) + : await postResponses(server.url, "policy/cursor", authHeaders); + const status = response.status; + await response.arrayBuffer(); + expect(capturedAuth).toEqual([]); + // Chat returns a pre-stream provider failure; Responses may encode the same terminal + // failure inside its normal response envelope. The wire observation is authoritative. + expect([200, 502]).toContain(status); + } finally { + await server.stop(true); + } + }); + }); + } + + test("Claude policy routing preserves trusted main auth only for its final Direct target", async () => { + const config = mixedConfig(); + config.routingProfiles = { + native: { candidates: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + const stored = liveJwt(); + saveConfig(config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postClaudeMessages(server.url, "policy/native"); + expect(response.status).toBe(200); + expect(nativeAuth).toEqual([`Bearer ${stored}`]); + } finally { + await server.stop(true); + } + }); + + test("Claude Combo routing reconstructs trusted main auth for its final Direct target", async () => { + const config = mixedConfig(); + config.combos = { + native: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + const stored = liveJwt(); + saveConfig(config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = await startOwnedServer(); + try { + const response = await postClaudeMessages(server.url, "combo/native"); + expect(response.status).toBe(200); + expect(nativeAuth).toEqual([`Bearer ${stored}`]); + } finally { + await server.stop(true); + } + }); + + test("Claude Combo routing cannot reconstruct main auth when the profile claim was fenced", async () => { + const config = mixedConfig(); + config.combos = { + native: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-5.6-luna" }] }, + }; + saveConfig(config); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: liveJwt(), account_id: "stored_main_acc" } }), + ); + + const server = startServer(0, { + inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "fixture-owned by another service" }), + }); + try { + expect(await waitForNativeMainStartupGate()).toMatchObject({ + status: "blocked", + reason: "foreign-ownership", + }); + const response = await postClaudeMessages(server.url, "combo/native"); + expect(response.status).toBe(401); + expect(nativeAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + for (const surface of ["Chat", "Responses"] as const) { + test(`${surface} shadow-call rewrites never carry the source route bearer into Cursor`, async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + defaultModel: "gpt-5.6-luna", + }; + config.shadowCallIntercept = { + enabled: true, + model: "cursorcustom/auto", + sourceModels: ["gpt-5.6-luna"], + }; + saveConfig(config); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0, { inspectNativeCodexOwnership }); + try { + const authHeaders = { + "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer source-route-token", + }; + const response = surface === "Chat" + ? await postChatCompletions(server.url, "gpt-5.6-luna", authHeaders) + : await postResponses(server.url, "gpt-5.6-luna", authHeaders); + const status = response.status; + await response.arrayBuffer(); + expect(capturedAuth).toEqual([]); + expect([200, 401, 502]).toContain(status); + } finally { + await server.stop(true); + } + }); + }); + } + + test("Claude replay never treats stored main auth as a Cursor credential", async () => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + saveConfig(cursorForwardConfig(baseUrl)); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + })); + + const server = await startOwnedServer(); + try { + const response = await postClaudeMessages(server.url, "cursorcustom/auto"); + expect(response.status).toBe(502); + expect(capturedAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + }); +}); + /** * The predicate above must be keyed on TRANSPORT, not on the provider's name. * diff --git a/tests/codex-integration/catalog-hub-context-window.test.ts b/tests/codex-integration/catalog-hub-context-window.test.ts new file mode 100644 index 0000000000..fb44a64dd1 --- /dev/null +++ b/tests/codex-integration/catalog-hub-context-window.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/provider-fetch"; + +/** + * Regression coverage for #4032 (chained clients / provider hub). + * + * A hub that re-serves an upstream catalog reports the per-model window under + * `capabilities.context_length`. `catalogHintsFromModelsApiItem` already read that + * same record for `max_output_tokens`, but never for the context window, so every + * routed row fell through to the 128k compatibility floor in parsing.ts while local + * forward rows kept their real values. + * + * The capability field is appended AFTER the recognized metadata/limits fields and + * after the Copilot-specific `capabilities.limits.max_context_window_tokens`, so no + * provider that already resolved a window changes behaviour. + */ + +const HUB_MODELS_ITEM = { + id: "anthropic/claude-opus-5", + object: "model" as const, + owned_by: "opencodex-hub", + capabilities: { + context_length: 922000, + max_output_tokens: 64000, + }, +}; + +describe("provider-hub capabilities.context_length (#4032)", () => { + test("absorbs capabilities.context_length from a hub-shaped /v1/models item", () => { + const hints = catalogHintsFromModelsApiItem("hub", HUB_MODELS_ITEM); + expect(hints.contextWindow).toBe(922000); + }); + + test("the same record still yields max_output_tokens (asymmetry is gone)", () => { + const hints = catalogHintsFromModelsApiItem("hub", HUB_MODELS_ITEM); + expect(hints.maxOutputTokens).toBe(64000); + }); + + test("reads the capability record from metadata.capabilities too", () => { + const hints = catalogHintsFromModelsApiItem("hub", { + id: "meta-shaped", + metadata: { capabilities: { context_length: 400000 } }, + }); + expect(hints.contextWindow).toBe(400000); + }); + + test("a recognized context field still wins over the capability record", () => { + // Contested on purpose: the capability field is appended last so no provider + // already supplying a recognized field changes behaviour. + const hints = catalogHintsFromModelsApiItem("hub", { + id: "both", + context_length: 32768, + capabilities: { context_length: 922000 }, + }); + expect(hints.contextWindow).toBe(32768); + }); + + test("Copilot's max_context_window_tokens still wins over the capability record", () => { + const hints = catalogHintsFromModelsApiItem("copilot", { + id: "gpt-5.6-sol", + capabilities: { context_length: 922000, limits: { max_context_window_tokens: 128000 } }, + }); + expect(hints.contextWindow).toBe(128000); + }); + + test("a non-positive or non-integer capability window is ignored", () => { + expect(catalogHintsFromModelsApiItem("hub", { id: "zero", capabilities: { context_length: 0 } }).contextWindow).toBeUndefined(); + expect(catalogHintsFromModelsApiItem("hub", { id: "neg", capabilities: { context_length: -1 } }).contextWindow).toBeUndefined(); + expect(catalogHintsFromModelsApiItem("hub", { id: "str", capabilities: { context_length: "922000" } }).contextWindow).toBeUndefined(); + }); +}); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 04eba75533..fea1be5032 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -24,19 +24,28 @@ import { getCodexAccountCredential, listCodexAccountIds, readCodexAccountRecord, + removeCodexAccountCredential, saveCodexAccountCredential, } from "../../src/codex/account-store"; import * as accountStoreModule from "../../src/codex/account-store"; import * as reserveAvailabilityModule from "../../src/codex/reserve-availability"; import { getMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { openManualResetCreditOperation } from "../../src/codex/reset-credit-operation-ledger"; +import { quotaRecoveryRecordForTests, resetQuotaRecoveryForTests } from "../../src/codex/quota-401-recovery"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { clearCodexUpstreamHealth, + clearCodexUpstreamHealthForAccount, + getCodexQuotaHealthSnapshot, + claimManualResetCooldowns, + settleManualResetCooldown, clearThreadAccountMap, getCodexUpstreamHealth, recordCodexUpstreamOutcome, resetCodexRoutingForManualSelection, resolveCodexAccountForThread, } from "../../src/codex/routing"; +import { pinnedCodexAccountId, setCodexAccountPin } from "../../src/codex/account-priority"; import { clearPoolRotationState } from "../../src/codex/pool-rotation"; import { clearCodexWebSocketRegistry, @@ -47,6 +56,8 @@ import type { OcxConfig } from "../../src/types"; import type { WsData } from "../../src/server/ws-bridge"; import { handleNativeProfileAPI } from "../../src/codex/native-profile-api"; import type { NativeProfileManager } from "../../src/codex/native-profile-manager"; +import { getMainPolicyQuota } from "../../src/codex/quota"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; import { reconcileCodexPlansFromTokens, resetJwtPlanNotesForTests } from "../../src/codex/plan-from-token"; import { @@ -542,6 +553,7 @@ beforeEach(() => { clearCodexWebSocketRegistry(); resetMainCodexAccountIdentityTrackingForTests(); resetJwtPlanNotesForTests(); + resetQuotaRecoveryForTests(); }); afterEach(async () => { @@ -557,6 +569,7 @@ afterEach(async () => { clearPoolRotationState(); clearCodexWebSocketRegistry(); globalThis.fetch = previousFetch; + resetQuotaRecoveryForTests(); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -971,13 +984,27 @@ describe("codex-auth API", () => { } }); - test("busy pool-quota probe maps reset-credit refresh to 503 server_busy with Retry-After 1", async () => { + test("busy usage observation preserves confirmed reset success without a retry directive", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "quota-reset-busy", email: "busy@example.test" }); + // Adapted from #3995 (e172453052bf7bbc4a0ae5aa24592982c0c64b15). + recordCodexUpstreamOutcome(config, "quota-reset-busy", 429, { + now: Date.now(), resetAt: Date.now() + 3_600_000, modelId: "gpt-5.6-sol", fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("quota-reset-busy", "shared"); + expect(cooldown).not.toBeNull(); const cleanup = seedCodexAuthAdmissionForTests({ quotaFlights: 16 }); - globalThis.fetch = (async (input: RequestInfo | URL) => String(input).includes("/consume") - ? Response.json({ code: "reset" }) - : previousFetch(input)) as typeof fetch; + let consumeCalls = 0; let usageCalls = 0; + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); urls.push(url); + if (url === "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume") { + consumeCalls += 1; + return Response.json({ code: "reset" }); + } + if (url === "https://chatgpt.com/backend-api/wham/usage") usageCalls += 1; + throw new Error("unexpected mock URL"); + }) as typeof fetch; try { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", @@ -985,9 +1012,16 @@ describe("codex-auth API", () => { body: JSON.stringify({ accountId: "quota-reset-busy" }), }); const response = await handleCodexAuthAPI(req, new URL(req.url), config); - expect(response?.status).toBe(503); - expect(response?.headers.get("Retry-After")).toBe("1"); - expect(await response?.json()).toMatchObject({ code: "server_busy" }); + expect(response?.status).toBe(200); + expect(response?.headers.get("Retry-After")).toBeNull(); + expect(await response?.json()).toEqual({ code: "reset" }); + expect(consumeCalls).toBe(1); + expect(usageCalls).toBe(0); + expect(urls).toEqual(["https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"]); + expect(getCodexQuotaHealthSnapshot("quota-reset-busy", "shared")).toEqual(cooldown); + const claims = claimManualResetCooldowns(config, "quota-reset-busy"); + try { expect(claims).toHaveLength(1); } + finally { for (const claim of claims) settleManualResetCooldown(config, claim, false); } } finally { cleanup(); } @@ -3047,6 +3081,42 @@ describe("codex-auth API", () => { } }); + // Adapted from luvs01's #3995, e172453052bf7bbc4a0ae5aa24592982c0c64b15. + test.each(["reset", "already_redeemed"])("cold main %s returns fresh WHAM credits without a prior lookup", async code => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "cold-main-reset-token", account_id: "cold-main-reset-account" }, + })); + // Intentionally no listing, reconciliation, writer observation or quota seed. + let consumeCalls = 0; let usageCalls = 0; + const urls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); urls.push(url); + if (url === "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume") { + consumeCalls += 1; + return Response.json({ code, remaining: 99 }); + } + if (url === "https://chatgpt.com/backend-api/wham/usage") { + usageCalls += 1; + return Response.json({ plan_type: "team", rate_limit: { secondary_window: { used_percent: 12 } }, + rate_limit_reset_credits: { available_count: 1 } }); + } + throw new Error("unexpected mock URL"); + }) as typeof fetch; + try { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ code, remaining: 1 }); + expect(consumeCalls).toBe(1); expect(usageCalls).toBe(1); + expect(urls).toEqual(["https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + "https://chatgpt.com/backend-api/wham/usage"]); + } finally { globalThis.fetch = originalFetch; } + }); + test("reset-credit consume returns remaining from fresh main WHAM credits", async () => { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: "main-reset-ok", account_id: "acct-main-reset-ok" }, @@ -3280,12 +3350,61 @@ describe("codex-auth API", () => { config, ); expect(retried!.status).toBe(200); + const replayed = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-alias", operationId: OTHER_OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(replayed!.status).toBe(200); + expect(await replayed!.json()).toEqual({ code: "reset", replayed: true }); expect(upstream.redeemRequestIds).toEqual([OP_ID, OP_ID]); } finally { globalThis.fetch = previousFetch; } }); + for (const failure of ["throw", "non-2xx", "unknown-code"] as const) { + test(`an alias marks a pending canonical operation ambiguous after ${failure}`, async () => { + const config = makeConfig(); + const accountId = "pool-pending-alias"; + const chatgptAccountId = "physical-pending-alias"; + seedPoolAccount(config, { id: accountId, email: "pending@example.test", chatgptAccountId }); + expect(openManualResetCreditOperation({ accountId, chatgptAccountId, operationId: OP_ID })) + .toMatchObject({ kind: "execute", operationId: OP_ID }); + const readOperation = () => { + const database = new Database(join(TEST_DIR, "config-mutation.sqlite"), { readonly: true }); + try { + return database.query<{ account_key: string; operation_id: string; state: string; code: string | null }, []>( + "SELECT account_key, operation_id, state, code FROM reset_credit_operations WHERE operation_kind = 'manual'", + ).get(); + } finally { + database.close(); + } + }; + const pending = readOperation(); + expect(pending).toMatchObject({ operation_id: OP_ID, state: "pending", code: null }); + const upstream = stubUpstream(() => { + if (failure === "throw") throw new Error("fixture consume failure"); + return failure === "non-2xx" + ? new Response("fixture unavailable", { status: 503 }) + : Response.json({ code: "weird" }); + }); + try { + const response = await handleCodexAuthAPI( + consumeRequest({ accountId, operationId: OTHER_OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(response!.status).toBe(failure === "throw" ? 500 : failure === "non-2xx" ? 503 : 200); + expect(readOperation()).toEqual({ ...pending!, state: "ambiguous" }); + expect(upstream.redeemRequestIds).toEqual([OP_ID]); + expect(getCodexAccountCredential(accountId)?.chatgptAccountId).toBe(chatgptAccountId); + } finally { + globalThis.fetch = previousFetch; + } + }); + } + test("an unknown upstream code stays ambiguous instead of settling the ledger", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-weird", email: "weird@example.test" }); @@ -5404,3 +5523,539 @@ describe("codex-auth helpers", () => { expect(isAccountNeedsReauth(id)).toBe(false); }); }); + + +describe("manual reset cooldown recovery (#3973)", () => { + const USAGE = "https://chatgpt.com/backend-api/wham/usage"; + const CONSUME = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"; + const OP = "be810596-310c-4c21-95cb-e47f984398a0"; + function gate() { + let release!: () => void; + const promise = new Promise<void>(resolve => { release = resolve; }); + return { promise, release }; + } + function usage(percent = 12) { + return { plan_type: "team", rate_limit: { secondary_window: { used_percent: percent } }, + rate_limit_reset_credits: { available_count: 2 } }; + } + function setup() { + const config = makeConfig({ activeCodexAccountId: "manual-a", accountPoolStrategy: "fill-first" }); + seedPoolAccount(config, { id: "manual-a", email: "manual@example.test", plan: "team" }); + setCodexAccountPin(config, "manual-a"); + cool(config, "manual-a"); + return config; + } + function cool(config: OcxConfig, id: string, modelId = "gpt-5.6-sol", now = Date.now()) { + recordCodexUpstreamOutcome(config, id, 429, { now, resetAt: now + 3_600_000, modelId, fixedAccount: true }); + } + function consume(config: OcxConfig, id = "manual-a", operationId = OP) { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: id, operationId }), + }); + return handleCodexAuthAPI(req, new URL(req.url), config); + } + function mock(consumeResponse: () => Response | Promise<Response>, usageResponse: () => Response | Promise<Response>) { + const urls: string[] = []; + globalThis.fetch = (async input => { + const url = String(input); + urls.push(url); + if (url === CONSUME) return consumeResponse(); + if (url === USAGE) return usageResponse(); + throw new Error("unexpected mock URL"); + }) as typeof fetch; + return urls; + } + + test.each(["reset", "already_redeemed", "nothing_to_reset", "no_credit", "unknown"])( + "only a new reset recovers, preserving pin/selection and other scopes: %s", async code => { + const config = setup(); + cool(config, "manual-a", "gpt-5.3-codex-spark"); + cool(config, "manual-a", "gpt-reserve"); + const spark = getCodexQuotaHealthSnapshot("manual-a", "spark"); + const reserve = getCodexQuotaHealthSnapshot("manual-a", "reserve"); + const urls = mock(() => Response.json({ code }), () => Response.json(usage())); + const result = await consume(config); + expect(result?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared") === null).toBe(code === "reset"); + expect(getCodexQuotaHealthSnapshot("manual-a", "spark")).toEqual(spark); + expect(getCodexQuotaHealthSnapshot("manual-a", "reserve")).toEqual(reserve); + expect(config.activeCodexAccountId).toBe("manual-a"); + expect(pinnedCodexAccountId(config)).toBe("manual-a"); + expect(urls).toEqual(code === "reset" || code === "already_redeemed" ? [CONSUME, USAGE] : [CONSUME]); + if (code === "reset") { + cool(config, "manual-a"); + const replay = await consume(config); + expect(await replay?.json()).toEqual({ code: "reset", replayed: true }); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).not.toBeNull(); + expect(urls).toEqual([CONSUME, USAGE]); + } + }, + ); + + test.each(["credits-only", "exhausted", "short-exhausted", "tertiary-only", "empty", "non-2xx", "malformed", "timeout"])( + "confirmed reset stays successful but incomplete/failed observation retains cooldown: %s", async kind => { + const config = setup(); + const urls = mock(() => Response.json({ code: "reset" }), () => { + if (kind === "timeout") throw new DOMException("fixture", "TimeoutError"); + if (kind === "non-2xx") return new Response("fixture", { status: 503 }); + if (kind === "malformed") return new Response("not-json"); + if (kind === "empty") return Response.json({}); + if (kind === "credits-only") return Response.json({ rate_limit_reset_credits: { available_count: 2 } }); + if (kind === "tertiary-only") return Response.json({ plan_type: "team", rate_limit: { tertiary_window: { used_percent: 5 } } }); + if (kind === "short-exhausted") return Response.json({ ...usage(), rate_limit: { + primary_window: { used_percent: 100, limit_window_seconds: 18_000 }, secondary_window: { used_percent: 12 }, + } }); + return Response.json(usage(100)); + }); + expect((await consume(config))?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).not.toBeNull(); + const nextClaims = claimManualResetCooldowns(config, "manual-a"); + expect(nextClaims).toHaveLength(1); + for (const claim of nextClaims) settleManualResetCooldown(config, claim, false); + expect(await (await consume(config))?.json()).toEqual({ code: "reset", replayed: true }); + expect(urls).toEqual([CONSUME, USAGE]); + }, + ); + + test("recovery never follows a physical-account match to another local alias", async () => { + const config = setup(); + seedPoolAccount(config, { id: "manual-alias", email: "alias@example.test", plan: "team", chatgptAccountId: "acct-manual-a" }); + cool(config, "manual-alias"); + const untouched = getCodexQuotaHealthSnapshot("manual-alias", "shared"); + const urls = mock(() => Response.json({ code: "reset" }), () => Response.json(usage())); + expect((await consume(config))?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); + expect(getCodexQuotaHealthSnapshot("manual-alias", "shared")).toEqual(untouched); + expect(urls).toEqual([CONSUME, USAGE]); + }); + + test.each(["team", "go", "free"])("monthly governing usage can recover %s", async plan => { + const config = setup(); + const urls = mock(() => Response.json({ code: "reset" }), () => Response.json({ plan_type: plan, + rate_limit: { primary_window: { used_percent: 4, limit_window_seconds: 2_628_000 } }, + })); + expect((await consume(config))?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); + expect(urls).toEqual([CONSUME, USAGE]); + }); + + test.each(["throw", "non-2xx", "unknown"])("ambiguous consume releases only its own cooldown claim: %s", async failure => { + const config = setup(); + const urls = mock(() => { + if (failure === "throw") throw new Error("fixture"); + return failure === "non-2xx" ? new Response("fixture", { status: 503 }) : Response.json({ code: "unknown" }); + }, () => Response.json(usage())); + const response = await consume(config); + expect(response?.status).toBe(failure === "throw" ? 500 : failure === "non-2xx" ? 503 : 200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).not.toBeNull(); + const claims = claimManualResetCooldowns(config, "manual-a"); + expect(claims).toHaveLength(1); + for (const claim of claims) settleManualResetCooldown(config, claim, false); + expect(urls).toEqual([CONSUME]); + }); + + test("post-reset 401 refresh carries the successful replay's dispatch and credential proof", async () => { + const config = setup(); const generation = readCodexAccountRecord("manual-a")!.generation; + const urls: string[] = []; let reads = 0; + globalThis.fetch = (async input => { + const url = String(input); urls.push(url); + if (url === CONSUME) return Response.json({ code: "reset" }); + if (url === USAGE) return ++reads === 1 ? new Response("{}", { status: 401 }) : Response.json(usage()); + if (url === "https://auth.openai.com/oauth/token") return Response.json({ + access_token: "refreshed-access", refresh_token: "refreshed-refresh", expires_in: 3600, + }); + throw new Error("unexpected mock URL"); + }) as typeof fetch; + expect((await consume(config))?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); + expect(readCodexAccountRecord("manual-a")!.generation).toBe(generation + 1); + expect(urls).toEqual([CONSUME, USAGE, "https://auth.openai.com/oauth/token", USAGE]); + }); + + test("same-tick external G+1 adopted by 401 replay cannot settle manual recovery", async () => { + const now = Date.now(); const clock = spyOn(Date, "now").mockReturnValue(now); + const firstUsage = gate(); const release401 = gate(); + let pending: ReturnType<typeof consume> | undefined; + const forceRefresh = accountStoreModule.forceRefreshCodexPoolToken; + let observedProvenance: string | undefined; + const refreshSpy = spyOn(accountStoreModule, "forceRefreshCodexPoolToken").mockImplementation(async (id, options) => { + const result = await forceRefresh(id, options); + observedProvenance = result.provenance; + return result; + }); + try { + expect(quotaRecoveryRecordForTests("manual-a")).toBeUndefined(); + const config = setup(); + const original = getCodexAccountCredential("manual-a")!; + // Establish a non-undefined replacement stamp before the manual claim. + saveCodexAccountCredential("manual-a", original); + const before = readCodexAccountRecord("manual-a")!; + expect(before.replacedAt).toBe(now); + let reads = 0; + const urls = mock(() => Response.json({ code: "reset" }), async () => { + if (++reads === 1) { firstUsage.release(); await release401.promise; return new Response("{}", { status: 401 }); } + return Response.json(usage()); + }); + pending = consume(config); + await firstUsage.promise; + saveCodexAccountCredential("manual-a", { ...original, accessToken: "external-access", refreshToken: "external-refresh" }); + const replacement = readCodexAccountRecord("manual-a")!; + expect(replacement.generation).toBe(before.generation + 1); + expect(replacement.replacedAt).toBe(before.replacedAt); + release401.release(); + expect(await (await pending)?.json()).toEqual({ code: "reset", remaining: 2 }); + expect(observedProvenance).toBe("external-replacement"); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).not.toBeNull(); + // No OAuth call: forceRefresh adopted the time-valid external replacement. + expect(urls).toEqual([CONSUME, USAGE, USAGE]); + const claims = claimManualResetCooldowns(config, "manual-a"); + expect(claims).toHaveLength(1); + for (const claim of claims) settleManualResetCooldown(config, claim, false); + } finally { + release401.release(); + try { if (pending) await pending; } + finally { refreshSpy.mockRestore(); clock.mockRestore(); } + } + }); + + test("manual 401 can join a genuine owned refresh and retain its +1 lineage", async () => { + const config = setup(); const before = readCodexAccountRecord("manual-a")!; + const firstUsage = gate(); const release401 = gate(); const oauthStarted = gate(); const releaseOAuth = gate(); const joined = gate(); + const forceRefresh = accountStoreModule.forceRefreshCodexPoolToken; + let refreshCalls = 0; + let joinedProvenance: string | undefined; + const spy = spyOn(accountStoreModule, "forceRefreshCodexPoolToken").mockImplementation(async (id, options) => { + const result = forceRefresh(id, options); + const isJoiner = ++refreshCalls === 2; + if (isJoiner) joined.release(); + const resolved = await result; + if (isJoiner) joinedProvenance = resolved.provenance; + return resolved; + }); + const urls: string[] = []; let reads = 0; + globalThis.fetch = (async input => { + const url = String(input); urls.push(url); + if (url === CONSUME) return Response.json({ code: "reset" }); + if (url === USAGE) { + if (++reads === 1) { firstUsage.release(); await release401.promise; return new Response("{}", { status: 401 }); } + return Response.json(usage()); + } + if (url === "https://auth.openai.com/oauth/token") { + oauthStarted.release(); await releaseOAuth.promise; + return Response.json({ access_token: "joined-access", refresh_token: "joined-refresh", expires_in: 3600 }); + } + throw new Error("unexpected mock URL"); + }) as typeof fetch; + const pending = consume(config); + let owner: ReturnType<typeof forceRefresh> | undefined; + try { + await firstUsage.promise; + owner = accountStoreModule.forceRefreshCodexPoolToken("manual-a", { + rejectedGeneration: before.generation, rejectedAccessToken: before.credential!.accessToken, + }); + await oauthStarted.promise; + release401.release(); await joined.promise; + releaseOAuth.release(); + expect((await owner).provenance).toBe("self-refresh"); + expect((await pending)?.status).toBe(200); + expect(joinedProvenance).toBe("joined-lineage"); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); + expect(readCodexAccountRecord("manual-a")!.generation).toBe(before.generation + 1); + expect(urls).toEqual([CONSUME, USAGE, "https://auth.openai.com/oauth/token", USAGE]); + } finally { + release401.release(); releaseOAuth.release(); + if (owner) await owner; await pending; + spy.mockRestore(); + } + }); + + test.each(["consume", "usage"])("new 429 during %s survives the old reset claim", async stage => { + const config = setup(); + const started = gate(); const finish = gate(); + let later: ReturnType<typeof getCodexQuotaHealthSnapshot>; + mock(async () => { + if (stage === "consume") { started.release(); await finish.promise; } + return Response.json({ code: "reset" }); + }, async () => { + if (stage === "usage") { started.release(); await finish.promise; } + return Response.json(usage()); + }); + const pending = consume(config); + try { + await started.promise; + cool(config, "manual-a", "gpt-5.6-sol", Date.now() + 1); + later = getCodexQuotaHealthSnapshot("manual-a", "shared"); + } finally { finish.release(); } + expect((await pending)?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toEqual(later!); + }); + + test.each(["replace", "remove", "readd", "pause", "recreate"])("usage cannot recover after %s", async change => { + const config = setup(); const started = gate(); const finish = gate(); + mock(() => Response.json({ code: "reset" }), async () => { + started.release(); await finish.promise; return Response.json(usage()); + }); + const pending = consume(config); + try { + await started.promise; + if (change === "replace") saveCodexAccountCredential("manual-a", { + accessToken: "replacement", refreshToken: "replacement-refresh", expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "replacement-account", + }); + if (change === "remove") config.codexAccounts = []; + if (change === "readd") { + removeCodexAccountCredential("manual-a"); + saveCodexAccountCredential("manual-a", { accessToken: "readded-access", refreshToken: "readded-refresh", + expiresAt: Date.now() + 3_600_000, chatgptAccountId: "acct-manual-a" }); + } + if (change === "pause") config.pausedCodexAccountIds = ["manual-a"]; + if (change === "recreate") { clearCodexUpstreamHealthForAccount("manual-a"); cool(config, "manual-a"); } + } finally { finish.release(); } + expect((await pending)?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).not.toBeNull(); + if (change === "pause") expect(config.pausedCodexAccountIds).toEqual(["manual-a"]); + }); + + test.each([false, true])("old usage cannot prove reset or overwrite a newer observation (old finishes first=%s)", async oldFirst => { + const config = setup(); const oldStarted = gate(); const oldFinish = gate(); + const freshStarted = gate(); const freshFinish = gate(); let reads = 0; + const urls = mock(() => Response.json({ code: "reset" }), async () => { + reads += 1; + if (reads === 1) { oldStarted.release(); await oldFinish.promise; return Response.json(usage(99)); } + freshStarted.release(); await freshFinish.promise; return Response.json(usage(12)); + }); + const frozenNow = Date.now(); + const clock = spyOn(Date, "now").mockReturnValue(frozenNow); + const old = listCodexAuthAccounts(config, true); + let reset: ReturnType<typeof consume> | undefined; + try { + await oldStarted.promise; + reset = consume(config); + await freshStarted.promise; + if (oldFirst) { + oldFinish.release(); await old; + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).not.toBeNull(); + } + freshFinish.release(); + expect((await reset)?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); + oldFinish.release(); await old; + expect(getAccountQuota("manual-a")?.weeklyPercent).toBe(12); + expect(urls).toEqual([USAGE, CONSUME, USAGE]); + } finally { + oldFinish.release(); freshFinish.release(); + await old; if (reset) await reset; + clock.mockRestore(); + } + }); + + // Adapt #3995/e172453052's two-flight convergence to fresh-before-old scheduling. + test("reset publishes a fourth usage request before two old current-generation flights complete", async () => { + const config = setup(); + const oldCredential = getCodexAccountCredential("manual-a")!; + const oldGeneration = readCodexAccountRecord("manual-a")!.generation; + const firstStarted = gate(); const release401 = gate(); const secondStarted = gate(); const secondFinish = gate(); + const replayStarted = gate(); const replayFinish = gate(); const freshStarted = gate(); + const latches = [firstStarted, release401, secondStarted, secondFinish, replayStarted, replayFinish, freshStarted]; + const pending: Promise<unknown>[] = []; + const urls: string[] = []; const usageBearers: Array<string | null> = []; + let usageCalls = 0; let consumeCalls = 0; let completedOldResponses = 0; + let rejectDeadline!: (error: Error) => void; + const deadline = new Promise<never>((_resolve, reject) => { rejectDeadline = reject; }); + // Failure bound only: success is synchronized on dispatch latches, never elapsed time. + const timeout = setTimeout(() => rejectDeadline(new Error("mock dispatch did not reach its expected phase")), watchdogMs(10_000)); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); urls.push(url); + if (url === CONSUME) { consumeCalls += 1; return Response.json({ code: "reset" }); } + if (url !== USAGE) throw new Error("unexpected mock URL"); + usageBearers.push(new Headers(init?.headers).get("Authorization")); + switch (++usageCalls) { + case 1: + firstStarted.release(); await release401.promise; + return new Response("{}", { status: 401 }); + case 2: + secondStarted.release(); await secondFinish.promise; completedOldResponses += 1; + return Response.json({ ...usage(88), rate_limit_reset_credits: { available_count: 66 } }); + case 3: + replayStarted.release(); await replayFinish.promise; completedOldResponses += 1; + return Response.json({ ...usage(99), rate_limit_reset_credits: { available_count: 77 } }); + case 4: + freshStarted.release(); return Response.json(usage(12)); + default: throw new Error("unexpected mock usage dispatch"); + } + }) as typeof fetch; + try { + const first = listCodexAuthAccounts(config, true); pending.push(first); + void first.catch(rejectDeadline); + await Promise.race([firstStarted.promise, deadline]); + // A fresh external generation starts its own ordinary flight while P's old 401 is held. + saveCodexAccountCredential("manual-a", { ...oldCredential, accessToken: "converged-access", refreshToken: "converged-refresh" }); + expect(readCodexAccountRecord("manual-a")!.generation).toBe(oldGeneration + 1); + const second = listCodexAuthAccounts(config, true); pending.push(second); + void second.catch(rejectDeadline); + await Promise.race([secondStarted.promise, deadline]); + release401.release(); + await Promise.race([replayStarted.promise, deadline]); + // Both old flights now use the current generation; neither response has completed. + expect(usageBearers).toEqual([`Bearer ${oldCredential.accessToken}`, "Bearer converged-access", "Bearer converged-access"]); + expect(completedOldResponses).toBe(0); + const reset = consume(config); pending.push(reset); + void reset.catch(rejectDeadline); + await Promise.race([freshStarted.promise, deadline]); + const response = await Promise.race([reset, deadline]); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ code: "reset", remaining: 2 }); + expect(completedOldResponses).toBe(0); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); + const fresh = structuredClone(getAccountQuota("manual-a")); + expect(fresh).toMatchObject({ weeklyPercent: 12, resetCredits: 2 }); + secondFinish.release(); replayFinish.release(); + await Promise.all([first, second]); + expect(completedOldResponses).toBe(2); + expect(getAccountQuota("manual-a")).toEqual(fresh); + expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); + expect(consumeCalls).toBe(1); expect(usageCalls).toBe(4); + expect(usageBearers).toEqual([`Bearer ${oldCredential.accessToken}`, "Bearer converged-access", "Bearer converged-access", "Bearer converged-access"]); + expect(urls).toEqual([USAGE, USAGE, USAGE, CONSUME, USAGE]); + } finally { + clearTimeout(timeout); + for (const latch of latches) latch.release(); + const results = await Promise.allSettled(pending); + globalThis.fetch = originalFetch; + for (const result of results) if (result.status === "rejected") throw result.reason; + } + }, 60_000); + + test("main Q-first/P-last publication preserves post-reset cache, credits and hard-lock readiness", async () => { + const config = makeConfig({ codexMainAccountHardLock: true }); + const accessToken = "ordered-main-token"; const accountId = "ordered-main-account"; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: accessToken, account_id: accountId } })); + reconcileMainCodexAccountRuntimeState(); + const writer = observeMainQuotaCredential(accessToken, accountId)!; + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { weeklyPercent: 100, resetCredits: 5 }, captureConfigGeneration(), writer); + expect(getMainAccountHardLockStatus(config).state).toBe("blocked"); + cool(config, MAIN_CODEX_ACCOUNT_ID); + const oldStarted = gate(); const oldFinish = gate(); let reads = 0; + const urls = mock(() => Response.json({ code: "reset" }), () => { + if (++reads === 1) return new Response(new ReadableStream<Uint8Array>({ + async start(controller) { + oldStarted.release(); await oldFinish.promise; + controller.enqueue(new TextEncoder().encode(JSON.stringify({ ...usage(100), + rate_limit_reset_credits: { available_count: 7 } }))); + controller.close(); + }, + }), { headers: { "Content-Type": "application/json" } }); + if (reads === 2) return Response.json(usage(12)); + // Later omission also verifies the private retained-credit slot was not overwritten by P. + return Response.json({ plan_type: "team", rate_limit: { secondary_window: { used_percent: 14 } } }); + }); + const old = fetchMainAccountInfoSnapshot(true); + try { + await oldStarted.promise; + const reset = await consume(config, MAIN_CODEX_ACCOUNT_ID); + expect(await reset?.json()).toEqual({ code: "reset", remaining: 2 }); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared")).toBeNull(); + const freshCache = structuredClone(getMainAccountInfoCache()); + const freshShared = structuredClone(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)); + const freshPolicy = structuredClone(getMainPolicyQuota()); + expect(freshCache?.quota).toMatchObject({ weeklyPercent: 12, resetCredits: 2 }); + expect(getMainAccountHardLockStatus(config).state).toBe("ready"); + oldFinish.release(); + const stale = await old; + expect(stale.quotaRefresh).toBeUndefined(); + expect(getMainAccountInfoCache()).toEqual(freshCache); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toEqual(freshShared); + expect(getMainPolicyQuota()).toEqual(freshPolicy); + expect(getMainAccountHardLockStatus(config).state).toBe("ready"); + const displayed = (await listCodexAuthAccounts(config, false)).find(account => account.isMain)!; + expect(displayed.quota).toMatchObject({ weeklyPercent: 12, resetCredits: 2 }); + await fetchMainAccountInfoSnapshot(true); + const afterOmission = (await listCodexAuthAccounts(config, false)).find(account => account.isMain)!; + expect(afterOmission.quota?.resetCredits).toBe(2); + expect(getMainAccountHardLockStatus(config).state).toBe("ready"); + expect(urls).toEqual([USAGE, CONSUME, USAGE, USAGE]); + } finally { oldFinish.release(); await old; } + }); + + test("a newer failed main read does not outrank an older successful publication", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "publication-main-token", account_id: "publication-main-account" }, + })); + reconcileMainCodexAccountRuntimeState(); + const started = gate(); const finish = gate(); let reads = 0; + const urls = mock(() => { throw new Error("consume is not expected"); }, async () => { + if (++reads === 1) { started.release(); await finish.promise; return Response.json(usage()); } + return new Response("fixture unavailable", { status: 503 }); + }); + const old = fetchMainAccountInfoSnapshot(true); + try { + await started.promise; + expect((await fetchMainAccountInfoSnapshot(true)).quotaRefresh).toEqual({ status: "http_error", httpStatus: 503 }); + finish.release(); + expect((await old).quotaRefresh).toEqual({ status: "ok" }); + expect(getMainAccountInfoCache()?.quota).toMatchObject({ weeklyPercent: 12, resetCredits: 2 }); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(12); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("ready"); + expect(urls).toEqual([USAGE, USAGE]); + } finally { finish.release(); await old; } + }); + + test("main reset usage does not erase an existing reauth quarantine", async () => { + const config = makeConfig(); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "manual-main-token", account_id: "manual-main-account" }, + })); + reconcileMainCodexAccountRuntimeState(); + cool(config, MAIN_CODEX_ACCOUNT_ID); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + const urls = mock(() => Response.json({ code: "reset" }), () => Response.json(usage())); + expect((await consume(config, MAIN_CODEX_ACCOUNT_ID))?.status).toBe(200); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared")).not.toBeNull(); + expect(urls).toEqual([CONSUME, USAGE]); + }); + + test("conflicting main token/header identity supplies no recovery proof", async () => { + const config = makeConfig(); + const payload = Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "token-account" } })).toString("base64url"); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "manual-main-token", id_token: `e30.${payload}.sig`, account_id: "header-account" }, + })); + reconcileMainCodexAccountRuntimeState(); cool(config, MAIN_CODEX_ACCOUNT_ID); + const urls = mock(() => Response.json({ code: "reset" }), () => Response.json(usage())); + expect(await (await consume(config, MAIN_CODEX_ACCOUNT_ID))?.json()).toEqual({ code: "reset" }); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared")).not.toBeNull(); + expect(urls).toEqual([CONSUME]); + }); + + test.each(["same", "bearer", "other-account", "aba"])("main recovery uses its own live credential proof: %s", async change => { + const config = makeConfig(); + const writeMain = (accountId: string, accessToken = "manual-main-token") => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: accessToken, account_id: accountId } })); + }; + writeMain("manual-main-account"); reconcileMainCodexAccountRuntimeState(); + cool(config, MAIN_CODEX_ACCOUNT_ID); + const started = gate(); const finish = gate(); let usageCalls = 0; + mock(() => Response.json({ code: "reset" }), async () => { + usageCalls += 1; + if (usageCalls === 1) { started.release(); await finish.promise; } + return Response.json(usage()); + }); + const pending = consume(config, MAIN_CODEX_ACCOUNT_ID); + try { + await started.promise; + expect(getNativeMainProfileRequestCount()).toBe(1); + if (change === "bearer") writeMain("manual-main-account", "replacement-main-token"); + if (change === "other-account" || change === "aba") { + writeMain("other-main-account"); reconcileMainCodexAccountRuntimeState(); + if (change === "aba") { writeMain("manual-main-account"); reconcileMainCodexAccountRuntimeState(); } + cool(config, MAIN_CODEX_ACCOUNT_ID); + } + } finally { finish.release(); } + expect((await pending)?.status).toBe(200); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared") === null).toBe(change === "same"); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); +}); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 65d0adcd0c..d06c58d572 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -55,6 +55,7 @@ import { CODEX_QUOTA_PROBE_INTERVAL_MS, clearCodexUpstreamHealth, clearThreadAccountMap, + getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome, resetCodexRoutingForManualSelection, } from "../../src/codex/routing"; @@ -1267,6 +1268,147 @@ describe("Codex auth context", () => { expect(directEntitlementChecks).toBe(1); }); + test("a fresh request can reuse caller main after the selected Pool account enters cooldown", async () => { + const cfg = { ...config(), autoSwitchThreshold: 0 }; + const now = 1_800_000_000_000; + const originalNow = Date.now; + const inbound = new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + await expect(resolveCodexAuthContext(inbound, cfg, "pool", { + ...options, excludeAccountId: "pool-a", + })).resolves.toMatchObject({ kind: "main", accountId: null }); + + const context = await resolveCodexAuthContext(inbound, cfg, "pool", options); + expect(context).toMatchObject({ kind: "main", accountId: null }); + const forwarded = headersForCodexAuthContext(inbound, context); + expect(forwarded.get("authorization")).toBe("Bearer caller-keyring-token"); + expect(forwarded.get("chatgpt-account-id")).toBe("caller-keyring-account"); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(cfg.activeCodexAccountPinned).toBeUndefined(); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + + test("cooldown caller-main fallback never resurrects the cooled subscription", async () => { + const now = 1_800_000_000_000; + const originalNow = Date.now; + const cfg = { ...config(), autoSwitchThreshold: 0 }; + // config() registers pool-a with email pool@example.test and workspace account pool_acc. + const callerJwt = (email?: string) => `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(now / 1000) + 86_400, + ...(email ? { email } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: "pool_acc" }, + })).toString("base64url")}.signature`; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + const resolve = (headers: Headers) => resolveCodexAuthContext(headers, cfg, "pool", options); + + // The cooled account's exact materialized credential cannot use the fallback. + await expect(resolve(new Headers({ + authorization: "Bearer pool_token", "chatgpt-account-id": "pool_acc", + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // A rotated token of the same account (same workspace id and email) is still that subscription. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt("pool@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // A distinct team member on the shared workspace account id may serve the request. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt("teammate@example.test")}`, + }))).resolves.toMatchObject({ kind: "main", accountId: null }); + // An unreadable caller identity fails closed. + await expect(resolve(new Headers({ + authorization: "Bearer opaque-caller-token", + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // The workspace account id without a readable email cannot be distinguished: fail closed. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt()}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + // Nothing mutated the cooldown or the Pool selection. + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + + test("cooldown caller-main fallback follows the stable user id, not the recorded email", async () => { + const now = 1_800_000_000_000; + const originalNow = Date.now; + const cfg = { ...config(), autoSwitchThreshold: 0 }; + // config() registers pool-a with email pool@example.test on workspace account pool_acc. + const jwt = (claims: Record<string, unknown>, email?: string) => `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(now / 1000) + 86_400, + ...(email ? { email } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: "pool_acc", ...claims }, + })).toString("base64url")}.signature`; + const storeCooled = (accessToken: string) => saveCodexAccountCredential("pool-a", { + accessToken, refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + storeCooled(jwt({ chatgpt_user_id: "user-cooled" }, "pool@example.test")); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + const resolve = (headers: Headers) => resolveCodexAuthContext(headers, cfg, "pool", options); + + // Distinct members of one workspace account, with no email claim anywhere to separate them. + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-teammate" })}`, + }))).resolves.toMatchObject({ kind: "main", accountId: null }); + // The same user stays inside its own cooldown after an email change and a token rotation. + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-cooled" }, "renamed@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + // A stored credential whose own user-id claims disagree identifies nobody, so even a caller + // the email rule would have waved through as a teammate fails closed. + storeCooled(jwt({ chatgpt_user_id: "user-cooled", user_id: "user-other" }, "pool@example.test")); + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-teammate" }, "teammate@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", @@ -1452,8 +1594,9 @@ describe("Codex auth context", () => { // The caller proved admission with one of OUR secrets. That secret must never leave the // process, so the only acceptable outcome is the stored main credential in its place. const admissionSecret = "ocx_data_localsecret"; + const storedCredential = liveJwt(); writeFileSync(join(testDir, "auth.json"), JSON.stringify({ - tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + tokens: { access_token: storedCredential, account_id: "stored_main_acc" }, })); const headers = materializeCodexUpstreamAuth( @@ -1463,7 +1606,7 @@ describe("Codex auth context", () => { ); expect(headers.get("authorization")).not.toContain(admissionSecret); - expect(headers.get("authorization")).toBe(`Bearer ${liveJwt()}`); + expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`); expect(headers.get("chatgpt-account-id")).toBe("stored_main_acc"); // Unrelated forwarded headers still ride along. expect(headers.get("openai-beta")).toBe("responses=experimental"); diff --git a/tests/codex-integration/codex-catalog-restore.test.ts b/tests/codex-integration/codex-catalog-restore.test.ts index a211701578..1b148daad1 100644 --- a/tests/codex-integration/codex-catalog-restore.test.ts +++ b/tests/codex-integration/codex-catalog-restore.test.ts @@ -39,7 +39,7 @@ describe("Codex catalog restore", () => { if (existsSync(opencodexHome)) removeTreeWithRetry(opencodexHome); }); - test("version-1 process journals restore, while matching client ownership is durable", () => { + test("version-1 process journals with injected hashes restore, while matching client ownership is durable", () => { const configPath = join(codexHome, "config.toml"); const journalPath = join(codexHome, "opencodex-journal.json"); const original = '# original\nmodel_provider = "openai"\n'; @@ -49,6 +49,8 @@ describe("Codex catalog restore", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, pid: 999_999, timestamp: new Date().toISOString(), })); @@ -65,6 +67,8 @@ describe("Codex catalog restore", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999_999, timestamp: new Date().toISOString(), diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index ac7fe1c2b3..6953f0560a 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -1707,6 +1707,43 @@ describe("combo catalog capability intersection", () => { )).not.toHaveProperty("reasoningEfforts"); }); + test("resolveComboCatalogMember restores canonical OpenAI effort levels through generic routes", () => { + const providers = new Map([["azu-lab2", { + adapter: "openai-chat" as const, + baseUrl: "https://azu-lab2.example/v1", + }]]); + const member = resolveComboCatalogMember( + { provider: "azu-lab2", model: "gpt-5.6-terra" }, + new Map([["azu-lab2/gpt-5.6-terra", { + provider: "azu-lab2", + id: "gpt-5.6-terra", + contextWindow: 373_000, + inputModalities: ["text", "image"], + }]]), + providers, + ); + expect(member?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); + + test("resolveComboCatalogMember applies sidecar hints to complete discovery rows", () => { + const providers = new Map([["sidecar", { + adapter: "openai-chat" as const, + baseUrl: "https://sidecar.example/v1", + modelInputModalities: { planner: ["text"] }, + }]]); + const member = resolveComboCatalogMember( + { provider: "sidecar", model: "planner" }, + new Map([["sidecar/planner", { + provider: "sidecar", + id: "planner", + contextWindow: 200_000, + inputModalities: ["text"], + }]]), + providers, + ); + expect(member?.inputModalities).toEqual(["text", "image"]); + }); + // Sniper for the OUTPUT-vs-INPUT mapping defect carried over from PR #3332. The test // above uses toMatchObject, which only inspects the keys it names, so without this a // regression that puts the OUTPUT ceiling into the INPUT slot passes green. @@ -7165,6 +7202,21 @@ describe("Codex reasoning-effort capability clamp", () => { }); }); +test("provider-configured cap applies to discovered window and does not get overwritten by discovery", () => { + const resolved = applyProviderConfigHints("prov", { + adapter: "openai-chat", + baseUrl: "https://prov.test/v1", + modelContextWindows: { "disco-model": 100_000 }, + }, { + provider: "prov", + id: "disco-model", + contextWindow: 200_000, + }, 150_000); + + expect(resolved.contextWindow).toBe(100_000); + expect(resolved.contextCap).toBe(150_000); +}); + describe("auto_review_model configuration (#1225)", () => { test("applyAutoReviewModelOverride sets auto_review_model_override across all entries", () => { const { applyAutoReviewModelOverride } = require("../../src/codex/catalog/sync"); diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index 419ed0da6c..e8f3692b70 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -492,9 +492,11 @@ describe("WP13 composed toggle acceptance", () => { // The CLI's own output is the assertion message: a bare "expected 0, got 1" sent two // Windows CI rounds chasing a timeout that was never the cause. expect(`exit=${back.exitCode}\nstderr: ${back.stderr}\nstdout: ${back.stdout}`).toContain("exit=0"); - expect((await fx.request(server.runtime, "/api/native-integrations/codex", { + const disabledAgain = await fx.request(server.runtime, "/api/native-integrations/codex", { method: "PUT", body: JSON.stringify({ enabled: false }), - })).body).toMatchObject({ desiredEnabled: false }); + }); + expect(disabledAgain.body).toMatchObject({ desiredEnabled: false }); + expect(String(disabledAgain.body.message)).toContain("ocx recover-history --ocx-compaction <thread-id> --yes"); } finally { await fx.stop(server); } diff --git a/tests/codex-integration/codex-cooldown-recovery.test.ts b/tests/codex-integration/codex-cooldown-recovery.test.ts index 7f9a87d33f..1f7b3a69a4 100644 --- a/tests/codex-integration/codex-cooldown-recovery.test.ts +++ b/tests/codex-integration/codex-cooldown-recovery.test.ts @@ -8,7 +8,7 @@ import { runCodexCooldownRecoveryProbes, seedCodexAuthAdmissionForTests, } from "../../src/codex/auth-api"; -import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { readCodexAccountRecord, saveCodexAccountCredential, saveCodexAccountCredentialIfGeneration } from "../../src/codex/account-store"; import { codexQuotaWindowForPlan, getAccountQuota, @@ -21,6 +21,11 @@ import upstreamModels from "../../src/codex/data/upstream-models.json"; import { CODEX_QUOTA_PROBE_INTERVAL_MS, clearCodexUpstreamHealth, + clearCodexUpstreamHealthForAccount, + claimDueCodexQuotaRecoveryProbes, + claimManualResetCooldowns, + settleCodexQuotaRecoveryProbe, + settleManualResetCooldown, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome, resolveCodexAccountForThread, @@ -117,6 +122,78 @@ describe("Codex cooldown recovery worker", () => { testCodexHome = ""; }); + test("manual reset bypasses pacing but does not steal a live background lease", () => { + const config = makeConfig(["a"]); saveCredential("a"); cool(config, "a"); + const manual = claimManualResetCooldowns(config, "a", START + 1); + expect(manual).toHaveLength(1); + expect(claimDueCodexQuotaRecoveryProbes(config, 1, due())).toEqual([]); + settleManualResetCooldown(config, manual[0]!, false, {}, START + 2); + const [background] = claimDueCodexQuotaRecoveryProbes(config, 1, due(START + 2)); + expect(background).toBeDefined(); + expect(claimManualResetCooldowns(config, "a", due(START + 2))).toEqual([]); + expect(settleManualResetCooldown(config, manual[0]!, false, {}, due(START + 2))).toBe(false); + expect(settleCodexQuotaRecoveryProbe(background!, true, { + credentialGeneration: readCodexAccountRecord("a")!.generation, + }, due(START + 2))).toBe(true); + }); + + test("manual recovery rejects an unrelated refresh edge and preserves exact-generation settlement", () => { + const config = makeConfig(["a"]); saveCredential("a"); cool(config, "a"); + const [claim] = claimManualResetCooldowns(config, "a", START); + expect(claim?.kind).toBe("pool"); + const before = readCodexAccountRecord("a")!; + const generation = before.generation; + expect(saveCodexAccountCredentialIfGeneration("a", generation, { + ...before.credential!, accessToken: "fresh-a", refreshToken: "fresh-refresh-a", + })).toBe(true); + expect(readCodexAccountRecord("a")!.replacedAt).toBe(before.replacedAt); + expect(settleManualResetCooldown(config, claim!, true, { + credentialGeneration: generation + 1, + refreshLineage: { fromGeneration: generation - 1, toGeneration: generation + 1, provenance: "self-refresh" }, + }, START)).toBe(false); + expect(getCodexQuotaHealthSnapshot("a", "shared", START)).not.toBeNull(); + // Rejection releases the lease rather than leaving manual recovery stuck. + const [exact] = claimManualResetCooldowns(config, "a", START); + expect(exact).toBeDefined(); + expect(settleManualResetCooldown(config, exact!, true, { credentialGeneration: generation + 1 }, START)).toBe(true); + }); + + test("a replacement between auth and claiming cannot acquire the replacement's cooldown", () => { + const config = makeConfig(["a"]); saveCredential("a"); cool(config, "a"); + const generation = readCodexAccountRecord("a")!.generation; + saveCredential("a", "-replacement"); + expect(claimManualResetCooldowns(config, "a", START, generation)).toEqual([]); + expect(getCodexQuotaHealthSnapshot("a", "shared", START)).not.toBeNull(); + }); + + test("deleted and recreated cooldown with identical generation and clock cannot reuse a manual lease", () => { + const config = makeConfig(["a"]); saveCredential("a"); cool(config, "a"); + const [old] = claimManualResetCooldowns(config, "a", START); + clearCodexUpstreamHealthForAccount("a"); cool(config, "a"); + const [replacement] = claimManualResetCooldowns(config, "a", START); + expect(replacement!.probe.cooldownGeneration).toBe(old!.probe.cooldownGeneration); + expect(replacement!.probe.leaseId).not.toBe(old!.probe.leaseId); + const proof = { credentialGeneration: readCodexAccountRecord("a")!.generation }; + expect(settleManualResetCooldown(config, old!, true, proof, START)).toBe(false); + expect(getCodexQuotaHealthSnapshot("a", "shared", START)).not.toBeNull(); + expect(settleManualResetCooldown(config, replacement!, true, proof, START)).toBe(true); + }); + + test.each(["retry-after", "default", "spark", "reserve", "paused", "missing"])( + "manual reset never claims an ineligible target: %s", kind => { + const config = makeConfig(["a"]); saveCredential("a"); + if (kind === "retry-after") recordCodexUpstreamOutcome(config, "a", 429, { now: START, retryAfter: "3600" }); + else if (kind === "default") recordCodexUpstreamOutcome(config, "a", 429, { now: START }); + else if (kind === "reserve") recordCodexUpstreamOutcome(config, "a", 429, { + now: START, resetAt: START + 3_600_000, modelId: "gpt-reserve", + }); + else cool(config, "a", kind === "spark" ? "spark" : "shared"); + if (kind === "paused") config.pausedCodexAccountIds = ["a"]; + if (kind === "missing") config.codexAccounts = []; + expect(claimManualResetCooldowns(config, "a", START + 1)).toEqual([]); + }, + ); + test("recovers cooled A independently while ordinary routing only selects B", async () => { const config = makeConfig(); saveCredential("a"); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 3a5345098e..827251129e 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -879,6 +879,202 @@ describe("injectCodexConfig integration (Design B)", () => { expect(restored).toContain('model = "gpt-5.5"'); }); + test("client compaction opt-in (#3978): writes an authenticated provider table and returns to Design B", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + + const enabled = runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })); + expect(enabled.status).toBe(0); + expect(String(JSON.parse(enabled.stdout).message)).toContain("client-side compaction mode"); + const providerTable = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(providerTable).toContain('model_provider = "opencodex"'); + expect(providerTable).toContain("[model_providers.opencodex]"); + expect(providerTable).toContain("requires_openai_auth = true"); + expect(providerTable).not.toContain("requires_openai_auth = false"); + // The root override is retained next to the table, which is what keeps threads still tagged + // `openai` resolving to this proxy instead of to api.openai.com. + expect(providerTable).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); + + expect(runInject(codexHome, ocxHome).status).toBe(0); + const designB = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(designB).toContain(DESIGN_B_BLOCK); + expect(designB).not.toContain("[model_providers.opencodex]"); + expect(designB).not.toContain('model_provider = "opencodex"'); + // Disabling leaves exactly one root override, not the table form's copy plus a new one. + expect(designB.match(/openai_base_url/g)?.length).toBe(1); + }); + + test("client compaction never replaces a user-owned root override", () => { + // The retention is marker-owned like every other injected root line. When the user owns + // that line, nothing is injected and their destination stands. The guarantee that an + // `openai`-tagged thread reaches this proxy therefore holds for the managed override only; + // a user pointing the built-in provider elsewhere keeps pointing it there. + const userOwned = 'openai_base_url = "https://user.example/v1"\nmodel = "gpt-5.5"\n'; + writeFileSync(join(codexHome, "config.toml"), userOwned, "utf8"); + + const enabled = runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })); + expect(enabled.status).toBe(0); + + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain('openai_base_url = "https://user.example/v1"'); + expect(config).not.toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); + expect(config.match(/openai_base_url/g)?.length).toBe(1); + // The opt-in itself still applies: new threads default to the proxy provider. + expect(config).toContain('model_provider = "opencodex"'); + expect(config).toContain("[model_providers.opencodex]"); + // The user's line must never be journaled as ours, or a later restore would strip it. + const journal = JSON.parse(readFileSync(join(codexHome, "opencodex-journal.json"), "utf8")); + expect(journal.injectedOpenaiBaseUrl).toBeNull(); + + // The reported result has to match the file that was just written. The old root-only + // warning claimed nothing was injected and told the operator to delete a valid setting, + // while the history line claimed those threads still reached the proxy. Both were wrong + // for this mixed configuration. + const message = String(JSON.parse(enabled.stdout).message); + expect(message).toContain("Injected opencodex as default provider"); + expect(message).not.toContain("Codex routing NOT injected"); + expect(message).not.toContain("remove your openai_base_url line"); + expect(message).toContain("left exactly as you set it"); + expect(message).toContain("follow your own root openai_base_url, not the proxy"); + }); + + test("the managed override keeps reporting proxy routing for existing threads", () => { + // Control for the case above: with no user-owned line, opencodex writes the root override + // itself, so the proxy claim is accurate and the root-only warning must not appear. + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + + const enabled = runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })); + expect(enabled.status).toBe(0); + + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); + + const message = String(JSON.parse(enabled.stdout).message); + expect(message).toContain("keep reaching the proxy through the retained openai_base_url override"); + expect(message).not.toContain("Codex routing NOT injected"); + expect(message).not.toContain("not the proxy"); + }); + + test("the retained root override is journaled so a comment-dropping rewrite can still restore", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + expect(runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })).status).toBe(0); + + // The marker comment is not durable: the app can reserialize config.toml and drop comments, + // after which only the journaled value distinguishes our line from a user's (#1798). + const journal = JSON.parse(readFileSync(join(codexHome, "opencodex-journal.json"), "utf8")); + expect(journal.injectedOpenaiBaseUrl).toBe("http://127.0.0.1:10100/v1"); + + const rewritten = readFileSync(join(codexHome, "config.toml"), "utf8") + .split("\n").filter(line => !line.startsWith("#")).join("\n"); + writeFileSync(join(codexHome, "config.toml"), rewritten, "utf8"); + expect(runRestore(codexHome, ocxHome).status).toBe(0); + const restored = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(restored).not.toContain("openai_base_url"); + expect(restored).not.toContain("[model_providers.opencodex]"); + }); + + test("authless together with client compaction keeps the authless form, root key and all", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + const sessionsDir = join(codexHome, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + const rolloutPath = join(sessionsDir, "rollout-authless.jsonl"); + writeFileSync(rolloutPath, `${JSON.stringify({ + type: "session_meta", + payload: { id: "thread-authless", model_provider: "openai" }, + })}\n`, "utf8"); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.run(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, + source TEXT, first_user_message TEXT, has_user_event INTEGER + )`); + db.run("INSERT INTO threads VALUES ('thread-authless', ?, 'openai', 'cli', 'hello', 1)", rolloutPath); + db.close(); + + const enabled = runInject(codexHome, ocxHome, JSON.stringify({ + codexClientCompaction: true, + codexDesktopAuthless: true, + })); + expect(enabled.status).toBe(0); + + // Authless is the stronger form and cannot carry the root key, so it keeps its existing + // shape: no root override, and resume history is forward-tagged with originals backed up. + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain("requires_openai_auth = false"); + expect(config).not.toContain("openai_base_url"); + const verifier = new Database(join(codexHome, "state_5.sqlite"), { readonly: true }); + expect(verifier.query("SELECT model_provider FROM threads WHERE id = 'thread-authless'").get()) + .toEqual({ model_provider: "opencodex" }); + verifier.close(); + }); + test("client compaction opt-in leaves pre-existing ocx1 resume history byte-for-byte unchanged", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + const sessionsDir = join(codexHome, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + const rolloutPath = join(sessionsDir, "rollout-ocx1.jsonl"); + const rollout = `${JSON.stringify({ + type: "compacted", + payload: { + replacement_history: [{ + type: "compaction", + encrypted_content: "ocx1:cG9ydGFibGUgc3VtbWFyeQ==", + }], + }, + })}\n`; + writeFileSync(rolloutPath, rollout, "utf8"); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.run(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, + source TEXT, first_user_message TEXT, has_user_event INTEGER + )`); + db.run("INSERT INTO threads VALUES ('thread-ocx1', ?, 'opencodex', 'cli', 'hello', 1)", rolloutPath); + db.close(); + + const enabled = runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })); + expect(enabled.status).toBe(0); + expect(String(JSON.parse(enabled.stdout).message)).toContain("left unchanged"); + expect(readFileSync(rolloutPath, "utf8")).toBe(rollout); + const verifier = new Database(join(codexHome, "state_5.sqlite"), { readonly: true }); + expect(verifier.query("SELECT model_provider FROM threads WHERE id = 'thread-ocx1'").get()) + .toEqual({ model_provider: "opencodex" }); + verifier.close(); + }); + + test("client compaction opt-in keeps existing Design B threads routed without touching history", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + const sessionsDir = join(codexHome, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + const rolloutPath = join(sessionsDir, "rollout-designb.jsonl"); + const rollout = `${JSON.stringify({ + type: "session_meta", + payload: { id: "thread-designb", model_provider: "openai" }, + })}\n`; + writeFileSync(rolloutPath, rollout, "utf8"); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.run(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, + source TEXT, first_user_message TEXT, has_user_event INTEGER + )`); + db.run("INSERT INTO threads VALUES ('thread-designb', ?, 'openai', 'cli', 'hello', 1)", rolloutPath); + db.close(); + + const enabled = runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })); + expect(enabled.status).toBe(0); + + // The thread stays tagged `openai` and its rollout is untouched. It keeps reaching the proxy + // because the injection retains the root override next to the provider table, so codex's + // built-in `openai` entry still resolves to this proxy. Re-tagging would have been the other + // way to keep it routed, but the length-preserving first-line repair cannot grow "openai" + // into "opencodex", and codex re-appends that stale first line on its next metadata write. + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain('model_provider = "opencodex"'); + expect(config).toContain("[model_providers.opencodex]"); + expect(config).toContain("openai_base_url"); + expect(readFileSync(rolloutPath, "utf8")).toBe(rollout); + const verifier = new Database(join(codexHome, "state_5.sqlite"), { readonly: true }); + expect(verifier.query("SELECT model_provider FROM threads WHERE id = 'thread-designb'").get()) + .toEqual({ model_provider: "openai" }); + verifier.close(); + }); + test("authless Desktop opt-in never weakens non-loopback admission", () => { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); diff --git a/tests/codex-integration/codex-inject.test.ts b/tests/codex-integration/codex-inject.test.ts index de87f13725..eb0ad6b7c1 100644 --- a/tests/codex-integration/codex-inject.test.ts +++ b/tests/codex-integration/codex-inject.test.ts @@ -108,6 +108,56 @@ describe("Codex config injection", () => { }); }); + describe("Codex client compaction opt-in (#3978)", () => { + test.each([undefined, false])("disabled preference %s keeps authenticated loopback on Design B", (codexClientCompaction) => { + const target = standaloneCodexRoutingTarget(10100, { codexClientCompaction }); + expect(target.clientCompaction).toBeUndefined(); + expect(buildProfileFile(target, null)).toBe(buildProfileFile(10100, null)); + }); + + test("loopback opt-in selects the dedicated provider without disabling ChatGPT auth", () => { + const target = standaloneCodexRoutingTarget(10100, { codexClientCompaction: true }); + expect(target).toMatchObject({ + requiresAdmissionToken: false, + clientCompaction: true, + }); + expect(target.desktopAuthless).toBeUndefined(); + + const profile = buildProfileFile(target, "/tmp/opencodex-catalog.json"); + expect(profile).toContain('model_provider = "opencodex"'); + expect(profile).toContain("requires_openai_auth = true"); + // The reference profile documents the provider table only. The root override that keeps + // existing `openai`-tagged threads on the proxy is a config.toml global, not a profile + // key, so the injected config carries it and this file does not. + expect(profile).not.toContain("openai_base_url"); + // The dedicated provider-table form cannot carry the realtime voice + // sideband (it needs the admission-token header): opting in must not + // inject experimental_realtime_ws_base_url. + expect(profile).not.toContain("experimental_realtime_ws_base_url"); + }); + + test("authless remains the stronger provider-table policy when both preferences are enabled", () => { + const target = standaloneCodexRoutingTarget(10100, { + codexClientCompaction: true, + codexDesktopAuthless: true, + }); + const profile = buildProfileFile(target, null); + expect(profile).toContain('model_provider = "opencodex"'); + expect(profile).toContain("requires_openai_auth = false"); + }); + + test("non-loopback admission remains token-protected", () => { + const target = standaloneCodexRoutingTarget(10100, { + hostname: "192.168.1.20", + codexClientCompaction: true, + }); + expect(target.requiresAdmissionToken).toBe(true); + const profile = buildProfileFile(target, null); + expect(profile).toContain('env_key = "OPENCODEX_API_AUTH_TOKEN"'); + expect(profile).toContain("requires_openai_auth = true"); + }); + }); + test("explicit HTTPS target emits exact provider destination and admission env", () => { const target = { baseUrl: "https://hub.example.test/v1", diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 3b46be997a..05d6d14e37 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -56,6 +56,224 @@ describe("codex-journal", () => { expect(out.hasPid).toBe(true); }); + test("hashless interrupted snapshot preserves later native config edits", () => { + const edited = '# current user settings\nmodel_provider = "openai"\nmodel = "user-selected-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + const result = restoreJournalState(); + console.log(JSON.stringify({ result, config: fs.readFileSync(configPath, "utf8"), + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.config).toBe(edited); + expect(out.result.configRestored).toBe(false); + expect(out.result.complete).toBe(false); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless interrupted snapshot preserves a later profile", () => { + const edited = 'model_provider = "openai"\nmodel = "user-profile-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(profilePath, ${JSON.stringify(edited)}); + const result = restoreJournalState(); + console.log(JSON.stringify({ result, profile: fs.existsSync(profilePath) ? fs.readFileSync(profilePath, "utf8") : null, + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.profile).toBe(edited); + expect(out.result.profileRestored).toBe(false); + expect(out.result.complete).toBe(false); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless already-original snapshot completes without rewriting config", () => { + const r = runScript(testDir, ` + const { spyOn } = require("bun:test"); + const config = require("./src/config"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + writeJournal(); + const originalWrite = config.atomicWriteFile; + let writes = 0; + const spy = spyOn(config, "atomicWriteFile").mockImplementation((...args) => { + writes += 1; + return originalWrite(...args); + }); + try { console.log(JSON.stringify({ result: restoreJournalState(), writes })); } + finally { spy.mockRestore(); } + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.result.complete).toBe(true); + expect(out.writes).toBe(0); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(false); + }); + + test("hashless snapshot distinguishes an empty original profile from absence", () => { + writeFileSync(join(testDir, "opencodex.config.toml"), "", "utf8"); + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + writeJournal(); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journal = JSON.parse(fs.readFileSync(journalPath, "utf8")); + const result = restoreJournalState(); + console.log(JSON.stringify({ originalProfile: journal.originalProfile, result, + profileExists: fs.existsSync(profilePath), profile: fs.readFileSync(profilePath, "utf8"), + journalExists: fs.existsSync(journalPath) })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.originalProfile).toBe(""); + expect(out.result.complete).toBe(true); + expect(out.profileExists).toBe(true); + expect(out.profile).toBe(""); + expect(out.journalExists).toBe(false); + }); + + test("hashless native restore refuses instead of reporting an uncertain snapshot as restored", () => { + const edited = '# current user settings\nmodel_provider = "openai"\nmodel = "user-selected-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal } = require("./src/codex/journal"); + const { restoreNativeCodex } = require("./src/codex/inject"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + const result = restoreNativeCodex(); + console.log(JSON.stringify({ result, config: fs.readFileSync(configPath, "utf8"), + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.result.success).toBe(false); + expect(out.result.artifacts.config.state).toBe("failed"); + expect(out.config).toBe(edited); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless routed snapshot is not promoted by reinjection after user edits", () => { + const edited = '# current user settings\nmodel = "user-selected-model"\n# Auto-injected by opencodex\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal } = require("./src/codex/journal"); + const { injectCodexConfig } = require("./src/codex/inject"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + (async () => { + const config = { port: 10200, providers: {}, defaultProvider: "openai" }; + const preflight = await injectCodexConfig(10200, config, { catalogPath: null, validateOnly: true }); + const result = await injectCodexConfig(10200, config, { catalogPath: null }); + console.log(JSON.stringify({ preflight, result, config: fs.readFileSync(configPath, "utf8"), + profileCreated: fs.existsSync(profilePath), journal: JSON.parse(fs.readFileSync(journalPath, "utf8")), + journalPreserved: fs.readFileSync(journalPath, "utf8") === before })); + })(); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.preflight.success).toBe(false); + expect(out.result.success).toBe(false); + expect(out.config).toBe(edited); + expect(out.profileCreated).toBe(false); + expect(out.journal.injectedConfigHash).toBeUndefined(); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless empty config snapshot does not recreate a later deleted file", () => { + writeFileSync(join(testDir, "config.toml"), "", "utf8"); + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH } = require("./src/codex/paths"); + const { writeJournal, restoreJournalState, JOURNAL_PATH } = require("./src/codex/journal"); + writeJournal(); + fs.unlinkSync(CODEX_CONFIG_PATH); + console.log(JSON.stringify({ result: restoreJournalState(), configExists: fs.existsSync(CODEX_CONFIG_PATH), + journalExists: fs.existsSync(JOURNAL_PATH) })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.configExists).toBe(false); + expect(out.journalExists).toBe(true); + expect(out.result.complete).toBe(false); + expect(out.result.unverified).toBe(true); + }); + + test("hashless client reconcile does not report an uncertain snapshot as restored", () => { + const edited = 'model_provider = "openai"\nmodel = "current-user-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH } = require("./src/codex/paths"); + const { writeJournal, reconcileJournal, JOURNAL_PATH } = require("./src/codex/journal"); + writeJournal({ owner: { kind: "client", apiKeyId: "previous-client" } }); + const before = fs.readFileSync(JOURNAL_PATH, "utf8"); + fs.writeFileSync(CODEX_CONFIG_PATH, ${JSON.stringify(edited)}); + const restored = reconcileJournal({ activeClientApiKeyId: "different-client" }); + console.log(JSON.stringify({ restored, config: fs.readFileSync(CODEX_CONFIG_PATH, "utf8"), + journalPreserved: fs.existsSync(JOURNAL_PATH) && fs.readFileSync(JOURNAL_PATH, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.restored).toBe(false); + expect(out.config).toBe(edited); + expect(out.journalPreserved).toBe(true); + expect(r.stderr).toContain("recovery was not verified"); + expect(r.stderr).not.toContain("was restored from the Codex journal"); + }); + + test("hashless native edits become the new snapshot before a successful injection", () => { + const edited = 'model_provider = "openai"\nmodel = "current-user-model"\n'; + const profile = 'model_provider = "openai"\nmodel = "current-user-profile"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } = require("./src/codex/paths"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const { injectCodexConfig } = require("./src/codex/inject"); + writeJournal(); + fs.writeFileSync(CODEX_CONFIG_PATH, ${JSON.stringify(edited)}); + fs.writeFileSync(CODEX_PROFILE_PATH, ${JSON.stringify(profile)}); + (async () => { + const config = { port: 10100, providers: {}, defaultProvider: "openai" }; + const preflight = await injectCodexConfig(10100, config, { catalogPath: null, validateOnly: true }); + const injected = await injectCodexConfig(10100, config, { catalogPath: null }); + const restored = restoreJournalState(); + console.log(JSON.stringify({ preflight, injected, restored, config: fs.readFileSync(CODEX_CONFIG_PATH, "utf8"), + profile: fs.readFileSync(CODEX_PROFILE_PATH, "utf8") })); + })(); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.preflight.success).toBe(true); + expect(out.injected.success).toBe(true); + expect(out.restored.complete).toBe(true); + expect(out.config).toBe(edited); + expect(out.profile).toBe(profile); + }); + test("reconcileJournal restores config when journaled PID is dead", () => { const journalPath = join(testDir, "opencodex-journal.json"); const original = "# original config\nmodel_provider = \"openai\"\n"; @@ -65,6 +283,8 @@ describe("codex-journal", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(modified).digest("hex"), + injectedProfileHash: null, pid: 999999, timestamp: new Date().toISOString(), }), "utf8"); @@ -136,6 +356,8 @@ describe("codex-journal", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999999, timestamp: new Date().toISOString(), @@ -344,6 +566,11 @@ describe("codex-journal", () => { '' ].join("\\n"), "utf8"); fs.writeFileSync(path.join(process.env.CODEX_HOME, "opencodex.config.toml"), 'model_provider = "opencodex"\\n', "utf8"); + require("./src/codex/journal").markJournalInjectedState( + fs.readFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "utf8"), + fs.readFileSync(path.join(process.env.CODEX_HOME, "opencodex.config.toml"), "utf8"), + { injectedOpenaiBaseUrl: null, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: null }, + ); const result = restoreNativeCodex(); console.log(JSON.stringify({ success: result.success, message: result.message })); `); @@ -440,20 +667,21 @@ describe("codex-journal", () => { expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(true); }); - test("full lifecycle: write → crash → reconcile restores", () => { + test("full lifecycle: snapshot → mark injection → crash → reconcile restores", () => { const r = runScript(testDir, ` - const { writeJournal } = require("./src/codex/journal"); + const { writeJournal, markJournalInjectedState } = require("./src/codex/journal"); writeJournal(); + const injected = "# injected opencodex config\\n"; + require("node:fs").writeFileSync(require("./src/codex/paths").CODEX_CONFIG_PATH, injected); + markJournalInjectedState(injected, null, { + injectedOpenaiBaseUrl: null, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: null, + }); console.log("written"); `); expect(r.status).toBe(0); const journalPath = join(testDir, "opencodex-journal.json"); expect(existsSync(journalPath)).toBe(true); - const journal = JSON.parse(readFileSync(journalPath, "utf8")); - - writeFileSync(join(testDir, "config.toml"), "# injected opencodex config\n", "utf8"); - const r2 = runScript(testDir, ` const { reconcileJournal } = require("./src/codex/journal"); const result = reconcileJournal(); diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index 09d12fc282..972f900760 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -223,6 +223,35 @@ describe("Codex request transport metadata", () => { expect(new Headers(dropped.headers).get(hintHeader)).toBe("model=gpt-5.6-sol"); }); + test("canonical adapter drops Lite only for the Spark wire model", async () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + headers: { "X-OpenAI-Internal-Codex-Responses-Lite": "true" }, + }); + + for (const [model, incomingLite, expectedLite] of [ + ["gpt-5.3-codex-spark", "true", null], + ["gpt-5.3-codex-spark", undefined, null], + ["gpt-5.6-sol", "true", "true"], + ] as const) { + const parsed = minimalParsed(); + parsed.modelId = model; + parsed._rawBody = { model, input: [], stream: true }; + const incoming = new Headers(); + if (incomingLite !== undefined) incoming.set(liteHeader, incomingLite); + const request = await adapter.buildRequest(parsed, { + headers: incoming, + }); + expect(new Headers(request.headers).get(liteHeader)).toBe(expectedLite); + } + + const routed = minimalParsed(); + routed.modelId = "spark-alias"; + routed._rawBody = { model: "gpt-5.3-codex-spark", input: [], stream: true }; + const request = await adapter.buildRequest(routed, { headers: new Headers({ [liteHeader]: "true" }) }); + expect(new Headers(request.headers).get(liteHeader)).toBeNull(); + }); + test("noncanonical adapters neither forward caller Lite nor synthesize a routing hint", async () => { for (const authMode of ["forward", "key"] as const) { const adapter = createResponsesPassthroughAdapter({ diff --git a/tests/codex-integration/codex-models-cache-invalidate.test.ts b/tests/codex-integration/codex-models-cache-invalidate.test.ts index bd423e7832..b644740e40 100644 --- a/tests/codex-integration/codex-models-cache-invalidate.test.ts +++ b/tests/codex-integration/codex-models-cache-invalidate.test.ts @@ -6,12 +6,13 @@ import { invalidateCodexModelsCache } from "../../src/codex/catalog"; import { invalidateCodexModelsCacheWithPermit } from "../../src/codex/catalog/sync"; import { withCatalogWriteSerialization } from "../../src/codex/catalog-write-serialization"; import { - afterCatalogWriteHandleAppServers, - collectCodexAppServerCatalogState, + collectCodexAppServerCatalogStateForRequest, resetCodexAppServerCatalogStateCache, + afterCatalogWriteHandleAppServers, } from "../../src/codex/app-server-processes"; import { refreshCodexModelCatalog } from "../../src/codex/refresh"; import { syncModelsToCodex } from "../../src/codex/sync"; +import { flushConfigDirHardening } from "../../src/config/paths"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -36,7 +37,9 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { process.env.OPENCODEX_HOME = opencodexHome; }); - afterEach(() => { + afterEach(async () => { + await flushConfigDirHardening(opencodexHome); + resetCodexAppServerCatalogStateCache(); if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; @@ -302,38 +305,43 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { expect(errors).toEqual([]); expect(logs).toEqual([]); }); - test("sync invalidates a cached not-running observation before a catalog write", async () => { let snapshots: Array<{ pid: number; commandLine: string }> = []; const io = { - platform: "linux" as const, - listSnapshots: () => snapshots, - readStartMs: () => 1_000, + platform: "win32" as const, + now: () => 3_000, + listSnapshotsAsync: async () => snapshots, + readStartMsBatchAsync: async (pids: readonly number[]) => new Map(pids.map(pid => [pid, 1_000])), catalogMtimeMs: () => 2_000, }; resetCodexAppServerCatalogStateCache(); - expect(collectCodexAppServerCatalogState(io).state).toBe("not_running"); + expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("not_running"); snapshots = [{ pid: 42, commandLine: "codex app-server" }]; + // Prove the real request cache is warm; injected synchronous IO bypasses it. + expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("not_running"); writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ models: [{ slug: "gpt-5.5" }] })); expect(invalidateCodexModelsCache({ allowWhenDesiredDisabled: true })).toBe(true); - expect(collectCodexAppServerCatalogState(io).state).toBe("stale"); + expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("stale"); }); test("sync invalidates cached process state even when catalog refresh is a no-op", async () => { let snapshots: Array<{ pid: number; commandLine: string }> = []; const io = { - platform: "linux" as const, - listSnapshots: () => snapshots, - readStartMs: () => 1_000, + platform: "win32" as const, + now: () => 3_000, + listSnapshotsAsync: async () => snapshots, + readStartMsBatchAsync: async (pids: readonly number[]) => new Map(pids.map(pid => [pid, 1_000])), catalogMtimeMs: () => 2_000, }; resetCodexAppServerCatalogStateCache(); - expect(collectCodexAppServerCatalogState(io).state).toBe("not_running"); + expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("not_running"); snapshots = [{ pid: 42, commandLine: "codex app-server" }]; + // Prove the real request cache is warm; injected synchronous IO bypasses it. + expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("not_running"); - await syncModelsToCodex(10100, emptyConfig, null, { + await syncModelsToCodex(19107, emptyConfig, null, { refreshCodexModelCatalog: async () => ({ added: 0, path: join(codexHome, "opencodex-catalog.json"), @@ -346,6 +354,6 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { currentExternalCodexModelProvider: () => null, }); - expect(collectCodexAppServerCatalogState(io).state).toBe("stale"); + expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("stale"); }); }); diff --git a/tests/codex-integration/codex-prompt-route.test.ts b/tests/codex-integration/codex-prompt-route.test.ts index ef862f58e2..feb38632db 100644 --- a/tests/codex-integration/codex-prompt-route.test.ts +++ b/tests/codex-integration/codex-prompt-route.test.ts @@ -15,6 +15,7 @@ import { LAYER_INVENTORY, readPromptLayers } from "../../src/codex/prompt-layers import { promptTextProbeSpawnAttemptsForTests, resetPromptTextProbeForTests, + setPromptTextProbeCloseBarrierForTests, setPromptTextProbeCommandForTests, } from "../../src/codex/prompt-text-probe"; import type { ManagementPrincipal } from "../../src/server/management-auth"; @@ -144,6 +145,33 @@ async function revision(fx: Fixture): Promise<string> { return res.body.revision as string; } +/** Hold admission through the edit even if the fixture child has already exited. */ +async function withHeldPromptProbeClose( + fx: Fixture, + whileHeld: () => Promise<void>, +): Promise<Awaited<ReturnType<typeof call>>> { + let releaseClose!: () => void; + setPromptTextProbeCloseBarrierForTests(new Promise<void>(resolve => { + releaseClose = resolve; + })); + // Observe an early request failure while the held-phase assertions are running. + const pending = call("GET", "/api/codex-prompt/text", fx).then( + response => ({ response }), + (error: unknown) => ({ error }), + ); + try { + await whileHeld(); + } finally { + // Clearing the seam alone cannot release a barrier the close handler captured. + releaseClose(); + setPromptTextProbeCloseBarrierForTests(null); + await pending; + } + const outcome = await pending; + if ("error" in outcome) throw outcome.error; + return outcome.response; +} + afterEach(async () => { await resetPromptTextProbeForTests(); while (roots.length) removeTreeWithRetry(roots.pop()!); @@ -880,21 +908,22 @@ describe("020 coverage completions", () => { binary: process.execPath, args: ["-e", [ `require("node:fs").writeFileSync(${JSON.stringify(startedPath)}, "started");`, - `setTimeout(() => process.stdout.write(${JSON.stringify(probeOutput)}), 200);`, + `process.stdout.write(${JSON.stringify(probeOutput)});`, ].join("")], }); - const beforeWrite = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "pre-write probe start"); - writeFileSync(fx.configPath, "include_apps_instructions = true\n", "utf8"); + const beforeWrite = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "pre-write probe start"); + writeFileSync(fx.configPath, "include_apps_instructions = true\n", "utf8"); - const afterWrite = await call("GET", "/api/codex-prompt/text", fx); - expect(afterWrite.body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const afterWrite = await call("GET", "/api/codex-prompt/text", fx); + expect(afterWrite.body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeWrite).body.ok).toBe(true); + expect(beforeWrite.body.ok).toBe(true); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.ok).toBe(true); @@ -918,27 +947,28 @@ describe("020 coverage completions", () => { `const prompt = fs.readFileSync(${JSON.stringify(selectedPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + prompt + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); const revisionBeforeEdit = await revision(fx); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "selected-variant probe start"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "selected-variant probe start"); - const edited = await call("PUT", "/api/codex-prompt/base", fx, { - id, title: "New", body: "new-body", revision: revisionBeforeEdit, - }); - expect(edited.status).toBe(200); - expect(await revision(fx)).toBe(revisionBeforeEdit); + const edited = await call("PUT", "/api/codex-prompt/base", fx, { + id, title: "New", body: "new-body", revision: revisionBeforeEdit, + }); + expect(edited.status).toBe(200); + expect(await revision(fx)).toBe(revisionBeforeEdit); - const afterEdit = await call("GET", "/api/codex-prompt/text", fx); - expect(afterEdit.body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const afterEdit = await call("GET", "/api/codex-prompt/text", fx); + expect(afterEdit.body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("# Old\nold-body"); + expect(beforeEdit.body.layers.skills.text).toBe("# Old\nold-body"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("# New\nnew-body"); @@ -968,24 +998,25 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(agentsPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), `${instructionFile} probe start`); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), `${instructionFile} probe start`); - // Nothing opencodex owns has changed: no config write, no store write, so - // the transaction revision and the selected base are identical here. - writeFileSync(agentsPath, "new-agent-text", "utf8"); + // Nothing opencodex owns has changed: no config write, no store write, so + // the transaction revision and the selected base are identical here. + writeFileSync(agentsPath, "new-agent-text", "utf8"); - const afterEdit = await call("GET", "/api/codex-prompt/text", fx); - expect(afterEdit.body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const afterEdit = await call("GET", "/api/codex-prompt/text", fx); + expect(afterEdit.body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-agent-text"); + expect(beforeEdit.body.layers.skills.text).toBe("old-agent-text"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-agent-text"); @@ -1006,33 +1037,35 @@ describe("020 coverage completions", () => { `const doc = fs.existsSync(${JSON.stringify(agentsPath)}) ? fs.readFileSync(${JSON.stringify(agentsPath)}, "utf8") : "\\u0000absent";`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); // absent -> present must move the key, so a probe started with no AGENTS.md // cannot be joined once one exists. - const beforeCreate = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "absent-state probe start"); - writeFileSync(agentsPath, "created-text", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const beforeCreate = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "absent-state probe start"); + writeFileSync(agentsPath, "created-text", "utf8"); + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); }); - expect((await beforeCreate).body.layers.skills.text).toBe("\u0000absent"); + expect(beforeCreate.body.layers.skills.text).toBe("\u0000absent"); const present = await call("GET", "/api/codex-prompt/text", fx); expect(present.body.layers.skills.text).toBe("created-text"); // present -> absent is the same requirement in reverse. - const beforeDelete = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => readFileSync(startedPath, "utf8").trim().split(/\r?\n/).length === 3, "present-state probe start"); - rmSync(agentsPath); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const beforeDelete = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => readFileSync(startedPath, "utf8").trim().split(/\r?\n/).length === 3, "present-state probe start"); + rmSync(agentsPath); + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); }); - expect((await beforeDelete).body.layers.skills.text).toBe("created-text"); + expect(beforeDelete.body.layers.skills.text).toBe("created-text"); }); /** @@ -1065,21 +1098,22 @@ describe("020 coverage completions", () => { `const doc = fs.existsSync(p) ? "present:" + fs.readFileSync(p, "utf8") : "missing";`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeTransition = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "transition probe start"); - if (transition.after === null) rmSync(agentsPath); - else writeFileSync(agentsPath, transition.after, "utf8"); + const beforeTransition = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "transition probe start"); + if (transition.after === null) rmSync(agentsPath); + else writeFileSync(agentsPath, transition.after, "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeTransition).body.layers.skills.text).toBe(`present:${transition.before}`); + expect(beforeTransition.body.layers.skills.text).toBe(`present:${transition.before}`); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe(transition.after === null ? "missing" : `present:${transition.after}`); @@ -1104,24 +1138,25 @@ describe("020 coverage completions", () => { `const doc = read(${JSON.stringify(overridePath)}) + "|" + read(${JSON.stringify(agentsPath)});`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeShift = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "framing probe start"); + const beforeShift = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "framing probe start"); - // Move the boundary: the concatenation of (name, contents) is byte-identical - // across this edit, so only a length-framed field distinguishes the two states. - writeFileSync(overridePath, "left\nAGENTS.md:right", "utf8"); - writeFileSync(agentsPath, "tail", "utf8"); + // Move the boundary: the concatenation of (name, contents) is byte-identical + // across this edit, so only a length-framed field distinguishes the two states. + writeFileSync(overridePath, "left\nAGENTS.md:right", "utf8"); + writeFileSync(agentsPath, "tail", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeShift).body.layers.skills.text).toBe("left|right\nAGENTS.md:tail"); + expect(beforeShift.body.layers.skills.text).toBe("left|right\nAGENTS.md:tail"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("left\nAGENTS.md:right|tail"); @@ -1157,20 +1192,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(externalPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "external base probe start"); - writeFileSync(externalPath, "new-external", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "external base probe start"); + writeFileSync(externalPath, "new-external", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-external"); + expect(beforeEdit.body.layers.skills.text).toBe("old-external"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-external"); @@ -1197,20 +1233,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(externalPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "relative base probe start"); - writeFileSync(externalPath, "new-relative", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "relative base probe start"); + writeFileSync(externalPath, "new-relative", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-relative"); + expect(beforeEdit.body.layers.skills.text).toBe("old-relative"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-relative"); @@ -1249,20 +1286,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(teamPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "fallback doc probe start"); - writeFileSync(teamPath, "new-team", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "fallback doc probe start"); + writeFileSync(teamPath, "new-team", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-team"); + expect(beforeEdit.body.layers.skills.text).toBe("old-team"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-team"); @@ -1297,21 +1335,22 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(parentDoc)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); const nested: Fixture = { ...fx, decoyHome: nestedHome }; - const beforeEdit = call("GET", "/api/codex-prompt/text", nested); - await waitUntil(() => existsSync(startedPath), "parent doc probe start"); - writeFileSync(parentDoc, "new-parent", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(nested, async () => { + await waitUntil(() => existsSync(startedPath), "parent doc probe start"); + writeFileSync(parentDoc, "new-parent", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", nested)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", nested)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-parent"); + expect(beforeEdit.body.layers.skills.text).toBe("old-parent"); const fresh = await call("GET", "/api/codex-prompt/text", nested); expect(fresh.body.layers.skills.text).toBe("new-parent"); @@ -1338,21 +1377,22 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(parentDoc)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); const nested: Fixture = { ...fx, decoyHome: nestedHome }; - const beforeEdit = call("GET", "/api/codex-prompt/text", nested); - await waitUntil(() => existsSync(startedPath), "marker doc probe start"); - writeFileSync(parentDoc, "new-marker", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(nested, async () => { + await waitUntil(() => existsSync(startedPath), "marker doc probe start"); + writeFileSync(parentDoc, "new-marker", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", nested)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", nested)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-marker"); + expect(beforeEdit.body.layers.skills.text).toBe("old-marker"); const fresh = await call("GET", "/api/codex-prompt/text", nested); expect(fresh.body.layers.skills.text).toBe("new-marker"); @@ -1385,20 +1425,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(teamPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "unparseable-config probe start"); - writeFileSync(teamPath, "new-unparseable", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "unparseable-config probe start"); + writeFileSync(teamPath, "new-unparseable", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-unparseable"); + expect(beforeEdit.body.layers.skills.text).toBe("old-unparseable"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-unparseable"); @@ -1422,20 +1463,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(manifest)}, "utf8").match(/description: (.*)/)[1];`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"<skills_instructions>" + doc + "</skills_instructions>"}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "skill manifest probe start"); - writeFileSync(manifest, "---\nname: probe-skill\ndescription: new-skill-text\n---\n", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "skill manifest probe start"); + writeFileSync(manifest, "---\nname: probe-skill\ndescription: new-skill-text\n---\n", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-skill-text"); + expect(beforeEdit.body.layers.skills.text).toBe("old-skill-text"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-skill-text"); diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 363bcc4798..32b01eac3c 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -6,8 +6,8 @@ * that a missing body is attributed to the right cause, because the dialog shows * that attribution to a user as an explanation. */ -import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync} from "node:fs"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -41,7 +41,30 @@ async function waitUntil(predicate: () => boolean, detail: string): Promise<void } } +function requireProcessId(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error("invalid published process id"); + return value; +} + +function readPublishedPid(path: string): number | undefined { + if (!existsSync(path)) return undefined; + const value = readFileSync(path, "utf8").trim(); + if (!/^\d+$/.test(value)) throw new Error("invalid published process id"); + return requireProcessId(Number(value)); +} + +async function waitForPublishedPid(path: string, detail: string): Promise<number> { + let pid: number | undefined; + await waitUntil(() => (pid = readPublishedPid(path)) !== undefined, detail); + return pid!; +} + +function publishPidSource(path: string): string { + return `const fs = require("node:fs"); const marker = ${JSON.stringify(path)}; const temporary = marker + "." + process.pid + ".tmp"; fs.writeFileSync(temporary, String(process.pid)); fs.renameSync(temporary, marker);`; +} + function isProcessAlive(pid: number): boolean { + requireProcessId(pid); try { process.kill(pid, 0); return true; @@ -61,6 +84,33 @@ afterEach(async () => { while (lifecycleRoots.length) removeTreeWithRetry(lifecycleRoots.pop()!); }); +test("PID markers are invisible until complete atomic publication", () => { + const marker = join(root(), "pid.txt"); + const temporary = marker + ".tmp"; + writeFileSync(temporary, "12"); + expect(readPublishedPid(marker)).toBeUndefined(); + writeFileSync(temporary, String(process.pid)); + renameSync(temporary, marker); + expect(readPublishedPid(marker)).toBe(process.pid); +}); + +test("malformed published PIDs never reach the process liveness check", () => { + const marker = join(root(), "pid.txt"); + const kill = spyOn(process, "kill"); + try { + for (const value of ["", "0", "-1", "1.5", "9007199254740992", "12junk"]) { + writeFileSync(marker, value); + expect(() => readPublishedPid(marker)).toThrow("invalid published process id"); + } + for (const pid of [0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => isProcessAlive(pid)).toThrow("invalid published process id"); + } + expect(kill).not.toHaveBeenCalled(); + } finally { + kill.mockRestore(); + } +}); + describe("section extraction", () => { test("a tag name containing a space is still matched", () => { // Codex renders `<permissions instructions>`, with a space. A [a-z_]+ pattern @@ -184,21 +234,21 @@ describe("prompt probe process lifecycle", () => { const pidPath = join(dir, "pid.txt"); const overlapPath = join(dir, "overlap.txt"); const hangingSource = [ - `require("node:fs").writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));`, + publishPidSource(pidPath), "setInterval(() => {}, 1_000);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", hangingSource] }); const controller = new AbortController(); const hanging = probePromptText(5_000, controller.signal); - await waitUntil(() => existsSync(pidPath), "hanging child pid"); - const pid = Number(readFileSync(pidPath, "utf8")); + const pid = await waitForPublishedPid(pidPath, "hanging child pid"); expect(isProcessAlive(pid)).toBe(true); controller.abort(); expect((await hanging).detail).toBe("prompt probe cancelled"); const replacementSource = [ - `const fs = require("node:fs"); const pid = Number(fs.readFileSync(${JSON.stringify(pidPath)}, "utf8"));`, + `const fs = require("node:fs"); const rawPid = fs.readFileSync(${JSON.stringify(pidPath)}, "utf8").trim(); const pid = Number(rawPid);`, + "if (!/^\\d+$/.test(rawPid) || !Number.isSafeInteger(pid) || pid <= 0) throw new Error(\"invalid published process id\");", "let priorProbeAlive = true;", "try { process.kill(pid, 0); } catch { priorProbeAlive = false; }", `if (priorProbeAlive) fs.writeFileSync(${JSON.stringify(overlapPath)}, "overlap");`, @@ -220,34 +270,52 @@ describe("prompt probe process lifecycle", () => { await waitUntil(() => !isProcessAlive(pid), "cancelled child exit"); }); - test("admission stays occupied between child exit and close handling", async () => { + async function exerciseCloseBoundary(injectFailure: boolean): Promise<void> { const pidPath = join(root(), "exited-parent-pid.txt"); let releaseClose!: () => void; setPromptTextProbeCloseBarrierForTests(new Promise<void>(resolve => { releaseClose = resolve; })); - const delayedCloseSource = [ - `const fs = require("node:fs");`, - `fs.writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));`, - `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)});`, - ].join(""); - setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", delayedCloseSource] }); - const first = probePromptText(2_000); - await waitUntil(() => existsSync(pidPath), "exit-close parent pid"); - const pid = Number(readFileSync(pidPath, "utf8")); - await waitUntil(() => !isProcessAlive(pid), "probe parent exit"); + let first: ReturnType<typeof probePromptText> | undefined; + try { + const delayedCloseSource = [ + publishPidSource(pidPath), + `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)});`, + ].join(""); + setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", delayedCloseSource] }); + first = probePromptText(2_000); + const pid = await waitForPublishedPid(pidPath, "exit-close parent pid"); + await waitUntil(() => !isProcessAlive(pid), "probe parent exit"); + if (injectFailure) throw new Error("fixture assertion failure before close release"); + + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + const blockedBeforeClose = await probePromptText(2_000); + expect(blockedBeforeClose.ok).toBe(false); + expect(blockedBeforeClose.detail).toBe("another prompt probe is still finishing; retry shortly"); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); + releaseClose(); + expect((await first).ok).toBe(true); + const afterClose = await probePromptText(2_000); + expect(afterClose.ok).toBe(true); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(2); + } finally { + releaseClose(); + try { if (first) await first; } finally { await resetPromptTextProbeForTests(); } + } + } + test("admission stays occupied between child exit and close handling", async () => { + await exerciseCloseBoundary(false); + }); + + test("a failure before close release leaves the probe reusable", async () => { + await expect(exerciseCloseBoundary(true)).rejects.toThrow("fixture assertion failure before close release"); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], }); - const blockedBeforeClose = await probePromptText(2_000); - - expect(blockedBeforeClose.ok).toBe(false); - expect(blockedBeforeClose.detail).toBe("another prompt probe is still finishing; retry shortly"); + expect((await probePromptText(2_000)).ok).toBe(true); expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - releaseClose(); - expect((await first).ok).toBe(true); - const afterClose = await probePromptText(2_000); - expect(afterClose.ok).toBe(true); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(2); }); }); diff --git a/tests/codex-integration/codex-quota-parser-parity.test.ts b/tests/codex-integration/codex-quota-parser-parity.test.ts index 172168ada8..a698525648 100644 --- a/tests/codex-integration/codex-quota-parser-parity.test.ts +++ b/tests/codex-integration/codex-quota-parser-parity.test.ts @@ -1,12 +1,56 @@ import { describe, expect, it } from "bun:test"; import { clearAccountQuota, + applyAccountQuotaFromUpstreamHeaders, + getAccountQuota, parseUpstreamQuotaHeaders, parseUsageQuota, setAccountQuotaFromParsed, } from "../../src/codex/quota"; import { codexPoolQuotaEvidence } from "../../src/routing/quota"; +describe("Spark quota survives partial header updates", () => { + it("keeps the WHAM Spark window when an ordinary response updates standard quota", () => { + clearAccountQuota(); + const refreshed = parseUsageQuota({ + rate_limit: { primary_window: { used_percent: 20, limit_window_seconds: 604_800 } }, + additional_rate_limits: [{ + limit_name: "GPT-5.3-Codex-Spark", + rate_limit: { primary_window: { used_percent: 30, reset_at: 2_000_000_000, limit_window_seconds: 604_800 } }, + }], + }); + setAccountQuotaFromParsed("spark-partial", refreshed); + applyAccountQuotaFromUpstreamHeaders("spark-partial", new Headers({ + "x-codex-primary-used-percent": "21", + "x-codex-primary-window-minutes": "10080", + })); + expect(getAccountQuota("spark-partial")?.weeklyPercent).toBe(21); + expect(getAccountQuota("spark-partial")?.customWindows).toEqual(refreshed?.customWindows); + }); + + it("replaces custom windows when supplied, including an explicit empty list", () => { + clearAccountQuota(); + setAccountQuotaFromParsed("spark-replace", { + customWindows: [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 30 }], + }); + const replacement = [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 0, resetAt: 2_000_000_000 }]; + setAccountQuotaFromParsed("spark-replace", { customWindows: replacement }); + expect(getAccountQuota("spark-replace")?.customWindows).toEqual(replacement); + setAccountQuotaFromParsed("spark-replace", { weeklyPercent: 21, customWindows: [] }); + expect(getAccountQuota("spark-replace")?.customWindows).toEqual([]); + }); + + it("does not carry custom windows across an account cache clear", () => { + clearAccountQuota(); + setAccountQuotaFromParsed("spark-clear", { + customWindows: [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 30 }], + }); + clearAccountQuota("spark-clear"); + setAccountQuotaFromParsed("spark-clear", { weeklyPercent: 21 }); + expect(getAccountQuota("spark-clear")?.customWindows).toBeUndefined(); + }); +}); + /** * The two quota parsers, pinned against each other. * @@ -109,4 +153,3 @@ describe("routing headroom accounts for the burst window", () => { expect(evidence.headroom).toBeLessThanOrEqual(0.05); }); }); - diff --git a/tests/codex-integration/codex-reset-credit-auto-redeem.test.ts b/tests/codex-integration/codex-reset-credit-auto-redeem.test.ts index f19f208cc1..eb7e213420 100644 --- a/tests/codex-integration/codex-reset-credit-auto-redeem.test.ts +++ b/tests/codex-integration/codex-reset-credit-auto-redeem.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync} from "node:fs"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { createResetCreditAutoRedeemer, planAutoRedeem, @@ -9,6 +11,8 @@ import { type ResetCredit, } from "../../src/codex/reset-credit-auto-redeem"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; +import { readConfigGeneration } from "../../src/config"; const T0 = Date.parse("2026-09-02T10:00:00Z"); const MIN = 60_000; @@ -18,19 +22,20 @@ const credit = (expiresInMin: number, grantedAt = "2026-09-01T00:00:00Z"): Reset }); /** Fake clock + manual timer: fire() runs the pending timer at its due time. */ -function harness(opts: { credits: () => ResetCredit[]; enabled?: () => boolean; lead?: number; journalFile: string; consumeCode?: string; consumeThrows?: boolean }) { +function harness(opts: { credits: () => ResetCredit[]; enabled?: () => boolean; lead?: number; journalFile: string; accountId?: string; consumeCode?: string; consumeThrows?: boolean; consume?: (id: string) => Promise<{ code: string }> }) { let now = T0; let pending: { fn: () => void; at: number } | null = null; const consumed: string[] = []; const logs: string[] = []; let inspects = 0; const redeemer = createResetCreditAutoRedeemer({ - accountId: "acct-main", + accountId: opts.accountId ?? "acct-main", settings: () => ({ enabled: opts.enabled ? opts.enabled() : true, leadTimeMinutes: opts.lead ?? 10 }), inspect: async () => { inspects += 1; return { credits: opts.credits() }; }, consume: async id => { if (opts.consumeThrows) throw new Error("socket hangup"); consumed.push(id); + if (opts.consume) return opts.consume(id); return { code: opts.consumeCode ?? "reset" }; }, now: () => now, @@ -43,14 +48,24 @@ function harness(opts: { credits: () => ResetCredit[]; enabled?: () => boolean; redeemer, consumed, logs, inspects: () => inspects, pendingAt: () => pending?.at ?? null, - advanceAndFire: async () => { if (!pending) throw new Error("no timer"); now = pending.at; const fn = pending.fn; pending = null; fn(); await new Promise(r => setTimeout(r, 5)); }, + // The timer synchronously installs inFlight; join that tick instead of sleeping. + advanceAndFire: async () => { if (!pending) throw new Error("no timer"); now = pending.at; const fn = pending.fn; pending = null; fn(); return await redeemer.tick(); }, setNow: (t: number) => { now = t; }, }; } let dir = ""; -beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "ocx-auto-redeem-")); }); -afterEach(() => { removeTreeWithRetry(dir); }); +let oldHome: string | undefined; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ocx-auto-redeem-")); + oldHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; +}); +afterEach(() => { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(dir); +}); describe("reset-credit auto-redeem settings + plan (#822)", () => { test("default off; malformed reads as off; lead time clamped", () => { @@ -72,6 +87,20 @@ describe("reset-credit auto-redeem settings + plan (#822)", () => { }); describe("reset-credit auto-redeemer runtime (#822)", () => { + test("a disabled tick creates neither a journal nor a mutation coordinator", async () => { + const journalFile = join(dir, "reset-credit-auto-redeem.json"); + expect(readdirSync(dir)).toEqual([]); + const h = harness({ credits: () => [credit(30)], enabled: () => false, journalFile }); + h.setNow(T0 + 20 * MIN); + expect(await h.redeemer.tick()).toEqual({ kind: "disabled" }); + expect(h.inspects()).toBe(0); + expect(h.consumed).toHaveLength(0); + expect(h.pendingAt()).toBeNull(); + expect(existsSync(journalFile)).toBe(false); + expect(existsSync(join(dir, "config-mutation.sqlite"))).toBe(false); + expect(readdirSync(dir)).toEqual([]); + }); + test("schedules at expiry minus lead, re-reads before dispatch, journals the request id first", async () => { const journalFile = join(dir, "j.json"); const h = harness({ credits: () => [credit(30)], journalFile }); @@ -150,6 +179,388 @@ describe("reset-credit auto-redeemer runtime (#822)", () => { expect(h.consumed).toHaveLength(0); }); + test("settling a delayed consume preserves a peer's settled journal entry", async () => { + const journalFile = join(dir, "j.json"); + let entered!: () => void; + let release!: () => void; + const started = new Promise<void>(resolve => { entered = resolve; }); + const gate = new Promise<void>(resolve => { release = resolve; }); + const a = harness({ credits: () => [credit(30)], journalFile, accountId: "acct-a", consume: async () => { + entered(); + await gate; + return { code: "reset" }; + } }); + const b = harness({ credits: () => [credit(30)], journalFile, accountId: "acct-b" }); + a.setNow(T0 + 20 * MIN); + b.setNow(T0 + 20 * MIN); + const first = a.redeemer.tick(); + try { + await Promise.race([started, first.then(() => { throw new Error("first consume was not entered"); })]); + expect((await b.redeemer.tick()).kind).toBe("dispatched"); + } finally { + release(); + await first; + } + expect((await first).kind).toBe("dispatched"); + const entries = JSON.parse(readFileSync(journalFile, "utf8")).entries as Array<{ redeemRequestId: string; state: string }>; + expect(entries).toHaveLength(2); + expect(entries.map(entry => entry.redeemRequestId).sort()).toEqual([...a.consumed, ...b.consumed].sort()); + expect(entries.every(entry => entry.state === "settled")).toBe(true); + expect((await b.redeemer.tick()).kind).toBe("skipped"); + expect(b.consumed).toHaveLength(1); + }); + + test("a separate SQLite writer blocks reservation before any consume", async () => { + const journalFile = join(dir, "j.json"); + const h = harness({ credits: () => [credit(30)], journalFile }); + h.setNow(T0 + 20 * MIN); + expect(readConfigGeneration().kind).toBe("ready"); + const holder = new Database(join(dir, "config-mutation.sqlite"), { readwrite: true, create: false }); + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + expect((await h.redeemer.tick()).kind).toBe("error"); + expect(h.consumed).toHaveLength(0); + expect(existsSync(journalFile)).toBe(false); + expect(h.pendingAt()).toBe(T0 + 20 * MIN + 1_000); + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } + h.setNow(T0 + 20 * MIN + 1_000); + expect((await h.redeemer.tick()).kind).toBe("dispatched"); + expect(h.consumed).toHaveLength(1); + const entries = JSON.parse(readFileSync(journalFile, "utf8")).entries; + expect(entries).toHaveLength(1); + expect(entries[0].redeemRequestId).toBe(h.consumed[0]); + expect(entries[0].state).toBe("settled"); + }); + + test("a SQLite reader blocks COMMIT after reservation publication and retries the same id", async () => { + const journalFile = join(dir, "j.json"); + const databaseFile = join(dir, "config-mutation.sqlite"); + expect(existsSync(databaseFile)).toBe(false); + const reader = new Database(databaseFile, { create: true }); + const h = harness({ credits: () => [credit(30)], journalFile }); + h.setNow(T0 + 20 * MIN); + let reservationId = ""; + try { + // No readConfigGeneration pre-initialization: the coordinator's first acquisition + // must write its schema, so COMMIT needs an exclusive rollback-journal lock. + reader.exec("PRAGMA journal_mode = DELETE; PRAGMA busy_timeout = 0"); + reader.exec("BEGIN; CREATE TABLE reader_fixture (value INTEGER); INSERT INTO reader_fixture VALUES (1); COMMIT"); + expect(reader.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: "delete" }); + expect(reader.query("SELECT name FROM sqlite_master WHERE name = 'config_generation'").all()).toEqual([]); + reader.exec("BEGIN"); + // BEGIN alone holds no read lock. This SELECT materializes the read transaction. + expect(reader.query("SELECT value FROM reader_fixture").all()).toEqual([{ value: 1 }]); + expect(reader.inTransaction).toBe(true); + const outcome = await h.redeemer.tick(); + expect(outcome).toEqual({ kind: "error", message: expect.stringMatching(/database (?:is|table is) locked/i) }); + expect(h.consumed).toHaveLength(0); + // An acquisition failure cannot publish this row: the callback ran before COMMIT failed. + const entries = JSON.parse(readFileSync(journalFile, "utf8")).entries; + expect(entries).toHaveLength(1); + expect(entries[0].state).toBe("dispatched"); + reservationId = entries[0].redeemRequestId; + expect(reservationId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(h.pendingAt()).toBe(T0 + 20 * MIN + 1_000); + } finally { + try { if (reader.inTransaction) reader.exec("ROLLBACK"); } finally { reader.close(); } + } + expect(await h.advanceAndFire()).toEqual({ kind: "dispatched", code: "reset", redeemRequestId: reservationId }); + expect(h.consumed).toEqual([reservationId]); + const entries = JSON.parse(readFileSync(journalFile, "utf8")).entries; + expect(entries).toHaveLength(1); + expect(entries[0].redeemRequestId).toBe(reservationId); + expect(entries[0].state).toBe("settled"); + }); + + test("two processes reserve one durable id before either consume settles", async () => { + const journalFile = join(dir, "j.json"); + const moduleUrl = pathToFileURL(repoPath("src/codex/reset-credit-auto-redeem.ts")).href; + const deadline = performance.now() + 25_000; + const markerPath = (name: string) => join(dir, name + ".json"); + const publish = (name: string) => { + const path = markerPath(name); + const temporary = path + "." + process.pid + ".tmp"; + writeFileSync(temporary, JSON.stringify({ pid: process.pid })); + renameSync(temporary, path); + }; + const launch = (worker: string) => { + const source = ` + import { existsSync, writeFileSync, renameSync } from "node:fs"; + import { join } from "node:path"; + import { createResetCreditAutoRedeemer } from ${JSON.stringify(moduleUrl)}; + const home = ${JSON.stringify(dir)}; + const worker = ${JSON.stringify(worker)}; + const deadline = performance.now() + 20_000; + const marker = name => join(home, name + ".json"); + const publish = (name, value) => { + const path = marker(name); + const temporary = path + "." + process.pid + ".tmp"; + writeFileSync(temporary, JSON.stringify({ ...value, pid: process.pid })); + renameSync(temporary, path); + }; + const waitFor = async name => { + while (!existsSync(marker(name))) { + if (performance.now() >= deadline) throw new Error("timed out waiting for " + name); + await Bun.sleep(10); + } + }; + const consumes = []; + const retries = []; + let scheduledMs = null; + const redeemer = createResetCreditAutoRedeemer({ + accountId: "acct-process-fixture", + journalFile: ${JSON.stringify(journalFile)}, + settings: () => ({ enabled: true, leadTimeMinutes: 10 }), + inspect: async () => ({ credits: [${JSON.stringify(credit(30))}] }), + now: () => ${T0 + 20 * MIN}, + // Only the loop below owns ticks; recorded timers cannot launch overlapping work. + setTimer: (_fn, ms) => { scheduledMs = ms; return 1; }, + clearTimer: () => { scheduledMs = null; }, + log: () => {}, + consume: async redeemRequestId => { + consumes.push(redeemRequestId); + if (consumes.length !== 1) throw new Error("unexpected repeated consume"); + publish(worker + "-consume", { redeemRequestId }); + await waitFor(worker + "-release"); + return { code: "reset" }; + }, + }); + try { + publish(worker + "-ready", {}); + await waitFor("start"); + let outcome; + while (true) { + if (performance.now() >= deadline) throw new Error("reservation contention deadline exceeded"); + scheduledMs = null; + outcome = await redeemer.tick(); + if (outcome.kind === "dispatched") break; + const contention = outcome.kind === "error" && ( + outcome.message === "Config mutation already in progress" + || /database (?:is|table is) locked/i.test(outcome.message) + ); + if (!contention || scheduledMs !== 1000 || consumes.length !== 0) { + throw new Error("unexpected tick: " + JSON.stringify({ outcome, scheduledMs, consumes })); + } + retries.push({ message: outcome.message, scheduledMs }); + // Honor the recorded contention delay; never retry arbitrary errors or settlement. + await Bun.sleep(scheduledMs); + } + publish(worker + "-result", { outcome, consumes, retries }); + } catch (error) { + publish(worker + "-result", { error: String(error), consumes, retries }); + console.error(error); + process.exitCode = 1; + } finally { + redeemer.stop(); + } + `; + const child = Bun.spawn([process.execPath, "-e", source], { + cwd: repoPath(), + env: { ...process.env, OPENCODEX_HOME: dir }, + stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + const output = { stdout: "", stderr: "" }; + const drain = async (stream: ReadableStream<Uint8Array>, key: "stdout" | "stderr") => { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + output[key] += decoder.decode(value, { stream: true }); + } + output[key] += decoder.decode(); + } catch (error) { + output[key] += "\npipe read failed: " + String(error); + } finally { reader.releaseLock(); } + }; + // Start draining both pipes immediately, including while waiting at the barriers. + const drained = Promise.all([drain(child.stdout, "stdout"), drain(child.stderr, "stderr")]); + return { worker, child, output, drained }; + }; + const children: ReturnType<typeof launch>[] = []; + const released = new Set<string>(); + const diagnostics = () => children.map(({ worker, child, output }) => + `${worker} pid=${child.pid} exit=${child.exitCode}\nstdout: ${output.stdout}\nstderr: ${output.stderr}`).join("\n"); + const waitUntil = async (label: string, ready: () => boolean) => { + while (true) { + for (const { worker, child } of children) { + if (child.exitCode !== null && (!released.has(worker) || child.exitCode !== 0)) { + throw new Error(`premature child exit waiting for ${label}\n${diagnostics()}`); + } + } + if (ready()) return; + if (performance.now() >= deadline) throw new Error(`timed out waiting for ${label}\n${diagnostics()}`); + await Bun.sleep(10); + } + }; + const readMarker = (name: string) => JSON.parse(readFileSync(markerPath(name), "utf8")); + try { + children.push(launch("a")); + children.push(launch("b")); + await waitUntil("both ready", () => children.every(({ worker }) => existsSync(markerPath(worker + "-ready")))); + for (const { worker, child } of children) expect(readMarker(worker + "-ready").pid).toBe(child.pid); + expect(children[0]!.child.pid).not.toBe(children[1]!.child.pid); + expect(existsSync(journalFile)).toBe(false); + publish("start"); + await waitUntil("both consumes", () => children.every(({ worker }) => existsSync(markerPath(worker + "-consume")))); + const ids = children.map(({ worker, child }) => { + const marker = readMarker(worker + "-consume"); + expect(marker.pid).toBe(child.pid); + expect(marker.redeemRequestId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(existsSync(markerPath(worker + "-result"))).toBe(false); + return marker.redeemRequestId as string; + }); + expect(new Set(ids).size).toBe(1); + const reserved = JSON.parse(readFileSync(journalFile, "utf8")).entries; + expect(reserved).toHaveLength(1); + expect(reserved[0].redeemRequestId).toBe(ids[0]); + expect(reserved[0].state).toBe("dispatched"); + // Release one child at a time so settlement needs no timing-dependent retries. + for (const { worker, child } of children) { + released.add(worker); + publish(worker + "-release"); + await waitUntil(worker + " result", () => existsSync(markerPath(worker + "-result"))); + const result = readMarker(worker + "-result"); + expect(result.pid).toBe(child.pid); + expect(result.error).toBeUndefined(); + expect(result.outcome).toEqual({ kind: "dispatched", code: "reset", redeemRequestId: ids[0] }); + expect(result.consumes).toEqual([ids[0]]); + await waitUntil(worker + " exit", () => child.exitCode !== null); + expect(await child.exited).toBe(0); + } + const settled = JSON.parse(readFileSync(journalFile, "utf8")).entries; + expect(settled).toHaveLength(1); + expect(settled[0].redeemRequestId).toBe(ids[0]); + expect(settled[0].state).toBe("settled"); + } catch (error) { + throw new Error(`${String(error)}\n${diagnostics()}`); + } finally { + try { + for (const { worker } of children) { + if (!existsSync(markerPath(worker + "-release"))) publish(worker + "-release"); + } + } finally { + // Start every cleanup even if another child's kill races its natural exit. + const cleanup = await Promise.allSettled(children.map(async ({ child, drained }) => { + try { + if (child.exitCode === null) child.kill("SIGKILL"); + } finally { + await child.exited; + await drained; + } + })); + const failedCleanup = cleanup.filter(result => result.status === "rejected"); + if (failedCleanup.length > 0) throw new AggregateError(failedCleanup.map(result => result.reason), "journal fixture child cleanup failed"); + } + } + }, 35_000); + + test("a peer that observes a settled credit keeps checking for future credits", async () => { + const journalFile = join(dir, "j.json"); + const first = harness({ credits: () => [credit(30)], journalFile }); + let peerCredits = [credit(30)]; + const peer = harness({ credits: () => peerCredits, journalFile }); + first.setNow(T0 + 20 * MIN); + peer.setNow(T0 + 20 * MIN); + expect((await first.redeemer.tick()).kind).toBe("dispatched"); + expect((await peer.redeemer.tick()).kind).toBe("skipped"); + expect(peer.consumed).toHaveLength(0); + expect(peer.pendingAt()).toBe(T0 + 35 * MIN); + const futureCredit = credit(45, "2026-09-02T10:30:00Z"); + peerCredits = [futureCredit]; + const outcome = await peer.advanceAndFire(); + expect(outcome).toEqual({ kind: "dispatched", code: "reset", redeemRequestId: expect.any(String) }); + expect(peer.consumed).toHaveLength(1); + expect(peer.consumed[0]).not.toBe(first.consumed[0]); + const entries = JSON.parse(readFileSync(journalFile, "utf8")).entries; + expect(entries).toHaveLength(2); + expect(entries.map((entry: { redeemRequestId: string }) => entry.redeemRequestId).sort()).toEqual([...first.consumed, ...peer.consumed].sort()); + expect(entries.find((entry: { redeemRequestId: string }) => entry.redeemRequestId === peer.consumed[0])).toMatchObject({ + grantedAt: futureCredit.granted_at, expiresAt: futureCredit.expires_at, state: "settled", + }); + }); + + test("settlement contention keeps the reserved request id for a later retry", async () => { + const journalFile = join(dir, "j.json"); + let holder: Database | null = null; + let attempts = 0; + const h = harness({ credits: () => [credit(30)], journalFile, consume: async () => { + if (attempts++ === 0) { + holder = new Database(join(dir, "config-mutation.sqlite"), { readwrite: true, create: false }); + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + } + return { code: "reset" }; + } }); + h.setNow(T0 + 20 * MIN); + try { + expect((await h.redeemer.tick()).kind).toBe("error"); + const entries = JSON.parse(readFileSync(journalFile, "utf8")).entries; + expect(entries).toHaveLength(1); + expect(entries[0].state).toBe("dispatched"); + expect(entries[0].redeemRequestId).toBe(h.consumed[0]); + expect(h.pendingAt()).toBe(T0 + 20 * MIN + 1_000); + } finally { + if (holder) { + (holder as Database).exec("ROLLBACK"); + (holder as Database).close(); + } + } + h.setNow(T0 + 20 * MIN + 1_000); + expect((await h.redeemer.tick()).kind).toBe("dispatched"); + expect(h.consumed).toHaveLength(2); + expect(h.consumed[0]).toBe(h.consumed[1]); + expect(JSON.parse(readFileSync(journalFile, "utf8")).entries[0].state).toBe("settled"); + }); + + test("journal retention uses the redeemer's injected clock", async () => { + const start = Date.parse("2000-01-01T00:00:00Z"); + const journalFile = join(dir, "j.json"); + const h = harness({ journalFile, credits: () => [{ + granted_at: "1999-12-31T00:00:00Z", + expires_at: new Date(start + 30 * MIN).toISOString(), + }] }); + h.setNow(start + 20 * MIN); + expect((await h.redeemer.tick()).kind).toBe("dispatched"); + const entries = JSON.parse(readFileSync(journalFile, "utf8")).entries; + expect(entries).toHaveLength(1); + expect(entries[0].updatedAt).toBe(start + 20 * MIN); + }); + + test("a persistent reservation write failure uses the idle retry interval", async () => { + const journalFile = join(dir, "journal-directory"); + mkdirSync(journalFile); + const h = harness({ credits: () => [credit(30)], journalFile }); + h.setNow(T0 + 20 * MIN); + expect((await h.redeemer.tick()).kind).toBe("error"); + expect(h.consumed).toHaveLength(0); + expect(h.pendingAt()).toBe(T0 + 35 * MIN); + }); + + for (const changedReservation of ["missing", "replaced"]) { + test(`settlement rejects a ${changedReservation} reservation without overwriting it`, async () => { + const journalFile = join(dir, "j.json"); + let replacement = ""; + const h = harness({ credits: () => [credit(30)], journalFile, consume: async () => { + const journal = JSON.parse(readFileSync(journalFile, "utf8")); + if (changedReservation === "missing") journal.entries = []; + else journal.entries[0].redeemRequestId = "replacement-request"; + replacement = JSON.stringify(journal); + writeFileSync(journalFile, replacement); + return { code: "reset" }; + } }); + h.setNow(T0 + 20 * MIN); + const outcome = await h.redeemer.tick(); + expect(outcome).toEqual({ kind: "error", message: "auto-redeem journal reservation changed before settlement" }); + expect(h.consumed).toHaveLength(1); + expect(readFileSync(journalFile, "utf8")).toBe(replacement); + expect(h.pendingAt()).toBe(T0 + 35 * MIN); + }); + } + test("stop clears the timer", async () => { const h = harness({ credits: () => [credit(30)], journalFile: join(dir, "j.json") }); await h.redeemer.tick(); diff --git a/tests/codex-integration/codex-retained-root-serialization.test.ts b/tests/codex-integration/codex-retained-root-serialization.test.ts index 428fe5a257..f97877ed90 100644 --- a/tests/codex-integration/codex-retained-root-serialization.test.ts +++ b/tests/codex-integration/codex-retained-root-serialization.test.ts @@ -19,12 +19,12 @@ import { import { claimOwnedServiceHome, withOwnedServiceHomePreload } from "../helpers/owned-service-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; -import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { watchdogMs } from "../helpers/ci-watchdog"; const repoRoot = resolveRepoRoot(); const sandboxes: Sandbox[] = []; -const CHILD_MARKER_BUDGET_MS = SPAWN_BUDGET_MS - 5_000; -const TWO_CHILD_TEST_BUDGET_MS = 2 * SPAWN_BUDGET_MS + 10_000; interface Sandbox { readonly root: string; @@ -112,7 +112,7 @@ function makeSandbox(prefix: string): Sandbox { */ async function teardownSandbox(sandbox: Sandbox): Promise<void> { for (const marker of sandbox.releaseMarkers) { - writeReleaseMarker(marker); + try { writeFileSync(marker, "release"); } catch { /* root may already be gone */ } } for (const child of sandbox.children) { if (child.exitCode === null) child.kill(); @@ -121,18 +121,25 @@ async function teardownSandbox(sandbox: Sandbox): Promise<void> { sandbox.children.clear(); } -function writeReleaseMarker(marker: string): void { - try { - writeFileSync(marker, "release"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } -} - function sandboxChildEnv(sandbox: Sandbox): Record<string, string> { return { ...sandbox.env, ...sandbox.serviceManagerEnv }; } +interface ChildResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** One consumer per pipe; barrier diagnostics and final assertions share the result. */ +function captureChildResult(child: ReturnType<typeof Bun.spawn>): Promise<ChildResult> { + return Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]).then(([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr })); +} + /** * Wait for a child to reach its barrier, failing fast with its output if it exits * first. The exit branch is a REJECTING promise, so while the race is pending an @@ -144,16 +151,30 @@ function sandboxChildEnv(sandbox: Sandbox): Record<string, string> { * no-op catch attached up front marks that late rejection handled without * changing what the race sees. */ -async function raceBarrier(child: ReturnType<typeof Bun.spawn>, barrier: Promise<void>): Promise<void> { - const exitedEarly = child.exited.then(async exitCode => { - const stdout = await new Response(child.stdout).text(); - const stderr = await new Response(child.stderr).text(); +async function raceBarrier(result: Promise<ChildResult>, barrier: Promise<void>): Promise<void> { + const exitedEarly = result.then(({ exitCode, stdout, stderr }) => { throw new Error(`sync exited before provider barrier (${exitCode})\nstdout=${stdout}\nstderr=${stderr}`); }); exitedEarly.catch(() => undefined); await Promise.race([barrier, exitedEarly]); } +test("barrier diagnostics retain both pipes when the child exits first", async () => { + const sandbox = makeSandbox("ocx-retained-early-exit-"); + const child = Bun.spawn([process.execPath, "--eval", ` + process.stdout.write("fixture-stdout\\n"); + process.stderr.write("fixture-stderr\\n"); + process.exitCode = 7; + `], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); + sandbox.children.add(child); + const result = captureChildResult(child); + + await expect(raceBarrier(result, new Promise<void>(() => {}))).rejects.toThrow( + "sync exited before provider barrier (7)\nstdout=fixture-stdout\n\nstderr=fixture-stderr\n", + ); + expect(await result).toEqual({ exitCode: 7, stdout: "fixture-stdout\n", stderr: "fixture-stderr\n" }); +}, SPAWN_BUDGET_MS); + // A `bun --eval` child on a loaded windows-latest shard takes 8-11 s just to boot and // reach its marker (runs 33590540220 and 33605898170), so a 10 s wait was the coin flip, // not the child. Every caller passes a deadline that sits inside its own test budget so @@ -210,9 +231,9 @@ async function holdCatalogLock(sandbox: Sandbox): Promise<{ }); sandbox.children.add(child); sandbox.releaseMarkers.add(release); - await waitForPath(ready, CHILD_MARKER_BUDGET_MS); + await waitForPath(ready, INTERNAL_DEADLINE_MS); return { - release: () => writeReleaseMarker(release), + release: () => { try { writeFileSync(release, "release"); } catch { /* teardown may have released already */ } }, child, }; } @@ -284,7 +305,7 @@ test("startup and CLI sync-cache cannot write models_cache while another process // the CLI sync-cache in series), two of them importing the server/CLI graphs at // 8-11 s each on windows-latest (see waitForPath). 15 s timed out on CI run // 33920624827; the local timing (~450 ms) is not what this number is for. -}, TWO_CHILD_TEST_BUDGET_MS); +}, SPAWN_BUDGET_MS); test("native restore cannot read-transform-write the catalog while another process owns K", async () => { const sandbox = makeSandbox("ocx-retained-restore-"); @@ -307,7 +328,7 @@ test("native restore cannot read-transform-write the catalog while another proce holder.release(); expect(await holder.child.exited).toBe(0); } -}, TWO_CHILD_TEST_BUDGET_MS); +}); async function runPublisher( sandbox: Sandbox, @@ -347,11 +368,13 @@ for (const publisher of ["convergence", "retained"] as const) { port: 0, fetch: async request => { if (!new URL(request.url).pathname.endsWith("/models")) return new Response("not found", { status: 404 }); - if (requests++ === 0) { + const first = requests++ === 0; + if (first) { writeFileSync(requested, "requested"); while (!existsSync(release)) await Bun.sleep(5); } - return Response.json({ data: [{ id: "race-model" }] }); + // Distinct snapshots make a stale publish observable in the final catalog. + return Response.json({ data: [{ id: first ? "race-model" : "newer-race-model" }] }); }, }); const config = { @@ -379,29 +402,32 @@ for (const publisher of ["convergence", "retained"] as const) { console.log(JSON.stringify({ status: response.status, body: await response.json() })); `], sandbox.preloadPath)], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); sandbox.children.add(sync); - const syncStdout = new Response(sync.stdout).text(); - const syncStderr = new Response(sync.stderr).text(); + const syncResult = captureChildResult(sync); - await raceBarrier(sync, waitForPath(requested, CHILD_MARKER_BUDGET_MS)); + // This real child imports the management route before reaching /models. + // Keep the CI startup floor, then leave room for the second publisher process. + await raceBarrier(syncResult, waitForPath(requested, watchdogMs(INTERNAL_DEADLINE_MS))); const published = await runPublisher(sandbox, publisher, config); if (published.exitCode !== 0) { throw new Error(`${publisher} publisher failed\nstdout=${published.stdout}\nstderr=${published.stderr}`); } const newer = readFileSync(catalogPath, "utf8"); expect(newer).not.toBe(initial); + const newerSlugs = JSON.parse(newer).models.map((model: { slug: string }) => model.slug); + expect(newerSlugs).toContain("fixture/newer-race-model"); + expect(newerSlugs).not.toContain("fixture/race-model"); writeFileSync(release, "release"); - const [exitCode, stdout, stderr] = await Promise.all([ - sync.exited, - syncStdout, - syncStderr, - ]); + // Exercise the losing exit branch before the successful caller reads output. + await sync.exited; + const { exitCode, stdout, stderr } = await syncResult; expect({ exitCode, stdout, stderr }).toMatchObject({ exitCode: 0 }); + expect(JSON.parse(stdout).status).toBe(200); expect(readFileSync(catalogPath, "utf8")).toBe(newer); } finally { provider.stop(true); } - }, TWO_CHILD_TEST_BUDGET_MS); + }, SPAWN_BUDGET_MS * 2); } /** @@ -448,7 +474,6 @@ test("a persisted runtime selection moved by another process during the await bl const sync = Bun.spawn([process.execPath, ...withOwnedServiceHomePreload(["--eval", ` import { existsSync, writeFileSync } from "node:fs"; - globalThis[Symbol.for("opencodex.test.provider-fetch")] = true; const config = ${JSON.stringify(config)}; config.providers.together.fetch = async () => { writeFileSync(${JSON.stringify(requested)}, "requested"); @@ -459,10 +484,9 @@ test("a persisted runtime selection moved by another process during the await bl console.log(JSON.stringify(await syncCatalogModels(config))); `], sandbox.preloadPath)], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); sandbox.children.add(sync); - const syncStdout = new Response(sync.stdout).text(); - const syncStderr = new Response(sync.stderr).text(); + const syncResult = captureChildResult(sync); - await raceBarrier(sync, waitForPath(requested, CHILD_MARKER_BUDGET_MS)); + await raceBarrier(syncResult, waitForPath(requested, INTERNAL_DEADLINE_MS)); // Another process selects a different Codex runtime. No catalog byte changes. writeFileSync(runtimeStatePath, `${JSON.stringify({ @@ -474,15 +498,12 @@ test("a persisted runtime selection moved by another process during the await bl }, null, 2)}\n`); writeFileSync(release, "release"); - const [exitCode, stdout, stderr] = await Promise.all([ - sync.exited, - syncStdout, - syncStderr, - ]); + await sync.exited; + const { exitCode, stdout, stderr } = await syncResult; expect({ exitCode, stderr }).toMatchObject({ exitCode: 0 }); expect(JSON.parse(stdout.trim())).toMatchObject({ catalogWritten: false }); expect(readFileSync(catalogPath, "utf8")).toBe(initial); -}, TWO_CHILD_TEST_BUDGET_MS); +}, SPAWN_BUDGET_MS); /** * The post-approval seam, raced by two real processes through a real route. @@ -490,7 +511,7 @@ test("a persisted runtime selection moved by another process during the await bl * Every case above drives `/api/sync`, which is a retained root. This one drives * `PATCH /api/providers` — one of the sixteen management mutations that used to * reach a catalog write through `refreshCodexCatalogBestEffort`, whose entire - * error policy silently discarded every exception. The interesting window is AFTER the route has + * error policy was `catch {}`. The interesting window is AFTER the route has * already persisted its own mutation and approved the refresh: two processes * arriving there together must serialize, and neither may report `committed` * for bytes the other replaced. @@ -507,19 +528,6 @@ test("two processes at the post-approval management seam serialize instead of in const catalogPath = seedCatalog(sandbox); const seeded = readFileSync(catalogPath, "utf8"); const barrier = join(sandbox.root, "seam-barrier"); - const routeConfig = { - port: 10100, - defaultProvider: "together", - providers: { - together: { - adapter: "openai-chat", - baseUrl: "https://api.together.xyz/v1", - apiKey: "seam-key", - models: ["fallback-model"], - }, - }, - }; - writeFileSync(join(sandbox.opencodexHome, "config.json"), JSON.stringify(routeConfig, null, 2)); // Warm the config ownership + mutation database in a single process first. // Two cold processes otherwise race to create `.opencodex-owner.json` and both @@ -547,8 +555,18 @@ test("two processes at the post-approval management seam serialize instead of in } return Response.json({ data: [{ id: "seam-model-" + ${JSON.stringify(marker)} }] }); }; - const config = ${JSON.stringify(routeConfig)}; - const { mutatePersistedConfig, saveConfigPreservingClaudeCode } = await import("./src/config.ts"); + const config = { + port: 10100, + defaultProvider: "together", + providers: { + together: { + adapter: "openai-chat", + baseUrl: "https://api.together.xyz/v1", + apiKey: "seam-key", + models: ["fallback-model"], + }, + }, + }; const { handleManagementAPI } = await import("./src/server/management-api.ts"); const url = new URL("http://localhost/api/providers?name=together"); const req = new Request(url, { @@ -556,12 +574,9 @@ test("two processes at the post-approval management seam serialize instead of in headers: { Host: "localhost", "content-type": "application/json" }, body: JSON.stringify({ note: "seam-" + ${JSON.stringify(marker)} }), }); - const response = await handleManagementAPI(req, url, config, { - mutatePersistedConfig, - saveConfigPreservingClaudeCode, - }); + const response = await handleManagementAPI(req, url, config); const body = await response.json(); - console.log(JSON.stringify({ status: response.status, body, catalogRefresh: body.catalogRefresh })); + console.log(JSON.stringify({ status: response.status, catalogRefresh: body.catalogRefresh })); `; const isPreApprovalLoss = (stderr: string): boolean => @@ -638,14 +653,10 @@ test("two processes at the post-approval management seam serialize instead of in } const parsed = JSON.parse(result.stdout.trim()) as { status: number; - body?: { error?: string }; catalogRefresh: { status: string }; }; - if (parsed.status === 500 && parsed.body?.error === "management persistence unavailable") { - continue; - } // The route persisted its mutation, so it must answer 2xx no matter what the - // catalog attempt decided. A throw here would reproduce the old silent-catch failure + // catalog attempt decided. A throw here would be the old `catch {}` failure // inverted: a persisted change reported as a 500. expect(parsed.status).toBeGreaterThanOrEqual(200); expect(parsed.status).toBeLessThan(300); @@ -662,9 +673,7 @@ test("two processes at the post-approval management seam serialize instead of in // with a typed disposition and satisfy every assertion above. const dispositions = results! .filter(r => r.exitCode === 0) - .map(r => JSON.parse(r.stdout.trim()) as { status: number; body?: { error?: string }; catalogRefresh?: { status: string } }) - .filter(r => !(r.status === 500 && r.body?.error === "management persistence unavailable")) - .map(r => r.catalogRefresh?.status); + .map(r => (JSON.parse(r.stdout.trim()) as { catalogRefresh: { status: string } }).catalogRefresh.status); expect(dispositions).toContain("committed"); // A commit means the catalog really moved. @@ -677,4 +686,4 @@ test("two processes at the post-approval management seam serialize instead of in const fromA = slugs.some(s => s.includes("seam-model-a")); const fromB = slugs.some(s => s.includes("seam-model-b")); expect(fromA && fromB).toBe(false); -}, TWO_CHILD_TEST_BUDGET_MS); +}, SPAWN_BUDGET_MS); diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index ff8b6e33be..85ecb3a3fd 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { initializePersistedConfigIfMissing, saveConfig } from "../../src/config"; import { STORE_BUDGET_MS } from "../helpers/test-budget"; import { CODEX_FAILURE_WINDOW_MS, @@ -55,14 +54,77 @@ import { routeModel } from "../../src/router"; import { consumeForInspection } from "../../src/server/relay"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { flushConfigDirHardeningForTests } from "../../src/config/paths"; + +import { flushConfigDirHardeningForTests, hardenConfigDir } from "../../src/config/paths"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; let TEST_DIR = ""; let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; + const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +function installRoutingScratchHome(): void { + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-routing-")); + // Routing cases exercise account state, not the operating system ACL implementation. + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.CODEX_HOME = TEST_DIR; +} + +async function removeRoutingScratchHome(): Promise<void> { + const ownedDirectory = TEST_DIR; + TEST_DIR = ""; + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (ownedDirectory) removeTreeWithRetry(ownedDirectory); + } +} + +test.skipIf(process.platform !== "win32")("routing scratch cleanup waits for its outstanding hardening flight", async () => { + installRoutingScratchHome(); + const ownedDirectory = TEST_DIR; + let entered!: () => void; + let release!: () => void; + const started = new Promise<void>(resolve => { entered = resolve; }); + const gate = new Promise<void>(resolve => { release = resolve; }); + let cleanup: Promise<void> | undefined; + let deadline: ReturnType<typeof setTimeout> | undefined; + try { + setAsyncIcaclsRunnerForTests(async () => { entered(); await gate; return ICACLS_OK; }); + hardenConfigDir(); + await Promise.race([ + started, + new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new Error("hardening fixture did not start")), 5_000); }), + ]); + let cleaned = false; + cleanup = removeRoutingScratchHome().then(() => { cleaned = true; }); + await Promise.resolve(); + expect(cleaned).toBe(false); + expect(existsSync(ownedDirectory)).toBe(true); + release(); + await cleanup; + expect(cleaned).toBe(true); + expect(existsSync(ownedDirectory)).toBe(false); + } finally { + if (deadline !== undefined) clearTimeout(deadline); + release(); + if (cleanup) await cleanup; + else await removeRoutingScratchHome(); + } +}, STORE_BUDGET_MS); + + function makeConfig(overrides: Partial<OcxConfig> = {}): OcxConfig { return { providers: {}, @@ -94,15 +156,7 @@ function pendingInspectionStream(): ReadableStream<Uint8Array> { describe("codex routing", () => { beforeEach(() => { - previousOpencodexHome = process.env.OPENCODEX_HOME; - setIcaclsRunnerForTests(() => ICACLS_OK); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); - TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-codex-routing-")); - process.env.OPENCODEX_HOME = TEST_DIR; - // Isolate the main-account credential source: TEST_DIR has no auth.json, so the main - // account is deterministically absent (these cases test the pool-only scenario). - previousCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = TEST_DIR; + installRoutingScratchHome(); clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); @@ -114,28 +168,22 @@ describe("codex routing", () => { }); afterEach(async () => { - clearAccountQuota(); - clearCodexUpstreamHealth(); - clearThreadAccountMap(); - clearAccountNeedsReauth("a"); - clearAccountNeedsReauth("b"); - clearAccountNeedsReauth("c"); - await flushConfigDirHardeningForTests(); - setIcaclsRunnerForTests(null); - setAsyncIcaclsRunnerForTests(null); - if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousOpencodexHome; - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; - removeTreeWithRetry(TEST_DIR); - TEST_DIR = ""; + try { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + clearAccountNeedsReauth("c"); + } finally { + await removeRoutingScratchHome(); + } }); test("usage score uses the hottest known quota window", () => { expect(computeCodexUsageScore({ weeklyPercent: 81 })).toBe(81); expect(computeCodexUsageScore({ weeklyPercent: 15, monthlyPercent: 91 })).toBe(91); expect(computeCodexUsageScore({ weeklyPercent: 15, monthlyPercent: 20, shortPercent: 92 })).toBe(92); - expect(computeCodexUsageScore({ weeklyPercent: 15, fiveHourPercent: 92 })).toBe(92); expect(computeCodexUsageScore({ weeklyPercent: 15 })).toBe(15); }); @@ -147,8 +195,7 @@ describe("codex routing", () => { expect(computeCodexUsageScore({ shortPercent: 0 })).toBe(CODEX_UNKNOWN_USAGE_SCORE); expect(computeCodexUsageScore({ shortPercent: 87 })).toBe(CODEX_UNKNOWN_USAGE_SCORE); // Once a governing window is known, the burst still wins when it is hotter. - expect(computeCodexUsageScore({ weeklyPercent: 1, shortPercent: 100, - shortResetAt: Date.now() + 60_000 })).toBe(100); + expect(computeCodexUsageScore({ weeklyPercent: 1, shortPercent: 100 })).toBe(100); expect(computeCodexUsageScore({ weeklyPercent: 40, shortPercent: 0 })).toBe(40); }); @@ -166,12 +213,9 @@ describe("codex routing", () => { // inverted into a recovered account that stays excluded. expect(computeCodexUsageScore({ shortPercent: 100, shortResetAt: now - 60_000 }, undefined, now)) .toBe(CODEX_UNKNOWN_USAGE_SCORE); - expect(computeCodexUsageScore({ weeklyPercent: 20, shortPercent: 100, shortResetAt: now - 60_000 }, undefined, now)) - .toBe(20); // No resetAt at all cannot be aged, so it stays unknown: a wrongly-selected account // fails one request, a wrongly-excluded one is invisible until someone reads the pool. expect(computeCodexUsageScore({ shortPercent: 100 }, undefined, now)).toBe(CODEX_UNKNOWN_USAGE_SCORE); - expect(computeCodexUsageScore({ weeklyPercent: 20, shortPercent: 100 }, undefined, now)).toBe(20); // Still narrow: a non-terminal short-only reading is unchanged. expect(computeCodexUsageScore({ shortPercent: 99, shortResetAt: now + 60_000 }, undefined, now)) .toBe(CODEX_UNKNOWN_USAGE_SCORE); @@ -250,20 +294,7 @@ describe("codex routing", () => { // clock far from wall time is the point: a fixture whose now matches Date.now() cannot // tell a threaded clock from one that was dropped somewhere in the helper chain. const now = 1_700_000_000_000; - const config = makeConfig({ - activeCodexAccountId: "a", - defaultProvider: "test", - providers: { - test: { - adapter: "openai-responses", - baseUrl: "https://example.invalid/v1", - apiKey: "test-key", - }, - }, - }); - // Automatic quota moves persist by design. Seed the fixture through the same - // fail-closed initialization boundary production uses instead of weakening it. - expect(initializePersistedConfigIfMissing(config)).toBe("created"); + const config = makeConfig({ activeCodexAccountId: "a" }); // A is full for the next hour, recorded in SECONDS. B has ordinary headroom. setAccountQuotaFromParsed("a", { shortPercent: 100, shortResetAt: (now + 3_600_000) / 1000 }); @@ -305,20 +336,7 @@ describe("codex routing", () => { setAccountQuotaFromParsed("a", { shortPercent: 100 }); const now = Date.now(); updateAccountQuota("b", 3); - const config = makeConfig({ - activeCodexAccountId: "a", - defaultProvider: "test", - providers: { - test: { - adapter: "openai-responses", - baseUrl: "https://example.invalid/v1", - apiKey: "test-key", - }, - }, - }); - // Moving away from an exhausted account persists the active selection. Seed the same - // valid on-disk boundary used by production so this regression can run in isolation. - expect(initializePersistedConfigIfMissing(config)).toBe("created"); + const config = makeConfig({ activeCodexAccountId: "a" }); expect(resolveCodexAccountForThread("thread-storm-new", config, now)).toBe("b"); // A thread already bound to A must move too - this is the half the reporter saw as 118 @@ -329,7 +347,6 @@ describe("codex routing", () => { updateAccountQuota("a", 10); updateAccountQuota("b", 20); const bound = makeConfig({ activeCodexAccountId: "a" }); - saveConfig(bound); expect(resolveCodexAccountForThread("thread-storm-bound", bound, now)).toBe("a"); clearAccountQuota("a"); setAccountQuotaFromParsed("a", { shortPercent: 100 }); @@ -1729,8 +1746,9 @@ describe("codex routing", () => { }); }); - test("WHAM preserves the 5h, weekly, and Spark weekly windows", () => { + test("WHAM keeps general and Spark windows separate", () => { expect(parseUsageQuota({ + plan_type: "pro", rate_limit: { primary_window: { used_percent: 11, reset_at: 1, limit_window_seconds: 5 * 60 * 60 }, secondary_window: { used_percent: 22, reset_at: 2, limit_window_seconds: 7 * 24 * 60 * 60 }, @@ -1739,18 +1757,20 @@ describe("codex routing", () => { limit_name: "GPT-5.3-Codex-Spark", metered_feature: "codex_bengalfox", rate_limit: { - primary_window: { used_percent: 33, reset_at: 3, limit_window_seconds: 7 * 24 * 60 * 60 }, + primary_window: { used_percent: 33, reset_at: 3, limit_window_seconds: 5 * 60 * 60 }, + secondary_window: { used_percent: 44, reset_at: 4, limit_window_seconds: 7 * 24 * 60 * 60 }, }, }], })).toEqual({ shortPercent: 11, shortResetAt: 1, shortWindowSeconds: 5 * 60 * 60, - fiveHourPercent: 11, - fiveHourResetAt: 1, weeklyPercent: 22, weeklyResetAt: 2, - customWindows: [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 33, resetAt: 3 }], + customWindows: [ + { label: "GPT-5.3-Codex-Spark 5h", percent: 33, resetAt: 3 }, + { label: "GPT-5.3-Codex-Spark Weekly", percent: 44, resetAt: 4 }, + ], }); }); @@ -2219,13 +2239,7 @@ describe("codex routing", () => { describe("codex account selection order", () => { beforeEach(() => { - previousOpencodexHome = process.env.OPENCODEX_HOME; - setIcaclsRunnerForTests(() => ICACLS_OK); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); - TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-codex-routing-")); - process.env.OPENCODEX_HOME = TEST_DIR; - previousCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = TEST_DIR; + installRoutingScratchHome(); clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); @@ -2237,21 +2251,16 @@ describe("codex account selection order", () => { }); afterEach(async () => { - clearAccountQuota(); - clearCodexUpstreamHealth(); - clearThreadAccountMap(); - clearPoolRotationState(); - clearAccountNeedsReauth("a"); - clearAccountNeedsReauth("b"); - await flushConfigDirHardeningForTests(); - setIcaclsRunnerForTests(null); - setAsyncIcaclsRunnerForTests(null); - if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousOpencodexHome; - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; - removeTreeWithRetry(TEST_DIR); - TEST_DIR = ""; + try { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + } finally { + await removeRoutingScratchHome(); + } }); /** `a` is ordered above `b`; the persisted operator selection is the lower tier. */ diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index 2dc5d6347d..bf95f304a6 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -970,3 +970,94 @@ describe("resolveCodexRuntime", () => { expect(diagnostics[0]?.affectedModels).toEqual(["openrouter/example"]); }); }); + +describe("dead configured pin recovery (#4035)", () => { + test("a dead configured pin is cleared when resolution degrades to fallback", () => { + // A Codex App update deletes the hashed plugin directory the pin names. The probe + // rejects the vanished absolute path ("path does not exist"), no PATH candidate + // exists, and resolution degrades to `fallback` — which the persist guard skipped, + // so the dead pin survived forever and every later resolve re-probed a path that + // cannot exist. + const configDir = tempConfigDir(); + const dead = join(configDir, "gone", "codex"); + persistCodexRuntime({ command: dead, version: "0.153.0", source: "configured" }, { configDir }); + expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(dead); + + const result = resolveAndPersistCodexRuntime({ + configDir, + env: { PATH: "" }, + platform: "linux", + existsSync: (path) => !String(path).includes("gone"), + execFileSync: () => { throw new Error("ENOENT"); }, + }); + + expect(result.runtime.source).toBe("fallback"); + expect(existsSync(join(configDir, "codex-runtime.json"))).toBe(false); + expect(loadPersistedCodexRuntime({ configDir })).toBeNull(); + }); + + test("a fallback resolve with no persisted pin writes nothing", () => { + const configDir = tempConfigDir(); + const result = resolveAndPersistCodexRuntime({ + configDir, + env: { PATH: "" }, + platform: "linux", + existsSync: () => false, + execFileSync: () => { throw new Error("ENOENT"); }, + }); + expect(result.runtime.source).toBe("fallback"); + expect(existsSync(join(configDir, "codex-runtime.json"))).toBe(false); + }); + + test("a live configured pin is NOT cleared when the resolve succeeds", () => { + // The clear is bound to a dead pin, not to every fallback-shaped result. + const configDir = tempConfigDir(); + const live = join(configDir, "bin", "codex"); + persistCodexRuntime({ command: live, version: "0.153.0", source: "configured" }, { configDir }); + const result = resolveAndPersistCodexRuntime({ + configDir, + env: { PATH: "" }, + platform: "linux", + existsSync: () => true, + execFileSync: () => "codex-cli 0.153.0", + }); + expect(result.runtime.source).toBe("configured"); + expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(live); + }); + + test("a pin rejected for a NON-path reason is left alone", () => { + // "unrecognized --version output" means the file is present but unusable; that is a + // different failure than a vanished path and is not this issue's recovery case. + const configDir = tempConfigDir(); + const weird = join(configDir, "weird", "codex"); + persistCodexRuntime({ command: weird, version: "0.153.0", source: "configured" }, { configDir }); + resolveAndPersistCodexRuntime({ + configDir, + env: { PATH: "" }, + platform: "linux", + existsSync: () => true, + execFileSync: () => "not a codex binary", + }); + expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(weird); + }); + + test("a case-different missing path does not retire a live pin on linux", () => { + // sameRuntimeCommand() lowercases, so on a case-sensitive filesystem it reports + // /plugins/Codex and /plugins/codex as the same command. They are different files. + // If CODEX_CLI_PATH names the missing lowercase one, its PATH_MISSING failure must + // not retire the uppercase pin that is still live (review finding on #4035). + const configDir = tempConfigDir(); + const live = join(configDir, "plugins", "Codex"); + const missing = join(configDir, "plugins", "codex"); + persistCodexRuntime({ command: live, version: "0.153.0", source: "configured" }, { configDir }); + resolveAndPersistCodexRuntime({ + configDir, + env: { PATH: "", CODEX_CLI_PATH: missing }, + platform: "linux", + existsSync: (p: string) => String(p) === live, + execFileSync: () => "codex-cli 0.153.0", + }); + expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(live); + }); + +}); diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index d9ec5d17d5..3178a7ed3a 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, inspectCodexShimBackingForCommand, installCodexShim, isLocalAbsoluteInspectionPath, isVersionManagerOwnedCodexPath, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, setCodexShimRollbackRestoreHookForTests, uninstallCodexShim } from "../../src/codex/shim"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; -import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; const SHIM_MARKER = "opencodex codex autostart shim"; const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; @@ -1296,6 +1296,112 @@ printf '%s\\n' child-codex } }); + for (const shell of ["cmd", "powershell", "pwsh"] as const) { + const cases = [ + { callerToken: undefined, bypass: false, label: "missing" }, + ...(shell === "cmd" ? [] : [{ callerToken: "", bypass: false, label: "empty" }]), + { callerToken: "caller-token", bypass: false, label: "explicit token, ensure" }, + { callerToken: "caller-token", bypass: true, label: "explicit token, bypass" }, + ]; + for (const { callerToken, bypass, label } of cases) { + test.skipIf(process.platform !== "win32")(`Windows ${shell} shim restores the caller token (${label})`, () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-token-scope-")); + const oldHome = process.env.OPENCODEX_HOME; + try { + process.env.OPENCODEX_HOME = dir; + const extension = shell === "cmd" ? "cmd" : "ps1"; + const realPath = join(dir, `codex-real.${extension}`); + const wrapperPath = join(dir, `codex.${extension}`); + const driverPath = join(dir, `driver.${extension}`); + const ensurePath = join(dir, "ensure.ts"); + const ensureLog = join(dir, "ensure.log"); + writeFileSync(join(dir, "service-api-token"), "file-token\n"); + writeFileSync(ensurePath, `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(ensureLog)}, "ensure"); process.exit(19);`); + if (shell === "cmd") { + writeFileSync(realPath, "@echo off\r\necho child:%OPENCODEX_API_AUTH_TOKEN%\r\nexit /b 37\r\n"); + writeFileSync(wrapperPath, buildWindowsCodexShim(realPath, process.execPath, ensurePath, "process")); + writeFileSync(driverPath, `@echo off\r\ncall "${wrapperPath}" exec "arg value"\r\nset "result=%ERRORLEVEL%"\r\necho after:%OPENCODEX_API_AUTH_TOKEN%\r\necho result:%result%\r\nexit /b 0\r\n`); + } else { + writeFileSync(realPath, '"child:$env:OPENCODEX_API_AUTH_TOKEN"\nexit 37\n'); + writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realPath, process.execPath, ensurePath, "process")}`); + const emptyToken = callerToken === "" ? "$env:OPENCODEX_API_AUTH_TOKEN = ''\n" : ""; + writeFileSync(driverPath, `\uFEFF$ErrorActionPreference = 'Stop'\n${emptyToken}$beforePresence = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN\n& '${wrapperPath.replace(/'/g, "''")}' exec 'arg value'\n$result = $LASTEXITCODE\n"after:$env:OPENCODEX_API_AUTH_TOKEN"\n"result:$result"\n"presence-preserved:$($beforePresence -eq (Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN))"\n`); + } + const env = shimChildEnv({ + OPENCODEX_HOME: dir, + OPENCODEX_API_AUTH_TOKEN: callerToken ?? "", + OCX_SHIM_BYPASS: bypass ? "1" : "", + }); + if (callerToken === undefined) delete env.OPENCODEX_API_AUTH_TOKEN; + const result = shell === "cmd" + ? spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", "driver.cmd"], { cwd: dir, env, encoding: "utf8", timeout: INTERNAL_DEADLINE_MS, windowsHide: true }) + : spawnSync(`${shell}.exe`, ["-NoProfile", "-NonInteractive", "-File", driverPath], { env, encoding: "utf8", timeout: INTERNAL_DEADLINE_MS, windowsHide: true }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim().split(/\r?\n/)).toEqual([ + `child:${callerToken || "file-token"}`, + `after:${callerToken ?? ""}`, + "result:37", + ...(shell === "cmd" ? [] : ["presence-preserved:True"]), + ]); + expect(existsSync(ensureLog)).toBe(!bypass); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(dir); + } + }, SPAWN_BUDGET_MS); + } + } + + for (const failurePhase of ["ensure", "Codex"]) { + for (const executable of ["powershell.exe", "pwsh.exe"]) { + for (const callerToken of [undefined, "", "caller-token"]) { + test.skipIf(process.platform !== "win32")(`Windows ${executable} shim restores ${callerToken === undefined ? "missing" : callerToken === "" ? "empty" : "explicit"} caller token when ${failurePhase} throws`, () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-token-error-")); + const oldHome = process.env.OPENCODEX_HOME; + try { + process.env.OPENCODEX_HOME = dir; + const wrapperPath = join(dir, "codex.ps1"); + const ensurePath = join(dir, "throw.ps1"); + const driverPath = join(dir, "driver.ps1"); + const realPath = join(dir, "codex-real.ps1"); + writeFileSync(join(dir, "service-api-token"), "file-token\n"); + writeFileSync(ensurePath, failurePhase === "ensure" ? "throw 'fixture ensure failure'\n" : "exit 19\n"); + writeFileSync(realPath, "throw 'fixture Codex failure'\n"); + writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realPath, ensurePath, "unused.ts", "process")}`); + const emptyToken = callerToken === "" ? "$env:OPENCODEX_API_AUTH_TOKEN = ''\n" : ""; + writeFileSync(driverPath, `\uFEFF$ErrorActionPreference = 'Stop'\n${emptyToken}$beforePresence = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN\ntry { & '${wrapperPath.replace(/'/g, "''")}' exec } catch { "error:$($_.Exception.Message)" }\n"after:$env:OPENCODEX_API_AUTH_TOKEN"\n"presence-preserved:$($beforePresence -eq (Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN))"\n`); + const env = shimChildEnv({ OPENCODEX_HOME: dir, OPENCODEX_API_AUTH_TOKEN: callerToken ?? "", OCX_SHIM_BYPASS: "" }); + if (callerToken === undefined) delete env.OPENCODEX_API_AUTH_TOKEN; + const result = spawnSync(executable, ["-NoProfile", "-NonInteractive", "-File", driverPath], { + env, encoding: "utf8", timeout: INTERNAL_DEADLINE_MS, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim().split(/\r?\n/)).toEqual([ + `error:fixture ${failurePhase} failure`, `after:${callerToken ?? ""}`, "presence-preserved:True", + ]); + + // A failed process must complete, rather than satisfy the check through a timeout. + writeFileSync(driverPath, `\uFEFF$ErrorActionPreference = 'Stop'\n& '${wrapperPath.replace(/'/g, "''")}' exec\n`); + const uncaught = spawnSync(executable, ["-NoProfile", "-NonInteractive", "-File", driverPath], { + env, encoding: "utf8", timeout: INTERNAL_DEADLINE_MS, windowsHide: true, + }); + expect(uncaught.error).toBeUndefined(); + expect(uncaught.signal).toBeNull(); + expect(typeof uncaught.status, uncaught.stderr).toBe("number"); + expect(uncaught.status, uncaught.stderr).not.toBe(0); + expect(uncaught.stderr).toContain(`fixture ${failurePhase} failure`); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(dir); + } + }, SPAWN_BUDGET_MS); + } + } + } + test("Unix shim skips ocx startup only for Codex management commands", () => { if (process.platform === "win32") return; diff --git a/tests/codex-integration/codex-spark-visibility.test.ts b/tests/codex-integration/codex-spark-visibility.test.ts index c5dfe1c8a7..f97073b142 100644 --- a/tests/codex-integration/codex-spark-visibility.test.ts +++ b/tests/codex-integration/codex-spark-visibility.test.ts @@ -9,6 +9,7 @@ import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const SPARK = "GPT-5.3-Codex-Spark Weekly"; +const SPARK_SHORT = "GPT-5.3-Codex-Spark 5h"; const originalHome = process.env.OPENCODEX_HOME; let home = ""; @@ -33,7 +34,7 @@ afterEach(() => { }); /** - * Codex Spark is a single-model weekly window. It reads 0% for most operators and, on a + * Codex Spark is a single-model quota with 5-hour and weekly windows. It reads 0% for most operators and, on a * multi-account pool, doubles the bar count on every card for information almost nobody acts * on — so it is hidden unless the operator asks for it. * @@ -46,7 +47,10 @@ describe("Codex Spark quota visibility", () => { saveConfig(baseConfig()); const stored = { weeklyPercent: 11, - customWindows: [{ label: SPARK, percent: 33, resetAt: 3 }], + customWindows: [ + { label: SPARK_SHORT, percent: 33, resetAt: 2 }, + { label: SPARK, percent: 34, resetAt: 3 }, + ], updatedAt: Date.now(), }; const projected = withSparkVisibility(stored); @@ -54,7 +58,7 @@ describe("Codex Spark quota visibility", () => { // saying the same thing on the wire. expect(projected.customWindows).toBeUndefined(); // The source object is untouched — routing and capacity still see the window. - expect(stored.customWindows).toHaveLength(1); + expect(stored.customWindows).toHaveLength(2); expect(projected.weeklyPercent).toBe(11); }); @@ -62,17 +66,26 @@ describe("Codex Spark quota visibility", () => { saveConfig(baseConfig(true)); loadConfig(); const projected = withSparkVisibility({ - customWindows: [{ label: SPARK, percent: 33, resetAt: 3 }], + customWindows: [ + { label: SPARK_SHORT, percent: 33, resetAt: 2 }, + { label: SPARK, percent: 34, resetAt: 3 }, + ], updatedAt: Date.now(), }); - expect(projected.customWindows).toEqual([{ label: SPARK, percent: 33, resetAt: 3 }]); + expect(projected.customWindows).toEqual([ + { label: SPARK_SHORT, percent: 33, resetAt: 2 }, + { label: SPARK, percent: 34, resetAt: 3 }, + ]); }); test("an explicit false hides it", () => { saveConfig(baseConfig(false)); loadConfig(); const projected = withSparkVisibility({ - customWindows: [{ label: SPARK, percent: 33 }], + customWindows: [ + { label: SPARK_SHORT, percent: 33 }, + { label: SPARK, percent: 34 }, + ], updatedAt: Date.now(), }); expect(projected.customWindows).toBeUndefined(); @@ -87,6 +100,7 @@ describe("Codex Spark quota visibility", () => { customWindows: [ { label: "First-party models", percent: 40 }, { label: "API usage", percent: 12 }, + { label: SPARK_SHORT, percent: 32 }, { label: SPARK, percent: 33 }, { label: "Fable", percent: 7 }, { label: "Total subscription credits", percent: 90 }, @@ -112,4 +126,3 @@ describe("Codex Spark quota visibility", () => { expect(withSparkVisibility(null)).toBeNull(); }); }); - diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 4ec67d2196..1390da6698 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -44,11 +44,19 @@ import { v2TotalLimitToV1ChildLimit, } from "../../src/codex/features"; import { resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../src/codex/multi-agent-mode-policy"; import { cmdV2, codexFeaturesInvocation, v2StatusLine, multiAgentModeLine } from "../../src/cli/v2"; import { handleManagementAPI } from "../../src/server/management-api"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { inMemoryManagementPersistence } from "../helpers/management-auth"; +// Independently pinned release presets: removing a production compatibility +// entry must not silently remove its regression case too. +const RELEASED_MODE_HINTS = [ + "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.", + "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it.", +] as const; + function template(): Record<string, unknown> { return { slug: "gpt-5.5", @@ -424,6 +432,19 @@ describe("multi_agent_mode_hint_text reader/writer", () => { expect(readFileSync(path, "utf8")).toBe(before); }); + test("writer upgrades exact released presets while preserving user-edited text", () => { + for (const legacy of RELEASED_MODE_HINTS) { + const path = fixtureConfig(TABLE); + expect(setMultiAgentModeHintText(legacy, path)).toEqual({ ok: true, changed: true }); + expect(getMultiAgentModeHintText(path)).toBe(MULTI_AGENT_MODE_HINT_RECOMMENDATION.text); + } + for (const custom of [`${RELEASED_MODE_HINTS[0]} `, `${RELEASED_MODE_HINTS[1]} Ask before delegating.`]) { + const path = fixtureConfig(TABLE); + expect(setMultiAgentModeHintText(custom, path)).toEqual({ ok: true, changed: true }); + expect(getMultiAgentModeHintText(path)).toBe(custom); + } + }); + test("writer clears with null: removes the key, keeps siblings", () => { const path = fixtureConfig("[features.multi_agent_v2]\nenabled = true\nmulti_agent_mode_hint_text = \"Proactive delegation\"\nmax_concurrent_threads_per_session = 17\n"); expect(setMultiAgentModeHintText(null, path)).toEqual({ ok: true, changed: true }); @@ -1349,6 +1370,7 @@ describe("management API parity surface for the WP2 keys", () => { agentsMaxDepth: 2, subagentDeveloperInstructions: null, multiAgentModeHintText: null, + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, agentsMaxDepthAppliesWhenV2Disabled: true, }); const v2Path = fixtureConfig("[features.multi_agent_v2]\nenabled = true\n"); @@ -1358,6 +1380,37 @@ describe("management API parity surface for the WP2 keys", () => { }); }); + test.each(RELEASED_MODE_HINTS)("GET preserves a released preset until an explicit hint save: %s", async legacy => { + const initial = `[features.multi_agent_v2]\nenabled = false\n# keep adjacent setting\nmax_concurrent_threads_per_session = 17\nmulti_agent_mode_hint_text = ${JSON.stringify(legacy)}\n`; + await withConfig(initial, async (path, deps) => { + const before = readFileSync(path, "utf8"); + const get = await handleManagementAPI(new Request("http://localhost/api/v2"), new URL("http://localhost/api/v2"), config, deps); + expect(await get?.json()).toMatchObject({ + multiAgentModeHintText: legacy, + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, + }); + expect(readFileSync(path, "utf8")).toBe(before); + + const unrelated = await handleManagementAPI(put({ agentsEnabled: false }), new URL("http://localhost/api/v2"), config, deps); + expect(unrelated?.status).toBe(200); + expect(getMultiAgentModeHintText(path)).toBe(legacy); + + const saved = await handleManagementAPI(put({ multiAgentModeHintText: legacy }), new URL("http://localhost/api/v2"), config, deps); + expect(saved?.status).toBe(200); + expect(await saved?.json()).toMatchObject({ + multiAgentModeHintText: MULTI_AGENT_MODE_HINT_RECOMMENDATION.text, + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, + }); + expect(getMultiAgentModeHintText(path)).toBe(MULTI_AGENT_MODE_HINT_RECOMMENDATION.text); + expect(readFileSync(path, "utf8")).toContain("# keep adjacent setting"); + expect(getMaxConcurrentThreads(path)).toBe(17); + + const after = readFileSync(path, "utf8"); + expect(setMultiAgentModeHintText(legacy, path)).toEqual({ ok: true, changed: false }); + expect(readFileSync(path, "utf8")).toBe(after); + }); + }); + test("PUT writes each new field independently and re-reads them", async () => { await withConfig("[features.multi_agent_v2]\nenabled = false\n", async (path, deps) => { const onlyNew = await handleManagementAPI(put({ agentsEnabled: false }), new URL("http://localhost/api/v2"), config, deps); diff --git a/tests/codex-integration/doctor.test.ts b/tests/codex-integration/doctor.test.ts index acb0f1b87e..3f52ea4e45 100644 --- a/tests/codex-integration/doctor.test.ts +++ b/tests/codex-integration/doctor.test.ts @@ -12,6 +12,7 @@ import { collectConfiguredProxy, collectProxyEnv, collectRunningProxyEnv, + chatgptPublicEndpointHint, collectWslDualInstall, fetchServiceMemory, formatResponseTempLines, @@ -641,6 +642,42 @@ describe("service memory section (#314 WP4)", () => { expect(hint).toContain("ocx service install"); }); + test("ChatGPT public endpoint hint explains channel latency without claiming a fixed delay", () => { + const canonical = { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }; + const hint = chatgptPublicEndpointHint({ openai: canonical }); + expect(hint).toContain("public ChatGPT endpoint"); + expect(hint).toContain("assumed"); + expect(hint).toContain("websocket"); + expect(hint).toContain("both Pool and Direct modes"); + expect(hint).not.toContain("11s"); + // The helper classifies configuration; it measures no latency. The copy has + // to stay hedged because eligible turns can still fall back to SSE and + // local pacing can delay dispatch before any upstream work starts. + expect(hint).toContain("fall back"); + expect(hint).toContain("one possible contributor"); + expect(chatgptPublicEndpointHint({})).toBeNull(); + // Resolution, not raw text. The registry entry for the built-in `openai` id has + // authKind "forward", so a row that omits `authMode` still forwards to ChatGPT and still + // needs the hint. Reading the raw row suppressed it. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" } })).not.toBeNull(); + // Same reason the other way round: the entry is not key-auth-overridable, so writing + // `authMode: "key"` on this id does not change where requests go. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "key", baseUrl: "https://chatgpt.com/backend-api/codex" } })).not.toBeNull(); + // The entry sets no baseUrl override, so a differing URL is discarded and the request + // still goes to the canonical endpoint. Describing that route is correct, and a lookalike + // host never becomes the destination. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com.example/v1" } })).not.toBeNull(); + // Trailing slashes still normalize to the canonical URL. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex/" } })).not.toBeNull(); + // A disabled row never routes, so it is not the route in use. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", disabled: true } })).toBeNull(); + // A blank baseUrl is discarded like any other override on this id, so it resolves to the + // canonical endpoint and still gets the hint. Resolution has no reachable throw here: + // src/router.ts only rejects an unresolved URL when the registry entry allows a baseUrl + // override, and the `openai` entry does not. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: " " } })).not.toBeNull(); + }); + test("proxyDownRestartHint prefers 'ocx service start' when a service is installed", () => { const hint = proxyDownRestartHint({ proxyRunning: false, port: 12000, serviceViable: true }); expect(hint).toContain("ocx service start"); @@ -957,4 +994,20 @@ describe("doctor reports an unclean prior proxy exit", () => { expect(logged.join("\n")).not.toContain("may have exited unexpectedly"); }); + + test("runDoctor outputs ChatGPT public endpoint hint when the canonical openai provider is configured", async () => { + const { writeFileSync } = await import("fs"); + const { join } = await import("path"); + writeFileSync( + join(tempHome, "config.json"), + JSON.stringify({ port: 9, codexAutoStart: false, providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" } } }), + "utf8", + ); + + await runDoctor([]); + + const output = logged.join("\n"); + expect(output).toContain("public ChatGPT endpoint"); + expect(output).toContain("assumed"); + }); }); diff --git a/tests/codex-integration/history-ocx-compaction-recovery.test.ts b/tests/codex-integration/history-ocx-compaction-recovery.test.ts new file mode 100644 index 0000000000..325a83c93d --- /dev/null +++ b/tests/codex-integration/history-ocx-compaction-recovery.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + recoverOcxCompactionHistory, + rewriteOcxCompactionsForNativeReplay, +} from "../../src/codex/ocx-compaction-history"; +import { encodeCompactionSummary, SUMMARY_PREFIX } from "../../src/responses/compaction"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("OpenCodeX compaction history recovery", () => { + test("lowers only proxy-owned compactions in compacted replacement history", () => { + const source = [ + JSON.stringify({ type: "session_meta", payload: { id: "thread-fixture" } }), + JSON.stringify({ + type: "compacted", + payload: { + replacement_history: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "keep" }] }, + { + type: "compaction", + id: "cmp_fixture", + encrypted_content: encodeCompactionSummary("fixture summary"), + }, + { type: "compaction", id: "cmp_native", encrypted_content: "native-opaque" }, + ], + }, + }), + JSON.stringify({ + type: "response_item", + payload: { type: "compaction", encrypted_content: encodeCompactionSummary("historical output") }, + }), + "", + ].join("\n"); + + const result = rewriteOcxCompactionsForNativeReplay(source); + + expect(result.replaced).toBe(1); + const lines = result.content.trimEnd().split("\n").map(line => JSON.parse(line)); + expect(lines[1].payload.replacement_history).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "keep" }] }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\nfixture summary` }], + }, + { type: "compaction", id: "cmp_native", encrypted_content: "native-opaque" }, + ]); + expect(lines[2].payload.encrypted_content).toStartWith("ocx1:"); + expect(result.content.endsWith("\n")).toBe(true); + }); + + test("is byte-stable when no repairable compaction exists", () => { + const source = `${JSON.stringify({ + type: "compacted", + payload: { replacement_history: [{ type: "compaction", encrypted_content: "native-opaque" }] }, + })}\nnot-json\n`; + + expect(rewriteOcxCompactionsForNativeReplay(source)).toEqual({ content: source, replaced: 0 }); + }); + + test("backs up and atomically repairs one database-selected rollout", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-compaction-recovery-")); + try { + const codexHome = join(root, "codex"); + const rolloutDir = join(codexHome, "sessions", "2026", "09", "07"); + const backupRoot = join(root, "backups"); + mkdirSync(rolloutDir, { recursive: true }); + const threadId = "01a018e6-242f-7801-81b8-ffc0a5c6d589"; + const rolloutPath = join(rolloutDir, `rollout-fixture-${threadId}.jsonl`); + const original = `${JSON.stringify({ + type: "compacted", + payload: { + replacement_history: [{ + type: "compaction", + id: "cmp_fixture", + encrypted_content: encodeCompactionSummary("recover me"), + }], + }, + })}\n`; + writeFileSync(rolloutPath, original, "utf8"); + const stateDbPath = join(codexHome, "state_5.sqlite"); + const db = new Database(stateDbPath, { create: true }); + db.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL)"); + db.query("INSERT INTO threads (id, rollout_path) VALUES (?, ?)").run(threadId, rolloutPath); + db.close(); + + const result = recoverOcxCompactionHistory({ + threadId, + codexHome, + stateDbPath, + backupRoot, + now: () => new Date("2026-09-07T00:00:00.000Z"), + }); + + expect(result.replaced).toBe(1); + expect(result.backupPath).not.toBeNull(); + expect(readFileSync(result.backupPath!, "utf8")).toBe(original); + expect(readFileSync(rolloutPath, "utf8")).toContain(`${SUMMARY_PREFIX}\\nrecover me`); + expect(readFileSync(rolloutPath, "utf8")).not.toContain("ocx1:"); + } finally { + removeTreeWithRetry(root); + } + }); +}); diff --git a/tests/codex-integration/issue-702-expired-replay-state.test.ts b/tests/codex-integration/issue-702-expired-replay-state.test.ts index b73439ecc9..13b96be638 100644 --- a/tests/codex-integration/issue-702-expired-replay-state.test.ts +++ b/tests/codex-integration/issue-702-expired-replay-state.test.ts @@ -21,7 +21,7 @@ import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; -import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; @@ -104,6 +104,46 @@ function completedSse(responseId: string, text: string): string { ].join("\n"); } +async function openResponseSocket(url: URL, headers: Record<string, string>): Promise<WebSocket> { + const target = new URL("/v1/responses", url); + target.protocol = "ws:"; + const socket = new WebSocket(target, { headers } as unknown as string[]); + await new Promise<void>((resolve, reject) => { + const timer = setTimeout(() => { + socket.close(); + reject(new Error("response socket did not open")); + }, INTERNAL_DEADLINE_MS); + socket.onopen = () => { clearTimeout(timer); resolve(); }; + socket.onerror = () => { clearTimeout(timer); reject(new Error("response socket failed to open")); }; + }); + return socket; +} + +async function sendSocketTurn(socket: WebSocket, body: Record<string, unknown>): Promise<Record<string, unknown>> { + return new Promise((resolve, reject) => { + const finish = (error?: Error, frame?: Record<string, unknown>) => { + clearTimeout(timer); + socket.onmessage = socket.onclose = socket.onerror = null; + if (error) reject(error); + else resolve(frame!); + }; + const timer = setTimeout(() => finish(new Error("response socket did not reach a terminal event")), INTERNAL_DEADLINE_MS); + socket.onclose = () => finish(new Error("response socket closed before its terminal event")); + socket.onerror = () => finish(new Error("response socket failed")); + socket.onmessage = event => { + try { + const frame = JSON.parse(String(event.data)); + if (["error", "response.completed", "response.failed", "response.incomplete"].includes(frame.type)) { + finish(undefined, frame); + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }; + socket.send(JSON.stringify({ type: "response.create", ...body })); + }); +} + async function waitForRecordedResponseState(): Promise<ResponseStateMetrics> { const deadline = performance.now() + 1_000; while (performance.now() < deadline) { @@ -364,11 +404,86 @@ describe("Issue #702 expired forward replay state", () => { error: { message: expect.stringMatching(/continuation state.*expired/i), type: "invalid_request_error", - code: "invalid_request_error", + code: "previous_response_not_found", }, }); }); + test.each(["expired", "missing"] as const)("%s forward state lets a WebSocket client reconnect and replay full tool history", async mode => { + const upstreamRequests: Record<string, unknown>[] = []; + const realNow = Date.now; + let server: ReturnType<typeof startServer> | null = null; + let socket: WebSocket | null = null; + const toolCall = { + type: "function_call", id: "fc_issue_702", call_id: "call_issue_702", + name: "lookup", arguments: '{"key":"historical"}', status: "completed", + }; + const toolResult = { + type: "function_call_output", call_id: "call_issue_702", output: "historical tool result", + }; + const history = [inputMessage(HISTORICAL_USER_SENTINEL), toolCall]; + const delta = [toolResult, inputMessage(CURRENT_USER_SENTINEL)]; + try { + if (mode === "expired") { + Date.now = () => realNow() - EXPIRED_AGE_MS; + rememberResponseState( + { input: [history[0]], store: false }, + { id: FIRST_RESPONSE_ID, status: "completed", output: [toolCall] }, + undefined, + { force: true }, + ); + Date.now = realNow; + expect(responseStateMetrics().oldestAgeMs).toBeGreaterThan(REPLAY_TTL_MS); + } + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "chatgpt.com" && url.pathname === "/backend-api/codex/responses") { + upstreamRequests.push(JSON.parse(String(init?.body))); + return new Response(completedSse("resp_issue_702_recovered", "recovered with full history"), { + headers: { "content-type": "text/event-stream" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + saveConfig({ ...forwardConfig(), websockets: true }); + server = startServer(0); + const headers = { + authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" })}`, + "chatgpt-account-id": "acct-issue-702", + }; + socket = await openResponseSocket(server.url, headers); + const rejected = await sendSocketTurn(socket, { + model: "gpt-5.5", previous_response_id: FIRST_RESPONSE_ID, input: delta, store: false, + }); + expect(rejected).toMatchObject({ + type: "error", status: 400, + error: { type: "invalid_request_error", code: "previous_response_not_found" }, + }); + expect(upstreamRequests).toHaveLength(0); + + // Codex recognizes this code, discards its incremental socket state, and reconnects + // with its complete input. The rejected delta must never be forwarded on its own. + socket.close(); + socket = await openResponseSocket(server.url, headers); + const recovered = await sendSocketTurn(socket, { + model: "gpt-5.5", input: [...history, ...delta], store: false, + tools: [{ type: "function", name: "lookup", parameters: { type: "object" } }], + }); + expect(recovered).toMatchObject({ type: "response.completed", response: { id: "resp_issue_702_recovered" } }); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]!.previous_response_id).toBeUndefined(); + // The canonical forward adapter removes item ids, but must preserve the call/result + // identity and every input item exactly once when the client supplies full history. + const { id: _itemId, ...forwardedToolCall } = toolCall; + expect(upstreamRequests[0]!.input).toEqual([history[0], forwardedToolCall, ...delta]); + } finally { + Date.now = realNow; + globalThis.fetch = originalFetch; + socket?.close(); + await server?.stop(true); + } + }, SERVER_BUDGET_MS); + test("forward mode expands fresh replay state before continuing upstream", async () => { const scenario = await runForwardScenario("fresh"); diff --git a/tests/codex-integration/main-account-hard-lock-auth.test.ts b/tests/codex-integration/main-account-hard-lock-auth.test.ts index d0959817c1..c3caaabe2c 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -25,7 +26,7 @@ import { observeMainQuotaIdentity, } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; -import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../../src/providers/openai-sidecar"; import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; import { handleResponses } from "../../src/server/responses/core"; @@ -33,6 +34,8 @@ import { handleResponsesCompact } from "../../src/server/responses/compact"; import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { helperPath, repoRoot } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; const MAIN = mainAccount.MAIN_CODEX_ACCOUNT_ID; const accountId = "hard-lock-main-fixture"; @@ -137,7 +140,162 @@ afterEach(() => { removeTreeWithRetry(home); }); +describe("startup policy binding read is bounded", () => { + // FIFO and symlink cases need POSIX semantics; Windows keeps the portable cases. + const boundedReadCases: string[] = ["valid", "oversize", "directory", "missing", + ...(process.platform === "win32" ? [] : ["fifo-retained", "fifo-hang-proof", "symlink"])]; + test.each(boundedReadCases)("bounded startup read handles %s", scenario => { + const child = Bun.spawnSync([process.execPath, helperPath("bounded-auth-read-child.ts")], { + cwd: repoRoot(), + env: { ...process.env, OCX_BOUNDED_READ_CASE: scenario, + HOME: home, USERPROFILE: home, TMP: home, TEMP: home, TMPDIR: home, + XDG_RUNTIME_DIR: home, LOCALAPPDATA: join(home, "LocalAppData"), + OPENCODEX_HOME: home, CODEX_HOME: home }, + timeout: SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS, stdout: "pipe", stderr: "pipe", + }); + // A regressed unbounded FIFO read never reaches here: the spawn timeout kills the child. + expect({ exitCode: child.exitCode, stderr: child.stderr.toString() }).toMatchObject({ exitCode: 0 }); + const line = child.stdout.toString().split(/\r?\n/).find(value => value.startsWith("BOUNDED_READ_RESULT=")); + expect(line).toBeDefined(); + const result = JSON.parse(line!.slice("BOUNDED_READ_RESULT=".length)); + if (scenario === "valid") { + expect(result).toMatchObject({ bound: true, matched: true }); + } else if (scenario === "fifo-retained") { + expect(result).toMatchObject({ firstBound: true, bound: false, retained: true }); + } else { + expect(result.bound).toBe(false); + } + if (scenario === "symlink") expect(result.matched).toBe(false); + }, SPAWN_BUDGET_MS); +}); + describe("main quota policy at native admission", () => { + test.each(["owned-99", "owned-98", "foreign", "unknown", "recovery", "second-listener", + "invalid-access-token", "invalid-account-id", "invalid-id-token", "mismatched-identity", "renewed-listener", + "stage-retry", "manual-recovery", "stale-sweep", "retained-unknown-binding", + "conflicting-token-identities", "conflicting-claims", "owned-opaque-99"] as const)( + "fresh startup restores durable main policy only after owned recovery (%s)", scenario => { + const restoredId = scenario === "recovery" ? "hard-lock-recovered-main" : accountId; + const restoredBearer = scenario === "owned-opaque-99" ? "opaque-owned-startup-bearer" : `header.${Buffer.from(JSON.stringify({ exp: tokenExpiry, + ...(["renewed-listener", "manual-recovery", "stale-sweep"].includes(scenario) ? { startupTokenRevision: 1 } : {}), + ...(scenario === "conflicting-claims" ? { chatgpt_account_id: restoredId } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: scenario === "conflicting-token-identities" + ? "hard-lock-conflicting-access-account" + : scenario === "conflicting-claims" ? "hard-lock-conflicting-claim-account" : restoredId } })).toString("base64url")}.signature`; + const quota = { weeklyPercent: scenario === "owned-98" ? 98 : 99, updatedAt: Date.now() - 7 * 60 * 60_000 }; + const identityKey = createHash("sha256").update("opencodex-main-quota-v1\0").update(restoredId).digest("hex"); + if (scenario.startsWith("invalid-") || scenario === "mismatched-identity") { + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + access_token: scenario === "invalid-access-token" ? 17 : bearer(), + account_id: scenario === "invalid-account-id" ? { invalid: true } + : scenario === "mismatched-identity" ? "conflicting-physical-account" : accountId, + ...(scenario === "invalid-id-token" ? { id_token: 17 } : {}), + } })); + } + if (scenario === "conflicting-token-identities" || scenario === "conflicting-claims" || scenario === "owned-opaque-99") { + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + access_token: restoredBearer, account_id: accountId, + ...(scenario === "conflicting-token-identities" ? { id_token: bearer() } : {}), + } })); + } + writeFileSync(join(home, "config.json"), JSON.stringify({ + ...config(), port: 0, hostname: "127.0.0.1", codexMainAccountHardLock: scenario !== "second-listener", + providers: { openai: { ...config().providers.openai, codexAccountMode: "direct" } }, + })); + writeFileSync(join(home, "config.toml"), 'model = "gpt-5.6-sol"\n'); + writeFileSync(join(home, "codex-quota-cache.json"), JSON.stringify({ + version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey, quota }, + })); + const fixturePath = join(home, "startup-fixture.json"); + writeFileSync(fixturePath, JSON.stringify({ scenario, accountId: restoredId, bearer: restoredBearer, + originalAccountId: accountId, originalBearer: bearer() })); + const child = Bun.spawnSync([process.execPath, helperPath("main-account-policy-startup-child.ts")], { + cwd: repoRoot(), env: { ...process.env, OCX_POLICY_STARTUP_FIXTURE: fixturePath, + HOME: home, USERPROFILE: home, TMP: home, TEMP: home, TMPDIR: home, + XDG_RUNTIME_DIR: home, LOCALAPPDATA: join(home, "LocalAppData") }, + timeout: SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS, stdout: "pipe", stderr: "pipe", + }); + expect({ exitCode: child.exitCode, signal: child.signalCode, stderr: child.stderr.toString() }).toMatchObject({ exitCode: 0 }); + const line = child.stdout.toString().split(/\r?\n/).find(value => value.startsWith("POLICY_STARTUP_RESULT=")); + expect(line).toBeDefined(); + const result = JSON.parse(line!.slice("POLICY_STARTUP_RESULT=".length)); + expect(result.before).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); + expect(result.listeners[0].tokenReads).toBe(0); + expect(result.unexpectedNetwork).toEqual([]); + expect(result.policyReadsPinned).toBe(true); + expect(result.beforePrimaryUpstreamCalls).toBe(scenario === "retained-unknown-binding" ? 3 : 0); + const unowned = scenario === "foreign" || scenario === "unknown"; + const unverified = scenario.startsWith("invalid-") || scenario === "mismatched-identity" + || scenario === "conflicting-token-identities" || scenario === "conflicting-claims"; + if (unowned) { + expect(result.firstAdmission.admitted).toBe(true); + expect(result.after.tokenReads).toBe(0); + } else { + expect(result.firstAdmission).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); + expect(result.settled.status).toBe("ready"); + // Every owned startup reads the pinned file before it can accept or reject a binding, so + // policyReadsPinned above cannot pass on an empty read list. + expect(result.after.tokenReads).toBeGreaterThan(0); + } + if (unowned || unverified) { + expect(result.after).toMatchObject({ matched: false, policy: null }); + expect(result.response.status).toBe(200); + expect(result.primaryUpstreamCalls).toBe(1); + } else { + expect(result.after).toMatchObject({ matched: true, policy: quota }); + expect(result.response.status).toBe(scenario === "owned-98" ? 200 : 429); + expect(result.primaryUpstreamCalls).toBe(scenario === "owned-98" ? 1 : 0); + if (scenario !== "owned-98") expect(result.response.hardLockError).toBe(true); + } + if (scenario === "recovery") { + expect(result.heldRecovery.observation).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); + expect(result.heldRecovery.poolFallback).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); + expect(result.heldRecovery.mainPin).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); + expect(result.heldRecovery.storedAlternative).toMatchObject({ admitted: true, kind: "pool" }); + expect(result.heldRecovery.automaticAlternative).toMatchObject({ admitted: true, kind: "pool" }); + expect(result.originalResponse.status).toBe(200); + } + if (scenario === "second-listener") { + expect(result.firstServerSettled).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); + } + if (scenario === "second-listener" || scenario === "renewed-listener") { + expect(result.listeners).toHaveLength(2); + expect(result.listeners[1].tokenReads).toBe(result.firstServerSettled.tokenReads); + } + if (scenario === "renewed-listener") { + expect(result.firstServerSettled).toMatchObject({ matched: false, policy: quota }); + expect(result.originalResponse.status).toBe(200); + } + if (scenario === "stage-retry" || scenario === "manual-recovery") { + expect(result.laterRecovery.blocked).toMatchObject({ matched: false, policy: null, tokenReads: 0, + gate: { status: "blocked", reason: scenario === "stage-retry" ? "stage-cleanup-required" : "manual-recovery" } }); + } + if (scenario === "stage-retry") expect(result.laterRecovery.sweepCalls).toBeGreaterThanOrEqual(2); + if (scenario === "manual-recovery") { + expect(result.laterRecovery).toMatchObject({ apiStatus: 200, duplicateCompleted: true, joined: true, recoveryCalls: 1 }); + expect(result.laterRecovery.pending).toMatchObject({ matched: false, tokenReads: 0, + gate: { status: "blocked", reason: "recovery-pending" } }); + expect(result.originalResponse.status).toBe(200); + } + if (scenario === "stale-sweep") { + expect(result.laterRecovery.pending.gate).toMatchObject({ status: "blocked", reason: "recovery-pending" }); + expect(result.laterRecovery.admission).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); + expect(result.originalResponse.status).toBe(200); + } + if (scenario === "retained-unknown-binding") { + expect(result.retainedUnknown.map((entry: { kind: string }) => entry.kind)) + .toEqual(["malformed", "conflicting", "conflicting-tokens"]); + for (const entry of result.retainedUnknown) { + expect(entry.observed).toMatchObject({ matched: true, policy: quota }); + expect(entry.main).toMatchObject({ status: 429, hardLockError: true }); + expect(entry.other.status).toBe(200); + } + expect(result.validReplacement).toMatchObject({ oldMatched: false, newMatched: true, policy: null, + old: { status: 200, hardLockError: false } }); + } + }, SPAWN_BUDGET_MS, + ); + test("short-only 99 blocks exact main and main-only Pool without probe or reauth", async () => { quota(99); const cfg = config(); @@ -185,6 +343,34 @@ describe("main quota policy at native admission", () => { })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); }); + for (const percent of [98.99, 99]) { + test(`Pool cooldown caller fallback keeps the main ${percent}% policy boundary`, async () => { + const cfg = config(); + addAlternative(cfg); + cfg.activeCodexAccountId = "hard-lock-pool"; + observeMainQuotaCredential(bearer(), accountId); + quota(percent); + const now = Date.now(); + recordCodexUpstreamOutcome(cfg, "hard-lock-pool", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("hard-lock-pool", "shared"); + expect(cooldown).not.toBeNull(); + spyOn(Date, "now").mockReturnValue(now + 1_000); + forbidPhysicalReads(); + const context = resolveCodexAuthContext(caller(), cfg, "pool", { + requestScopedMainCredential: true, modelId: "gpt-5.6-terra", + }); + if (percent < 99) { + await expect(context).resolves.toMatchObject({ kind: "main", accountId: null }); + } else { + await expect(context).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + } + expect(cfg.activeCodexAccountId).toBe("hard-lock-pool"); + expect(getCodexQuotaHealthSnapshot("hard-lock-pool", "shared")).toEqual(cooldown); + }); + } + test("unmatched, spoofed-claim, and conflicting-workspace callers do not inherit main policy", async () => { observeMainQuotaCredential(bearer(), accountId); quota(99); diff --git a/tests/codex-integration/multi-agent-compat.test.ts b/tests/codex-integration/multi-agent-compat.test.ts index ce5cab4ef5..181bcbc32c 100644 --- a/tests/codex-integration/multi-agent-compat.test.ts +++ b/tests/codex-integration/multi-agent-compat.test.ts @@ -7,7 +7,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { injectDeveloperMessage, multiAgentGuidanceText, sanitizeEncryptedContentInPlace, V2_GUIDANCE_CHAR_BUDGET } from "../../src/server/responses"; +import { injectDeveloperMessage, multiAgentGuidanceText, sanitizeEncryptedContentInPlace } from "../../src/server/responses"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../src/codex/multi-agent-mode-policy"; import { parseRequest } from "../../src/responses/parser"; import type { OcxParsedRequest } from "../../src/types"; import { CODEX_ACCOUNT_BOUND_CATALOG_KIND, effectiveSubagentRoster } from "../../src/codex/catalog"; @@ -85,6 +86,14 @@ function catalogFixture(dir: string, models: CatalogFixtureModel[]): void { const V2_ON = "[features.multi_agent_v2]\nenabled = true\n"; const V2_OFF = "[features]\nmulti_agent = true\n"; +const TRIGGER_ONLY_RECOMMENDATION = [ + "Proactive multi-agent delegation is active.", + "Only the delegation trigger changes: a separate explicit request is no longer required.", + "All existing user, authority, task-scope, and collaboration-tool rules continue to apply.", + "Delegate eligible independent work when parallel execution could materially improve speed or quality.", + "User requests override this hint.", + "This mode remains active until a later multi-agent mode developer message changes it.", +].join(" "); function parsedFixture(over: { reasoning?: string; @@ -104,14 +113,14 @@ function parsedFixture(over: { } describe("multiAgentGuidanceText", () => { - test("v1 tool surface + max injects the tagged Proactive text", async () => { + test.each(["max", "ultra"])("v1 %s uses the trigger-only proactive recommendation", async reasoning => { codexHomeFixture(V2_OFF); // guidance fires regardless of v2 flag const text = await multiAgentGuidanceText(parsedFixture({ - reasoning: "max", + reasoning, tools: [{ name: "spawn_agent", namespace: "agents" }, { name: "send_input", namespace: "agents" }], })); - expect(text).toContain("<multi_agent_mode>"); - expect(text).toContain("Proactive multi-agent delegation is active"); + expect(text).toBe(`<multi_agent_mode>${TRIGGER_ONLY_RECOMMENDATION}</multi_agent_mode>`); + expect(MULTI_AGENT_MODE_HINT_RECOMMENDATION.text).toBe(TRIGGER_ONLY_RECOMMENDATION); }); test("v1 tool surface below the top tier stays silent", async () => { @@ -267,7 +276,7 @@ describe("multiAgentGuidanceText", () => { } }); - test("v2 built-in guidance is schema-agnostic and keeps fork rules", async () => { + test("v2 built-in guidance reports routing metadata without replacing native delegation rules", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [{ slug: "anthropic/claude-sonnet-5", @@ -279,10 +288,13 @@ describe("multiAgentGuidanceText", () => { { injectionModel: "anthropic/claude-sonnet-5" }, ); - expect(text).toContain("When the active spawn_agent tool supports optional"); - expect(text).toContain("use only models listed for this collaboration surface"); - expect(text).toContain("fork_turns"); - expect(text).toContain('"none"'); + expect(text).toStartWith("<opencodex_subagent_guidance>"); + expect(text).toEndWith("</opencodex_subagent_guidance>"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); + expect(text).toContain("does not override Codex delegation or model-selection rules"); + expect(text).not.toContain("fork_turns"); + expect(text).not.toContain("use it unless"); + expect(text).not.toContain("<multi_agent_mode>"); expect(text).not.toMatch(/hidden/i); expect(text).not.toMatch(/not in the schema/i); expect(text).not.toMatch(/never claim/i); @@ -384,7 +396,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(custom).toBe('<multi_agent_mode>Use team/gpt-5.6-sol.</multi_agent_mode>'); + expect(custom).toBe('<opencodex_subagent_guidance>Use team/gpt-5.6-sol.</opencodex_subagent_guidance>'); const exactBare = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), @@ -403,7 +415,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(exactBareCustom).toBe("<multi_agent_mode>Use local-fast.</multi_agent_mode>"); + expect(exactBareCustom).toBe("<opencodex_subagent_guidance>Use local-fast.</opencodex_subagent_guidance>"); const bareParent = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), @@ -449,7 +461,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(ambiguousCustom).toBe("<multi_agent_mode>Use .</multi_agent_mode>"); + expect(ambiguousCustom).toBe("<opencodex_subagent_guidance>Use .</opencodex_subagent_guidance>"); expect(ambiguousCustom).not.toContain("gpt-5.6-sol"); }); @@ -508,7 +520,7 @@ describe("multiAgentGuidanceText", () => { injectionModel: "gpt-5.6-sol", injectionPrompt: "Use {{model}}.", }, - )).toBe("<multi_agent_mode>Use .</multi_agent_mode>"); + )).toBe("<opencodex_subagent_guidance>Use .</opencodex_subagent_guidance>"); }); test("effective roster applies alias, visibility, v2 compatibility, stable priority, cap, and diagnostics", async () => { @@ -612,7 +624,7 @@ describe("multiAgentGuidanceText", () => { { injectionModel: "anthropic/claude-sonnet-5" }, ); expect(text).toContain('"anthropic/claude-sonnet-5"'); - expect(text).toContain("fork_turns"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toContain("Proactive multi-agent delegation is active"); // and WITHOUT an injectionModel it stays silent (codex-rs owns the v2 Proactive text) expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "ultra", tools: nativeV2 }))).toBeNull(); @@ -654,7 +666,7 @@ describe("multiAgentGuidanceText", () => { injectionEffort: "xhigh", subagentModels: ["gpt-5.6-terra"], }); - expect(text).toContain("When the active spawn_agent tool supports optional"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toMatch(/hidden|not in the schema|never claim/i); expect(text).toContain('(reasoning_effort high/max/ultra): "gpt-5.6-terra"'); }); @@ -738,8 +750,8 @@ describe("multiAgentGuidanceText", () => { // gpt-5.6-luna carries upstream's "v1" pin, which is now an eligible LEAF worker // (codex-rs 6d4d9442c), so it joins the substituted roster. expect(text).toBe( - '<multi_agent_mode>CUSTOM model=raw/preferred-model effort=max' - + ' Available models (reasoning_effort high/max): "gpt-5.6-terra", "gpt-5.6-luna".</multi_agent_mode>', + '<opencodex_subagent_guidance>CUSTOM model=raw/preferred-model effort=max' + + ' Available models (reasoning_effort high/max): "gpt-5.6-terra", "gpt-5.6-luna".</opencodex_subagent_guidance>', ); }); @@ -778,14 +790,14 @@ describe("multiAgentGuidanceText", () => { expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "medium", tools: v2Tools }))).toBeNull(); }); - test("v2 surface + roster alone (no injectionModel) fires with the argument-acceptance preamble", async () => { + test("v2 surface + roster alone (no injectionModel) reports routing metadata", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [{ slug: "gpt-5.6-terra", efforts: ["high", "max", "ultra"] }]); const text = await multiAgentGuidanceText( parsedFixture({ reasoning: "medium", tools: [{ name: "spawn_agent" }] }), { subagentModels: ["gpt-5.6-terra"] }, ); - expect(text).toContain("When the active spawn_agent tool supports optional"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toMatch(/hidden|not in the schema|never claim/i); expect(text).toContain('(reasoning_effort high/max/ultra): "gpt-5.6-terra"'); expect(text).not.toContain("Preferred sub-agent"); @@ -844,7 +856,7 @@ describe("multiAgentGuidanceText", () => { expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "max", tools: v1Tools }))).not.toBeNull(); }); - test("v2 body stays within the guidance budget with a full 5-model roster", async () => { + test("v2 body stays within the 700-char budget with a full 5-model roster", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [ { slug: "gpt-5.5", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, @@ -861,8 +873,8 @@ describe("multiAgentGuidanceText", () => { subagentModels: ["gpt-5.5", "opencode-go/glm-5.2", "anthropic/claude-opus-4-6", "gpt-5.6-sol", "gpt-5.6-terra"], }, ); - const body = text!.replace(/^<multi_agent_mode>/, "").replace(/<\/multi_agent_mode>$/, ""); - expect(body.length).toBeLessThanOrEqual(V2_GUIDANCE_CHAR_BUDGET); + const body = text!.replace(/^<opencodex_subagent_guidance>/, "").replace(/<\/opencodex_subagent_guidance>$/, ""); + expect(body.length).toBeLessThanOrEqual(700); expect(body).toContain("Available models"); // roster fits inside the budget }); @@ -953,6 +965,43 @@ describe("injectDeveloperMessage", () => { && (part as Record<string, unknown>).text === text; }).length; + test("upgrades historical v1 wording once and preserves replayed guidance", async () => { + codexHomeFixture(V2_OFF); + // Released bytes are independent of today's recommendation and remain in the conversation. + const legacyText = "<multi_agent_mode>Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it.</multi_agent_mode>"; + const produce = () => multiAgentGuidanceText(parsedFixture({ + reasoning: "max", tools: [{ name: "spawn_agent", namespace: "agents" }], + })); + const text = await produce(); + expect(text).toBe(`<multi_agent_mode>${TRIGGER_ONLY_RECOMMENDATION}</multi_agent_mode>`); + const history = [generatedItem(legacyText), + { type: "message", role: "user", content: "previous turn" }, + { type: "message", role: "assistant", content: "done" }]; + const firstInput = [...structuredClone(history), { type: "message", role: "user", content: "new turn" }]; + const first = parseRequest({ model: "gpt-5.5", input: firstInput, previous_response_id: "resp_old_v1" }); + first._replayPrefixLen = history.length; + first._continuationConversationMessageIndex = history.length; + injectDeveloperMessage(first, text!); + expect(firstInput.slice(0, history.length)).toEqual(history); + expect(firstInput.slice(history.length)).toEqual([generatedItem(text!), + { type: "message", role: "user", content: "new turn" }]); + expect(countExact(firstInput, legacyText)).toBe(1); + expect(countExact(firstInput, text!)).toBe(1); + + const nextHistory = [...firstInput, { type: "message", role: "assistant", content: "done again" }]; + const secondInput = [...structuredClone(nextHistory), { type: "message", role: "user", content: "next turn" }]; + const second = parseRequest({ model: "gpt-5.5", input: secondInput, previous_response_id: "resp_new_v1" }); + second._replayPrefixLen = nextHistory.length; + second._continuationConversationMessageIndex = nextHistory.length; + const nextText = await produce(); + expect(nextText).toBe(text); + injectDeveloperMessage(second, nextText!); + expect(secondInput.slice(0, nextHistory.length)).toEqual(nextHistory); + expect(secondInput).toHaveLength(nextHistory.length + 1); + expect(countExact(secondInput, legacyText)).toBe(1); + expect(countExact(secondInput, text!)).toBe(1); + }); + test("inserts after leading developer metadata and before conversation", () => { const parsed = parseRequest({ model: "gpt-5.5", @@ -1182,6 +1231,96 @@ describe("injectDeveloperMessage", () => { expect((replay._rawBody as { input: unknown[] }).input.at(-1)).toEqual(generatedItem(guidanceA)); }); + test("proxy guidance dedup records a metadata A-B-A transition", () => { + const metadataA = "<opencodex_subagent_guidance>A</opencodex_subagent_guidance>"; + const metadataB = "<opencodex_subagent_guidance>B</opencodex_subagent_guidance>"; + const current = { type: "message", role: "user", content: "current turn" }; + const rawInput = [generatedItem(metadataA), generatedItem(metadataB), current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_1" }); + parsed._replayPrefixLen = 2; + parsed._continuationConversationMessageIndex = 2; + + injectDeveloperMessage(parsed, metadataA); + + expect(rawInput).toEqual([generatedItem(metadataA), generatedItem(metadataB), generatedItem(metadataA), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([metadataA, metadataB, metadataA, "current turn"]); + }); + + test("proxy guidance dedup preserves intervening native mode changes", () => { + const nativeA = "<multi_agent_mode>Native policy A</multi_agent_mode>"; + const nativeB = "<multi_agent_mode>Native policy B</multi_agent_mode>"; + const metadata = "<opencodex_subagent_guidance>Routing metadata</opencodex_subagent_guidance>"; + const rawInput = [generatedItem(nativeA), generatedItem(metadata), generatedItem(nativeB), { role: "user", content: "work" }]; + const before = structuredClone(rawInput); + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = 3; + + injectDeveloperMessage(parsed, metadata); + + expect(rawInput).toEqual(before); + expect(parsed.context.messages.map(message => message.content)).toEqual([nativeA, metadata, nativeB, "work"]); + }); + + test("native mode dedup ignores later proxy guidance", () => { + const native = "<multi_agent_mode>Native policy</multi_agent_mode>"; + const metadata = "<opencodex_subagent_guidance>Routing metadata</opencodex_subagent_guidance>"; + const rawInput = [generatedItem(native), generatedItem(metadata), { role: "user", content: "work" }]; + const before = structuredClone(rawInput); + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = 2; + + injectDeveloperMessage(parsed, native); + + expect(rawInput).toEqual(before); + expect(countExact(rawInput, native)).toBe(1); + }); + + test("restores default v2 guidance after a custom prompt without changing the custom body", async () => { + const dir = codexHomeFixture(V2_ON); + catalogFixture(dir, [{ slug: "gpt-5.6-terra", efforts: ["high", "max"], multiAgentVersion: "v2" }]); + const fixture = parsedFixture({ tools: [{ name: "spawn_agent" }] }); + const options = { injectionModel: "gpt-5.6-terra", injectionEffort: "high" }; + const metadata = await multiAgentGuidanceText(fixture, options); + const custom = await multiAgentGuidanceText(fixture, { + ...options, + injectionPrompt: "Custom {{model}} effort={{effort}}\nKeep {{unknown}}.", + }); + expect(metadata).not.toBeNull(); + const current = { type: "message", role: "user", content: "current turn" }; + const rawInput = [generatedItem(metadata!), generatedItem(custom!), current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_1" }); + parsed._replayPrefixLen = 2; + parsed._continuationConversationMessageIndex = 2; + + injectDeveloperMessage(parsed, (await multiAgentGuidanceText(fixture, options))!); + + expect(rawInput).toEqual([generatedItem(metadata!), generatedItem(custom!), generatedItem(metadata!), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([metadata, custom, metadata, "current turn"]); + expect(custom).toBe("<opencodex_subagent_guidance>Custom gpt-5.6-terra effort=high\nKeep {{unknown}}.</opencodex_subagent_guidance>"); + }); + + const legacyBuiltIn = '<multi_agent_mode>When the active spawn_agent tool supports optional "model" or "reasoning_effort" overrides, ' + + 'use only models listed for this collaboration surface. When setting either override, set fork_turns to "none" ' + + '(or a positive turn count such as "3"; full-history forks reject overrides) and make the task message self-contained.' + + ' Preferred sub-agent: model "gpt-5.6-terra", reasoning_effort "high" — use it unless the user names another.</multi_agent_mode>'; + test.each([ + ["built-in", legacyBuiltIn], + ["custom", "<multi_agent_mode>Operator-authored legacy prompt.</multi_agent_mode>"], + ])("preserves legacy %s and native policy when first injecting new proxy guidance", (_kind, legacy) => { + const native = "<multi_agent_mode>Native delegation policy</multi_agent_mode>"; + const metadata = "<opencodex_subagent_guidance>Routing metadata</opencodex_subagent_guidance>"; + const current = { type: "message", role: "user", content: "work" }; + const prefix = [generatedItem(legacy), generatedItem(native)]; + const rawInput = [...prefix, current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = prefix.length; + + injectDeveloperMessage(parsed, metadata); + + expect(rawInput).toEqual([...prefix, generatedItem(metadata), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([legacy, native, metadata, "work"]); + }); + test("exact-guidance predicate rejects every near-match replay-prefix shape (#326)", () => { const nearMatches: Array<[string, unknown]> = [ ["non-record item", null], diff --git a/tests/codex-integration/native-profile-processes.test.ts b/tests/codex-integration/native-profile-processes.test.ts index 0a5c449d12..133f97ff9d 100644 --- a/tests/codex-integration/native-profile-processes.test.ts +++ b/tests/codex-integration/native-profile-processes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { @@ -157,27 +157,19 @@ describe("native profile process probe", () => { }); test("kills and settles a timed-out child", async () => { - const directory = mkdtempSync(join(tmpdir(), "ocx-native-probe-")); - const survived = join(directory, "survived"); - const script = [ - `setTimeout(() => require('node:fs').writeFileSync(${JSON.stringify(survived)}, 'alive'), 300);`, - "setInterval(() => {}, 1_000);", - ].join(" "); - try { - await expect(executeNativeProcess(process.execPath, ["-e", script], { - encoding: "utf8", - timeout: 100, - maxBuffer: 1024, - windowsHide: true, - shell: false, - killSignal: "SIGKILL", - })).rejects.toThrow(); - await Bun.sleep(600); - expect(existsSync(survived)).toBe(false); - } finally { - removeTreeWithRetry(directory); - } - }); + // A child marker races the parent's timeout when its event loop is busy. + // Check the observed exit signal instead. Normal exit is a finite fuse, so + // disabling the executor timeout fails this assertion without orphaning a child. + const script = "setTimeout(() => process.exit(0), 10_000);"; + await expect(executeNativeProcess(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 100, + maxBuffer: 1024, + windowsHide: true, + shell: false, + killSignal: "SIGKILL", + })).rejects.toMatchObject({ killed: true, signal: "SIGKILL" }); + }, 15_000); test("rejects output above the configured byte cap", async () => { await expect(executeNativeProcess(process.execPath, [ diff --git a/tests/codex-integration/project-config-warnings.test.ts b/tests/codex-integration/project-config-warnings.test.ts index 16f9dd258d..1767ee655a 100644 --- a/tests/codex-integration/project-config-warnings.test.ts +++ b/tests/codex-integration/project-config-warnings.test.ts @@ -129,6 +129,49 @@ describe("parseTomlDocument", () => { const valid = parseTomlDocument('model_provider = "provider\\\\name"'); expect(valid.root.model_provider).toBe("provider\\name"); }, 2_000); + + for (const scenario of [ + { name: "root override", sameLine: false, tail: ['model_provider = "custom"'], + code: "model_provider_root", via: "root", profileName: null }, + { name: "same-line string", sameLine: true, tail: ['model_provider = "custom"'], + code: "model_provider_root", via: "root", profileName: null }, + { name: "selected profile", sameLine: false, + tail: ['profile = "work"', '[profiles.work]', 'model_provider = "custom"'], + code: "profile_selector", via: "profile", profileName: "work" }, + { name: "selected provider table", sameLine: false, + tail: ['model_provider = "custom"', '[model_providers.custom]', 'name = "Custom"'], + code: "model_providers_table", via: "root", profileName: null }, + ] as const) { + test(`overlapping multiline terminator preserves ${scenario.name} diagnostics`, () => { + const text = ['developer_instructions = """' + (scenario.sameLine ? "" : "\n") + + "foo" + "\\" + '"'.repeat(4), ...scenario.tail].join("\n"); + // Independent TOML parsing proves the escaped quote is followed by a real terminator. + expect(Bun.TOML.parse(text).developer_instructions).toBe('foo"'); + expect(resolveEffectiveProjectModelProvider(text)).toEqual({ + provider: "custom", profileName: scenario.profileName, via: scenario.via, + }); + const warnings = analyzeProjectCodexConfig(text, "fixture/.codex/config.toml"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ code: scenario.code, detail: "custom" }); + expect(warnings[0]!.profileName).toBe(scenario.profileName ?? undefined); + if (scenario.code === "model_providers_table") { + expect(parseTomlDocument(text).sections.get("model_providers.custom")?.name).toBe("Custom"); + } + }); + } + + test("escaped three quotes keep fake routing inside the multiline body", () => { + const text = ['developer_instructions = """', "foo" + "\\" + '"'.repeat(3), + 'model_provider = "custom"', '[model_providers.custom]', 'name = "Custom"', + '"""', 'model_provider = "openai"'].join("\n"); + const parsedByBun = Bun.TOML.parse(text); + expect(parsedByBun.model_provider).toBe("openai"); + expect(parsedByBun.developer_instructions).toContain('[model_providers.custom]'); + const parsed = parseTomlDocument(text); + expect(parsed.root.model_provider).toBe("openai"); + expect(parsed.sections.has("model_providers.custom")).toBe(false); + expect(analyzeProjectCodexConfig(text, "fixture/.codex/config.toml")).toEqual([]); + }); }); describe("parseTrustedProjectPathsFromCodexConfig", () => { diff --git a/tests/codex-integration/reserve-availability.test.ts b/tests/codex-integration/reserve-availability.test.ts index d5298ea2f7..38d10e7ada 100644 --- a/tests/codex-integration/reserve-availability.test.ts +++ b/tests/codex-integration/reserve-availability.test.ts @@ -3,7 +3,8 @@ import { clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity, } from "../../src/codex/main-account-cache"; import { - getMainReserveAuthorization, isMainReserveAuthorizationLive, observeMainReserveRevocation, + getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, + observeMainReserveRevocation, } from "../../src/codex/reserve-availability"; import type { WhamUsageResponse } from "../../src/codex/quota-types"; @@ -239,3 +240,31 @@ describe("owned main Reserve capability", () => { } finally { timer.mockRestore(); response.resolve(Response.json(grant())); } }); }); + +describe("native user identity claims", () => { + const token = (auth: Record<string, unknown>) => + `fixture.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": auth })).toString("base64url")}.signature`; + + test("precedence stays on the raw claims, so an unusable chatgpt_user_id blocks the fallback", () => { + expect(nativeUserIdClaims(token({ chatgpt_user_id: "user-a", user_id: "user-a" }))) + .toEqual({ userId: "user-a", conflict: false }); + expect(nativeUserIdClaims(token({ user_id: "user-b" }))).toEqual({ userId: "user-b", conflict: false }); + // An empty or non-string primary claim selects nothing rather than falling through. + expect(nativeUserIdClaims(token({ chatgpt_user_id: "", user_id: "user-c" }))) + .toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims(token({ chatgpt_user_id: 17, user_id: "user-d" }))) + .toEqual({ userId: undefined, conflict: false }); + }); + + test("two disagreeing encodings report a conflict without changing the selected id", () => { + expect(nativeUserIdClaims(token({ chatgpt_user_id: "user-a", user_id: "user-b" }))) + .toEqual({ userId: "user-a", conflict: true }); + }); + + test("absent, unparseable, and foreign-namespace tokens report nothing", () => { + expect(nativeUserIdClaims(token({}))).toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims("not-a-token")).toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims(`fixture.${Buffer.from(JSON.stringify({ sub: "user-a" })).toString("base64url")}.sig`)) + .toEqual({ userId: undefined, conflict: false }); + }); +}); diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index 06a18dd99d..f874ce66de 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -8,6 +8,7 @@ import { nextAtomicTempSequence } from "../../src/config/atomic-write"; import { CodexCredentialRefreshLockTimeoutError, getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import type { OcxConfig } from "../../src/types"; import { ManagementRequest, managementHeaders } from "../helpers/management-auth"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; @@ -24,23 +25,44 @@ function config(port = 10100): OcxConfig { }; } -async function waitForPath(path: string): Promise<void> { - for (let attempt = 0; attempt < 500; attempt += 1) { +async function waitForOwnedChildReady(child: ReturnType<typeof Bun.spawn>, path: string): Promise<void> { + // Readiness budget follows the predeclared platform policy: 5 s locally, 30 s on CI, + // 45 s on Windows CI (watchdogMs), because spawning a Bun child that imports + // src/config.ts takes real time on a loaded runner — a sibling child in the failing + // shard needed 8.3 s end to end. The deadline uses elapsed time with a final recheck, + // and an already-exited child fails fast with its exit code instead of polling the + // full budget. + const budgetMs = watchdogMs(5_000); + const deadline = performance.now() + budgetMs; + while (performance.now() < deadline) { if (existsSync(path)) return; - await Bun.sleep(10); + const exit = await Promise.race([ + Bun.sleep(10).then(() => null), + child.exited.then(code => code as number | null), + ]); + if (exit !== null) { + const stderr = await new Response(child.stderr).text().catch(() => ""); + throw new Error(`config-lock child exited ${exit} before writing marker ${path}\nchild stderr: ${stderr}`); + } } - throw new Error(`Timed out waiting for child marker ${path}`); + if (existsSync(path)) return; + throw new Error(`Timed out waiting ${budgetMs}ms for child marker ${path}`); } async function waitForOwnedChild(child: ReturnType<typeof Bun.spawn>): Promise<number> { + // The child polls for the release marker on a 10 ms sleep, so its exit is bounded by the + // filesystem noticing that write plus one Bun teardown; a loaded Windows runner needs + // real room for both. This helper's own kill() fires only after the full budget and + // throws, so a surfaced exit 143 is never from here — it is the readiness-timeout + // catch's child.kill(), and the error to read is the readiness failure, not this wait. const result = await Promise.race([ child.exited.then(exitCode => ({ exitCode })), - Bun.sleep(5_000).then(() => null), + Bun.sleep(30_000).then(() => null), ]); if (result) return result.exitCode; child.kill(); await child.exited; - throw new Error("Timed out waiting for owned config-lock child"); + throw new Error("Timed out waiting for owned config-lock child after 30s"); } beforeEach(() => { @@ -76,10 +98,12 @@ test("a live cross-process holder is not stolen and runtime writers fail immedia stderr: "pipe", }); + let childKilled = false; try { try { - await waitForPath(readyPath); + await waitForOwnedChildReady(child, readyPath); } catch (error) { + childKilled = true; child.kill(); await child.exited; const stderr = await new Response(child.stderr).text().catch(() => ""); @@ -103,7 +127,11 @@ test("a live cross-process holder is not stolen and runtime writers fail immedia expect(getCodexAccountCredential("busy-account")).toBeNull(); } finally { writeFileSync(releasePath, "release"); - expect(await waitForOwnedChild(child)).toBe(0); + // The readiness-failure path already killed the child; expecting exit 0 here + // would mask that primary error with a bare 143. + if (!childKilled) { + expect(await waitForOwnedChild(child)).toBe(0); + } } saveConfig(config(20200)); @@ -230,22 +258,38 @@ test("exclusive temp collision does not remove or modify somebody else's file", test("failed hardening occurs before candidate bytes are written", () => { let wrote = false; - expect(() => initializePersistedConfigIfMissing(config(), { - harden(_fd, temp) { - expect(readFileSync(temp, "utf8")).toBe(""); - throw new Error("ACL denied"); - }, - write() { wrote = true; }, - })).toThrow(InitialConfigPublicationError); + let linked = false; + let failure: unknown; + try { + initializePersistedConfigIfMissing(config(), { + harden(_fd, temp) { + expect(readFileSync(temp, "utf8")).toBe(""); + throw new Error("private ACL failure detail"); + }, + write() { wrote = true; }, + link() { linked = true; }, + }); + } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(InitialConfigPublicationError); + expect((failure as Error).message).toContain("permissions could not be secured"); + expect((failure as Error).message).toContain("OPENCODEX_HOME"); + expect((failure as Error).message).not.toContain("private ACL failure detail"); + expect(failure).toMatchObject({ publication: "not-published", hardLinkUnavailable: false, residualTemp: false }); expect(wrote).toBe(false); + expect(linked).toBe(false); expect(existsSync(getConfigPath())).toBe(false); expect(initTemps()).toEqual([]); }); test("partial write failure removes only the unpublished temporary name", () => { - expect(() => initializePersistedConfigIfMissing(config(), { - write(fd, bytes) { writeFileSync(fd, bytes.slice(0, 10)); throw new Error("disk full"); }, - })).toThrow(InitialConfigPublicationError); + let failure: unknown; + try { + initializePersistedConfigIfMissing(config(), { + write(fd, bytes) { writeFileSync(fd, bytes.slice(0, 10)); throw new Error("disk full"); }, + }); + } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(InitialConfigPublicationError); + expect((failure as Error).message).toBe("Initial config publication did not finish."); expect(existsSync(getConfigPath())).toBe(false); expect(initTemps()).toEqual([]); }); @@ -259,6 +303,10 @@ test.each(["EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV", "EPERM"])("unsupported/de } catch (error) { expect(error).toBeInstanceOf(InitialConfigPublicationError); expect((error as InitialConfigPublicationError).hardLinkUnavailable).toBe(true); + expect((error as Error).message).toContain("OPENCODEX_HOME"); + expect((error as Error).message).toContain("private file permissions"); + expect((error as Error).message).not.toContain("do not print raw error"); + expect((error as Error).message).not.toContain("permissions could not be secured"); } expect(existsSync(getConfigPath())).toBe(false); expect(initTemps()).toEqual([]); @@ -361,8 +409,17 @@ test("management API maps config mutation lock contention to retryable 503", asy stderr: "pipe", }); + let childKilled = false; try { - await waitForPath(readyPath); + try { + await waitForOwnedChildReady(child, readyPath); + } catch (error) { + childKilled = true; + child.kill(); + await child.exited; + const stderr = await new Response(child.stderr).text().catch(() => ""); + throw new Error(`${(error as Error).message}\nchild stderr: ${stderr}`); + } const { handleManagementAPI } = await import("../../src/server/management-api"); const url = new URL("http://localhost/api/codex-auth/auto-switch"); const response = await handleManagementAPI( @@ -381,6 +438,10 @@ test("management API maps config mutation lock contention to retryable 503", asy }); } finally { writeFileSync(releasePath, "release"); - expect(await waitForOwnedChild(child)).toBe(0); + // The readiness-failure path already killed the child; expecting exit 0 here + // would mask that primary error with a bare 143. + if (!childKilled) { + expect(await waitForOwnedChild(child)).toBe(0); + } } }); diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index 029dbb5146..8e88100389 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -381,6 +381,42 @@ describe("PUT /api/settings", () => { expect(bad!.status).toBe(400); }); + test("codexClientCompaction (#3978): absent reports false, changes converge once, and disable deletes the key", async () => { + const config = baseConfig(); + const absent = await (await getSettings(config))!.json() as { codexClientCompaction?: boolean }; + expect(absent.codexClientCompaction).toBe(false); + + let convergences = 0; + let saved: OcxConfig | undefined; + const on = await putSettings(config, { codexClientCompaction: true }, { + saveConfigPreservingClaudeCode: next => { saved = next; }, + createManagementConvergeCodex: catalogConvergenceFactory(() => { convergences += 1; }), + }); + expect(on!.status).toBe(200); + expect(await on!.json()).toMatchObject({ codexClientCompaction: true }); + expect(saved?.codexClientCompaction).toBe(true); + expect(convergences).toBe(1); + + const same = await putSettings(config, { codexClientCompaction: true }, { + saveConfigPreservingClaudeCode: () => {}, + createManagementConvergeCodex: catalogConvergenceFactory(() => { convergences += 1; }), + }); + expect(same!.status).toBe(200); + expect(convergences).toBe(1); + + const off = await putSettings(config, { codexClientCompaction: false }, { + saveConfigPreservingClaudeCode: next => { saved = next; }, + createManagementConvergeCodex: catalogConvergenceFactory(() => { convergences += 1; }), + }); + expect(off!.status).toBe(200); + expect(await off!.json()).toMatchObject({ codexClientCompaction: false }); + expect(Object.hasOwn(saved!, "codexClientCompaction")).toBe(false); + expect(convergences).toBe(2); + + const bad = await putSettings(config, { codexClientCompaction: "yes" }); + expect(bad!.status).toBe(400); + }); + test("account-picker disable does not initialize an empty namespace map", async () => { const config = baseConfig(); let convergences = 0; diff --git a/tests/fixtures/parent-stop-runner.ts b/tests/fixtures/parent-stop-runner.ts new file mode 100644 index 0000000000..9a26d52845 --- /dev/null +++ b/tests/fixtures/parent-stop-runner.ts @@ -0,0 +1,99 @@ +/** Runs the real CLI/stop module graph with process and client I/O isolated in this child. */ +import { mock } from "bun:test"; +import * as childProcess from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CodexNativeRestoreResult } from "../../src/codex/inject"; + +const options = JSON.parse(readFileSync(0, "utf8")) as { + receipt: boolean; + response: unknown; + restore: CodexNativeRestoreResult; + status?: number; +}; +const home = process.env.OPENCODEX_HOME!; +const endpoint = { hostname: "127.0.0.1", port: 10100 }; +const fakePid = 4242; +const calls = { killed: 0, native: 0, grok: 0, cleared: 0, exited: 0 }; +const urls: string[] = []; +const unexpectedIo: string[] = []; +let alive = true; +let nonce: string | undefined; + +function unexpected(operation: string): never { + unexpectedIo.push(operation); + throw new Error(`unexpected external I/O in parent stop fixture: ${operation}`); +} + +// Neither an accidental POSIX signal nor the Windows taskkill fallback may reach the host. +process.kill = ((pid: number, signal?: number | NodeJS.Signals) => { + if (pid !== fakePid || signal !== 0) { + calls.killed += 1; + return unexpected(`process.kill(${pid}, ${signal})`); + } + if (alive) return true; + calls.exited += 1; + throw Object.assign(new Error("fixture process exited"), { code: "ESRCH" }); +}) as typeof process.kill; +mock.module("node:child_process", () => ({ + ...childProcess, + execFileSync: () => { calls.killed += 1; return unexpected("execFileSync"); }, +})); + +const receipts = await import("../../src/config/pending-teardown"); +process.on("exit", () => { + writeFileSync(join(home, "parent-stop-result.json"), JSON.stringify({ + calls, urls, unexpectedIo, nonce, + receiptExists: nonce !== undefined && existsSync(receipts.pendingTeardownPathFor(nonce)), + })); +}); +const claimReceipt = receipts.claimPendingTeardown; +const clearReceipt = receipts.clearPendingTeardown; +mock.module("../../src/config/pending-teardown", () => ({ + ...receipts, + claimPendingTeardown: (...args: Parameters<typeof claimReceipt>) => { + if (!options.receipt) throw new Error("receipt storage unavailable"); + const receipt = claimReceipt(...args); + nonce = receipt.nonce; + return receipt; + }, + clearPendingTeardown: (value: string) => { calls.cleared += 1; return clearReceipt(value); }, +})); + +const state = await import("../../src/config/process-state"); +mock.module("../../src/config/process-state", () => ({ + ...state, + readPid: () => fakePid, + readRuntimePort: () => endpoint, + removePid() {}, + removeRuntimePort() {}, +})); +const service = await import("../../src/service"); +mock.module("../../src/service", () => ({ ...service, stopServiceIfInstalledDetailed: () => "absent" })); +const native = await import("../../src/codex/inject"); +mock.module("../../src/codex/inject", () => ({ + ...native, + restoreNativeCodexAsync: async () => { calls.native += 1; return options.restore; }, +})); +const grok = await import("../../src/grok/inject"); +mock.module("../../src/grok/inject", () => ({ + ...grok, + stripGrokConfig: () => { calls.grok += 1; return { ok: true, changed: true, message: "Grok restored" }; }, +})); +const systemEnv = await import("../../src/server/system-env"); +mock.module("../../src/server/system-env", () => ({ ...systemEnv, revertSystemEnv() {} })); +const portReclaim = await import("../../src/server/port-reclaim"); +mock.module("../../src/server/port-reclaim", () => ({ ...portReclaim, reclaimListenPort: async () => {} })); +// Only shim preflight is unrelated to this stop contract; parsing and dispatch stay real. +mock.module("../../src/cli/codex-shim-autorestore", () => ({ maybeAutoRestoreCodexShim() {} })); + +globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input); + if (!url.startsWith("http://127.0.0.1:10100/api/stop")) return unexpected(`fetch(${url})`); + urls.push(url); + if ((options.status ?? 200) !== 409) alive = false; + return Response.json(options.response, { status: options.status ?? 200 }); +}) as typeof fetch; + +process.argv = [process.execPath, "ocx", "stop"]; +await import("../../src/cli/index"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e9f32f3bcf..f62377f3e7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -4,7 +4,6 @@ "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", "acl-error-classification.test.ts": "lib", - "actionlint-runner.test.ts": "ci-workflows", "active-registry-admission.test.ts": "codex-integration", "adapter-buffered-tool-conformance.test.ts": "adapters", "adapter-error-inline.test.ts": "adapters", @@ -14,19 +13,11 @@ "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", - "agent-roles-sync.test.ts": "routing", "agent-task-recovery-cache.test.ts": "server", "agent-task-recovery-combo.test.ts": "server", "agent-task-recovery-fallback.test.ts": "server", "agent-task-recovery-security.test.ts": "server", "agent-task-recovery.test.ts": "server", - "aistudio-bridge-endpoint.test.ts": "adapters/google", - "aistudio-credentials.test.ts": "adapters/google", - "aistudio-extension.test.ts": "adapters/google", - "aistudio-login-cli.test.ts": "adapters/google", - "aistudio-login-flow.test.ts": "adapters/google", - "aistudio-native-webkit.test.ts": "adapters/google", - "aistudio-session-sync.test.ts": "adapters/google", "alias-management-api.test.ts": "server", "alibaba-intl-token-plan.test.ts": "gui", "alibaba-region-backup.test.ts": "providers", @@ -66,6 +57,7 @@ "api-codex-log-guard.test.ts": "server", "api-debug.test.ts": "server", "api-key-attribution.test.ts": "server", + "api-key-selection-capture.test.ts": "providers", "api-keys-routes.test.ts": "server", "api-storage-cleanup.test.ts": "storage", "api-storage-policy-already-running.test.ts": "storage", @@ -81,16 +73,17 @@ "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", "aside-profile-identity.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", + "aside-profiles-routes.test.ts": "server", + "aside-profiles.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", - "audit-high.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", - "auto-release-workflow.test.ts": "fork", "autostart-health.test.ts": "service", "azure-adapter.test.ts": "providers", "azure-model-router-tool-schema.test.ts": "providers", "baseten-provider.test.ts": "providers", "bearer-admission-routed-provider.test.ts": "codex-integration", - "benchmark-claude-tokens-script.test.ts": "claude-integration", "bounded-body.test.ts": "server", "bridge-legacy-shell-normalization.test.ts": "adapters", "bridge-lifecycle.test.ts": "adapters", @@ -107,6 +100,9 @@ "bun-stream-caps.test.ts": "lib", "cancel-body-on-abort.test.ts": "server", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-go-exact-efforts.test.ts": "codex-integration", + "catalog-hub-context-window.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", @@ -119,7 +115,6 @@ "chatgpt-device-auth.test.ts": "oauth", "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", - "check-hygiene.test.ts": "ci-workflows", "chutes-provider.test.ts": "providers", "ci-workflows.test.ts": "ci-workflows", "citation-markers.test.ts": "responses", @@ -133,21 +128,17 @@ "claude-auth-detect.test.ts": "claude-integration", "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", - "claude-certification.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", - "claude-client-version.test.ts": "claude-integration", - "claude-code-compatibility-manifest.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", + "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-native-context.test.ts": "claude-integration", "claude-desktop-policy.test.ts": "claude-integration", - "claude-directive-auth.test.ts": "claude-integration", - "claude-directive-fallback.test.ts": "claude-integration", - "claude-directive-lifecycle.test.ts": "claude-integration", + "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", @@ -159,14 +150,10 @@ "claude-models-discovery.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", - "claude-reasoning-roundtrip.test.ts": "claude-integration", - "claude-route-callback.test.ts": "claude-integration", - "claude-session.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", - "claude-token-benchmark.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", "cli-account-pool-verbs.test.ts": "cli", @@ -186,6 +173,7 @@ "cli-help.test.ts": "cli", "cli-json-contract.test.ts": "cli", "cli-management-auth.test.ts": "cli", + "cli-models-price.test.ts": "cli", "cli-models-reasoning.test.ts": "cli", "cli-models-runtime-dispatch.test.ts": "cli", "cli-models.test.ts": "cli", @@ -210,12 +198,16 @@ "client-export-modality-enum.test.ts": "clients", "client-fingerprint.test.ts": "clients", "client-hub-relay.test.ts": "clients", + "client-injection-guard.test.ts": "codex-integration", + "client-lifecycle-lock.test.ts": "clients", "client-machine-listener.test.ts": "clients", "cline-pass-deepseek-v4-tool-replay.test.ts": "providers", "cline-pass-provider.test.ts": "providers", "cline-pass-reasoning-efforts.test.ts": "providers", "cline-provider.test.ts": "providers", "closed-pr-branch-cleanup.test.ts": "ci-workflows", + "codebuddy-adapter.test.ts": "providers", + "codebuddy-protocol.test.ts": "providers", "codex-account-delete-atomicity.test.ts": "codex-integration", "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", @@ -308,7 +300,6 @@ "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", "codex-quota-rejection.test.ts": "codex-integration", - "codex-quota.test.ts": "providers", "codex-refresh.test.ts": "codex-integration", "codex-reset-credit-auto-redeem.test.ts": "codex-integration", "codex-reset-credit-operation-ledger.test.ts": "codex-integration", @@ -363,8 +354,6 @@ "container-bootstrap.test.ts": "service", "context-cap-unknown-window.test.ts": "providers", "continuation-dedup.test.ts": "responses", - "conversation-progress.test.ts": "usage", - "core-fork-preservation.test.ts": "responses", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", "cost-scoring.test.ts": "usage", @@ -450,9 +439,8 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", - "dev-auto-release-workflow.test.ts": "fork", - "dev-promotion-workflow.test.ts": "fork", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", @@ -538,6 +526,7 @@ "gui-static.test.ts": "gui", "health-scoring.test.ts": "server", "history-migration-guardian.test.ts": "codex-integration", + "history-ocx-compaction-recovery.test.ts": "codex-integration", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", "init-backup-cleanup.test.ts": "service", @@ -595,6 +584,7 @@ "lab-evidence-sanitization.test.ts": "lab", "lab-fabric-outcome-validation.test.ts": "lab", "lab-fabric-persistence-boundary.test.ts": "lab", + "lab-fabric-producer-deadline.test.ts": "lab", "lab-fabric-task.test.ts": "lab", "lab-installation-salt-cache.test.ts": "lab", "lab-ledger-mutation-lock.test.ts": "lab", @@ -630,22 +620,18 @@ "lab-read-filter-validation.test.ts": "lab", "lab-read-surfaces.test.ts": "lab", "legacy-shell-compat.test.ts": "responses", - "live-inference-workflow.test.ts": "ci-workflows", - "live-smoke-ci.test.ts": "ci-workflows", - "live-smoke-cli.test.ts": "ci-workflows", - "live-smoke-report.test.ts": "ci-workflows", "local-management-attestation.test.ts": "server", "local-management-capability.test.ts": "server", "local-management-direct-transport.test.ts": "server", "local-provider-reload-client.test.ts": "server", "local-token-detect.test.ts": "oauth", - "logs-filter.test.ts": "gui", "logs-model-tier-confirmation.test.ts": "gui", "logs-timezone.test.ts": "server", "loop-reasoning-replay.test.ts": "images", "loop.test.ts": "images", "loopback-listener-admission.test.ts": "server", "loopback-listener-integration.test.ts": "server", + "macos-serial-lanes.test.ts": "ci-workflows", "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", "main-account-hard-lock-recovery.test.ts": "codex-integration", @@ -669,14 +655,17 @@ "minimax-reasoning-split.test.ts": "providers", "model-cache-generation-tombstone.test.ts": "codex-integration", "model-cache.test.ts": "codex-integration", + "model-costs-management-api.test.ts": "server", "model-discovery-management-api.test.ts": "server", "model-display-names-management-api.test.ts": "codex-integration", - "model-metadata-resolver.test.ts": "codex-integration", "model-metadata-sync.test.ts": "codex-integration", + "model-pinned-effort-config.test.ts": "config", + "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", "model-selection-guidance.test.ts": "cli", "model-visibility-management-api.test.ts": "codex-integration", + "models-feedback-callback.test.ts": "gui", "models-page-groups.test.ts": "gui", "models-workspace-tabs.test.ts": "gui", "moonshot-endpoints.test.ts": "providers", @@ -720,7 +709,6 @@ "oauth-callback-binds.test.ts": "oauth", "oauth-callback-server.test.ts": "oauth", "oauth-device-code-contract.test.ts": "oauth", - "oauth-failover-optout-security.test.ts": "oauth", "oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth", "oauth-log.test.ts": "oauth", @@ -738,7 +726,6 @@ "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", - "oauth-transport.test.ts": "oauth", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", @@ -781,9 +768,9 @@ "opencode-go-session-header.test.ts": "providers", "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", - "orcarouter-provider.test.ts": "providers", "openrouter-provider-routing.test.ts": "providers", "optional-shutdown-hooks.test.ts": "lib", + "orcarouter-provider.test.ts": "providers", "outbound-body-guard.test.ts": "server", "owned-service-home.test.ts": "server", "package-tree-integrity.test.ts": "ci-workflows", @@ -801,17 +788,13 @@ "policy-execution.test.ts": "routing", "port-reclaim.test.ts": "server", "ports.test.ts": "server", - "post-release-decision.test.cjs": "fork", "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", "privacy-scan-meta-key.test.ts": "ci-workflows", - "privacy-scan.test.ts": "ci-workflows", "process-control-graceful.test.ts": "lib", "process-control.test.ts": "lib", "process-state.test.ts": "service", "project-config-warnings.test.ts": "codex-integration", - "promotion-audit-reuse.test.ts": "ci-workflows", - "promotion-backmerge.test.ts": "fork", "provider-account-quota-persistence.test.ts": "providers", "provider-account-quota-routes.test.ts": "server", "provider-account-quota.test.ts": "providers", @@ -834,13 +817,14 @@ "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", "provider-static-model-discovery.test.ts": "providers", - "provider-tls-profile.test.ts": "providers", "provider-workspace-auth.test.ts": "gui", "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", "provider-workspace-state.test.ts": "gui", "proxy-env.test.ts": "server", "proxy-liveness.test.ts": "server", + "qoder-adapter.test.ts": "providers", + "qoder-live-models.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", @@ -859,22 +843,17 @@ "raycast-client.test.ts": "clients", "raycast-detect.test.ts": "clients", "reasoning-effort.test.ts": "codex-integration", + "reasoning-envelope.test.ts": "responses", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", - "register.test.ts": "fork", "relay-eager.test.ts": "server", - "release-candidate-publish-workflow.test.ts": "fork", - "release-candidate-workflow.test.ts": "fork", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", - "release-pr-workflow.test.ts": "fork", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", "remove-tree-helper.test.ts": "lib", - "replit-pair-install-response.test.ts": "providers", - "replit-provider-setup.test.ts": "providers", "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", "request-evidence.test.ts": "usage", @@ -901,12 +880,13 @@ "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", - "responses-forward-incomplete-quota.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", + "responses-forward-incomplete-quota.test.ts": "responses", "responses-forward-posit-continuation.test.ts": "responses", "responses-forward-prompt-envelope.test.ts": "responses", + "responses-function-tool-repair.test.ts": "responses", "responses-image-gen-repair.test.ts": "responses", "responses-inbound-store-default.test.ts": "responses", "responses-item-id-repair.test.ts": "responses", @@ -938,6 +918,7 @@ "retry-after-429.test.ts": "server", "route-decision-trace.test.ts": "server", "route-explainability.test.ts": "cli", + "routed-agent-messages.test.ts": "adapters", "router-combo-failover-classification.test.ts": "routing", "router-discarded-baseurl-warning.test.ts": "routing", "router-template-baseurl.test.ts": "routing", @@ -961,10 +942,12 @@ "selected-models.test.ts": "codex-integration", "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", + "server-agent-task-recovery-replay.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.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", @@ -1008,8 +991,7 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", - "smoke-fingerprint-cache.test.ts": "ci-workflows", - "smoke-runner.test.ts": "ci-workflows", + "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", "sse-decoder.test.ts": "responses", "sse-failed-tail.test.ts": "responses", @@ -1045,25 +1027,7 @@ "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", "subagent-roster-retention.test.ts": "routing", - "sync-cli.test.ts": "fork", "sync-client-integrations.test.ts": "clients", - "sync-contained.test.ts": "fork", - "sync-detect.test.ts": "fork", - "sync-generic-cli.test.ts": "fork", - "sync-generic-http.test.ts": "fork", - "sync-lane.test.ts": "fork", - "sync-notify.test.ts": "fork", - "sync-overlap.test.ts": "fork", - "sync-ownership.test.ts": "fork", - "sync-pin.test.ts": "fork", - "sync-pr-mergeable.test.ts": "fork", - "sync-prepare.test.ts": "fork", - "sync-preservation.test.ts": "fork", - "sync-publish.test.ts": "fork", - "sync-pull-request.test.ts": "fork", - "sync-vendor-atomic.test.ts": "fork", - "sync-webhook.test.ts": "fork", - "sync-workflow.test.ts": "fork", "synthetic-tool.test.ts": "images", "system-env.test.ts": "server", "system-restart-client.test.ts": "cli", @@ -1071,10 +1035,6 @@ "system-restart.test.ts": "server", "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", - "telemetry-dispatcher.test.ts": "usage", - "telemetry-fingerprint.test.ts": "usage", - "telemetry-hook.test.ts": "usage", - "telemetry-ledger.test.ts": "usage", "tencent-siliconflow-providers.test.ts": "gui", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", @@ -1082,7 +1042,6 @@ "test-home-guard.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", - "reasoning-envelope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", "tool-argument-integers.test.ts": "adapters", @@ -1122,6 +1081,7 @@ "usage-shape-extraction.test.ts": "usage", "usage-summary.test.ts": "usage", "usage-surfaces.test.ts": "usage", + "usage-time-range.test.ts": "usage", "user-cost-overlay-coderabbit-regressions.test.ts": "usage", "user-cost-overlay-live-reconcile.test.ts": "usage", "user-cost-overlay-provider-delete.test.ts": "usage", @@ -1170,7 +1130,6 @@ "windows-user-principal.test.ts": "windows", "winsw-stop-hardening.test.ts": "windows", "winsw.test.ts": "service", - "workflow-policy.test.ts": "ci-workflows", "ws-endpoint.test.ts": "responses", "ws-upstream-reuse.test.ts": "responses", "ws-upstream.test.ts": "responses", @@ -1189,26 +1148,5 @@ "zhipu-bigmodel-provider.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", - "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", - "aside-profiles-routes.test.ts": "server", - "aside-profiles.test.ts": "clients", - "aside-profile-paths.test.ts": "clients", - "aside-profile-sync-owner.test.ts": "clients", - "catalog-full-picker-order.test.ts": "codex-integration", - "catalog-go-exact-efforts.test.ts": "codex-integration", - "claude-desktop-discovery.test.ts": "claude-integration", - "claude-desktop-remote-hub.test.ts": "claude-integration", - "client-injection-guard.test.ts": "codex-integration", - "client-lifecycle-lock.test.ts": "clients", - "desktop-remote-store.test.ts": "clients", - "lab-fabric-producer-deadline.test.ts": "lab", - "opencode-go-agent-messages.test.ts": "providers", - "responses-function-tool-repair.test.ts": "responses", - "server-agent-task-recovery-replay.test.ts": "server", - "server-google-antigravity-oauth-401-replay.test.ts": "server", - "cli-models-price.test.ts": "cli", - "model-costs-management-api.test.ts": "server", - "usage-time-range.test.ts": "usage", - "model-pinned-effort.test.ts": "codex-integration", - "model-pinned-effort-config.test.ts": "config" + "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" } diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 2353104311..47b196b688 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -111,6 +111,7 @@ describe("the client registries cannot drift apart", () => { test("source preservation and cross-process locking are registry capabilities", () => { expect(INTEGRATION_CLIENTS.omp.sourcePreservingYaml?.path).toEqual(["providers", "opencodex"]); + expect(INTEGRATION_CLIENTS.hermes.sourcePreservingYaml?.path).toEqual(["providers", "opencodex"]); expect(INTEGRATION_CLIENTS.dsh.sourcePreservingYaml?.path).toEqual([ "llm-pi-ai", "providers", "opencodex", ]); @@ -649,7 +650,6 @@ describe("the base URL is composed, never interpolated", () => { ]; for (const [hostname, expected] of cases) { const configPath = installClient("hermes"); - writeFileSync(configPath, "providers: {}\n"); const result = applyIntegration({ clientId: "hermes", models: MODELS, port: 10100, config: { ...CONFIG, hostname } as OcxConfig, @@ -673,14 +673,14 @@ describe("a restore never launders a foreign edit into owned content", () => { * made the state read `current`, and disable then deleted the user's own * field as if it were ours. */ - const configPath = installClient("hermes"); + const configPath = installClient("gajae"); writeFileSync(configPath, "providers:\n mine:\n api: http://keep-me\n"); const write = { - clientId: "hermes" as const, models: MODELS, config: CONFIG, port: 10100, + clientId: "gajae" as const, models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store, }; expect(applyIntegration(write).ok).toBe(true); - const applyOp = store.listOperations("hermes")[0]!.opId; + const applyOp = store.listOperations("gajae")[0]!.opId; // The user edits the file by hand, adding something of their own. const edited = `${readFileSync(configPath, "utf8")}user_field: mine\n`; @@ -688,7 +688,7 @@ describe("a restore never launders a foreign edit into owned content", () => { // Confirmed drift-restore back to the applied bytes; the edit is snapshotted. expect(restoreIntegration({ ...write, opId: applyOp, confirmDrift: true }).ok).toBe(true); - const restoreOp = store.listOperations("hermes")[0]!.opId; + const restoreOp = store.listOperations("gajae")[0]!.opId; // Undo that restore: the user's edited bytes come back. expect(restoreIntegration({ ...write, opId: restoreOp, confirmDrift: true }).ok).toBe(true); @@ -696,7 +696,7 @@ describe("a restore never launders a foreign edit into owned content", () => { // The record no longer describes these bytes, so the state is conflict… const status = readIntegrationState({ - clientId: "hermes", models: MODELS, config: CONFIG, port: 10100, + clientId: "gajae", models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store, }); expect(status.state).toBe("conflict"); @@ -717,9 +717,9 @@ describe("the store's own root stays tidy", () => { * catches is a new bookkeeping file appearing without anyone deciding it * should exist. */ - writeFileSync(installClient("hermes"), "providers: {}\n"); + writeFileSync(installClient("gajae"), "providers: {}\n"); const write = { - clientId: "hermes" as const, models: MODELS, config: CONFIG, port: 10100, + clientId: "gajae" as const, models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store, }; expect(applyIntegration(write).ok).toBe(true); diff --git a/tests/gui/models-feedback-callback.test.ts b/tests/gui/models-feedback-callback.test.ts new file mode 100644 index 0000000000..460baae2b5 --- /dev/null +++ b/tests/gui/models-feedback-callback.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { repoPath } from "../helpers/repo-root"; + +const modelsSource = await Bun.file(repoPath("gui", "src", "pages", "Models.tsx")).text(); + +/** + * `publishFeedback` is called from 21 sites and, more importantly, from inside + * `saveDisplayName`, which is itself a `useCallback`. Declared as a plain function it was a + * new identity on every render, so `saveDisplayName` either captured a stale copy or had to + * omit it from its dependency array — the omission is what dev shipped. React's setters are + * the only values the body reads, and those are guaranteed stable, so `useCallback(..., [])` + * is sound and makes the dependency honest instead of suppressed. + */ +test("publishFeedback is a stable useCallback with an empty dependency list", () => { + const at = modelsSource.indexOf("const publishFeedback ="); + expect(at).toBeGreaterThan(-1); + + const declaration = modelsSource.slice(at, modelsSource.indexOf("\n //", at)); + expect(declaration).toContain("useCallback((nextOk: boolean, message: string)"); + // The body may only touch setters; anything else would make [] a lie. + expect(declaration).toContain("setOk(nextOk)"); + expect(declaration).toContain("setStatus(message)"); + expect(declaration).toContain("setFeedbackGen(g => g + 1)"); + expect(declaration.trimEnd().endsWith("}, []);")).toBe(true); +}); + +test("saveDisplayName declares publishFeedback in its dependency array", () => { + const bodyAt = modelsSource.indexOf("const saveDisplayName = useCallback"); + expect(bodyAt).toBeGreaterThan(-1); + + const body = modelsSource.slice(bodyAt); + const deps = body.slice(body.indexOf("}, ["), body.indexOf("]);") + 3); + expect(body.slice(0, body.indexOf("}, ["))) + .toContain("publishFeedback(true, confirmed"); + expect(deps).toContain("publishFeedback"); +}); + diff --git a/tests/helpers/adapter-conformance/wire-drivers.ts b/tests/helpers/adapter-conformance/wire-drivers.ts index 979d1d4780..d99ea81a6d 100644 --- a/tests/helpers/adapter-conformance/wire-drivers.ts +++ b/tests/helpers/adapter-conformance/wire-drivers.ts @@ -318,4 +318,13 @@ export const TOOL_WIRE_DRIVERS = { } }, }, + codebuddy: { + // CodeBuddy v1 runs the vendor CLI with `--tools ""` so Codex keeps tool ownership; it forwards + // no client tool catalog and is exempt from routed-tool conformance, so this driver is never + // invoked. It fails loudly if a future change routes it here before the control-protocol tool + // bridge (sdk_mcp / can_use_tool) lands. + async observeOutbound(): Promise<string> { + throw new Error("codebuddy forwards no client tool catalog in v1; excluded from tool conformance"); + }, + }, } satisfies Record<AdapterWire, ToolWireDriver>; diff --git a/tests/helpers/bounded-auth-read-child.ts b/tests/helpers/bounded-auth-read-child.ts new file mode 100644 index 0000000000..a5fd21bdd4 --- /dev/null +++ b/tests/helpers/bounded-auth-read-child.ts @@ -0,0 +1,61 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Child-process fixture for the bounded startup policy-binding read. A regressed (unbounded) +// FIFO read would block forever, so the parent's spawn timeout is the hang detector; the child +// itself only reports bind outcomes. +const caseName = process.env.OCX_BOUNDED_READ_CASE!; +const root = mkdtempSync(join(tmpdir(), "ocx-bounded-auth-read-")); +const accountId = "bounded-read-account"; +const bearer = `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 86_400, + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, +})).toString("base64url")}.signature`; +const validAuth = JSON.stringify({ tokens: { + access_token: bearer, refresh_token: "bounded-read-refresh", account_id: accountId, +} }); + +const { initializeMainAccountPolicyBinding } = await import("../../src/codex/account-lifecycle"); +const { matchesMainQuotaCredential } = await import("../../src/codex/main-account-cache"); + +const validPath = join(root, "auth-valid.json"); +writeFileSync(validPath, validAuth); +const result: Record<string, unknown> = { case: caseName }; + +if (caseName === "valid") { + result.bound = initializeMainAccountPolicyBinding(validPath); + result.matched = matchesMainQuotaCredential(bearer, accountId); +} else if (caseName === "fifo-retained" || caseName === "fifo-hang-proof") { + const fifoPath = join(root, "auth-fifo"); + execFileSync("mkfifo", [fifoPath]); + if (caseName === "fifo-retained") result.firstBound = initializeMainAccountPolicyBinding(validPath); + const startedAt = Date.now(); + result.bound = initializeMainAccountPolicyBinding(fifoPath); + result.elapsedMs = Date.now() - startedAt; + if (caseName === "fifo-retained") result.retained = matchesMainQuotaCredential(bearer, accountId); +} else if (caseName === "symlink") { + // The link target is a fully valid auth file: following the link would bind, so a refused + // bind proves the no-follow read rather than a content failure. + const linkPath = join(root, "auth-link.json"); + symlinkSync(validPath, linkPath); + result.bound = initializeMainAccountPolicyBinding(linkPath); + result.matched = matchesMainQuotaCredential(bearer, accountId); +} else if (caseName === "oversize") { + const bigPath = join(root, "auth-big.json"); + // Valid JSON whose tokens would bind if read: only the size cap can keep this false. + writeFileSync(bigPath, JSON.stringify({ tokens: { + access_token: bearer, refresh_token: "bounded-read-refresh", account_id: accountId, + }, padding: "a".repeat(5 * 1024 * 1024) })); + result.bound = initializeMainAccountPolicyBinding(bigPath); +} else if (caseName === "directory") { + const dirPath = join(root, "auth-dir"); + mkdirSync(dirPath); + result.bound = initializeMainAccountPolicyBinding(dirPath); +} else if (caseName === "missing") { + result.bound = initializeMainAccountPolicyBinding(join(root, "auth-absent.json")); +} else { + throw new Error(`unknown bounded-read case: ${caseName}`); +} +console.log("BOUNDED_READ_RESULT=" + JSON.stringify(result)); diff --git a/tests/helpers/main-account-policy-startup-child.ts b/tests/helpers/main-account-policy-startup-child.ts new file mode 100644 index 0000000000..387a12cfcd --- /dev/null +++ b/tests/helpers/main-account-policy-startup-child.ts @@ -0,0 +1,292 @@ +import { spyOn } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +interface Fixture { + scenario: "owned-99" | "owned-98" | "foreign" | "unknown" | "recovery" | "second-listener" + | "invalid-access-token" | "invalid-account-id" | "invalid-id-token" | "mismatched-identity" | "renewed-listener" + | "stage-retry" | "manual-recovery" | "stale-sweep" | "retained-unknown-binding" + | "conflicting-token-identities" | "conflicting-claims" | "owned-opaque-99"; + accountId: string; + bearer: string; + originalAccountId: string; + originalBearer: string; +} + +const fixture: Fixture = JSON.parse(readFileSync(process.env.OCX_POLICY_STARTUP_FIXTURE!, "utf8")); +let upstreamCalls = 0; +const unexpectedNetwork: string[] = []; +// Install before product imports. Every response is synthetic; no endpoint can escape the fixture. +globalThis.fetch = Object.assign(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/responses")) { + upstreamCalls++; + return Response.json({ + id: "resp_policy_startup", object: "response", status: "completed", created_at: 1, + model: "gpt-5.6-sol", output: [], usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 }, + }); + } + unexpectedNetwork.push(`${url.hostname}${url.pathname}`); + throw new Error("Unexpected network request in startup policy fixture"); +}, { preconnect() {} }) as typeof fetch; + +const { setIcaclsRunnerForTests } = await import("../../src/lib/windows-secret-acl"); +setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); +const authCollision = await import("../../src/codex/auth-collision"); +const readTokens = authCollision.readCodexTokensResult; +const tokenReads: Array<string | undefined> = []; +const tokenSpy = spyOn(authCollision, "readCodexTokensResult").mockImplementation(authPath => { + tokenReads.push(authPath); + return readTokens(authPath); +}); +const { NativeProfileManager } = await import("../../src/codex/native-profile-manager"); +const { matchesMainQuotaCredential } = await import("../../src/codex/main-account-cache"); +const { getMainPolicyQuota } = await import("../../src/codex/quota"); +const { resolveCodexAuthContext } = await import("../../src/codex/auth-context"); +const { saveCodexAccountCredential } = await import("../../src/codex/account-store"); +const { blockNativeMainRecovery, completeNativeMainRecovery, nativeMainStartupGateSnapshot, waitForNativeMainStartupGate } = await import("../../src/codex/native-profile-startup"); +const { handleNativeProfileAPI } = await import("../../src/codex/native-profile-api"); +const { startServer } = await import("../../src/server"); +const { handleResponses } = await import("../../src/server/responses/core"); +const { loadConfig, saveConfig } = await import("../../src/config"); + +let config = loadConfig(); +const observe = () => ({ + matched: matchesMainQuotaCredential(fixture.bearer, fixture.accountId), + policy: getMainPolicyQuota(), + tokenReads: tokenReads.length, + gate: nativeMainStartupGateSnapshot(), +}); +const before = observe(); +function barrier() { + let enter!: () => void; + let release!: () => void; + const entered = new Promise<void>(resolve => { enter = resolve; }); + const released = new Promise<void>(resolve => { release = resolve; }); + return { entered, release: () => release(), async wait() { enter(); await released; } }; +} +async function waitForReady() { + const deadline = Date.now() + 15_000; + while (nativeMainStartupGateSnapshot().status !== "ready") { + if (Date.now() >= deadline) throw new Error("startup policy fixture did not become ready"); + await Bun.sleep(1); + } +} +const manager = new NativeProfileManager({ + codexHome: process.env.CODEX_HOME!, configDir: process.env.OPENCODEX_HOME!, + keyProvider: { + async get() { return { keyRef: "memory:policy-startup", key: Buffer.alloc(32, 7) }; }, + async create() { return { keyRef: "memory:policy-startup", key: Buffer.alloc(32, 7) }; }, + }, + hardenPath: async () => {}, processProbe: async () => ({ status: "clear", count: 0 }), +}); +let recovered = false; +let recoveryCalls = 0; +let sweepCalls = 0; +const oldSweep = barrier(); +const bindingSweep = barrier(); +const writeRecoveredAuth = () => writeFileSync(manager.context.authPath, JSON.stringify({ tokens: { + access_token: fixture.bearer, refresh_token: "fixture-refresh", account_id: fixture.accountId, +} })); +let enterRecovery!: () => void; +let releaseRecovery!: () => void; +const recoveryEntered = new Promise<void>(resolve => { enterRecovery = resolve; }); +const recoveryRelease = new Promise<void>(resolve => { releaseRecovery = resolve; }); +if (fixture.scenario === "recovery" || fixture.scenario === "manual-recovery") { + // The existing recovery seam changes the physical credential only when the held recovery runs. + manager.recover = async () => { + recoveryCalls++; + writeRecoveredAuth(); + recovered = true; + return { status: "none" } as Awaited<ReturnType<NativeProfileManager["recover"]>>; + }; + saveCodexAccountCredential("startup-pool", { + accessToken: "fixture-pool-access", refreshToken: "fixture-pool-refresh", + expiresAt: Date.now() + 86_400_000, chatgptAccountId: "fixture-pool-account", + }); +} +if (["stage-retry", "manual-recovery", "stale-sweep"].includes(fixture.scenario)) { + manager.stageSweepRequired = () => true; + manager.sweepStages = async () => { + const call = ++sweepCalls; + let plaintextMayRemain = false; + if (fixture.scenario === "stage-retry") { + if (call === 1) plaintextMayRemain = true; + if (call === 2) await oldSweep.wait(); + } else if (fixture.scenario === "manual-recovery") { + if (call === 1) await bindingSweep.wait(); + } else { + if (call === 2) { await oldSweep.wait(); plaintextMayRemain = true; } + if (call === 3) await bindingSweep.wait(); + } + return { plaintextMayRemain } as Awaited<ReturnType<NativeProfileManager["sweepStages"]>>; + }; +} + +const listeners: Array<ReturnType<typeof observe>> = []; +const realServe = Bun.serve; +Bun.serve = ((options: Parameters<typeof Bun.serve>[0]) => { + listeners.push(observe()); + return realServe(options); +}) as typeof Bun.serve; +const ownership = fixture.scenario === "foreign" || fixture.scenario === "unknown" ? fixture.scenario : "owned"; +const start = () => startServer(0, { + inspectNativeCodexOwnership: () => ({ ownership, reason: "synthetic policy-startup fixture" }), + nativeMainStartup: { + manager, + ...(["stage-retry", "stale-sweep"].includes(fixture.scenario) ? { stageSweepIntervalMs: 10 } : {}), + ...(fixture.scenario === "manual-recovery" ? { + probeRecoveryState: () => recovered ? "none" as const : "manual" as const, + } : {}), + ...(fixture.scenario === "recovery" ? { + probeRecoveryState: () => recovered ? "none" as const : "journal" as const, + beforeRecovery: async () => { enterRecovery(); await recoveryRelease; }, + } : {}), + }, +}); +const servers: Array<ReturnType<typeof start>> = []; +const headers = (token = fixture.bearer, id = fixture.accountId) => + new Headers({ authorization: `Bearer ${token}`, "chatgpt-account-id": id }); +const admit = async ( + mode: "direct" | "pool" = "direct", + options: Parameters<typeof resolveCodexAuthContext>[3] = {}, + policy = config, +) => { + try { const context = await resolveCodexAuthContext(headers(), policy, mode, options); return { admitted: true, kind: context.kind }; } + catch (error) { return { admitted: false, error: (error as Error).name }; } +}; +const wire = async (token = fixture.bearer, id = fixture.accountId) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { ...Object.fromEntries(headers(token, id)), "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-sol", input: "synthetic startup probe", stream: false }), + }), config, { model: "", provider: "" }); + const text = await response.text(); + return { status: response.status, hardLockError: text.includes("codexMainAccountHardLock") }; +}; + +try { + servers.push(start()); + let firstServerSettled: ReturnType<typeof observe> | undefined; + if (fixture.scenario === "second-listener" || fixture.scenario === "renewed-listener") { + await waitForNativeMainStartupGate(); + firstServerSettled = observe(); + if (fixture.scenario === "renewed-listener") { + writeFileSync(manager.context.authPath, JSON.stringify({ tokens: { + access_token: fixture.bearer, refresh_token: "fixture-refresh", account_id: fixture.accountId, + } })); + } + config = { ...config, codexMainAccountHardLock: true }; + saveConfig(config); + servers.push(start()); + } + const firstAdmission = await admit(); + let heldRecovery: Record<string, unknown> | undefined; + let laterRecovery: Record<string, unknown> | undefined; + let retainedUnknown: Array<Record<string, unknown>> | undefined; + let validReplacement: Record<string, unknown> | undefined; + const otherAccountId = "hard-lock-verified-other"; + const otherBearer = `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400, + "https://api.openai.com/auth": { chatgpt_account_id: otherAccountId } })).toString("base64url")}.signature`; + if (fixture.scenario === "recovery") { + await recoveryEntered; + heldRecovery = { + observation: observe(), + poolFallback: await admit("pool", { requestScopedMainCredential: true }), + mainPin: await admit("pool", { requestScopedMainCredential: true }, { ...config, activeCodexAccountPinned: "__main__" }), + storedAlternative: await admit("pool", { accountId: "startup-pool" }, { + ...config, codexAccounts: [{ id: "startup-pool", email: "pool@example.test", isMain: false }], + }), + automaticAlternative: await admit("pool", { requestScopedMainCredential: true }, { + ...config, codexAccounts: [{ id: "startup-pool", email: "pool@example.test", isMain: false }], + }), + }; + releaseRecovery(); + } + if (fixture.scenario === "stage-retry") { + await waitForNativeMainStartupGate(); + laterRecovery = { blocked: observe() }; + await oldSweep.entered; + oldSweep.release(); + await waitForReady(); + laterRecovery.sweepCalls = sweepCalls; + } + if (fixture.scenario === "manual-recovery") { + await waitForNativeMainStartupGate(); + laterRecovery = { blocked: observe() }; + const request = new Request("http://localhost/api/native-main-profiles/recover", { + method: "POST", headers: { "content-type": "application/json" }, body: "{}", + }); + const response = await handleNativeProfileAPI(request, new URL(request.url), config, { + manager, probeRecoveryState: () => recovered ? "none" : "manual", + }); + laterRecovery.apiStatus = response?.status; + await response?.text(); + laterRecovery.pending = observe(); + if (nativeMainStartupGateSnapshot().status === "blocked") { + await bindingSweep.entered; + const firstFlight = waitForNativeMainStartupGate(); + laterRecovery.duplicateCompleted = completeNativeMainRecovery(manager.context.homeId); + laterRecovery.joined = firstFlight === waitForNativeMainStartupGate(); + laterRecovery.recoveryCalls = recoveryCalls; + bindingSweep.release(); + } + } + if (fixture.scenario === "stale-sweep") { + await waitForNativeMainStartupGate(); + await oldSweep.entered; + writeRecoveredAuth(); + blockNativeMainRecovery(manager.context.homeId); + completeNativeMainRecovery(manager.context.homeId); + await bindingSweep.entered; + oldSweep.release(); + // Deliver the older sweep result while the new binding's explicit barrier is still held. + await Bun.sleep(0); + laterRecovery = { pending: observe(), admission: await admit() }; + bindingSweep.release(); + } + if (fixture.scenario === "retained-unknown-binding") { + await waitForNativeMainStartupGate(); + retainedUnknown = []; + for (const kind of ["malformed", "conflicting", "conflicting-tokens"] as const) { + writeFileSync(manager.context.authPath, kind === "malformed" ? "{" : JSON.stringify({ tokens: { + access_token: otherBearer, account_id: fixture.accountId, + ...(kind === "conflicting-tokens" ? { id_token: fixture.bearer } : {}), + } })); + servers.push(start()); + await waitForNativeMainStartupGate(); + retainedUnknown.push({ kind, observed: observe(), main: await wire(), + other: await wire(otherBearer, otherAccountId) }); + } + } + const settled = await waitForNativeMainStartupGate(); + const after = observe(); + const settledAdmission = await admit(); + const beforePrimaryUpstreamCalls = upstreamCalls; + const response = await wire(); + const primaryUpstreamCalls = upstreamCalls - beforePrimaryUpstreamCalls; + const originalResponse = ["recovery", "renewed-listener", "manual-recovery", "stale-sweep"].includes(fixture.scenario) + ? await wire(fixture.originalBearer, fixture.originalAccountId) : undefined; + if (fixture.scenario === "retained-unknown-binding") { + writeFileSync(manager.context.authPath, JSON.stringify({ tokens: { access_token: otherBearer, account_id: otherAccountId } })); + servers.push(start()); + await waitForNativeMainStartupGate(); + validReplacement = { oldMatched: matchesMainQuotaCredential(fixture.bearer, fixture.accountId), + newMatched: matchesMainQuotaCredential(otherBearer, otherAccountId), policy: getMainPolicyQuota(), old: await wire() }; + } + console.log("POLICY_STARTUP_RESULT=" + JSON.stringify({ + scenario: fixture.scenario, before, listeners, firstServerSettled, firstAdmission, heldRecovery, laterRecovery, + retainedUnknown, validReplacement, + settled, after, settledAdmission, response, beforePrimaryUpstreamCalls, primaryUpstreamCalls, originalResponse, + unexpectedNetwork, + policyReadsPinned: tokenReads.every(path => path === manager.context.authPath), + })); +} finally { + releaseRecovery(); + oldSweep.release(); + bindingSweep.release(); + Bun.serve = realServe; + for (const server of servers.reverse()) await server.stop(true); + tokenSpy.mockRestore(); + setIcaclsRunnerForTests(null); +} diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index 976e230b90..4b9d6423b7 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; @@ -7,11 +7,8 @@ import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; import type { ImageBridgePlan, ImageCallResult } from "../../src/images/types"; import type { ImageBridgeDeps } from "../../src/images/loop"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; -import { getDebugLogEntries, resetDebugLogBufferForTests } from "../../src/lib/debug-log-buffer"; -import { resetDebugSettingsForTests } from "../../src/lib/debug-settings"; const PREV_HOME = process.env.OPENCODEX_HOME; -const PREV_DEBUG = process.env.OCX_DEBUG; let runWithImageBridgeProduction: typeof import("../../src/images/loop")["runWithImageBridge"]; let clampImageMaxRounds: typeof import("../../src/images/loop")["clampImageMaxRounds"]; let DEFAULT_MAX_ROUNDS: typeof import("../../src/images/loop")["DEFAULT_MAX_ROUNDS"]; @@ -55,12 +52,6 @@ function runWithImageBridge( }); } afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; mock.restore(); }); -afterEach(() => { - resetDebugSettingsForTests(); - resetDebugLogBufferForTests(); - if (PREV_DEBUG === undefined) delete process.env.OCX_DEBUG; - else process.env.OCX_DEBUG = PREV_DEBUG; -}); // --- Mock adapter: yields canned events per iteration from a queue --- let streamQueue: AdapterEvent[][] = []; @@ -113,24 +104,36 @@ async function runAndGetSSE(streams: AdapterEvent[][], fulfill?: ImageCallResult } describe("runWithImageBridge", () => { - test("routed image streams carry adapter and bridge diagnostics", async () => { - process.env.OCX_DEBUG = "1"; - const error = spyOn(console, "error").mockImplementation(() => {}); + test.each([307, 308])("the direct image-loop send does not follow %i", async status => { + let targetHits = 0; + let originHits = 0; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return new Response("{}"); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); try { - streamQueue = [[{ type: "text_delta", text: "image diagnostic secret" }, { type: "done" }]]; const response = await runWithImageBridge({ - parsed: makeParsed(), - adapter: mockAdapter, - plan, - diagnostic: { requestId: "image-diagnostic", adapterName: "test" }, + parsed: makeParsed(), plan, + adapter: { + ...mockAdapter, + fetchResponse: undefined, + buildRequest: async () => ({ url: `http://127.0.0.1:${origin.port}/model`, method: "POST", headers: { "x-api-key": "synthetic-key" }, body: "synthetic prompt" }), + }, }); - await response.text(); - const lines = getDebugLogEntries().map(entry => entry.line); - expect(lines.some(line => line.includes('"stage":"adapter"') && line.includes('"eventType":"text_delta"'))).toBe(true); - expect(lines.some(line => line.includes('"stage":"bridge"') && line.includes('"eventType":"text_delta"'))).toBe(true); - expect(lines.every(line => !line.includes("image diagnostic secret"))).toBe(true); + const error = await response.json() as { error: { type: string; message: string } }; + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(response.status).toBe(status); + expect(error.error.type).toBe("upstream_error"); + expect(error.error.message).toBe(`Provider error ${status}`); + expect(response.headers.get("location")).toBeNull(); } finally { - error.mockRestore(); + await origin.stop(true); + await target.stop(true); } }); diff --git a/tests/lib/abort-idle-deadline.test.ts b/tests/lib/abort-idle-deadline.test.ts index 9c8422ba53..d72f09488e 100644 --- a/tests/lib/abort-idle-deadline.test.ts +++ b/tests/lib/abort-idle-deadline.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { idleDeadline } from "../../src/lib/abort"; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); @@ -17,18 +17,59 @@ test("idleDeadline fires once after the idle window with no reset", async () => expect(fired).toBe(1); }); -test("idleDeadline reset() re-arms and postpones firing", async () => { +test("idleDeadline reset() re-arms and postpones firing", () => { + // Keep this boundary check synchronous: real sleeps can resume after the idle window. + // The other cases below still exercise Bun's real timers. + type TimerHandle = ReturnType<typeof setTimeout>; + let now = 0; + let nextHandle = 0; + const timers = new Map<TimerHandle, { at: number; fire: () => void }>(); + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, delay = 0, ...args: unknown[] + ) => { + const handle = ++nextHandle as unknown as TimerHandle; + timers.set(handle, { at: now + delay, fire: () => callback(...args) }); + return handle; + }) as typeof setTimeout); + const clearSpy = spyOn(globalThis, "clearTimeout").mockImplementation(handle => { + timers.delete(handle as TimerHandle); + }); + const advanceBy = (ms: number) => { + const target = now + ms; + for (;;) { + const due = [...timers].filter(([, timer]) => timer.at <= target) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) break; + timers.delete(due[0]); + now = due[1].at; + due[1].fire(); + } + now = target; + }; let fired = 0; - const idle = idleDeadline(120, () => { fired += 1; }); - idle.reset(); - for (let i = 0; i < 4; i++) { - await sleep(40); - idle.reset(); // keep-alive: total elapsed (160ms) exceeds 120ms but silence never does + let idle: ReturnType<typeof idleDeadline> | undefined; + try { + idle = idleDeadline(120, () => { fired += 1; }); + idle.reset(); + for (let i = 0; i < 4; i++) { + advanceBy(40); + idle.reset(); // total elapsed exceeds 120 ms, but each silent interval does not + } + expect(fired).toBe(0); + advanceBy(119); + expect(fired).toBe(0); + advanceBy(1); + expect(fired).toBe(1); + advanceBy(240); + expect(fired).toBe(1); + } finally { + try { + idle?.cancel(); + } finally { + clearSpy.mockRestore(); + timeoutSpy.mockRestore(); + } } - expect(fired).toBe(0); - await sleep(220); - expect(fired).toBe(1); - idle.cancel(); }); test("idleDeadline pause() disarms without retiring; reset() re-arms after pause", async () => { diff --git a/tests/lib/credential-redirect-guard.test.ts b/tests/lib/credential-redirect-guard.test.ts index 81213abd1e..6a4ff7534b 100644 --- a/tests/lib/credential-redirect-guard.test.ts +++ b/tests/lib/credential-redirect-guard.test.ts @@ -1,58 +1,97 @@ /** * Cross-origin redirect guard for credential-bearing sidecars (#1471 review). * - * Bun follows 3xx by default. It drops `Authorization` when the redirect crosses origins, but - * it forwards NONSTANDARD headers unchanged — which is exactly where the Codex identity lives: - * `chatgpt-account-id`, `session_id`, `x-codex-turn-metadata`. So a canonical ChatGPT endpoint - * answering 302 would hand those to the redirect target while `Authorization` looked safely - * stripped. The first test proves that runtime behavior rather than asserting it from memory; - * the second pins the fix at every credential-bearing call site. + * Exercise production transports against two loopback origins. Safety is a property of the + * application send boundary, independent of which headers a particular runtime happens to + * strip when following redirects. Existing explicit sidecar guards remain checked below. */ import { describe, expect, test } from "bun:test"; import { repoPath } from "../helpers/repo-root"; +import { fetchWithHeaderTimeout, providerFetch } from "../../src/server/responses/fetch-helpers"; +import { fetchWithAttemptDeadline } from "../../src/lib/upstream-retry"; +import { fetchWithHeaderDeadline } from "../../src/server/claude-messages"; +import { fetchGoogleWithRetry } from "../../src/adapters/google-http"; +import { fetchKiroWithRetry } from "../../src/adapters/kiro-retry"; +import type { OcxProviderConfig } from "../../src/types"; -describe("Bun forwards nonstandard headers across a redirect", () => { - test("Authorization is dropped but Codex identity headers are not", async () => { - const captured: Record<string, string | null> = {}; +describe("credential-bearing production transports do not follow redirects", () => { + const nativeFetch = globalThis.fetch; + const senders = ["header", "header-legacy-false", "deadline", "provider", "provider-rebuilt", "claude", "google", "kiro"] as const; + for (const sender of senders) for (const sameOrigin of [false, true]) test.each([301, 302, 303, 307, 308])(`${sender} ${sameOrigin ? "same" : "cross"}-origin: preserves %i without a target send`, async status => { + let targetHits = 0; + let originHits = 0; + const observedRedirect: Array<RequestRedirect | undefined> = []; const target = Bun.serve({ - port: 0, - fetch(req) { - captured.authorization = req.headers.get("authorization"); - captured.account = req.headers.get("chatgpt-account-id"); - captured.session = req.headers.get("session_id"); - captured.turn = req.headers.get("x-codex-turn-metadata"); + hostname: "127.0.0.1", port: 0, + fetch() { + targetHits += 1; return new Response("ok"); }, }); const origin = Bun.serve({ - port: 0, - fetch: () => new Response(null, { - status: 302, - headers: { location: `http://127.0.0.1:${target.port}/landed` }, - }), + hostname: "127.0.0.1", port: 0, + fetch: req => { + if (new URL(req.url).pathname === "/landed") { + targetHits += 1; + return new Response("ok"); + } + originHits += 1; + return new Response("untrusted redirect body", { + status, + headers: { location: sameOrigin ? "/landed" : `http://127.0.0.1:${target.port}/landed` }, + }); + }, }); - + let response: Response | undefined; try { - await fetch(`http://127.0.0.1:${origin.port}/start`, { + const url = `http://127.0.0.1:${origin.port}/start`; + const init: RequestInit = { + method: "POST", body: "synthetic request", redirect: "follow", headers: { - authorization: "Bearer secret-token", + authorization: "Bearer synthetic-token", + "x-api-key": "synthetic-provider-key", "chatgpt-account-id": "acct-123", - session_id: "sess-456", - "x-codex-turn-metadata": "turn-789", }, - }); + }; + const executor = (async (input, sentInit) => { + observedRedirect.push(sentInit?.redirect); + return nativeFetch(input, sentInit); + }) as typeof globalThis.fetch; + const signal = new AbortController().signal; + if (sender === "header") response = await fetchWithHeaderTimeout(url, init, signal, 2_000, false, executor); + else if (sender === "header-legacy-false") response = await fetchWithHeaderTimeout(url, init, signal, 2_000, false, executor, false); + else if (sender === "deadline") response = await fetchWithAttemptDeadline(url, init, 2_000, signal, false, executor); + else if (sender === "claude") { + const result = await fetchWithHeaderDeadline(url, init, 2_000, signal, undefined, executor); + expect(result.kind).toBe("response"); + if (result.kind === "response") response = result.upstream; + } else if (sender === "google" || sender === "kiro") { + const request = { url, method: "POST", headers: init.headers as Record<string, string>, body: init.body as string }; + const context = { abortSignal: signal, timeoutMs: 2_000, returnRawErrors: true, executor }; + if (sender === "google") response = await fetchGoogleWithRetry("test", request, context); + else { + globalThis.fetch = executor; + response = await fetchKiroWithRetry(request, context); + } + } + else { + const provider = { adapter: "openai-chat", baseUrl: url, fetch: executor } as OcxProviderConfig & { fetch: typeof globalThis.fetch }; + const fetcher = providerFetch(provider, undefined, sender === "provider-rebuilt" ? { + dispatchOverride: (input, sentInit, execute) => execute(input, { ...sentInit, redirect: "follow" }), + } : {}); + response = await fetcher(url, init); + } + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(observedRedirect).toEqual(["manual"]); + expect(response?.status).toBe(status); + expect(response?.headers.get("location")).toBe(sameOrigin ? "/landed" : `http://127.0.0.1:${target.port}/landed`); } finally { - origin.stop(true); - target.stop(true); + globalThis.fetch = nativeFetch; + await response?.body?.cancel(); + await origin.stop(true); + await target.stop(true); } - - // The half that looks safe... - expect(captured.authorization).toBeNull(); - // ...and the half that is not. This is why `redirect: "manual"` is required and why - // relying on Authorization stripping alone would be a false sense of safety. - expect(captured.account).toBe("acct-123"); - expect(captured.session).toBe("sess-456"); - expect(captured.turn).toBe("turn-789"); }); }); @@ -73,16 +112,5 @@ describe("credential-bearing sidecars refuse to follow redirects", () => { }); } - // The Responses and compact paths reach the same policy through a different mechanism: - // `fetchWithHeaderTimeout` takes a `manualRedirect` flag and applies `redirect: "manual"` - // centrally (#914). Assert the shared helper still does that, so the two families cannot - // drift apart silently. - test("the shared credential-bearing fetch helper still applies manual redirects", async () => { - const helper = await Bun.file(new URL("../../src/server/responses/fetch-helpers.ts", import.meta.url)).text(); - expect(helper).toContain('redirect: "manual" as const'); - - // And the callers still opt in for forward auth rather than dropping the flag. - const compact = await Bun.file(new URL("../../src/server/responses/compact.ts", import.meta.url)).text(); - expect(compact).toContain('sendProvider.authMode === "forward"'); - }); + // These source checks supplement, rather than replace, the physical-send tests above. }); diff --git a/tests/lib/process-control-graceful.test.ts b/tests/lib/process-control-graceful.test.ts index 690232b2a9..9fad73d38b 100644 --- a/tests/lib/process-control-graceful.test.ts +++ b/tests/lib/process-control-graceful.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { gracefulStopHost, stopProxyGracefully } from "../../src/lib/process-control"; +import { gracefulStopHost, lastStopRefusalMessage, stopProxyGracefully } from "../../src/lib/process-control"; function okResponse(): Response { - return new Response(JSON.stringify({ success: true }), { status: 200 }); + return new Response(JSON.stringify({ success: true, sharedTeardown: "performed" }), { status: 200 }); } describe("gracefulStopHost", () => { @@ -22,18 +22,61 @@ describe("gracefulStopHost", () => { }); describe("stopProxyGracefully", () => { - test("prefers the canonical HTTPS runtime origin", async () => { - const calls: string[] = []; - await stopProxyGracefully(9, { - readRuntime: () => ({ port: 10443, hostname: "0.0.0.0", origin: "https://proxy.example.com" }), - fetchFn: (async (url: string | URL | Request) => { - calls.push(String(url)); - return okResponse(); - }) as typeof fetch, - waitExit: () => true, - env: {}, + for (const [name, body] of [ + ["reported restore failure", JSON.stringify({ success: false, sharedTeardown: "performed" })], + ["missing teardown result", JSON.stringify({ success: true })], + ["unexpected deferral", JSON.stringify({ success: true, sharedTeardown: "deferred" })], + ["nonboolean success", JSON.stringify({ success: "true", sharedTeardown: "performed" })], + ["empty body", ""], + ["invalid JSON", "{broken"], + ["null body", "null"], + ["array body", "[]"], + ]) { + test(`process exit does not confirm shared teardown: ${name}`, async () => { + const waits: number[] = []; + const result = await stopProxyGracefully(4242, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response(body, { status: 200 })) as typeof fetch, + waitExit: pid => { waits.push(pid); return true; }, + exitTimeoutMs: 1, + env: {}, + }); + expect(result).toBe("teardown-unconfirmed"); + expect(waits).toEqual([4242]); }); - expect(calls).toEqual(["https://proxy.example.com/api/stop"]); + } + + test("requires the assigned deferred response when a receipt nonce was sent", async () => { + for (const sharedTeardown of ["deferred", "performed"]) { + const result = await stopProxyGracefully(4242, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response(JSON.stringify({ success: true, sharedTeardown }))) as typeof fetch, + waitExit: () => true, + deferSharedTeardownNonce: "receipt-nonce", + exitTimeoutMs: 1, + env: {}, + }); + expect(result).toBe(sharedTeardown === "deferred" ? true : "teardown-unconfirmed"); + } + }); + + test("an unconfirmed response still requires process exit", async () => { + expect(await stopProxyGracefully(4242, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response(JSON.stringify({ success: false, sharedTeardown: "performed" }))) as typeof fetch, + waitExit: () => false, + exitTimeoutMs: 1, + env: {}, + })).toBe(false); + }); + + test("ownership refusal never waits for exit or becomes a teardown retry", async () => { + expect(await stopProxyGracefully(4242, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response("refused", { status: 409 })) as typeof fetch, + waitExit: () => { throw new Error("must not wait for a refused stop"); }, + env: {}, + })).toBe("refused"); }); test("follows the recorded bind hostname when it names a concrete address", async () => { @@ -121,3 +164,38 @@ describe("stopProxyGracefully", () => { expect(noExit).toBe(false); }); }); + +describe("409 refusal reporting", () => { + test("a refusal carries the server's own reason, not the ownership guess", async () => { + // /api/stop answers 409 for more than one reason: a scheduler wrapper under another + // home, and (since #4023) the proxy being the installed launchd/systemd job itself. + // stopProxy used to report the first of those unconditionally, sending an operator + // whose proxy is simply the service to a CODEX_HOME that does not exist. + const selfUnload = "This proxy is running as the installed service, so stopping the manager" + + " from inside it would end this process before native Codex is restored." + + " Run `ocx stop`, which stops the service from outside and completes the restore." + + " Nothing was changed."; + const result = await stopProxyGracefully(7, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response( + JSON.stringify({ success: false, code: "self_unload_service", message: selfUnload }), + { status: 409, headers: { "content-type": "application/json" } }, + )) as typeof fetch, + waitExit: () => true, + env: {}, + }); + expect(result).toBe("refused"); + expect(lastStopRefusalMessage()).toBe(selfUnload); + }); + + test("a 409 with no readable body falls back rather than reporting a stale reason", async () => { + const result = await stopProxyGracefully(7, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response("not json", { status: 409 })) as typeof fetch, + waitExit: () => true, + env: {}, + }); + expect(result).toBe("refused"); + expect(lastStopRefusalMessage()).toBeNull(); + }); +}); diff --git a/tests/oauth/chatgpt-oauth.test.ts b/tests/oauth/chatgpt-oauth.test.ts index 24213b7e44..29f35f808d 100644 --- a/tests/oauth/chatgpt-oauth.test.ts +++ b/tests/oauth/chatgpt-oauth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { decodeJwtPayload, extractAccountId, extractEmail } from "../../src/oauth/chatgpt"; +import { decodeJwtPayload, extractAccountId, extractAccountIdClaims, extractEmail } from "../../src/oauth/chatgpt"; function fakeJwt(payload: Record<string, unknown>): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); @@ -63,6 +63,39 @@ describe("ChatGPT OAuth JWT helpers", () => { expect(extractAccountId(undefined, undefined)).toBeUndefined(); }); + test("extractAccountIdClaims accepts agreeing account-id encodings", () => { + const jwt = fakeJwt({ + chatgpt_account_id: "acct_same", + "https://api.openai.com/auth": { chatgpt_account_id: "acct_same" }, + }); + expect(extractAccountIdClaims(jwt)).toEqual({ accountId: "acct_same", conflict: false }); + }); + + test("extractAccountIdClaims flags conflicting account-id encodings", () => { + const jwt = fakeJwt({ + chatgpt_account_id: "acct_top", + "https://api.openai.com/auth": { chatgpt_account_id: "acct_ns_other" }, + }); + expect(extractAccountIdClaims(jwt)).toEqual({ accountId: "acct_top", conflict: true }); + }); + + test("extractAccountIdClaims treats organizations as membership, never as a conflict", () => { + // id_token_add_organizations makes org ids legitimately differ from the account id. + const jwt = fakeJwt({ + chatgpt_account_id: "acct_main", + organizations: [{ id: "org_member" }, { id: "org_other" }], + }); + expect(extractAccountIdClaims(jwt)).toEqual({ accountId: "acct_main", conflict: false }); + const orgOnly = fakeJwt({ organizations: [{ id: "org_fallback" }, { id: "org_second" }] }); + expect(extractAccountIdClaims(orgOnly)).toEqual({ accountId: "org_fallback", conflict: false }); + }); + + test("extractAccountIdClaims reads nothing from a claim-free or malformed token", () => { + expect(extractAccountIdClaims(fakeJwt({ sub: "user" }))).toEqual({ accountId: undefined, conflict: false }); + expect(extractAccountIdClaims(undefined)).toEqual({ accountId: undefined, conflict: false }); + expect(extractAccountIdClaims("not-a-jwt")).toEqual({ accountId: undefined, conflict: false }); + }); + test("extractEmail extracts and lowercases email", () => { const jwt = fakeJwt({ email: "User@Example.COM" }); expect(extractEmail(jwt)).toBe("user@example.com"); diff --git a/tests/oauth/oauth-accounts-api.test.ts b/tests/oauth/oauth-accounts-api.test.ts index 75e88d7dbb..c3c6e0c632 100644 --- a/tests/oauth/oauth-accounts-api.test.ts +++ b/tests/oauth/oauth-accounts-api.test.ts @@ -156,7 +156,9 @@ describe("multiauth accounts API", () => { expect(requireManagementAuth(ctx.req, state, ctx.config)).toBeNull(); // Deliberately memoized. const pending = reader.read(); publishAccountSelection("private-provider", "oauth"); - await expect(pending).rejects.toMatchObject({ name: "NotAllowedError" }); + // Nothing was queued before revocation, so the stream closes quietly instead of + // erroring; the pending read resolves done and the post-revocation frame is never sent. + await expect(pending).resolves.toMatchObject({ done: true }); } finally { await reader.cancel().catch(() => undefined); } }); @@ -179,7 +181,31 @@ describe("multiauth accounts API", () => { session.expiresAt = Date.now() - 1; const pending = reader.read(); tick(); - await expect(pending).rejects.toMatchObject({ name: "NotAllowedError" }); + await expect(pending).resolves.toMatchObject({ done: true }); + } finally { + await reader?.cancel().catch(() => undefined); + interval.mockRestore(); + } + }); + + test("selection stream discards frames queued before revocation instead of draining them", async () => { + const { ctx, state, token } = selectionSessionFixture(); + const interval = spyOn(globalThis, "setInterval"); + let reader: ReadableStreamDefaultReader<Uint8Array> | undefined; + try { + const response = await handleOauthAccountRoutes(ctx); + expect(response?.status).toBe(200); + reader = response!.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("event: ready"); + // No pending read: this event stays queued in the controller when the session expires. + publishAccountSelection("queued-provider", "oauth"); + state.sessions.get(token)!.expiresAt = Date.now() - 1; + const tick = interval.mock.calls.find(call => call[1] === 15_000)?.[0]; + if (typeof tick !== "function") throw new Error("selection heartbeat not registered"); + tick(); + // A non-empty queue still takes the error path: the queued frame is discarded and the + // revoked consumer rejects instead of ever draining it. + await expect(reader.read()).rejects.toMatchObject({ name: "NotAllowedError" }); } finally { await reader?.cancel().catch(() => undefined); interval.mockRestore(); diff --git a/tests/oauth/state-store-sweeper.test.ts b/tests/oauth/state-store-sweeper.test.ts index 36ede716d4..b162e2214b 100644 --- a/tests/oauth/state-store-sweeper.test.ts +++ b/tests/oauth/state-store-sweeper.test.ts @@ -22,7 +22,8 @@ import { setOcxStartProcessProbeForTests, sweepDeadOcxStartProcessCache, } from "../../src/config"; -import { STATE_STORE_REGISTRATIONS } from "../../src/lib/state-store-registrations"; +import { STATE_STORE_REGISTRATIONS, setLiveStateStoreConfig, reconcileLiveStateStores } from "../../src/lib/state-store-registrations"; +import { clearComboRecallForTests, recallComboForLane, rememberComboForLane } from "../../src/server/responses/combo-session-recall"; import { getAccountSet, saveCredential } from "../../src/oauth/store"; import { clearAccountQuotaCache, @@ -78,12 +79,14 @@ beforeEach(() => { sweeperHome = mkdtempSync(join(tmpdir(), "ocx-sweeper-home-")); process.env.OPENCODEX_HOME = sweeperHome; resetStateStoreSweeperForTests(); + clearComboRecallForTests(); resetAppOwnedMemoryForTests(); clearResponseStateMemoryForTests(); __resetAntigravityReplayCache(); }); afterEach(() => { resetStateStoreSweeperForTests(); + clearComboRecallForTests(); resetAppOwnedMemoryForTests(); clearResponseStateMemoryForTests(); __resetAntigravityReplayCache(); @@ -147,6 +150,7 @@ describe("state-store sweeper", () => { "model-cache-history", "pool-rotation", "combo-rotation", + "combo-session-recall", "guardian-backoff", "codex-reauth", "oauth-reauth", @@ -157,6 +161,59 @@ describe("state-store sweeper", () => { ]); }); + test("registered combo recall cleanup rejects an old completion after delete and recreate while retaining another owner", () => { + registerStateStore(STATE_STORE_REGISTRATIONS.find(row => row.name === "combo-session-recall")!); + const config: OcxConfig = { + port: 0, defaultProvider: "a", + providers: { a: { adapter: "openai-chat", baseUrl: "https://a.example/v1" } }, + combos: { + first: { targets: [{ provider: "a", model: "m1" }] }, + other: { targets: [{ provider: "a", model: "m2" }] }, + }, + }; + setLiveStateStoreConfig(config); + const staleGeneration = captureConfigGeneration(); + rememberComboForLane("first-lane", "first", { provider: "a", model: "m1" }, "visible-first", staleGeneration); + rememberComboForLane("other-lane", "other", { provider: "a", model: "m2" }, "visible-other", staleGeneration); + delete config.combos!.first; + expect(reconcileLiveStateStores()).toEqual({ storesVisited: 1, rowsRemoved: 1 }); + config.combos!.first = { targets: [{ provider: "a", model: "m1" }] }; + expect(reconcileLiveStateStores()).toEqual({ storesVisited: 1, rowsRemoved: 0 }); + rememberComboForLane("first-lane", "first", { provider: "a", model: "m1" }, "visible-first", staleGeneration); + expect(recallComboForLane(config, "first-lane", "visible-first")).toBeUndefined(); + expect(recallComboForLane(config, "other-lane", "visible-other")).toBe("other"); + rememberComboForLane("first-lane", "first", { provider: "a", model: "m1" }, "visible-new", captureConfigGeneration()); + expect(recallComboForLane(config, "first-lane", "visible-new")).toBe("first"); + delete config.providers.a; + expect(reconcileLiveStateStores()).toEqual({ storesVisited: 1, rowsRemoved: 2 }); + }); + + test("combo recall watermark rejects writers after a partially failed generation", () => { + registerStateStore(STATE_STORE_REGISTRATIONS.find(row => row.name === "combo-session-recall")!); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const unregisterFailure = registerStateStore({ name: "failed-owner", reconcileGeneration: () => { throw new Error("retry"); } }); + const owners = context(0, { + comboIds: new Set(["first"]), comboTargets: new Set(["first::a/m1"]), providerNames: new Set(["a"]), + }); + const config: OcxConfig = { + port: 0, defaultProvider: "a", providers: { a: { adapter: "openai-chat", baseUrl: "https://a.example/v1" } }, + combos: { first: { targets: [{ provider: "a", model: "m1" }] } }, + }; + try { + reconcileStateGeneration(owners); + expect(captureConfigGeneration()).toBe(0); + rememberComboForLane("lane", "first", { provider: "a", model: "m1" }, "m1", 0); + expect(recallComboForLane(config, "lane", "m1")).toBeUndefined(); + unregisterFailure(); + reconcileStateGeneration(owners); + rememberComboForLane("lane", "first", { provider: "a", model: "m1" }, "m1", captureConfigGeneration()); + expect(recallComboForLane(config, "lane", "m1")).toBe("first"); + } finally { + unregisterFailure(); + warning.mockRestore(); + } + }); + test("a sweeper tick expires continuation and Antigravity rows without store traffic", () => { rememberResponseState({ input: "old" }, { id: "resp_sweeper_ttl", output: [], status: "completed" }); observeAntigravityReplay("gemini-3-pro", "session-old", [{ diff --git a/tests/providers/api-key-selection-capture.test.ts b/tests/providers/api-key-selection-capture.test.ts new file mode 100644 index 0000000000..bd03c9f1e1 --- /dev/null +++ b/tests/providers/api-key-selection-capture.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { captureProviderApiKeySelection } from "../../src/providers/api-key-selection-capture"; +import { captureProviderApiKeySelection as legacyCapture } from "../../src/providers/api-key-selection"; +import type { OcxProviderConfig } from "../../src/types"; +import { repoPath } from "../helpers/repo-root"; + +describe("API-key selection snapshot", () => { + const base: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://example.test/v1" }; + + test("captures the selected entry and revision without resolving its reference", () => { + const provider: OcxProviderConfig = { + ...base, + apiKey: "${OCX_CAPTURE_FIXTURE}", + apiKeySelectionRevision: "revision-before", + apiKeyPool: [ + { id: "other", key: "keychain:other" }, + { id: "selected", key: "${OCX_CAPTURE_FIXTURE}" }, + ], + }; + const before = structuredClone(provider); + const snapshot = captureProviderApiKeySelection(provider); + expect(snapshot).toEqual({ entryId: "selected", reference: "${OCX_CAPTURE_FIXTURE}", revision: "revision-before" }); + expect(provider).toEqual(before); + provider.apiKey = "keychain:other"; + provider.apiKeySelectionRevision = "revision-after"; + provider.apiKeyPool![1]!.id = "changed"; + expect(snapshot).toEqual({ entryId: "selected", reference: "${OCX_CAPTURE_FIXTURE}", revision: "revision-before" }); + }); + + test("retains an unmatched reference and absent optional fields", () => { + expect(captureProviderApiKeySelection(base)).toEqual({ entryId: undefined, reference: undefined, revision: undefined }); + expect(captureProviderApiKeySelection({ ...base, apiKey: "keychain:unpooled", apiKeyPool: [] })).toEqual({ + entryId: undefined, reference: "keychain:unpooled", revision: undefined, + }); + }); + + test("preserves first-match semantics when a pool repeats the same reference", () => { + expect(captureProviderApiKeySelection({ + ...base, + apiKey: "keychain:shared", + apiKeyPool: [{ id: "first", key: "keychain:shared" }, { id: "second", key: "keychain:shared" }], + })).toEqual({ entryId: "first", reference: "keychain:shared", revision: undefined }); + }); + + test("preserves the existing export", () => { + expect(legacyCapture).toBe(captureProviderApiKeySelection); + }); +}); + +describe("selection capture dependency boundary", () => { + const transpiler = new Bun.Transpiler({ loader: "ts" }); + const runtimeImports = (source: string) => transpiler.scanImports(transpiler.transformSync(source)).map(entry => entry.path); + + test("the leaf has no runtime imports", () => { + expect(runtimeImports(readFileSync(repoPath("src/providers/api-key-selection-capture.ts"), "utf8"))).toEqual([]); + }); + + test("the router consumes capture without a direct import of the stateful selection module", () => { + const imports = runtimeImports(readFileSync(repoPath("src/router.ts"), "utf8")); + expect(imports).toContain("./providers/api-key-selection-capture"); + expect(imports).not.toContain("./providers/api-key-selection"); + }); + + test("the boundary scanner distinguishes erased types from a runtime dependency", () => { + expect(runtimeImports('import type { T } from "../router"; export const value = 1;')).toEqual([]); + expect(runtimeImports('import "../router"; export const value = 1;')).toEqual(["../router"]); + }); +}); diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts new file mode 100644 index 0000000000..0da898ea82 --- /dev/null +++ b/tests/providers/codebuddy-adapter.test.ts @@ -0,0 +1,381 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { buildArgs, buildChildEnv, createCodeBuddyAdapter, type SpawnFn } from "../../src/adapters/codebuddy/adapter"; +import { CODEBUDDY_CN_PROFILE, CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../../src/adapters/codebuddy/profiles"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const enc = new TextEncoder(); + +// The binary-discovery cache is module-level (a production perf seam); reset it so a test that +// reports a missing CLI cannot mask a later test's injected binary. +beforeEach(() => clearCodeBuddyBinaryCache()); + +interface FakeChild extends EventEmitter { + stdout: Readable; + stderr: Readable; + stdin: Writable; + killed: boolean; + exitCode: number | null; + kill: (signal?: string) => boolean; + written: string[]; +} + +function fakeChild(stdout: Uint8Array[], opts: { stderr?: string; exitCode?: number; emitClose?: boolean } = {}): FakeChild { + const child = new EventEmitter() as FakeChild; + child.stdout = Readable.from(stdout); + child.stderr = Readable.from(opts.stderr ? [enc.encode(opts.stderr)] : []); + child.written = []; + child.stdin = new Writable({ write(chunk, _enc, cb) { child.written.push(String(chunk)); cb(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + if (opts.emitClose !== false) { + setTimeout(() => { child.exitCode = opts.exitCode ?? 0; child.emit("close", opts.exitCode ?? 0); }, 3); + } + return child; +} + +function provider(overrides: Partial<OcxProviderConfig> = {}): OcxProviderConfig { + return { + adapter: "codebuddy", + baseUrl: CODEBUDDY_GLOBAL_PROFILE.canonicalBaseUrl, + apiKey: "cb-global-key", + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + ...overrides, + } as OcxProviderConfig; +} + +function parsed(overrides: Partial<OcxParsedRequest> = {}): OcxParsedRequest { + return { + modelId: "glm-5.3", + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + ...overrides, + } as OcxParsedRequest; +} + +function incoming(abortSignal?: AbortSignal) { + return { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), ...(abortSignal ? { abortSignal } : {}) }; +} + +async function run(adapter: ReturnType<typeof createCodeBuddyAdapter>, p: OcxParsedRequest, inc = incoming()): Promise<AdapterEvent[]> { + const events: AdapterEvent[] = []; + await adapter.runTurn!(p, inc, e => events.push(e)); + return events; +} + +describe("codebuddy child environment is region-scoped and never global", () => { + test("global profile sets public environment and the global key only", () => { + const env = buildChildEnv(CODEBUDDY_GLOBAL_PROFILE, "cb-global-key"); + expect(env.CODEBUDDY_INTERNET_ENVIRONMENT).toBe("public"); + expect(env.CODEBUDDY_API_KEY).toBe("cb-global-key"); + expect(env.CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS).toBe("1"); + }); + + test("CN profile sets internal environment and the CN key only", () => { + const env = buildChildEnv(CODEBUDDY_CN_PROFILE, "cb-cn-key"); + expect(env.CODEBUDDY_INTERNET_ENVIRONMENT).toBe("internal"); + expect(env.CODEBUDDY_API_KEY).toBe("cb-cn-key"); + }); + + test("a stray parent CODEBUDDY_INTERNET_ENVIRONMENT cannot flip the region", () => { + const previous = process.env.CODEBUDDY_INTERNET_ENVIRONMENT; + process.env.CODEBUDDY_INTERNET_ENVIRONMENT = "internal"; + try { + const env = buildChildEnv(CODEBUDDY_GLOBAL_PROFILE, "k"); + expect(env.CODEBUDDY_INTERNET_ENVIRONMENT).toBe("public"); + // The parent CODEBUDDY_* is never inherited: only the profile-set keys are present. + expect(Object.keys(env).filter(k => k.startsWith("CODEBUDDY_")).sort()).toEqual([ + "CODEBUDDY_API_KEY", "CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS", "CODEBUDDY_INTERNET_ENVIRONMENT", + ]); + } finally { + if (previous === undefined) delete process.env.CODEBUDDY_INTERNET_ENVIRONMENT; + else process.env.CODEBUDDY_INTERNET_ENVIRONMENT = previous; + } + }); +}); + +describe("codebuddy headless arguments keep tool ownership with Codex", () => { + test("disables all CLI tools and never requests permission bypass", () => { + const args = buildArgs(CODEBUDDY_GLOBAL_PROFILE, parsed(), provider()); + const toolsIndex = args.indexOf("--tools"); + expect(toolsIndex).toBeGreaterThanOrEqual(0); + expect(args[toolsIndex + 1]).toBe(""); // "" = disable all built-in tools + expect(args).toContain("--strict-mcp-config"); // no MCP tools either + expect(args).not.toContain("-y"); + expect(args).not.toContain("--dangerously-skip-permissions"); + expect(args).toContain("--output-format"); + expect(args[args.indexOf("--output-format") + 1]).toBe("stream-json"); + expect(args[args.indexOf("--model") + 1]).toBe("glm-5.3"); + }); + + test("maps Codex reasoning effort onto --effort and folds the system prompt", () => { + const args = buildArgs( + CODEBUDDY_GLOBAL_PROFILE, + parsed({ options: { reasoning: "high" }, context: { systemPrompt: ["Be terse."], messages: [] } }), + provider(), + ); + expect(args[args.indexOf("--effort") + 1]).toBe("high"); + expect(args[args.indexOf("--append-system-prompt") + 1]).toBe("Be terse."); + }); +}); + +describe("codebuddy runTurn fails closed before any spawn", () => { + test("a non-canonical base URL is refused and the credential is never placed in a child env", async () => { + let spawned = 0; + const spawn: SpawnFn = () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }; + const adapter = createCodeBuddyAdapter(provider({ baseUrl: "https://evil.example.test" }), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, parsed()); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "non_canonical_destination", retryable: false }); + }); + + test("a missing credential is refused before spawn", async () => { + let spawned = 0; + const adapter = createCodeBuddyAdapter(provider({ apiKey: undefined }), { spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, parsed()); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "missing_credential" }); + }); + + test("a missing CLI is a clear pre-flight error, not a mid-turn ENOENT", async () => { + let spawned = 0; + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, which: () => undefined }); + const events = await run(adapter, parsed()); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "cli_not_found" }); + expect(String((events[0] as { message: string }).message)).toContain("npm install -g @tencent-ai/codebuddy-code"); + }); + + test("an asynchronous spawn failure settles as cli_spawn_failed without waiting for close", async () => { + const child = fakeChild([], { emitClose: false }); + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => { + setTimeout(() => child.emit("error", Object.assign(new Error("spawn ENOENT cb-global-key"), { code: "ENOENT" })), 0); + return child as unknown as ChildProcess; + }, + which: () => "/stale/path/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "cli_spawn_failed", retryable: false }); + expect((events[0] as { message: string }).message).not.toContain("cb-global-key"); + }); + + test("a synchronous spawn failure redacts the exact configured credential", async () => { + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => { throw new Error("launch rejected credential cb-global-key"); }, + which: () => "/stale/path/codebuddy", + }); + + const events = await run(adapter, parsed()); + expect(events[0]).toMatchObject({ type: "error", code: "cli_spawn_failed", retryable: false }); + expect((events[0] as { message: string }).message).toContain("credential [redacted]"); + expect((events[0] as { message: string }).message).not.toContain("cb-global-key"); + }); + + test("a Windows cmd shim is launched through commandInvocation with escaped arguments", async () => { + let command = ""; + let args: readonly string[] = []; + let options: import("node:child_process").SpawnOptions | undefined; + const adapter = createCodeBuddyAdapter(provider(), { + platform: "win32", + which: () => "C:\\npm\\codebuddy.cmd", + spawn: (seenCommand, seenArgs, seenOptions) => { + command = seenCommand; + args = seenArgs; + options = seenOptions; + return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + + await run(adapter, parsed({ context: { systemPrompt: ['Say "hello" & stop'], messages: [] } })); + expect(command.toLowerCase()).toContain("cmd.exe"); + expect(args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(args[3]).toContain("codebuddy.cmd"); + expect(args[3]).toContain("Say"); + expect(options?.windowsVerbatimArguments).toBe(true); + }); +}); + +describe("codebuddy runTurn streams a headless turn", () => { + test("emits text deltas then done with usage, and feeds the conversation to stdin", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}}\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":7,"output_tokens":2}}\n'), + ]; + const child = fakeChild(stdout); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe("Hello"); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 7, outputTokens: 2, totalTokens: 9 } }); + expect(child.written.join("")).toContain('"text":"hello"'); + }); + + test("region isolation: the global adapter never spawns with the CN environment", async () => { + let seenEnv: NodeJS.ProcessEnv | undefined; + const spawn: SpawnFn = (_cmd, _args, opts) => { seenEnv = opts.env as NodeJS.ProcessEnv; return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; }; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + await run(adapter, parsed()); + expect(seenEnv?.CODEBUDDY_INTERNET_ENVIRONMENT).toBe("public"); + expect(seenEnv?.CODEBUDDY_API_KEY).toBe("cb-global-key"); + }); + + test("an upstream error result surfaces as an error event", async () => { + const stdout = [enc.encode('{"type":"result","subtype":"error_during_execution","is_error":true,"result":"insufficient credits"}\n')]; + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => fakeChild(stdout) as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(events.at(-1)).toMatchObject({ type: "error", message: "insufficient credits", status: 502 }); + }); + + test("a pre-aborted signal ends the turn without spawning", async () => { + let spawned = 0; + const controller = new AbortController(); + controller.abort(); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, parsed(), incoming(controller.signal)); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error" }); + }); + + test("a CLI that exits without a result reports stderr (redacted) as an upstream error", async () => { + const child = fakeChild([], { stderr: "fatal: CODEBUDDY_API_KEY=sk-secretvalue rejected", exitCode: 1 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + const last = events.at(-1) as { type: string; message: string; code: string }; + expect(last.type).toBe("error"); + expect(last.code).toBe("process_exit_error"); + expect(last.message).not.toContain("sk-secretvalue"); + }); + + test("redacts the exact configured credential even when stderr uses no known secret prefix", async () => { + const child = fakeChild([], { stderr: "authentication failed: token cb-global-key rejected", exitCode: 1 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + const last = events.at(-1) as { message: string }; + expect(last.message).toContain("token [redacted] rejected"); + expect(last.message).not.toContain("cb-global-key"); + }); + + test("a CLI that exits with non-zero exit code and empty stderr reports process_exit_error and never done", async () => { + const child = fakeChild([], { stderr: "", exitCode: 1 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + status: 502, + code: "process_exit_error", + errorType: "upstream_error", + }); + expect((events[0] as { message: string }).message).toContain("exited with non-zero exit code 1"); + // Under no circumstance should a synthetic done be emitted! + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("a CLI that exits with code 0 but emitted no terminal result frame fails closed with protocol_error", async () => { + // Upstream closed stdout without emitting a result frame + const child = fakeChild([ + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}}\n'), + ], { exitCode: 0 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(events.some(e => e.type === "done")).toBe(false); + const last = events.at(-1) as { type: string; message: string; code: string; status: number }; + expect(last.type).toBe("error"); + expect(last.code).toBe("protocol_error"); + expect(last.status).toBe(502); + expect(last.message).toContain("ended without a terminal result frame"); + }); + + test("a stream with malformed JSON terminates child and fails closed with protocol_error", async () => { + const child = fakeChild([ + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}}\n'), + enc.encode('CORRUPTED_NOT_JSON\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ], { exitCode: 0 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(child.killed).toBe(true); + expect(events.some(e => e.type === "done")).toBe(false); + const last = events.at(-1) as { type: string; message: string; code: string; status: number }; + expect(last.type).toBe("error"); + expect(last.code).toBe("protocol_error"); + expect(last.status).toBe(502); + expect(last.message).toContain("Malformed stream-json frame"); + }); + + test("an in-flight abort kills the child process gracefully with SIGTERM", async () => { + const controller = new AbortController(); + const stdoutStream = new Readable({ + read() { + // Feed one partial delta then abort before result + this.push(enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"start"}}}\n')); + setTimeout(() => controller.abort(), 5); + }, + }); + const child = new EventEmitter() as FakeChild; + child.stdout = stdoutStream; + child.stderr = Readable.from([]); + child.written = []; + child.stdin = new Writable({ write(_c, _e, cb) { cb(); } }); + child.killed = false; + child.exitCode = null; + let killSignal: string | undefined; + child.kill = (sig?: string) => { + child.killed = true; + killSignal = sig; + setTimeout(() => { child.exitCode = 143; child.emit("close", 143); }, 5); + return true; + }; + + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed(), incoming(controller.signal)); + expect(child.killed).toBe(true); + expect(killSignal).toBe("SIGTERM"); + expect(events.some(e => e.type === "error")).toBe(true); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("a timeout destroys a stalled stdout stream and returns even when close never arrives", async () => { + const stdoutStream = new Readable({ read() { /* stays open until timeout destroys it */ } }); + const child = new EventEmitter() as FakeChild; + child.stdout = stdoutStream; + child.stderr = Readable.from([]); + child.written = []; + child.stdin = new Writable({ write(_c, _e, cb) { cb(); } }); + child.killed = false; + child.exitCode = null; + const signals: string[] = []; + child.kill = (sig?: string) => { + child.killed = true; + signals.push(sig ?? "SIGTERM"); + return true; + }; + + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => child as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + timeoutMs: 10, + killGraceMs: 10, + reapTimeoutMs: 35, + }); + const startedAt = Date.now(); + const events = await run(adapter, parsed()); + + expect(Date.now() - startedAt).toBeLessThan(250); + expect(stdoutStream.destroyed).toBe(true); + expect(signals).toContain("SIGTERM"); + expect(events).toContainEqual(expect.objectContaining({ type: "error", status: 504, code: "timeout" })); + expect(events.some(e => e.type === "done")).toBe(false); + }); +}); diff --git a/tests/providers/codebuddy-protocol.test.ts b/tests/providers/codebuddy-protocol.test.ts new file mode 100644 index 0000000000..a51cb1ea7b --- /dev/null +++ b/tests/providers/codebuddy-protocol.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, test } from "bun:test"; +import { + buildConversationInput, + buildInputLines, + buildSystemPrompt, + mapStreamMessageToEvents, + readJsonLines, + usageFromResult, +} from "../../src/adapters/coding-agent/protocol"; +import type { OcxParsedRequest } from "../../src/types"; + +// The stream-json protocol for coding-agent CLIs +// (src/adapters/coding-agent/protocol.ts); these fixtures exercise it via CodeBuddy frames. + +const enc = new TextEncoder(); + +async function* chunks(...parts: Uint8Array[]): AsyncGenerator<Uint8Array> { + for (const part of parts) yield part; +} + +async function collect(gen: AsyncGenerator<Record<string, unknown>>): Promise<Record<string, unknown>[]> { + const out: Record<string, unknown>[] = []; + for await (const item of gen) out.push(item); + return out; +} + +function parsedRequest(overrides: Partial<OcxParsedRequest> = {}): OcxParsedRequest { + return { + modelId: "glm-5.3", + stream: true, + options: {}, + context: { messages: [] }, + ...overrides, + } as OcxParsedRequest; +} + +describe("codebuddy stream-json line reader", () => { + test("parses multiple frames delivered in a single chunk", async () => { + const line = enc.encode('{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n'); + const out = await collect(readJsonLines(chunks(line))); + expect(out.map(m => m.type)).toEqual(["a", "b", "c"]); + }); + + test("applies the line limit to each frame instead of the combined chunk", async () => { + const line = enc.encode('{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n'); + const out = await collect(readJsonLines(chunks(line), { maxLineBytes: 12 })); + expect(out.map(m => m.type)).toEqual(["a", "b", "c"]); + }); + + test("reassembles a JSON frame fragmented across chunk boundaries", async () => { + const full = enc.encode('{"type":"result","subtype":"success"}\n'); + const out = await collect(readJsonLines(chunks(full.slice(0, 12), full.slice(12, 25), full.slice(25)))); + expect(out).toEqual([{ type: "result", subtype: "success" }]); + }); + + test("reassembles a multi-byte UTF-8 character split across chunks", async () => { + const full = enc.encode('{"type":"stream_event","text":"世界"}\n'); + // "世" is a 3-byte sequence; split inside it so the decoder must buffer the partial char. + const marker = enc.encode('"text":"').length; + const splitAt = full.indexOf(enc.encode("世")[0]!, marker) + 1; + const out = await collect(readJsonLines(chunks(full.slice(0, splitAt), full.slice(splitAt)))); + expect(out[0]?.text).toBe("世界"); + }); + + test("handles CRLF line endings transparently", async () => { + const line = enc.encode('{"type":"a"}\r\n{"type":"b"}\r\n'); + const out = await collect(readJsonLines(chunks(line))); + expect(out.map(m => m.type)).toEqual(["a", "b"]); + }); + + test("emits a final frame that has no trailing newline (upstream EOF)", async () => { + const out = await collect(readJsonLines(chunks(enc.encode('{"type":"result"}')))); + expect(out).toEqual([{ type: "result" }]); + }); + + test("fails closed on malformed stream-json line with CodingAgentProtocolError", async () => { + const line = enc.encode('{"type":"ok"}\nnot-json\n'); + const gen = readJsonLines(chunks(line)); + await expect(collect(gen)).rejects.toThrow("Malformed stream-json frame received from coding-agent CLI"); + }); + + test("fails closed on non-object JSON frame (array or primitive)", async () => { + const line = enc.encode('[1,2]\n'); + const gen = readJsonLines(chunks(line)); + await expect(collect(gen)).rejects.toThrow("Non-object stream-json frame received from coding-agent CLI"); + }); + + test("ignores blank and whitespace padding lines between valid frames", async () => { + const line = enc.encode(' \n\n{"type":"ok"}\n \n'); + const out = await collect(readJsonLines(chunks(line))); + expect(out).toEqual([{ type: "ok" }]); + }); + + test("enforces the total byte ceiling", async () => { + const gen = readJsonLines(chunks(enc.encode("x".repeat(100))), { maxTotalBytes: 10 }); + await expect(collect(gen)).rejects.toThrow(/total byte ceiling/); + }); +}); + +describe("codebuddy stream-json event mapping", () => { + test("classifies coding-agent auth, rate-limit, and unavailable-model results", () => { + const frame = (detail: string) => mapStreamMessageToEvents( + { type: "result", subtype: "error_during_execution", is_error: true, errors: [detail] }, + { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }, + )[0]; + expect(frame("Not logged in; invalid token")).toMatchObject({ status: 401, code: "invalid_api_key", retryable: false }); + expect(frame("Too many requests: rate limit reached")).toMatchObject({ status: 429, code: "rate_limit_exceeded", retryable: true }); + expect(frame("Model is unavailable")).toMatchObject({ status: 400, code: "model_not_found", retryable: false }); + }); + + test("maps partial text and thinking deltas and decouples their state", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + const text = mapStreamMessageToEvents( + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hi" } } }, + state, + ); + expect(text).toEqual([{ type: "text_delta", text: "Hi" }]); + expect(state.sawPartialText).toBe(true); + expect(state.sawPartialThinking).toBe(false); + + const thinking = mapStreamMessageToEvents( + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "thinking_delta", thinking: "let me see" } } }, + state, + ); + expect(thinking).toEqual([{ type: "thinking_delta", thinking: "let me see" }]); + expect(state.sawPartialThinking).toBe(true); + }); + + test("assistant fallback matrix: independently decouples partial text and partial thinking", () => { + // Case 1: Partial text seen, partial thinking NOT seen -> assistant emits thinking only, no duplicate text + const state1 = { sawPartialText: true, sawPartialThinking: false, sawTerminalResult: false }; + const events1 = mapStreamMessageToEvents( + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "final answer" }, + ], + }, + }, + state1, + ); + expect(events1).toEqual([{ type: "thinking_delta", thinking: "reasoning..." }]); + + // Case 2: Partial thinking seen, partial text NOT seen -> assistant emits text only, no duplicate thinking + const state2 = { sawPartialText: false, sawPartialThinking: true, sawTerminalResult: false }; + const events2 = mapStreamMessageToEvents( + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "final answer" }, + ], + }, + }, + state2, + ); + expect(events2).toEqual([{ type: "text_delta", text: "final answer" }]); + + // Case 3: Both partials seen -> assistant emits nothing + const state3 = { sawPartialText: true, sawPartialThinking: true, sawTerminalResult: false }; + const events3 = mapStreamMessageToEvents( + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "final answer" }, + ], + }, + }, + state3, + ); + expect(events3).toEqual([]); + + // Case 4: Neither partial seen -> assistant emits both thinking and text + const state4 = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + const events4 = mapStreamMessageToEvents( + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "final answer" }, + ], + }, + }, + state4, + ); + expect(events4).toEqual([ + { type: "thinking_delta", thinking: "reasoning..." }, + { type: "text_delta", text: "final answer" }, + ]); + }); + + test("maps a successful result frame to done with usage and marks sawTerminalResult", () => { + const state = { sawPartialText: true, sawPartialThinking: false, sawTerminalResult: false }; + const events = mapStreamMessageToEvents( + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 } }, + state, + ); + expect(state.sawTerminalResult).toBe(true); + expect(events).toEqual([{ + type: "done", + stopReason: "stop", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15, cachedInputTokens: 2, cacheReadInputTokens: 2 }, + }]); + }); + + test("maps an errored result frame to an upstream error, keeping usage without marking success", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + const events = mapStreamMessageToEvents( + { type: "result", subtype: "error_during_execution", is_error: true, result: "boom", usage: { input_tokens: 3, output_tokens: 0 } }, + state, + ); + expect(state.sawTerminalResult).toBe(false); + expect(events[0]).toMatchObject({ type: "error", status: 502, errorType: "upstream_error", message: "boom" }); + }); + + test("ignores system/init and background task frames", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + expect(mapStreamMessageToEvents({ type: "system", subtype: "init" }, state)).toEqual([]); + expect(mapStreamMessageToEvents({ type: "system", subtype: "task_started" }, state)).toEqual([]); + }); + + test("parses tool_use blocks defensively even though v1 disables tools", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, openToolCallId: undefined as string | undefined }; + const start = mapStreamMessageToEvents( + { type: "stream_event", event: { type: "content_block_start", content_block: { type: "tool_use", id: "t1", name: "exec" } } }, + state, + ); + expect(start).toEqual([{ type: "tool_call_start", id: "t1", name: "exec" }]); + const delta = mapStreamMessageToEvents( + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "input_json_delta", partial_json: "{\"a\":1}" } } }, + state, + ); + expect(delta).toEqual([{ type: "tool_call_delta", arguments: "{\"a\":1}" }]); + const stop = mapStreamMessageToEvents({ type: "stream_event", event: { type: "content_block_stop" } }, state); + expect(stop).toEqual([{ type: "tool_call_end" }]); + expect(state.openToolCallId).toBeUndefined(); + }); + + test("usageFromResult returns undefined when no usage is present", () => { + expect(usageFromResult({ type: "result" })).toBeUndefined(); + }); +}); + +describe("codebuddy conversation input builder (Strategy C projection)", () => { + test("folds system + developer prompts and skips developer messages in the input stream", () => { + const parsed = parsedRequest({ + context: { + systemPrompt: ["You are Codex."], + messages: [ + { role: "developer", content: "Policy: be brief.", timestamp: 0 }, + { role: "user", content: "hello", timestamp: 1 }, + ], + }, + }); + expect(buildSystemPrompt(parsed)).toBe("You are Codex.\n\nPolicy: be brief."); + const lines = buildConversationInput(parsed).map(line => JSON.parse(line)); + expect(lines).toHaveLength(1); + expect(lines[0]).toEqual({ type: "user", message: { role: "user", content: [{ type: "text", text: "hello" }] } }); + }); + + test("projects multi-turn conversation into legal user-message frames with clear context separation", () => { + const parsed = parsedRequest({ + context: { + messages: [ + { role: "user", content: "Check the files.", timestamp: 0 }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I will call exec" }, + { type: "toolCall", id: "c1", name: "exec", arguments: { cmd: "ls" } }, + ], + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "exec", + content: "file1.txt\nfile2.txt", + isError: false, + timestamp: 2, + }, + { role: "user", content: "Now read file1.txt", timestamp: 3 }, + ], + }, + }); + + const lines = buildConversationInput(parsed).map(line => JSON.parse(line)); + // Must ONLY emit legal user message frames; zero assistant replay frames! + expect(lines).toHaveLength(1); + expect(lines[0].type).toBe("user"); + expect(lines[0].message.role).toBe("user"); + + const text = lines[0].message.content[0].text as string; + expect(text).toContain("Prior conversation context:"); + expect(text).toContain("USER:\nCheck the files."); + expect(text).toContain("ASSISTANT:\n[Thinking: I will call exec]\n[Tool call: exec (call_id: c1)"); + expect(text).toContain("TOOL RESULT (call_id: c1):\nfile1.txt\nfile2.txt"); + expect(text).toContain("Current user request:\n\nNow read file1.txt"); + + // Must NOT contain raw assistant frames + for (const raw of buildConversationInput(parsed)) { + expect(raw).not.toContain('"type":"assistant"'); + } + }); + + test("encodes a base64 image part and never silently drops a remote image", () => { + const dataUrl = buildInputLines({ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,QUJD" }], timestamp: 0 } as never) + .map(line => JSON.parse(line)); + expect(dataUrl[0].message.content[0]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "QUJD" } }); + const remote = buildInputLines({ role: "user", content: [{ type: "image", imageUrl: "https://x.test/a.png" }], timestamp: 0 } as never) + .map(line => JSON.parse(line)); + expect(remote[0].message.content[0]).toEqual({ type: "image", source: { type: "url", url: "https://x.test/a.png" } }); + }); + + test("preserves images attached during multi-turn conversation projection", () => { + const parsed = parsedRequest({ + context: { + messages: [ + { role: "user", content: "Here is the layout", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "Show me the screenshot" }], timestamp: 1 }, + { + role: "user", + content: [ + { type: "text", text: "Look at this screenshot" }, + { type: "image", imageUrl: "data:image/png;base64,QUJD" }, + ], + timestamp: 2, + }, + ], + }, + }); + + const lines = buildConversationInput(parsed).map(line => JSON.parse(line)); + expect(lines).toHaveLength(1); + expect(lines[0].type).toBe("user"); + const content = lines[0].message.content as Array<Record<string, unknown>>; + expect(content[0].type).toBe("text"); + expect(content[0].text).toContain("Current user request:\n\nLook at this screenshot"); + expect(content[1]).toEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: "QUJD" }, + }); + }); +}); diff --git a/tests/providers/cursor/cursor-stream-health.test.ts b/tests/providers/cursor/cursor-stream-health.test.ts index dc7b572bf1..27a6cdac43 100644 --- a/tests/providers/cursor/cursor-stream-health.test.ts +++ b/tests/providers/cursor/cursor-stream-health.test.ts @@ -12,6 +12,7 @@ import { import { encodeConnectFrame } from "../../../src/adapters/cursor/framing"; import { createLiveCursorTransport } from "../../../src/adapters/cursor/live-transport"; import { createTestTranslatorBudget } from "../../helpers/translator-budget"; +import { isolationBudgetMs, watchdogMs } from "../../helpers/ci-watchdog"; import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; /** @@ -99,7 +100,11 @@ function runRequest(): CursorRunRequest { } as CursorRunRequest; } -async function drain(baseUrl: string, knobs: { streamSilenceFailMs?: number; streamHeartbeatOnlyFailMs?: number }): Promise<{ +async function drain( + baseUrl: string, + knobs: { streamSilenceFailMs?: number; streamHeartbeatOnlyFailMs?: number }, + onFirstText?: () => void, +): Promise<{ messages: CursorServerMessage[]; failure?: Error; }> { @@ -112,7 +117,14 @@ async function drain(baseUrl: string, knobs: { streamSilenceFailMs?: number; str const messages: CursorServerMessage[] = []; let failure: Error | undefined; try { - for await (const message of transport.run(runRequest())) messages.push(message); + for await (const message of transport.run(runRequest())) { + messages.push(message); + if (message.type === "text" && onFirstText) { + const notify = onFirstText; + onFirstText = undefined; + notify(); + } + } } catch (err) { failure = err instanceof Error ? err : new Error(String(err)); } finally { @@ -122,6 +134,15 @@ async function drain(baseUrl: string, knobs: { streamSilenceFailMs?: number; str } describe("Cursor inbound stream-health watchdog (T04)", () => { + // Scale once: the load helper applies a floor, so scaling each deadline separately + // would collapse the two clocks to the same value in CI. + const silenceMs = isolationBudgetMs(1_000); + const heartbeatOnlyMs = 2 * silenceMs; + const progressDurationMs = 3 * silenceMs; + // Include the existing two-second first-frame allowance and leave time for cleanup. + const fixtureLimitMs = 4 * silenceMs + 2_000; + const timeoutMs = Math.max(watchdogMs(15_000), fixtureLimitMs + silenceMs); + test("silence after the first frame fails the turn with the stall error", async () => { await withH2Server(stream => { stream.on("error", () => {}); @@ -140,27 +161,24 @@ describe("Cursor inbound stream-health watchdog (T04)", () => { stream.on("error", () => {}); stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); stream.write(Buffer.from(textDeltaFrame("hi"))); - // 40ms, not 100ms. - // - // The silence clock below is 400ms, so a 100ms ping left a margin of four - // ticks: miss three in a row and the SILENCE watchdog fires first, which - // is a different error and a green-looking bug report. That is exactly what - // happened on the v2.41.0 macOS runner -- the assertion wanted - // "heartbeat-only" and got "no inbound frames for 1s before turnEnded". - // - // Nothing about the behaviour under test needs a slow ping: the point is - // that heartbeats reset the silence clock and do NOT reset the - // heartbeat-only clock. A tighter interval tests the same two clocks with - // ten ticks of margin instead of four. + // Frequent heartbeats/checkpoints keep the silence clock fresh while the + // longer heartbeat-only clock must still expire under a loaded test runner. const ping = setInterval(() => { try { stream.write(Buffer.from(heartbeatFrame())); stream.write(Buffer.from(checkpointFrame())); } catch { clearInterval(ping); } }, 40); - stream.on("close", () => clearInterval(ping)); + const limit = setTimeout(() => stream.close(), fixtureLimitMs); + stream.on("close", () => { + clearInterval(ping); + clearTimeout(limit); + }); }, async baseUrl => { - const { failure } = await drain(baseUrl, { streamSilenceFailMs: 400, streamHeartbeatOnlyFailMs: 900 }); + const { failure } = await drain(baseUrl, { + streamSilenceFailMs: silenceMs, + streamHeartbeatOnlyFailMs: heartbeatOnlyMs, + }); expect(failure).toBeDefined(); // Assert on the message, and say which watchdog won when the wrong one does. // A bare toContain here reported only the expected substring, which reads as @@ -168,35 +186,50 @@ describe("Cursor inbound stream-health watchdog (T04)", () => { // silence watchdog fired first on a loaded runner. expect(failure!.message).toContain("heartbeat-only"); }); - }, 15_000); + }, timeoutMs); test("meaningful frames keep resetting both clocks; turnEnded finishes cleanly", async () => { + let firstTextReceivedAt: number | undefined; + let completedProgressSpan = false; await withH2Server(stream => { stream.on("error", () => {}); stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(textDeltaFrame("part-0"))); + const latestEndAt = performance.now() + fixtureLimitMs; let count = 0; const tick = setInterval(() => { count += 1; try { - if (count < 6) { - stream.write(Buffer.from(textDeltaFrame(`part-${count}`))); - } else { + const now = performance.now(); + const progressComplete = firstTextReceivedAt !== undefined + && now - firstTextReceivedAt >= progressDurationMs; + if (progressComplete || now >= latestEndAt) { + completedProgressSpan = progressComplete; stream.write(Buffer.from(turnEndedFrame())); stream.end(); clearInterval(tick); + } else { + stream.write(Buffer.from(textDeltaFrame(`part-${count}`))); } - } catch { clearInterval(tick); } - }, 150); + } catch { + clearInterval(tick); + stream.destroy(); + } + }, 100); stream.on("close", () => clearInterval(tick)); }, async baseUrl => { - // Each 150ms text delta must reset the 400ms silence clock: six ticks ≈ 900ms total, - // far past a NON-resetting 400ms deadline. - const { messages, failure } = await drain(baseUrl, { streamSilenceFailMs: 400, streamHeartbeatOnlyFailMs: 10_000 }); + // Observe progress for 3S after receipt: both non-resetting deadlines (S and 2S) + // would expire before turnEnded, even when the first text reaches us late. + const { messages, failure } = await drain(baseUrl, { + streamSilenceFailMs: silenceMs, + streamHeartbeatOnlyFailMs: heartbeatOnlyMs, + }, () => { firstTextReceivedAt = performance.now(); }); expect(failure).toBeUndefined(); + expect(completedProgressSpan).toBe(true); expect(messages.some(message => message.type === "text")).toBe(true); expect(messages.some(message => message.type === "done")).toBe(true); }); - }, 15_000); + }, timeoutMs); test("turnEnded disarms the watchdog even when the server holds the stream open", async () => { await withH2Server(stream => { diff --git a/tests/providers/mimo-free-provider.test.ts b/tests/providers/mimo-free-provider.test.ts index 04cd2c908e..00176d2589 100644 --- a/tests/providers/mimo-free-provider.test.ts +++ b/tests/providers/mimo-free-provider.test.ts @@ -12,6 +12,64 @@ import { createMimoFreeAdapter, } from "../../src/adapters/mimo-free"; import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +for (const phase of ["bootstrap", "chat", "401-replay"] as const) test.each([307, 308])(`MiMo ${phase} never follows %i`, async status => { + const nativeFetch = globalThis.fetch; + const previousHome = process.env.OPENCODEX_HOME; + const testHome = mkdtempSync(join(tmpdir(), "ocx-mimo-redirect-")); + process.env.OPENCODEX_HOME = testHome; + resetMimoClientIdCache(); + resetMimoJwtCache(); + let targetHits = 0; + let originHits = 0; + let chatSends = 0; + const observed: Array<RequestRedirect | undefined> = []; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return Response.json({ jwt: "redirected", ok: true }); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url !== MIMO_CHAT_URL && url !== "https://api.xiaomimimo.com/api/free-ai/bootstrap") throw new Error("unexpected external request"); + observed.push(init?.redirect); + if (phase === "401-replay") { + if (url.endsWith("/bootstrap")) return Response.json({ jwt: "fresh-token" }); + if (++chatSends === 1) return new Response("expired", { status: 401 }); + } + // Only remap the canonical URL; do not repair the production redirect option. + return nativeFetch(`http://127.0.0.1:${origin.port}/mimo`, init); + }) as typeof fetch; + let response: Response | undefined; + try { + if (phase === "bootstrap") await expect(getMimoJwt()).rejects.toThrow(`MiMo bootstrap failed: ${status}`); + else { + const adapter = createMimoFreeAdapter(providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "mimo-free")!)); + response = await adapter.fetchResponse!({ url: MIMO_CHAT_URL, method: "POST", headers: { authorization: "Bearer synthetic-token" }, body: "synthetic prompt" }, {}); + expect(response.status).toBe(status); + } + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(observed).toEqual(phase === "401-replay" ? ["manual", "manual", "manual"] : ["manual"]); + } finally { + globalThis.fetch = nativeFetch; + await response?.body?.cancel(); + await origin.stop(true); + await target.stop(true); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + resetMimoClientIdCache(); + resetMimoJwtCache(); + removeTreeWithRetry(testHome); + } +}); function minimalRequest(model = "mimo-auto"): OcxParsedRequest { return { @@ -148,7 +206,10 @@ describe("mimo-free JWT cache", () => { test("getMimoJwt fetches from bootstrap and caches", async () => { const fakeJwt = "header." + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64") + ".sig"; const originalFetch = globalThis.fetch; - globalThis.fetch = mock(async () => new Response(JSON.stringify({ jwt: fakeJwt }), { status: 200 })); + globalThis.fetch = mock(async (_input, init) => { + expect(init?.redirect).toBe("manual"); + return new Response(JSON.stringify({ jwt: fakeJwt }), { status: 200 }); + }); try { const jwt1 = await getMimoJwt(); expect(jwt1).toBe(fakeJwt); @@ -263,6 +324,7 @@ describe("mimo-free auth retry predicate", () => { const calls: string[] = []; const originalFetch = globalThis.fetch; globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => { + expect(init?.redirect).toBe("manual"); const u = String(url); if (u.includes("/bootstrap")) { calls.push("bootstrap"); diff --git a/tests/providers/muse-spark-web-search-compat.test.ts b/tests/providers/muse-spark-web-search-compat.test.ts index ed22b4e036..5254c7163d 100644 --- a/tests/providers/muse-spark-web-search-compat.test.ts +++ b/tests/providers/muse-spark-web-search-compat.test.ts @@ -35,6 +35,12 @@ const META_PROVIDER = { baseUrl: "https://api.meta.ai/v1", }; +const META_PATH_PROVIDER = { + ...ZEN_PROVIDER, + baseUrl: "https://api.meta.ai", + responsesPath: "/v1/responses", +}; + /** A Codex web_search declaration exactly as `hosted_spec.rs` emits it for TextAndImage. */ function webSearchTool(): Record<string, unknown> { return { @@ -158,6 +164,47 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { expect(Object.hasOwn(nested, "indexed_web_access")).toBe(false); }); + /** + * The Contributor Free tiers ride the same Zen Responses wire with the same + * gateway contract, so a Codex `web_search` + refused-field body 400s for them + * exactly like the paid tiers. + */ + test("Contributor Free tiers get the same web_search sanitization", () => { + for (const modelId of ["muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free"]) { + const body = build(modelId, { tools: [webSearchTool()] }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search"); + expect(tool.search_context_size).toBe("medium"); + expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); + } + }); + + test("a nested additional_tools declaration is sanitized for the Free tiers too", () => { + for (const modelId of ["muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free"]) { + const body = build(modelId, { + input: [{ type: "additional_tools", tools: [webSearchTool()] }], + }); + const item = (body.input as Array<Record<string, unknown>>)[0]!; + const nested = (item.tools as Array<Record<string, unknown>>)[0]!; + expect(nested.type).toBe("web_search"); + expect(Object.hasOwn(nested, "search_content_types")).toBe(false); + expect(Object.hasOwn(nested, "indexed_web_access")).toBe(false); + } + }); + + test("the Free tiers keep the field on web_search_preview, where the gateway accepts it", () => { + for (const modelId of ["muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free"]) { + const body = build(modelId, { + tools: [{ ...webSearchTool(), type: "web_search_preview" }], + }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search_preview"); + expect(tool.search_content_types).toEqual(["text", "image"]); + expect(tool.indexed_web_access).toBe(true); + } + }); + test("OpenCode Go applies the same Muse compatibility guard", () => { const body = buildForProvider(ZEN_GO_PROVIDER, "muse-spark-1.3-contributor", { tools: [webSearchTool()], @@ -178,7 +225,14 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { } }); - test("direct Meta preserves its web_search fields at both tool positions", () => { + /** + * #3456 scoped the guard to OpenCode Zen/Go URLs on the assumption that + * `https://api.meta.ai/v1` accepted Codex's extra web_search fields. Direct + * Meta still 400s `tools[].search_content_types` on ordinary `web_search` + * (live 2026-09-07 against muse-spark-1.3-contributor). Same model-id set, + * same field drop, same preview preservation. + */ + test("direct Meta strips rejected web_search fields at both tool positions", () => { const body = buildForProvider(META_PROVIDER, "muse-spark-1.3-contributor", { tools: [webSearchTool()], input: [{ type: "additional_tools", tools: [webSearchTool()] }], @@ -187,8 +241,29 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { const item = (body.input as Array<Record<string, unknown>>)[0]!; const nested = (item.tools as Array<Record<string, unknown>>)[0]!; for (const declaration of [tool, nested]) { - expect(declaration.search_content_types).toEqual(["text", "image"]); - expect(declaration.indexed_web_access).toBe(true); + expect(declaration.type).toBe("web_search"); + expect(declaration.search_context_size).toBe("medium"); + expect(Object.hasOwn(declaration, "search_content_types")).toBe(false); + expect(Object.hasOwn(declaration, "indexed_web_access")).toBe(false); } }); + + test("direct Meta keeps the field on web_search_preview", () => { + const body = buildForProvider(META_PROVIDER, "muse-spark-1.3-contributor", { + tools: [{ ...webSearchTool(), type: "web_search_preview" }], + }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search_preview"); + expect(tool.search_content_types).toEqual(["text", "image"]); + expect(tool.indexed_web_access).toBe(true); + }); + + test("split Meta baseUrl and responsesPath derives the same strict destination", () => { + const body = buildForProvider(META_PATH_PROVIDER, "muse-spark-1.3-contributor", { + tools: [webSearchTool()], + }); + const tool = toolsOf(body)[0]!; + expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); + }); }); diff --git a/tests/providers/opencode-go-grok46-responses.test.ts b/tests/providers/opencode-go-grok46-responses.test.ts index 19c2addd46..d35fa6cf8c 100644 --- a/tests/providers/opencode-go-grok46-responses.test.ts +++ b/tests/providers/opencode-go-grok46-responses.test.ts @@ -5,6 +5,7 @@ import { getProviderRegistryEntry } from "../../src/providers/registry"; import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import type { OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { normalizeOpenCodeGoAdditionalTools } from "../../src/adapters/opencode-go-additional-tools"; const createResponsesPassthroughAdapter = (...args: Parameters<typeof createResponsesPassthroughAdapterProduction>) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); @@ -21,18 +22,22 @@ function provider(baseUrl = "https://opencode.ai/zen/go/v1"): OcxProviderConfig } as OcxProviderConfig; } -function build( +function buildRequest( modelId: string, rawBody: Record<string, unknown>, configuredProvider = provider(), -): Record<string, unknown> { - const request = createResponsesPassthroughAdapter(configuredProvider).buildRequest({ +) { + return createResponsesPassthroughAdapter(configuredProvider).buildRequest({ modelId, context: { messages: [] }, stream: true, options: {}, _rawBody: { model: modelId, input: "ping", ...rawBody }, }, { headers: new Headers() }); +} + +function build(modelId: string, rawBody: Record<string, unknown>, configuredProvider = provider()): Record<string, unknown> { + const request = buildRequest(modelId, rawBody, configuredProvider); return JSON.parse(request.body) as Record<string, unknown>; } @@ -68,7 +73,7 @@ describe("OpenCode Go Grok 4.6 Responses compatibility", () => { expect(body.tools).toEqual([functionTool]); }); - test("drops hosted search from an additional_tools-only request", () => { + test("promotes additional_tools-only declarations before dropping refused hosted search", () => { const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } }; const body = build("grok-4.6", { input: [{ @@ -77,7 +82,8 @@ describe("OpenCode Go Grok 4.6 Responses compatibility", () => { }], }); - expect(body.input).toEqual([{ type: "additional_tools", tools: [functionTool] }]); + expect(body.input).toEqual([]); + expect(body.tools).toEqual([functionTool]); }); test("disables an explicit choice for a removed hosted tool", () => { @@ -133,3 +139,157 @@ describe("OpenCode Go Grok 4.6 Responses compatibility", () => { expect(body.tools).toEqual([{ type: "web_search" }]); }); }); + +describe("OpenCode Go additional_tools placement", () => { + const lookup = { type: "function", name: "lookup", parameters: { type: "object" } }; + const group = (name: string, tools: unknown[]) => ({ type: "namespace", name, tools }); + + test("preserves the canonical namespace dedupe and distinct response aliases", () => { + const raw = { + tools: [lookup, lookup], + input: [ + { type: "additional_tools", tools: [group("functions", [lookup, lookup]), group("alpha", [lookup])] }, + { type: "additional_tools", tools: [group("alpha", [lookup]), group("beta", [lookup])] }, + ], + }; + const original = structuredClone(raw); + const request = buildRequest("gpt-5.6-luna", raw); + const sent = JSON.parse(request.body); + expect(sent.input).toEqual([]); + expect(sent.tools).toEqual([lookup, { ...lookup, name: "alpha__lookup" }, { ...lookup, name: "beta__lookup" }]); + expect(request.convertedRoutedNamespaceToolAliases?.get("alpha__lookup")) + .toEqual({ namespace: "alpha", name: "lookup", kind: "function" }); + expect(request.convertedRoutedNamespaceToolAliases?.get("beta__lookup")) + .toEqual({ namespace: "beta", name: "lookup", kind: "function" }); + expect(raw).toEqual(original); + }); + + test.each(["none", "allowed"])("preserves custom/function lowering and %s authorization", choice => { + const request = buildRequest("gpt-5.6-luna", { + input: [{ type: "additional_tools", tools: [ + group("alpha", [lookup, { type: "custom", name: "custom_probe", description: "Freeform input" }]), + ] }], + tool_choice: choice === "none" ? "none" : { + type: "allowed_tools", mode: "auto", tools: [{ type: "function", namespace: "alpha", name: "lookup" }], + }, + }, { ...provider(), supportsResponsesCustomTools: false }); + const sent = JSON.parse(request.body); + expect(sent.input).toEqual([]); + expect(sent.tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "function", name: "alpha__lookup" }), + expect.objectContaining({ type: "function", name: "alpha__custom_probe" }), + ])); + expect(request.convertedRoutedNamespaceToolAliases?.has("alpha__custom_probe")).toBe(false); + expect(request.convertedRoutedNamespaceToolAliases?.has("alpha__lookup")).toBe(choice === "allowed"); + expect(sent.tool_choice).toEqual(choice === "none" ? "none" : { + type: "allowed_tools", mode: "auto", tools: [{ type: "function", name: "alpha__lookup" }], + }); + }); + + test("keeps nameless hosted tools for Luna and prunes Go Grok selectors after promotion", () => { + const web = { type: "web_search" }; + const raw = { input: [{ type: "additional_tools", tools: [web, lookup] }], tool_choice: { + type: "allowed_tools", mode: "required", tools: [web, { type: "function", name: "lookup" }], + } }; + expect(build("gpt-5.6-luna", raw).tools).toEqual([web, lookup]); + const grok = build("grok-4.6", raw); + expect(grok.tools).toEqual([lookup]); + expect(grok.tool_choice).toEqual({ + type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "lookup" }], + }); + expect(build("grok-4.6", { input: [{ type: "additional_tools", tools: [web] }], tool_choice: "required" })) + .toMatchObject({ input: [], tools: [], tool_choice: "none" }); + }); + + test("activates tools loaded by tool search before moving their catalog", () => { + const sent = build("gpt-5.6-luna", { input: [ + { type: "additional_tools", tools: [{ ...lookup, defer_loading: true }] }, + { type: "tool_search_output", id: "tso_loaded", call_id: "call_search", tools: [lookup] }, + ] }); + expect(sent.tools).toEqual([lookup]); + expect((sent.input as Array<{ type: string }>).some(item => item.type === "additional_tools")).toBe(false); + }); + + test.each(["https://opencode.ai/zen/go/v1/responses", "https://opencode.ai:443/zen/go/v1/responses"])( + "promotes only wrappers on %s without mutating frozen caller data", responseUrl => { + const message = Object.freeze({ type: "message", role: "user", content: "keep" }); + const tools = Object.freeze([lookup]); + const raw = Object.freeze({ input: Object.freeze([message, Object.freeze({ type: "additional_tools", tools })]) }); + const result = normalizeOpenCodeGoAdditionalTools(raw, responseUrl) as { input: unknown[]; tools: unknown[] }; + expect(result).not.toBe(raw); + expect(result.input).toEqual([message]); + expect(result.input[0]).toBe(message); + expect(result.tools).toEqual([lookup]); + expect(result.tools[0]).toBe(lookup); + expect(raw.input).toHaveLength(2); + }, + ); + + test.each([ + { baseUrl: "https://opencode.ai/zen/go/v1" }, + { baseUrl: "https://opencode.ai:443/zen/go/v1/" }, + { baseUrl: "https://opencode.ai/zen/go/v1//" }, + { baseUrl: "https://opencode.ai/zen/go/v1/responses" }, + { baseUrl: "https://opencode.ai", responsesPath: "/zen/go/v1/responses" }, + ])("promotes on the final Go endpoint for $baseUrl", destination => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + const request = buildRequest("gpt-5.6-luna", raw, { ...provider(), ...destination }); + expect(new URL(request.url).href).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(JSON.parse(request.body)).toMatchObject({ input: [], tools: [lookup] }); + }); + + test("a custom path overriding a Go base does not inherit Go placement", () => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + const request = buildRequest("gpt-5.6-luna", raw, { ...provider(), responsesPath: "/../../v1/responses" }); + expect(new URL(request.url).href).toBe("https://opencode.ai/zen/v1/responses"); + expect(JSON.parse(request.body).input).toEqual(raw.input); + }); + + test.each([ + "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1/responses/", + "https://opencode.ai/zen/go/v1//responses", + ])("leaves a noncanonical final resource %s unchanged", responseUrl => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + expect(normalizeOpenCodeGoAdditionalTools(raw, responseUrl)).toBe(raw); + }); + + test.each([ + "https://opencode.ai/zen/v1", "https://opencode.ai.evil.test/zen/go/v1", + "http://opencode.ai/zen/go/v1", "https://opencode.ai:444/zen/go/v1", + "https://opencode.ai/zen/go/v10", + (() => { + const url = new URL("https://opencode.ai/zen/go/v1"); + url.username = "fixture-user"; + url.password = "synthetic-password"; + return url.href; + })(), "https://opencode.ai/zen/go/v1?tenant=test", + "https://opencode.ai/zen/go/v1?", "https://opencode.ai/zen/go/v1#", + "https://opencode.ai/zen/go/v1#fragment", "https://example.test/v1", + ])("does not promote for unapproved destination %s", baseUrl => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + const request = buildRequest("gpt-5.6-luna", raw, provider(baseUrl)); + expect(normalizeOpenCodeGoAdditionalTools(raw, request.url)).toBe(raw); + expect(JSON.parse(request.body).input).toEqual(raw.input); + }); + + test("keeps forward wrappers and mixed ciphertext unchanged", () => { + const mixed = { type: "agent_message", content: [ + { type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }, + ] }; + const raw = { input: [mixed, { type: "additional_tools", tools: [lookup] }] }; + expect(build("gpt-5.6-luna", raw, { ...provider(), authMode: "forward" }).input).toEqual(raw.input); + expect(build("gpt-5.6-luna", raw).input).toEqual([mixed]); + }); + + test("keeps malformed wrappers and no-op bodies; removes a valid empty wrapper", () => { + const valid = { input: [{ type: "additional_tools", tools: [lookup] }] }; + expect(normalizeOpenCodeGoAdditionalTools(valid, "not a URL")).toBe(valid); + for (const raw of [null, [], { input: "ping" }, { input: [] }, + { input: [{ type: "additional_tools", tools: null }] }, + { tools: null, input: [{ type: "additional_tools", tools: [lookup] }] }, + ]) expect(normalizeOpenCodeGoAdditionalTools(raw, "https://opencode.ai/zen/go/v1/responses")).toBe(raw); + const malformed = { type: "additional_tools", tools: null }; + expect(build("gpt-5.6-luna", { input: [malformed, { type: "additional_tools", tools: [] }] })) + .toMatchObject({ input: [malformed], tools: [] }); + }); +}); diff --git a/tests/providers/opencode-go-luna-wire.test.ts b/tests/providers/opencode-go-luna-wire.test.ts index 152783b9bd..c0afb15553 100644 --- a/tests/providers/opencode-go-luna-wire.test.ts +++ b/tests/providers/opencode-go-luna-wire.test.ts @@ -5,13 +5,17 @@ * boundary that lets operators opt out if the upstream changes. */ import { afterEach, describe, expect, test } from "bun:test"; -import { providerConfigSeed } from "../../src/providers/derive"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import { handleResponses } from "../../src/server/responses/core"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { parseRequest } from "../../src/responses/parser"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; const MODEL = "gpt-5.6-luna"; +const GO_RESPONSES_MODELS = [MODEL, "grok-4.6", "muse-spark-1.3-contributor"]; function opencodeGo(overrides: Partial<OcxProviderConfig> = {}): OcxProviderConfig { const entry = getProviderRegistryEntry("opencode-go"); @@ -43,6 +47,174 @@ describe("OpenCode Go GPT 5.6 Luna wire selection (#1482)", () => { }); }); +describe("OpenCode Go stateless Responses", () => { + test("seeds and backfills the canonical preset while preserving explicit false and custom names", () => { + expect(opencodeGo().statelessResponses).toBe(true); + const stale = opencodeGo(); + delete stale.statelessResponses; + enrichProviderFromRegistry("opencode-go", stale); + expect(stale.statelessResponses).toBe(true); + const overridden = opencodeGo({ statelessResponses: false }); + enrichProviderFromRegistry("opencode-go", overridden); + expect(overridden.statelessResponses).toBe(false); + const renamed = opencodeGo(); + delete renamed.statelessResponses; + enrichProviderFromRegistry("my-go", renamed); + expect(renamed.statelessResponses).toBeUndefined(); + expect(providerConfigSeed(getProviderRegistryEntry("cerebras")!).statelessResponses).toBeUndefined(); + }); + + test.each(GO_RESPONSES_MODELS)("%s repairs orphan calls/results and preserves paired results", model => { + const input = [ + { type: "function_call", call_id: "call_done", name: "probe", arguments: "{}" }, + { type: "function_call", call_id: "call_missing", name: "probe", arguments: "{}" }, + { type: "function_call_output", call_id: "call_done", output: "actual result" }, + { type: "function_call_output", call_id: "call_unknown", output: "orphan result" }, + ]; + const raw = { model, input, previous_response_id: "resp_unrecorded_go", stream: true }; + const original = structuredClone(raw); + for (const expanded of [false, true]) { + const parsed = parseRequest(raw); + parsed._previousResponseInputExpanded = expanded; + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...opencodeGo(), adapter: "openai-responses", + })); + const sent = JSON.parse(adapter.buildRequest(parsed).body); + expect(sent.previous_response_id).toBeUndefined(); + expect(sent.store).toBe(false); + expect(sent.input).toEqual([ + input[0], input[1], input[2], + expect.objectContaining({ type: "function_call_output", call_id: "call_missing", output: expect.stringContaining("no tool result was recorded") }), + expect.objectContaining({ type: "message", role: "user", content: expect.any(Array) }), + ]); + expect(JSON.stringify(sent.input[4])).toContain("orphan result"); + expect(raw).toEqual(original); + } + const stateful = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...opencodeGo({ statelessResponses: false }), adapter: "openai-responses", + })); + const sent = JSON.parse(stateful.buildRequest(parseRequest(raw)).body); + expect(sent.previous_response_id).toBe("resp_unrecorded_go"); + expect(sent.store).not.toBe(false); + expect(sent.input).toEqual(input); + }); +}); + +describe("OpenCode Go stateless reasoning and continuation routes", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + const continuations = [ + { id: "full", name: "full history", fullHistory: true, summary: "auto" }, + { id: "delta", name: "delta", fullHistory: false, summary: "auto" }, + { id: "hidden", name: "hidden-summary full history", fullHistory: true, summary: "none" }, + ]; + for (const model of GO_RESPONSES_MODELS) for (const streaming of [true, false]) for (const continuation of continuations) { + test(`${model} preserves ${continuation.name} across two ${streaming ? "SSE" : "JSON"} turns`, async () => { + const requests: Array<{ url: string; body: Record<string, unknown> }> = []; + // Opaque synthetic provider state, never a real credential or decrypted task. + const blob = "provider-minted-go-reasoning-state"; + const prefix = `${model.replaceAll(".", "_")}_${streaming ? "sse" : "json"}_${continuation.id}`; + const reasoning = [ + { type: "reasoning", id: `rs_${prefix}_summary`, status: "completed", summary: [{ type: "summary_text", text: "Already summarized" }] }, + { type: "reasoning", id: `rs_${prefix}_content`, status: "completed", content: [{ type: "reasoning_text", text: "Visible thinking" }], summary: [] }, + { type: "reasoning", id: `rs_${prefix}_blob`, status: "completed", content: [{ type: "reasoning_text", text: "Opaque item trace" }], summary: [], encrypted_content: blob }, + ]; + const call = { type: "function_call", id: `fc_${prefix}`, status: "completed", call_id: `call_${prefix}`, name: "probe", arguments: "{}" }; + const priorMessage = { type: "message", id: `msg_${prefix}_prior`, status: "completed", role: "assistant", + content: [{ type: "output_text", text: "Probe requested", annotations: [] }], + }; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), body: JSON.parse(String(init?.body ?? "{}")) }); + const output = requests.length === 1 ? [...reasoning, call, priorMessage] : [{ + type: "message", id: `msg_${prefix}`, status: "completed", role: "assistant", + content: [{ type: "output_text", text: "Continuation accepted", annotations: [] }], + }]; + const response = { id: `resp_${prefix}_${requests.length}`, object: "response", status: "completed", model, output }; + if (!streaming) return Response.json(response); + const payloads: Record<string, unknown>[] = [{ type: "response.created", response: { ...response, status: "in_progress", output: [] } }]; + for (const [index, item] of output.entries()) { + payloads.push({ type: "response.output_item.added", output_index: index, item }); + if (requests.length === 1 && index === 1) payloads.push({ + type: "response.reasoning_text.delta", item_id: item.id, output_index: index, content_index: 0, delta: "Visible thinking", + }); + payloads.push({ type: "response.output_item.done", output_index: index, item }); + } + payloads.push({ type: "response.completed", response }); + return new Response(payloads.map((payload, sequence_number) => + `data: ${JSON.stringify({ ...payload, sequence_number })}\n\n` + ).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { providers: { "opencode-go": opencodeGo() } } as unknown as OcxConfig; + const drive = async (body: Record<string, unknown>) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `opencode-go/${model}`, stream: streaming, reasoning: { summary: continuation.summary }, + tools: [{ type: "function", name: "probe", parameters: { type: "object" } }], ...body }), + }), config, { model: "", provider: "" }, { inboundWire: "responses" }); + expect(response.status).toBe(200); + const text = await response.text(); + if (!streaming) return { document: JSON.parse(text), text }; + const events = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + const terminal = events.find(event => event.type === "response.completed"); + expect(terminal).toBeDefined(); + return { document: terminal.response, text }; + }; + const initial = { type: "message", role: "user", content: [{ type: "input_text", text: "Run probe" }] }; + const first = await drive({ input: [initial] }); + expect(first.document.output[0]).toEqual(reasoning[0]); + expect(first.document.output[1]).toEqual(continuation.summary === "auto" ? { + type: "reasoning", id: `rs_${prefix}_content`, status: "completed", summary: [{ type: "summary_text", text: "Visible thinking" }], + } : reasoning[1]); + expect(first.document.output[2]).toEqual(reasoning[2]); + expect(first.document.output[3]).toMatchObject(call); + expect(first.document.output[4]).toEqual(priorMessage); + if (streaming) { + const channel = continuation.summary === "auto" ? "reasoning_summary_text" : "reasoning_text"; + expect(first.text).toContain(`"type":"response.${channel}.delta"`); + } + const result = { type: "function_call_output", call_id: call.call_id, output: "probe succeeded" }; + // Echo exactly the client-visible history through handleResponses. An upstream-shape + // cache would prepend it again after the content-to-summary rewrite (F1). + const nextBody = { + input: continuation.fullHistory ? [initial, ...first.document.output, result] : [result], + previous_response_id: first.document.id, store: true, + conversation: "conversation_fixture", background: true, metadata: { fixture: "go" }, prompt: { id: "prompt_fixture" }, + }; + const originalHistory = structuredClone(nextBody); + const second = await drive(nextBody); + for (const field of ["previous_response_id", "conversation", "background", "metadata", "prompt"]) { + expect(requests[1]!.body[field]).toBeUndefined(); + } + expect(nextBody).toEqual(originalHistory); + expect(second.text).toContain("Continuation accepted"); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(request.body.previous_response_id).toBeUndefined(); + expect(request.body.store).toBe(false); + expect(request.body.stream).toBe(streaming); + } + const replay = requests[1]!.body.input as Array<Record<string, unknown>>; + expect(replay.filter(item => item.type === "function_call")).toEqual([ + expect.objectContaining({ call_id: call.call_id, name: "probe", arguments: "{}" }), + ]); + expect(replay.filter(item => item.type === "function_call_output")).toEqual([result]); + expect(replay.filter(item => item.type === "message" && item.role === "user")).toEqual([initial]); + expect(replay.filter(item => item.type === "message" && item.role === "assistant")).toEqual([ + expect.objectContaining({ role: "assistant", content: priorMessage.content }), + ]); + expect(replay.filter(item => item.type === "reasoning")).toHaveLength(3); + expect(replay).toContainEqual(expect.objectContaining({ type: "reasoning", encrypted_content: blob })); + expect(JSON.stringify(replay)).toContain("Already summarized"); + if (continuation.summary === "auto") expect(replay).toContainEqual(expect.objectContaining({ + type: "reasoning", summary: [{ type: "summary_text", text: "Visible thinking" }], + })); + expect(JSON.stringify(replay)).not.toContain("no tool result was recorded"); + }); + } +}); + describe("OpenCode Go Luna Responses route (#1482)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index ab28c8475f..c176dc703e 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -4,6 +4,7 @@ import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-tran import { getProviderRegistryEntry } from "../../src/providers/registry"; import { handleResponses } from "../../src/server/responses/core"; import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; const MUSE_MODEL = "muse-spark-1.3-contributor"; @@ -25,7 +26,14 @@ function codexHeaders(child = "child-thread-a"): Record<string, string> { }; } -function upstreamResponse(url: string): Response { +function upstreamResponse(url: string, stream = false): Response { + if (stream && url.endsWith("/chat/completions")) { + return new Response([ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "ok" } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } })}\n\n`, + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + } if (url.endsWith("/responses")) { return Response.json({ id: "resp_opencode_go_session", @@ -54,6 +62,8 @@ async function captureRequest(input: { child?: string; provider?: OcxProviderConfig; nativeChat?: boolean; + claude?: boolean; + metadataUserId?: string; headers?: Record<string, string>; } = {}): Promise<{ url: string; headers: Headers }> { const providerName = input.providerName ?? "opencode-go"; @@ -62,13 +72,26 @@ async function captureRequest(input: { globalThis.fetch = (async (requestInput: RequestInfo | URL, init?: RequestInit) => { const url = String(requestInput); requests.push({ url, headers: new Headers(init?.headers) }); - return upstreamResponse(url); + return upstreamResponse(url, input.claude); }) as typeof fetch; const config = { providers: { [providerName]: input.provider ?? opencodeGo() }, } as unknown as OcxConfig; - const response = input.nativeChat ? await handleChatCompletions( + const response = input.claude ? await handleClaudeMessages( + new Request("http://localhost/v1/messages", { + method: "POST", + headers: input.headers ?? { "content-type": "application/json" }, + body: JSON.stringify({ + model: `${providerName}/${model}`, max_tokens: 64, stream: false, + system: "A shared system prompt is not a conversation identifier.", + messages: [{ role: "user", content: "ping" }], + ...(input.metadataUserId !== undefined ? { metadata: { user_id: input.metadataUserId } } : {}), + }), + }), + config, + { model: "", provider: "" }, + ) : input.nativeChat ? await handleChatCompletions( new Request("http://localhost/v1/chat/completions", { method: "POST", headers: input.headers ?? codexHeaders(input.child), @@ -97,6 +120,118 @@ describe("OpenCode Go session affinity (#3344)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); + test("Claude metadata gives stable Go affinity across turns and distinct conversations", async () => { + const input = { claude: true, model: CHAT_MODEL, metadataUserId: "user_test_account__session_conversation-a" }; + const first = await captureRequest(input); + const continued = await captureRequest(input); + const next = await captureRequest({ ...input, metadataUserId: "user_test_account__session_conversation-b" }); + expect(first.url).toBe("https://opencode.ai/zen/go/v1/chat/completions"); + // Fixed SHA-256 vectors calculated independently of the production helpers. + expect(first.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + expect(continued.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + expect(next.headers.get(SESSION_HEADER)).toBe("ocx_55fec02e7f2c7f9358958ab6d1589530"); + expect(first.headers.get(SESSION_HEADER)).not.toContain("conversation-a"); + }); + + test("Claude recognizes renamed canonical Go destinations and omits shared system affinity", async () => { + const input = { claude: true, model: CHAT_MODEL, providerName: "renamed-go" }; + const metadata = await captureRequest({ ...input, metadataUserId: "user_test_account__session_conversation-a" }); + const desktop = await captureRequest(input); + expect(metadata.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + expect(desktop.headers.has(SESSION_HEADER)).toBe(false); + }); + + test("Claude explicit Go header precedes metadata and matches native Chat affinity", async () => { + const headers = { "content-type": "application/json", [SESSION_HEADER]: "client-session-a" }; + const claude = await captureRequest({ claude: true, model: CHAT_MODEL, headers, metadataUserId: "different-metadata-session" }); + const chat = await captureRequest({ nativeChat: true, model: CHAT_MODEL, headers }); + expect(claude.headers.get(SESSION_HEADER)).toBe("ocx_516d593899f34b7baca2db37c7b0c8c5"); + expect(chat.headers.get(SESSION_HEADER)).toBe("ocx_516d593899f34b7baca2db37c7b0c8c5"); + }); + + test("Claude affinity survives per-model Responses wire selection", async () => { + const input = { claude: true, metadataUserId: "user_test_account__session_conversation-a" }; + const chat = await captureRequest({ ...input, model: CHAT_MODEL }); + const responses = await captureRequest({ ...input, model: MUSE_MODEL }); + expect(responses.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(chat.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + expect(responses.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + const explicit = await captureRequest({ + ...input, model: MUSE_MODEL, + headers: { "content-type": "application/json", [SESSION_HEADER]: "client-session-a" }, + }); + expect(explicit.headers.get(SESSION_HEADER)).toBe("ocx_516d593899f34b7baca2db37c7b0c8c5"); + }); + + for (const [model, url] of [ + [CHAT_MODEL, "https://opencode.ai/zen/go/v1/chat/completions"], + [MUSE_MODEL, "https://opencode.ai/zen/go/v1/responses"], + ] as const) { + test(`Claude ${model} falls back to valid metadata after invalid explicit Go identity`, async () => { + // Interior tab is constructible in HTTP Headers but rejected by the identity owner. + for (const session of ["", " ", "invalid\tidentity", "x".repeat(4097)]) { + const captured = await captureRequest({ + claude: true, model, metadataUserId: "user_test_account__session_conversation-a", + headers: { "content-type": "application/json", [SESSION_HEADER]: session }, + }); + expect(captured.url).toBe(url); + expect(captured.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + const invalidLane = await captureRequest({ + claude: true, model, metadataUserId: "user_test_account__session_conversation-a", + headers: { "content-type": "application/json", session_id: session }, + }); + expect(invalidLane.url).toBe(url); + expect(invalidLane.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + } + }); + + test(`Claude ${model} omits Go affinity without usable metadata identity`, async () => { + for (const metadataUserId of [undefined, "", " \t\n ", "invalid\u0000identity", "x".repeat(4097)]) { + const captured = await captureRequest({ claude: true, model, metadataUserId }); + expect(captured.url).toBe(url); + expect(captured.headers.has(SESSION_HEADER)).toBe(false); + expect(captured.headers.has("session_id")).toBe(false); + } + }); + + test(`Claude ${model} keeps explicit and operator identity with empty metadata`, async () => { + const input = { + claude: true, model, metadataUserId: "", + headers: { "content-type": "application/json", [SESSION_HEADER]: " client-session-a " }, + }; + const explicit = await captureRequest(input); + expect(explicit.url).toBe(url); + expect(explicit.headers.get(SESSION_HEADER)).toBe("ocx_516d593899f34b7baca2db37c7b0c8c5"); + const operator = await captureRequest({ ...input, provider: opencodeGo({ headers: { "X-OpenCode-Session": "operator-session" } }) }); + expect(operator.url).toBe(url); + expect(operator.headers.get(SESSION_HEADER)).toBe("operator-session"); + }); + + test(`Claude ${model} preserves explicit session lanes and operator header precedence`, async () => { + for (const laneHeader of ["session_id", "session-id", "thread-id", "x-codex-parent-thread-id"]) { + const headers = { "content-type": "application/json", [laneHeader]: "native-client-session", [SESSION_HEADER]: "different-fallback" }; + const input = { claude: true, model, headers, metadataUserId: "different-metadata-session" }; + const claude = await captureRequest(input); + expect(claude.url).toBe(url); + expect(claude.headers.get(SESSION_HEADER)).toBe("ocx_a197dbb87311c29a5fbe51140e3845ce"); + const operator = await captureRequest({ ...input, provider: opencodeGo({ headers: { "X-OpenCode-Session": "operator-session" } }) }); + expect(operator.url).toBe(url); + expect(operator.headers.get(SESSION_HEADER)).toBe("operator-session"); + } + }); + } + + test("Claude does not add Go affinity to custom or lookalike destinations", async () => { + for (const baseUrl of ["https://custom.example/v1", "https://opencode.ai.evil.test/zen/go/v1"]) { + const captured = await captureRequest({ + claude: true, model: CHAT_MODEL, providerName: "custom-go", + provider: opencodeGo({ baseUrl }), metadataUserId: "user_test_account__session_conversation-a", + headers: { "content-type": "application/json", [SESSION_HEADER]: "client-session-a" }, + }); + expect(captured.headers.has(SESSION_HEADER)).toBe(false); + } + }); + test("native Chat ingress preserves stable Go affinity and separates conversations", async () => { const provider = opencodeGo(); const input = { nativeChat: true, model: "omen-alpha", provider }; diff --git a/tests/providers/provider-connection-test.test.ts b/tests/providers/provider-connection-test.test.ts index 18f0676668..1a60493cd0 100644 --- a/tests/providers/provider-connection-test.test.ts +++ b/tests/providers/provider-connection-test.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { setFetchCursorUsableModelsForTests } from "../../src/adapters/cursor/live-models"; +import { setFetchQoderModelsForTests } from "../../src/adapters/qoder/live-models"; import { handleManagementAPI } from "../../src/server/management-api"; import { saveConfig } from "../../src/config"; import { OAUTH_PROVIDERS } from "../../src/oauth"; @@ -24,6 +25,7 @@ beforeEach(() => { afterEach(() => { setFetchCursorUsableModelsForTests(null); + setFetchQoderModelsForTests(null); globalThis.fetch = originalFetch; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -54,6 +56,38 @@ async function probe(config: OcxConfig, name: string): Promise<{ status: number; } describe("POST /api/providers/test (WP040 connectivity probe)", () => { + test("Qoder probes the official CLI model list for the configured PAT", async () => { + const calls: Array<{ providerId: string; token: string }> = []; + setFetchQoderModelsForTests((profile, token) => { + calls.push({ providerId: profile.providerId, token }); + return { ok: true, models: ["Qwen3.8-Max", "GLM-5.3"] }; + }); + const config = baseConfig({ + qoder: { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "qoder-pat", authMode: "key", liveModels: true }, + }); + + const { body } = await probe(config, "qoder"); + + expect(body).toMatchObject({ ok: true, models: 2, message: "Connected. 2 models." }); + expect(calls).toEqual([{ providerId: "qoder", token: "qoder-pat" }]); + }); + + test("Qoder CN probes its own CLI profile and PAT", async () => { + const calls: Array<{ providerId: string; token: string }> = []; + setFetchQoderModelsForTests((profile, token) => { + calls.push({ providerId: profile.providerId, token }); + return { ok: true, models: ["Qwen3.8-Flash"] }; + }); + const config = baseConfig({ + "qoder-cn": { adapter: "qoder", baseUrl: "https://qoder.cn", apiKey: "cn-pat", authMode: "key", liveModels: true }, + }); + + const { body } = await probe(config, "qoder-cn"); + + expect(body).toMatchObject({ ok: true, models: 1, message: "Connected. 1 models." }); + expect(calls).toEqual([{ providerId: "qoder-cn", token: "cn-pat" }]); + }); + test("Cursor probes GetUsableModels and reports the live model count", async () => { const calls: { apiKey: string; baseUrl?: string }[] = []; setFetchCursorUsableModelsForTests(async options => { @@ -316,6 +350,40 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { }); }); + test("Nous probe accepts 390 synthetic paid/free rows above 256 KiB (#3939)", async () => { + const payload = JSON.stringify({ + data: Array.from({ length: 390 }, (_, index) => ({ + id: index === 0 ? "tencent/hy3:free" : `vendor/model-${index}`, + metadata: { description: "x".repeat(1_400) }, + })), + }); + const bytes = new TextEncoder().encode(payload).byteLength; + expect(bytes).toBeGreaterThan(262_144); + expect(bytes).toBeLessThan(1_048_576); + let fetches = 0; + globalThis.fetch = (async (input, init) => { + fetches += 1; + expect(String(input)).toBe("https://inference-api.nousresearch.com/v1/models"); + expect(init?.method ?? "GET").toBe("GET"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer access-token-nous-probe-fixture"); + return new Response(payload, { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + await saveCredential("nous", { + access: "access-token-nous-probe-fixture", + refresh: "nous-probe-fixture-refresh", + expires: Date.now() + 3_600_000, + }); + const config = baseConfig({ + nous: { ...structuredClone(OAUTH_PROVIDERS.nous!.providerConfig) }, + }); + + const { status, body } = await probe(config, "nous"); + + expect(status).toBe(200); + expect(fetches).toBe(1); + expect(body).toMatchObject({ ok: true, models: 390 }); + }); + test("Google's models-array response shape is accepted (x-goog-api-key path)", async () => { let requestedUrl = ""; globalThis.fetch = (async (input: RequestInfo | URL) => { diff --git a/tests/providers/provider-model-aliases.test.ts b/tests/providers/provider-model-aliases.test.ts index d326a8562e..5ba8b5981e 100644 --- a/tests/providers/provider-model-aliases.test.ts +++ b/tests/providers/provider-model-aliases.test.ts @@ -217,6 +217,50 @@ describe("provider and model aliases", () => { routeReason: "explicit-provider-namespace", }); }); + test.each(["agy", "AgY"])("canonical provider name %s suppresses a colliding built-in alias", async canonicalName => { + const c = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + [canonicalName]: { + adapter: "openai-chat", + baseUrl: "https://custom.test/v1", + models: ["gemini-3.8-flash"], + liveModels: false, + }, + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + models: ["gemini-3.8-flash"], + liveModels: false, + }, + }, + } as unknown as OcxConfig; + + const models = await gatherRoutedModels(c); + const googleModel = models.find(m => m.provider === "google-antigravity" && m.id === "gemini-3.8-flash")!; + expect(googleModel.providerAlias).toBeNull(); + + const [googleEntry] = buildCatalogEntries(null, [], [googleModel]); + expect(googleEntry!.display_name).toBe("google-antigravity/gemini-3.8-flash"); + expect(routeModel(c, googleEntry!.display_name)).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash", + }); + expect(routeModel(c, `${canonicalName}/gemini-3.8-flash`)).toMatchObject({ + providerName: canonicalName, + modelId: "gemini-3.8-flash", + }); + + // Canonical names stay case-sensitive. A case variant must not activate the + // claimed registry alias; preserve the unknown slash-id default fallback. + expect(routeModel({ ...c, defaultProvider: canonicalName }, "AGY/gemini-3.8-flash")).toMatchObject({ + providerName: canonicalName, + modelId: "AGY/gemini-3.8-flash", + routeReason: "default-provider", + }); + }); test("static gather (liveModels: false) suppresses agy when other provider explicitly owns it", async () => { const c = { port: 10100, diff --git a/tests/providers/provider-model-discovery-contract.test.ts b/tests/providers/provider-model-discovery-contract.test.ts index 494d0e484f..b77a3b6fe5 100644 --- a/tests/providers/provider-model-discovery-contract.test.ts +++ b/tests/providers/provider-model-discovery-contract.test.ts @@ -1,10 +1,12 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { gatherRoutedModels } from "../../src/codex/catalog"; import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/provider-fetch"; -import { clearModelCache, getFreshCached, setCached } from "../../src/codex/model-cache"; +import { clearModelCache, getFreshCached, getProviderDiscoveryStatus, getProviderLiveModelCount, setCached } from "../../src/codex/model-cache"; import { buildModelsRequest } from "../../src/oauth"; +import { saveCredential } from "../../src/oauth/store"; import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../../src/oauth/key-providers"; import { deriveKeyLoginMap, providerConfigSeed } from "../../src/providers/derive"; import { @@ -25,6 +27,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { withStubbedProviderFetch } from "../helpers/catalog-provider-fetch"; import { withRegistryDiscovery } from "../helpers/provider-registry-discovery"; import { fixturePath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const FIXTURE = readFileSync(fixturePath("provider-model-discovery.json"), "utf8"); const originalFetch = globalThis.fetch; @@ -455,6 +458,79 @@ describe("registry-owned provider model discovery", () => { expect(cancelled).toBe(true); }); + describe("Nous native catalog response cap (#3939)", () => { + let previousHome: string | undefined; + let credentialHome: string; + + beforeEach(async () => { + previousHome = process.env.OPENCODEX_HOME; + credentialHome = mkdtempSync(join(tmpdir(), "ocx-nous-discovery-")); + process.env.OPENCODEX_HOME = credentialHome; + clearModelCache("nous"); + await saveCredential("nous", { + access: "access-token-nous-discovery-fixture", + refresh: "nous-discovery-fixture-refresh", + expires: Date.now() + 3_600_000, + }); + }); + + afterEach(() => { + clearModelCache("nous"); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(credentialHome); + }); + + test("gathers and caches 390 synthetic paid/free rows above 256 KiB", async () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "nous"); + if (!entry) throw new Error("missing nous registry entry"); + const payload = JSON.stringify({ + data: Array.from({ length: 390 }, (_, index) => ({ + id: index === 0 ? "tencent/hy3:free" : `vendor/model-${index}`, + metadata: { description: "x".repeat(1_400) }, + })), + }); + const bytes = new TextEncoder().encode(payload).byteLength; + expect(bytes).toBeGreaterThan(262_144); + expect(bytes).toBeLessThan(1_048_576); + + let fetches = 0; + globalThis.fetch = (async (input, init) => { + fetches += 1; + expect(String(input)).toBe("https://inference-api.nousresearch.com/v1/models"); + expect(init?.method ?? "GET").toBe("GET"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer access-token-nous-discovery-fixture"); + return new Response(payload, { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const config = withStubbedProviderFetch<OcxConfig>({ + defaultProvider: "nous", + providers: { nous: { ...providerConfigSeed(entry), models: ["safe-fallback"] } }, + }); + const discovery = resolveProviderModelDiscovery("nous", config.providers.nous!); + expect(discovery.maxResponseBytes).toBe(1_048_576); + expect(discovery.maxModels).toBe(512); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const models = (await gatherRoutedModels(config)).filter(model => model.provider === "nous"); + expect(fetches).toBe(1); + expect(models).toHaveLength(390); + const ids = models.map(model => model.id); + expect(ids).toContain("tencent/hy3:free"); + expect(ids).toContain("vendor/model-1"); + expect(ids).toContain("vendor/model-389"); + expect(ids).not.toContain("safe-fallback"); + // Gather sorts its published rows; the cache retains upstream order. + expect(getFreshCached("nous", 60_000)?.map(model => model.id).sort()).toEqual([...ids].sort()); + expect(getProviderLiveModelCount("nous")).toBe(390); + expect(getProviderDiscoveryStatus("nous")).toEqual({ status: "ok" }); + expect((await gatherRoutedModels(config)).filter(model => model.provider === "nous")).toEqual(models); + expect(fetches).toBe(1); + } finally { + warning.mockRestore(); + } + }); + }); + test("rejects invalid UTF-8 before JSON parsing", async () => { const invalidUtf8Json = new Uint8Array([ 0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xc3, 0x28, 0x22, 0x7d, diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 8045ce649a..697aefc03d 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -32,15 +32,25 @@ function nativeTemplate(): Record<string, unknown> { } const EXPECTED_KEY_PROVIDER_IDS = [ - "anthropic-apikey", "openai-apikey", "meta-model", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", + "anthropic-apikey", "openai-apikey", "meta-model", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "packycode", "bizrouter", "groq", "google", "google-vertex", "azure-openai", "deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "digitalocean", "scaleway", "featherless", "novita", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", + "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", ]; describe("provider registry parity", () => { + test("CodeBuddy static catalogs cover every official CLI-agent model in the bundled 2.143.0 manifest", () => { + const global = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "codebuddy")!); + const cn = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "codebuddy-cn")!); + expect(global.models).toContain("gemini-3.5-flash"); + expect(cn.models).toEqual(expect.arrayContaining([ + "glm-5.0", "glm-5.0-turbo", "glm-5v-turbo", "glm-4.7", "kimi-k2.5", "deepseek-v3-2-volc", + ])); + }); + test("registry ids are unique", () => { const ids = PROVIDER_REGISTRY.map(entry => entry.id); expect(new Set(ids).size).toBe(ids.length); @@ -1246,13 +1256,13 @@ describe("free-provider directory isolation", () => { test("directory metadata never becomes a canonical runtime provider", () => { // The directory is a catalog of endpoints we have not adopted. If its ids reached // PROVIDER_REGISTRY, routedProviderConfig() would canonicalize a user's same-named provider - // onto the directory's adapter and baseUrl — for `qoder` that baseUrl is the empty string, - // so the request would lose its destination entirely. + // onto the directory's adapter and baseUrl, so the request could lose its destination. const directoryOnlyIds = FREE_PROVIDER_DIRECTORY .filter(entry => entry.supportLevel === "reference") .map(entry => entry.id); expect(directoryOnlyIds.length).toBeGreaterThan(0); - expect(directoryOnlyIds).toContain("qoder"); + expect(directoryOnlyIds).not.toContain("qoder"); + expect(directoryOnlyIds).not.toContain("qoder-cn"); const registryIds = new Set(PROVIDER_REGISTRY.map(entry => entry.id)); for (const id of directoryOnlyIds) { @@ -1297,6 +1307,33 @@ describe("free-provider directory isolation", () => { baseUrl: "https://custom.example.test/v1", liveModels: true, }); + expect(routed.provider.adapter).not.toBe("qoder"); + expect(routed.provider.baseUrl).not.toBe("https://qoder.com"); + expect(routed.modelId).toBe("custom-model"); + }); + + test("a custom provider named codebuddy keeps its own destination (preserveCustomDestination)", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "codebuddy", + providers: { + codebuddy: { + adapter: "openai-chat", + baseUrl: "https://custom.codebuddy.example.test/v1", + apiKey: "test-key", + liveModels: true, + }, + }, + }; + + const routed = routeModel(config, "codebuddy/custom-model"); + expect(routed.provider).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://custom.codebuddy.example.test/v1", + liveModels: true, + }); + expect(routed.provider.adapter).not.toBe("codebuddy"); + expect(routed.provider.baseUrl).not.toBe("https://www.codebuddy.ai"); expect(routed.modelId).toBe("custom-model"); }); diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts new file mode 100644 index 0000000000..5411504354 --- /dev/null +++ b/tests/providers/qoder-adapter.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { buildQoderArgs, buildQoderChildEnv, createQoderAdapter } from "../../src/adapters/qoder/adapter"; +import { clearQoderBinaryCache, QODER_CN_PROFILE, QODER_GLOBAL_PROFILE, resolveQoderProfile } from "../../src/adapters/qoder/profiles"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const enc = new TextEncoder(); +beforeEach(() => clearQoderBinaryCache()); + +function provider(overrides: Partial<OcxProviderConfig> = {}): OcxProviderConfig { + return { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "qoder-pat", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], ...overrides } as OcxProviderConfig; +} + +function parsed(overrides: Partial<OcxParsedRequest> = {}): OcxParsedRequest { + return { modelId: "Qwen3.8-Max", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, ...overrides } as OcxParsedRequest; +} + +function fakeChild(frames: string[]): ChildProcess { + const child = new EventEmitter() as ChildProcess & { killed: boolean; exitCode: number | null }; + child.stdout = Readable.from(frames.map(frame => enc.encode(frame))); + child.stderr = Readable.from([]); + child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + setTimeout(() => { child.exitCode = 0; child.emit("close", 0); }, 2); + return child; +} + +describe("qoder adapter", () => { + test("uses only the Global PAT and disables tools, MCP, settings hooks, and persistence", () => { + const env = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "qoder-pat"); + expect(env.QODER_PERSONAL_ACCESS_TOKEN).toBe("qoder-pat"); + expect(Object.keys(env).filter(key => key.startsWith("QODER"))).toEqual(["QODER_PERSONAL_ACCESS_TOKEN"]); + const args = buildQoderArgs(parsed({ options: { reasoning: "high" } }), provider()); + expect(args[args.indexOf("--tools") + 1]).toBe(""); + expect(args[args.indexOf("--setting-sources") + 1]).toBe(""); + expect(args).toContain("--strict-mcp-config"); + expect(args).toContain("--no-session-persistence"); + expect(args[args.indexOf("--reasoning-effort") + 1]).toBe("high"); + expect(args).not.toContain("--dangerously-skip-permissions"); + }); + + test("keeps Global and CN profiles, executables, destinations, and PAT variables isolated", async () => { + expect(resolveQoderProfile("https://qoder.com/")).toBe(QODER_GLOBAL_PROFILE); + expect(resolveQoderProfile("https://qoder.cn/")).toBe(QODER_CN_PROFILE); + expect(QODER_CN_PROFILE.binaryCandidates).toEqual(["qodercn", "qoderclicn"]); + + const globalEnv = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "global-pat"); + const cnEnv = buildQoderChildEnv(QODER_CN_PROFILE, "cn-pat"); + expect(globalEnv.QODER_PERSONAL_ACCESS_TOKEN).toBe("global-pat"); + expect(globalEnv.QODERCN_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + expect(cnEnv.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-pat"); + expect(cnEnv.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + + const spawned: Array<{ executable: string; env: NodeJS.ProcessEnv }> = []; + const runRegion = async (configured: OcxProviderConfig, executable: string) => { + const adapter = createQoderAdapter(configured, { + which: candidate => candidate === executable ? `/bin/${candidate}` : undefined, + spawn: (command, _args, options) => { + spawned.push({ executable: command, env: options.env ?? {} }); + return fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + }, + }); + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, () => {}); + }; + await Promise.all([ + runRegion(provider({ baseUrl: "https://qoder.com", apiKey: "global-pat" }), "qoder"), + runRegion(provider({ baseUrl: "https://qoder.cn", apiKey: "cn-pat" }), "qodercn"), + ]); + expect(spawned).toHaveLength(2); + const global = spawned.find(item => item.executable.endsWith("/qoder"))!; + const cn = spawned.find(item => item.executable.endsWith("/qodercn"))!; + expect(global.env.QODER_PERSONAL_ACCESS_TOKEN).toBe("global-pat"); + expect(global.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + expect(cn.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-pat"); + expect(cn.env.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + }); + + test("fails closed before spawn for a non-canonical destination", async () => { + let spawned = 0; + const adapter = createQoderAdapter(provider({ baseUrl: "https://evil.example.test" }), { which: () => "/bin/qoder", spawn: () => { spawned++; return fakeChild([]); } }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "non_canonical_destination" }); + }); + + test("rejects unverified image input instead of silently dropping or forwarding it", async () => { + let spawned = 0; + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => { spawned++; return fakeChild([]); } }); + const request = parsed({ context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "unsupported_input_modality" }); + }); + + test("maps Qoder credit exhaustion to a non-retryable 429", async () => { + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => fakeChild([ + '{"type":"assistant","message":{"content":[{"type":"text","text":"limit"}]} }\n', + '{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["You reached your credit usage limit"],"error_code":118}\n', + ]), + killGraceMs: 10, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.at(-1)).toMatchObject({ type: "error", status: 429, errorType: "insufficient_quota", code: "insufficient_quota", retryable: false }); + }); +}); diff --git a/tests/providers/qoder-live-models.test.ts b/tests/providers/qoder-live-models.test.ts new file mode 100644 index 0000000000..ba00e378e7 --- /dev/null +++ b/tests/providers/qoder-live-models.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { fetchQoderModels, parseQoderModelList, setFetchQoderModelsForTests } from "../../src/adapters/qoder/live-models"; +import { clearQoderBinaryCache, QODER_CN_PROFILE, QODER_GLOBAL_PROFILE } from "../../src/adapters/qoder/profiles"; +import { fetchProviderModels } from "../../src/codex/catalog/provider-fetch"; +import { clearModelCache, providerCacheGenerations } from "../../src/codex/model-cache"; +import type { OcxProviderConfig } from "../../src/types"; + +beforeEach(() => clearQoderBinaryCache()); +afterEach(() => { + setFetchQoderModelsForTests(null); + clearModelCache("qoder-test"); + providerCacheGenerations.delete("qoder-test"); +}); + +describe("qoder live model discovery", () => { + test("parses the documented plaintext table with validation and dedupe", () => { + expect(parseQoderModelList("MODEL\nQwen3.8-Max\nQwen3.8-Max\nGLM-5.3\n")).toEqual({ ok: true, models: ["Qwen3.8-Max", "GLM-5.3"] }); + expect(parseQoderModelList("warning that is not a roster\n")).toMatchObject({ ok: false, error: "invalid_output" }); + }); + + test("passes PAT only in scoped env and supports Windows cmd shims", async () => { + let seen: { command: string; args: readonly string[]; env?: NodeJS.ProcessEnv } | undefined; + const exec = async (command: string, args: readonly string[], options: { env: Record<string, string> }) => { + seen = { command, args, env: options.env }; + return { stdout: "MODEL\nQwen3.8-Max\n", stderr: "" }; + }; + const result = await fetchQoderModels(QODER_GLOBAL_PROFILE, "secret-pat", { platform: "win32", which: () => "C:\\npm\\qoder.cmd", exec }); + expect(result).toEqual({ ok: true, models: ["Qwen3.8-Max"] }); + expect(seen?.command.toLowerCase()).toContain("cmd.exe"); + expect(seen?.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(seen?.env?.QODER_PERSONAL_ACCESS_TOKEN).toBe("secret-pat"); + }); + + test("CN discovery selects qodercn and passes only the CN PAT variable", async () => { + let seen: { command: string; env: Record<string, string> } | undefined; + const result = await fetchQoderModels(QODER_CN_PROFILE, "cn-secret", { + which: candidate => candidate === "qodercn" ? "/bin/qodercn" : undefined, + exec: async (command, _args, options) => { + seen = { command, env: options.env }; + return { stdout: "MODEL\nQwen3.8-Max\nQwen3.8-Flash\n", stderr: "" }; + }, + }); + expect(result).toEqual({ ok: true, models: ["Qwen3.8-Max", "Qwen3.8-Flash"] }); + expect(seen?.command).toBe("/bin/qodercn"); + expect(seen?.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-secret"); + expect(seen?.env.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + }); + + test("live account roster is authoritative and static models are only fallback", async () => { + setFetchQoderModelsForTests((_profile, token) => token === "pat" ? { ok: true, models: ["Account-Model"] } : { ok: false, error: "auth" }); + const provider = { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "pat", authMode: "key", liveModels: true, models: ["Static-Model"] } as OcxProviderConfig; + const models = await fetchProviderModels("qoder-test", provider, 60_000); + expect(models.map(model => model.id)).toEqual(["Account-Model"]); + }); + + test("a PAT change cannot reuse the previous account's entitlement cache", async () => { + const calls: string[] = []; + setFetchQoderModelsForTests((_profile, token) => { + calls.push(token); + return { ok: true, models: [`${token}-model`] }; + }); + const base = { adapter: "qoder", baseUrl: "https://qoder.com", authMode: "key", liveModels: true } as OcxProviderConfig; + const accountA = await fetchProviderModels("qoder-test", { ...base, apiKey: "account-a" }, 60_000); + const accountB = await fetchProviderModels("qoder-test", { ...base, apiKey: "account-b" }, 60_000); + expect(accountA.map(model => model.id)).toEqual(["account-a-model"]); + expect(accountB.map(model => model.id)).toEqual(["account-b-model"]); + expect(calls).toEqual(["account-a", "account-b"]); + }); + + test("sequential accounts receive only their own PAT and authentication failures are redacted", async () => { + const credentials: string[] = []; + const exec = async (_command: string, _args: readonly string[], options: { env: Record<string, string> }) => { + const token = options.env.QODER_PERSONAL_ACCESS_TOKEN ?? ""; + credentials.push(token); + if (token === "bad-secret") { + throw Object.assign(new Error("auth failed"), { stderr: `Not logged in: QODER_PERSONAL_ACCESS_TOKEN=${token}` }); + } + return { stdout: `MODEL\n${token}-model\n`, stderr: "" }; + }; + const first = await fetchQoderModels(QODER_GLOBAL_PROFILE, "account-a", { which: () => "/bin/qoder", exec }); + const second = await fetchQoderModels(QODER_GLOBAL_PROFILE, "account-b", { which: () => "/bin/qoder", exec }); + const failed = await fetchQoderModels(QODER_GLOBAL_PROFILE, "bad-secret", { which: () => "/bin/qoder", exec }); + expect(credentials).toEqual(["account-a", "account-b", "bad-secret"]); + expect(first).toEqual({ ok: true, models: ["account-a-model"] }); + expect(second).toEqual({ ok: true, models: ["account-b-model"] }); + expect(failed).toMatchObject({ ok: false, error: "auth" }); + expect(JSON.stringify(failed)).not.toContain("bad-secret"); + }); +}); diff --git a/tests/providers/sponsor-presets.test.ts b/tests/providers/sponsor-presets.test.ts new file mode 100644 index 0000000000..c7e28e9e16 --- /dev/null +++ b/tests/providers/sponsor-presets.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { deriveProviderPresets } from "../../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; + +/** + * The registry `sponsor` field is the only thing that marks a paid sponsor, and SPONSORS.md + * promises it changes nothing but picker placement and a label. These pin the wire shape the + * dashboard and `ocx provider presets` read, and that every sponsor entry carries a landing URL. + */ +describe("sponsor presets", () => { + test("registry sponsor entries surface tier and URL on the derived preset", () => { + const sponsors = PROVIDER_REGISTRY.filter(entry => entry.sponsor); + const presets = deriveProviderPresets(); + for (const entry of sponsors) { + const preset = presets.find(p => p.id === entry.id); + expect(preset, entry.id).toBeDefined(); + expect(preset?.sponsor).toBe(entry.sponsor!.tier); + expect(preset?.sponsorUrl).toBe(entry.sponsor!.url); + expect(entry.sponsor!.url.startsWith("https://")).toBe(true); + } + }); + + test("non-sponsor presets carry no sponsor keys at all", () => { + const sponsorIds = new Set(PROVIDER_REGISTRY.filter(entry => entry.sponsor).map(entry => entry.id)); + for (const preset of deriveProviderPresets()) { + if (sponsorIds.has(preset.id)) continue; + expect("sponsor" in preset, preset.id).toBe(false); + expect("sponsorUrl" in preset, preset.id).toBe(false); + } + }); + + test("derived preset order is registry order — pinning is the picker's job", () => { + const ids = deriveProviderPresets().map(p => p.id).filter(id => id !== "custom"); + const registryOrder = PROVIDER_REGISTRY.map(e => e.id).filter(id => ids.includes(id)); + const seen = new Set<string>(); + const deduped = registryOrder.filter(id => (seen.has(id) ? false : (seen.add(id), true))); + expect(ids).toEqual(deduped); + }); +}); diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index 42a17ee9cc..cf4221f727 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -513,7 +513,18 @@ describe("POST /api/stop teardown", () => { test("a 409 does not escalate to a forced kill", () => { // Escalating would run the daemon's cleanup and strip shared config while the foreign // service keeps the proxy alive — the exact hole the ownership gate exists to close. - expect(PROCESS_CONTROL_SOURCE).toContain('if (res.status === 409) return "refused"'); + // The 409 branch may capture the server's reason first (#4023 added a second refusal + // cause), but it must still return "refused" without falling through to !res.ok. + const stopGracefully = sliceFn( + PROCESS_CONTROL_SOURCE, + "export async function stopProxyGracefully(", + "export async function stopProxy(", + ); + const four09At = stopGracefully.indexOf("res.status === 409"); + expect(four09At).toBeGreaterThan(-1); + expect(stopGracefully.slice(four09At)).toContain('return "refused"'); + expect(stopGracefully.indexOf('return "refused"', four09At)) + .toBeLessThan(stopGracefully.indexOf("if (!res.ok) return false;", four09At)); const stopProxyFn = sliceFn(PROCESS_CONTROL_SOURCE, "export async function stopProxy(", "export function killProxy("); const refusedAt = stopProxyFn.indexOf('graceful === "refused"'); diff --git a/tests/providers/xai/xai-oauth-retry.test.ts b/tests/providers/xai/xai-oauth-retry.test.ts index 542b594a3c..b5ecfa0efb 100644 --- a/tests/providers/xai/xai-oauth-retry.test.ts +++ b/tests/providers/xai/xai-oauth-retry.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { postXaiToken, XaiTokenRequestError } from "../../../src/oauth/xai"; +import { discoverXaiOAuthEndpoints, postXaiToken, XaiTokenRequestError } from "../../../src/oauth/xai"; const original=globalThis.fetch; afterEach(()=>{globalThis.fetch=original;}); function queue(items:Array<Response|Error>){let n=0;globalThis.fetch=(async()=>{const x=items[n++]!;if(x instanceof Error)throw x;return x;}) as typeof fetch;return()=>n;} const body={grant_type:"refresh_token",client_id:"client",refresh_token:"secret"}; const ok=()=>new Response(JSON.stringify({access_token:"a",refresh_token:"r",expires_in:3600})); @@ -10,3 +10,43 @@ describe("xAI retry",()=>{ test("permanent 4xx is not retried or leaked",async()=>{const calls=queue([new Response(JSON.stringify({error:"invalid_grant"}),{status:400})]);await expect(postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async()=>{}})).rejects.toBeInstanceOf(XaiTokenRequestError);expect(calls()).toBe(1);}); test("caller abort is not retried",async()=>{const c=new AbortController();c.abort();let calls=0;globalThis.fetch=(async()=>{calls++;throw new DOMException("aborted","AbortError")}) as typeof fetch;await expect(postXaiToken("https://auth.x.ai/token",body,c.signal,{sleep:async()=>{}})).rejects.toMatchObject({name:"AbortError"});expect(calls).toBe(1);}); }); + +describe("xAI Retry-After handling",()=>{ + const ra=(value:string)=>new Response("",{status:429,headers:{"retry-after":value}}); + const WEEKDAYS=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],MONTHS=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + const hm=d=>`${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}:${String(d.getUTCSeconds()).padStart(2,"0")}`; + const rfc850=(d:Date)=>`${WEEKDAYS[d.getUTCDay()]}, ${String(d.getUTCDate()).padStart(2,"0")}-${MONTHS[d.getUTCMonth()]}-${String(d.getUTCFullYear()%100).padStart(2,"0")} ${hm(d)} GMT`; + const asctime=(d:Date)=>`${WEEKDAYS[d.getUTCDay()]!.slice(0,3)} ${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2," ")} ${hm(d)} ${d.getUTCFullYear()}`; + test("429 honors Retry-After seconds beyond the jitter cap",async()=>{const calls=queue([ra("60"),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(calls()).toBe(2);expect(d).toEqual([60000]);}); + test("Retry-After above the 60s budget is terminal, never retried early",async()=>{const calls=queue([ra("3600")]),d:number[]=[];await expect(postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5})).rejects.toMatchObject({status:429});expect(calls()).toBe(1);expect(d).toEqual([]);}); + test("Retry-After one second above the budget is terminal",async()=>{const calls=queue([ra("61")]),d:number[]=[];await expect(postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5})).rejects.toMatchObject({status:429});expect(calls()).toBe(1);expect(d).toEqual([]);}); + test("Retry-After below the old 2s cap is honored exactly",async()=>{const calls=queue([ra("1"),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(d).toEqual([1000]);}); + test("fractional Retry-After is honored",async()=>{const calls=queue([ra("1.5"),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(d).toEqual([1500]);}); + test("HTTP-date Retry-After is honored",async()=>{const calls=queue([ra(new Date(Date.now()+30_000).toUTCString()),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(calls()).toBe(2);expect(d.length).toBe(1);expect(d[0]!).toBeGreaterThan(2000);expect(d[0]!).toBeLessThanOrEqual(30000);}); + test("RFC 850 HTTP-date Retry-After is honored",async()=>{const calls=queue([ra(rfc850(new Date(Date.now()+30_000))),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(calls()).toBe(2);expect(d.length).toBe(1);expect(d[0]!).toBeGreaterThan(2000);expect(d[0]!).toBeLessThanOrEqual(30000);}); + test("asctime HTTP-date Retry-After is honored",async()=>{const calls=queue([ra(asctime(new Date(Date.now()+30_000))),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(calls()).toBe(2);expect(d.length).toBe(1);expect(d[0]!).toBeGreaterThan(2000);expect(d[0]!).toBeLessThanOrEqual(30000);}); + test("unparseable Retry-After falls back to jitter",async()=>{const calls=queue([ra("soon"),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(d).toEqual([100]);}); + test("past HTTP-date falls back to jitter",async()=>{const calls=queue([ra("Sun, 06 Nov 1994 08:49:37 GMT"),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(d).toEqual([100]);}); + test("whitespace-padded seconds are honored",async()=>{const calls=queue([ra(" 2 "),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(d).toEqual([2000]);}); + test("hostile Retry-After vectors fall back to jitter",async()=>{for(const v of ["0","-5","1e3","0x10",""]){const calls=queue([ra(v),ok()]),d:number[]=[];await postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)},random:()=>.5});expect(d).toEqual([100]);expect(calls()).toBe(2);}}); +}); + +describe("xAI abort handling",()=>{ + test("abort with a custom reason is not retried",async()=>{const c=new AbortController();const reason=new Error("user cancel");let calls=0;globalThis.fetch=(async()=>{calls++;c.abort(reason);throw c.signal.reason;}) as typeof fetch;const d:number[]=[];await expect(postXaiToken("https://auth.x.ai/token",body,c.signal,{sleep:async x=>{d.push(x)}})).rejects.toBe(reason);expect(calls).toBe(1);expect(d).toEqual([]);}); + test("token request timeout is terminal",async()=>{let calls=0;globalThis.fetch=(async()=>{calls++;throw new DOMException("timed out","TimeoutError");}) as typeof fetch;const d:number[]=[];await expect(postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async x=>{d.push(x)}})).rejects.toMatchObject({name:"TimeoutError"});expect(calls).toBe(1);expect(d).toEqual([]);}); + test("Bun-shaped timeout abort is terminal",async()=>{const c=new AbortController();let calls=0;globalThis.fetch=(async()=>{calls++;throw new DOMException("The operation was aborted","AbortError");}) as typeof fetch;const d:number[]=[];await expect(postXaiToken("https://auth.x.ai/token",body,c.signal,{sleep:async x=>{d.push(x)}})).rejects.toMatchObject({name:"AbortError"});expect(calls).toBe(1);expect(d).toEqual([]);}); + test("caller aborted before a 429 backoff does not sleep",async()=>{const c=new AbortController();let calls=0;globalThis.fetch=(async()=>{calls++;c.abort();return new Response("",{status:429,headers:{"retry-after":"60"}});}) as typeof fetch;const d:number[]=[];await expect(postXaiToken("https://auth.x.ai/token",body,c.signal,{sleep:async x=>{d.push(x)}})).rejects.toMatchObject({status:429});expect(calls).toBe(1);expect(d).toEqual([]);}); + test("caller abort during a Retry-After wait rejects promptly",async()=>{const c=new AbortController();const reason=new Error("cancel during wait");globalThis.fetch=(async()=>new Response("",{status:429,headers:{"retry-after":"60"}})) as typeof fetch;const d:number[]=[];const pending=postXaiToken("https://auth.x.ai/token",body,c.signal,{sleep:async x=>{d.push(x);await new Promise(()=>{});}});while(!d.length)await Bun.sleep(1);c.abort(reason);await expect(pending).rejects.toBe(reason);expect(d).toEqual([60000]);}); +}); + +describe("xAI endpoint validation",()=>{ + const discover=(authorization_endpoint:string,token_endpoint:string)=>{globalThis.fetch=(async()=>Response.json({authorization_endpoint,token_endpoint})) as typeof fetch;return discoverXaiOAuthEndpoints();}; + test("accepts the live discovery shape",async()=>{const d=await discover("https://auth.x.ai/oauth2/authorize","https://auth.x.ai/oauth2/token");expect(d).toEqual({authorizationEndpoint:"https://auth.x.ai/oauth2/authorize",tokenEndpoint:"https://auth.x.ai/oauth2/token"});}); + test("accepts the allow-listed accounts host",async()=>{const d=await discover("https://accounts.x.ai/oauth2/authorize","https://accounts.x.ai/oauth2/token");expect(d.tokenEndpoint).toBe("https://accounts.x.ai/oauth2/token");}); + test("rejects plain http",async()=>{await expect(discover("http://auth.x.ai/oauth2/authorize","http://auth.x.ai/oauth2/token")).rejects.toThrow(/unexpected endpoint/);}); + test("rejects the apex host",async()=>{await expect(discover("https://x.ai/oauth2/authorize","https://x.ai/oauth2/token")).rejects.toThrow(/unexpected endpoint/);}); + test("rejects unlisted subdomains",async()=>{await expect(discover("https://evil.x.ai/token","https://api.x.ai/token")).rejects.toThrow(/unexpected endpoint/);}); + test("rejects embedded userinfo without echoing it",async()=>{const u="https://u:p@"+"auth.x.ai/oauth2/token";await expect(discover(u,u)).rejects.toThrowError(/unexpected endpoint/);try{await discover(u,u);expect.unreachable();}catch(error){const m=(error as Error).message;expect(m).not.toContain("u:p");expect(m).not.toContain("p@auth");}}); + test("rejects an explicit port",async()=>{await expect(discover("https://auth.x.ai:8443/oauth2/authorize","https://auth.x.ai:8443/oauth2/token")).rejects.toThrow(/unexpected endpoint/);}); + test("malformed discovery URL is a generic Error, not a TypeError",async()=>{await expect(discover("not a url","::")).rejects.toThrowError(/unparseable endpoint URL/);try{await discover("not a url","::");expect.unreachable();}catch(error){expect(error).toBeInstanceOf(Error);expect(error).not.toBeInstanceOf(TypeError);expect(error).not.toBeInstanceOf(XaiTokenRequestError);}}); +}); diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 25876172ff..ee273ef814 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -1585,6 +1585,106 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]?.parameters).toEqual({ ...parameters, type: "object" }); }); + test("drops unicode property-escape patterns on the codex forward path", () => { + // Claude Code 2.1.265 puts `\p{Cc}` in the `pattern` of its built-in Artifact tool. The + // ChatGPT backend compiles `pattern` with Python `re`, which has no property escapes, and + // answers "Invalid schema for function 'Artifact': … is not a 'regex'" — so every request + // from such a client fails, whether or not the tool is ever called. + const field = '^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}"\\\\./[\\]]{1,200}$'; + const collection = "^(?!\\.\\.?(?:/|$))[A-Za-z0-9_\\-.~:@+]{1,200}$"; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: [], + tools: [{ + type: "function", + name: "Artifact", + parameters: { + type: "object", + properties: { + field: { type: "string", pattern: field }, + collection: { type: "string", pattern: collection }, + }, + }, + }], + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Array<{ name: string; parameters: { properties: Record<string, Record<string, unknown>> } }>; + }; + const properties = body.tools[0]?.parameters.properties; + + expect(body.tools).toHaveLength(1); + expect(body.tools[0]?.name).toBe("Artifact"); + expect(properties.field.pattern).toBeUndefined(); + expect(properties.field.type).toBe("string"); + // Lookaheads compile under Python `re`, so only the incompatible pattern is dropped. + expect(properties.collection.pattern).toBe(collection); + }); + + test("leaves a closed regex-keyed object alone on the codex forward path", () => { + // Dropping this matcher would leave `additionalProperties: false` forbidding every key it + // covered, and `minProperties: 1` would make the object admit nothing — a dictionary tool + // silently reduced to an empty-object-only tool. The schema goes out as written instead, so + // a destination that compiles ECMA regexes still works and one that cannot names the regex. + const parameters = { + type: "object", + patternProperties: { "^\\p{L}+$": { type: "string" } }, + additionalProperties: false, + minProperties: 1, + }; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: [], + tools: [{ type: "function", name: "Label", parameters }], + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Array<{ name: string; parameters: unknown }>; + }; + + expect(body.tools).toHaveLength(1); + expect(body.tools[0]?.parameters).toEqual(parameters); + }); + + + test.each(["allOf", "not", "oneOf"] as const)("Responses wire preserves composed %s argument constraints", kind => { + const matcher = "^\\p{L}+$"; + const parameters = kind === "allOf" ? { + type: "object", minProperties: 1, unevaluatedProperties: false, + allOf: [{ patternProperties: { [matcher]: { type: "string" } } }], + } : kind === "not" ? { + type: "object", required: ["value"], + properties: { value: { not: { type: "string", pattern: matcher } } }, + } : { + type: "object", required: ["value"], + properties: { value: { oneOf: [{ type: "string", pattern: matcher }, { const: "123" }] } }, + }; + const original = JSON.stringify(parameters); + // Witnesses are accepted before normalization: {name:"ok"} evaluates its sole key in + // allOf; {value:"123"} fails the letter pattern, satisfying not or exactly one branch. + expect(new RegExp(matcher, "u").test("name")).toBe(true); + expect(new RegExp(matcher, "u").test("123")).toBe(false); + + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", context: { messages: [] }, stream: true, options: {}, + _rawBody: { model: "test-model", input: [], tools: [{ type: "function", name: "Composed", parameters }] }, + }, { headers: new Headers() }); + const wire = JSON.parse(request.body) as { tools: Array<{ parameters: unknown }> }; + expect(wire.tools).toHaveLength(1); + expect(wire.tools[0].parameters).toEqual(JSON.parse(original)); + expect(JSON.stringify(parameters)).toBe(original); + }); + test("model reasoning-summary opt-out strips unsupported delivery fields (#323)", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", diff --git a/tests/responses/passthrough-abort.test.ts b/tests/responses/passthrough-abort.test.ts index 6985c6cd54..9550b88863 100644 --- a/tests/responses/passthrough-abort.test.ts +++ b/tests/responses/passthrough-abort.test.ts @@ -81,7 +81,7 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { expect(sseBranch).toContain("rewriteBlocks: clientBlockRewrite"); // Elsewhere the failed-tail relay converts mid-stream resets into a clean response.failed. expect(sseBranch).toMatch( - /relaySseWithFailedTail\(\s*rewrittenBody,\s*upstream,\s*reason\s*=>\s*clientGone\.abort\(reason\),\s*\{\s*upstreamError:\s*logCtx\.upstreamError\s*\},\s*\)/, + /relaySseWithFailedTail\(\s*rewrittenBody,\s*upstream,\s*reason\s*=>\s*\{\s*responseCompletionCancelled\s*=\s*true;\s*clientGone\.abort\(reason\);\s*\},\s*\{\s*upstreamError:\s*logCtx\.upstreamError\s*\},\s*\)/, ); expect(sseBranch).toContain("new Response(clientBody"); expect(sseBranch).toContain("markNativePassthroughSseResponse"); diff --git a/tests/responses/responses-account-label.test.ts b/tests/responses/responses-account-label.test.ts index 4c06acd961..d051508edc 100644 --- a/tests/responses/responses-account-label.test.ts +++ b/tests/responses/responses-account-label.test.ts @@ -191,6 +191,65 @@ describe("Responses account usage attribution", () => { } }); + test("late WS quota from a replaced pool credential cannot repopulate cleared state", async () => { + const originalWebSocket = globalThis.WebSocket; + let releaseFinalQuota!: () => void; + const finalQuotaAllowed = new Promise<void>(resolve => { releaseFinalQuota = resolve; }); + try { + await withPoolHome(async () => { + savePoolCredential("pool-ws-replaced"); + class MetadataSocket { + listeners = new Map<string, Array<(event: unknown) => void>>(); + constructor() { queueMicrotask(() => this.emit("open", {})); } + addEventListener(type: string, listener: (event: unknown) => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + removeEventListener(type: string, listener: (event: unknown) => void) { + this.listeners.set(type, (this.listeners.get(type) ?? []).filter(value => value !== listener)); + } + emit(type: string, event: unknown) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + send() { + const payload = (value: unknown) => this.emit("message", { data: JSON.stringify(value) }); + queueMicrotask(() => { + payload({ type: "codex.rate_limits", rate_limits: { + primary: { used_percent: 10, window_minutes: 10080 }, + } }); + payload({ type: "response.created", response: { id: "quota-response" } }); + void finalQuotaAllowed.then(() => { + payload({ type: "codex.rate_limits", rate_limits: { + primary: { used_percent: 100, window_minutes: 10080 }, + } }); + payload({ type: "response.completed", response: { id: "quota-response", status: "completed", output: [] } }); + }); + }); + } + close() { this.emit("close", {}); } + } + globalThis.WebSocket = MetadataSocket as unknown as typeof WebSocket; + globalThis.fetch = (async () => { throw new Error("unexpected HTTP request"); }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: true }), + }), poolConfig(["pool-ws-replaced"]), { model: "", provider: "" }, { + codexWsRuntimeIdentity: "1.4.0", + }); + expect(getAccountQuota("pool-ws-replaced")?.weeklyPercent).toBe(10); + + savePoolCredential("pool-ws-replaced"); + clearAccountQuota("pool-ws-replaced"); + releaseFinalQuota(); + await response.text(); + + expect(getAccountQuota("pool-ws-replaced")).toBeNull(); + }); + } finally { + releaseFinalQuota(); + globalThis.WebSocket = originalWebSocket; + } + }); + test("main-pool and legacy added accounts carry their effective labels", async () => { await withPoolHome(async home => { writeFileSync(join(home, "auth.json"), JSON.stringify({ diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 2a1f69be1e..f703a1899e 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1,3 +1,5 @@ +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; /** * Issue #422: a Responses-shaped wire does not imply support for Codex's private * `compaction_trigger` item. Only the canonical ChatGPT backend speaks that @@ -10,6 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; import { OPAQUE_COMPACTION_NOTE, SUMMARY_PREFIX } from "../../src/responses/compaction"; +import { externalTaskInputContent } from "../../src/responses/task-input"; import { looksLikeBackendCiphertext } from "../../src/server/responses/encrypted-payload"; import * as adapterResolveModule from "../../src/server/adapter-resolve"; import * as visionModule from "../../src/vision"; @@ -35,6 +38,8 @@ import { supportsNativeResponsesCompactEndpoint } from "../../src/providers/open import type { RequestLogContext } from "../../src/server/request-log"; import { acquireNativeMainProfileDrain, tryAdmitTurn } from "../../src/server/lifecycle"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { clearComboRecallForTests, recallComboForLane, rememberComboForLane } from "../../src/server/responses/combo-session-recall"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; @@ -949,6 +954,90 @@ describe("compact alternate-account attempt (#913)", () => { }); } + for (const version of ["v1", "v2"] as const) { + test(`${version} recalled native combo reselects the current account and respects admission refusal`, async () => { + await withPoolEnv("ocx-combo-recall-account-", async config => { + clearComboRecallForTests(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + config.combos = { native: { targets: [{ provider: "openai", model: "gpt-5.5" }] } }; + config.codexAccountNamespaces = { side: "pool-a" }; + const headers = { session_id: "account-recall" }; + const accounts: Array<string | null> = []; + // Fix the selected account deterministically while retaining the real credential + // and admission owner; an explicit namespace still owns its account selection. + const resolver = authContextModule.resolveCodexAuthContext; + const authSpy = spyOn(authContextModule, "resolveCodexAuthContext").mockImplementation( + (incoming, liveConfig, mode, options = {}) => resolver(incoming, liveConfig, mode, { + ...options, accountId: options.accountId ?? liveConfig.activeCodexAccountId, + }), + ); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + accounts.push(request.headers.get("chatgpt-account-id")); + const body = await request.json() as { input?: Array<{ type?: string }> }; + if (request.url.endsWith("/responses/compact")) { + return jsonResponse({ output: [{ type: "compaction", encrypted_content: "native-recall-ciphertext" }] }); + } + const compact = Array.isArray(body.input) && body.input.some(item => item.type === "compaction_trigger"); + return sseResponse([{ type: "response.completed", response: { + ...completedPayload("native answer"), model: "gpt-5.5", + ...(compact ? { output: [{ type: "compaction", encrypted_content: "native-recall-ciphertext" }] } : {}), + } }]); + }) as typeof fetch; + const client = new AbortController(); + let completionTimer: ReturnType<typeof setTimeout> | undefined; + try { + let complete!: () => void; + const completed = new Promise<void>(resolve => { complete = resolve; }); + const seedWork = (async () => { + const seed = await handleResponses(compactionRequest({ model: "combo/native", stream: true, input: "hello" }, client.signal, headers), + config, { model: "", provider: "" }, { onResponseComplete: complete, abortSignal: client.signal }); + expect(seed.status).toBe(200); + await seed.text(); + await completed; + })(); + await Promise.race([ + seedWork, + new Promise<never>((_, reject) => { + completionTimer = setTimeout(() => reject(new Error("native combo seed did not complete")), 10_000); + }), + ]); + clearTimeout(completionTimer); + completionTimer = undefined; + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "account-recall" })), "gpt-5.5")).toBe("native"); + config.activeCodexAccountId = "pool-b"; + const compact = version === "v1" ? handleResponsesCompact : handleResponses; + const log: RequestLogContext = { model: "", provider: "" }; + const response = await compact(compactionRequest(baseCompactionBody({ model: "gpt-5.5", stream: true }), client.signal, headers), config, log); + expect(response.status).toBe(200); + await response.text(); + expect(log.comboId).toBe("native"); + expect(accounts).toEqual(["pool_acc_a", "pool_acc_b"]); + + const explicitLog: RequestLogContext = { model: "", provider: "" }; + const explicit = await compact(compactionRequest(baseCompactionBody({ model: "side/gpt-5.5", stream: true }), client.signal, headers), config, explicitLog); + expect(explicit.status).toBe(200); + await explicit.text(); + expect(explicitLog.comboId).toBeUndefined(); + expect(accounts.at(-1)).toBe("pool_acc_a"); + const sends = accounts.length; + authSpy.mockRejectedValue(new authContextModule.CodexMainProfileDrainingError()); + const refused = await compact(compactionRequest(baseCompactionBody({ model: "gpt-5.5", stream: true }), client.signal, headers), config, { model: "", provider: "" }); + expect(refused.status).toBe(503); + expect(accounts).toHaveLength(sends); + } finally { + if (completionTimer !== undefined) clearTimeout(completionTimer); + client.abort(); + authSpy.mockRestore(); + clearComboRecallForTests(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + } + }); + }); + } + for (const [model, account] of [["gpt-5.5", "pool-a"], ["side/gpt-5.5", "pool-b"]] as const) { test(`native 404 falls back to canonical SSE with ${model} account and session identity`, async () => { await withPoolEnv("ocx-compact-404-canonical-", async config => { @@ -1693,6 +1782,419 @@ describe("compact alternate-account attempt (#913)", () => { }); }); +describe("compaction combo recall after combo switch (#3891)", () => { + afterEach(() => clearComboRecallForTests()); + + function comboTestConfig(): OcxConfig { + return { + defaultProvider: "gw", + providers: { + gw: { + adapter: "openai-chat", + baseUrl: "https://gw-primary.example/v1", + authMode: "key", + apiKey: "key-gw", + models: ["gpt-5.6-terra"], + }, + alt: { + adapter: "openai-chat", + baseUrl: "https://gw-alt.example/v1", + authMode: "key", + apiKey: "key-alt", + models: ["gpt-5.6-luna"], + }, + }, + combos: { + terra: { strategy: "failover", targets: [{ provider: "gw", model: "gpt-5.6-terra" }] }, + }, + } as unknown as OcxConfig; + } + + function chatCompletionPayload(text: string): Record<string, unknown> { + return { + choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }; + } + + // The routed compact turn dispatches combo children as SSE (stream is forced + // when route.combo is set), so streaming-capable mocks answer the chat wire. + function chatStreamResponse(text: string): Response { + return new Response([ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: text }, finish_reason: null }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`, + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + } + + test("bare native model after combo switch routes through the remembered combo", async () => { + const calls: Array<{ url: string; body: Record<string, unknown> }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown> }); + return jsonResponse(chatCompletionPayload("handoff summary")); + }) as typeof fetch; + + const config = comboTestConfig(); + const laneHeaders = { "session_id": "lane-combo-recall" }; + + // Step 1: an ordinary combo turn succeeds, populating the recall map. + const comboRes = await handleResponses( + compactionRequest({ model: "combo/terra", stream: false, input: "hello" }, undefined, laneHeaders), + config, + { model: "", provider: "" }, + ); + expect(comboRes.status).toBe(200); + + // Step 2: compaction arrives with the bare native model on the same lane. + const logCtx: RequestLogContext = { model: "", provider: "" }; + const res = await handleResponses( + compactionRequest(baseCompactionBody({ model: "gpt-5.6-terra" }), undefined, laneHeaders), + config, + logCtx, + ); + + expect(res.status).toBe(200); + expect(logCtx.provider).toBe("combo"); + expect(logCtx.comboId).toBe("terra"); + expect(logCtx.requestedModel).toBe("combo/terra"); + const json = await res.json() as { output?: Array<{ type?: string }> }; + expect((json.output ?? []).filter(item => item.type === "compaction").length).toBe(1); + }); + + test("v1 /responses/compact takes the same recall path", async () => { + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + const body = await request.json() as { stream?: boolean }; + return body.stream === true + ? chatStreamResponse("handoff summary") + : jsonResponse(chatCompletionPayload("handoff summary")); + }) as typeof fetch; + + const config = comboTestConfig(); + const laneHeaders = { "session_id": "lane-compact-recall" }; + + const comboRes = await handleResponses( + compactionRequest({ model: "combo/terra", stream: false, input: "hello" }, undefined, laneHeaders), + config, + { model: "", provider: "" }, + ); + expect(comboRes.status).toBe(200); + + const logCtx: RequestLogContext = { model: "", provider: "" }; + const compactRes = await handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.6-terra" }), undefined, laneHeaders), + config, + logCtx, + ); + + expect(compactRes.status).toBe(200); + expect(logCtx.provider).toBe("combo"); + }); + + test("a different lane does not borrow the remembered combo", async () => { + globalThis.fetch = (async () => jsonResponse(chatCompletionPayload("handoff summary"))) as typeof fetch; + + const config = comboTestConfig(); + + // Populate recall on lane A. + await handleResponses( + compactionRequest({ model: "combo/terra", stream: false, input: "hello" }, undefined, { "session_id": "lane-A" }), + config, + { model: "", provider: "" }, + ); + + // Compaction on lane B: the bare model should NOT be rewritten to the combo. + // It falls through to the compaction default-provider fallback (#2901) and lands on gw. + const logCtx: RequestLogContext = { model: "", provider: "" }; + const res = await handleResponses( + compactionRequest(baseCompactionBody({ model: "gpt-5.6-terra" }), undefined, { "session_id": "lane-B" }), + config, + logCtx, + ); + + expect(res.status).toBe(200); + expect(logCtx.provider).toBe("gw"); + expect(logCtx.comboId).toBeUndefined(); + }); + + test("a non-matching bare model is not rewritten", async () => { + globalThis.fetch = (async () => jsonResponse(chatCompletionPayload("handoff summary"))) as typeof fetch; + + const config = comboTestConfig(); + const laneHeaders = { "session_id": "lane-no-match" }; + + await handleResponses( + compactionRequest({ model: "combo/terra", stream: false, input: "hello" }, undefined, laneHeaders), + config, + { model: "", provider: "" }, + ); + + // Bare model "gpt-5.6-luna" does not match terra combo target "gpt-5.6-terra". + const logCtx: RequestLogContext = { model: "", provider: "" }; + const res = await handleResponses( + compactionRequest(baseCompactionBody({ model: "gpt-5.6-luna" }), undefined, laneHeaders), + config, + logCtx, + ); + + expect(res.status).toBe(200); + expect(logCtx.provider).toBe("gw"); + expect(logCtx.comboId).toBeUndefined(); + }); + + test("recall routes before the bare model can 404 without an openai provider", async () => { + // Maintainer review: with no canonical openai row, the bare model dies in + // routeCompactionModel before any combo logic unless the recall rewrite + // also reaches the routed identity, not only the raw body model. + const config = { + defaultProvider: "openai", + providers: { + gw: { + adapter: "openai-chat", + baseUrl: "https://gw-primary.example/v1", + authMode: "key", + apiKey: "key-gw", + models: ["gpt-5.6-terra"], + }, + }, + combos: { + terra: { strategy: "failover", targets: [{ provider: "gw", model: "gpt-5.6-terra" }] }, + }, + } as unknown as OcxConfig; + const bodies: Array<Record<string, unknown>> = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + const body = await request.json() as Record<string, unknown>; + bodies.push(body); + return body.stream === true + ? chatStreamResponse("handoff summary") + : jsonResponse(chatCompletionPayload("handoff summary")); + }) as typeof fetch; + + const laneHeaders = { "session_id": "lane-recall-404" }; + const comboRes = await handleResponses( + compactionRequest({ model: "combo/terra", stream: false, input: "hello" }, undefined, laneHeaders), + config, + { model: "", provider: "" }, + ); + expect(comboRes.status).toBe(200); + + const logCtx: RequestLogContext = { model: "", provider: "" }; + const res = await handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.6-terra" }), undefined, laneHeaders), + config, + logCtx, + ); + + expect(res.status).toBe(200); + expect(logCtx.provider).toBe("combo"); + expect(logCtx.comboId).toBe("terra"); + // The internal combo turn goes out streaming through the combo dispatch. + expect(bodies[1]!.stream).toBe(true); + await res.text(); + }); + + test("recall keeps a native-compact target on the combo /responses path", async () => { + // CodeRabbit review: the recalled target itself can live on a provider + // that supports the native /responses/compact endpoint. Without the + // routed identity sync, the bare model would go straight to the native + // compact endpoint and bypass combo dispatch entirely. + const config = { + defaultProvider: "openai-apikey", + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "test-key", + }, + }, + combos: { + terra: { strategy: "failover", targets: [{ provider: "openai-apikey", model: "gpt-5.6-terra" }] }, + }, + } as unknown as OcxConfig; + const calls: Array<{ url: string; body: Record<string, unknown> }> = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/responses/compact")) { + return Response.json({ detail: "Not Found" }, { status: 404 }); + } + calls.push({ url: request.url, body: await request.json() as Record<string, unknown> }); + return calls.at(-1)!.body.stream === true + ? sseResponse([{ type: "response.completed", response: { ...completedPayload("handoff summary"), model: "gpt-5.6-terra" } }]) + : jsonResponse({ ...completedPayload("handoff summary"), model: "gpt-5.6-terra" }); + }) as typeof fetch; + + const laneHeaders = { "session_id": "lane-recall-native" }; + const comboRes = await handleResponses( + compactionRequest({ model: "combo/terra", stream: false, input: "hello" }, undefined, laneHeaders), + config, + { model: "", provider: "" }, + ); + expect(comboRes.status).toBe(200); + + const logCtx: RequestLogContext = { model: "", provider: "" }; + const res = await handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.6-terra" }), undefined, laneHeaders), + config, + logCtx, + ); + + expect(res.status).toBe(200); + expect(logCtx.provider).toBe("combo"); + expect(logCtx.comboId).toBe("terra"); + // Both upstream calls take the plain /responses path; the native compact + // endpoint (which this provider supports) must never be hit. + expect(calls.map(call => call.url)).toEqual([ + "https://api.openai.com/v1/responses", + "https://api.openai.com/v1/responses", + ]); + expect(calls[1]!.body.stream).toBe(true); + await res.text(); + }); + + function installRecallChatFixture(): void { + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const body = await new Request(input, init).json() as { stream?: boolean }; + return body.stream ? chatStreamResponse("summary") : jsonResponse(chatCompletionPayload("answer")); + }) as typeof fetch; + } + + async function seedRecall(config: OcxConfig, lane: string | undefined = "recall-lane"): Promise<void> { + const response = await handleResponses(compactionRequest( + { model: "combo/terra", stream: false, input: "hello" }, undefined, + lane ? { session_id: lane } : {}, + ), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ status: "completed", model: "gpt-5.6-terra" }); + } + + for (const version of ["v1", "v2"] as const) { + const compact = version === "v1" ? handleResponsesCompact : handleResponses; + const dispatch = async (config: OcxConfig, model: string, lane: string | undefined = "recall-lane") => { + const log: RequestLogContext = { model: "", provider: "" }; + const response = await compact(compactionRequest(baseCompactionBody({ model }), undefined, + lane ? { session_id: lane } : {}), config, log); + expect(response.status).toBe(200); + await response.text(); + return log; + }; + + test(`${version} explicit bare nativeAlias beats a different remembered combo`, async () => { + installRecallChatFixture(); + const config = comboTestConfig(); + config.combos!.explicit = { + alias: "gpt-5.6-terra", nativeAlias: true, + targets: [{ provider: "alt", model: "gpt-5.6-luna" }], + }; + await seedRecall(config); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "recall-lane" })), "gpt-5.6-terra")).toBe("terra"); + const log = await dispatch(config, "gpt-5.6-terra"); + expect(log.comboId).toBe("explicit"); + expect(log.resolvedModel).toBe("gpt-5.6-luna"); + }); + + test(`${version} explicit provider and combo selectors beat recall`, async () => { + installRecallChatFixture(); + const config = comboTestConfig(); + config.combos!.explicit = { targets: [{ provider: "alt", model: "gpt-5.6-luna" }] }; + await seedRecall(config); + expect((await dispatch(config, "alt/gpt-5.6-luna")).provider).toBe("alt"); + expect((await dispatch(config, "combo/explicit")).comboId).toBe("explicit"); + }); + + for (const mutation of ["delete", "rename", "replace-target", "delete-provider", "disable-provider"] as const) { + test(`${version} ${mutation} invalidates remembered ownership before fallback`, async () => { + installRecallChatFixture(); + const config = comboTestConfig(); + await seedRecall(config); + // The default is distinct from the original target and remains usable. + config.defaultProvider = "alt"; + if (mutation === "rename") config.combos!.renamed = config.combos!.terra!; + if (mutation === "delete" || mutation === "rename") delete config.combos!.terra; + if (mutation === "replace-target") config.combos!.terra!.targets = [{ provider: "alt", model: "gpt-5.6-luna" }]; + if (mutation === "delete-provider") delete config.providers.gw; + if (mutation === "disable-provider") config.providers.gw!.disabled = true; + const log = await dispatch(config, "gpt-5.6-terra"); + expect(log.comboId).toBeUndefined(); + expect(log.provider).toBe("alt"); + }); + } + + test(`${version} missing and sibling lanes cannot borrow a completed selection`, async () => { + installRecallChatFixture(); + const config = comboTestConfig(); + await seedRecall(config); + expect((await dispatch(config, "gpt-5.6-terra", "sibling")).comboId).toBeUndefined(); + // Empty lane explicitly omits the header (undefined would use the helper default). + expect((await dispatch(config, "gpt-5.6-terra", "")).comboId).toBeUndefined(); + clearComboRecallForTests(); + await seedRecall(config, ""); + expect((await dispatch(config, "gpt-5.6-terra")).comboId).toBeUndefined(); + }); + + test(`${version} recall expires at thirty minutes and evicts the oldest of 257 lanes`, async () => { + installRecallChatFixture(); + const config = comboTestConfig(); + let now = 100_000; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + await seedRecall(config); + now += 30 * 60 * 1000 - 1; + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "recall-lane" })), "gpt-5.6-terra")).toBe("terra"); + now += 1; + expect((await dispatch(config, "gpt-5.6-terra")).comboId).toBeUndefined(); + const target = { provider: "gw", model: "gpt-5.6-terra" }; + for (let index = 0; index < 257; index += 1) { + rememberComboForLane(sessionLaneIdFromRequest(new Headers({ session_id: `lane-${index}` })), "terra", target, "gpt-5.6-terra", captureConfigGeneration()); + } + expect((await dispatch(config, "gpt-5.6-terra", "lane-0")).comboId).toBeUndefined(); + expect((await dispatch(config, "gpt-5.6-terra", "lane-1")).comboId).toBe("terra"); + expect((await dispatch(config, "gpt-5.6-terra", "lane-256")).comboId).toBe("terra"); + } finally { + clock.mockRestore(); + } + }); + + test(`${version} virtual Pro target recalls the emitted base model`, async () => { + const config = comboTestConfig(); + config.providers["openai-apikey"] = { + adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", apiKey: "test-key", + }; + config.combos!.terra!.targets = [{ provider: "openai-apikey", model: "gpt-5.6-terra-pro" }]; + const calls: Array<{ url: string; body: Record<string, unknown> }> = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + const body = await request.json() as Record<string, unknown>; + calls.push({ url: request.url, body }); + const completed = { ...completedPayload("summary"), model: "gpt-5.6-terra" }; + return body.stream ? sseResponse([{ type: "response.completed", response: completed }]) : jsonResponse(completed); + }) as typeof fetch; + await seedRecall(config); + expect(calls[0]!.body).toMatchObject({ model: "gpt-5.6-terra", reasoning: { mode: "pro" } }); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "recall-lane" })), "gpt-5.6-terra-pro")).toBeUndefined(); + expect((await dispatch(config, "gpt-5.6-terra")).comboId).toBe("terra"); + expect(calls.every(call => call.url.endsWith("/responses"))).toBe(true); + }); + + test(`${version} recalled combo resolves the current key rather than retaining a credential`, async () => { + const config = comboTestConfig(); + const auth: Array<string | null> = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + auth.push(request.headers.get("authorization")); + const body = await request.json() as { stream?: boolean }; + return body.stream ? chatStreamResponse("summary") : jsonResponse(chatCompletionPayload("answer")); + }) as typeof fetch; + await seedRecall(config); + config.providers.gw!.apiKey = "key-current"; + expect((await dispatch(config, "gpt-5.6-terra")).comboId).toBe("terra"); + expect(auth).toEqual(["Bearer key-gw", "Bearer key-current"]); + }); + } + +}); + test("a no-eligible policy compact request persists the evaluation trace", async () => { const config = { ...keyProviderConfig(), @@ -1890,9 +2392,27 @@ describe("external task-input envelopes (#3735)", () => { expect(captured[0]!.messages).toEqual([{ role: "user", content: "plaintext task" }]); }); + test("an empty or null call_id is task input, not a rejection (#3807 supersedes)", async () => { + // These two shapes were in the invalid list above until #3807 showed they are the same + // seed as the absent-field form: neither value can pair with a `function_call`, and a + // Codex desktop sub-agent seed emitted with an explicit `call_id: null` was answered + // 400 for a turn that is really external task input. A wrong-TYPED key stays rejected. + const captured: Array<Record<string, unknown>> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ id: "chat_seed", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); + }) as typeof fetch; + for (const callId of [null, ""]) { + captured.length = 0; + const res = await handleResponses(compactionRequest(body({ ...external("seeded task"), call_id: callId })), + keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + await res.text(); + expect(captured[0]!.messages).toEqual([{ role: "user", content: "seeded task" }]); + } + }); + const invalid: Array<[string, Record<string, unknown>]> = [ - ["empty call id", { ...external(), call_id: "" }], - ["null call id", { ...external(), call_id: null }], ["numeric call id", { ...external(), call_id: 42 }], ["incomplete metadata", { ...external(), namespace: "" }], ["custom output", { ...external(), type: "custom_tool_call_output" }], @@ -2167,3 +2687,49 @@ describe("unpaired tool result boundary (#3259)", () => { expect(bodies[0]).not.toContain("undefined"); }); }); + +describe("unusable-call_id task-input seed (#3807)", () => { + const seed = (extra: Record<string, unknown>) => ({ + type: "function_call_output", id: "fc_seed", name: "create_thread", namespace: "codex", + output: "<codex_delegation>continue</codex_delegation>", ...extra, + }); + + test("a seed carrying call_id: null is admitted as task input", () => { + // `null` is not a pairing key, so the item is the same external seed the absent-field + // form already carries. Rejecting it produced the reported 400 on clients that emit + // the field explicitly. + expect(externalTaskInputContent(seed({ call_id: null }))).toBe("<codex_delegation>continue</codex_delegation>"); + }); + + test("a seed carrying an empty-string call_id is admitted identically", () => { + expect(externalTaskInputContent(seed({ call_id: "" }))).toBe("<codex_delegation>continue</codex_delegation>"); + expect(externalTaskInputContent(seed({ call_id: " " }))).toBe("<codex_delegation>continue</codex_delegation>"); + }); + + test("the absent-field form still works (no regression on a73bb160f)", () => { + expect(externalTaskInputContent(seed({}))).toBe("<codex_delegation>continue</codex_delegation>"); + }); + + test("a REAL call_id is still a paired tool result, never task input", () => { + // The pairing key is what separates a tool result from a seed. Admitting a paired + // result as user text would silently drop a real tool round-trip. + expect(externalTaskInputContent(seed({ call_id: "call_1" }))).toBeUndefined(); + }); + + test("a non-string, non-null call_id stays rejected", () => { + // A numeric id is malformed input, not the absent-pairing seed shape; it keeps the + // #3259 rejection so a wrong-typed key cannot reach a translating adapter. + expect(externalTaskInputContent(seed({ call_id: 42 }))).toBeUndefined(); + expect(externalTaskInputContent(seed({ call_id: {} }))).toBeUndefined(); + }); + + test("every other #3735 validation still holds with an unusable call_id", () => { + // The relaxation is ONLY about the pairing key. Envelope completeness, blank output, + // and opaque ciphertext keep their existing rejections. + expect(externalTaskInputContent({ type: "function_call_output", call_id: null, output: "x" })).toBeUndefined(); + expect(externalTaskInputContent(seed({ call_id: null, namespace: "" }))).toBeUndefined(); + expect(externalTaskInputContent(seed({ call_id: null, output: " " }))).toBeUndefined(); + expect(externalTaskInputContent(seed({ call_id: null, output: [] }))).toBeUndefined(); + expect(externalTaskInputContent(seed({ call_id: null, output: [{ type: "input_image", image_url: 42 }] }))).toBeUndefined(); + }); +}); diff --git a/tests/responses/responses-opaque-blob-recovery.test.ts b/tests/responses/responses-opaque-blob-recovery.test.ts index cf26431381..87eb74f923 100644 --- a/tests/responses/responses-opaque-blob-recovery.test.ts +++ b/tests/responses/responses-opaque-blob-recovery.test.ts @@ -154,6 +154,23 @@ function serializedOutboundWithEncryptedAgentMessage(): string { return JSON.stringify({ model: "model-a", input: agentMessageReplayInput() }); } +/** + * What a routed destination receives on the retry: recovery has replaced the undecryptable + * part with an omission marker, which leaves the item entirely plaintext, so the adapter + * converts it into the public user message a routed Responses schema can accept. + */ +function recoveredAgentMessage(): Record<string, unknown> { + return { + type: "message", + role: "user", + content: [ + { type: "input_text", text: 'Agent message {"author":"/root/child_task","recipient":"/root"}' }, + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "input_text", text: "[encrypted content omitted]" }, + ], + }; +} + function config(): OcxConfig { return { defaultProvider: "first", @@ -550,15 +567,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(outbound).toHaveLength(4); const retriedInput = outbound.at(3)?.input as Array<Record<string, unknown>> | undefined; - expect(retriedInput?.at(0)).toEqual({ - type: "agent_message", - author: "/root/child_task", - recipient: "/root", - content: [ - { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, - { type: "input_text", text: "[encrypted content omitted]" }, - ], - }); + expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); expect(retriedInput?.at(1)).toEqual(agentMessageReplayInput().at(1)); expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["transient-5xx", "opaque-blob-rejection"]); }); @@ -580,15 +589,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); const retriedInput = outbound.at(1)?.input as Array<Record<string, unknown>> | undefined; - expect(retriedInput?.at(0)).toEqual({ - type: "agent_message", - author: "/root/child_task", - recipient: "/root", - content: [ - { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, - { type: "input_text", text: "[encrypted content omitted]" }, - ], - }); + expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); }); test("recovers a zero-output error-event decrypt failure before client relay", async () => { @@ -608,15 +609,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); const retriedInput = outbound.at(1)?.input as Array<Record<string, unknown>> | undefined; - expect(retriedInput?.at(0)).toEqual({ - type: "agent_message", - author: "/root/child_task", - recipient: "/root", - content: [ - { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, - { type: "input_text", text: "[encrypted content omitted]" }, - ], - }); + expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); }); for (const streamMode of ["legacy-tee", "eager-relay"] as const) { @@ -751,15 +744,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); const retriedInput = outbound.at(1)?.input as Array<Record<string, unknown>> | undefined; - expect(retriedInput?.at(0)).toEqual({ - type: "agent_message", - author: "/root/child_task", - recipient: "/root", - content: [ - { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, - { type: "input_text", text: "[encrypted content omitted]" }, - ], - }); + expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); }); test("absent Content-Type decrypt stream does not recover a non-stream request", async () => { diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 0debca2d0a..12ad3d9082 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -1020,8 +1020,6 @@ describe("external task-input envelopes (#3735)", () => { { name: "blank name", item: { type: "function_call_output", id: "i", name: "", namespace: "ns", output: "ok" } }, { name: "missing namespace", item: { type: "function_call_output", id: "i", name: "n", output: "ok" } }, { name: "blank namespace", item: { type: "function_call_output", id: "i", name: "n", namespace: "\t", output: "ok" } }, - { name: "empty call_id", item: { type: "function_call_output", call_id: "", id: "i", name: "n", namespace: "ns", output: "ok" } }, - { name: "null call_id", item: { type: "function_call_output", call_id: null, id: "i", name: "n", namespace: "ns", output: "ok" } }, { name: "number call_id", item: { type: "function_call_output", call_id: 1, id: "i", name: "n", namespace: "ns", output: "ok" } }, { name: "custom_tool_call_output", item: { type: "custom_tool_call_output", id: "i", name: "n", namespace: "ns", output: "ok" } }, { @@ -1096,6 +1094,27 @@ describe("external task-input envelopes (#3735)", () => { expect(parsed.context.messages.some((message) => message.role === "toolResult")).toBe(true); }); + test.each([ + { name: "empty call_id", callId: "" }, + { name: "null call_id", callId: null }, + ])("$name is a seed on the user path, not a tool result (#3807 supersedes)", ({ callId }) => { + // These rows asserted a toolResult until #3807: neither value can pair with a + // `function_call`, so a client that emits the field explicitly was carrying the same + // seed as the absent-field form and had it answered 400 downstream. A wrong-TYPED + // key ("number call_id" above) is malformed input and keeps its rejection. + // + // A whitespace-only `call_id` is deliberately absent from this table: it satisfies the + // schema's `z.string().min(1)`, so functionCallOutputItemSchema claims the item and + // strips id/name/namespace before the parser runs. The helper admits it (covered in + // responses-compaction-routing), but the envelope never survives to reach it here. + const parsed = parseFrozen([{ + type: "function_call_output", call_id: callId, + id: "i", name: "n", namespace: "ns", output: "ok", + }]); + expect(parsed.context.messages).toMatchObject([{ role: "user", content: "ok" }]); + expect(parsed.context.messages.some((message) => message.role === "toolResult")).toBe(false); + }); + test("own and inherited call_id properties are helper-ineligible", () => { const base = { type: "function_call_output", diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index 969dbde912..2fc90e766f 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -3106,21 +3106,26 @@ describe("Responses previous_response_id state", () => { // Two proxies sharing one config dir race every tick. Reporting the loser's ENOENT as a // failure would tell an operator a file is "in use or locked" when nobody holds it. const old = new Date(Date.now() - 60 * 60 * 1_000); - const path = join(home, "responses-state.json.ocx.9104.1.tmp"); + const deadPid = findDeadPid(); + expect(deadPid).not.toBe(process.pid); + const path = join(home, `responses-state.json.ocx.${deadPid}.1.tmp`); writeFileSync(path, "private state"); utimesSync(path, old, old); + const unlinked: string[] = []; const result = recoverStaleResponseStateTemps(home, { isProcessAlive: () => false, bootTime: () => 0, - unlink: () => { + unlink: target => { + unlinked.push(target); const error = new Error("gone") as NodeJS.ErrnoException; error.code = "ENOENT"; throw error; }, }); - expect(result).toMatchObject({ matched: 1, removed: 1, failed: 0 }); + expect(result).toMatchObject({ matched: 1, eligible: 1, removed: 1, failed: 0 }); + expect(unlinked).toEqual([path]); }); test("a dry run reports exactly what a reclaim then removes", () => { diff --git a/tests/routing/combo-child-headers.test.ts b/tests/routing/combo-child-headers.test.ts index 16fb99d4d3..27c9e89960 100644 --- a/tests/routing/combo-child-headers.test.ts +++ b/tests/routing/combo-child-headers.test.ts @@ -17,6 +17,8 @@ describe("combo child request headers", () => { const parent = new Request("http://127.0.0.1:10100/v1/responses", { method: "POST", headers: { + authorization: "Bearer fixture", + "chatgpt-account-id": "caller-account", "content-type": "application/json", "content-encoding": "zstd", }, @@ -40,6 +42,8 @@ describe("combo child request headers", () => { ).rejects.toThrow(/Unknown frame descriptor|Invalid JSON|Unexpected token/i); const fixedHeaders = buildComboChildHeaders(parent.headers); + expect(fixedHeaders.has("authorization")).toBe(false); + expect(fixedHeaders.has("chatgpt-account-id")).toBe(false); expect(fixedHeaders.has("content-length")).toBe(false); expect(fixedHeaders.has("content-encoding")).toBe(false); const childDecoded = await readJsonRequestBody( diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index 205c70b872..1883c1277d 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -5,6 +5,7 @@ import { RequestPacingQueueOverloadError } from "../../src/providers/request-pac import type { OcxConfig } from "../../src/types"; import { beginRequestAttempt, type RequestLogContext } from "../../src/server/request-log"; import type { RouteDecisionTraceV1 } from "../../src/routing/trace"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates, @@ -47,6 +48,36 @@ function seedAttempt(logCtx: RequestLogContext, provider: string, model: string) } describe("policy candidate fallback", () => { + test("policy hops retain only the original sidecar snapshot outside primary headers", async () => { + const authorization = `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "sidecar-account" })}`; + const initial = request(); + const headers = new Headers(initial.headers); + headers.set("authorization", authorization); + headers.set("chatgpt-account-id", "sidecar-account"); + const log = { model: "", provider: "" } as RequestLogContext; + const snapshots: unknown[] = []; + const primaryAuth: Array<string | null> = []; + const response = await handleResponsesWithPolicyFallback(new Request(initial, { headers }), { + port: 0, defaultProvider: "provider-a", providers: {}, + }, log, {}, { + runCore: async (req, _config, context, options) => { + snapshots.push(options.openAiSidecarAuth); + primaryAuth.push(req.headers.get("authorization")); + context.routeDecision = policyTrace(); + return snapshots.length === 1 + ? Response.json({ error: { message: "retry next candidate" } }, { status: 503 }) + : Response.json({ status: "completed" }); + }, + }); + expect(response.status).toBe(200); + expect(primaryAuth).toEqual([authorization, null]); + expect(snapshots).toEqual([ + { authorization, chatgptAccountId: "sidecar-account" }, + { authorization, chatgptAccountId: "sidecar-account" }, + ]); + expect(snapshots[1]).toBe(snapshots[0]); + }); + test("ranks only eligible untried candidates by score and stable original order", () => { const ranked = rankPolicyFallbackCandidates(policyTrace(), new Set(["provider-a\u0000model-a"])); expect(ranked.map(candidate => `${candidate.provider}/${candidate.model}`)).toEqual([ @@ -137,16 +168,26 @@ describe("policy candidate fallback", () => { const trace = policyTrace(); const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; const seenModels: string[] = []; + const seenAuthorization: Array<string | null> = []; + const seenAccountIds: Array<string | null> = []; const seenTerminalCodes: Array<string | undefined> = []; let bodyAcceptedCount = 0; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, { + const initialRequest = request(); + const initialHeaders = new Headers(initialRequest.headers); + initialHeaders.set("authorization", "Bearer fixture"); + initialHeaders.set("chatgpt-account-id", "caller-account"); + const credentialedRequest = new Request(initialRequest, { headers: initialHeaders }); + + const response = await handleResponsesWithPolicyFallback(credentialedRequest, {} as OcxConfig, logCtx, { onRequestBodyRead: () => { bodyAcceptedCount += 1; }, }, { runCore: async (req, _config, childLog, options) => { options.onRequestBodyRead?.(); + seenAuthorization.push(req.headers.get("authorization")); + seenAccountIds.push(req.headers.get("chatgpt-account-id")); const body = await req.json() as { model: string }; seenModels.push(body.model); seenTerminalCodes.push(childLog.terminalErrorCode); @@ -170,6 +211,8 @@ describe("policy candidate fallback", () => { expect(response.status).toBe(200); expect(bodyAcceptedCount).toBe(1); expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]); + expect(seenAuthorization).toEqual(["Bearer fixture", null]); + expect(seenAccountIds).toEqual(["caller-account", null]); expect(seenTerminalCodes).toEqual([undefined, undefined]); expect(logCtx.requestedModel).toBe("policy/daily"); expect(logCtx.routeDecision).toBe(trace); diff --git a/tests/routing/subagent-fallback-handle-responses.test.ts b/tests/routing/subagent-fallback-handle-responses.test.ts index 0eb1cac2d4..e3fcb0c82a 100644 --- a/tests/routing/subagent-fallback-handle-responses.test.ts +++ b/tests/routing/subagent-fallback-handle-responses.test.ts @@ -182,6 +182,30 @@ function installPoolCredential(accountId: string, chatgptAccountId: string, now: }); } +function storedMainFallbackAuthorization() { + const accountId = "normalization-main-account"; + const token = `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1_000) + 86_400 })) + .toString("base64url")}.signature`; + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: token, account_id: accountId }, + })); + let claims = 0; + const options: NonNullable<Parameters<typeof handleResponses>[3]> = { + admission: { kind: "environment", source: "bearer" }, + turnAdmissionLease: { + release() {}, + beginCodexAccountSelection() { + return { + mainProfileDraining: false, + claimMainProfile: () => { claims += 1; return true; }, + release() {}, + }; + }, + }, + }; + return { accountId, token, options, headers: { authorization: "Bearer normalization-admission" }, claims: () => claims }; +} + function isCodexModelsFetch(input: unknown): boolean { try { const url = new URL(String(input)); @@ -814,6 +838,7 @@ describe("subagent fallback final-route normalization", () => { }); test("routed primary falling back to native gpt-5.5 clamps max effort to xhigh", async () => { + const credentials = storedMainFallbackAuthorization(); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", subagentModelFallback: ["gpt-5.5"], @@ -849,8 +874,9 @@ describe("subagent fallback final-route normalization", () => { stream: false, reasoning: { effort: "max" }, }, - {}, + credentials.options, logCtx, + credentials.headers, ); expect(response.status).toBe(200); @@ -861,9 +887,12 @@ describe("subagent fallback final-route normalization", () => { }; expect(body.model).toBe("gpt-5.5"); expect(body.reasoning?.effort).toBe("xhigh"); + expect(capture.auths).toEqual([`Bearer ${credentials.token}`]); + expect(credentials.claims()).toBeGreaterThan(0); }); test("routed primary falling back to native gpt-5.6 keeps real max effort", async () => { + const credentials = storedMainFallbackAuthorization(); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", subagentModelFallback: ["gpt-5.6-terra"], @@ -888,18 +917,20 @@ describe("subagent fallback final-route normalization", () => { noteSubagentModelFailure("grok-4.5", "429", cfg); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array<string | null> }; - mockUpstream(capture, { "Bearer caller-codex-token": ["gpt-5.6-terra"] }); + mockUpstream(capture, { [credentials.accountId]: ["gpt-5.6-terra"] }); const response = await postSpawn(cfg, { model: "xai/grok-4.5", input: readableAgentInput(), stream: false, reasoning: { effort: "max" }, - }); + }, credentials.options, { model: "", provider: "" }, credentials.headers); expect(response.status).toBe(200); const body = JSON.parse(capture.bodies[0]!) as { reasoning?: { effort?: string } }; expect(body.reasoning?.effort).toBe("max"); + expect(capture.auths).toEqual([`Bearer ${credentials.token}`]); + expect(credentials.claims()).toBeGreaterThan(0); }); test("native primary falling back to routed does not receive a native clamp", async () => { @@ -941,6 +972,7 @@ describe("subagent fallback final-route normalization", () => { }); test("routed primary falls back to native and preserves encrypted task passthrough", async () => { + const credentials = storedMainFallbackAuthorization(); resetSubagentModelFallbackStateForTests(); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -963,13 +995,13 @@ describe("subagent fallback final-route normalization", () => { }); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array<string | null> }; - mockUpstream(capture, { "Bearer caller-codex-token": ["gpt-5.6-terra"] }); + mockUpstream(capture, { [credentials.accountId]: ["gpt-5.6-terra"] }); const response = await postSpawn(cfg, { model: "xai/grok-4.5", input: encryptedAgentInput(), stream: false, - }); + }, credentials.options, { model: "", provider: "" }, credentials.headers); if (response.status !== 200) { const body = await response.text(); @@ -977,6 +1009,8 @@ describe("subagent fallback final-route normalization", () => { } expect(capture.urls.some((url) => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); expect(capture.bodies[0]).toContain(FERNET_TASK); + expect(capture.auths).toEqual([`Bearer ${credentials.token}`]); + expect(credentials.claims()).toBeGreaterThan(0); }); test("native primary falls back to routed for readable child tasks", async () => { @@ -2079,6 +2113,44 @@ describe("account-gated retry entitlement boundary", () => { expect(entitlementCalls).toBe(3); }); + test.each(["absent", "present"])("a shadow rewrite cannot restore an opaque source bearer (explicit account: %s)", async accountHeader => { + const hasAccount = accountHeader === "present"; + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + const cfg = retryConfig(); + cfg.shadowCallIntercept = { enabled: true, model: `openai/${model}`, sourceModels: ["gpt-5.6-luna"] }; + let entitlementCalls = 0; + let callerRosterReads = 0; + const observed: Array<{ authorization: string | null; accountId: string | null }> = []; + globalThis.fetch = (async (input, init) => { + const headers = new Headers(init?.headers); + if (new URL(String(input)).pathname.endsWith("/models")) { + callerRosterReads += 1; + return Response.json({ models: [{ slug: model, supported_in_api: true, visibility: "list" }] }); + } + observed.push({ authorization: headers.get("authorization"), accountId: headers.get("chatgpt-account-id") }); + return observed.length === 1 ? unsupportedCodexModelResponse(model) + : Response.json({ id: "unexpected-source-credential-retry", status: "completed", output: [] }); + }) as typeof fetch; + const response = await postDirectCodex(cfg, { model: "gpt-5.6-luna", input: "hello", stream: false }, { + admission: { kind: "environment", source: "dedicated" }, + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + return entitlementCalls === 1 ? entitlementSnapshot({ "pool-a": [model] }) + : entitlementSnapshot({ "pool-a": ["gpt-5.6-sol"] }); + }, + }, { + authorization: "Bearer source-route-token", + ...(hasAccount ? { "chatgpt-account-id": "source-route-account" } : {}), + }); + await response.arrayBuffer(); + expect(observed).toEqual([{ authorization: "Bearer pool-a_token", accountId: "pool_acc_a" }]); + expect(callerRosterReads).toBe(0); + expect(entitlementCalls).toBeGreaterThan(1); + expect(response.status).toBe(400); + }); + test("a first-refresh programmer error cancels the 400 and releases its quota probe", async () => { const cooldownAt = 1_800_000_000_000; const probeAt = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS; diff --git a/tests/server/port-reclaim.test.ts b/tests/server/port-reclaim.test.ts index 56833cf490..930a7866c9 100644 --- a/tests/server/port-reclaim.test.ts +++ b/tests/server/port-reclaim.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from "bun:test"; -import { reclaimListenPort } from "../../src/server/port-reclaim"; +import { describe, expect, spyOn, test } from "bun:test"; +import { reclaimListenPort, type ReclaimListenPortOptions } from "../../src/server/port-reclaim"; import { isBareIpv6Address, parseTcpQuadsForLocalPort, @@ -7,6 +7,20 @@ import { } from "../../src/server/windows-tcp-drop"; import { parseListenPidsFromNetstat } from "../../src/server/port-reclaim"; +/** Exercise several scans and the deadline without depending on wall-clock scheduling. */ +async function reclaimWithMockClock(options: ReclaimListenPortOptions): Promise<boolean> { + let now = 1_000; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + return await reclaimListenPort(10100, "127.0.0.1", { + ...options, timeoutMs: 50, intervalMs: 10, scanIntervalMs: 10, + sleepMs: async () => { now += 10; }, + }); + } finally { + clock.mockRestore(); + } +} + describe("parseListenPidsFromNetstat", () => { test("extracts Windows LISTENING owners for the local port", () => { const output = [ @@ -299,14 +313,11 @@ describe("reclaimListenPort", () => { expect(killed).toEqual([4242]); }); - test("allowlisted pid with failing ocx revalidation is still killed (trusted teardown PID)", async () => { + test("skips kill across later scans when allowlisted pid fails revalidation", async () => { const killed: number[] = []; let available = false; let checks = 0; - await expect(reclaimListenPort(10100, "127.0.0.1", { - timeoutMs: 200, - intervalMs: 20, - scanIntervalMs: 20, + await expect(reclaimWithMockClock({ dropTcpRows: false, killOcxHolders: true, onlyKillPids: [100], @@ -315,29 +326,25 @@ describe("reclaimListenPort", () => { isAliveFn: () => !available, verifyOcxFn: pid => { checks += 1; - // First pass (scan identity) succeeds; later scans reclassify as non-ocx. - // Allowlisted teardown PIDs still take the best-effort kill path. + // Scan identity succeeds, then pre-kill revalidation and later scans reject it. return checks === 1 ? pid : null; }, killFn: pid => { killed.push(pid); available = true; }, - sleepMs: async () => {}, - })).resolves.toBe(true); - expect(killed).toEqual([100]); + })).resolves.toBe(false); + expect(checks).toBeGreaterThanOrEqual(3); + expect(killed).toEqual([]); }); - test("allowlisted revalidation failure still permits TCP drop after kill", async () => { + test("does not drop TCP rows across later scans after allowlisted revalidation fails", async () => { const killed: number[] = []; const dropped: number[] = []; let alive = true; let available = false; let checks = 0; - await expect(reclaimListenPort(10100, "127.0.0.1", { - timeoutMs: 200, - intervalMs: 20, - scanIntervalMs: 20, + await expect(reclaimWithMockClock({ dropTcpRows: true, killOcxHolders: true, onlyKillPids: [100], @@ -357,19 +364,16 @@ describe("reclaimListenPort", () => { available = true; return { dropped: 1, skippedIpv6: 0, accessDenied: 0 }; }, - sleepMs: async () => {}, - })).resolves.toBe(true); - expect(killed).toEqual([100]); - expect(dropped).toEqual([10100]); + })).resolves.toBe(false); + expect(checks).toBeGreaterThanOrEqual(3); + expect(killed).toEqual([]); + expect(dropped).toEqual([]); }); - test("does not drop TCP rows while allowlisted non-ocx survives kill", async () => { + test("does not kill or drop TCP rows for an allowlisted non-ocx listener", async () => { const killed: number[] = []; const dropped: number[] = []; - await expect(reclaimListenPort(10100, "127.0.0.1", { - timeoutMs: 80, - intervalMs: 20, - scanIntervalMs: 20, + await expect(reclaimWithMockClock({ dropTcpRows: true, killOcxHolders: true, onlyKillPids: [100], @@ -384,9 +388,8 @@ describe("reclaimListenPort", () => { dropped.push(port); return { dropped: 1, skippedIpv6: 0, accessDenied: 0 }; }, - sleepMs: async () => {}, })).resolves.toBe(false); - expect(killed).toEqual([100]); + expect(killed).toEqual([]); expect(dropped).toEqual([]); }); @@ -525,20 +528,17 @@ describe("reclaimListenPort", () => { expect(dropped).toEqual([]); }); - test("allowlisted PID that fails ocx verify still gets killed and does not block TCP drop", async () => { + test("allowlisted PID that fails ocx verify stays protected until the deadline", async () => { const killed: number[] = []; const dropped: number[] = []; let alive = true; let available = false; - await expect(reclaimListenPort(10100, "127.0.0.1", { - timeoutMs: 200, - intervalMs: 20, - scanIntervalMs: 20, + await expect(reclaimWithMockClock({ dropTcpRows: true, killOcxHolders: true, onlyKillPids: [14772], isAvailableFn: async () => available, - // Windows often keeps a dead pre-update owner listed; cmdline probe already failed. + // This holder is still alive; a historical PID does not override verifier rejection. listListenPidsFn: () => (alive ? [14772] : []), isAliveFn: () => alive, verifyOcxFn: () => null, @@ -551,9 +551,40 @@ describe("reclaimListenPort", () => { available = true; return { dropped: 1, skippedIpv6: 0, accessDenied: 0 }; }, - sleepMs: async () => {}, + })).resolves.toBe(false); + expect(killed).toEqual([]); + expect(dropped).toEqual([]); + }); + + test.each([false, true])("a different verifier PID is rejected with killAllOcxOnPort=%s", async killAllOcxOnPort => { + const killed: number[] = []; + const dropped: number[] = []; + await expect(reclaimWithMockClock({ + dropTcpRows: true, killOcxHolders: true, killAllOcxOnPort, onlyKillPids: [100], + isAvailableFn: async () => false, listListenPidsFn: () => [100], isAliveFn: () => true, + verifyOcxFn: () => 200, + killFn: pid => { killed.push(pid); }, + dropTcpFn: port => { dropped.push(port); return 1; }, + })).resolves.toBe(false); + expect(killed).toEqual([]); + expect(dropped).toEqual([]); + }); + + test("a later successful verification can reclaim a previously rejected holder", async () => { + let alive = true; + let available = false; + let checks = 0; + const checksAtKill: number[] = []; + const dropped: number[] = []; + await expect(reclaimWithMockClock({ + dropTcpRows: true, killOcxHolders: true, onlyKillPids: [100], + isAvailableFn: async () => available, listListenPidsFn: () => alive ? [100] : [], + isAliveFn: () => alive, + verifyOcxFn: pid => ++checks === 1 ? null : pid, + killFn: () => { checksAtKill.push(checks); alive = false; }, + dropTcpFn: port => { dropped.push(port); available = true; return 1; }, })).resolves.toBe(true); - expect(killed).toEqual([14772]); + expect(checksAtKill).toEqual([3]); // rejected scan, accepted scan, accepted pre-kill check expect(dropped).toEqual([10100]); }); diff --git a/tests/server/relay-eager.test.ts b/tests/server/relay-eager.test.ts index e7be8ba57c..ad4d4e458e 100644 --- a/tests/server/relay-eager.test.ts +++ b/tests/server/relay-eager.test.ts @@ -1617,6 +1617,38 @@ describe("relaySseEagerBounded — error paths", () => { expect(rec.synthetics).toEqual([]); expect(rec.dones).toBe(1); }); + + test("(090-13) bare upstream error event at clean EOF passes upstream_error reason to onSynthetic", async () => { + // A { type: "error" } bare error SSE frame arrives, then the upstream closes cleanly. + // The relay emits an upstreamErrorTailFrame rather than an adapterEofIncompleteFrame. + // onSynthetic must receive reason="upstream_error" so callers can distinguish a semantic + // upstream failure from a plain body-read reset (which carries no reason argument). + const up = controlledUpstream(); + const syntheticCalls: Array<[string, string | undefined]> = []; + const rec090 = { dones: 0 }; + const inspector090 = createSseInspector({}); + const hooks090: EagerRelayHooks = { + inspectChunk: c => inspector090.feed(c), + finishInspection: () => inspector090.finish(), + disposeInspection: () => inspector090.dispose(), + sawTerminal: () => inspector090.reported(), + onSynthetic: (kind, reason) => syntheticCalls.push([kind, reason]), + onClientCancel: () => {}, + onDone: () => { rec090.dones += 1; }, + }; + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks090); + const bareErrorPayload = JSON.stringify({ type: "error", message: "provider stream failed" }); + up.push(sse(bareErrorPayload)); + up.close(); + const out = await readAll(relayed); + await settle(); + + expect(out.match(/event: response\.failed/g)?.length).toBe(1); + expect(out).not.toContain("response.incomplete"); + expect(out).toContain("provider stream failed"); + expect(syntheticCalls).toEqual([["failed", "upstream_error"]]); + expect(rec090.dones).toBe(1); + }); }); describe("createSseInspector — extraction locks (h)", () => { diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index e5b7570f2a..eb040a3b97 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -1,3 +1,4 @@ +import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; import { logsFromApiBody } from "../helpers/logs-api"; import { @@ -42,7 +43,9 @@ import { import { clearCursorThreadContinuityForTests } from "../../src/adapters/cursor/thread-continuity"; import { COMPACT_PROMPT, encodeCompactionSummary } from "../../src/responses/compaction"; import { clearKeyCooldowns } from "../../src/providers/key-failover"; -import { consumeComboFailure } from "../../src/server/responses/core"; +import { consumeComboFailure, createChildPassthroughCallbackGate } from "../../src/server/responses/core"; +import { clearComboRecallForTests, recallComboForLane, reconcileComboRecall } from "../../src/server/responses/combo-session-recall"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; // Full-suite Windows load: startServer + combo rename/delete management flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -143,6 +146,7 @@ beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "ocx-combo-030-")); process.env.OPENCODEX_HOME = testDir; clearComboSelectionState(); + clearComboRecallForTests(); clearComboTargetCooldowns(); clearKeyCooldowns(); clearCodexUpstreamHealth(); @@ -175,6 +179,7 @@ afterEach(async () => { isolatedCodexHome = null; if (testDir) removeTreeWithRetry(testDir); clearComboSelectionState(); + clearComboRecallForTests(); clearComboTargetCooldowns(); clearKeyCooldowns(); clearCodexUpstreamHealth(); @@ -437,6 +442,289 @@ function heldNativeTerminal(payload: Record<string, unknown>) { } describe("server combo failover 030 activation matrix", () => { + test("recall completion has an independent gate slot and publishes once only on commit", () => { + const calls: string[] = []; + const gate = createChildPassthroughCallbackGate({ + onNativePassthroughTerminal: status => calls.push(status), + onResponseComplete: model => calls.push(model), + }); + gate.onTerminal("completed"); + gate.onResponseComplete("final-model"); + expect(calls).toEqual([]); + gate.commit(); + gate.commit(); + gate.onResponseComplete("duplicate-model"); + expect(calls).toEqual(["completed", "final-model"]); + }); + + for (const rejection of ["discard", "failed", "incomplete", "cancel"] as const) { + test(`recall gate drops pre-commit completion on ${rejection}`, () => { + const models: string[] = []; + const gate = createChildPassthroughCallbackGate({ onResponseComplete: model => models.push(model) }); + gate.onResponseComplete("unaccepted-model"); + if (rejection === "discard") gate.discard(); + else if (rejection === "cancel") gate.onCancel(); + else gate.onTerminal(rejection); + gate.commit(); + gate.onResponseComplete("late-model"); + expect(models).toEqual([]); + }); + } + + for (const wire of ["native", "chat", "runTurn"] as const) { + for (const stream of [false, true]) { + for (const terminal of ["completed", "failed", "incomplete"] as const) { + test(`${wire} ${stream ? "SSE" : "JSON"} ${terminal} B replaces A only after completed response`, async () => { + const encode = (events: Array<Record<string, unknown>>) => events.map(event => + `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""); + const upstream = serve(async request => { + const body = await request.json() as { model: string; stream?: boolean }; + if (body.model === "m1") return Response.json(responsesSuccess("A", "m1")); + if (wire === "native") { + const response = { ...responsesSuccess("B output", "final-b"), status: terminal }; + return stream ? new Response(encode([ + { type: "response.output_text.delta", delta: "B output", item_id: "msg_b", output_index: 0, content_index: 0 }, + { type: `response.${terminal}`, response }, + ]), { headers: { "content-type": "text/event-stream" } }) : Response.json(response); + } + if (stream) { + if (terminal === "failed") return chatErrorStream("failed after output", "B output"); + return new Response([ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: "B output" }, finish_reason: null }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: terminal === "incomplete" ? "length" : "stop" }] })}\n\n`, + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + } + if (terminal === "failed") return Response.json({ error: { message: "failed B" } }); + return Response.json({ choices: [{ index: 0, message: { role: "assistant", content: "B output" }, finish_reason: terminal === "incomplete" ? "length" : "stop" }] }); + }); + customRunTurn = async (_parsed, _incoming, emit) => { + emit({ type: "text_delta", text: "B output" }); + if (terminal === "failed") emit({ type: "error", message: "failed after output" }); + else emit({ type: "done", ...(terminal === "incomplete" ? { stopReason: "length" } : {}) }); + }; + const config = comboConfig({ + a: provider("openai-responses", baseUrl(upstream), "key-a"), + b: provider(wire === "native" ? "openai-responses" : wire === "chat" ? "openai-chat" : "test-run-turn", baseUrl(upstream), "key-b"), + }); + config.combos = { + alpha: { targets: [{ provider: "a", model: "m1" }] }, + beta: { targets: [{ provider: "b", model: "m2" }] }, + }; + const headers = { session_id: "terminal-recall" }; + const a = await post(config, { model: "combo/alpha" }, {}, headers); + expect(await a.json()).toMatchObject({ status: "completed", model: "m1" }); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "terminal-recall" })), "m1")).toBe("alpha"); + const models: string[] = []; + const completed = deferred(); + const b = await post(config, { model: "combo/beta", stream }, { + onResponseComplete: model => { models.push(model); completed.resolve(); }, + }, headers); + const body = await b.text(); + if (terminal === "completed") { + await within(completed.promise); + const expected = wire === "native" ? "final-b" : "m2"; + expect(body).toContain(`"model":"${expected}"`); + expect(models).toEqual([expected]); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "terminal-recall" })), expected)).toBe("beta"); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "terminal-recall" })), "m1")).toBeUndefined(); + } else { + expect(models).toEqual([]); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "terminal-recall" })), "m1")).toBe("alpha"); + } + }); + } + } + } + + for (const recordTerminalOutcomes of [true, false]) { + for (const scenario of ["completed", "missing-model", "empty-model", "failed-first", "incomplete-first", "undeclared-tool"] as const) { + test(`native SSE recall ${scenario} with terminal recording ${recordTerminalOutcomes}`, async () => { + const upstream = serve(() => { + const response = responsesSuccess("B", "final-b"); + if (scenario === "missing-model") delete response.model; + if (scenario === "empty-model") response.model = ""; + const events: Array<Record<string, unknown>> = [{ type: "response.output_text.delta", delta: "B", item_id: "msg_b", output_index: 0, content_index: 0 }]; + if (scenario === "failed-first" || scenario === "incomplete-first") { + const status = scenario === "failed-first" ? "failed" : "incomplete"; + events.push({ type: `response.${status}`, response: { ...response, status } }); + } + if (scenario === "undeclared-tool") events.push({ + type: "response.output_item.added", output_index: 0, + item: { type: "function_call", id: "fc_bad", call_id: "bad", name: "not_declared", arguments: "{}" }, + }); + // Empty terminal output cannot erase an earlier rejected tool call. + events.push({ type: "response.completed", response: { ...response, output: [] } }); + return new Response(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); + }); + const seed = serve(() => Response.json(responsesSuccess("A", "m1"))); + const config = comboConfig({ + a: provider("openai-responses", baseUrl(seed), "key-a"), + b: provider("openai-responses", baseUrl(upstream), "key-b"), + }); + config.combos = { + alpha: { targets: [{ provider: "a", model: "m1" }] }, + beta: { targets: [{ provider: "b", model: "m2" }] }, + }; + const headers = { session_id: "native-sticky" }; + await (await post(config, { model: "combo/alpha" }, {}, headers)).text(); + const completed = deferred(); + const models: string[] = []; + const response = await post(config, { model: "combo/beta", stream: true, tools: [] }, { + recordTerminalOutcomes, + onResponseComplete: model => { models.push(model); completed.resolve(); }, + }, headers); + await response.text(); + if (scenario === "completed") { + await within(completed.promise); + expect(models).toEqual(["final-b"]); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "native-sticky" })), "final-b")).toBe("beta"); + } else { + expect(models).toEqual([]); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "native-sticky" })), "m1")).toBe("alpha"); + } + }); + } + } + + for (const streamMode of ["legacy-tee", "eager-relay"] as const) { + for (const recordTerminalOutcomes of [true, false]) { + for (const firstModel of [undefined, "", "missing-response", "null-response"] as const) { + test(`native ${streamMode} ignores hidden completion after ${firstModel === undefined ? "missing-model" : firstModel || "empty-model"} with recording ${recordTerminalOutcomes}`, async () => { + const seed = serve(() => Response.json(responsesSuccess("A", "m1"))); + const upstream = serve(() => { + const first = responsesSuccess("first", "ignored"); + if (firstModel === undefined) delete first.model; + else if (firstModel === "") first.model = firstModel; + const firstEvent: Record<string, unknown> = { type: "response.completed", response: first }; + if (firstModel === "missing-response") delete firstEvent.response; + else if (firstModel === "null-response") firstEvent.response = null; + const events = [ + { type: "response.output_text.delta", delta: "B", item_id: "msg_b", output_index: 0, content_index: 0 }, + firstEvent, + { type: "response.completed", response: responsesSuccess("hidden", "final-b") }, + ]; + return new Response(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); + }); + const config = comboConfig({ + a: provider("openai-responses", baseUrl(seed), "key-a"), + b: provider("openai-responses", baseUrl(upstream), "key-b"), + }); + config.streamMode = streamMode; + config.combos = { + alpha: { targets: [{ provider: "a", model: "m1" }] }, + beta: { targets: [{ provider: "b", model: "m2" }] }, + }; + const headers = { session_id: "first-terminal-recall" }; + const lane = sessionLaneIdFromRequest(new Headers(headers)); + await (await post(config, { model: "combo/alpha" }, {}, headers)).text(); + expect(recallComboForLane(config, lane, "m1")).toBe("alpha"); + const completedModels: string[] = []; + const response = await post(config, { model: "combo/beta", stream: true }, { + recordTerminalOutcomes, onResponseComplete: model => { completedModels.push(model); }, + }, headers); + const wire = await response.text(); + expect(wire).not.toContain("final-b"); + expect(completedModels).toEqual([]); + expect(recallComboForLane(config, lane, "m1")).toBe("alpha"); + expect(recallComboForLane(config, lane, "final-b")).toBeUndefined(); + }); + } + } + } + + test("native output before cancellation preserves A and cannot record late B completion", async () => { + const seed = serve(() => Response.json(responsesSuccess("A", "m1"))); + const held = heldNativeTerminal({ type: "response.completed", response: responsesSuccess("B", "final-b") }); + const config = comboConfig({ + a: provider("openai-responses", baseUrl(seed), "key-a"), + b: provider("openai-responses", baseUrl(held.upstream), "key-b"), + }); + config.combos = { + alpha: { targets: [{ provider: "a", model: "m1" }] }, + beta: { targets: [{ provider: "b", model: "m2" }] }, + }; + const headers = { session_id: "cancel-recall" }; + await (await post(config, { model: "combo/alpha" }, {}, headers)).text(); + const abort = new AbortController(); + const models: string[] = []; + const response = await post(config, { model: "combo/beta", stream: true }, { + abortSignal: abort.signal, onResponseComplete: model => models.push(model), + }, headers); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "cancel-recall" })), "m1")).toBe("alpha"); + abort.abort(); + held.release(); + await response.text().catch(() => undefined); + expect(models).toEqual([]); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "cancel-recall" })), "m1")).toBe("alpha"); + }); + + test("a held completed response cannot resurrect recall across combo delete and recreate", async () => { + const seed = serve(() => Response.json(responsesSuccess("A", "m1"))); + const held = heldNativeTerminal({ type: "response.completed", response: responsesSuccess("B", "final-b") }); + const config = comboConfig({ + a: provider("openai-responses", baseUrl(seed), "key-a"), + b: provider("openai-responses", baseUrl(held.upstream), "key-b"), + }); + config.combos = { + alpha: { targets: [{ provider: "a", model: "m1" }] }, + beta: { targets: [{ provider: "b", model: "m2" }] }, + }; + const headers = { session_id: "recreated-recall" }; + await (await post(config, { model: "combo/alpha" }, {}, headers)).text(); + const completed = deferred(); + const response = await post(config, { model: "combo/beta", stream: true }, { + onResponseComplete: () => completed.resolve(), + }, headers); + const generation = captureConfigGeneration(); + delete config.combos.beta; + const owners = { + generation: generation + 1, + providerNames: new Set(["a", "b"]), comboIds: new Set(["alpha"]), comboTargets: new Set(["alpha::a/m1"]), + codexAccountIds: new Set<string>(), oauthAccountKeys: new Set<string>(), configRoots: new Set<string>(), + }; + reconcileComboRecall(owners); + config.combos.beta = { targets: [{ provider: "b", model: "m2" }] }; + reconcileComboRecall({ + ...owners, generation: generation + 2, + comboIds: new Set(["alpha", "beta"]), comboTargets: new Set(["alpha::a/m1", "beta::b/m2"]), + }); + held.release(); + await response.text(); + await within(completed.promise); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "recreated-recall" })), "m1")).toBe("alpha"); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "recreated-recall" })), "final-b")).toBeUndefined(); + }); + + for (const media of ["image", "video"] as const) { + test(`${media} bridge completion records the final response model`, async () => { + const tools: string[] = []; + const routed = serve(async request => { + const body = await request.json() as { tools?: Array<{ function?: { name?: string } }> }; + tools.push(...(body.tools ?? []).map(tool => tool.function?.name ?? "")); + return chatStream("media bridge answer"); + }); + const config = comboConfig({ + a: provider("openai-chat", baseUrl(routed), "key-a"), + xai: provider("openai-chat", "https://api.x.ai/v1", "synthetic-xai-key"), + }, [{ provider: "a", model: "m1" }]); + config.images = media === "image" ? { bridgeEnabled: true } : { videoBridgeEnabled: true }; + const models: string[] = []; + const response = await post(config, { + stream: true, ...(media === "image" ? { tools: [{ type: "image_generation" }] } : {}), + }, { onResponseComplete: model => models.push(model) }, { session_id: "media-recall" }); + const frames = await collectSse(response); + expect(tools).toContain(media === "image" ? "image_gen" : "video_gen"); + expect(frames.some(frame => frame.event === "response.completed")).toBe(true); + expect(models).toEqual(["m1"]); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "media-recall" })), "m1")).toBe("free"); + }); + } + test("dispatches a selected concrete target despite a shadowing combo alias", async () => { const hits: string[] = []; const a = serve(async request => { @@ -1575,12 +1863,14 @@ describe("server combo failover 030 activation matrix", () => { }); test("hosted web-search eager model failure hops through the loop path", async () => { - const modelHits: Array<{ model?: string; hasWebTool: boolean }> = []; + const modelHits: Array<{ model?: string; hasWebTool: boolean; authorization: string | null; account: string | null }> = []; const routed = serve(async request => { const body = await request.json() as { model?: string; tools?: Array<{ type?: string }> }; modelHits.push({ model: body.model, hasWebTool: body.tools?.some(tool => tool.type === "function") ?? false, + authorization: request.headers.get("authorization"), + account: request.headers.get("chatgpt-account-id"), }); if (body.model === "m1") { return Response.json({ error: { message: "loop unavailable" } }, { status: 503 }); @@ -1601,10 +1891,12 @@ describe("server combo failover 030 activation matrix", () => { { provider: "b", model: "m2" }, ]); config.webSearchSidecar = { enabled: true, backend: "openai" }; + const models: string[] = []; const response = await post(config, { stream: true, tools: [{ type: "web_search" }], - }, {}, { + }, { onResponseComplete: model => models.push(model) }, { + session_id: "web-search-recall", authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "acct-combo-search" })}`, "chatgpt-account-id": "acct-combo-search", }); @@ -1612,6 +1904,76 @@ describe("server combo failover 030 activation matrix", () => { expect(JSON.stringify(await collectSse(response))).toContain("web loop backup"); expect(modelHits.map(hit => hit.model)).toEqual(["m1", "m2"]); expect(modelHits.every(hit => hit.hasWebTool)).toBe(true); + expect(modelHits.map(hit => hit.authorization)).toEqual(["Bearer key-a", "Bearer key-b"]); + expect(modelHits.every(hit => hit.account === null)).toBe(true); + expect(models).toEqual(["m2"]); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "web-search-recall" })), "m2")).toBe("free"); + }); + + test.each(["valid", "chat-valid", "mismatched-account", "proxy-secret", "joined-proxy-secret", "explicit-null", "org-only-jwt"])("Combo sidecar auth stays off primary wires: %s", async authKind => { + const valid = authKind === "valid" || authKind === "chat-valid"; + const nativeToken = fakeChatGptJwt({ chatgpt_account_id: "acct-scoped-sidecar" }); + // A generic organizations claim is not OpenAI-domain evidence for a sidecar snapshot. + const token = authKind === "proxy-secret" ? `ocx_data_${nativeToken}` + : authKind === "joined-proxy-secret" ? `${nativeToken}, Bearer ocx_data_embedded` + : authKind === "org-only-jwt" ? fakeChatGptJwt({ organizations: [{ id: "org-foreign" }] }) : nativeToken; + const sidecarHits: Array<{ authorization: string | null; account: string | null }> = []; + const primaryHits: Array<{ model?: string; authorization: string | null; account: string | null; webTool: boolean }> = []; + let requestedSearch = false; + const sidecar = serve(request => { + sidecarHits.push({ authorization: request.headers.get("authorization"), account: request.headers.get("chatgpt-account-id") }); + return new Response( + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"synthetic web result"}\n\n' + + 'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + }); + const routed = serve(async request => { + const body = await request.json() as { model?: string; tools?: Array<{ type?: string; function?: { name?: string } }> }; + const tool = body.tools?.find(tool => tool.type === "function")?.function?.name; + primaryHits.push({ model: body.model, authorization: request.headers.get("authorization"), account: request.headers.get("chatgpt-account-id"), webTool: !!tool }); + if (body.model === "m1") return Response.json({ error: { message: "try next model" } }, { status: 503 }); + if (tool && !requestedSearch) { + requestedSearch = true; + return new Response(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_search", type: "function", function: { name: tool, arguments: '{"query":"synthetic query"}' } }] }, finish_reason: null }] })}\n\n` + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: [DONE]\n\n', + { headers: { "content-type": "text/event-stream" } }); + } + return chatStream("scoped sidecar complete"); + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof globalThis.Request ? input.url : String(input)); + if (url.origin === "https://chatgpt.com" && url.pathname === "/backend-api/codex/responses") { + return originalFetch(sidecar.url, init); + } + if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") throw new Error("unexpected external request"); + return originalFetch(input, init); + }) as typeof globalThis.fetch; + const config = comboConfig({ + a: provider("openai-chat", baseUrl(routed), "key-a"), + b: provider("openai-chat", baseUrl(routed), "key-b"), + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" }, + }, [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }]); + config.webSearchSidecar = { enabled: true, backend: "openai" }; + const headers = { + authorization: `Bearer ${token}`, + "chatgpt-account-id": authKind === "mismatched-account" ? "other-account" + : authKind === "org-only-jwt" ? "org-foreign" : "acct-scoped-sidecar", + }; + const response = authKind === "chat-valid" + ? await (await import("../../src/server/chat-completions")).handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ model: "combo/free", messages: [{ role: "user", content: "search" }], stream: true, tools: [{ type: "web_search" }] }), + }), config, { model: "", provider: "" }) + : await post(config, { stream: true, tools: [{ type: "web_search" }] }, authKind === "explicit-null" ? { openAiSidecarAuth: null } : {}, headers); + expect(response.status).toBe(200); + expect(JSON.stringify(await collectSse(response))).toContain("scoped sidecar complete"); + expect(sidecarHits).toEqual(valid + ? [{ authorization: `Bearer ${nativeToken}`, account: "acct-scoped-sidecar" }] : []); + expect(primaryHits.map(hit => hit.authorization)).toEqual(valid + ? ["Bearer key-a", "Bearer key-b", "Bearer key-b"] : ["Bearer key-a", "Bearer key-b"]); + expect(primaryHits.every(hit => hit.account === null)).toBe(true); + expect(primaryHits.every(hit => hit.webTool === valid)).toBe(true); }); test("context 400 stops while exhausted retryable targets return the sanitized last status", async () => { @@ -2886,7 +3248,7 @@ describe("server combo failover 030 activation matrix", () => { const terminalFrame = (status: "failed" | "completed") => [ `event: response.${status}`, `data: ${JSON.stringify({ type: `response.${status}`, response: { - id: `resp_${status}`, status, output: [], + id: `resp_${status}`, status, model: status === "completed" ? "final-b" : "failed-a", output: [], ...(status === "failed" ? { error: { code: "rate_limit_exceeded", message: "discarded quota failure" } } : {}), } })}`, "", @@ -2905,13 +3267,16 @@ describe("server combo failover 030 activation matrix", () => { }); const finalized = deferred(); const statuses: string[] = []; + const models: string[] = []; + const completed = deferred(); let cancels = 0; const parent: RequestLogContext = { model: "", provider: "" }; const snapshots: RequestLogContext[] = []; const response = await handleResponses(new Request("http://localhost/v1/responses", { - method: "POST", headers: { "content-type": "application/json" }, + method: "POST", headers: { "content-type": "application/json", session_id: "hop-recall" }, body: JSON.stringify({ model: "combo/free", input: "hello", stream: true }), }), config, parent, { + onResponseComplete: model => { models.push(model); completed.resolve(); }, onNativePassthroughTerminal: status => { statuses.push(status); snapshots.push({ ...parent }); @@ -2922,10 +3287,14 @@ describe("server combo failover 030 activation matrix", () => { expect(response.status).toBe(200); await response.text(); await within(finalized.promise); + await within(completed.promise); + expect(models).toEqual(["final-b"]); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "hop-recall" })), "final-b")).toBe("free"); + expect(recallComboForLane(config, sessionLaneIdFromRequest(new Headers({ session_id: "hop-recall" })), "m1")).toBeUndefined(); expect(statuses).toEqual(["completed"]); expect(cancels).toBe(0); expect(snapshots).toHaveLength(1); - expect(snapshots[0]).toMatchObject({ provider: "combo", model: "combo/free", resolvedModel: "m2" }); + expect(snapshots[0]).toMatchObject({ provider: "combo", model: "combo/free", resolvedModel: "final-b" }); for (const field of ["terminalHttpStatus", "terminalIncompleteReason", "terminalErrorCode", "upstreamError"] as const) { expect(snapshots[0]![field]).toBeUndefined(); } diff --git a/tests/server/server-images.test.ts b/tests/server/server-images.test.ts index 6f7481b034..7fcfd10257 100644 --- a/tests/server/server-images.test.ts +++ b/tests/server/server-images.test.ts @@ -1612,6 +1612,7 @@ function ccaFetchMock( try { parsedBody = JSON.parse(init.body); } catch { /* non-JSON body */ } } if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + expect(init?.redirect).toBe("manual"); registryHits.push({ url: requestUrl, headers, body: parsedBody }); return Response.json(payload, { status }); } @@ -1629,6 +1630,47 @@ const CCA_CREDENTIAL = { projectId: "cca-project-123", } as const; +test.each([307, 308])("CCA image transport does not follow a canonical endpoint's %i", async status => { + let targetHits = 0; + let originHits = 0; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return Response.json({ response: { candidates: [] } }); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + // Map only the canonical URL; pass production init unchanged to the real transport. + return originalFetch(`http://127.0.0.1:${origin.port}/cca`, init); + } + if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") throw new Error("unexpected external request"); + return originalFetch(input, init); + }) as typeof fetch; + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/images/generations", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "synthetic prompt", model: "gpt-image-2" }), + }); + await response.text(); + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(response.status).toBe(502); + expect(response.headers.get("location")).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + await server.stop(true); + await origin.stop(true); + await target.stop(true); + } +}); + test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { const registryHits: CcaFetchRequest[] = []; const otherHits: CcaFetchRequest[] = []; diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index 16ee4c7946..f6d4da8916 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -1237,10 +1237,12 @@ test("sideband relay preserves multibyte UTF-8 frames byte-identically in both d // The env-gated frame forensic log (OCX_LIVE_FRAME_LOG) records per-frame metadata and // U+FFFD presence without writing full payloads — the attribution tool for multibyte // transcript corruption reports. -test("sideband frame log records direction, kind, and U+FFFD context without full payloads", async () => { +test("sideband frame log preserves delivery without recording damaged or clean text", async () => { const frameLogPath = join(TEST_DIR, "frames.jsonl"); + const previousFrameLog = process.env.OCX_LIVE_FRAME_LOG; process.env.OCX_LIVE_FRAME_LOG = frameLogPath; const FFFD_TEXT = "가볍게 ��기핼봐요"; + const received: string[] = []; const upstream = Bun.serve({ port: 0, @@ -1293,7 +1295,8 @@ test("sideband frame log records direction, kind, and U+FFFD context without ful client.addEventListener("open", () => { client.send("clean-frame"); }); - client.addEventListener("message", () => { + client.addEventListener("message", event => { + received.push(String(event.data)); acks += 1; if (acks >= 2) { clearTimeout(timer); @@ -1316,23 +1319,70 @@ test("sideband frame log records direction, kind, and U+FFFD context without ful expect(u2cFffd).toBeDefined(); expect(u2cFffd.kind).toBe("text"); expect(u2cFffd.bytes).toBeGreaterThan(0); - expect(u2cFffd.context).toContain("�"); + expect(received).toContain(FFFD_TEXT); expect(c2uClean).toBeDefined(); expect(c2uClean.fffd).toBe(false); - // Full payloads must never be logged — only short FFFD context excerpts. + // Even a short damaged transcript must not be persisted as diagnostic context. for (const line of lines) { + expect(Object.keys(line).sort()).toEqual(["bytes", "dir", "fffd", "kind", "ts"]); expect(JSON.stringify(line)).not.toContain("clean-frame"); + expect(JSON.stringify(line)).not.toContain(FFFD_TEXT); } client.close(); } finally { - delete process.env.OCX_LIVE_FRAME_LOG; + if (previousFrameLog === undefined) delete process.env.OCX_LIVE_FRAME_LOG; + else process.env.OCX_LIVE_FRAME_LOG = previousFrameLog; globalThis.WebSocket = RealWebSocket; await server.stop(true); await upstream.stop(true); } }); +test("frame diagnostics retain only metadata for text, binary, and bounded views", async () => { + const { logLiveSidebandFrame } = await import("../../src/server/live"); + const previousFrameLog = process.env.OCX_LIVE_FRAME_LOG; + const frameLogPath = join(TEST_DIR, "frame-metadata.jsonl"); + const damagedText = "private-voice-�"; + const encoded = new TextEncoder().encode(damagedText); + const padded = new TextEncoder().encode("�safe�"); + const frames: Array<{ data: unknown; kind: string; bytes: number; fffd: boolean }> = [ + { data: damagedText, kind: "text", bytes: 17, fffd: true }, + { data: encoded.buffer, kind: "binary", bytes: 17, fffd: true }, + { data: Buffer.from(encoded), kind: "binary", bytes: 17, fffd: true }, + // Replacement characters outside this view must not affect the flag or byte count. + { data: new Uint8Array(padded.buffer, 3, 4), kind: "binary", bytes: 4, fffd: false }, + { data: new DataView(padded.buffer, 3, 4), kind: "binary", bytes: 4, fffd: false }, + { data: "한글", kind: "text", bytes: 6, fffd: false }, + { data: new Uint8Array([0xff]), kind: "binary", bytes: 1, fffd: true }, + ]; + try { + process.env.OCX_LIVE_FRAME_LOG = frameLogPath; + for (const frame of frames) logLiveSidebandFrame("u2c", frame.data); + logLiveSidebandFrame("c2u", { privateText: damagedText }); + const raw = readFileSync(frameLogPath, "utf8"); + const records = raw.trim().split("\n").map(line => JSON.parse(line)); + expect(records).toHaveLength(frames.length); + records.forEach((record, index) => { + const expected = frames[index]!; + expect(record).toEqual({ + ts: expect.any(String), dir: "u2c", kind: expected.kind, + bytes: expected.bytes, fffd: expected.fffd, + }); + expect(Number.isNaN(Date.parse(record.ts))).toBe(false); + }); + for (const content of [damagedText, "safe", "한글", "�"]) expect(raw).not.toContain(content); + delete process.env.OCX_LIVE_FRAME_LOG; + logLiveSidebandFrame("c2u", damagedText); + expect(readFileSync(frameLogPath, "utf8")).toBe(raw); + process.env.OCX_LIVE_FRAME_LOG = TEST_DIR; + expect(() => logLiveSidebandFrame("c2u", damagedText)).not.toThrow(); + } finally { + if (previousFrameLog === undefined) delete process.env.OCX_LIVE_FRAME_LOG; + else process.env.OCX_LIVE_FRAME_LOG = previousFrameLog; + } +}); + // ── /readyz: per-server readiness gate ──────────────────────────────────────── // /healthz remains the immediate liveness signal (with only bounded capability // metadata); /readyz is the stricter gate that reflects the post-startup Codex sync diff --git a/tests/server/server-xai-responses-streaming.test.ts b/tests/server/server-xai-responses-streaming.test.ts index 16ae451172..8a2298972c 100644 --- a/tests/server/server-xai-responses-streaming.test.ts +++ b/tests/server/server-xai-responses-streaming.test.ts @@ -1,10 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; -import { flushConfigDirHardeningForTests } from "../../src/config/paths"; -import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { saveCredential } from "../../src/oauth/store"; import { XAI_GROK_CLI_BASE_URL, @@ -14,6 +12,7 @@ import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; const RESPONSES_ENDPOINT = `${XAI_GROK_CLI_BASE_URL}/responses`; const encoder = new TextEncoder(); @@ -22,11 +21,34 @@ let testDir = ""; let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; let originalFetch: typeof fetch; -const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +let activeRoutedCase: { controller: AbortController; settled: Promise<void> } | null = null; + +function runRoutedCase(body: (signal: AbortSignal) => Promise<void>): Promise<void> { + const controller = new AbortController(); + const result = body(controller.signal); + // Observe the entire body, including its server-stop finally, even after a test timeout. + activeRoutedCase = { controller, settled: result.then(() => {}, () => {}) }; + return result; +} + +async function drainRoutedCase(): Promise<void> { + const active = activeRoutedCase; + if (!active) return; + active.controller.abort(new DOMException("xAI fixture cleanup", "AbortError")); + await active.settled; + if (activeRoutedCase === active) activeRoutedCase = null; +} + +function startXaiTestServer() { + return startServer(0, { + // This wire fixture does not exercise native Codex service ownership. Avoid + // unrelated Windows service queries and native-main recovery during setup. + inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "xAI wire fixture" }), + }); +} beforeEach(async () => { - setIcaclsRunnerForTests(() => ICACLS_OK); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + if (activeRoutedCase) throw new Error("previous routed-parent fixture has not finished cleanup"); originalFetch = globalThis.fetch; previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-xai-responses-codex-"); @@ -42,16 +64,14 @@ beforeEach(async () => { }); afterEach(async () => { + await drainRoutedCase(); globalThis.fetch = originalFetch; - await flushConfigDirHardeningForTests(); - setIcaclsRunnerForTests(null); - setAsyncIcaclsRunnerForTests(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); isolatedCodexHome = null; if (testDir) removeTreeWithRetry(testDir); -}); +}, SERVER_BUDGET_MS); function config(): OcxConfig { return { @@ -80,6 +100,137 @@ function sse(payload: unknown): Uint8Array { } describe("xAI OAuth Responses streaming opt-in", () => { + test("routed-case cleanup waits for the entire aborted body finally", async () => { + let markFinally!: () => void; + const enteredFinally = new Promise<void>(resolve => { markFinally = resolve; }); + let releaseFinally!: () => void; + const finallyGate = new Promise<void>(resolve => { releaseFinally = resolve; }); + let finallyFinished = false; + const running = runRoutedCase(async signal => { + try { + await new Promise<never>((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + } finally { + markFinally(); + await finallyGate; + finallyFinished = true; + } + }); + const outcome = running.then(() => null, (error: unknown) => error); + let drained = false; + const draining = drainRoutedCase().then(() => { drained = true; }); + try { + await enteredFinally; + await Promise.resolve(); + // Awaiting outcome first would hide a drain helper that returned too early. + expect(drained).toBe(false); + expect(finallyFinished).toBe(false); + } finally { + releaseFinally(); + await draining; + await outcome; + } + expect(await outcome).toMatchObject({ name: "AbortError" }); + expect(finallyFinished).toBe(true); + expect(activeRoutedCase).toBeNull(); + }, SERVER_BUDGET_MS); + + test.each([true, false])("continues a routed parent after a string child result (stream=%s)", stream => runRoutedCase(async signal => { + const captured: Array<Record<string, unknown>> = []; + let privateItemRejections = 0; + const childText = " Synthetic worker result\nAll requested observations returned.\n "; + const call = { type: "function_call", id: "fc_parent_probe", status: "completed", + call_id: "call_parent_probe", name: "probe", arguments: "{}", + }; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + // The fixture never falls through to a real OAuth or inference endpoint. + if (url !== RESPONSES_ENDPOINT) throw new Error(`Unexpected fixture destination: ${url}`); + const body = JSON.parse(String(init?.body)) as Record<string, unknown>; + captured.push(body); + const items = body.input as Array<{ type?: string }>; + if (items.some(item => item.type === "agent_message")) { + privateItemRejections += 1; + return Response.json({ error: 'unknown item type "agent_message"' }, { status: 422 }); + } + const output = captured.length === 1 ? [call] : [{ + type: "message", id: `msg_child_result_${captured.length}`, status: "completed", role: "assistant", + content: [{ type: "output_text", text: captured.length === 2 ? childText : "Parent continued", annotations: [] }], + }]; + const response = { id: `resp_child_result_${captured.length}`, object: "response", status: "completed", + model: "grok-4.6", output, + }; + if (!stream) return Response.json(response); + return new Response(new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(sse({ type: "response.created", sequence_number: 0, + response: { ...response, status: "in_progress", output: [] }, + })); + controller.enqueue(sse({ type: "response.output_item.added", sequence_number: 1, output_index: 0, item: output[0] })); + controller.enqueue(sse({ type: "response.output_item.done", sequence_number: 2, output_index: 0, item: output[0] })); + controller.enqueue(sse({ type: "response.completed", sequence_number: 3, response })); + controller.close(); + }, + }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + saveConfig({ ...config(), multiAgentMode: "v2" }); + const server = startXaiTestServer(); + const send = async (session: string, input: unknown[], parentSession?: string) => { + signal.throwIfAborted(); + const response = await originalFetch(new URL("/v1/responses", server.url), { + signal, + method: "POST", headers: { "content-type": "application/json", "session-id": session, + ...(parentSession ? { "x-codex-parent-thread-id": parentSession } : {}), + }, + body: JSON.stringify({ model: "xai/grok-4.6", stream, store: false, input, + tools: [{ type: "function", name: "probe", parameters: { type: "object", properties: {} } }], + }), + }); + expect(response.status).toBe(200); + if (!stream) return await response.json() as { output: Array<Record<string, unknown>> }; + const text = await response.text(); + const events = text.split(/\r?\n/).filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6))); + const terminal = events.find(event => event.type === "response.completed"); + expect(terminal).toBeDefined(); + return terminal.response as { output: Array<Record<string, unknown>> }; + }; + try { + const initial = { type: "message", role: "user", content: [{ type: "input_text", text: "Collect a worker result" }] }; + const parent = await send("fixture-parent", [initial]); + expect(parent.output[0]).toMatchObject(call); + const child = await send("fixture-worker", [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Return the synthetic observations" }] }, + ], "fixture-parent"); + const childContent = child.output[0]!.content as Array<{ type: string; text: string }>; + expect(childContent[0]).toMatchObject({ type: "output_text", text: childText }); + // Codex-client envelope simulation only: no scheduler or real child process is run. + const toolResult = { type: "function_call_output", call_id: call.call_id, output: "Probe completed" }; + const agentMessage = { type: "agent_message", id: "amsg_worker_result", author: "/root/worker", recipient: "/root", + content: childContent[0]!.text, + }; + const resumed = await send("fixture-parent", [initial, ...parent.output, toolResult, agentMessage]); + expect(resumed.output[0]).toMatchObject({ type: "message", content: [{ type: "output_text", text: "Parent continued" }] }); + expect(privateItemRejections).toBe(0); + expect(captured).toHaveLength(3); + const input = captured[2]!.input as Array<Record<string, unknown>>; + expect(input.some(item => item.type === "agent_message")).toBe(false); + expect(input.filter(item => item.type === "function_call")).toEqual([ + expect.objectContaining({ call_id: call.call_id, name: "probe", arguments: "{}" }), + ]); + expect(input.filter(item => item.type === "function_call_output")).toEqual([toolResult]); + expect(input).toContainEqual({ type: "message", role: "user", content: [ + { type: "input_text", text: 'Agent message {"author":"/root/worker","recipient":"/root"}' }, + { type: "input_text", text: childText }, + ] }); + expect(agentMessage.content).toBe(childText); + } finally { + await server.stop(true); + } + }), 10_000); + test("uses the native Responses wire and relays the first delta before completion", async () => { let releaseCompletion!: () => void; const completionGate = new Promise<void>(resolve => { releaseCompletion = resolve; }); @@ -173,7 +324,7 @@ describe("xAI OAuth Responses streaming opt-in", () => { }) as typeof fetch; saveConfig(config()); - const server = startServer(0); + const server = startXaiTestServer(); let reader: ReadableStreamDefaultReader<Uint8Array> | undefined; try { const response = await originalFetch(new URL("/v1/responses", server.url), { @@ -288,7 +439,7 @@ describe("xAI OAuth Responses streaming opt-in", () => { }) as typeof fetch; saveConfig(config()); - const server = startServer(0); + const server = startXaiTestServer(); try { const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", @@ -383,7 +534,7 @@ describe("xAI OAuth Responses streaming opt-in", () => { }) as typeof fetch; saveConfig(config()); - const server = startServer(0); + const server = startXaiTestServer(); try { const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", diff --git a/tests/server/stream-aborted-marker.test.ts b/tests/server/stream-aborted-marker.test.ts index 5e142a3429..cce7be1faf 100644 --- a/tests/server/stream-aborted-marker.test.ts +++ b/tests/server/stream-aborted-marker.test.ts @@ -205,4 +205,79 @@ describe("streamAborted marker (codex-router #139)", () => { expect(row?.status).toBe(499); expect(row?.attempts?.[0]?.streamAborted).toBeUndefined(); }); + + test("bare upstream error event at clean EOF meters as 502 without streamAborted", async () => { + const { logCtx, attempt } = makeLogCtx(); + const terminalReported = Promise.withResolvers<void>(); + const terminals: Array<[string, number | undefined]> = []; + // Stream sends a bare { type: "error" } SSE event then closes cleanly (no read error). + // The onCleanEof path in consumeForInspection detects the witnessed bare error and + // reports failed -- but a semantic EOF is not a body-read reset, so streamAborted is absent. + const barePayload = JSON.stringify({ type: "error", message: "provider failed cleanly" }); + const body = new ReadableStream<Uint8Array>({ + pull(controller) { + controller.enqueue(encoder.encode("data: " + barePayload + "\n\n")); + controller.close(); + }, + }); + consumeForInspection( + body, + (status, httpStatusOverride) => { + terminals.push([status, httpStatusOverride]); + terminalReported.resolve(); + }, + undefined, + () => {}, + logCtx, + ); + await terminalReported.promise; + expect(terminals).toEqual([["failed", 502]]); + expect(attempt.streamAborted).toBeUndefined(); + + addFinalRequestLog( + "ocx-bare-error-eof", + Date.now(), + logCtx, + httpStatusForRequestLogTerminal("failed", logCtx), + { terminalStatus: "failed", closeReason: "terminal" }, + addRequestLog, + ); + const [row] = readUsageEntries(); + expect(row?.status).toBe(502); + expect(row?.attempts?.[0]?.status).toBe(502); + expect(row?.attempts?.[0]?.streamAborted).toBeUndefined(); + }); + + test("read error after a bare upstream error event carries streamAborted", async () => { + const { logCtx, attempt } = makeLogCtx(); + const terminalReported = Promise.withResolvers<void>(); + const terminals: Array<[string, number | undefined]> = []; + // A bare error event arrives, then the body-read itself fails (socket reset). + // The read error takes the onReadError path and sets streamAborted. + const barePayload = JSON.stringify({ type: "error", message: "pre-reset error" }); + let reads = 0; + const body = new ReadableStream<Uint8Array>({ + pull(controller) { + reads += 1; + if (reads === 1) { + controller.enqueue(encoder.encode("data: " + barePayload + "\n\n")); + } else { + controller.error(new Error("socket reset after error event")); + } + }, + }); + consumeForInspection( + body, + (status, httpStatusOverride) => { + terminals.push([status, httpStatusOverride]); + terminalReported.resolve(); + }, + undefined, + () => {}, + logCtx, + ); + await terminalReported.promise; + expect(terminals).toEqual([["failed", 502]]); + expect(attempt.streamAborted).toBe(true); + }); }); diff --git a/tests/server/v2-agent-message-failfast.test.ts b/tests/server/v2-agent-message-failfast.test.ts index 0dec3b4071..0d2b77dcdd 100644 --- a/tests/server/v2-agent-message-failfast.test.ts +++ b/tests/server/v2-agent-message-failfast.test.ts @@ -4,6 +4,7 @@ import { hasUnreadableEncryptedAgentTask, } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; const originalFetch = globalThis.fetch; @@ -212,9 +213,13 @@ describe("V2 routed agent-message ciphertext guard", () => { test("filters a combo to a decrypt-capable native target before dispatch", async () => { const fetchedUrls: string[] = []; + const nativeToken = fakeChatGptJwt({ chatgpt_account_id: "native-combo-caller" }); + const forwardedAuth: Array<{ authorization: string | null; account: string | null }> = []; let forwardedBody = ""; globalThis.fetch = (async (input, init) => { fetchedUrls.push(String(input)); + const headers = new Headers(init?.headers); + forwardedAuth.push({ authorization: headers.get("authorization"), account: headers.get("chatgpt-account-id") }); forwardedBody = typeof init?.body === "string" ? init.body : ""; return Response.json({ id: "resp_combo_native", @@ -233,13 +238,14 @@ describe("V2 routed agent-message ciphertext guard", () => { { type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, ]), - { authorization: "Bearer caller-codex-token" }, + { authorization: `Bearer ${nativeToken}`, "chatgpt-account-id": "native-combo-caller" }, ); expect(response.status).toBe(200); expect(fetchedUrls).toHaveLength(1); expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex"); expect(fetchedUrls[0]).not.toContain("api.x.ai"); + expect(forwardedAuth).toEqual([{ authorization: `Bearer ${nativeToken}`, account: "native-combo-caller" }]); expect(forwardedBody).toContain(FERNET_TASK); }); @@ -283,7 +289,11 @@ describe("V2 routed agent-message ciphertext guard", () => { ]; const forwardedModels: string[] = []; const forwardedBodies: string[] = []; + const nativeToken = fakeChatGptJwt({ chatgpt_account_id: "native-combo-caller" }); + const forwardedAuth: Array<{ authorization: string | null; account: string | null }> = []; globalThis.fetch = (async (_input, init) => { + const headers = new Headers(init?.headers); + forwardedAuth.push({ authorization: headers.get("authorization"), account: headers.get("chatgpt-account-id") }); const raw = typeof init?.body === "string" ? init.body : ""; forwardedBodies.push(raw); const parsed = JSON.parse(raw) as { model?: string }; @@ -308,12 +318,13 @@ describe("V2 routed agent-message ciphertext guard", () => { { type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, ]), - { authorization: "Bearer caller-codex-token" }, + { authorization: `Bearer ${nativeToken}`, "chatgpt-account-id": "native-combo-caller" }, ); expect(response.status).toBe(200); expect(forwardedModels).toEqual(["gpt-native-primary", "gpt-native-backup"]); expect(forwardedBodies).toHaveLength(2); + expect(forwardedAuth).toEqual(Array(2).fill({ authorization: `Bearer ${nativeToken}`, account: "native-combo-caller" })); expect(forwardedBodies.every(body => body.includes(FERNET_TASK))).toBe(true); }); diff --git a/tests/service/init-eof.test.ts b/tests/service/init-eof.test.ts index 11f5c447bd..bcbdb007d3 100644 --- a/tests/service/init-eof.test.ts +++ b/tests/service/init-eof.test.ts @@ -176,6 +176,55 @@ describe("ocx init piped stdin (#754)", () => { } finally { await stop(proc); } }, 30_000); + test.each(["permissions", "link", "link-residue"])("publication recovery guidance reaches the CLI (%s)", async failure => { + const home = makeHome(); + const backup = join(home, "config.json.pre-openai-tiers-v2.bak"); + writeFileSync(backup, "preserve backup on publication failure"); + const bootstrap = ` + import { mock } from "bun:test"; + const configApi = { ...await import("./src/config.ts") }; + const failure = ${JSON.stringify(failure)}; + const io = failure === "permissions" + ? { harden() { throw new Error("private permission detail"); } } + : { + link() { throw Object.assign(new Error("private link detail"), { code: "EPERM" }); }, + ...(failure === "link-residue" ? { unlink() { throw new Error("private cleanup detail"); } } : {}), + }; + mock.module("./src/config.ts", () => ({ + ...configApi, + initializePersistedConfigIfMissing(config) { + return configApi.initializePersistedConfigIfMissing(config, io); + }, + })); + const { runInit } = await import("./src/cli/init.ts"); + await runInit(); + `; + const proc = launch(home, "init", bootstrap); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + proc.stdin.write("21001\n"); + await proc.stdin.flush(); + const stdout = remainingOutput(proc.stdout); + expect(await proc.exited).toBe(1); + const diagnostic = await stderr; + expect(diagnostic).toContain("OPENCODEX_HOME"); + expect(diagnostic).toContain("ocx init"); + expect(diagnostic).not.toMatch(/fixture-init-key|private (permission|link|cleanup) detail/); + if (failure === "permissions") { + expect(diagnostic).toContain("permissions could not be secured"); + expect(diagnostic).not.toContain("Config may already exist"); + } else { + expect(diagnostic).toContain("hard-link publication"); + expect(diagnostic).toContain("Config may already exist; inspect it before retrying"); + } + expect(diagnostic.includes("A temporary file could not be removed")).toBe(failure === "link-residue"); + expect(await stdout).not.toMatch(/Inject into|autostart shim|Setup complete/); + expect(existsSync(join(home, "config.json"))).toBe(false); + expect(readFileSync(backup, "utf8")).toBe("preserve backup on publication failure"); + } finally { await stop(proc); } + }, 30_000); + // Windows process.kill does not deliver a POSIX SIGINT to readline. test.skipIf(process.platform === "win32")("SIGINT settles a pending prompt without creating config", async () => { const home = makeHome(); diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index 61eadbdc93..e9d116cfcb 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -1,11 +1,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stopProxyGracefully } from "../../src/lib/process-control"; import { performStopTeardown } from "../../src/server/stop-teardown"; import type { CodexNativeRestoreResult } from "../../src/codex/inject"; +import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../../src/update/stop-contract.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { fixturePath, repoPath } from "../helpers/repo-root"; /** * Behavioural cover for the deferred shared teardown (#3008). @@ -46,6 +49,82 @@ function restoreResult(success: boolean): CodexNativeRestoreResult { } as unknown as CodexNativeRestoreResult; } +async function runParentStop(options: { receipt: boolean; response: unknown; restore: CodexNativeRestoreResult; status?: number }) { + const child = spawnSync(process.execPath, [fixturePath("parent-stop-runner.ts")], { + cwd: repoPath(), + env: { ...process.env, OPENCODEX_HOME: home }, + input: JSON.stringify(options), + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + }); + expect(child.error, child.stderr).toBeUndefined(); + expect(child.signal, child.stderr).toBeNull(); + const reportPath = join(home, "parent-stop-result.json"); + expect(existsSync(reportPath), child.stderr).toBe(true); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as { + calls: { killed: number; native: number; grok: number; cleared: number; exited: number }; + urls: string[]; nonce?: string; receiptExists: boolean; unexpectedIo: string[]; + }; + expect(report.unexpectedIo, child.stderr).toEqual([]); + expect(report.urls, child.stderr).toHaveLength(1); + return { ...report, exitCode: child.status, stderr: child.stderr }; +} + +describe("parent CLI shared teardown completion", () => { + test("receipt failure and unconfirmed child teardown cause real parent restoration without a kill", async () => { + const outcome = await runParentStop({ receipt: false, + response: { success: false, sharedTeardown: "performed" }, restore: restoreResult(true) }); + expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + expect(outcome.calls).toMatchObject({ killed: 0, exited: 1, native: 1, grok: 1, cleared: 0 }); + expect(outcome.exitCode).toBe(0); + }); + + test("a confirmed performed teardown prevents duplicate parent restoration", async () => { + const outcome = await runParentStop({ receipt: false, + response: { success: true, sharedTeardown: "performed" }, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ killed: 0, native: 0, grok: 0 }); + expect(outcome.exitCode).toBe(0); + }); + + test("a failed parent restoration leaves its actual receipt outstanding", async () => { + const outcome = await runParentStop({ receipt: true, + response: { success: false, sharedTeardown: "performed" }, restore: restoreResult(false) }); + expect(outcome.urls[0]).toContain(`teardownNonce=${outcome.nonce}`); + expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 0 }); + expect(outcome.exitCode).toBe(1); + expect(outcome.receiptExists).toBe(true); + }); + + test("confirmed deferral leaves restoration and receipt discharge to the parent", async () => { + const outcome = await runParentStop({ receipt: true, + response: { success: true, sharedTeardown: "deferred" }, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 1 }); + expect(outcome.exitCode).toBe(0); + expect(outcome.receiptExists).toBe(false); + }); + + test("history-only parent failure preserves its distinct exit and discharges restored client state", async () => { + const restore = { ...restoreResult(false), artifacts: { + config: { state: "restored" }, catalog: { state: "restored" }, history: { state: "failed" }, + } } as unknown as CodexNativeRestoreResult; + const outcome = await runParentStop({ receipt: true, + response: { success: false, sharedTeardown: "performed" }, restore }); + expect(outcome.calls).toMatchObject({ killed: 0, native: 1, grok: 1, cleared: 1 }); + expect(outcome.exitCode).toBe(STOP_HISTORY_INCOMPLETE_EXIT_CODE); + expect(outcome.receiptExists).toBe(false); + }); + + test("a refused stop keeps the parent from restoring or discharging its receipt", async () => { + const outcome = await runParentStop({ receipt: true, status: 409, + response: { success: false, message: "Run the stop outside the installed service." }, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ killed: 0, exited: 0, native: 0, grok: 0, cleared: 0 }); + expect(outcome.exitCode).toBe(1); + expect(outcome.receiptExists).toBe(true); + expect(outcome.stderr).toContain("Run the stop outside the installed service."); + }); +}); + describe("stopProxyGracefully deferral flag", () => { test("the default stop asks for no deferral", async () => { const urls: string[] = []; @@ -53,7 +132,7 @@ describe("stopProxyGracefully deferral flag", () => { readRuntime: () => ({ port: 10100 }), fetchFn: (async (url: string | URL | Request) => { urls.push(String(url)); - return new Response(JSON.stringify({ success: true }), { status: 200 }); + return new Response(JSON.stringify({ success: true, sharedTeardown: "performed" }), { status: 200 }); }) as typeof fetch, waitExit: () => true, env: {}, @@ -67,7 +146,7 @@ describe("stopProxyGracefully deferral flag", () => { readRuntime: () => ({ port: 10100 }), fetchFn: (async (url: string | URL | Request) => { urls.push(String(url)); - return new Response(JSON.stringify({ success: true }), { status: 200 }); + return new Response(JSON.stringify({ success: true, sharedTeardown: "deferred" }), { status: 200 }); }) as typeof fetch, waitExit: () => true, env: {}, @@ -86,7 +165,7 @@ describe("stopProxyGracefully deferral flag", () => { runtimeEndpoint: { hostname: "127.0.0.1", port: 10100 }, fetchFn: (async (url: string | URL | Request) => { urls.push(String(url)); - return new Response(JSON.stringify({ success: true }), { status: 200 }); + return new Response(JSON.stringify({ success: true, sharedTeardown: "performed" }), { status: 200 }); }) as typeof fetch, waitExit: () => true, env: {}, @@ -456,3 +535,91 @@ describe("pending teardown receipts", () => { expect(mod.deferralMatchesReceipt("")).toBe(false); }); }); + +describe("self-unloading manager refusal (#4023)", () => { + test("a darwin proxy running AS the launchd job reports a self-unload risk", async () => { + // `stopServiceIfInstalledDetailed()` calls `launchctl unload` on the plist that owns + // THIS process, so the manager stop can terminate the request handler before the + // shared teardown two statements later restores native Codex. The Windows guard that + // prevents exactly this returned early for every non-Windows platform. + const { installedServiceRespawnRisk } = await import("../../src/service"); + expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", { + env: { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" }, + exists: () => true, + })).toBe("self-unload"); + }); + + test("linux systemd is exempted identically and gets the same answer", async () => { + const { installedServiceRespawnRisk } = await import("../../src/service"); + expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "linux", { + env: { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" }, + exists: () => true, + })).toBe("self-unload"); + }); + + test("a manually started proxy is unaffected, even with a service installed", async () => { + // Only the plist and unit write OCX_SERVICE_MANAGED. Without it this process is not + // the managed job, so no unload can reach it and the inline stop stays available. + const { installedServiceRespawnRisk } = await import("../../src/service"); + expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", { + env: {}, + exists: () => true, + })).toBe("none"); + }); + + test("the managed job with no service definition on disk is not at risk", async () => { + const { installedServiceRespawnRisk } = await import("../../src/service"); + expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", { + env: { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" }, + exists: () => false, + })).toBe("none"); + }); + + test("Windows classification is untouched by the new branch", async () => { + const { installedServiceRespawnRisk } = await import("../../src/service"); + expect(installedServiceRespawnRisk(() => ({ status: "present" }) as never, "win32", { + env: { OCX_SERVICE: "1" }, + exists: () => true, + })).toBe("respawnable"); + expect(installedServiceRespawnRisk(() => ({ status: "unknown" }) as never, "win32")).toBe("unknown"); + expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "win32")).toBe("none"); + }); + + + test("a proxy spawned by an ensure path is not the managed job", async () => { + // Both `ocx claude` and `ocx opencode` set OCX_SERVICE=1 on their detached child to + // borrow its routing-preservation meaning (src/cli/claude.ts, src/cli/opencode.ts), + // so that variable cannot identify the managed job. A user with the service installed + // but stopped, running one of those commands, must keep a working dashboard Stop. + const { installedServiceRespawnRisk } = await import("../../src/service"); + expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", { + env: { OCX_SERVICE: "1" }, + exists: () => true, + })).toBe("none"); + expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "linux", { + env: { OCX_SERVICE: "1" }, + exists: () => true, + })).toBe("none"); + }); + + +test("the route refuses a self-unload before the manager is touched", () => { + const source = readFileSync(repoPath("src", "server", "management-api.ts"), "utf8"); + const from = source.indexOf('"/api/stop"'); + const handler = source.slice(from, source.indexOf("/api/codex-auth/", from)); + expect(handler).toContain('code: "self_unload_service"'); + // Same invariant the Windows guard carries: refuse BEFORE acting, and say so. + expect(handler.indexOf('code: "self_unload_service"')) + .toBeLessThan(handler.indexOf("stopServiceIfInstalledDetailed()")); + const branch = handler.slice(handler.indexOf('code: "self_unload_service"'), handler.indexOf('code: "self_unload_service"') + 600); + expect(branch).toContain("Nothing was changed."); + expect(branch).toContain("ocx stop"); + }); + + test("a receipt-backed ocx stop keeps its deferral path", () => { + // `ocx stop` claims a receipt, defers the teardown, and performs it itself once the + // proxy is proven down — so it must not be refused by the new branch. + const source = readFileSync(repoPath("src", "server", "management-api.ts"), "utf8"); + expect(source).toContain('const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();'); + }); +}); diff --git a/tests/storage/storage-cleanup.test.ts b/tests/storage/storage-cleanup.test.ts index 31cbcd6d21..8e4564e0ef 100644 --- a/tests/storage/storage-cleanup.test.ts +++ b/tests/storage/storage-cleanup.test.ts @@ -586,23 +586,60 @@ describe("executeArchivedCleanup", () => { db.close(); }, { timeout: STORE_BUDGET_MS }); - test("rejects candidates still referenced by a live spawn edge", () => { + test("skips candidates still referenced by a live spawn edge", () => { home = buildHome({ withSpawnEdges: true }); // Edge told→tmid; deleting only oldest (told) leaves tmid outside the set. const result = runWithDigest(34, "quarantine", home); - expect(result.ok).toBe(false); - expect(result.error).toBe("referenced_history"); + expect(result.ok).toBe(true); + expect(result.count).toBe(0); + expect(result.skippedReferencedPaths).toEqual(["archived_sessions/rollout-old.jsonl"]); expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + // Stage dir should not remain when no candidates are selected. + expect(existsSync(join(home, ".trash"))).toBe(false); }); - test("rejects paginated history_mode threads", () => { + test("skips paginated history_mode threads", () => { home = buildHome(); const db = new Database(join(home, "state_5.sqlite")); db.exec(`UPDATE threads SET history_mode='paginated' WHERE id='told'`); db.close(); const result = runWithDigest(50, "quarantine", home); - expect(result.ok).toBe(false); - expect(result.error).toBe("referenced_history"); + expect(result.ok).toBe(true); + expect(result.count).toBe(0); + expect(result.skippedReferencedPaths).toEqual(["archived_sessions/rollout-old.jsonl"]); + // Ensure the trash root has been removed when nothing was staged. + expect(existsSync(join(home, ".trash"))).toBe(false); + }); + + test("deletes safe candidates while skipping referenced history", () => { + home = buildHome({ withSpawnEdges: true }); + const exactPaths = [ + "archived_sessions/rollout-old.jsonl", + "archived_sessions/rollout-new.jsonl", + ]; + const preview = previewExactArchivedCleanup( + listArchivedCandidates(home).filter(candidate => exactPaths.includes(candidate.relPath)), + home, + ); + const result = executeArchivedCleanup({ + percent: 0, + mode: "quarantine", + digest: preview.digest, + candidateRelPaths: [ + "archived_sessions/rollout-old.jsonl", + "archived_sessions/rollout-new.jsonl", + ], + codexHome: home, + }); + expect(result.ok).toBe(true); + expect(result.removedPaths).toEqual(["archived_sessions/rollout-new.jsonl"]); + expect(result.skippedReferencedPaths).toEqual(["archived_sessions/rollout-old.jsonl"]); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-new.jsonl"))).toBe(false); + const db = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(db.query("SELECT id FROM threads WHERE id='told'").get()).toBeTruthy(); + expect(db.query("SELECT id FROM threads WHERE id='tnew'").get()).toBeNull(); + db.close(); }); test("quarantine removes both plain and compressed physical files", () => { diff --git a/tests/storage/storage-mutation-race.test.ts b/tests/storage/storage-mutation-race.test.ts index fb89dd9215..24a410e86f 100644 --- a/tests/storage/storage-mutation-race.test.ts +++ b/tests/storage/storage-mutation-race.test.ts @@ -265,27 +265,40 @@ describe("storage mutation coordinator", () => { test("policy run is rejected while manual cleanup holds the shared mutation slot", async () => { const home = isolatedCodexHome!.path; - setArchivedCleanupJobTestHooks({ blockMs: 1200 }); + const cleanupReadyPath = join(testDir, "policy-cleanup-slot.ready"); + const releaseCleanupPath = join(testDir, "policy-cleanup-release"); + setArchivedCleanupJobTestHooks({ + pauseAfterAcquire: { + kind: "cleanup", + readyPath: cleanupReadyPath, + releasePath: releaseCleanupPath, + }, + }); seedArchivedPair(home); const server = startServer(0); + let cleanupPromise: Promise<Response> | null = null; try { const preview = await previewDigest(server.url, 50); - const cleanupPromise = fetch(new URL("/api/storage/cleanup", server.url), { + cleanupPromise = fetch(new URL("/api/storage/cleanup", server.url), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), }); - await Bun.sleep(80); + await waitForCondition("cleanup slot before policy admission", () => existsSync(cleanupReadyPath)); + expect(getActiveStorageMutation(home)?.kind).toBe("cleanup"); const { startedAt } = await enablePolicyAndRun(server.url); const done = await waitForPolicyJob(server.url, startedAt); expect(done.job.lastOutcome?.ok).toBe(false); expect(done.job.lastOutcome?.error).toBe("storage_mutation_busy"); + writeFileSync(releaseCleanupPath, "release\n"); const cleanupRes = await cleanupPromise; expect(cleanupRes.status).toBe(200); } finally { + writeFileSync(releaseCleanupPath, "release\n"); + if (cleanupPromise) await cleanupPromise.catch(() => undefined); await stopRaceServer(server); } }, { timeout: 30_000 }); diff --git a/tests/usage/request-log.test.ts b/tests/usage/request-log.test.ts index 33fbd13b1f..6080e52ceb 100644 --- a/tests/usage/request-log.test.ts +++ b/tests/usage/request-log.test.ts @@ -32,6 +32,7 @@ import { bridgeToResponsesSSE } from "../../src/bridge"; import type { AdapterEvent, OcxConfig, OcxUsage } from "../../src/types"; import { appendUsageEntry, + normalizeUsageEntryForTest, readUsageEntries, resetUsageReadCacheForTests, type PersistedUsageEntry, @@ -447,6 +448,31 @@ describe("request log metadata", () => { } }); + test("persists transport finality evidence from the final request log", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-finality-usage-")); + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + addFinalRequestLog("ocx-finality-persist", 1, { + model: "gpt-6-astra", + provider: "openai", + transportPhase: "mid_stream", + terminalSource: "synthetic", + upstreamError: "synthetic terminal", + }, 502, { terminalStatus: "failed", closeReason: "terminal" }); + expect(getRequestLogEntries()[0]).toMatchObject({ transportPhase: "mid_stream", terminalSource: "synthetic" }); + expect(readUsageEntries()[0]).toMatchObject({ transportPhase: "mid_stream", terminalSource: "synthetic" }); + } finally { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); + // The value is caller-controlled, so proving it lands is only half the contract: the // persistence path must also be the SANITIZED one. A test that only ever writes a safe // short slug passes identically whether `sanitizeLogMetadataString` is applied or not. @@ -1760,6 +1786,33 @@ describe("request log metadata", () => { }); describe("request log restart hydrate", () => { + test("persists and rehydrates transport finality evidence", () => { + const persisted = { + requestId: "ocx-finality-evidence", + timestamp: 1_800_000_000_000, + provider: "openai", + model: "gpt-6-astra", + status: 502, + durationMs: 42, + usageStatus: "unreported", + errorCode: "upstream_server_error", + terminalStatus: "failed", + closeReason: "terminal", + upstreamError: "upstream failed", + transportPhase: "mid_stream", + terminalSource: "synthetic", + } as PersistedUsageEntry; + + expect(normalizeUsageEntryForTest(persisted)).toMatchObject({ + transportPhase: "mid_stream", + terminalSource: "synthetic", + }); + expect(requestLogEntryFromPersistedUsage(persisted)).toMatchObject({ + transportPhase: "mid_stream", + terminalSource: "synthetic", + }); + }); + test("projects persisted usage rows into /api/logs entries", () => { const persisted: PersistedUsageEntry = { requestId: "ocx-revive", diff --git a/tests/usage/usage-aggregate-cache.test.ts b/tests/usage/usage-aggregate-cache.test.ts index 3efb5615e4..cdaea2c23b 100644 --- a/tests/usage/usage-aggregate-cache.test.ts +++ b/tests/usage/usage-aggregate-cache.test.ts @@ -23,6 +23,12 @@ import { resetUsageReadCacheForTests, type PersistedUsageEntry } from "../../src import * as usageLedgerScannerModule from "../../src/usage/ledger-scanner"; import { refreshUserCostOverlays } from "../../src/usage/user-cost-overlays"; import { buildRouteDecisionTrace } from "../../src/routing/trace"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { buildResponseJSON } from "../../src/bridge"; +import { formatUsageReport } from "../../src/cli/usage-report"; +import { addFinalRequestLog, clearRequestLogsForTests, type RequestLogContext } from "../../src/server/request-log"; +import type { AdapterEvent } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; const NOW = Date.parse("2026-09-01T10:00:00.000Z"); @@ -62,6 +68,7 @@ beforeEach(() => { }); afterEach(() => { + clearRequestLogsForTests(); resetUsageAggregateCacheForTests(); resetUsageReadCacheForTests(); resetAppOwnedMemoryForTests(); @@ -72,6 +79,56 @@ afterEach(() => { }); describe("retained usage aggregate cache", () => { + test.each(["message_start", "message_delta"].flatMap(phase => + ["bad", [], null, false, 7, { output_tokens: "bad" }].map(usage => ({ phase, usage })), + ))("malformed streamed usage at $phase stays unreported after a valid update: $usage", async ({ phase, usage }) => { + const adapter = withTestTranslatorBudget(createAnthropicAdapter({ + adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "test-key", + })); + const frames = [ + { type: "message_start", message: { usage: phase === "message_start" ? usage : { input_tokens: 10 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }, + ...(phase === "message_delta" ? [{ type: "message_delta", delta: {}, usage }] : []), + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 4 } }, + { type: "message_stop" }, + ].map(frame => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`).join(""); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(frames))) events.push(event); + const logCtx: RequestLogContext = { provider: "anthropic", model: "claude-test" }; + const result = buildResponseJSON(events, "anthropic/claude-test", { onUsage: observed => { logCtx.usage = observed; } }); + expect(result.status).toBe("completed"); + expect(JSON.stringify(result.output)).toContain("ok"); + addFinalRequestLog("malformed-stream-usage", Date.now(), logCtx, 200, { closeReason: "non_stream" }); + const persisted = JSON.parse(readFileSync(join(testDir, "usage.jsonl"), "utf8").trim()); + expect(persisted.usageStatus).toBe("unreported"); + expect(persisted.usage).toBeUndefined(); + const report = (await getUsageAggregate()).accumulator.summarize("all", Date.now()); + expect(report.summary.requests).toBe(1); + expect(report.summary.unmeteredRequests).toBe(1); + }); + + test("malformed Anthropic usage stays unmetered through the real ledger and human report", async () => { + const adapter = withTestTranslatorBudget(createAnthropicAdapter({ + adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "test-key", + })); + const response = Response.json({ + content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: "\x1b[2J" }, + }); + const events = await adapter.parseResponse!(response) as AdapterEvent[]; + const logCtx: RequestLogContext = { provider: "anthropic", model: "claude-test" }; + buildResponseJSON(events, "anthropic/claude-test", { onUsage: usage => { logCtx.usage = usage; } }); + addFinalRequestLog("malformed-usage", Date.now(), logCtx, 200, { closeReason: "non_stream" }); + const persisted = JSON.parse(readFileSync(join(testDir, "usage.jsonl"), "utf8").trim()); + const report = (await getUsageAggregate()).accumulator.summarize("all", Date.now()); + expect(report.summary.requests).toBe(1); + expect(formatUsageReport(report).every(line => !/[\x00-\x1f\x7f-\x9f]/.test(line))).toBe(true); + expect(persisted.usageStatus).toBe("unreported"); + expect(persisted.usage).toBeUndefined(); + expect(report.summary.unmeteredRequests).toBe(1); + }); + test("custom cache keys isolate both endpoints and never poison preset aggregates", async () => { const path = join(testDir, "usage.jsonl"); const rows = [NOW - 2_000, NOW - 1_000, NOW].map((timestamp, index) => ({ ...entry(String(index)), timestamp })); diff --git a/tests/videos/xai-video-client.test.ts b/tests/videos/xai-video-client.test.ts index fd8d773484..2b56ab3e4a 100644 --- a/tests/videos/xai-video-client.test.ts +++ b/tests/videos/xai-video-client.test.ts @@ -16,6 +16,31 @@ function mockFetchResponse(body: unknown, status = 200): Response { }); } +for (const phase of ["submit", "poll"] as const) test.each([307, 308])(`video ${phase} never follows %i`, async status => { + let targetHits = 0; + let originHits = 0; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + targetHits++; + return Response.json({ request_id: "redirected", status: "done" }); + } }); + const origin = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => { + originHits++; + return new Response("redirect", { status, headers: { location: `http://127.0.0.1:${target.port}/target` } }); + } }); + try { + const scopedAuth = { baseUrl: `http://127.0.0.1:${origin.port}`, token: "synthetic-video-token" }; + const result = phase === "submit" ? submitVideoJob({ prompt: "synthetic prompt" }, scopedAuth) : pollVideoJob("job", scopedAuth); + const error = await result.catch(error => error as Error & { status: number }); + expect(targetHits).toBe(0); + expect(originHits).toBe(1); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ status }); + } finally { + await origin.stop(true); + await target.stop(true); + } +}); + describe("submitVideoJob", () => { test("returns request_id from response", async () => { const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ request_id: "vid-123" }))); diff --git a/tests/vision/vision-anthropic.test.ts b/tests/vision/vision-anthropic.test.ts index fe3ad8adfe..86b08f2289 100644 --- a/tests/vision/vision-anthropic.test.ts +++ b/tests/vision/vision-anthropic.test.ts @@ -182,6 +182,7 @@ describe("Anthropic vision executor", () => { test("POSTs /v1/messages with the Claude Code OAuth fingerprint and a base64 image block", async () => { let captured: { url: string; headers: Headers; body: Record<string, unknown> } | undefined; globalThis.fetch = (async (url, init) => { + expect(init?.redirect).toBe("manual"); captured = { url: String(url), headers: new Headers(init?.headers), diff --git a/tests/web-search/web-search-anthropic.test.ts b/tests/web-search/web-search-anthropic.test.ts index 980eacddaa..bba64616be 100644 --- a/tests/web-search/web-search-anthropic.test.ts +++ b/tests/web-search/web-search-anthropic.test.ts @@ -291,6 +291,7 @@ describe("runAnthropicWebSearch request shape", () => { test("POSTs /v1/messages with the OAuth fingerprint, disabled thinking, and the web_search tool", async () => { let captured: { url: string; headers: Record<string, string>; body: Record<string, unknown> } | null = null; globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + expect(init?.redirect).toBe("manual"); const headers: Record<string, string> = {}; new Headers(init?.headers).forEach((v, k) => { headers[k] = v; }); captured = { url: String(url), headers, body: JSON.parse(String(init?.body)) }; diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index a5e9ccc780..315ee73a5e 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -2691,5 +2691,6 @@ describe("connection-reset recovery parity on the web-search legs", () => { // The loop sets accept-encoding: identity so raw byte progress stays observable; the recovery // helper clones headers into a Headers instance and must not drop it. expect(typeof attempts[1]!.body).toBe("string"); + expect(attempts.every(attempt => attempt.redirect === "manual")).toBe(true); }); }); diff --git a/tests/windows/windows-secret-acl.test.ts b/tests/windows/windows-secret-acl.test.ts index aa011bb516..29d1b7bc3f 100644 --- a/tests/windows/windows-secret-acl.test.ts +++ b/tests/windows/windows-secret-acl.test.ts @@ -632,6 +632,30 @@ describe("icacls executable authority", () => { }); }); +describe("atomic secret temp writer portability", () => { + test("sync and async secret temp writers use Bun-portable exclusive creation", async () => { + // Bun on Windows misinterpreted the equivalent numeric O_* combination as + // ENOENT, so every pid/config/oauth temp write failed during ocx start + // and on management-API config saves. Keep both writers on the portable + // exclusive-write spelling ("wx" keeps O_EXCL; 0o600 keeps the private + // mode) so the O_CREAT bit can never be dropped again. + const src = readFileSync(repoPath("src", "config", "atomic-write.ts"), "utf8"); + expect(src.match(/openSync\(path, "wx", 0o600\)/g)).toHaveLength(2); + }); +}); + +describe("initial config temp writer portability", () => { + test("initial config publication uses Bun-portable exclusive creation", () => { + // publishInitialConfigNoReplace carries the same Bun/Windows exposure as the + // atomic writers above: the numeric O_* combination lost its creation bit, so + // first-run `ocx init` failed before it could publish config.json. Exclusive + // creation is what makes the added O_TRUNC harmless — an existing temp name + // (or a symlink planted at one) fails the open instead of being truncated. + const src = readFileSync(repoPath("src", "config", "initialize.ts"), "utf8"); + expect(src.match(/openSync\(temp, "wx", 0o600\)/g)).toHaveLength(1); + }); +}); + describe("diagnostics sanitization contract", () => { test("HardenResult diagnostics field is a plain string when present", () => { const filePath = join(testDir, "diag-test.json");