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 5987bfa991..8d3e72c619 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 70a17a7a81..371b222e52 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 编程成为真正的生产力工具。点此链接注册,立即开始使用!
---> --- @@ -288,7 +291,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 c4619c2866..6161766917 100644 --- a/bun.lock +++ b/bun.lock @@ -8,11 +8,11 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.4.0", + "bun": "1.4.2", "zod": "4.4.3", }, "devDependencies": { - "@types/bun": "1.4.0", + "@types/bun": "1.4.2", "typescript": "7.0.2", }, }, @@ -60,31 +60,31 @@ "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.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=="], - "@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=="], @@ -136,9 +136,9 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun": ["bun@1.4.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/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/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_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/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/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 e9eaa5219c..14bf8bb0d3 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 @@ -84,8 +84,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 59652bbaef..d87aa4895a 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 d4f3dc266c..7cc7d977e4 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 | @@ -317,16 +321,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/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 0c3cb32697..59bac66535 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -542,6 +542,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 bcda44080b..6f9b756789 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -319,3 +319,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 2944208279..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 @@ -76,6 +83,42 @@ 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. @@ -245,6 +288,74 @@ The cache remains bounded; this does not extend retention or recover history the 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 @@ -605,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 @@ -619,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/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 0279ee96ba..277e9d1370 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 | @@ -333,6 +340,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` | @@ -356,6 +378,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` | @@ -617,6 +640,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 43c5c09d90..1a7293dab8 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -99,8 +99,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 d4dc59de4c..8d2be3b2e8 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 @@ -56,7 +56,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 b7952b5c28..9f8612a2c6 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` の別名です。設定のクリーンアップには、新規インストールによって作成された所有権メタデータが必要です。従来のディレクトリまたは共有ディレクトリはそのまま残ります。 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 8f0a03bc09..a04596512a 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` をクライアントへ返す場合があります。クライアントのリダイレクト動作は、このサーバー転送ポリシーとは別です。 + ## エンドポイントの概要 |クライアントサーフェス |エンドポイント |非ストリームの結果が成功 |成功したストリームまたはソケットの結果 | @@ -234,14 +238,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 90857854f0..527c810d42 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -425,6 +425,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 41f90537cd..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를 바라보게 합니다. @@ -250,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/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 5baf9853ea..1372ed7c15 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 @@ -56,7 +56,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 3624a2a803..76cef4bda7 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 d4c19ebd46..925e6b363a 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 전달 경로가 공식 클라이언트 메타데이터를 꾸며 내지 않는다는 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 ed3c6a565d..fd99e29147 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/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 7837ae4d22..fbabaf0cb3 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`을 클라이언트에 반환할 수 있습니다. 클라이언트의 리다이렉트 동작은 이 서버 전송 정책과 별개입니다. + ## 엔드포인트 개요 | 클라이언트 표면 | 엔드포인트 | 성공한 비스트리밍 결과 | 성공한 스트리밍 또는 소켓 결과 | @@ -299,16 +303,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/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 4b95d1bcd2..ab96881d11 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 a7d61a0bee..24ccd5c094 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 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 21760eef0f..bc5f56e02d 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 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index f32a2fdc09..f7e8f16abe 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -124,7 +124,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | 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?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index cdc8d6af52..0fe874c9a9 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -27,6 +27,7 @@ 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). | +| `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. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index c0dd38f1fb..b7fdbf072f 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -389,7 +389,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 | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 1f2e589252..c44f335a71 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 | @@ -465,16 +469,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 d8cf873514..7dfd7ea94c 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 @@ -78,8 +78,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/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index f743f5f468..e72d956bcd 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, а затем 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 a3ef007784..d87a28c08f 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 результат | Успешный результат потока или сокета | @@ -301,16 +305,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 3ce565d2cf..c03f23ba44 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 @@ -94,8 +94,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 6a7a565139..0aa50bfebf 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 6989e86a22..fed53c1afb 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 | @@ -326,17 +330,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 fae4b5a7e1..c8c6bcba3d 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 @@ -56,7 +56,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 5977c734de..541485dbf4 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` 的别名。配置清理需要由全新安装创建的所有权元数据;旧版或共享目录会保留原样。 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 9736aeaff9..8e3d4d8714 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`。客户端的重定向行为与此服务器传输策略是不同的边界。 + ## 端点总览 | 客户端表面 | 端点 | 成功的非流式结果 | 成功的流式或套接字结果 | @@ -247,15 +251,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 ca74fe7dd6..59be70f24d 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 @@ -61,7 +61,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 71d575e774..0eaf6c75f9 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 a4baa921c9..361a5892be 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 d91433490d..82fe041b95 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -8,6 +8,7 @@ import { useMemo, useState } from "react"; import { useT } from "../../i18n/shared"; import { bucketPresets, + pinSponsors, filterPresets, type CatalogPreset, } from "./provider-presets"; @@ -95,7 +96,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]); @@ -112,7 +113,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 6598f43b07..60efdb3950 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -13,6 +13,8 @@ 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; @@ -30,12 +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, }: { item: WorkspaceItem; + preset?: CatalogPreset; usageTotals?: ProviderUsageTotals; quotaReport?: ProviderQuotaReportView; currentQuotaReading?: AccountQuotaReading; @@ -140,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")}> @@ -166,12 +171,6 @@ export default function ProviderOverview({ <dt>{t("modal.defaultModel")}</dt> <dd>{item.defaultModel ?? <span className="muted">—</span>}</dd> </div> - {item.note && ( - <div className="pws-kv-row"> - <dt>{t("pws.cell.note")}</dt> - <dd className="muted">{item.note}</dd> - </div> - )} </dl> {apiBase && ( <div className="row" style={{ marginTop: 12, alignItems: "center" }}> @@ -250,6 +249,7 @@ export default function ProviderOverview({ </div> )} </section> + <NotesSection item={item} onUpdateProvider={onUpdateProvider} /> </div> <aside className="pws-overview-sidebar"> @@ -280,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/i18n/de.ts b/gui/src/i18n/de.ts index 40ee6ea36f..88c29b7c9d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1777,6 +1777,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.", @@ -1875,6 +1876,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.", @@ -2485,6 +2493,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 53d940d7e6..578311546f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -579,6 +579,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", @@ -1010,6 +1012,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.", @@ -1132,6 +1135,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 7afb44ab2c..3c9d66243d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -564,6 +564,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", @@ -986,6 +988,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.", @@ -1105,6 +1108,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 75095da332..d40c44bf53 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -925,6 +925,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 を終了して再試行してください。", @@ -1047,6 +1048,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 キー不要です。そのまま利用できます。", @@ -2506,6 +2514,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 5ae812e9e8..126e0a3f0c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1816,6 +1816,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를 종료한 뒤 다시 시도하세요.", @@ -1914,6 +1915,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 키가 필요 없습니다. 바로 사용할 수 있습니다.", @@ -2507,6 +2515,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 2900abb6f3..7898a70c67 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -980,6 +980,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 и повторите попытку.", @@ -1102,6 +1103,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-ключ не нужен. Работает из коробки.", @@ -2508,6 +2516,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 f551a97d43..6397b05022 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -999,6 +999,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.", @@ -1121,6 +1122,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.", @@ -2508,6 +2516,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 ae0ee0517b..f2777a2591 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -788,6 +788,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 後重試。", @@ -900,6 +901,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 金鑰,開箱即用。", @@ -2470,6 +2478,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 ca92aaeafc..36f81b44f1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1797,6 +1797,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 后重试。", @@ -1895,6 +1896,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 密钥,开箱即用。", @@ -2506,6 +2514,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 55d9c16106..d14a81005d 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -302,11 +302,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; // second identical value bails out of React's state diff, so the old timer would dismiss // the new toast early. Every publish bumps the generation. const [feedbackGen, setFeedbackGen] = useState(0); - const publishFeedback = (nextOk: boolean, message: string) => { + const publishFeedback = useCallback((nextOk: boolean, message: string) => { setOk(nextOk); setStatus(message); setFeedbackGen(g => g + 1); - }; + }, []); // Transient action feedback as a fixed toast: appearing or auto-clearing it never shifts // the workspace below (the old inline Notice pushed the whole model grid down by its // height on every apply). The timer itself just clears the status again. @@ -695,7 +695,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; setDisplayNameSaving(false); } } - }, [apiBase, displayNameModel, displayNameRecovery, finishDisplayNameEdit, load, t]); + }, [apiBase, displayNameModel, displayNameRecovery, finishDisplayNameEdit, load, publishFeedback, t]); // Shadow/v2 controls must not wait on the models catalog (live discovery can be slow). useEffect(() => { diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 78b2096d95..665044591f 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"; @@ -290,13 +291,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; }, ); @@ -598,6 +599,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 fceb45fe04..751e609de1 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -29,6 +29,7 @@ interface CleanupResult { trashDir?: string; error?: string; message?: string; + skippedReferencedPaths?: string[]; } interface TrashEntry { @@ -222,11 +223,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/Usage.tsx b/gui/src/pages/Usage.tsx index cfcf00e578..96f0f1db0c 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"; @@ -804,6 +805,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; @@ -877,54 +879,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 b6914586b6..029e39b2da 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 605d4eefc4..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; @@ -106,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, }, }; @@ -677,7 +679,7 @@ 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(); + if (key === "codexDesktopAuthless" || key === "codexClientCompaction") await runSync(); } catch { dispatchSettings({ type: "save-failed" }); setError(true); @@ -689,6 +691,7 @@ export function useDashboardData(apiBase: string) { 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 @@ -851,7 +854,8 @@ export function useDashboardData(apiBase: string) { effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, filteredGroups, sidecarModels, visionModels, - saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, toggleCodexDesktopAuthless, runSync, clearSyncFeedback, + saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, toggleCodexDesktopAuthless, + toggleCodexClientCompaction, runSync, clearSyncFeedback, fetchUpdateCheck, closeUpdateDialog, openUpdateDialog, changeUpdateChannel, runUpdate, }; } 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.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/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/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index db257ba6b2..ea4e98e51c 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -85,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}"]`)!; @@ -97,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); @@ -357,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 95794a7f46..2346ee05be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.48.0-preview.20260908", + "version": "2.49.0-preview.20260909", "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", @@ -66,11 +68,11 @@ "@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": { - "@types/bun": "1.4.0", + "@types/bun": "1.4.2", "typescript": "7.0.2" }, "overrides": { diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 444963711c..8587431f7d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -222,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", @@ -236,11 +237,11 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", - "aside-profiles-routes.test.ts": "server", - "aside-profiles.test.ts": "clients", - "aside-profile-paths.test.ts": "clients", "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", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", @@ -266,6 +267,7 @@ "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", @@ -336,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", @@ -357,17 +360,19 @@ "client-config-export.test.ts": "config", "client-config-new-clients.test.ts": "config", "client-connect.test.ts": "clients", - "client-injection-guard.test.ts": "codex-integration", - "client-lifecycle-lock.test.ts": "clients", "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", @@ -455,9 +460,9 @@ "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-rejection.test.ts": "codex-integration", "codex-refresh.test.ts": "codex-integration", @@ -597,9 +602,9 @@ "desktop-3p-guard.test.ts": "clients", "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", - "desktop-remote-store.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", @@ -618,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", @@ -686,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", @@ -789,13 +797,13 @@ "loopback-listener-admission.test.ts": "server", "loopback-listener-integration.test.ts": "server", "macos-serial-lanes.test.ts": "ci-workflows", - "management-api-logs-metrics.test.ts": "server", "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", @@ -812,12 +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-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", @@ -850,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", @@ -923,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", @@ -978,6 +988,8 @@ "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", @@ -991,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", @@ -1016,7 +1017,6 @@ "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", - "version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", "remove-tree-helper.test.ts": "lib", "repo-hygiene.test.ts": "ci-workflows", @@ -1027,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", @@ -1034,13 +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-function-tool-repair.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", @@ -1127,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", @@ -1145,6 +1156,7 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.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", @@ -1234,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", @@ -1299,12 +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", - "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", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index a9a8279198..cc3dfebf3f 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -566,16 +566,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, @@ -584,15 +596,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 } { @@ -1059,7 +1074,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; @@ -1113,7 +1128,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; } @@ -1202,7 +1217,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 9d828af0bd..9e1b46307e 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -429,6 +429,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 }; } @@ -894,17 +906,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 2900fd58b2..c4aa523ee6 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -25,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 { @@ -649,14 +650,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" }, }; } @@ -2134,6 +2139,7 @@ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ 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 = [ @@ -2142,12 +2148,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, 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/cli/dispatch.ts b/src/cli/dispatch.ts index c884bb2e5d..e85de1c05f 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 d7148530a0..a769252620 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -45,6 +45,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, @@ -1000,6 +1003,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(); @@ -1330,6 +1368,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 0e4ea89d72..1daed4c679 100644 --- a/src/cli/effort.ts +++ b/src/cli/effort.ts @@ -2,6 +2,7 @@ import { loadConfig, saveConfig } 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 0cd6bec4dc..43916695b6 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 663514a120..309eea763a 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1442,8 +1442,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 467a0f7971..73bbd68e31 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 2e2a775867..1a1e66d88b 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -241,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). @@ -264,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. 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 b90e0b12cf..add955bb45 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, @@ -1412,6 +1425,13 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid // supplying a recognized field changes behavior (#1797). plainRecord(item.meta)?.n_ctx, 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( @@ -1582,6 +1602,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/inject.ts b/src/codex/inject.ts index 37e73c8e33..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, @@ -174,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 { @@ -197,14 +200,19 @@ function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTar return { ...target, baseUrl: `${parsed.origin}/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" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless">, + config?: Pick< + OcxConfig, + "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction" + >, ): CodexRoutingTarget { const loopback = config?.unauthenticatedLoopbackListener; const effectivePort = loopback?.enabled ? loopback.port : port; @@ -217,6 +225,9 @@ export function standaloneCodexRoutingTarget( ...(config?.codexDesktopAuthless === true && !requiresAdmissionToken ? { desktopAuthless: true } : {}), + ...(config?.codexClientCompaction === true && !requiresAdmissionToken + ? { clientCompaction: true } + : {}), }; } @@ -833,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)", @@ -1014,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 @@ -1030,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. @@ -1149,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, @@ -1157,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 @@ -1318,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 @@ -1331,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: @@ -1363,17 +1428,41 @@ 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 { success: true, @@ -1391,7 +1480,9 @@ export async function injectCodexConfig( } 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 { @@ -1405,7 +1496,7 @@ 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}`), }; @@ -1733,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/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts index 370bbda95e..0d9bee79ef 100644 --- a/src/codex/internal/catalog-writer.ts +++ b/src/codex/internal/catalog-writer.ts @@ -16,6 +16,7 @@ import { forgetEphemeralSecretPath, hardenSecretPath, } from "../../lib/windows-secret-acl"; +import { resetCodexAppServerCatalogStateCache } from "../app-server-processes"; export interface PreparedCatalogFileWrite { readonly path: string; @@ -167,6 +168,7 @@ export function replaceActiveCodexCatalog( ): void { assertCatalogWritePermit(permit, owningCodexHome); atomicWriteFile(prepared.path, prepared.content, io); + resetCodexAppServerCatalogStateCache(); } /** Atomically publish the catalog-path-keyed immutable backup without clobbering. */ @@ -200,4 +202,5 @@ export function replaceCodexModelsCache( ): void { assertCatalogWritePermit(permit, owningCodexHome); atomicWriteFile(prepared.path, prepared.content, io); + resetCodexAppServerCatalogStateCache(); } 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/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.ts b/src/codex/quota.ts index 0648e4a717..351f5fb994 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -336,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; @@ -795,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; @@ -803,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/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/sync.ts b/src/codex/sync.ts index 4c10068b74..6d7008a01a 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -8,6 +8,7 @@ import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./cat import { shouldSyncCodexOnStart } from "./desired-state"; import { admitCodexWrite, type CodexAdmission } from "./admission"; import type { CodexCatalogSyncOptions } from "./catalog/sync"; +import { resetCodexAppServerCatalogStateCache } from "./app-server-processes"; export interface CodexSyncResult { /** @@ -116,6 +117,10 @@ export async function syncModelsToCodex( message: admission.message, }; } + // Config injection is a relevant Codex write even when the catalog bytes are unchanged. + // Drop cached process evidence before async discovery so a process that appeared since the + // last read cannot make native-default guidance report active after this sync. + resetCodexAppServerCatalogStateCache(); const p = port ?? config.port ?? 10100; const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)(); diff --git a/src/config.ts b/src/config.ts index 8da89cbfdf..8cfaf63391 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1189,6 +1189,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/images/loop.ts b/src/images/loop.ts index 7d4855f91b..e33ae02b41 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -537,6 +537,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 13ab3a0c95..49da43987c 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 @@ -111,8 +132,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; } @@ -120,7 +156,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 { @@ -140,10 +177,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/upstream-retry.ts b/src/lib/upstream-retry.ts index 49049dbeb2..74302af51f 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 bb4d1c8497..9f1ea858ff 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -40,6 +40,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; @@ -50,6 +106,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 f876b18b38..f1669c7576 100644 --- a/src/oauth/xai.ts +++ b/src/oauth/xai.ts @@ -1,4 +1,5 @@ /** 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"; @@ -11,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."; @@ -45,10 +49,21 @@ 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(); } @@ -94,15 +109,135 @@ 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,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{}const suffix=detail?`: ${detail}`:oauthError?`: ${oauthError}`:"";return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}${suffix}`);} +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; + 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: { @@ -111,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 8cf14cfcb3..131c3dfa80 100644 --- a/src/providers/api-key-selection.ts +++ b/src/providers/api-key-selection.ts @@ -6,14 +6,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/derive.ts b/src/providers/derive.ts index 02852fce39..67e6c0522e 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. @@ -607,6 +611,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 5beb971a4e..9f03b6e11e 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; @@ -1903,6 +1925,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, @@ -1915,6 +1940,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. @@ -3140,6 +3184,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 ad1aaeff35..8528370efb 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 { 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 8c3e37eea8..7ff76f0f2e 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -603,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" }; @@ -837,6 +837,7 @@ async function handleClaudeMessagesWithBudget( } 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"}. @@ -852,8 +853,13 @@ 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) { @@ -923,6 +929,10 @@ 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, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), 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/management-api.ts b/src/server/management-api.ts index c703a33e07..118afd5ffd 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -300,6 +300,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/config-routes.ts b/src/server/management/config-routes.ts index 08f4b85d27..527178ba0f 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -329,6 +329,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon oauthOpenBrowser: config.oauthOpenBrowser !== false, // 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), @@ -419,6 +421,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon ultraFastTier?: unknown; codexMainAccountHardLock?: unknown; codexDesktopAuthless?: unknown; + codexClientCompaction?: unknown; }; if (body.codexAutoStart === undefined && body.streamMode === undefined @@ -429,8 +432,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon && body.showCodexSparkQuota === undefined && body.ultraFastTier === undefined && body.codexMainAccountHardLock === undefined - && body.codexDesktopAuthless === undefined) { - return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, showCodexSparkQuota, ultraFastTier, codexMainAccountHardLock, or codexDesktopAuthless" }, 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); @@ -457,6 +461,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon if (body.codexDesktopAuthless !== undefined && typeof body.codexDesktopAuthless !== "boolean") { return jsonResponse({ error: "codexDesktopAuthless boolean is required" }, 400); } + 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) { if (!isPlainRecord(body.codexQuotaAutoRefresh)) { @@ -509,10 +516,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon 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 clientCompactionWasEnabled = config.codexClientCompaction === true; try { if (typeof body.codexAutoStart === "boolean") { config.codexAutoStart = body.codexAutoStart; @@ -547,6 +557,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon 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 = { ...(config.codexQuotaAutoRefresh?.[id] ?? {}) }; @@ -592,16 +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(); } - // The authless switch changes the injected config.toml shape, so converge now rather than - // waiting for the next start; the injector re-reads config and rewrites the form. + // 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 @@ -620,6 +638,7 @@ 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), diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 0177e56776..a66ff4b79c 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -425,6 +425,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 e63001c980..56158bd162 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -366,13 +366,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 92b3c21381..0fce336631 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -40,6 +40,8 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +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"; @@ -1350,6 +1352,28 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp message: `Connected. ${live.models.length} models.`, }); } + 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`" }); 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 6fc90aab74..823dad9483 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -22,6 +22,8 @@ import { appendUsageEntry, isKnownAdmissionKind, isKnownInboundProtocol, + isKnownTerminalSource, + isKnownTransportPhase, isKnownUsageSurface, isCodexUsageAccountLogLabel, isValidReasoningWireValue, @@ -320,6 +322,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 } : {}), }; @@ -441,6 +445,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 e01a79cea8..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"; @@ -233,13 +234,7 @@ 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>"; @@ -491,8 +486,8 @@ export async function multiAgentGuidanceText( } 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>`; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b961e7cef9..c40ebaba09 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -210,11 +210,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"; @@ -1121,7 +1124,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; @@ -1287,7 +1291,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"; @@ -1321,7 +1325,7 @@ async function retryCodexPoolOnAlternateAccount( } try { retryAuthCtx ??= await resolveCodexAuthContext( - req.headers, + callerAuthHeaders, config, "pool", { @@ -1329,7 +1333,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, }, @@ -1399,7 +1403,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, @@ -1703,9 +1707,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. */ @@ -1925,6 +1937,9 @@ export function createChildPassthroughCallbackGate(options: HandleResponsesOptio 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"); @@ -2009,7 +2024,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 }; /** @@ -2021,8 +2036,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 @@ -2046,15 +2128,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, @@ -2090,7 +2173,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, @@ -2109,10 +2192,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) { @@ -3084,6 +3184,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", @@ -3332,6 +3438,7 @@ async function handleResponsesInner( logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier); 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 @@ -3353,6 +3460,7 @@ async function handleResponsesInner( } catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ } const targetRoute = resolveRoute(_sci.model); if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) { + credentialDomainWasRewritten = true; const _sciOriginal = parsed.modelId; parsed.modelId = _sci.model; if (parsed._rawBody && typeof parsed._rawBody === "object") { @@ -3484,6 +3592,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) { @@ -3620,6 +3729,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) { @@ -3742,11 +3852,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; } @@ -4036,7 +4148,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. @@ -4223,9 +4336,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 }; @@ -4313,9 +4426,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, @@ -5400,7 +5522,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, diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index b6365be4be..00bdbdc0f2 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -74,13 +74,20 @@ export function providerFetch( const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => { base.preconnect?.(...args); }; + // Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends. + // Return the original 3xx so the response owner retains its retry/health/relay contract. + const dispatch = Object.assign( + (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, base) - : base(input, dispatchInit); + ? options.dispatchOverride(input, dispatchInit, dispatch) + : dispatch(input, dispatchInit); }, { preconnect }, ) as typeof globalThis.fetch; @@ -163,6 +170,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">, @@ -170,7 +181,8 @@ export async function fetchWithHeaderTimeout( timeoutMs: number, preferIdentityEncoding = false, executor: typeof globalThis.fetch = globalThis.fetch, - manualRedirect = false, + // Retained for existing callers; credential-bearing transport no longer opts out. + _manualRedirect = false, ): Promise<Response> { const pacing = executor as ProviderFetch; await pacing.waitForPacing?.(abortSignal); @@ -189,10 +201,9 @@ export async function fetchWithHeaderTimeout( return await fetchExecutor(url, { ...init, headers, - // Credential-bearing sends opt into manual redirects so a 3xx is relayed - // as a Response instead of being followed into a rejection that is - // indistinguishable from a pre-connection failure (#914). - ...(manualRedirect ? { 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/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 0b0a2b2b98..7baadbfadf 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -693,6 +693,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 @@ -753,7 +759,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 fd8408cc19..2944c22f9a 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -172,6 +172,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 @@ -217,6 +221,22 @@ export function isKnownInboundProtocol(value: unknown): value is NonNullable<Per return typeof value === "string" && KNOWN_INBOUND_PROTOCOLS.has(value as NonNullable<PersistedUsageEntry["inboundProtocol"]>); } +const KNOWN_TRANSPORT_PHASES = new Set<NonNullable<PersistedUsageEntry["transportPhase"]>>([ + "pre_headers", "mid_stream", "terminal_sse", +]); + +export function isKnownTransportPhase(value: unknown): value is NonNullable<PersistedUsageEntry["transportPhase"]> { + return typeof value === "string" && KNOWN_TRANSPORT_PHASES.has(value as NonNullable<PersistedUsageEntry["transportPhase"]>); +} + +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 { return join(configDir ?? getConfigDir(), "usage.jsonl"); } @@ -511,6 +531,8 @@ 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; @@ -579,6 +601,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 } : {}), 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 0c957e1c17..99b275ed8d 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -478,6 +478,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/src/web-search/progress-stream.ts b/src/web-search/progress-stream.ts index f51a55efc6..e3889eb5bc 100644 --- a/src/web-search/progress-stream.ts +++ b/src/web-search/progress-stream.ts @@ -303,6 +303,10 @@ export async function* parseStreamWithProgress( } if (event.type === "done" || event.type === "incomplete") { heldTerminal = event; + // Response-byte inactivity ends once the adapter has produced a terminal event. + // From here the separate post-terminal drain guard owns the bounded wait for + // iterator cleanup, so leaving the inactivity timer armed creates a false timeout. + clearInactivity(); continue; } await handoff.deliver(event); 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 572c73f25f..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 diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index d3f20de401..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. @@ -470,7 +475,10 @@ custom `injectionPrompt` bodies. The built-in text reports the resolved preferre 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 retains its `<multi_agent_mode>` proactive text at `max` or `ultra`. +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 diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index c98c837cec..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 diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index d6e66375b7..2c21897855 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -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: diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 91627acaf0..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 @@ -160,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 09952ea5ab..19eeed80a1 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); @@ -143,6 +166,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/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 38ab13eac6..f6c50559f5 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/cli/cli-effort.test.ts b/tests/cli/cli-effort.test.ts index 6e8079183b..b80028fd02 100644 --- a/tests/cli/cli-effort.test.ts +++ b/tests/cli/cli-effort.test.ts @@ -112,6 +112,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 () => { @@ -210,6 +316,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 78f0cc04a1..5594d44a59 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 d101b75bc8..1020439e49 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"; @@ -288,8 +288,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); @@ -345,6 +345,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-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 15e6758b7a..95b5291a1a 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"; @@ -81,6 +82,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(), })); @@ -181,6 +184,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-status-json.test.ts b/tests/cli/cli-status-json.test.ts index 10ab4f110e..24191ba887 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, spyOn, test } from "bun:test"; +import { beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; @@ -711,13 +711,15 @@ describe("status reports stale process records end to end", () => { * discard port 9 is conventionally unused but not guaranteed, and if anything answers * on it the probe is accepted rather than refused and these fixtures invert. */ - let freePort = 9; - beforeAll(async () => { + async function allocateFreePort(): Promise<number> { const probe = createServer(); await new Promise<void>(resolve => { probe.listen(0, "127.0.0.1", () => resolve()); }); - freePort = (probe.address() as AddressInfo).port; + const port = (probe.address() as AddressInfo).port; await new Promise<void>(resolve => { probe.close(() => resolve()); }); - }); + return port; + } + let freePort: number; + beforeEach(async () => { freePort = await allocateFreePort(); }); test("a dead owner record surfaces in --json and in human output", () => { const home = mkdtempSync(join(tmpdir(), "ocx-stale-json-")); @@ -787,10 +789,14 @@ describe("status reports stale process records end to end", () => { await new Promise<void>(resolve => { occupied.listen(0, "127.0.0.1", () => resolve()); }); const occupiedPort = (occupied.address() as AddressInfo).port; try { + // Allocate after the listener is bound: it can reuse the port released by + // beforeEach, so that earlier number no longer proves a refused endpoint. + const recordedPort = await allocateFreePort(); + expect(recordedPort).not.toBe(occupiedPort); const pid = findDeadPid(); writeFileSync(join(home, "config.json"), JSON.stringify({ port: occupiedPort, codexAutoStart: false }), "utf8"); writeFileSync(join(home, "ocx.pid"), String(pid), "utf8"); - writeFileSync(join(home, "runtime-port.json"), JSON.stringify({ pid, port: freePort, hostname: "127.0.0.1" }), "utf8"); + writeFileSync(join(home, "runtime-port.json"), JSON.stringify({ pid, port: recordedPort, hostname: "127.0.0.1" }), "utf8"); const parsed = JSON.parse(runStatusJson(home).stdout) as { proxy?: { staleProcessState?: unknown } }; expect(parsed.proxy?.staleProcessState).toBe(true); 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-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 73417b85f7..ac09216229 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"; @@ -1262,6 +1263,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", 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 bb3c8a880f..38f362c81b 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -1640,6 +1640,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. @@ -7085,6 +7122,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 b333fa3c65..44730e5453 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -484,9 +484,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-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 b6be3c2f69..5208e93653 100644 --- a/tests/codex-integration/codex-inject.test.ts +++ b/tests/codex-integration/codex-inject.test.ts @@ -70,6 +70,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-models-cache-invalidate.test.ts b/tests/codex-integration/codex-models-cache-invalidate.test.ts index 3efb316511..b644740e40 100644 --- a/tests/codex-integration/codex-models-cache-invalidate.test.ts +++ b/tests/codex-integration/codex-models-cache-invalidate.test.ts @@ -5,9 +5,14 @@ import { join } from "node:path"; import { invalidateCodexModelsCache } from "../../src/codex/catalog"; import { invalidateCodexModelsCacheWithPermit } from "../../src/codex/catalog/sync"; import { withCatalogWriteSerialization } from "../../src/codex/catalog-write-serialization"; -import { afterCatalogWriteHandleAppServers } from "../../src/codex/app-server-processes"; +import { + 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"; @@ -32,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; @@ -298,4 +305,55 @@ 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: "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((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((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: "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((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(19107, emptyConfig, null, { + refreshCodexModelCatalog: async () => ({ + added: 0, + path: join(codexHome, "opencodex-catalog.json"), + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + comboOmissions: [], + }), + injectCodexConfig: async () => ({ success: true, message: "injected" }), + currentExternalCodexModelProvider: () => null, + }); + + 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-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-retained-root-serialization.test.ts b/tests/codex-integration/codex-retained-root-serialization.test.ts index 07f6446582..f97877ed90 100644 --- a/tests/codex-integration/codex-retained-root-serialization.test.ts +++ b/tests/codex-integration/codex-retained-root-serialization.test.ts @@ -21,6 +21,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; 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[] = []; @@ -124,6 +125,21 @@ 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 @@ -135,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 @@ -338,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 = { @@ -370,27 +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 syncResult = captureChildResult(sync); - await raceBarrier(sync, waitForPath(requested, INTERNAL_DEADLINE_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, - new Response(sync.stdout).text(), - new Response(sync.stderr).text(), - ]); + // 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); } - }, SPAWN_BUDGET_MS); + }, SPAWN_BUDGET_MS * 2); } /** @@ -447,8 +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 syncResult = captureChildResult(sync); - await raceBarrier(sync, waitForPath(requested, INTERNAL_DEADLINE_MS)); + await raceBarrier(syncResult, waitForPath(requested, INTERNAL_DEADLINE_MS)); // Another process selects a different Codex runtime. No catalog byte changes. writeFileSync(runtimeStatePath, `${JSON.stringify({ @@ -460,11 +498,8 @@ 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, - new Response(sync.stdout).text(), - new Response(sync.stderr).text(), - ]); + 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); diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 11774ef474..85ecb3a3fd 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -1746,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 }, @@ -1756,7 +1757,8 @@ 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({ @@ -1765,7 +1767,10 @@ describe("codex routing", () => { shortWindowSeconds: 5 * 60 * 60, 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 }, + ], }); }); 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-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/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/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 6b3d1374b2..181bcbc32c 100644 --- a/tests/codex-integration/multi-agent-compat.test.ts +++ b/tests/codex-integration/multi-agent-compat.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; 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 () => { @@ -956,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", 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 56a4d74659..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)); @@ -381,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( @@ -401,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 272e45be6a..514ba6110a 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -379,6 +379,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 2aafc9c654..f62377f3e7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -57,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", @@ -71,11 +72,11 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", - "aside-profiles-routes.test.ts": "server", - "aside-profiles.test.ts": "clients", - "aside-profile-paths.test.ts": "clients", "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", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", @@ -101,6 +102,7 @@ "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", @@ -128,7 +130,6 @@ "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", - "claude-source-envelope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", @@ -151,6 +152,7 @@ "claude-outbound.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", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", @@ -171,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", @@ -192,17 +195,19 @@ "client-config-export.test.ts": "config", "client-config-new-clients.test.ts": "config", "client-connect.test.ts": "clients", - "client-injection-guard.test.ts": "codex-integration", - "client-lifecycle-lock.test.ts": "clients", "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", @@ -290,9 +295,9 @@ "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-rejection.test.ts": "codex-integration", "codex-refresh.test.ts": "codex-integration", @@ -432,9 +437,9 @@ "desktop-3p-guard.test.ts": "clients", "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", - "desktop-remote-store.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", @@ -521,10 +526,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", @@ -624,13 +632,13 @@ "loopback-listener-admission.test.ts": "server", "loopback-listener-integration.test.ts": "server", "macos-serial-lanes.test.ts": "ci-workflows", - "management-api-logs-metrics.test.ts": "server", "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", @@ -647,12 +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-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", @@ -685,9 +698,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", @@ -758,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", @@ -813,6 +823,8 @@ "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", @@ -826,22 +838,12 @@ "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", "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", @@ -850,7 +852,6 @@ "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", - "version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", "remove-tree-helper.test.ts": "lib", "repo-hygiene.test.ts": "ci-workflows", @@ -861,6 +862,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", @@ -868,13 +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-function-tool-repair.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", @@ -961,8 +973,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", @@ -979,6 +991,7 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.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", @@ -1029,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", @@ -1069,11 +1081,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", @@ -1134,10 +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", - "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 01db03bf0c..4b9d6423b7 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -104,6 +104,39 @@ async function runAndGetSSE(streams: AdapterEvent[][], fulfill?: ImageCallResult } describe("runWithImageBridge", () => { + 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 { + const response = await runWithImageBridge({ + 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" }), + }, + }); + 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 { + await origin.stop(true); + await target.stop(true); + } + }); + test("translator overflow remains typed through the image loop and bridge", async () => { const sse = await runAndGetSSE([[ { 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 ea1c5189c6..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,6 +22,63 @@ describe("gracefulStopHost", () => { }); describe("stopProxyGracefully", () => { + 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]); + }); + } + + 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 () => { const calls: string[] = []; await stopProxyGracefully(9, { @@ -107,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/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/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 062a42ebcd..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 { @@ -219,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()] }], @@ -228,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/provider-connection-test.test.ts b/tests/providers/provider-connection-test.test.ts index 0a9af797fd..bbadd8c442 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 => { diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 9aeff8e1b2..ab9a5c64a3 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); @@ -1243,13 +1253,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) { @@ -1294,6 +1304,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 554ab98d65..88212768af 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -509,7 +509,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 df2d064cab..d5a49fd39e 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -1461,6 +1461,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/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index fafbbd6806..f703a1899e 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -12,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"; @@ -2391,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" }], @@ -2668,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-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/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 83bdc0686b..da3ab4bc63 100644 --- a/tests/routing/subagent-fallback-handle-responses.test.ts +++ b/tests/routing/subagent-fallback-handle-responses.test.ts @@ -174,6 +174,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)); @@ -572,6 +596,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"], @@ -607,8 +632,9 @@ describe("subagent fallback final-route normalization", () => { stream: false, reasoning: { effort: "max" }, }, - {}, + credentials.options, logCtx, + credentials.headers, ); expect(response.status).toBe(200); @@ -619,9 +645,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"], @@ -646,18 +675,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 () => { @@ -699,6 +730,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", @@ -721,13 +753,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(); @@ -735,6 +767,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 () => { @@ -1834,6 +1868,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 c08c706bac..38f0368d54 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -1858,12 +1858,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 }); @@ -1897,10 +1899,78 @@ 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 () => { let stopBackupHits = 0; const context = serve(() => Response.json({ error: { code: "context_length_exceeded", message: "too many tokens" } }, { status: 400 })); diff --git a/tests/server/server-images.test.ts b/tests/server/server-images.test.ts index a6b78da18c..371aa264c0 100644 --- a/tests/server/server-images.test.ts +++ b/tests/server/server-images.test.ts @@ -1604,6 +1604,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 }); } @@ -1621,6 +1622,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-xai-responses-streaming.test.ts b/tests/server/server-xai-responses-streaming.test.ts index 316ee56b91..8a2298972c 100644 --- a/tests/server/server-xai-responses-streaming.test.ts +++ b/tests/server/server-xai-responses-streaming.test.ts @@ -12,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(); @@ -20,8 +21,34 @@ let testDir = ""; let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; let originalFetch: typeof fetch; +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 () => { + 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-"); @@ -36,14 +63,15 @@ beforeEach(async () => { }); }); -afterEach(() => { +afterEach(async () => { + await drainRoutedCase(); globalThis.fetch = originalFetch; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); isolatedCodexHome = null; if (testDir) removeTreeWithRetry(testDir); -}); +}, SERVER_BUDGET_MS); function config(): OcxConfig { return { @@ -72,7 +100,43 @@ function sse(payload: unknown): Uint8Array { } describe("xAI OAuth Responses streaming opt-in", () => { - test.each([true, false])("continues a routed parent after a string child result (stream=%s)", async stream => { + 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 "; @@ -112,9 +176,11 @@ describe("xAI OAuth Responses streaming opt-in", () => { }) as typeof fetch; saveConfig({ ...config(), multiAgentMode: "v2" }); - const server = startServer(0); + 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 } : {}), }, @@ -163,7 +229,7 @@ describe("xAI OAuth Responses streaming opt-in", () => { } finally { await server.stop(true); } - }, 10_000); + }), 10_000); test("uses the native Responses wire and relays the first delta before completion", async () => { let releaseCompletion!: () => void; @@ -258,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), { @@ -373,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", @@ -468,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/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/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 0c0ef3c095..f956cf6a4d 100644 --- a/tests/vision/vision-anthropic.test.ts +++ b/tests/vision/vision-anthropic.test.ts @@ -181,6 +181,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-progress-stream.test.ts b/tests/web-search/web-search-progress-stream.test.ts index ddb325b2fb..d36bb42594 100644 --- a/tests/web-search/web-search-progress-stream.test.ts +++ b/tests/web-search/web-search-progress-stream.test.ts @@ -215,10 +215,13 @@ describe("web-search streamed-body progress collector", () => { let returned = false; const parser: ParseStream = async function* () { yield { type: "done", usage: { inputTokens: 1, outputTokens: 2 } }; - await sleep(20); + await sleep(30); returned = true; }; - const iterator = parseStreamWithProgress(new Response(chunkStream([])), parser, { inactivityTimeoutMs: 100 }); + const iterator = parseStreamWithProgress(new Response(chunkStream([])), parser, { + inactivityTimeoutMs: 10, + postTerminalDrainTimeoutMs: 100, + }); const next = await iterator.next(); expect(returned).toBe(true); expect(next.value).toEqual({ type: "done", usage: { inputTokens: 1, outputTokens: 2 } }); diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index ce8f6e0f8a..c116743d86 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -2649,6 +2649,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); }); }); -