From 9ec9467c72a517d9cefbd50467e55c600e15375f Mon Sep 17 00:00:00 2001
From: lidge-jun
Date: Tue, 8 Sep 2026 19:14:28 +0900
Subject: [PATCH 001/109] chore(release): open dev at 2.49.0 before releasing
2.48.0
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 6547da6552..6097460fb5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@bitkyc08/opencodex",
- "version": "2.48.0",
+ "version": "2.49.0",
"description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
"type": "module",
"main": "./bin/package-main.mjs",
From 358e1ee1e4e561a77c816142736e692ac8ab6011 Mon Sep 17 00:00:00 2001
From: lidge-jun
Date: Tue, 8 Sep 2026 19:36:57 +0900
Subject: [PATCH 002/109] docs(devlog): archive the 2.48.0 release train record
---
devlog/_fin/260908_release_248/000_plan.md | 31 +++++++++++++++
devlog/_fin/260908_release_248/010_release.md | 16 ++++++++
.../_fin/260908_release_248/020_progress.md | 14 +++++++
.../030_wp2_regression_review.md | 39 +++++++++++++++++++
.../260908_release_248/040_wp3_promotion.md | 35 +++++++++++++++++
.../260908_release_248/050_wp4_publication.md | 35 +++++++++++++++++
6 files changed, 170 insertions(+)
create mode 100644 devlog/_fin/260908_release_248/000_plan.md
create mode 100644 devlog/_fin/260908_release_248/010_release.md
create mode 100644 devlog/_fin/260908_release_248/020_progress.md
create mode 100644 devlog/_fin/260908_release_248/030_wp2_regression_review.md
create mode 100644 devlog/_fin/260908_release_248/040_wp3_promotion.md
create mode 100644 devlog/_fin/260908_release_248/050_wp4_publication.md
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
+```
From 87b685d8ac96a8b49f3dff6a7239a2733ad691a7 Mon Sep 17 00:00:00 2001
From: Flowershangfromthebranches
<152056395+Flowershangfromthebranches@users.noreply.github.com>
Date: Thu, 3 Sep 2026 13:37:45 +0800
Subject: [PATCH 003/109] feat(provider): add official CodeBuddy Global and CN
providers
(cherry picked from commit 7e56b6399087f708d1e4067a45ccc89ba1cf7da9)
---
.../src/content/docs/guides/providers.md | 32 ++
.../docs/reference/configuration/providers.md | 2 +-
src/adapters/codebuddy/adapter.ts | 84 ++++
src/adapters/codebuddy/profiles.ts | 52 +++
src/adapters/coding-agent/profile.ts | 100 ++++
src/adapters/coding-agent/protocol.ts | 430 ++++++++++++++++++
src/adapters/coding-agent/turn.ts | 285 ++++++++++++
src/adapters/registry.ts | 7 +
src/providers/codebuddy-models.ts | 156 +++++++
src/providers/registry.ts | 72 +++
.../adapter-buffered-tool-conformance.test.ts | 2 +
.../adapter-registry-authority.test.ts | 13 +-
.../adapters/adapter-tool-conformance.test.ts | 11 +
tests/codebuddy-adapter.test.ts | 286 ++++++++++++
tests/codebuddy-protocol.test.ts | 338 ++++++++++++++
.../adapter-conformance/wire-drivers.ts | 9 +
.../provider-registry-parity.test.ts | 26 ++
17 files changed, 1899 insertions(+), 6 deletions(-)
create mode 100644 src/adapters/codebuddy/adapter.ts
create mode 100644 src/adapters/codebuddy/profiles.ts
create mode 100644 src/adapters/coding-agent/profile.ts
create mode 100644 src/adapters/coding-agent/protocol.ts
create mode 100644 src/adapters/coding-agent/turn.ts
create mode 100644 src/providers/codebuddy-models.ts
create mode 100644 tests/codebuddy-adapter.test.ts
create mode 100644 tests/codebuddy-protocol.test.ts
diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 0279ee96ba..131219e0dc 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -617,6 +617,38 @@ 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.
+
### A6API credit quota
A custom `openai-chat` provider using `authMode: "key"` and the canonical
diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md
index f32a2fdc09..44f7d4c383 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`. |
| `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/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts
new file mode 100644
index 0000000000..1ab3a37421
--- /dev/null
+++ b/src/adapters/codebuddy/adapter.ts
@@ -0,0 +1,84 @@
+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 {
+ 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;
+}
+
+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 {
+ yield { type: "error", message: "CodeBuddy adapter uses runTurn; the fetch/parseStream path is disabled." };
+ },
+
+ async runTurn(parsed, incoming, emit): Promise {
+ 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..1db14bc50e
--- /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";
+ /** 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();
+
+/** 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..a3fa06cb07
--- /dev/null
+++ b/src/adapters/coding-agent/protocol.ts
@@ -0,0 +1,430 @@
+import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../../types";
+
+/**
+ * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code).
+ *
+ * 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;
+
+/**
+ * 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 frame that does not parse to a JSON record is dropped, never thrown: an unparseable
+ * line is padding, and terminating on it would discard deltas that already arrived (the same
+ * reasoning the command-code NDJSON reader documents for #1219/#1240).
+ */
+export async function* readJsonLines(
+ chunks: AsyncIterable,
+ limits: { maxLineBytes?: number; maxTotalBytes?: number } = {},
+): AsyncGenerator {
+ 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 {
+ 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 CodeBuddy 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 CodeBuddy 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 });
+ if (encoder.encode(buffer).byteLength > maxLineBytes) {
+ throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling");
+ }
+ 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");
+ }
+ }
+ // 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 | undefined {
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : 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) {
+ events.push({
+ type: "error",
+ message: asString(message.result) || "CodeBuddy CLI ended the turn with an execution error",
+ status: 502,
+ errorType: "upstream_error",
+ code: "upstream_error",
+ ...(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;
+
+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..1a8201da83
--- /dev/null
+++ b/src/adapters/coding-agent/turn.ts
@@ -0,0 +1,285 @@
+import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
+import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
+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;
+
+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;
+}
+
+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 {
+ const env: Record = {};
+ 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 env value and common secret shapes before surfacing diagnostics. */
+export function redactSecrets(text: string, tokenEnv: string): string {
+ const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ return text
+ .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]");
+}
+
+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;
+ 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 {
+ 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;
+
+ 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);
+
+ let child: ChildProcess;
+ try {
+ child = spawnFn(binary, args, { env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
+ } catch (err) {
+ emit({ type: "error", message: redactSecrets(err instanceof Error ? err.message : String(err), profile.tokenEnv), status: 500, errorType: "upstream_error" });
+ return;
+ }
+
+ 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 | 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 onAbort = (): void => { kill(); };
+ incoming.abortSignal?.addEventListener("abort", onAbort, { once: true });
+ const timeoutTimer = setTimeout(() => {
+ kill();
+ 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;
+ 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);
+ if (terminalEmitted) break;
+ }
+ } catch (err) {
+ kill();
+ streamProtocolError = err instanceof Error ? err.message : String(err);
+ emitOnce({
+ type: "error",
+ message: redactSecrets(streamProtocolError, profile.tokenEnv),
+ status: 502,
+ errorType: "upstream_error",
+ code: "protocol_error",
+ retryable: false,
+ });
+ }
+ } catch (err) {
+ kill();
+ emitOnce({ type: "error", message: redactSecrets(err instanceof Error ? err.message : String(err), profile.tokenEnv), status: 502, errorType: "upstream_error" });
+ } 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.
+ await new Promise(resolve => {
+ if (child.exitCode !== null) { resolve(); return; }
+ const graceTimer = setTimeout(() => { kill(); }, killGraceMs);
+ child.once("close", () => { clearTimeout(graceTimer); resolve(); });
+ });
+ if (killTimer) clearTimeout(killTimer);
+
+ if (!terminalEmitted) {
+ const stderr = redactSecrets(boundedStderr(stderrChunks), profile.tokenEnv);
+ if (incoming.abortSignal?.aborted) {
+ emitOnce({ type: "error", message: `${profile.label} turn was aborted.`, retryable: false });
+ } else if (streamProtocolError) {
+ emitOnce({
+ type: "error",
+ message: redactSecrets(streamProtocolError, profile.tokenEnv),
+ 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/registry.ts b/src/adapters/registry.ts
index 81fdbf99a4..999bd1cfbb 100644
--- a/src/adapters/registry.ts
+++ b/src/adapters/registry.ts
@@ -2,6 +2,7 @@ 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 { createCommandCodeAdapter } from "./command-code";
import { createCursorAdapter } from "./cursor";
import { createGoogleAdapter } from "./google";
@@ -20,6 +21,7 @@ export interface AdapterFactoryContext {
}
export type AdapterWire =
+ | "codebuddy"
| "command-code"
| "openai-chat"
| "ollama-native"
@@ -53,6 +55,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",
diff --git a/src/providers/codebuddy-models.ts b/src/providers/codebuddy-models.ts
new file mode 100644
index 0000000000..36603b0a15
--- /dev/null
+++ b/src/providers/codebuddy-models.ts
@@ -0,0 +1,156 @@
+/**
+ * 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",
+ "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",
+ "kimi-k3-1",
+ "kimi-k2.7",
+ "kimi-k2.6",
+ "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 = {
+ "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,
+ "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 = {
+ "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,
+ "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 = {
+ "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 = {
+ "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 = {
+ "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,
+ "kimi-k3-1": 1_000_000,
+ "kimi-k2.7": 256_000,
+ "kimi-k2.6": 256_000,
+ "hy3": 192_000,
+ "hunyuan-chat": 200_000,
+};
+
+export const CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS: Record = {
+ "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,
+ "kimi-k3-1": 32_000,
+ "kimi-k2.7": 32_000,
+ "kimi-k2.6": 32_000,
+ "hy3": 64_000,
+ "hunyuan-chat": 8_192,
+};
+
+export const CODEBUDDY_CN_MODEL_REASONING_EFFORTS: Record = {
+ "hy3": ["low", "high"],
+};
+
+export const CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS: Record = {
+ "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", "hunyuan-chat"];
diff --git a/src/providers/registry.ts b/src/providers/registry.ts
index 5beb971a4e..1966fc9208 100644
--- a/src/providers/registry.ts
+++ b/src/providers/registry.ts
@@ -21,6 +21,20 @@ 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";
export type ProviderAuthKind = "forward" | "oauth" | "key" | "local";
export type MetadataModelIdNormalize = "case-insensitive";
@@ -3140,6 +3154,64 @@ 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 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/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 = {
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..0dc53c15f2 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",
@@ -30,11 +31,13 @@ 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"
+ // 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..4d9410a515 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 = {
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"]);
+
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/codebuddy-adapter.test.ts b/tests/codebuddy-adapter.test.ts
new file mode 100644
index 0000000000..1299ec614a
--- /dev/null
+++ b/tests/codebuddy-adapter.test.ts
@@ -0,0 +1,286 @@
+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 {
+ 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 {
+ 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, p: OcxParsedRequest, inc = incoming()): Promise {
+ 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");
+ });
+});
+
+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("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);
+ });
+});
diff --git a/tests/codebuddy-protocol.test.ts b/tests/codebuddy-protocol.test.ts
new file mode 100644
index 0000000000..0d48fc33dc
--- /dev/null
+++ b/tests/codebuddy-protocol.test.ts
@@ -0,0 +1,338 @@
+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 {
+ for (const part of parts) yield part;
+}
+
+async function collect(gen: AsyncGenerator>): Promise[]> {
+ const out: Record[] = [];
+ for await (const item of gen) out.push(item);
+ return out;
+}
+
+function parsedRequest(overrides: Partial = {}): 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("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 CodeBuddy 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 CodeBuddy 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("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>;
+ 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/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 {
+ throw new Error("codebuddy forwards no client tool catalog in v1; excluded from tool conformance");
+ },
+ },
} satisfies Record;
diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts
index 9aeff8e1b2..fe8810442c 100644
--- a/tests/providers/provider-registry-parity.test.ts
+++ b/tests/providers/provider-registry-parity.test.ts
@@ -38,6 +38,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [
"volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral",
"minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway",
"opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo",
+ "codebuddy", "codebuddy-cn",
];
describe("provider registry parity", () => {
@@ -1297,6 +1298,31 @@ describe("free-provider directory isolation", () => {
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");
+ });
+
test("only rows with checked provenance claim a verification date", () => {
for (const entry of FREE_PROVIDER_DIRECTORY) {
if (entry.verification === "unverified") {
From e80f5a3f63c22b35a4fbdd675b0b8f666a8a8486 Mon Sep 17 00:00:00 2001
From: Flowershangfromthebranches
<152056395+Flowershangfromthebranches@users.noreply.github.com>
Date: Thu, 3 Sep 2026 15:46:18 +0800
Subject: [PATCH 004/109] fix(provider): harden CodeBuddy CLI lifecycle
(cherry picked from commit f651611f1b435b4e0ac4f1c68515ee0f70d11c30)
---
src/adapters/coding-agent/turn.ts | 73 ++++++++++++++-----
src/providers/codebuddy-models.ts | 30 +++++++-
tests/codebuddy-adapter.test.ts | 26 +++++++
.../provider-registry-parity.test.ts | 9 +++
4 files changed, 117 insertions(+), 21 deletions(-)
diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts
index 1a8201da83..7feacc8db3 100644
--- a/src/adapters/coding-agent/turn.ts
+++ b/src/adapters/coding-agent/turn.ts
@@ -45,10 +45,12 @@ export function baseScopedEnv(): Record {
return env;
}
-/** Redact the profile's credential env value and common secret shapes before surfacing diagnostics. */
-export function redactSecrets(text: string, tokenEnv: string): string {
+/** 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, "\\$&");
- return text
+ 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]");
@@ -139,6 +141,26 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise(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") {
@@ -181,6 +203,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise(resolve => {
- if (child.exitCode !== null) { resolve(); return; }
- const graceTimer = setTimeout(() => { kill(); }, killGraceMs);
- child.once("close", () => { clearTimeout(graceTimer); resolve(); });
- });
+ const graceTimer = setTimeout(() => { kill(); }, killGraceMs);
+ await processLifecycle;
+ clearTimeout(graceTimer);
if (killTimer) clearTimeout(killTimer);
if (!terminalEmitted) {
- const stderr = redactSecrets(boundedStderr(stderrChunks), profile.tokenEnv);
+ 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),
+ message: redactSecrets(streamProtocolError, profile.tokenEnv, apiKey),
status: 502,
errorType: "upstream_error",
code: "protocol_error",
diff --git a/src/providers/codebuddy-models.ts b/src/providers/codebuddy-models.ts
index 36603b0a15..edd6a30415 100644
--- a/src/providers/codebuddy-models.ts
+++ b/src/providers/codebuddy-models.ts
@@ -24,6 +24,7 @@ export const CODEBUDDY_GLOBAL_MODELS = [
"gpt-5.5",
"gpt-5.4",
"gpt-5.3-codex",
+ "gemini-3.5-flash",
"glm-5.3",
"glm-5.2",
"kimi-k3",
@@ -40,9 +41,15 @@ export const CODEBUDDY_CN_MODELS = [
"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",
];
@@ -67,6 +74,7 @@ export const CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS: Record = {
"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,
@@ -86,6 +94,7 @@ export const CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS: Record =
"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,
@@ -118,9 +127,15 @@ export const CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS: Record = {
"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,
};
@@ -133,9 +148,15 @@ export const CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS: Record = {
"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,
};
@@ -153,4 +174,11 @@ export const CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS: Record {
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");
+ });
});
describe("codebuddy runTurn streams a headless turn", () => {
@@ -204,6 +221,15 @@ describe("codebuddy runTurn streams a headless turn", () => {
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 });
diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts
index fe8810442c..b39594195d 100644
--- a/tests/providers/provider-registry-parity.test.ts
+++ b/tests/providers/provider-registry-parity.test.ts
@@ -42,6 +42,15 @@ const EXPECTED_KEY_PROVIDER_IDS = [
];
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);
From 1e3cd678e4f90c962ad9e738e79fd4e90250f5da Mon Sep 17 00:00:00 2001
From: Flowershangfromthebranches
<152056395+Flowershangfromthebranches@users.noreply.github.com>
Date: Thu, 3 Sep 2026 17:34:44 +0800
Subject: [PATCH 005/109] fix(provider): address CodeBuddy runtime review
(cherry picked from commit 18530f8e8c23d3ae254d6964fbe29a166e2224c2)
---
src/adapters/coding-agent/protocol.ts | 14 +++---
src/adapters/coding-agent/turn.ts | 41 ++++++++++++++--
tests/codebuddy-adapter.test.ts | 69 +++++++++++++++++++++++++++
tests/codebuddy-protocol.test.ts | 6 +++
4 files changed, 120 insertions(+), 10 deletions(-)
diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts
index a3fa06cb07..2326b52ad3 100644
--- a/src/adapters/coding-agent/protocol.ts
+++ b/src/adapters/coding-agent/protocol.ts
@@ -46,9 +46,8 @@ export type StreamMessage = Record;
*
* 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 frame that does not parse to a JSON record is dropped, never thrown: an unparseable
- * line is padding, and terminating on it would discard deltas that already arrived (the same
- * reasoning the command-code NDJSON reader documents for #1219/#1240).
+ * 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,
@@ -62,6 +61,9 @@ export async function* readJsonLines(
let totalBytes = 0;
const flushLine = function* (line: string): Generator {
+ 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;
@@ -88,9 +90,6 @@ export async function* readJsonLines(
throw new CodingAgentStreamLimitError("Coding-agent stream exceeded the total byte ceiling");
}
buffer += decoder.decode(chunk, { stream: true });
- if (encoder.encode(buffer).byteLength > maxLineBytes) {
- throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling");
- }
let newline = buffer.indexOf("\n");
while (newline >= 0) {
const line = buffer.slice(0, newline);
@@ -98,6 +97,9 @@ export async function* readJsonLines(
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();
diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts
index 7feacc8db3..e8ef20fe45 100644
--- a/src/adapters/coding-agent/turn.ts
+++ b/src/adapters/coding-agent/turn.ts
@@ -1,5 +1,6 @@
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";
@@ -14,6 +15,10 @@ export interface CodingAgentDeps {
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;
@@ -85,6 +90,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { kill(); };
+ 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);
@@ -245,8 +271,15 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { kill(); }, killGraceMs);
- await processLifecycle;
+ let reapTimer: ReturnType | undefined;
+ await Promise.race([
+ processLifecycle,
+ new Promise(resolve => {
+ reapTimer = setTimeout(resolve, reapTimeoutMs);
+ }),
+ ]);
clearTimeout(graceTimer);
+ if (reapTimer) clearTimeout(reapTimer);
if (killTimer) clearTimeout(killTimer);
if (!terminalEmitted) {
diff --git a/tests/codebuddy-adapter.test.ts b/tests/codebuddy-adapter.test.ts
index b2e360909b..7d05072148 100644
--- a/tests/codebuddy-adapter.test.ts
+++ b/tests/codebuddy-adapter.test.ts
@@ -167,6 +167,42 @@ describe("codebuddy runTurn fails closed before any spawn", () => {
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", () => {
@@ -309,4 +345,37 @@ describe("codebuddy runTurn streams a headless turn", () => {
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/codebuddy-protocol.test.ts b/tests/codebuddy-protocol.test.ts
index 0d48fc33dc..a769b9d8e5 100644
--- a/tests/codebuddy-protocol.test.ts
+++ b/tests/codebuddy-protocol.test.ts
@@ -41,6 +41,12 @@ describe("codebuddy stream-json line reader", () => {
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))));
From aa5404ad855cb0cdfaebc7546e16f8f6b64b5a4f Mon Sep 17 00:00:00 2001
From: Flowershangfromthebranches
<152056395+Flowershangfromthebranches@users.noreply.github.com>
Date: Fri, 4 Sep 2026 21:55:51 +0800
Subject: [PATCH 006/109] docs(codebuddy): document adapter factory and turn
input contracts
(cherry picked from commit 4b705e92d792f3d5c3c427f478ed96dec5da3836)
---
src/adapters/codebuddy/adapter.ts | 1 +
src/adapters/coding-agent/turn.ts | 2 ++
2 files changed, 3 insertions(+)
diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts
index 1ab3a37421..234e06907e 100644
--- a/src/adapters/codebuddy/adapter.ts
+++ b/src/adapters/codebuddy/adapter.ts
@@ -56,6 +56,7 @@ export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, p
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",
diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts
index e8ef20fe45..c1ff73fa8d 100644
--- a/src/adapters/coding-agent/turn.ts
+++ b/src/adapters/coding-agent/turn.ts
@@ -8,6 +8,7 @@ import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProv
/** 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;
@@ -61,6 +62,7 @@ export function redactSecrets(text: string, tokenEnv: string, credential?: strin
.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[];
From 769e4208fd2cd6735608275730c29231a7290d90 Mon Sep 17 00:00:00 2001
From: t
Date: Tue, 8 Sep 2026 21:06:01 +0900
Subject: [PATCH 007/109] test(providers): place CodeBuddy tests in their
layout domain
The carried CodeBuddy commits predate the tests/ modularization, so their
two test files landed at the tests/ root where the layout guard rejects
them. Move them under tests/providers and register both in layout.json
and the expected-layout fixture.
---
scripts/test-layout/layout.json | 72 +++++++++---------
tests/fixtures/test-layout-expected.json | 74 ++++++++++---------
.../{ => providers}/codebuddy-adapter.test.ts | 8 +-
.../codebuddy-protocol.test.ts | 4 +-
4 files changed, 81 insertions(+), 77 deletions(-)
rename tests/{ => providers}/codebuddy-adapter.test.ts (98%)
rename tests/{ => providers}/codebuddy-protocol.test.ts (99%)
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 444963711c..96367a93ff 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -236,11 +236,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",
@@ -336,6 +336,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 +358,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 +458,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 +600,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 +621,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",
@@ -690,6 +693,8 @@
"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 +794,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,11 +817,15 @@
"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-page-groups.test.ts": "gui",
"models-workspace-tabs.test.ts": "gui",
@@ -850,9 +859,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 +929,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",
@@ -991,17 +997,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 +1011,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 +1021,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 +1039,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 +1132,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",
@@ -1234,11 +1239,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 +1306,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/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 2aafc9c654..6e11c7840e 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -71,11 +71,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",
@@ -128,7 +128,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 +150,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 +171,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 +193,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 +293,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 +435,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",
@@ -525,6 +528,8 @@
"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 +629,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,11 +652,15 @@
"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-page-groups.test.ts": "gui",
"models-workspace-tabs.test.ts": "gui",
@@ -685,9 +694,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 +764,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",
@@ -826,22 +832,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 +846,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 +856,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 +874,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 +967,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",
@@ -1029,7 +1035,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 +1074,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 +1141,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/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts
similarity index 98%
rename from tests/codebuddy-adapter.test.ts
rename to tests/providers/codebuddy-adapter.test.ts
index 7d05072148..0da898ea82 100644
--- a/tests/codebuddy-adapter.test.ts
+++ b/tests/providers/codebuddy-adapter.test.ts
@@ -2,10 +2,10 @@ 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";
+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();
diff --git a/tests/codebuddy-protocol.test.ts b/tests/providers/codebuddy-protocol.test.ts
similarity index 99%
rename from tests/codebuddy-protocol.test.ts
rename to tests/providers/codebuddy-protocol.test.ts
index a769b9d8e5..a006c48aa5 100644
--- a/tests/codebuddy-protocol.test.ts
+++ b/tests/providers/codebuddy-protocol.test.ts
@@ -6,8 +6,8 @@ import {
mapStreamMessageToEvents,
readJsonLines,
usageFromResult,
-} from "../src/adapters/coding-agent/protocol";
-import type { OcxParsedRequest } from "../src/types";
+} 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.
From 124c57b1f832d18f38ab9c3a9abae7defd61d5ca Mon Sep 17 00:00:00 2001
From: Flowershangfromthebranches
<152056395+Flowershangfromthebranches@users.noreply.github.com>
Date: Thu, 3 Sep 2026 17:56:22 +0800
Subject: [PATCH 008/109] feat(provider): add Qoder Global PAT provider
(cherry picked from commit 4ac98bd4de0a7ed4c490d24b34c54d9f161e9657)
---
README.md | 2 +-
docs/qoder-cli-provider.md | 43 +++++++++
src/adapters/coding-agent/profile.ts | 2 +-
src/adapters/coding-agent/protocol.ts | 43 +++++++--
src/adapters/qoder/adapter.ts | 70 +++++++++++++++
src/adapters/qoder/live-models.ts | 89 +++++++++++++++++++
src/adapters/qoder/profiles.ts | 24 +++++
src/adapters/registry.ts | 5 ++
src/codex/catalog/provider-fetch.ts | 46 ++++++++++
src/codex/model-cache.ts | 15 +++-
src/providers/free-directory.ts | 12 +++
src/providers/qoder-models.ts | 16 ++++
src/providers/registry.ts | 20 +++++
src/server/management/provider-routes.ts | 24 +++++
.../adapter-registry-authority.test.ts | 3 +
.../adapters/adapter-tool-conformance.test.ts | 2 +-
tests/providers/codebuddy-protocol.test.ts | 14 ++-
.../provider-connection-test.test.ts | 18 ++++
.../provider-registry-parity.test.ts | 9 +-
tests/qoder-adapter.test.ts | 79 ++++++++++++++++
tests/qoder-live-models.test.ts | 74 +++++++++++++++
21 files changed, 590 insertions(+), 20 deletions(-)
create mode 100644 docs/qoder-cli-provider.md
create mode 100644 src/adapters/qoder/adapter.ts
create mode 100644 src/adapters/qoder/live-models.ts
create mode 100644 src/adapters/qoder/profiles.ts
create mode 100644 src/providers/qoder-models.ts
create mode 100644 tests/qoder-adapter.test.ts
create mode 100644 tests/qoder-live-models.test.ts
diff --git a/README.md b/README.md
index 70a17a7a81..7b2fc0b7f5 100644
--- a/README.md
+++ b/README.md
@@ -288,7 +288,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 (official PAT + CLI), SiliconFlow, and more. Full list: `ocx init` or the
[provider docs](https://opencodex.me/guides/providers/).
## CLI
diff --git a/docs/qoder-cli-provider.md b/docs/qoder-cli-provider.md
new file mode 100644
index 0000000000..2ca9b6df95
--- /dev/null
+++ b/docs/qoder-cli-provider.md
@@ -0,0 +1,43 @@
+# Qoder CLI providers
+
+OpenCodex supports Qoder Global through Qoder's official Personal Access Token and headless CLI.
+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.
+
+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/src/adapters/coding-agent/profile.ts b/src/adapters/coding-agent/profile.ts
index 1db14bc50e..7298469767 100644
--- a/src/adapters/coding-agent/profile.ts
+++ b/src/adapters/coding-agent/profile.ts
@@ -13,7 +13,7 @@ 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";
+ 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). */
diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts
index 2326b52ad3..f68406b268 100644
--- a/src/adapters/coding-agent/protocol.ts
+++ b/src/adapters/coding-agent/protocol.ts
@@ -1,7 +1,7 @@
import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../../types";
/**
- * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code).
+ * 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:
@@ -72,13 +72,13 @@ export async function* readJsonLines(
} catch {
const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " ");
throw new CodingAgentProtocolError(
- `Malformed stream-json frame received from CodeBuddy CLI: ${snippet}`,
+ `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 CodeBuddy CLI: ${snippet}`,
+ `Non-object stream-json frame received from coding-agent CLI: ${snippet}`,
);
}
yield parsed as StreamMessage;
@@ -186,12 +186,41 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa
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);
+ const authentication = /not logged in|invalid (?:personal access )?token|authentication/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: asString(message.result) || "CodeBuddy CLI ended the turn with an execution error",
- status: 502,
- errorType: "upstream_error",
- code: "upstream_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;
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 {
+ 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 {
+ yield { type: "error", message: "Qoder adapter uses runTurn; the fetch/parseStream path is disabled." };
+ },
+ async runTurn(parsed, incoming, emit): Promise {
+ 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; timeout: number; maxBuffer: number; windowsHide: boolean; windowsVerbatimArguments?: boolean },
+) => Promise;
+
+export interface QoderModelsDeps {
+ which?: WhichFn;
+ platform?: NodeJS.Platform;
+ timeoutMs?: number;
+ exec?: QoderExecFn;
+}
+
+type QoderModelsFetcher = (profile: QoderProfile, apiKey: string) => QoderModelsResult | Promise;
+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();
+ 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[2]): Promise {
+ 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 {
+ 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..6d8e2a1f68
--- /dev/null
+++ b/src/adapters/qoder/profiles.ts
@@ -0,0 +1,24 @@
+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_PROFILES: readonly QoderProfile[] = [QODER_GLOBAL_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 999bd1cfbb..d8edbead92 100644
--- a/src/adapters/registry.ts
+++ b/src/adapters/registry.ts
@@ -3,6 +3,7 @@ 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";
@@ -115,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;
export type AdapterId = keyof typeof ADAPTER_REGISTRY;
diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts
index b90e0b12cf..98954641a3 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,
@@ -1582,6 +1584,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),
+ ), "authoritative");
+ }
+ const scopedStale = getStaleCached(name, authorityIdentity);
+ if (isModelsFetchCoolingDown(name) && scopedStale) {
+ return observed(withConfiguredRetention(
+ applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold),
+ ), "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),
+ }));
+ 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) : 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/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/providers/free-directory.ts b/src/providers/free-directory.ts
index ab6e9b2389..e0ca0e455b 100644
--- a/src/providers/free-directory.ts
+++ b/src/providers/free-directory.ts
@@ -140,6 +140,18 @@ const CONNECTABLE: Record = {
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",
+ },
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" }),
diff --git a/src/providers/qoder-models.ts b/src/providers/qoder-models.ts
new file mode 100644
index 0000000000..d97483a015
--- /dev/null
+++ b/src/providers/qoder-models.ts
@@ -0,0 +1,16 @@
+/**
+ * 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;
+
+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 1966fc9208..d2a38de446 100644
--- a/src/providers/registry.ts
+++ b/src/providers/registry.ts
@@ -35,6 +35,7 @@ import {
CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS,
CODEBUDDY_REASONING_EFFORTS,
} from "./codebuddy-models";
+import { QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "./qoder-models";
export type ProviderAuthKind = "forward" | "oauth" | "key" | "local";
export type MetadataModelIdNormalize = "case-insensitive";
@@ -3154,6 +3155,25 @@ 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`.",
+ },
{
// Official CodeBuddy Code CLI provider (Tencent Cloud), GLOBAL / `public` environment.
// Transport is the vendor-documented headless CLI automation surface
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 {
}
});
- const TOOL_LESS_ADAPTERS = new Set(["codebuddy"]);
+ 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()) {
diff --git a/tests/providers/codebuddy-protocol.test.ts b/tests/providers/codebuddy-protocol.test.ts
index a006c48aa5..a51cb1ea7b 100644
--- a/tests/providers/codebuddy-protocol.test.ts
+++ b/tests/providers/codebuddy-protocol.test.ts
@@ -76,13 +76,13 @@ describe("codebuddy stream-json line reader", () => {
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 CodeBuddy CLI");
+ 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 CodeBuddy CLI");
+ 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 () => {
@@ -98,6 +98,16 @@ describe("codebuddy stream-json line reader", () => {
});
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(
diff --git a/tests/providers/provider-connection-test.test.ts b/tests/providers/provider-connection-test.test.ts
index 0a9af797fd..2ce2df35e7 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,22 @@ 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("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 b39594195d..d4802df497 100644
--- a/tests/providers/provider-registry-parity.test.ts
+++ b/tests/providers/provider-registry-parity.test.ts
@@ -38,7 +38,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [
"volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral",
"minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway",
"opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo",
- "codebuddy", "codebuddy-cn",
+ "qoder", "codebuddy", "codebuddy-cn",
];
describe("provider registry parity", () => {
@@ -1253,13 +1253,12 @@ 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");
const registryIds = new Set(PROVIDER_REGISTRY.map(entry => entry.id));
for (const id of directoryOnlyIds) {
@@ -1304,6 +1303,8 @@ 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");
});
diff --git a/tests/qoder-adapter.test.ts b/tests/qoder-adapter.test.ts
new file mode 100644
index 0000000000..123c02571c
--- /dev/null
+++ b/tests/qoder-adapter.test.ts
@@ -0,0 +1,79 @@
+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_GLOBAL_PROFILE } 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 {
+ return { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "qoder-pat", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], ...overrides } as OcxProviderConfig;
+}
+
+function parsed(overrides: Partial = {}): 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("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/qoder-live-models.test.ts b/tests/qoder-live-models.test.ts
new file mode 100644
index 0000000000..858d730c92
--- /dev/null
+++ b/tests/qoder-live-models.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { fetchQoderModels, parseQoderModelList, setFetchQoderModelsForTests } from "../src/adapters/qoder/live-models";
+import { clearQoderBinaryCache, 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 }) => {
+ 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("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 }) => {
+ 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");
+ });
+});
From 094cb93d0c251710bc826524987e025137ad958b Mon Sep 17 00:00:00 2001
From: t
Date: Tue, 8 Sep 2026 21:06:44 +0900
Subject: [PATCH 009/109] test(providers): place Qoder tests in their layout
domain
Same drift as the CodeBuddy layer: the carried Qoder commit predates the
tests/ modularization. Move both files under tests/providers and register
them in layout.json and the expected-layout fixture.
---
scripts/test-layout/layout.json | 2 ++
tests/fixtures/test-layout-expected.json | 2 ++
tests/{ => providers}/qoder-adapter.test.ts | 8 ++++----
tests/{ => providers}/qoder-live-models.test.ts | 10 +++++-----
4 files changed, 13 insertions(+), 9 deletions(-)
rename tests/{ => providers}/qoder-adapter.test.ts (96%)
rename tests/{ => providers}/qoder-live-models.test.ts (93%)
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 96367a93ff..0ff7d4861c 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -984,6 +984,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",
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 6e11c7840e..98907bd26a 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -819,6 +819,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",
diff --git a/tests/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts
similarity index 96%
rename from tests/qoder-adapter.test.ts
rename to tests/providers/qoder-adapter.test.ts
index 123c02571c..3b3271fd69 100644
--- a/tests/qoder-adapter.test.ts
+++ b/tests/providers/qoder-adapter.test.ts
@@ -2,10 +2,10 @@ 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_GLOBAL_PROFILE } from "../src/adapters/qoder/profiles";
-import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types";
-import { createTestTranslatorBudget } from "./helpers/translator-budget";
+import { buildQoderArgs, buildQoderChildEnv, createQoderAdapter } from "../../src/adapters/qoder/adapter";
+import { clearQoderBinaryCache, QODER_GLOBAL_PROFILE } 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());
diff --git a/tests/qoder-live-models.test.ts b/tests/providers/qoder-live-models.test.ts
similarity index 93%
rename from tests/qoder-live-models.test.ts
rename to tests/providers/qoder-live-models.test.ts
index 858d730c92..7f63cd28f7 100644
--- a/tests/qoder-live-models.test.ts
+++ b/tests/providers/qoder-live-models.test.ts
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
-import { fetchQoderModels, parseQoderModelList, setFetchQoderModelsForTests } from "../src/adapters/qoder/live-models";
-import { clearQoderBinaryCache, 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";
+import { fetchQoderModels, parseQoderModelList, setFetchQoderModelsForTests } from "../../src/adapters/qoder/live-models";
+import { clearQoderBinaryCache, 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(() => {
From 5adf130dae8c006fca229e0b1ccc03b6418d3b60 Mon Sep 17 00:00:00 2001
From: t
Date: Tue, 8 Sep 2026 21:29:43 +0900
Subject: [PATCH 010/109] fix(qoder): carry captured alias through discovery
and anchor auth errors
The Qoder catalog branch was written against the pre-#3601 shape of
provider-fetch.ts, so it merged cleanly while dropping captured.effectiveAlias
from all four hint applications; a Qoder row would carry whatever alias the
cached row already held instead of the decision captured at flight admission.
The shared coding-agent result classifier matched the bare word
authentication anywhere in vendor text, turning transient upstream wording
into a 401 credential verdict that drives reauth messaging and key-pool
rotation. Anchor it to credential phrasing; the existing 'Not logged in;
invalid token' fixture still classifies as 401.
---
src/adapters/coding-agent/protocol.ts | 4 +++-
src/codex/catalog/provider-fetch.ts | 8 ++++----
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts
index f68406b268..27fefb581b 100644
--- a/src/adapters/coding-agent/protocol.ts
+++ b/src/adapters/coding-agent/protocol.ts
@@ -195,7 +195,9 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa
// 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);
- const authentication = /not logged in|invalid (?:personal access )?token|authentication/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({
diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts
index 98954641a3..dab45af38e 100644
--- a/src/codex/catalog/provider-fetch.ts
+++ b/src/codex/catalog/provider-fetch.ts
@@ -1595,13 +1595,13 @@ async function fetchProviderModelsWithAuth(
const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
if (fresh) {
return observed(withConfiguredRetention(
- applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold),
+ 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),
+ applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
), "degraded");
}
const live = await fetchQoderModels(profile, apiKey);
@@ -1609,7 +1609,7 @@ async function fetchProviderModelsWithAuth(
const discovered = live.models.map(id => ({
id,
provider: name,
- ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold),
+ ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
}));
const forCache = withConfiguredRetention(discovered, { retainComboTargets: false });
if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
@@ -1625,7 +1625,7 @@ async function fetchProviderModelsWithAuth(
}
const stale = getStaleCached(name, authorityIdentity);
return observed(withConfiguredRetention(
- stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold) : configured,
+ stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
), "degraded");
}
if (prov.adapter === "cursor") {
From 615c5c62c59af28a19e948977bd12d47bd21ec01 Mon Sep 17 00:00:00 2001
From: Flowershangfromthebranches
<152056395+Flowershangfromthebranches@users.noreply.github.com>
Date: Thu, 3 Sep 2026 18:06:25 +0800
Subject: [PATCH 011/109] feat(provider): add Qoder CN PAT provider
(cherry picked from commit a4e805084de48a5121c5c4d0e3973a3491d6d302)
---
README.md | 2 +-
docs/qoder-cli-provider.md | 32 +++++++++++++++-
src/adapters/qoder/profiles.ts | 14 ++++++-
src/providers/free-directory.ts | 16 +++++++-
src/providers/qoder-models.ts | 9 +++++
src/providers/registry.ts | 20 +++++++++-
.../provider-connection-test.test.ts | 16 ++++++++
.../provider-registry-parity.test.ts | 3 +-
tests/providers/qoder-adapter.test.ts | 38 ++++++++++++++++++-
tests/providers/qoder-live-models.test.ts | 17 ++++++++-
10 files changed, 158 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 7b2fc0b7f5..ce0e6daef2 100644
--- a/README.md
+++ b/README.md
@@ -288,7 +288,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, Qoder Global (official PAT + CLI), 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/docs/qoder-cli-provider.md b/docs/qoder-cli-provider.md
index 2ca9b6df95..8148506626 100644
--- a/docs/qoder-cli-provider.md
+++ b/docs/qoder-cli-provider.md
@@ -1,6 +1,6 @@
# Qoder CLI providers
-OpenCodex supports Qoder Global through Qoder's official Personal Access Token and headless CLI.
+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
@@ -30,6 +30,36 @@ OpenCodex. There is no automatic regional failover or credential exchange. The c
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:
diff --git a/src/adapters/qoder/profiles.ts b/src/adapters/qoder/profiles.ts
index 6d8e2a1f68..a90a274f43 100644
--- a/src/adapters/qoder/profiles.ts
+++ b/src/adapters/qoder/profiles.ts
@@ -17,7 +17,19 @@ export const QODER_GLOBAL_PROFILE: QoderProfile = {
documentationUrl: "https://docs.qoder.com/cli/authentication",
};
-export const QODER_PROFILES: readonly QoderProfile[] = [QODER_GLOBAL_PROFILE];
+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;
}
diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts
index e0ca0e455b..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;
@@ -152,6 +152,18 @@ const CONNECTABLE: Record = {
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" }),
@@ -172,7 +184,7 @@ const LABELS: Record = {
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/qoder-models.ts b/src/providers/qoder-models.ts
index d97483a015..0f8d8c4350 100644
--- a/src/providers/qoder-models.ts
+++ b/src/providers/qoder-models.ts
@@ -13,4 +13,13 @@ export const QODER_GLOBAL_MODELS = [
"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 d2a38de446..6d84efe529 100644
--- a/src/providers/registry.ts
+++ b/src/providers/registry.ts
@@ -35,7 +35,7 @@ import {
CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS,
CODEBUDDY_REASONING_EFFORTS,
} from "./codebuddy-models";
-import { QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "./qoder-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";
@@ -3174,6 +3174,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
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
diff --git a/tests/providers/provider-connection-test.test.ts b/tests/providers/provider-connection-test.test.ts
index 2ce2df35e7..bbadd8c442 100644
--- a/tests/providers/provider-connection-test.test.ts
+++ b/tests/providers/provider-connection-test.test.ts
@@ -72,6 +72,22 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => {
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 d4802df497..009aec37d9 100644
--- a/tests/providers/provider-registry-parity.test.ts
+++ b/tests/providers/provider-registry-parity.test.ts
@@ -38,7 +38,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [
"volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral",
"minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway",
"opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo",
- "qoder", "codebuddy", "codebuddy-cn",
+ "qoder", "qoder-cn", "codebuddy", "codebuddy-cn",
];
describe("provider registry parity", () => {
@@ -1259,6 +1259,7 @@ describe("free-provider directory isolation", () => {
.map(entry => entry.id);
expect(directoryOnlyIds.length).toBeGreaterThan(0);
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) {
diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts
index 3b3271fd69..5411504354 100644
--- a/tests/providers/qoder-adapter.test.ts
+++ b/tests/providers/qoder-adapter.test.ts
@@ -3,7 +3,7 @@ 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_GLOBAL_PROFILE } from "../../src/adapters/qoder/profiles";
+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";
@@ -44,6 +44,42 @@ describe("qoder adapter", () => {
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([]); } });
diff --git a/tests/providers/qoder-live-models.test.ts b/tests/providers/qoder-live-models.test.ts
index 7f63cd28f7..ba00e378e7 100644
--- a/tests/providers/qoder-live-models.test.ts
+++ b/tests/providers/qoder-live-models.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { fetchQoderModels, parseQoderModelList, setFetchQoderModelsForTests } from "../../src/adapters/qoder/live-models";
-import { clearQoderBinaryCache, QODER_GLOBAL_PROFILE } from "../../src/adapters/qoder/profiles";
+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";
@@ -31,6 +31,21 @@ describe("qoder live model discovery", () => {
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 } | 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;
From a0e794d1d0eab22d00676903a088c2644af99ba6 Mon Sep 17 00:00:00 2001
From: rrmlima
Date: Mon, 7 Sep 2026 23:15:31 -0300
Subject: [PATCH 012/109] feat(integrations): support source-preserving YAML
for Hermes Agent (#3989)
Configure `sourcePreservingYaml: { path: ["providers", "opencodex"] }` for
Hermes Agent in `INTEGRATION_CLIENTS`.
Hermes Agent is a multi-provider autonomous agent whose configuration
(`~/.hermes/config.yaml`) typically hosts multiple providers, vision models,
and comments. Previously, Hermes was registered without source preservation,
causing `classifyIntegration` to fail-closed (`state: "conflict", reason: "foreign-edit"`)
whenever a user modified sibling providers or comments. In the dashboard UI,
this locked the toggle and threatened a destructive full-file restore ("Replace").
By giving Hermes source-preserving YAML semantics (joining OMP and DSH):
- OpenCodex mutates only `providers.opencodex` in-place.
- Sibling provider configurations, user comments, and formatting remain untouched.
- Unit and invariant test suites updated with dedicated regression coverage.
- Public documentation in `guides/integrations.md` updated accordingly.
Closes #3989
(cherry picked from commit a1fe9caeba9d980ade932cac473934178f1925a4)
---
.../src/content/docs/guides/integrations.md | 10 +-
src/integrations/registry.ts | 1 +
tests/clients/integrations-state.test.ts | 21 +++-
tests/clients/integrations-writer.test.ts | 102 ++++++++++++------
tests/gui/integrations-invariants.test.ts | 16 +--
5 files changed, 106 insertions(+), 44 deletions(-)
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/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 hermesConfigPath(env, home),
detectDir: (env = process.env, home = homedir()) => hermesHomeDir(env, home),
+ sourcePreservingYaml: { path: ["providers", "opencodex"] },
},
openclaw: {
id: "openclaw",
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 {
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;
expect(doc).toEqual({ providers: {} });
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);
From 14ce693e5846596c823941ce90add538713a25b1 Mon Sep 17 00:00:00 2001
From: root
Date: Mon, 7 Sep 2026 23:05:14 -0300
Subject: [PATCH 013/109] fix(google): guard model-tail histories with user
continue nudge across Gemini and CCA
Google Gemini (via Cloud Code Assist / Antigravity, Vertex AI, and AI Studio)
rejects requests whose conversation history ends on a model turn with HTTP 400
INVALID_ARGUMENT ("Requests ending with a model turn are not supported.").
Previously, the continue nudge was only attached for Claude-on-Antigravity under
the assumption that Gemini natively accepts model-tail histories. In practice,
autonomous multi-turn loops (such as subagent orchestration and continuation turns
in Codex CLI) produce histories ending in an assistant message, triggering the 400.
This patch:
- Injects a synthetic user `(continue)` nudge in `messagesToGeminiFormat` whenever
the reconstructed `contents` ends with `role: "model"` or is empty.
- Broadens the post-replay Antigravity tail check to cover all Google models.
- Updates unit tests to verify that Gemini 3.7 Flash, Gemini 3.8 Flash, and
AI Studio endpoints all receive the continue nudge on model-tail contexts.
Co-authored-by: CommandCodeBot
(cherry picked from commit 1837b8f99ed6bf5bf99cac841335a3abf64f416f)
---
src/adapters/google.ts | 32 ++++++++++++------
.../google-claude-prefill-guard.test.ts | 33 ++++++++++++++++---
2 files changed, 51 insertions(+), 14 deletions(-)
diff --git a/src/adapters/google.ts b/src/adapters/google.ts
index 9d828af0bd..5b4342ec30 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,16 +906,16 @@ 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)" }] });
- }
+ // Gemini 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 last = contents.length > 0 ? contents[contents.length - 1] as { role?: string } : undefined;
+ if (!last || last.role === "model") {
+ contents.push({ role: "user", parts: [{ text: "(continue)" }] });
}
}
const envelope = {
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)" }] });
});
});
From 6ba1e67502fcdb50975b2aa106ff41dd044cf3bb Mon Sep 17 00:00:00 2001
From: t
Date: Tue, 8 Sep 2026 21:31:42 +0900
Subject: [PATCH 014/109] feat(gui): give Qoder its brand mark and name the CLI
providers
qoder and qoder-cn landed without an icon alias or display name, so the
dashboard drew them as an initial tile with a raw config id underneath;
codebuddy and codebuddy-cn had the same gap. Every other first-class provider
carries both.
Qoder ships its own square symbol as the declared site icon, byte-identical
across qoder.com, qoder.cn, qoder.com.cn and the schema.org Organization
logo, and neither terms document restricts its use; it is committed
verbatim and wired to both ids. CodeBuddy keeps the initials tile on purpose:
section 9.3 of the Tencent service agreement forbids using Tencent brand
features under any circumstances without written consent. Both decisions,
with sources and clauses, are recorded in the provider-icons README so the
refusal is not silently re-litigated.
Also publishes the Qoder Global/CN section in the docs-site providers guide
(the repo-level docs/qoder-cli-provider.md is not built) and lists the
qoder adapter in the configuration reference.
---
.../src/content/docs/guides/providers.md | 34 ++++++++++
.../docs/reference/configuration/providers.md | 2 +-
gui/public/provider-icons/README.md | 66 +++++++++++++++++++
gui/public/provider-icons/qoder.svg | 5 ++
gui/src/provider-icons.ts | 12 ++++
gui/tests/provider-icons.test.ts | 25 +++++++
6 files changed, 143 insertions(+), 1 deletion(-)
create mode 100644 gui/public/provider-icons/qoder.svg
diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 131219e0dc..31bb2e3d42 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -649,6 +649,40 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C
- **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/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md
index 44f7d4c383..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`), `codebuddy`. |
+| `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/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 ``/``, 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/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 @@
+
diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts
index 7f7996a085..6cae98676d 100644
--- a/gui/src/provider-icons.ts
+++ b/gui/src/provider-icons.ts
@@ -43,6 +43,14 @@ const PROVIDER_ICON_ALIASES: Record = {
"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",
@@ -141,6 +149,10 @@ const PROVIDER_DISPLAY_NAMES: Record = {
"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",
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);
+});
From 295bcf82be2d42b84039b07782d6f9a36a80eff2 Mon Sep 17 00:00:00 2001
From: t
Date: Tue, 8 Sep 2026 21:47:13 +0900
Subject: [PATCH 015/109] docs(integrations): keep fr and zh-TW in step with
Hermes source preservation
The English guide now lists Hermes with OMP and DSH as source-preserving
YAML writers; the French and Traditional Chinese pages still named it among
the clients whose switch locks on sibling edits, contradicting runtime
behaviour.
---
docs-site/src/content/docs/fr/guides/integrations.md | 6 +++---
docs-site/src/content/docs/zh-tw/guides/integrations.md | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
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/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),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。
## 誠實的預期
From 16d49ceab82f69d6d37ca47f331af1603f441aeb Mon Sep 17 00:00:00 2001
From: t
Date: Tue, 8 Sep 2026 21:47:13 +0900
Subject: [PATCH 016/109] fix(google): keep a single owner for the model-tail
continue nudge
The carried change added the nudge to messagesToGeminiFormat for every
Google mode and also widened the post-replay Antigravity check from
Claude-only to all models, leaving two injection sites on one request. The
second is only a no-op while nothing between them reshapes the tail; the
signature sanitizer can. Drop the post-replay copy so the formatter owns the
invariant.
---
src/adapters/google.ts | 14 +++-----------
1 file changed, 3 insertions(+), 11 deletions(-)
diff --git a/src/adapters/google.ts b/src/adapters/google.ts
index 5b4342ec30..9e1b46307e 100644
--- a/src/adapters/google.ts
+++ b/src/adapters/google.ts
@@ -906,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);
- // Gemini 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 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,
From ed66b6bdbafc2866c2b64b8260d0cab4f928ac81 Mon Sep 17 00:00:00 2001
From: t
Date: Tue, 8 Sep 2026 23:05:12 +0900
Subject: [PATCH 017/109] docs(devlog): record the provider runtime stack
landing
Roadmap, measured conflict map, mark sourcing decision, two audit rounds,
secondary PR dispositions, and the delivery record for #4026-#4031: six
layers merged bottom-up into dev at e2bf1672c after a green lane=all run
(34231255231) on the top head 16d49ceab. Local suite/typecheck/build were
deliberately NOT RUN; hosted CI is the only execution proof. Closed as
_fin because every outcome it records is already in public history.
---
.../260908_provider_runtime_stack/000_plan.md | 65 ++++++++++++++++++
.../010_layer_plan.md | 34 +++++++++
.../011_conflict_map.md | 24 +++++++
.../012_mark_sourcing.md | 23 +++++++
.../013_secondary_dispositions.md | 23 +++++++
.../020_wp2_carry.md | 23 +++++++
.../030_wp3_marks_docs.md | 15 ++++
.../040_wp4_publish_merge.md | 16 +++++
.../050_delivery_record.md | 58 ++++++++++++++++
.../060_ledger.md | 17 +++++
.../assets/031_l4_provider_marks.png | Bin 0 -> 30266 bytes
11 files changed, 298 insertions(+)
create mode 100644 devlog/_fin/260908_provider_runtime_stack/000_plan.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/010_layer_plan.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/011_conflict_map.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/012_mark_sourcing.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/013_secondary_dispositions.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/020_wp2_carry.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/030_wp3_marks_docs.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/040_wp4_publish_merge.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/050_delivery_record.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/060_ledger.md
create mode 100644 devlog/_fin/260908_provider_runtime_stack/assets/031_l4_provider_marks.png
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 0000000000000000000000000000000000000000..18f4797503fd4c4885df4e8d34c05487e8de1324
GIT binary patch
literal 30266
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
S8)%&*lCGA4<{gdwC;tcBR+_v3
literal 0
HcmV?d00001
From cfff026d58c4d6e894214cbec5248d70d759ba7f Mon Sep 17 00:00:00 2001
From: JUN
Date: Wed, 9 Sep 2026 02:37:43 +0900
Subject: [PATCH 018/109] wp1: land the luvs01 fixture and bug-fix train (9
PRs) (#4049)
* test(lib): make idle deadline reset timing deterministic
Carried from #4041 (author luvs01) for maintainer-side CI and sequential integration.
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* test: stabilize Windows fixture waits and case cleanup
Carried from #4015 (author luvs01) for maintainer-side CI and sequential integration.
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* test(codex): verify timeout termination without racing child timers
Carried from #4012 (author luvs01) for maintainer-side CI and sequential integration.
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* test(codex): hold prompt-probe admission through document edits
Carried from #4014 (author luvs01) for maintainer-side CI and sequential integration.
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* test(clients): bound transaction fixture child completion
Carried from #4004 (author luvs01) for maintainer-side CI and sequential integration.
Closes #4003
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* fix(codex): retain overlapping multiline TOML terminators
Carried from #4039 (author luvs01) for maintainer-side CI and sequential integration.
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* fix(cli): reject unsupported caps and report ignored legacy values
Carried from #4043 (author luvs01) for maintainer-side CI and sequential integration.
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* fix(codex): share trigger-only delegation guidance with v1
Carried from #4034 (author luvs01) for maintainer-side CI and sequential integration.
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* fix(codex): preserve settings when journal injection hashes are missing
Carried from #4006 (author luvs01) for maintainer-side CI and sequential integration.
Closes #4005
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* docs(cli): note that --injection still accepts none and minimal
CodeRabbit review on #4049: the effort reference documents caps only, so the
new rejection of none/minimal reads as a global removal. --injection keeps
both values because it sets the injection effort rather than a cap.
---------
Co-authored-by: t
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
---
.../docs/fr/guides/codex-integration.md | 7 +-
.../docs/fr/guides/sub-agent-surface.md | 5 +-
.../content/docs/guides/codex-integration.md | 24 +-
.../content/docs/guides/sub-agent-surface.md | 6 +-
.../docs/ja/guides/codex-integration.md | 4 +-
.../docs/ja/guides/sub-agent-surface.md | 4 +-
.../docs/ko/guides/codex-integration.md | 4 +-
.../docs/ko/guides/sub-agent-surface.md | 4 +-
.../content/docs/ko/reference/cli/agents.md | 21 ++
.../docs/ko/reference/cli/lifecycle.md | 8 +
.../src/content/docs/reference/cli/agents.md | 21 ++
.../content/docs/reference/cli/lifecycle.md | 8 +
.../docs/ru/guides/codex-integration.md | 7 +-
.../docs/ru/guides/sub-agent-surface.md | 5 +-
.../docs/tr/guides/codex-integration.md | 7 +-
.../docs/tr/guides/sub-agent-surface.md | 5 +-
.../docs/zh-cn/guides/codex-integration.md | 6 +-
.../docs/zh-cn/guides/sub-agent-surface.md | 4 +-
.../docs/zh-tw/guides/codex-integration.md | 6 +-
.../docs/zh-tw/guides/sub-agent-surface.md | 4 +-
src/cli/effort.ts | 32 +-
src/codex/inject.ts | 40 ++-
src/codex/journal.ts | 73 +++-
src/codex/project-config-warnings.ts | 4 +-
src/server/responses/collaboration.ts | 13 +-
structure/02_config-and-codex-home.md | 10 +
structure/03_catalog-and-subagents.md | 10 +-
tests/cli/cli-effort.test.ts | 126 +++++++
tests/cli/cli-start-journal-order.test.ts | 5 +
tests/clients/client-connect.test.ts | 134 ++++++--
.../codex-catalog-restore.test.ts | 6 +-
tests/codex-integration/codex-journal.test.ts | 240 ++++++++++++-
.../codex-prompt-route.test.ts | 314 ++++++++++--------
.../codex-retained-root-serialization.test.ts | 73 ++--
.../multi-agent-compat.test.ts | 54 ++-
.../native-profile-processes.test.ts | 36 +-
.../project-config-warnings.test.ts | 43 +++
tests/lib/abort-idle-deadline.test.ts | 63 +++-
.../server-xai-responses-streaming.test.ts | 82 ++++-
39 files changed, 1214 insertions(+), 304 deletions(-)
diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md
index 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/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/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md
index 2944208279..5b75a21b41 100644
--- a/docs-site/src/content/docs/guides/codex-integration.md
+++ b/docs-site/src/content/docs/guides/codex-integration.md
@@ -605,9 +605,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 +619,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/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/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/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md
index 41f90537cd..ea7ff40cde 100644
--- a/docs-site/src/content/docs/ko/guides/codex-integration.md
+++ b/docs-site/src/content/docs/ko/guides/codex-integration.md
@@ -250,7 +250,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..796a3d7a12 100644
--- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md
@@ -54,6 +54,10 @@ stop/start 대체 동작 없이 안전하게 실패합니다. 소유권을 확
프록시를 중지하지 않고 기본 Codex를 **복원**합니다. 주입된 설정 줄과 라우팅된 카탈로그 항목을
제거하므로 일반 `codex`가 다시 네이티브로 동작합니다. `eject`는 `restore`의 별칭입니다.
+저장된 저널에 해당 파일의 주입 상태 해시가 없으면, 변경된 설정 파일을 덮어쓰는 대신 복원 실패를
+보고합니다. 현재 파일과 저널은 검토용으로 보존됩니다.
+[해시 없는 저널의 복구 규칙](/guides/codex-integration/#recovery-without-injection-hashes)을 참고하세요.
+
둘 중 어느 표기든 `back`을 붙이면 이미 실행 중인 프록시를 가리키도록 일반 `codex`를 다시
연결하되, 프록시 수명 주기는 바꾸지 않습니다.
@@ -185,6 +189,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/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..92253e41f8 100644
--- a/docs-site/src/content/docs/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/reference/cli/lifecycle.md
@@ -59,6 +59,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:
@@ -214,6 +218,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/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/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/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-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/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/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 [--json]
+ ocx effort [--json]
ocx effort set [--main ] [--subagent ] [--injection ] [--json]
ocx effort clear [--json]
ocx effort 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 {
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 {
"",
"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 {
- 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/codex/inject.ts b/src/codex/inject.ts
index 37e73c8e33..2241d8422b 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,
@@ -1149,6 +1150,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,21 +1176,14 @@ 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, {
@@ -1733,6 +1745,12 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex
function restoreCodexConfigInline(): CodexRestoreConfigResult {
try {
const journal = restoreJournalState();
+ if (journal.unverified) {
+ return {
+ state: "failed", changed: false, action: "failed",
+ message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.",
+ };
+ }
const restored = journal.configRestored
? { success: true, message: "Codex config restored from opencodex journal." }
: removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
diff --git a/src/codex/journal.ts b/src/codex/journal.ts
index b2ec89eef0..8aafb19202 100644
--- a/src/codex/journal.ts
+++ b/src/codex/journal.ts
@@ -62,12 +62,40 @@ export interface RestoreJournalResult {
configChanged: boolean;
profileChanged: boolean;
complete: boolean;
+ /** A changed artifact has no recorded injected hash, so snapshot ownership is unknown. */
+ unverified: boolean;
}
function sha256(content: string | null): string | null {
return content === null ? null : createHash("sha256").update(content).digest("hex");
}
+function compareJournalState(journal: Journal, config: string | null, profile: string | null) {
+ const originalConfig = Buffer.from(journal.originalConfig, "base64").toString("utf-8");
+ const originalProfile = journal.originalProfile === null
+ ? null
+ : Buffer.from(journal.originalProfile, "base64").toString("utf-8");
+ const configAlreadyOriginal = config === originalConfig;
+ const profileAlreadyOriginal = profile === originalProfile;
+ const configHashKnown = typeof journal.injectedConfigHash === "string" && journal.injectedConfigHash.length > 0;
+ const profileHashKnown = journal.injectedProfileHash !== undefined;
+ return {
+ originalConfig,
+ originalProfile,
+ configAlreadyOriginal,
+ profileAlreadyOriginal,
+ configUnchanged: configAlreadyOriginal || (configHashKnown && sha256(config) === journal.injectedConfigHash),
+ profileUnchanged: profileAlreadyOriginal || (profileHashKnown && sha256(profile) === (journal.injectedProfileHash ?? null)),
+ unverified: (!configHashKnown && !configAlreadyOriginal) || (!profileHashKnown && !profileAlreadyOriginal),
+ };
+}
+
+/** Read-only check of the pre-injection snapshot input, never the newly injected bytes. */
+export function hasUnverifiedJournalBaseline(config: string | null, profile: string | null): boolean {
+ const journal = readJournal(false);
+ return journal !== null && compareJournalState(journal, config, profile).unverified;
+}
+
export interface WriteJournalOptions {
/**
* The caller's verdict on the config it is about to transform: false when
@@ -114,7 +142,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
const journal: Journal = {
version: 1,
originalConfig: Buffer.from(config).toString("base64"),
- originalProfile: profile ? Buffer.from(profile).toString("base64") : null,
+ originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null,
pid: process.pid,
owner: options.owner?.kind === "client"
? { kind: "client", apiKeyId: options.owner.apiKeyId }
@@ -208,22 +236,34 @@ export function journalOwner(): JournalOwner | null {
export function restoreJournalState(): RestoreJournalResult {
const journal = readJournal();
if (!journal) {
- return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false };
+ return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false };
}
- const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : "";
+ const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null;
const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null;
- const configUnchanged = !journal.injectedConfigHash || sha256(currentConfig) === journal.injectedConfigHash;
- const profileUnchanged = journal.injectedProfileHash === undefined || sha256(currentProfile) === (journal.injectedProfileHash ?? null);
+ const comparison = compareJournalState(journal, currentConfig, currentProfile);
+ const { configUnchanged, profileUnchanged } = comparison;
+ // A legacy record or interruption before markJournalInjectedState is not proof that
+ // later bytes belong to OpenCodex. Keep the whole pair and its recovery evidence intact.
+ if (comparison.unverified) {
+ return {
+ configRestored: comparison.configAlreadyOriginal,
+ profileRestored: comparison.profileAlreadyOriginal,
+ configChanged: !configUnchanged,
+ profileChanged: !profileUnchanged,
+ complete: false,
+ unverified: true,
+ };
+ }
- let configRestored = false;
- let profileRestored = false;
- if (configUnchanged) {
- atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8"));
+ let configRestored = comparison.configAlreadyOriginal;
+ let profileRestored = comparison.profileAlreadyOriginal;
+ if (configUnchanged && !configRestored) {
+ atomicWriteFile(CODEX_CONFIG_PATH, comparison.originalConfig);
configRestored = true;
}
- if (profileUnchanged) {
- if (journal.originalProfile !== null) {
- atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8"));
+ if (profileUnchanged && !profileRestored) {
+ if (comparison.originalProfile !== null) {
+ atomicWriteFile(CODEX_PROFILE_PATH, comparison.originalProfile);
profileRestored = true;
} else if (existsSync(CODEX_PROFILE_PATH)) {
// "There was no profile before, so remove the one we generated." Claiming success
@@ -249,6 +289,7 @@ export function restoreJournalState(): RestoreJournalResult {
configChanged: !configUnchanged,
profileChanged: !profileUnchanged,
complete,
+ unverified: false,
};
}
@@ -267,6 +308,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
if (owner?.kind === "client") {
if (options.activeClientApiKeyId === owner.apiKeyId) return false;
const restored = restoreJournalState();
+ if (restored.unverified) {
+ console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
+ return false;
+ }
if (!restored.configRestored && !restored.profileRestored) return false;
console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`);
return true;
@@ -281,6 +326,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
}
}
const restored = restoreJournalState();
+ if (restored.unverified) {
+ console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
+ return false;
+ }
if (!restored.configRestored && !restored.profileRestored) return false;
console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`);
return true;
diff --git a/src/codex/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/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 = "