From 56e56a899ba67ed45323bc0cbe408be8403e8c52 Mon Sep 17 00:00:00 2001 From: YUHAO-corn Date: Thu, 20 Aug 2026 15:54:46 +0800 Subject: [PATCH 01/67] docs: clarify account pool policy boundary --- README.md | 8 +++++++- docs-site/src/content/docs/guides/web-dashboard.md | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f6f0770eed..b2abd358a7 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,14 @@ see the [installation docs](https://opencodex.me/getting-started/installation/). - **Use any LLM with Codex, Claude Code, Claude Desktop, and Grok Build** — 40+ providers out of the box, each keeping its own native UI. -- **Pool ChatGPT accounts safely** — thread affinity, quota-aware auto-switching, cooldown and +- **Pool ChatGPT accounts** — thread affinity, quota-aware auto-switching, cooldown and fail-closed auth handling. + + > **Provider-policy note:** Account pooling is for routing and resilience. OpenCodex does not + > endorse using additional accounts to circumvent provider limits or sharing account credentials + > between people. You are responsible for complying with each provider's current terms. See the + > [Codex Auth account-pool guidance](https://opencodex.me/guides/web-dashboard/#codex-auth-and-account-pools) + > and [OpenAI's current Terms of Use](https://openai.com/policies/terms-of-use/). - **Combos** — one virtual model id with failover or weighted round-robin across providers. See the [combo guide](https://opencodex.me/guides/combos/). - **Sub-agents on any model** — feature routed models in Codex's sub-agent picker, with v1/v2 diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index a32643fbb6..6c2ca81e05 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -110,6 +110,17 @@ In-flight requests keep their captured credentials, and a 401/403 reauthenticati may clear affinity and rotate to another eligible Pool account. This is separate from `openai-apikey` and other providers. +:::caution[Provider-policy responsibility] +The account pool is a technical account-management, routing, and resilience feature. It does not +claim that having multiple accounts is itself prohibited; compliance depends on the account setup and +use pattern. OpenCodex does not endorse using additional accounts to circumvent rate limits, quotas, +plan limits, or other provider restrictions, or sharing account credentials between people. You are +responsible for complying with the provider's current terms for every connected account and use +pattern. Provider restrictions, suspension, or termination are outside OpenCodex's control; +maintainers do not provide policy advice and cannot resolve provider enforcement. Review +[OpenAI's current Terms of Use](https://openai.com/policies/terms-of-use/). +::: + - Manually choosing an account applies immediately: an already-bound thread moves to it on its next request, and only requests already in flight keep the account they captured. A manual choice is also pinned: the card shows a **PINNED** badge, and a higher selection order cannot preempt that account From 1ff6ba11f8c1374265489304e1b261554ea311db Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 18:53:34 +0900 Subject: [PATCH 02/67] docs(devlog): record the v2.28.0 release and its CI evidence --- .../120_release_2280.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 devlog/_plan/260820_bug_pr_backlog_consolidation/120_release_2280.md diff --git a/devlog/_plan/260820_bug_pr_backlog_consolidation/120_release_2280.md b/devlog/_plan/260820_bug_pr_backlog_consolidation/120_release_2280.md new file mode 100644 index 0000000000..859cafdb83 --- /dev/null +++ b/devlog/_plan/260820_bug_pr_backlog_consolidation/120_release_2280.md @@ -0,0 +1,74 @@ +# 120 — v2.28.0 release + +Unit: 260820_bug_pr_backlog_consolidation + +Published: `@bitkyc08/opencodex@2.28.0` as npm `latest`. + +| Artifact | Value | +|---|---| +| Release commit | `584059132` (main) | +| Tag | `v2.28.0` → `5840591322117f3ee9568b35b135a6d4339f7711` | +| GitHub release | `v2.28.0`, 2026-08-20T09:17:50Z | +| Release workflow | run `32355161140`, **success**, head `584059132` | +| npm dist-tags | `latest: 2.28.0` | +| preview line | `2.28.0-preview.20260820` at `d2c700c1a` | + +## The CI stability claim, as measured rather than asserted + +The release gate in `release.yml` is deliberately narrow: it requires a **successful push-event +`ci.yml` run for the exact release SHA on the release branch**. A PR run for the same SHA does +not qualify, because it ran against a merge ref under a different trigger context. + +That gate was met: `main` push-event CI is **success at `584059132`** — the exact commit the +tag points at. + +**`dev` CI is red at `96f288d59`, and that was checked rather than waved past.** The failures +are Windows-only: + +- `windows 1/4`: WP13 composed `E` (54,074 ms) and `Restore truth` (45,197 ms). +- `windows 2/4`: journal-ownership start/ensure, and CL-10 deterministic bundle failing inside + `publishPrivateFileExclusive` on a `D:\` path. + +Every non-Windows job in that same run succeeded. The Windows leg is `workflow_dispatch`-only +by design and is explicitly excluded from the release gate — `ci.yml:544-546` states that +`release.yml` gates on Linux + macOS + gates, and that "Windows re-enters the gate when the +tracked failures are fixed, not before" (issue #1059). These are the pre-existing failures +#2152 catalogues, not a regression from this range. + +So: **not a green Windows leg, and not claimed as one.** The release shipped on the gate the +repository actually defines, with the red leg named and attributed. + +## Two things the release helper caught that a manual publish would not have + +**The local gate failed on missing GUI dependencies, not on a defect.** The first run failed 7 +tests with `Cannot find package 'react'`. CI installs twice — root and then `gui/` — and builds +the GUI, because tests that fetch the served dashboard read their session bootstrap out of +`gui/dist/index.html`. The `ci.yml` comment at line 280 predicts this exact failure. After +`cd gui && bun install` plus `bun run build`, the gate passed and the publish proceeded. + +**The helper is re-entrant and it proved it.** A later re-run refused with "release version +2.28.0 is already partially or fully used", listing npm, the remote tag, and the GitHub release. +That is the metadata preflight working as intended: it is what turned an ambiguous "did that +publish land?" into a verified yes, and it is why the second attempt could not double-publish. + +## Verification performed + +``` +npm view @bitkyc08/opencodex version -> 2.28.0 +npm view ... dist-tags -> latest: 2.28.0 +git tag -l v2.28.0 -> v2.28.0 +gh release view v2.28.0 -> present +git merge-base --is-ancestor 584059132 origin/main -> YES +gh run view 32355161140 -> success @ 584059132 +``` + +A pushed commit and a dispatched workflow were not treated as a completed release; each artifact +above was read back from its own authority. + +## Not done + +The preview channel carries `2.28.0-preview.20260820` as a commit, but npm `dist-tags.preview` +still reads `2.26.0-preview.20260819` — that channel has not been published. Its CI was still +in progress at `d2c700c1a`. Stable was the release under authorization; the preview publish is +a separate decision. + From 82acf305e730352eec95f6dedad0aec3edfa1b37 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 20:01:37 +0900 Subject: [PATCH 03/67] docs(devlog): record the preview publish and the two runner-bound CI failures --- .../120_release_2280.md | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/devlog/_plan/260820_bug_pr_backlog_consolidation/120_release_2280.md b/devlog/_plan/260820_bug_pr_backlog_consolidation/120_release_2280.md index 859cafdb83..5c9852a6d2 100644 --- a/devlog/_plan/260820_bug_pr_backlog_consolidation/120_release_2280.md +++ b/devlog/_plan/260820_bug_pr_backlog_consolidation/120_release_2280.md @@ -65,10 +65,45 @@ gh run view 32355161140 -> success @ 584059132 A pushed commit and a dispatched workflow were not treated as a completed release; each artifact above was read back from its own authority. -## Not done +## The preview channel, published after this record was first written -The preview channel carries `2.28.0-preview.20260820` as a commit, but npm `dist-tags.preview` -still reads `2.26.0-preview.20260819` — that channel has not been published. Its CI was still -in progress at `d2c700c1a`. Stable was the release under authorization; the preview publish is -a separate decision. +At the time the section above was written, `preview` carried the version commit but npm still +read `2.26.0-preview.20260819` — the channel had been trailing `latest` by a full release. It +has since been published on the same path. +| Artifact | Value | +|---|---| +| Release commit | `d2c700c1a` (preview) | +| Tag | `v2.28.0-preview.20260820` | +| GitHub release | `v2.28.0-preview.20260820`, pre-release, 2026-08-20T10:54:38Z | +| Release workflow | run `32361122670`, **success** | +| npm dist-tags | `preview: 2.28.0-preview.20260820` | + +Both channels now sit on the same content, which is what `preview` is for and what it had +stopped being while it lagged a release behind. + +The helper enforces the channel pairing itself rather than trusting the invocation: a +`preview` branch release must carry a `-preview.` version and publish to the `preview` +dist-tag, and `main` must be stable semver on `latest`. There is no argument combination that +crosses them. + +**The last dispatch of this publish failed, and that failure is the guard working.** Run +`32361621459` refused with "v2.28.0-preview.20260820 already exists. Refusing to publish a +version with pre-existing Git metadata." A local shell had died mid-suite and the release was +restarted; by then run `32361122670` had already published. The refusal is what turned a +possible double-publish into a no-op, and it is the same preflight that caught the stable +re-run described above. + +## Two Linux failures that were not defects + +`test 1/4` failed twice on `Codex autostart shim > an aged lock held by a live restore owner +is never reclaimed`, once on the stable release PR and once on the preview one. Both times it +hit the 60 s lane ceiling; both times it passed on re-run, and it passes locally in a 20 s +single-file run. The case spawns two real Bun processes and has one wait for the other's lock, +so it is spawn-latency-bound on a contended runner rather than assertion-bound. It carries no +budget of its own, unlike the cases in `tests/helpers/test-budget.ts`. + +Worth naming rather than burying: two occurrences on the same case is a pattern, not noise. It +is not a release blocker — nothing about the shipped code changed between the red and green +runs — but it belongs on the same list as the Windows spawn-cost failures in #2152, and giving +it an intrinsic budget is the obvious next step. From bbf29b3c92a3b6b9af81d8ef275f4bb3bed92977 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 20 Aug 2026 11:53:29 +0000 Subject: [PATCH 04/67] fix(responses): bound terminal guard delta retention --- src/server/responses/terminal-guard.ts | 21 ++++++++++++++------- tests/terminal-guard.test.ts | 11 +++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index 347a489f19..67a55c2bfe 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -153,6 +153,16 @@ export interface GuardedEventStreamOptions { maxAutoContinuations?: number; } +/** + * Events that are useful to the downstream stream consumer but carry no state used by the + * terminal-continuation decision or its rebuilt request. Keep them out of the retained event + * list so adapter liveness markers and arbitrarily large tool-argument fragments cannot make + * the guard's per-turn memory grow without adding any continuation semantics. + */ +export function isTerminalGuardPassthroughOnly(event: AdapterEvent): boolean { + return event.type === "heartbeat" || event.type === "tool_call_delta"; +} + function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined { if (!first) return second; if (!second) return first; @@ -193,13 +203,10 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio const seen: AdapterEvent[] = []; let terminalSeen = false; for await (const event of source) { - // A heartbeat is adapter liveness, not turn content: it exists so the bridge watchdog - // can tell a buffering adapter from a hung one. Retaining it here would put an - // unbounded number of empty markers into `seen`, which feeds both the continuation - // analysis and the rebuilt request — and the openai-chat adapter now emits one per - // tool-call delta, so a long argument payload alone could grow this array without - // limit. The empty-completion guard already passes them through unretained; match it. - if (event.type === "heartbeat") { + // Liveness markers and tool argument fragments are passed through to the bridge, but + // neither analyzeTerminalTurn nor buildContinuationRequest consumes them. Retaining the + // fragments would duplicate arbitrarily large argument payloads in `seen` for no effect. + if (isTerminalGuardPassthroughOnly(event)) { yield event; continue; } diff --git a/tests/terminal-guard.test.ts b/tests/terminal-guard.test.ts index 906c726997..356e54fb3d 100644 --- a/tests/terminal-guard.test.ts +++ b/tests/terminal-guard.test.ts @@ -3,6 +3,7 @@ import { analyzeTerminalTurn, buildContinuationRequest, guardTerminalEventStream, + isTerminalGuardPassthroughOnly, } from "../src/server/responses/terminal-guard"; import { buildResponseJSON } from "../src/bridge"; import type { AdapterEvent, OcxParsedRequest } from "../src/types"; @@ -236,6 +237,16 @@ describe("terminal guard", () => { expect(actual.filter(event => event.type === "done")).toHaveLength(1); }); + test("does not retain passthrough-only liveness or tool argument fragments", () => { + expect(isTerminalGuardPassthroughOnly({ type: "heartbeat" })).toBe(true); + expect(isTerminalGuardPassthroughOnly({ + type: "tool_call_delta", + arguments: "x".repeat(1024 * 1024), + })).toBe(true); + expect(isTerminalGuardPassthroughOnly({ type: "tool_call_start", id: "call_1", name: "exec_command" })).toBe(false); + expect(isTerminalGuardPassthroughOnly({ type: "text_delta", text: "working" })).toBe(false); + }); + test("stops after the configured continuation bound", async () => { let continuations = 0; const actual: AdapterEvent[] = []; From d541ffbb657e4f8b87eb4986143c75613062af00 Mon Sep 17 00:00:00 2001 From: agentHits Date: Thu, 20 Aug 2026 15:35:35 +0300 Subject: [PATCH 05/67] fix(google): support unwrapped freeform tool arguments in antigravity replay cache - Freeform/custom tools (such as default_api:exec) are emitted to clients as custom_tool_call with { input: '{"cmd":...}' } while upstream observation records parsed function arguments { cmd: ... }. - applyAntigravityReplay now unwraps { input: string } to match against observed JSON argument signatures when exact matching misses, fixing 400 errors during early turns and replayed freeform execution (#2125). --- src/adapters/google-antigravity-replay.ts | 29 ++++++++++++-- src/adapters/google.ts | 3 +- tests/google-antigravity-replay.test.ts | 12 ++++++ ...google-signature-history-roundtrip.test.ts | 40 ++++++++++++++++++- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 745ac44f9b..c2743be937 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -702,11 +702,34 @@ export function applyAntigravityReplay(model: string, sessionId: string, content if (!fc) continue; if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) continue; const ck = functionCallKey(fc.name, fc.args); - const call = ck ? entry.byCall.get(ck) : undefined; + let call = ck ? entry.byCall.get(ck) : undefined; + let matchedKey = ck; + if (!call && typeof fc.name === "string" && typeof fc.args === "object" && fc.args !== null) { + // Freeform / custom tool replay unwrap: + // The client replays custom_tool_call with arguments: { input: "..." }. + // Upstream was invoked with args: { input: "..." } or raw string or parsed JSON. + const argsObj = fc.args as Record; + if (typeof argsObj.input === "string") { + try { + const parsedInput = JSON.parse(argsObj.input); + if (parsedInput && typeof parsedInput === "object") { + const altKey = functionCallKey(fc.name, parsedInput); + if (altKey && entry.byCall.has(altKey)) { + call = entry.byCall.get(altKey); + matchedKey = altKey; + } + } + } catch { + // not JSON, keep default + } + } + } if (call && ck) { part.thoughtSignature = call.signature; - entry.byCall.delete(ck); - entry.byCall.set(ck, { ...call, touchedAtMs: now }); + if (matchedKey) { + entry.byCall.delete(matchedKey); + entry.byCall.set(matchedKey, { ...call, touchedAtMs: now }); + } touched = true; } } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index f152d91741..5ce985dd4b 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -737,10 +737,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; emittedContentEvent = true; + const restoredName = restoreGoogleToolName(part.functionCall.name); yield { type: "tool_call_start", id, - name: restoreGoogleToolName(part.functionCall.name), + name: restoredName, ...googleToolCallMetadataFromPart(part, pendingStreamThoughtSig), }; yield { type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) }; diff --git a/tests/google-antigravity-replay.test.ts b/tests/google-antigravity-replay.test.ts index 0ef9524533..7e5709c744 100644 --- a/tests/google-antigravity-replay.test.ts +++ b/tests/google-antigravity-replay.test.ts @@ -205,6 +205,18 @@ describe("antigravity reasoning-replay cache", () => { expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-orderindep00000"); }); + test("matches freeform/custom_tool_call {input: string} against observed parsed JSON args", () => { + // Upstream saw functionCall with parsed args { cmd: "ls -la" } + observeAntigravityReplay(MODEL, SESSION, [fcPart("default_api:exec", { cmd: "ls -la" }, "sig-freeform-exec-1111")]); + // Client replays custom_tool_call with serialized input { input: '{"cmd":"ls -la"}' } + const contents = [{ + role: "model", + parts: [{ functionCall: { name: "default_api:exec", args: { input: JSON.stringify({ cmd: "ls -la" }) } } }], + }]; + applyAntigravityReplay(MODEL, SESSION, contents); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-freeform-exec-1111"); + }); + test("claude models do not use the replay cache", () => { expect(antigravityUsesReplayCache("claude-opus-4.6")).toBe(false); expect(antigravityUsesReplayCache("gemini-3-pro")).toBe(true); diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index b40c21f074..c914825bee 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -7,7 +7,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; -import { __resetAntigravityReplayCache } from "../src/adapters/google-antigravity-replay"; +import { __resetAntigravityReplayCache, observeAntigravityReplay } from "../src/adapters/google-antigravity-replay"; import { parseRequest } from "../src/responses/parser"; import { flushThoughtSignatureReplayForTests, @@ -257,6 +257,44 @@ describe("#1735 thought signature survives history replay", () => { expect(part?.thoughtSignature).toBe(SIGNATURE_B); }); + test("a custom_tool_call without call_id store entry falls back to in-memory replay cache by unwrapped args", async () => { + const adapter = createGoogleAdapter({ + ...provider, + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "test-proj", + apiKey: "test-token", + }); + const parsedDummy = parseRequestScoped({ + model: MODEL, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "test-session-freeform" }] }], + tools: [{ type: "function", name: "default_api:exec", description: "run", parameters: { type: "object" } }], + }, undefined); + const dummyReq = await adapter.buildRequest(parsedDummy); + const wireModel = JSON.parse(dummyReq.body as string).model; + const wireSession = JSON.parse(dummyReq.body as string).request.sessionId; + const wireToolName = JSON.parse(dummyReq.body as string).request.tools[0].functionDeclarations[0].name; + + // Warm up the Antigravity replay cache with parsed function args: + observeAntigravityReplay(wireModel, wireSession, [ + { functionCall: { name: wireToolName, args: { cmd: "whoami" } }, thoughtSignature: SIGNATURE }, + ]); + const parsed = parseRequestScoped({ + model: MODEL, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "test-session-freeform" }] }, + { type: "custom_tool_call", call_id: "call_custom_unscoped", name: "default_api:exec", input: JSON.stringify({ cmd: "whoami" }) }, + { type: "custom_tool_call_output", call_id: "call_custom_unscoped", output: "agent" }, + ], + tools: [{ type: "function", name: "default_api:exec", description: "run", parameters: { type: "object" } }], + }, undefined); // unscoped so durable store cannot hit + const request = await adapter.buildRequest(parsed); + const reqObj = JSON.parse(request.body as string); + const contents = reqObj.request.contents; + const modelTurn = contents.find((c: { role: string }) => c.role === "model"); + expect(modelTurn.parts[0].thoughtSignature).toBe(SIGNATURE); + }); + test("a tool_search_call replay is re-signed from the proxy-side store", async () => { rememberThoughtSignatureForReplay("call_ts_1", SIGNATURE, scopeFor()); const parsed = parseRequestScoped({ From be656941d484eefd5f40c70b72282935a50a6459 Mon Sep 17 00:00:00 2001 From: agentHits Date: Thu, 20 Aug 2026 15:48:07 +0300 Subject: [PATCH 06/67] fix(google): restore signature on matched alternate key and bound input length pre-parse - Restore call.signature when call && matchedKey (not gated on ck), ensuring whitespace-padded wrapped arguments whose ck overflows 64 KiB still restore their signature if the parsed inner JSON is within bounds. - Bound argsObj.input.trim() to REPLAY_MAX_CANONICAL_ARGS_BYTES before calling JSON.parse in the custom tool replay unwrap, preventing oversized remote payloads from triggering unbounded allocations. - Add regression tests for oversized whitespace-wrapped input restoring correctly and oversized JSON payloads being rejected before parse (addressing review feedback from @Ingwannu on #2198). --- src/adapters/google-antigravity-replay.ts | 13 ++++++------ tests/google-antigravity-replay.test.ts | 25 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index c2743be937..96240edb7f 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -709,7 +709,10 @@ export function applyAntigravityReplay(model: string, sessionId: string, content // The client replays custom_tool_call with arguments: { input: "..." }. // Upstream was invoked with args: { input: "..." } or raw string or parsed JSON. const argsObj = fc.args as Record; - if (typeof argsObj.input === "string") { + if ( + typeof argsObj.input === "string" + && utf8.encode(argsObj.input.trim()).byteLength <= REPLAY_MAX_CANONICAL_ARGS_BYTES + ) { try { const parsedInput = JSON.parse(argsObj.input); if (parsedInput && typeof parsedInput === "object") { @@ -724,12 +727,10 @@ export function applyAntigravityReplay(model: string, sessionId: string, content } } } - if (call && ck) { + if (call && matchedKey) { part.thoughtSignature = call.signature; - if (matchedKey) { - entry.byCall.delete(matchedKey); - entry.byCall.set(matchedKey, { ...call, touchedAtMs: now }); - } + entry.byCall.delete(matchedKey); + entry.byCall.set(matchedKey, { ...call, touchedAtMs: now }); touched = true; } } diff --git a/tests/google-antigravity-replay.test.ts b/tests/google-antigravity-replay.test.ts index 7e5709c744..27d2da4b0d 100644 --- a/tests/google-antigravity-replay.test.ts +++ b/tests/google-antigravity-replay.test.ts @@ -217,6 +217,31 @@ describe("antigravity reasoning-replay cache", () => { expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-freeform-exec-1111"); }); + test("matches alternate key even when wrapped representation exceeds canonical key limit (ck undefined)", () => { + observeAntigravityReplay(MODEL, SESSION, [fcPart("default_api:exec", { cmd: "x" }, "sig-whitespace-overflow")]); + // 65 KiB of leading whitespace makes the wrapped object exceed 64 KiB, so functionCallKey(fc.name, fc.args) is null/undefined. + // However, the parsed inner object is small and produces a valid alternate key. + const bigWhitespaceJson = " ".repeat(66 * 1024) + JSON.stringify({ cmd: "x" }); + const contents = [{ + role: "model", + parts: [{ functionCall: { name: "default_api:exec", args: { input: bigWhitespaceJson } } }], + }]; + applyAntigravityReplay(MODEL, SESSION, contents); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-whitespace-overflow"); + }); + + test("rejects oversized input before JSON.parse without matching or allocating", () => { + observeAntigravityReplay(MODEL, SESSION, [fcPart("default_api:exec", { data: "huge" }, "sig-should-not-match")]); + // 100 KiB of valid JSON payload exceeds REPLAY_MAX_CANONICAL_ARGS_BYTES and must be skipped before parsing. + const oversizedPayload = JSON.stringify({ data: "a".repeat(100 * 1024) }); + const contents = [{ + role: "model", + parts: [{ functionCall: { name: "default_api:exec", args: { input: oversizedPayload } } }], + }]; + applyAntigravityReplay(MODEL, SESSION, contents); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + }); + test("claude models do not use the replay cache", () => { expect(antigravityUsesReplayCache("claude-opus-4.6")).toBe(false); expect(antigravityUsesReplayCache("gemini-3-pro")).toBe(true); From 99412cc85a65db31eca92947440d7ef7a03ee97c Mon Sep 17 00:00:00 2001 From: agentHits Date: Thu, 20 Aug 2026 16:00:46 +0300 Subject: [PATCH 07/67] fix(google): parse validated trimmed input directly and test parse boundaries - Compute trimmedInput and pass that exact validated string to JSON.parse, preventing large whitespace-padded prefixes/suffixes from reaching JSON.parse. - Add parse seam assertions proving that trimmed small payloads are parsed directly and that oversized valid payloads never reach JSON.parse (addressing feedback from @Ingwannu on #2198). --- src/adapters/google-antigravity-replay.ts | 16 ++++----- tests/google-antigravity-replay.test.ts | 43 ++++++++++++++++++----- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 96240edb7f..5f12a4253e 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -709,12 +709,11 @@ export function applyAntigravityReplay(model: string, sessionId: string, content // The client replays custom_tool_call with arguments: { input: "..." }. // Upstream was invoked with args: { input: "..." } or raw string or parsed JSON. const argsObj = fc.args as Record; - if ( - typeof argsObj.input === "string" - && utf8.encode(argsObj.input.trim()).byteLength <= REPLAY_MAX_CANONICAL_ARGS_BYTES - ) { - try { - const parsedInput = JSON.parse(argsObj.input); + if (typeof argsObj.input === "string") { + const trimmedInput = argsObj.input.trim(); + if (utf8.encode(trimmedInput).byteLength <= REPLAY_MAX_CANONICAL_ARGS_BYTES) { + try { + const parsedInput = JSON.parse(trimmedInput); if (parsedInput && typeof parsedInput === "object") { const altKey = functionCallKey(fc.name, parsedInput); if (altKey && entry.byCall.has(altKey)) { @@ -722,8 +721,9 @@ export function applyAntigravityReplay(model: string, sessionId: string, content matchedKey = altKey; } } - } catch { - // not JSON, keep default + } catch { + // not JSON, keep default + } } } } diff --git a/tests/google-antigravity-replay.test.ts b/tests/google-antigravity-replay.test.ts index 27d2da4b0d..f507b62e10 100644 --- a/tests/google-antigravity-replay.test.ts +++ b/tests/google-antigravity-replay.test.ts @@ -219,27 +219,54 @@ describe("antigravity reasoning-replay cache", () => { test("matches alternate key even when wrapped representation exceeds canonical key limit (ck undefined)", () => { observeAntigravityReplay(MODEL, SESSION, [fcPart("default_api:exec", { cmd: "x" }, "sig-whitespace-overflow")]); - // 65 KiB of leading whitespace makes the wrapped object exceed 64 KiB, so functionCallKey(fc.name, fc.args) is null/undefined. - // However, the parsed inner object is small and produces a valid alternate key. - const bigWhitespaceJson = " ".repeat(66 * 1024) + JSON.stringify({ cmd: "x" }); + // 2 MiB of leading/trailing whitespace makes the raw string large, but trimmed payload is small (under 64 KiB). + // It must parse the trimmed slice directly rather than passing the 2 MiB string to JSON.parse. + const smallJson = JSON.stringify({ cmd: "x" }); + const bigWhitespaceJson = " ".repeat(2 * 1024 * 1024) + smallJson + " ".repeat(1024); + let parsedString = ""; + const originalParse = JSON.parse; + JSON.parse = (text, reviver) => { + if (typeof text === "string" && text.includes('"cmd":"x"')) { + parsedString = text; + } + return originalParse(text, reviver); + }; const contents = [{ role: "model", parts: [{ functionCall: { name: "default_api:exec", args: { input: bigWhitespaceJson } } }], }]; - applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-whitespace-overflow"); + try { + applyAntigravityReplay(MODEL, SESSION, contents); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-whitespace-overflow"); + expect(parsedString).toBe(smallJson); + } finally { + JSON.parse = originalParse; + } }); test("rejects oversized input before JSON.parse without matching or allocating", () => { observeAntigravityReplay(MODEL, SESSION, [fcPart("default_api:exec", { data: "huge" }, "sig-should-not-match")]); - // 100 KiB of valid JSON payload exceeds REPLAY_MAX_CANONICAL_ARGS_BYTES and must be skipped before parsing. + // 100 KiB of valid JSON payload exceeds REPLAY_MAX_CANONICAL_ARGS_BYTES and must never reach JSON.parse. + let parseCalled = false; + const originalParse = JSON.parse; + JSON.parse = (text, reviver) => { + if (typeof text === "string" && text.includes('"data":')) { + parseCalled = true; + } + return originalParse(text, reviver); + }; const oversizedPayload = JSON.stringify({ data: "a".repeat(100 * 1024) }); const contents = [{ role: "model", parts: [{ functionCall: { name: "default_api:exec", args: { input: oversizedPayload } } }], }]; - applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + try { + applyAntigravityReplay(MODEL, SESSION, contents); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect(parseCalled).toBe(false); + } finally { + JSON.parse = originalParse; + } }); test("claude models do not use the replay cache", () => { From 4e89471a7b9390fb8b1575fe528fe26282fa1c9b Mon Sep 17 00:00:00 2001 From: agentHits Date: Thu, 20 Aug 2026 16:29:59 +0300 Subject: [PATCH 08/67] fix(google): check string length before utf8.encode in replay unwrap - Guard trimmedInput.length <= REPLAY_MAX_CANONICAL_ARGS_BYTES before calling utf8.encode(trimmedInput), preventing large ASCII/serialized input from allocating proportional Uint8Array buffers on the request path. - Add regression test asserting that oversized inputs are rejected before TextEncoder.encode and JSON.parse (addressing review feedback from @Ingwannu and CodeRabbit on #2198). --- src/adapters/google-antigravity-replay.ts | 5 ++++- tests/google-antigravity-replay.test.ts | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 5f12a4253e..3d574b54d9 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -711,7 +711,10 @@ export function applyAntigravityReplay(model: string, sessionId: string, content const argsObj = fc.args as Record; if (typeof argsObj.input === "string") { const trimmedInput = argsObj.input.trim(); - if (utf8.encode(trimmedInput).byteLength <= REPLAY_MAX_CANONICAL_ARGS_BYTES) { + if ( + trimmedInput.length <= REPLAY_MAX_CANONICAL_ARGS_BYTES + && utf8.encode(trimmedInput).byteLength <= REPLAY_MAX_CANONICAL_ARGS_BYTES + ) { try { const parsedInput = JSON.parse(trimmedInput); if (parsedInput && typeof parsedInput === "object") { diff --git a/tests/google-antigravity-replay.test.ts b/tests/google-antigravity-replay.test.ts index f507b62e10..8fab813cca 100644 --- a/tests/google-antigravity-replay.test.ts +++ b/tests/google-antigravity-replay.test.ts @@ -246,15 +246,23 @@ describe("antigravity reasoning-replay cache", () => { test("rejects oversized input before JSON.parse without matching or allocating", () => { observeAntigravityReplay(MODEL, SESSION, [fcPart("default_api:exec", { data: "huge" }, "sig-should-not-match")]); - // 100 KiB of valid JSON payload exceeds REPLAY_MAX_CANONICAL_ARGS_BYTES and must never reach JSON.parse. + // 100 KiB of valid JSON payload exceeds REPLAY_MAX_CANONICAL_ARGS_BYTES and must be rejected before TextEncoder.encode / JSON.parse. let parseCalled = false; + let encodeCalledOnPayload = false; const originalParse = JSON.parse; + const originalEncode = TextEncoder.prototype.encode; JSON.parse = (text, reviver) => { if (typeof text === "string" && text.includes('"data":')) { parseCalled = true; } return originalParse(text, reviver); }; + TextEncoder.prototype.encode = function (input) { + if (typeof input === "string" && input.includes('"data":')) { + encodeCalledOnPayload = true; + } + return originalEncode.call(this, input); + }; const oversizedPayload = JSON.stringify({ data: "a".repeat(100 * 1024) }); const contents = [{ role: "model", @@ -264,8 +272,10 @@ describe("antigravity reasoning-replay cache", () => { applyAntigravityReplay(MODEL, SESSION, contents); expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); expect(parseCalled).toBe(false); + expect(encodeCalledOnPayload).toBe(false); } finally { JSON.parse = originalParse; + TextEncoder.prototype.encode = originalEncode; } }); From d949a687035c1dbe94d3642236e601a8c1478591 Mon Sep 17 00:00:00 2001 From: YUHAO-corn Date: Thu, 20 Aug 2026 21:37:05 +0800 Subject: [PATCH 09/67] docs: clarify pool enforcement boundary --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b2abd358a7..106251a8a4 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,11 @@ see the [installation docs](https://opencodex.me/getting-started/installation/). - **Pool ChatGPT accounts** — thread affinity, quota-aware auto-switching, cooldown and fail-closed auth handling. - > **Provider-policy note:** Account pooling is for routing and resilience. OpenCodex does not - > endorse using additional accounts to circumvent provider limits or sharing account credentials - > between people. You are responsible for complying with each provider's current terms. See the + > **Provider-policy note:** Account pooling is for routing and operational resilience only; it does + > not guarantee protection from provider rate limits, enforcement, suspension, or other account + > actions. OpenCodex does not endorse using additional accounts to circumvent provider limits or + > sharing account credentials between people. You are responsible for complying with each + > provider's current terms. See the > [Codex Auth account-pool guidance](https://opencodex.me/guides/web-dashboard/#codex-auth-and-account-pools) > and [OpenAI's current Terms of Use](https://openai.com/policies/terms-of-use/). - **Combos** — one virtual model id with failover or weighted round-robin across providers. See From 13cf68acdbbdd7c5b645b12f1aa5d9df99b3d370 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:32:30 +0900 Subject: [PATCH 10/67] fix(responses): bound orphan call reordering work --- src/adapters/openai-responses.ts | 40 +++++++++++++------ ...ses-stateless-dangling-call-repair.test.ts | 32 +++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index f033a0240a..37374ff873 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -725,8 +725,18 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes }; const reorderBatchOutputs = (items: unknown[]): unknown[] => { const ordered: unknown[] = []; + const claimedOutputIndexes = new Set(); + const outputIndexesByKey = new Map(); + for (let outputIndex = 0; outputIndex < items.length; outputIndex += 1) { + const outputKey = outputKeyOf(items[outputIndex]); + if (outputKey === null) continue; + const bucket = outputIndexesByKey.get(outputKey); + if (bucket) bucket.indexes.push(outputIndex); + else outputIndexesByKey.set(outputKey, { indexes: [outputIndex], offset: 0 }); + } let index = 0; while (index < items.length) { + if (claimedOutputIndexes.has(index)) { index += 1; continue; } const key = callKeyOf(items[index]); if (key === null) { ordered.push(items[index]); index += 1; continue; } const batch: unknown[] = []; @@ -745,20 +755,26 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes index = cursor; continue; } - const remainder: unknown[] = []; - const batchOutputs: Array<{ key: string; item: unknown }> = []; - for (let probe = cursor; probe < items.length; probe += 1) { - const outputKey = outputKeyOf(items[probe]); - if (outputKey !== null && batchKeys.includes(outputKey)) { - batchOutputs.push({ key: outputKey, item: items[probe] }); - } else { - remainder.push(items[probe]); + const batchOutputs: unknown[] = []; + const emittedKeys = new Set(); + for (const batchKey of batchKeys) { + if (emittedKeys.has(batchKey)) continue; + emittedKeys.add(batchKey); + const bucket = outputIndexesByKey.get(batchKey); + if (!bucket) continue; + while (bucket.offset < bucket.indexes.length && bucket.indexes[bucket.offset]! < cursor) { + bucket.offset += 1; + } + while (bucket.offset < bucket.indexes.length) { + const outputIndex = bucket.indexes[bucket.offset]!; + bucket.offset += 1; + if (claimedOutputIndexes.has(outputIndex)) continue; + claimedOutputIndexes.add(outputIndex); + batchOutputs.push(items[outputIndex]); } } - batchOutputs.sort((left, right) => batchKeys.indexOf(left.key) - batchKeys.indexOf(right.key)); - ordered.push(...batch, ...batchOutputs.map(output => output.item)); - ordered.push(...reorderBatchOutputs(remainder)); - return ordered; + ordered.push(...batch, ...batchOutputs); + index = cursor; } return ordered; }; diff --git a/tests/responses-stateless-dangling-call-repair.test.ts b/tests/responses-stateless-dangling-call-repair.test.ts index 8ef39259d3..bfddde4324 100644 --- a/tests/responses-stateless-dangling-call-repair.test.ts +++ b/tests/responses-stateless-dangling-call-repair.test.ts @@ -125,6 +125,38 @@ describe("stateless Responses wire repairs orphaned tool calls", () => { expect(String((input[4] as { output: unknown }).output)).toContain("no tool result was recorded"); }); + test("repairs many separated dangling calls without recursive reprocessing", async () => { + const callCount = 20_000; + const requestInput = Array.from({ length: callCount }, (_, index) => [ + { type: "function_call", id: `fc_${index}`, call_id: `call_${index}`, name: "exec_command", arguments: "{}" }, + { type: "message", role: "user", content: [{ type: "input_text", text: `separator ${index}` }] }, + ]).flat(); + + const { body } = await drive(requestInput); + const input = body.input as Array>; + expect(input).toHaveLength(callCount * 3); + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_0" }); + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_0" }); + expect(input.at(-3)).toMatchObject({ type: "function_call", call_id: `call_${callCount - 1}` }); + expect(input.at(-2)).toMatchObject({ type: "function_call_output", call_id: `call_${callCount - 1}` }); + expect(input.at(-1)).toMatchObject({ type: "message" }); + }); + + test("consumes repeated output-key indexes once without dropping repaired items", async () => { + const callCount = 2_000; + const requestInput = Array.from({ length: callCount }, (_, index) => [ + { type: "function_call", id: `fc_repeat_${index}`, call_id: "call_repeat", name: "exec_command", arguments: "{}" }, + { type: "message", role: "user", content: [{ type: "input_text", text: `separator ${index}` }] }, + ]).flat(); + + const { body } = await drive(requestInput); + const input = body.input as Array>; + expect(input).toHaveLength(callCount * 3); + expect(input.filter(item => item.type === "function_call")).toHaveLength(callCount); + expect(input.filter(item => item.type === "function_call_output")).toHaveLength(callCount); + expect(input.filter(item => item.type === "message")).toHaveLength(callCount); + }); + test("leaves intact call/output pairs untouched", async () => { const { body } = await drive([ { type: "function_call", id: "fc_ok", call_id: "call_ok", name: "exec_command", arguments: "{}" }, From b591cac294047b74e5c8a11b3b0c1b0f5c3566e8 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:45:26 +0900 Subject: [PATCH 11/67] fix(responses): scope repeated orphan outputs per batch --- src/adapters/openai-responses.ts | 4 +--- tests/responses-stateless-dangling-call-repair.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 37374ff873..b85252b142 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -756,10 +756,7 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes continue; } const batchOutputs: unknown[] = []; - const emittedKeys = new Set(); for (const batchKey of batchKeys) { - if (emittedKeys.has(batchKey)) continue; - emittedKeys.add(batchKey); const bucket = outputIndexesByKey.get(batchKey); if (!bucket) continue; while (bucket.offset < bucket.indexes.length && bucket.indexes[bucket.offset]! < cursor) { @@ -771,6 +768,7 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes if (claimedOutputIndexes.has(outputIndex)) continue; claimedOutputIndexes.add(outputIndex); batchOutputs.push(items[outputIndex]); + break; } } ordered.push(...batch, ...batchOutputs); diff --git a/tests/responses-stateless-dangling-call-repair.test.ts b/tests/responses-stateless-dangling-call-repair.test.ts index bfddde4324..ede2a8d119 100644 --- a/tests/responses-stateless-dangling-call-repair.test.ts +++ b/tests/responses-stateless-dangling-call-repair.test.ts @@ -155,6 +155,13 @@ describe("stateless Responses wire repairs orphaned tool calls", () => { expect(input.filter(item => item.type === "function_call")).toHaveLength(callCount); expect(input.filter(item => item.type === "function_call_output")).toHaveLength(callCount); expect(input.filter(item => item.type === "message")).toHaveLength(callCount); + for (let index = 0; index < callCount; index += 1) { + expect(input.slice(index * 3, index * 3 + 3)).toMatchObject([ + { type: "function_call", call_id: "call_repeat" }, + { type: "function_call_output", call_id: "call_repeat" }, + { type: "message", content: [{ type: "input_text", text: `separator ${index}` }] }, + ]); + } }); test("leaves intact call/output pairs untouched", async () => { From d8b37cc250954060acc21486a271a6c41b10f7df Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:02:20 +0900 Subject: [PATCH 12/67] fix(codex): refresh journaled injection ownership --- src/codex/inject.ts | 10 ++++++- src/codex/journal.ts | 24 +++++++++++----- tests/codex-journal.test.ts | 35 ++++++++++++++++++++++- tests/codex-restore-app-rewrite.test.ts | 38 +++++++++++++++++++++++-- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/codex/inject.ts b/src/codex/inject.ts index e137d1567b..e7e123c6e3 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -894,7 +894,15 @@ export async function injectCodexConfig( }); atomicWriteFile(CODEX_CONFIG_PATH, content); atomicWriteFile(CODEX_PROFILE_PATH, profileContent); - markJournalInjectedState(content, profileContent); + markJournalInjectedState(content, profileContent, { + // A root override is ours only in loopback Design B when no user-owned value won. + injectedOpenaiBaseUrl: legacyMode || keptUserBaseUrl + ? null + : rootTomlString(content, "openai_base_url"), + // This is the catalog artifact selected for this injection, even when config.toml + // already points at that path and therefore needs no textual rewrite. + injectedCatalogPath: catalogPath, + }); }; /* diff --git a/src/codex/journal.ts b/src/codex/journal.ts index a1a34806c9..f6b6e29b42 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile } from "../config"; -import { hasInjectedCodexRouting, rootTomlString } from "./injected-marker"; +import { hasInjectedCodexRouting } from "./injected-marker"; import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; /** @@ -108,16 +108,26 @@ export function writeJournal(options: WriteJournalOptions = {}): void { atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal)); } -export function markJournalInjectedState(config: string, profile: string | null): void { +export interface InjectedJournalOwnership { + injectedOpenaiBaseUrl: string | null; + injectedCatalogPath: string | null; +} + +export function markJournalInjectedState( + config: string, + profile: string | null, + ownership: InjectedJournalOwnership, +): void { const journal = readJournal(); if (!journal) return; - if (journal.injectedConfigHash) return; + // Keep the first native snapshot, but refresh the state written by every successful + // reinjection. Otherwise restore compares against stale bytes and stale ownership values. journal.injectedConfigHash = sha256(config) ?? undefined; journal.injectedProfileHash = sha256(profile); - // Read from the bytes we are about to install, not from the file: another writer may - // already have rewritten it, and then the recorded value would describe their config. - journal.injectedOpenaiBaseUrl = rootTomlString(config, "openai_base_url"); - journal.injectedCatalogPath = rootTomlString(config, "model_catalog_json"); + // Only the caller knows which values it actually owns. Deriving these from the final TOML + // would mistake a preserved user override for injected routing. + journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl; + journal.injectedCatalogPath = ownership.injectedCatalogPath; atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal)); } diff --git a/tests/codex-journal.test.ts b/tests/codex-journal.test.ts index 827ddd29d4..b09c31d4aa 100644 --- a/tests/codex-journal.test.ts +++ b/tests/codex-journal.test.ts @@ -553,7 +553,10 @@ describe("codex-journal", () => { const r = runScript(testDir, ` const { markJournalInjectedState } = require("./src/codex/journal"); - markJournalInjectedState("# injected\\n", null); + markJournalInjectedState("# injected\\n", null, { + injectedOpenaiBaseUrl: null, + injectedCatalogPath: null, + }); console.log(String(process.pid)); `); expect(r.status).toBe(0); @@ -564,6 +567,36 @@ describe("codex-journal", () => { expect(typeof second.injectedConfigHash).toBe("string"); // marked by the second }); + test("reinjection refreshes injected hashes and ownership without replacing the native snapshot", () => { + const r = runScript(testDir, ` + const fs = require("fs"); + const path = require("path"); + const { writeJournal, markJournalInjectedState } = require("./src/codex/journal"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + markJournalInjectedState("# first injection\\n", null, { + injectedOpenaiBaseUrl: "http://127.0.0.1:10100/v1", + injectedCatalogPath: "first-catalog.json", + }); + const first = JSON.parse(fs.readFileSync(journalPath, "utf8")); + markJournalInjectedState("# second injection\\n", "# second profile\\n", { + injectedOpenaiBaseUrl: "http://127.0.0.1:10200/v1", + injectedCatalogPath: "second-catalog.json", + }); + const second = JSON.parse(fs.readFileSync(journalPath, "utf8")); + console.log(JSON.stringify({ firstHash: first.injectedConfigHash, secondHash: second.injectedConfigHash })); + `); + expect(r.status).toBe(0); + const hashes = JSON.parse(r.stdout) as { firstHash: string; secondHash: string }; + expect(hashes.secondHash).not.toBe(hashes.firstHash); + + const journal = JSON.parse(readFileSync(join(testDir, "opencodex-journal.json"), "utf8")); + expect(Buffer.from(journal.originalConfig, "base64").toString("utf8")).toContain("# original config"); + expect(journal.injectedOpenaiBaseUrl).toBe("http://127.0.0.1:10200/v1"); + expect(journal.injectedCatalogPath).toBe("second-catalog.json"); + expect(typeof journal.injectedProfileHash).toBe("string"); + }); + test("writeJournal() with no options still snapshots a native config", () => { const r = runScript(testDir, `require("./src/codex/journal").writeJournal(); console.log("written");`); expect(r.status).toBe(0); diff --git a/tests/codex-restore-app-rewrite.test.ts b/tests/codex-restore-app-rewrite.test.ts index 21cea8dce4..2212385ce4 100644 --- a/tests/codex-restore-app-rewrite.test.ts +++ b/tests/codex-restore-app-rewrite.test.ts @@ -39,7 +39,7 @@ const INJECT_REWRITE_RESTORE = [ " const rewritten = injected", " .split(String.fromCharCode(10))", ' .filter(line => !line.trim().startsWith("#"))', - " .join(String.fromCharCode(10));", + " .join(String.fromCharCode(10)) + String.fromCharCode(10) + '# app rewrite';", ' fs.writeFileSync(configPath, rewritten, "utf8");', " const result = restoreNativeCodex();", " console.log(JSON.stringify({ success: result.success, message: result.message }));", @@ -74,6 +74,28 @@ const CATALOG_REWRITE_RESTORE = [ " console.log(JSON.stringify({ success: result.success, catalog: result.artifacts.catalog.path }));", "})();", ].join(String.fromCharCode(10)); + +/** Reinject with a new route and catalog, then expose the durable ownership record. */ +const REINJECT_AND_READ_JOURNAL = [ + 'const fs = require("fs");', + 'const path = require("path");', + 'const { injectCodexConfig } = require("./src/codex/inject");', + "(async () => {", + ' const firstCatalog = path.join(process.env.CODEX_HOME, "first-catalog.json");', + ' const secondCatalog = path.join(process.env.CODEX_HOME, "second-catalog.json");', + " const config = {", + " port: 10100,", + " providers: {},", + ' defaultProvider: "openai",', + ' injectionModel: "gpt-5.6-sol",', + ' injectionEffort: "high",', + " };", + " await injectCodexConfig(10100, config, { catalogPath: firstCatalog });", + " await injectCodexConfig(10200, { ...config, port: 10200 }, { catalogPath: secondCatalog });", + ' const journal = JSON.parse(fs.readFileSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json"), "utf8"));', + " console.log(JSON.stringify({ url: journal.injectedOpenaiBaseUrl, catalog: journal.injectedCatalogPath }));", + "})();", +].join(String.fromCharCode(10)); function runScript(codexHome: string, script: string): { stdout: string; stderr: string; status: number } { const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, @@ -109,8 +131,9 @@ describe("#1798 restore after the Codex app rewrites the config", () => { }, 15_000); test("a user's own openai_base_url written before injection is preserved", () => { + // Force a byte mismatch so exact journal restore cannot hide a fallback ownership bug. // The mirror-image risk of the fix: stripping ANY unmarked openai_base_url would - // delete a URL we never wrote. The journaled baseline is the arbiter, not the key name. + // delete a URL we never wrote. The journaled ownership evidence is the arbiter. writeFileSync( join(testDir, "config.toml"), 'openai_base_url = "https://my-own-gateway.example/v1"\nmodel = "gpt-5.5"\n', @@ -125,6 +148,17 @@ describe("#1798 restore after the Codex app rewrites the config", () => { expect(restored).not.toContain("127.0.0.1:10100"); }, 15_000); + test("reinjection refreshes the owned route and catalog recorded for restore", () => { + writeFileSync(join(testDir, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + + const r = runScript(testDir, REINJECT_AND_READ_JOURNAL); + if (r.status !== 0) throw new Error(r.stderr || r.stdout); + + const recorded = JSON.parse(r.stdout) as { url: string; catalog: string }; + expect(recorded.url).toBe("http://127.0.0.1:10200/v1"); + expect(recorded.catalog).toBe(join(testDir, "second-catalog.json")); + }, 20_000); + test("the routed catalog we wrote is restored even when the rewrite dropped model_catalog_json", () => { // The catalog half of #1798. Restore used to re-resolve its target from the CURRENT // config, so a rewrite that removed `model_catalog_json` sent it to the default catalog From 33d9a3bee31cce69bc6ff6942c9a16fc854ee408 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:54:16 +0900 Subject: [PATCH 13/67] fix(codex): preserve edits across reinjection restore --- src/codex/journal.ts | 10 +++-- tests/codex-journal.test.ts | 7 +++- tests/codex-restore-app-rewrite.test.ts | 56 +++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/codex/journal.ts b/src/codex/journal.ts index f6b6e29b42..b8923a6daa 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -120,9 +120,13 @@ export function markJournalInjectedState( ): void { const journal = readJournal(); if (!journal) return; - // Keep the first native snapshot, but refresh the state written by every successful - // reinjection. Otherwise restore compares against stale bytes and stale ownership values. - journal.injectedConfigHash = sha256(config) ?? undefined; + // The first exact injected config is the only safe whole-snapshot restore boundary for + // the first native snapshot. A later reinjection may preserve user edits made while routed; + // hashing those newer bytes and restoring the first snapshot would delete those edits. + // Keep the first hash so changed/reinjected configs take the owned-field fallback path. + journal.injectedConfigHash ??= sha256(config) ?? undefined; + // The profile file is wholly generated by OpenCodex, so its latest exact hash remains safe + // to refresh and lets restore remove the latest generated profile after a port change. journal.injectedProfileHash = sha256(profile); // Only the caller knows which values it actually owns. Deriving these from the final TOML // would mistake a preserved user override for injected routing. diff --git a/tests/codex-journal.test.ts b/tests/codex-journal.test.ts index b09c31d4aa..b1e5e1fbce 100644 --- a/tests/codex-journal.test.ts +++ b/tests/codex-journal.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -567,7 +568,7 @@ describe("codex-journal", () => { expect(typeof second.injectedConfigHash).toBe("string"); // marked by the second }); - test("reinjection refreshes injected hashes and ownership without replacing the native snapshot", () => { + test("reinjection keeps the first config hash while refreshing owned route and catalog", () => { const r = runScript(testDir, ` const fs = require("fs"); const path = require("path"); @@ -588,7 +589,9 @@ describe("codex-journal", () => { `); expect(r.status).toBe(0); const hashes = JSON.parse(r.stdout) as { firstHash: string; secondHash: string }; - expect(hashes.secondHash).not.toBe(hashes.firstHash); + expect(typeof hashes.firstHash).toBe("string"); + expect(hashes.firstHash).toBe(createHash("sha256").update("# first injection\n").digest("hex")); + expect(hashes.secondHash).toBe(hashes.firstHash); const journal = JSON.parse(readFileSync(join(testDir, "opencodex-journal.json"), "utf8")); expect(Buffer.from(journal.originalConfig, "base64").toString("utf8")).toContain("# original config"); diff --git a/tests/codex-restore-app-rewrite.test.ts b/tests/codex-restore-app-rewrite.test.ts index 2212385ce4..2e37309e18 100644 --- a/tests/codex-restore-app-rewrite.test.ts +++ b/tests/codex-restore-app-rewrite.test.ts @@ -96,6 +96,38 @@ const REINJECT_AND_READ_JOURNAL = [ " console.log(JSON.stringify({ url: journal.injectedOpenaiBaseUrl, catalog: journal.injectedCatalogPath }));", "})();", ].join(String.fromCharCode(10)); + +/** Preserve a user edit made after the first injection across reinjection and restore. */ +const REINJECT_AFTER_USER_EDIT_RESTORE = [ + 'const fs = require("fs");', + 'const path = require("path");', + 'const { injectCodexConfig, restoreNativeCodex } = require("./src/codex/inject");', + "(async () => {", + " const config = {", + " port: 10100,", + " providers: {},", + ' defaultProvider: "openai",', + ' injectionModel: "gpt-5.6-sol",', + ' injectionEffort: "high",', + " };", + " await injectCodexConfig(10100, config, { catalogPath: null });", + ' const configPath = path.join(process.env.CODEX_HOME, "config.toml");', + ' fs.appendFileSync(configPath, String.fromCharCode(10) + \'approval_policy = "never"\' + String.fromCharCode(10), "utf8");', + " await injectCodexConfig(10200, { ...config, port: 10200 }, { catalogPath: null });", + ' const beforeRestore = fs.readFileSync(configPath, "utf8");', + " const result = restoreNativeCodex({ skipHistory: true });", + ' const afterRestore = fs.readFileSync(configPath, "utf8");', + ' const profileExistsAfterRestore = fs.existsSync(path.join(process.env.CODEX_HOME, "opencodex.config.toml"));', + " console.log(JSON.stringify({", + " success: result.success,", + " action: result.artifacts.config.action,", + " beforeRestore,", + " afterRestore,", + " profileExistsAfterRestore,", + " }));", + "})();", +].join(String.fromCharCode(10)); + function runScript(codexHome: string, script: string): { stdout: string; stderr: string; status: number } { const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, @@ -159,6 +191,30 @@ describe("#1798 restore after the Codex app rewrites the config", () => { expect(recorded.catalog).toBe(join(testDir, "second-catalog.json")); }, 20_000); + test("a user setting added after first injection survives reinjection and restore", () => { + writeFileSync(join(testDir, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + + const r = runScript(testDir, REINJECT_AFTER_USER_EDIT_RESTORE); + if (r.status !== 0) throw new Error(r.stderr || r.stdout); + + const result = JSON.parse(r.stdout) as { + success: boolean; + action: string; + beforeRestore: string; + afterRestore: string; + profileExistsAfterRestore: boolean; + }; + expect(result.success).toBe(true); + expect(result.action).toBe("owned-fields-stripped"); + expect(result.beforeRestore).toContain('approval_policy = "never"'); + expect(result.beforeRestore).toContain("127.0.0.1:10200"); + expect(result.afterRestore).toContain('approval_policy = "never"'); + expect(result.afterRestore).toContain('model = "gpt-5.5"'); + expect(result.afterRestore).not.toContain("openai_base_url"); + expect(result.afterRestore).not.toContain("127.0.0.1:10200"); + expect(result.profileExistsAfterRestore).toBe(false); + }, 20_000); + test("the routed catalog we wrote is restored even when the rewrite dropped model_catalog_json", () => { // The catalog half of #1798. Restore used to re-resolve its target from the CURRENT // config, so a rewrite that removed `model_catalog_json` sent it to the default catalog From f5907919fc08df851982a26bc3f8a5a141becd9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=91=E6=A5=A0?= <17256544+zsn12@user.noreply.gitee.com> Date: Thu, 20 Aug 2026 23:04:38 +0800 Subject: [PATCH 14/67] fix(cursor): route composer-2.5 tool continuations through userMessageAction --- src/adapters/cursor/discovery.ts | 18 ++++++++++++++++++ src/adapters/cursor/protobuf-request.ts | 15 +++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 888f1c1f79..146fa19739 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -146,6 +146,24 @@ export function isCursorExternalWireModel(modelId: string): boolean { return !isCursorNativeWireModel(modelId); } +/** + * Native composer models whose tool-result continuation must still be sent as a + * userMessageAction with the plain "Continue:" text instead of a bare resumeAction. + * + * Observed on live Cursor Connect traffic (2026-08-20): `composer-2.5` (the + * standard, non-fast build) resumes a tool-result turn with server-side native + * tool calls (read/grep/exec) instead of answering, or completes with zero text + * (empty `content` + `stop`). `composer-2.5-fast` answers correctly on the same + * resumeAction path, so only the affected id is listed here. Sending the same + * continuation as an explicit user message (external path) makes the model + * answer reliably. + */ +export function cursorNeedsExternalToolContinuation(modelId: string): boolean { + if (isCursorExternalWireModel(modelId)) return true; + const wire = cursorCodexToWireModelId(modelId).trim().toLowerCase(); + return wire === "composer-2.5"; +} + function stripCursorEffortSuffix(wireModelId: string): string { const suffixes = [...CANONICAL_EFFORT_SUFFIXES].sort((a, b) => b.length - a.length); for (const suffix of suffixes) { diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index e03dc4e71f..d2adf79848 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -4,7 +4,7 @@ import { ValueSchema } from "@bufbuild/protobuf/wkt"; import type { OcxAssistantContentPart, OcxMessage, OcxToolResultMessage } from "../../types"; import { namespacedToolName } from "../../types"; import type { CursorRunRequest } from "./types"; -import { isCursorExternalWireModel } from "./discovery"; +import { cursorNeedsExternalToolContinuation } from "./discovery"; import { normalizeCursorToolResultText } from "./tool-result-normalize"; import { debugProviderDiagnostic } from "../../lib/debug"; import { @@ -209,7 +209,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR }; } - const externalModel = isCursorExternalWireModel(request.modelId); + const externalModel = cursorNeedsExternalToolContinuation(request.modelId); const lastRawIsToolResult = messages.at(-1)?.role === "toolResult"; const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages); @@ -641,7 +641,7 @@ function conversationTurns( const messages = request.rawMessages; if (!messages?.length) return []; const end = lastActionIndex(messages); - const externalModel = isCursorExternalWireModel(request.modelId); + const externalModel = cursorNeedsExternalToolContinuation(request.modelId); const historyEnd = messages.at(-1)?.role === "toolResult" ? messages.length : Math.max(0, end); const start = externalModel ? Math.max(0, historyMessageStart) : 0; const turns: Uint8Array[] = []; @@ -793,8 +793,11 @@ function buildPreparedCursorRunRequest( const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult"; // Native models resume the remembered Cursor conversation. External wire // models continue as userMessageAction so history-blob tool results stay - // visible without a ResumeAction. - const externalToolContinuation = lastRawIsToolResult && isCursorExternalWireModel(request.modelId); + // visible without a ResumeAction. Some native composer ids are also routed + // through the external continuation path (cursorNeedsExternalToolContinuation) + // because a bare resumeAction makes them continue exploring with native tools + // instead of answering (observed on composer-2.5; see discovery.ts). + const externalToolContinuation = lastRawIsToolResult && cursorNeedsExternalToolContinuation(request.modelId); const actionCase = (externalToolContinuation || (!lastRawIsToolResult && text.trim().length > 0)) ? "userMessageAction" : "resumeAction"; @@ -832,7 +835,7 @@ function buildPreparedCursorRunRequest( action: actionCase, conversationId: request.conversationId, turnType: lastRawIsToolResult ? "tool-continuation" : "initial", - externalModel: isCursorExternalWireModel(request.modelId), + externalModel: cursorNeedsExternalToolContinuation(request.modelId), rawMessages: request.rawMessages?.length ?? 0, rootBlobs: rootPromptMessageIds.length, rootBytes: rootPromptMessagesState.byteLength, From 4a5042f3d5b57bc111b0f4e4ea5b563e3b381869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=91=E6=A5=A0?= <17256544+zsn12@user.noreply.gitee.com> Date: Thu, 20 Aug 2026 23:38:06 +0800 Subject: [PATCH 15/67] test(cursor): cover composer-2.5 tool-continuation routing Hygiene blocked the PR for missing_regression_test; lock in the userMessageAction path for composer-2.5 and keep resumeAction on fast. Co-authored-by: Cursor --- tests/cursor-blob.test.ts | 35 ++++++++++++++++++++++++++++++---- tests/cursor-discovery.test.ts | 10 ++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index e91111f782..d20a9a9aa2 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -602,7 +602,7 @@ describe("Cursor blob handshake", () => { test("keeps ResumeAction for native-model tool-result continuations", () => { const bytes = encodeCursorRunRequest({ - modelId: "composer-2.5", + modelId: "composer-2.5-fast", conversationId: "c1", system: ["You are helpful."], messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], @@ -610,7 +610,7 @@ describe("Cursor blob handshake", () => { { role: "user", content: "read a file", timestamp: 1 }, { role: "assistant", - model: "cursor/composer-2.5", + model: "cursor/composer-2.5-fast", timestamp: 2, content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "a.txt" } }], }, @@ -623,9 +623,36 @@ describe("Cursor blob handshake", () => { expect(run?.action?.action.case).toBe("resumeAction"); }); + test("drives composer-2.5 tool-result continuations as userMessageAction", () => { + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-composer-cont", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/composer-2.5", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + + expect(run?.action?.action.case).toBe("userMessageAction"); + const value = run?.action?.action.case === "userMessageAction" ? run.action.action.value : undefined; + expect(value?.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT); + const roots = decodeRootMessages(bytes) as Array<{ role?: string }>; + expect(JSON.stringify(roots)).toContain("contents"); + }); + test("drives external-model tool-result continuations as userMessageAction", () => { - // External wire models encode tool-result hops as userMessageAction; native - // models keep resumeAction. Tool results stay in the history blobs. + // External wire models encode tool-result hops as userMessageAction. Native + // composer-2.5 uses the same path; other native composer ids keep resumeAction. const bytes = encodeCursorRunRequest({ modelId: "claude-fable-5", conversationId: "c-ext-cont", diff --git a/tests/cursor-discovery.test.ts b/tests/cursor-discovery.test.ts index 7e9595aca2..8b7a9af8f8 100644 --- a/tests/cursor-discovery.test.ts +++ b/tests/cursor-discovery.test.ts @@ -16,6 +16,7 @@ import { inferCursorContextWindow, isCursorExternalWireModel, isCursorNativeWireModel, + cursorNeedsExternalToolContinuation, normalizeCursorModels, } from "../src/adapters/cursor/discovery"; @@ -187,4 +188,13 @@ describe("Cursor discovery metadata", () => { expect(isCursorExternalWireModel("claude-4.6-sonnet-high")).toBe(true); expect(isCursorExternalWireModel("cursor/gpt-5.6-sol")).toBe(true); }); + + test("routes composer-2.5 tool continuations through the external userMessageAction path", () => { + expect(cursorNeedsExternalToolContinuation("composer-2.5")).toBe(true); + expect(cursorNeedsExternalToolContinuation("cursor/composer-2.5")).toBe(true); + expect(cursorNeedsExternalToolContinuation("composer-2.5-fast")).toBe(false); + expect(cursorNeedsExternalToolContinuation("cursor/composer-2.5-fast")).toBe(false); + expect(cursorNeedsExternalToolContinuation("auto")).toBe(false); + expect(cursorNeedsExternalToolContinuation("gpt-5.6-sol")).toBe(true); + }); }); From b277e74b164c858738379720173118acdc8db518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=91=E6=A5=A0?= <17256544+zsn12@user.noreply.gitee.com> Date: Thu, 20 Aug 2026 23:42:59 +0800 Subject: [PATCH 16/67] test(cursor): use composer-2.5-fast for native replay regression composer-2.5 now routes tool continuations through the external path; keep native turn-step replay coverage on composer-2.5-fast. Co-authored-by: Cursor --- tests/cursor-blob.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index d20a9a9aa2..2aec1ff461 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -525,7 +525,7 @@ describe("Cursor blob handshake", () => { test("native Cursor replay preserves tool calls with results in turn steps", () => { const bytes = encodeCursorRunRequest({ - modelId: "composer-2.5", + modelId: "composer-2.5-fast", conversationId: "c1", system: ["You are helpful."], messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], From b30e5baad3f60e16da84029e5e1da8fc9dd410be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=91=E6=A5=A0?= <17256544+zsn12@user.noreply.gitee.com> Date: Thu, 20 Aug 2026 23:49:47 +0800 Subject: [PATCH 17/67] fix(cursor): narrow composer-2.5 continuation to action selection Keep isCursorExternalWireModel for history/replay and apply the composer-2.5 userMessageAction exception only on trailing tool results. Co-authored-by: Cursor --- src/adapters/cursor/protobuf-request.ts | 8 ++--- tests/cursor-blob.test.ts | 41 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index d2adf79848..22e348bb1e 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -4,7 +4,7 @@ import { ValueSchema } from "@bufbuild/protobuf/wkt"; import type { OcxAssistantContentPart, OcxMessage, OcxToolResultMessage } from "../../types"; import { namespacedToolName } from "../../types"; import type { CursorRunRequest } from "./types"; -import { cursorNeedsExternalToolContinuation } from "./discovery"; +import { cursorNeedsExternalToolContinuation, isCursorExternalWireModel } from "./discovery"; import { normalizeCursorToolResultText } from "./tool-result-normalize"; import { debugProviderDiagnostic } from "../../lib/debug"; import { @@ -209,7 +209,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR }; } - const externalModel = cursorNeedsExternalToolContinuation(request.modelId); + const externalModel = isCursorExternalWireModel(request.modelId); const lastRawIsToolResult = messages.at(-1)?.role === "toolResult"; const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages); @@ -641,7 +641,7 @@ function conversationTurns( const messages = request.rawMessages; if (!messages?.length) return []; const end = lastActionIndex(messages); - const externalModel = cursorNeedsExternalToolContinuation(request.modelId); + const externalModel = isCursorExternalWireModel(request.modelId); const historyEnd = messages.at(-1)?.role === "toolResult" ? messages.length : Math.max(0, end); const start = externalModel ? Math.max(0, historyMessageStart) : 0; const turns: Uint8Array[] = []; @@ -835,7 +835,7 @@ function buildPreparedCursorRunRequest( action: actionCase, conversationId: request.conversationId, turnType: lastRawIsToolResult ? "tool-continuation" : "initial", - externalModel: cursorNeedsExternalToolContinuation(request.modelId), + externalModel: isCursorExternalWireModel(request.modelId), rawMessages: request.rawMessages?.length ?? 0, rootBlobs: rootPromptMessageIds.length, rootBytes: rootPromptMessagesState.byteLength, diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 2aec1ff461..e712b42918 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -525,7 +525,7 @@ describe("Cursor blob handshake", () => { test("native Cursor replay preserves tool calls with results in turn steps", () => { const bytes = encodeCursorRunRequest({ - modelId: "composer-2.5-fast", + modelId: "composer-2.5", conversationId: "c1", system: ["You are helpful."], messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], @@ -561,7 +561,44 @@ describe("Cursor blob handshake", () => { if (content?.case === "text") expect(content.value.text).toBe("contents"); } } - expect(run?.action?.action.case).toBe("resumeAction"); + expect(run?.action?.action.case).toBe("userMessageAction"); + const value = run?.action?.action.case === "userMessageAction" ? run.action.action.value : undefined; + expect(value?.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT); + }); + + test("composer-2.5 ordinary turns keep native replay semantics", () => { + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-native-turn", + system: ["You are helpful."], + messages: [{ role: "user", content: "follow up" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/composer-2.5", + timestamp: 2, + content: [ + { type: "thinking", thinking: "hidden reasoning" }, + { type: "text", text: "I'll read it" }, + ], + }, + { role: "user", content: "follow up", timestamp: 3 }, + ], + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const turn = fromBinary(ConversationTurnStructureSchema, blobData(run?.conversationState?.turns[0] ?? new Uint8Array())); + expect(turn.turn.case).toBe("agentConversationTurn"); + const steps = turn.turn.case === "agentConversationTurn" ? turn.turn.value.steps : []; + expect(steps).toHaveLength(2); + const firstStep = fromBinary(ConversationStepSchema, blobData(steps[0]!)); + const secondStep = fromBinary(ConversationStepSchema, blobData(steps[1]!)); + expect(firstStep.message.case).toBe("thinkingMessage"); + expect(secondStep.message.case).toBe("assistantMessage"); + expect(run?.action?.action.case).toBe("userMessageAction"); + const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; + expect(JSON.stringify(roots)).toContain("hidden reasoning"); }); test("external Cursor replay uses text history instead of native tool/thinking structures", () => { From c6fa2563d057c32c1e8f706fcfb4f78d584b90f3 Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 03:11:05 +0900 Subject: [PATCH 18/67] feat(clients): add Prime Agent as an export and integration client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prime Agent (PrimeIntellect) is not a Pi lookalike — it is the pi coding agent shipped under a different brand. Its package declares a `piConfig` block, and the agent derives both its config directory (`.prime/agent`) and its env prefix from that block alone; with no block the same code falls back to `@earendil-works/pi-coding-agent` and `~/.pi`. `models.json` is therefore the same contract Pi already reads, so this client reuses Pi's builder and summarizer verbatim rather than restating a schema that would drift. A test locks the two generated documents byte-for-byte so a future Pi-only change cannot quietly diverge. The one thing that could still differ is the path we own, and it does not: Prime keeps our entries under the same `providers.` key. The only new behavior is path resolution, which honors `PRIME_AGENT_CODING_AGENT_DIR` (verified live: setting it moves which models.json `prime-agent model list` reads) and refuses relative overrides for the same reason MCode and ZCode do. Registered loopback-only. Prime's provider block does accept `headers`, so a dedicated admission header has somewhere to live, but remote credential wiring is deferred from this initial integration — the same stance OMP took. The English CLI reference for `--client` was two clients stale; it now names the current set. Translated copies are left for a docs sync. --- .../src/content/docs/reference/cli/agents.md | 4 +- gui/src/app-routing.ts | 1 + .../client-config-clients.ts | 3 +- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/Integrations.tsx | 2 + .../integrations/FileIntegrationPage.tsx | 2 + gui/src/pages/integrations/integration-api.ts | 1 + .../pages/integrations/overview-clients.ts | 1 + gui/tests/client-config-panel.test.tsx | 2 +- gui/tests/fr-localization.test.ts | 2 + gui/tests/integrations-api.test.ts | 2 +- gui/tests/integrations-overview-rows.test.ts | 3 +- gui/tests/locale-parity.test.ts | 2 + src/cli/registry.ts | 4 +- src/clients/config-export.ts | 56 +++++++++- src/integrations/registry.ts | 10 ++ .../client-config-export-new-clients.test.ts | 8 +- tests/client-config-export.test.ts | 4 +- tests/integrations-invariants.test.ts | 8 +- tests/integrations-state.test.ts | 4 +- tests/prime-client.test.ts | 103 ++++++++++++++++++ 29 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 tests/prime-client.test.ts diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index e09159d1c5..62f35ec7a0 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -169,7 +169,7 @@ Manage and apply the Grok Build model fence. ## Client config export -### `ocx export --client ` +### `ocx export --client ` Print a client config wired to the running proxy. The command serializes the `opencodex` provider block — base URL, model list, and the client's credential @@ -180,7 +180,7 @@ models Codex can currently see. | Flag | Action | | --- | --- | -| `--client ` | Required. Selects the client config dialect. | +| `--client ` | Required. Selects the client config dialect. | | `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. | | `--out ` | Write the client's native config format to ``. Refuses to replace an existing file. | | `--force` | Allow `--out` to replace an existing file. | diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index b58b507a7e..be8fa2a84b 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -92,6 +92,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/dsh", "integrations/mcode", "integrations/zcode", + "integrations/prime", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index c8007a4584..fc46026971 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -22,6 +22,7 @@ export const CLIENT_LABEL_KEYS = { dsh: "api.clientConfig.clientDsh", mcode: "api.clientConfig.clientMcode", zcode: "api.clientConfig.clientZcode", + prime: "api.clientConfig.clientPrime", } as const; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d6c503134a..22f5868a86 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -855,6 +855,7 @@ export const de: Record = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Die Codex-Anbindung wird vom Proxy-Dienst verwaltet. Beim Start von opencodex wird sie angewendet; beim Stoppen des Dienstes wird das native Routing wiederhergestellt.", "integrations.codex.openService": "Dienststeuerung öffnen", @@ -968,6 +969,7 @@ export const de: Record = { "integrations.semantics.dsh": "OpenCodex verwaltet nur llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH lädt diesen Anbieter im laufenden Betrieb neu; Ihr Standardmodell und deepseek-official bleiben unverändert. Derzeit nur über Loopback; es werden keine echten Zugangsdaten geschrieben.", "integrations.semantics.mcode": "Verwaltet nur custom_provider.opencodex. Standardmodell und MiniMax-Anmeldung bleiben unverändert.", "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", + "integrations.semantics.prime": "Verwaltet nur providers.opencodex in ~/.prime/agent/models.json. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1252,6 +1254,7 @@ export const de: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 87892599ce..ca9e465fce 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1344,6 +1344,7 @@ export const en = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex wiring is owned by the proxy service. Starting opencodex applies it; stopping the service restores native routing.", "integrations.codex.openService": "Open service controls", @@ -1457,6 +1458,7 @@ export const en = { "integrations.semantics.dsh": "OpenCodex manages only llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH hot reloads this provider; your default model and deepseek-official stay unchanged. Currently loopback-only; no real credential is written.", "integrations.semantics.mcode": "Manages only custom_provider.opencodex. Your default model and MiniMax login stay unchanged.", "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", + "integrations.semantics.prime": "Manages only providers.opencodex in ~/.prime/agent/models.json. Your other providers and model overrides stay unchanged. Applies to new sessions.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -1749,6 +1751,7 @@ export const en = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4da3bc33f1..5ec009b99a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1317,6 +1317,7 @@ export const fr: Record = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Le câblage de Codex est géré par le service proxy. Le démarrage d’opencodex l’applique ; l’arrêt du service rétablit le routage natif.", "integrations.codex.openService": "Ouvrir les commandes du service", @@ -1430,6 +1431,7 @@ export const fr: Record = { "integrations.semantics.dsh": "OpenCodex gère uniquement llm-pi-ai.providers.opencodex dans $DSH_HOME/settings.yaml. DSH recharge ce fournisseur à chaud ; votre modèle par défaut et deepseek-official restent inchangés. Seule l’adresse de bouclage est actuellement prise en charge ; aucun identifiant réel n’est écrit.", "integrations.semantics.mcode": "Gère uniquement custom_provider.opencodex. Votre modèle par défaut et votre connexion MiniMax restent inchangés.", "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", + "integrations.semantics.prime": "Gère uniquement providers.opencodex dans ~/.prime/agent/models.json. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", @@ -1713,6 +1715,7 @@ export const fr: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.copy": "Copier la configuration", "api.clientConfig.download": "Télécharger", "api.clientConfig.loading": "Génération de la configuration du client…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a65cc76d6b..1e2ffc1c50 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1277,6 +1277,7 @@ export const ja: Record = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex の接続はプロキシサービスが管理します。opencodex を起動すると適用され、サービスを停止するとネイティブのルーティングに戻ります。", "integrations.codex.openService": "サービス制御を開く", @@ -1390,6 +1391,7 @@ export const ja: Record = { "integrations.semantics.dsh": "OpenCodex が管理するのは $DSH_HOME/settings.yaml 内の llm-pi-ai.providers.opencodex だけです。DSH はこのプロバイダーをホットリロードし、既定のモデルと deepseek-official は変更しません。現在はループバック専用で、実際の認証情報は書き込みません。", "integrations.semantics.mcode": "custom_provider.opencodex のみを管理します。既定モデルと MiniMax ログインは変更しません。", "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", + "integrations.semantics.prime": "~/.prime/agent/models.json の providers.opencodex のみを管理します。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -1679,6 +1681,7 @@ export const ja: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ba19d179e8..3b3929f23d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -879,6 +879,7 @@ export const ko: Record = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 연결은 프록시 서비스가 관리합니다. opencodex를 시작하면 적용되고 서비스를 중지하면 기본 라우팅으로 복원됩니다.", "integrations.codex.openService": "서비스 제어 열기", @@ -992,6 +993,7 @@ export const ko: Record = { "integrations.semantics.dsh": "OpenCodex는 $DSH_HOME/settings.yaml의 llm-pi-ai.providers.opencodex만 관리합니다. DSH는 이 provider를 hot reload하며 기본 model과 deepseek-official은 변경하지 않습니다. 현재 loopback 전용이며 실제 credential을 기록하지 않습니다.", "integrations.semantics.mcode": "custom_provider.opencodex만 관리하며 기본 모델과 MiniMax 로그인은 변경하지 않습니다.", "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", + "integrations.semantics.prime": "~/.prime/agent/models.json의 providers.opencodex만 관리하며 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1279,6 +1281,7 @@ export const ko: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f41af16d00..e01db1b274 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1328,6 +1328,7 @@ export const ru: Record = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Подключением Codex управляет прокси-сервис. При запуске opencodex оно применяется, а при остановке сервиса восстанавливается нативная маршрутизация.", "integrations.codex.openService": "Открыть управление сервисом", @@ -1441,6 +1442,7 @@ export const ru: Record = { "integrations.semantics.dsh": "OpenCodex управляет только llm-pi-ai.providers.opencodex в $DSH_HOME/settings.yaml. DSH применяет этот провайдер горячей перезагрузкой; модель по умолчанию и deepseek-official остаются без изменений. Сейчас поддерживается только loopback; реальные учётные данные не записываются.", "integrations.semantics.mcode": "Управляет только custom_provider.opencodex. Модель по умолчанию и вход MiniMax не меняются.", "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", + "integrations.semantics.prime": "Управляет только providers.opencodex в ~/.prime/agent/models.json. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -1730,6 +1732,7 @@ export const ru: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 101dc684c4..9dca6cf326 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1335,6 +1335,7 @@ export const tr: Record = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex bağlantısı proxy servisine aittir.", "integrations.codex.openService": "Servis kontrollerini aç", @@ -1447,6 +1448,7 @@ export const tr: Record = { "integrations.semantics.dsh": "OpenCodex yalnızca $DSH_HOME/settings.yaml içindeki llm-pi-ai.providers.opencodex bölümünü yönetir. DSH bu sağlayıcıyı çalışırken yeniden yükler; varsayılan modeliniz ve deepseek-official değişmez. Şimdilik yalnızca geri döngü desteklenir; gerçek kimlik bilgisi yazılmaz.", "integrations.semantics.mcode": "Yalnızca custom_provider.opencodex bölümünü yönetir. Varsayılan model ve MiniMax oturumu değişmez.", "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", + "integrations.semantics.prime": "Yalnızca ~/.prime/agent/models.json içindeki providers.opencodex bölümünü yönetir. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -1737,6 +1739,7 @@ export const tr: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 89b891d603..5822504d5d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1853,6 +1853,7 @@ export const zhTW: Record = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 連線由代理服務管理。啟動 opencodex 時套用;停止服務時還原原生路由。", "integrations.codex.openService": "開啟服務控制", @@ -1966,6 +1967,7 @@ export const zhTW: Record = { "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 會熱重載該 provider;你的預設模型與 deepseek-official 維持不變。目前僅支援 loopback,且不會寫入真實憑證。", "integrations.semantics.mcode": "僅管理 custom_provider.opencodex,不會變更預設模型或 MiniMax 登入狀態。", "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", + "integrations.semantics.prime": "僅管理 ~/.prime/agent/models.json 中的 providers.opencodex,不會變更其他供應商或模型覆寫設定。對新工作階段生效。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -2005,6 +2007,7 @@ export const zhTW: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c5d588efa0..174adb9564 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -872,6 +872,7 @@ export const zh: Record = { "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.prime": "Prime Agent", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 连接由代理服务管理。启动 opencodex 时应用该连接;停止服务时恢复原生路由。", "integrations.codex.openService": "打开服务控制", @@ -985,6 +986,7 @@ export const zh: Record = { "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 会热重载该 provider;你的默认模型和 deepseek-official 保持不变。目前仅支持环回地址,且不会写入真实凭据。", "integrations.semantics.mcode": "仅管理 custom_provider.opencodex,不会更改默认模型或 MiniMax 登录状态。", "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", + "integrations.semantics.prime": "仅管理 ~/.prime/agent/models.json 中的 providers.opencodex,不会更改其他提供商或模型覆盖设置。对新会话生效。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1272,6 +1274,7 @@ export const zh: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index 2505589b2a..70018037b9 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -39,6 +39,7 @@ const TABS: readonly TabDefinition[] = [ { id: "dsh", hash: "integrations/dsh", labelKey: "integrations.tab.dsh" }, { id: "mcode", hash: "integrations/mcode", labelKey: "integrations.tab.mcode" }, { id: "zcode", hash: "integrations/zcode", labelKey: "integrations.tab.zcode" }, + { id: "prime", hash: "integrations/prime", labelKey: "integrations.tab.prime" }, ] as const; const FILE_CLIENTS = new Set([ @@ -52,6 +53,7 @@ const FILE_CLIENTS = new Set([ "dsh", "mcode", "zcode", + "prime", ]); function readIntegrationTab(hash = window.location.hash): IntegrationTab { diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 2a745eff23..8f507345f0 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -27,6 +27,7 @@ const SEMANTICS_KEY: Record = { dsh: "integrations.semantics.dsh", mcode: "integrations.semantics.mcode", zcode: "integrations.semantics.zcode", + prime: "integrations.semantics.prime", }; const TAB_LABEL_KEY: Record = { @@ -40,6 +41,7 @@ const TAB_LABEL_KEY: Record = { dsh: "integrations.tab.dsh", mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", + prime: "integrations.tab.prime", }; const KIND_KEY: Record = { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 3c09ef834c..14f3a24787 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -11,6 +11,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "dsh", "mcode", "zcode", + "prime", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 92f684e587..4142529c04 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -144,6 +144,7 @@ const FILE_LABEL_KEY: Record = { dsh: "integrations.tab.dsh", mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", + prime: "integrations.tab.prime", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index f6acce7e93..d8eb6fb587 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -171,7 +171,7 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut } test("the API download surface includes DSH and MiniMax Code as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode"]); + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 4ef424f15b..f05312c251 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -91,6 +91,8 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientDsh", "api.clientConfig.clientMcode", "api.clientConfig.clientZcode", + "integrations.tab.prime", + "api.clientConfig.clientPrime", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 962d6be312..8ef6c8090c 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -18,7 +18,7 @@ const originalFetch = globalThis.fetch; test("DSH is a file integration client", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index d2bc3a5bcd..551ff7634d 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -178,10 +178,11 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(14); + expect(built.rows).toHaveLength(15); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); + expect(rowById(built, "prime").state).toBe("unknown"); expect(rowById(built, "kimi").state).toBe("unknown"); expect(rowById(built, "dsh")).toMatchObject({ hash: "integrations/dsh", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 700e292096..1dddbfc3a3 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -112,6 +112,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "integrations.tab.zcode", "api.clientConfig.clientMcode", "api.clientConfig.clientZcode", + "integrations.tab.prime", + "api.clientConfig.clientPrime", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 425ce5412a..c8c786b54e 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -216,8 +216,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 47f883159d..56c4a9d1c9 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -432,6 +432,26 @@ export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(zcodeHomeDir(env, home), "v2", "config.json"); } +/** + * Prime Agent resolves its agent directory from `PRIME_AGENT_CODING_AGENT_DIR` + * — the brand-derived spelling of the `PI_CODING_AGENT_DIR` that `ompAgentDir` + * already honors, because the agent builds that variable name from its own + * `piConfig.name` — and otherwise falls back to `~/.prime/agent`. Relative + * overrides are refused for the same reason as MCode's and ZCode's: a + * background proxy and a foreground client can have different working + * directories. + */ +export function primeAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const override = env.PRIME_AGENT_CODING_AGENT_DIR?.trim(); + if (override) return absoluteClientPath(override, home, "PRIME_AGENT_CODING_AGENT_DIR"); + return join(home, ".prime", "agent"); +} + +/** Prime Agent's canonical custom-provider catalog. */ +export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(primeAgentDir(env, home), "models.json"); +} + /** * One proxy-routed model destined for a client config. Deliberately narrower than * `CatalogModel` so a serializer cannot reach for a field that does not survive the @@ -473,7 +493,8 @@ export type ExportClientId = | "gajae" | "dsh" | "mcode" - | "zcode"; + | "zcode" + | "prime"; export interface ExportClientSpec { id: ExportClientId; @@ -1428,6 +1449,24 @@ function buildZcodeContribution(ctx: ExportContext): ManagedContribution { return singleFragment("zcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]); } +/** + * Prime Agent (PrimeIntellect) is the pi coding agent shipped under a different + * brand rather than a lookalike: its package declares a `piConfig` block, and + * the agent derives its config directory (`.prime/agent`) and env prefix from + * that block alone. `models.json` is therefore the SAME contract Pi reads, so + * this client reuses Pi's builder and summarizer verbatim. Restating the shape + * here would create a second copy of one fact, which is exactly how the + * "anything that is not OpenCode must be Pi" summarizer bug happened. + * + * The one thing that could still differ is the path we own, and it does not: + * Prime keeps our entries under the same `providers.` key. So the only new + * code is stamping the right client id on the ownership record. + */ +function buildPrimeContribution(ctx: ExportContext): ManagedContribution { + const doc = buildPiClientConfig(ctx); + return singleFragment("prime", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} + export const EXPORT_CLIENTS: Record = { opencode: { id: "opencode", @@ -1566,6 +1605,21 @@ export const EXPORT_CLIENTS: Record = { // remote binds refuse — same reasoning as MCode. loopbackOnly: true, }, + prime: { + id: "prime", + filename: "prime-models.json", + destination: env => primeConfigPath(env), + apiKeyEnv: "", + exportHint: "Prime Agent reads a non-secret placeholder from models.json; loopback needs no key.", + build: buildPiClientConfig, + format: "json", + summarize: summarizePi, + buildContribution: buildPrimeContribution, + // Prime's provider block does accept `headers`, so a dedicated admission + // header has somewhere to live, but remote credential wiring is deferred + // from this initial loopback-only integration — same stance as OMP's. + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 42fe51a28d..bcba383630 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -27,6 +27,8 @@ import { opencodeGlobalConfigPath, openclawConfigPath, openclawHomeDir, + primeAgentDir, + primeConfigPath, zcodeConfigPath, zcodeHomeDir, type ExportClientId, @@ -126,6 +128,14 @@ export const INTEGRATION_CLIENTS: Record zcodeConfigPath(env, home), detectDir: (env = process.env, home = homedir()) => zcodeHomeDir(env, home), }, + prime: { + id: "prime", + configPath: (env = process.env, home = homedir()) => primeConfigPath(env, home), + // The agent directory, not its parent: `PRIME_AGENT_CODING_AGENT_DIR` names + // that directory directly, so there is no parent to test when the override + // is set. Same choice as OMP, whose detect signal is `ompAgentDir`. + detectDir: (env = process.env, home = homedir()) => primeAgentDir(env, home), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index 63dc426168..496804c0cf 100644 --- a/tests/client-config-export-new-clients.test.ts +++ b/tests/client-config-export-new-clients.test.ts @@ -58,11 +58,11 @@ function ctx(config: OcxConfig = LOOPBACK): ExportContext { describe("no secret reaches a client config", () => { test("the generated client support policy identifies every loopback-only integration", () => { - // Pi, Kimi and Gajae cannot emit the dedicated admission header. OMP can - // carry provider headers, but remote credential wiring is deliberately - // deferred from this initial generated integration. + // Pi, Kimi and Gajae cannot emit the dedicated admission header. OMP and + // Prime can carry provider headers, but remote credential wiring is + // deliberately deferred from those initial generated integrations. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index 5a7ef81b82..c32d0fdfe9 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -514,8 +514,8 @@ describe("stable ordering (accept criterion 4)", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the nine file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode"]); + test("covers exactly the eleven file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts index 3b4bd7fdff..b7490f7e69 100644 --- a/tests/integrations-invariants.test.ts +++ b/tests/integrations-invariants.test.ts @@ -66,9 +66,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same ten ids", async () => { + test("every list of clients holds exactly the same eleven ids", async () => { /* - * Five lists name the same ten clients, and two of them are maintained by + * Five lists name the same eleven clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -79,7 +79,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(10); + expect(expected).toHaveLength(11); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -153,6 +153,8 @@ describe("every client survives a full lifecycle", () => { dsh: "llm-pi-ai:\n providers:\n mine:\n api: openai-completions\n", mcode: "custom_provider:\n mine:\n name: Keep Me\n", zcode: '{\n "provider": {\n "builtin:zai-start-plan": { "name": "Keep Me", "kind": "anthropic" }\n }\n}\n', + // Prime reads Pi's models.json contract, so it seeds the same shape. + prime: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', }; for (const clientId of INTEGRATION_CLIENT_IDS) { diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts index b2ae723444..bc6cf8f662 100644 --- a/tests/integrations-state.test.ts +++ b/tests/integrations-state.test.ts @@ -714,9 +714,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae, dsh, mcode and zcode are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode and prime are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/prime-client.test.ts b/tests/prime-client.test.ts new file mode 100644 index 0000000000..2ddb42cf0c --- /dev/null +++ b/tests/prime-client.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + LOOPBACK_API_KEY_PLACEHOLDER, + OPENCODE_PROVIDER_ID, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + primeAgentDir, + primeConfigPath, + type ExportContext, + type PiGeneratedConfig, +} from "../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../src/integrations/registry"; +import type { OcxConfig } from "../src/types"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function context(): ExportContext { + return { + baseUrl: "http://127.0.0.1:10100/v1", + config: CONFIG, + models: [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + // No authoritative context window: ships without limits rather than guessing. + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + ], + }; +} + +describe("Prime Agent client config", () => { + /** + * The load-bearing claim of this client: Prime Agent is the pi coding agent + * under a different brand, so it reads the SAME models.json contract rather + * than a lookalike. Locking the two documents together is what keeps that + * claim true — if a future Pi-only change diverges, this fails here instead + * of silently shipping Prime users a config their agent rejects. + */ + test("generates byte-for-byte the document Pi generates", () => { + const prime = buildClientConfigText("prime", context()); + const pi = buildClientConfigText("pi", context()); + expect(prime.format).toBe("json"); + expect(prime.text).toBe(pi.text); + }); + + test("adds only providers.opencodex, wired to the loopback proxy", () => { + const document = buildClientConfig("prime", context()) as PiGeneratedConfig; + expect(Object.keys(document)).toEqual(["providers"]); + expect(Object.keys(document.providers)).toEqual([OPENCODE_PROVIDER_ID]); + const provider = document.providers[OPENCODE_PROVIDER_ID]!; + expect(provider.baseUrl).toBe("http://127.0.0.1:10100/v1"); + expect(provider.api).toBe("openai-completions"); + expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER); + }); + + test("native JSON round-trips and never carries a credential", () => { + const sentinel = ["sk", "live", "prime", "sentinel"].join("-"); + const withKey = { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig; + const built = buildClientConfigText("prime", { ...context(), config: withKey }); + expect(JSON.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain(sentinel); + expect(built.text).toContain(LOOPBACK_API_KEY_PLACEHOLDER); + }); + + test("the contribution owns exactly the providers.opencodex path under its own id", () => { + const contribution = buildClientContribution("prime", context()); + // Reusing Pi's builder must not leak Pi's id into the ownership record, or + // the writer would stamp one client's block with the other's name. + expect(contribution.clientId).toBe("prime"); + expect(contribution.fragments.map(f => f.path)).toEqual([["providers", OPENCODE_PROVIDER_ID]]); + }); + + test("resolves the agent-dir override and the documented destination", () => { + expect(primeAgentDir({}, "/home/u")).toBe(join("/home/u", ".prime", "agent")); + expect(primeConfigPath({}, "/home/u")).toBe(join("/home/u", ".prime", "agent", "models.json")); + expect(primeConfigPath({ PRIME_AGENT_CODING_AGENT_DIR: "/elsewhere" }, "/home/u")).toBe(join("/elsewhere", "models.json")); + expect(primeConfigPath({ PRIME_AGENT_CODING_AGENT_DIR: "~/alt" }, "/home/u")).toBe(join("/home/u", "alt", "models.json")); + // A relative override would name different files for the proxy and the + // agent, which have different working directories. + expect(() => primeConfigPath({ PRIME_AGENT_CODING_AGENT_DIR: "relative" }, "/home/u")).toThrow(ClientPathError); + }); + + test("detects installation by the agent directory the override names", () => { + const spec = INTEGRATION_CLIENTS.prime; + expect(spec.detectDir({}, "/home/u")).toBe(join("/home/u", ".prime", "agent")); + expect(spec.detectDir({ PRIME_AGENT_CODING_AGENT_DIR: "/elsewhere" } as NodeJS.ProcessEnv, "/home/u")).toBe("/elsewhere"); + }); + + test("ships as a loopback-only integration with no env var to export", () => { + const spec = EXPORT_CLIENTS.prime; + expect(spec.loopbackOnly).toBe(true); + expect(spec.apiKeyEnv).toBe(""); + expect(spec.filename).toBe("prime-models.json"); + }); +}); From 5b4662043b11aba90a11ebc1f7c5cee0cf123492 Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 03:39:03 +0900 Subject: [PATCH 19/67] fix(help): correct the export client count and hold it in lockstep Top-level help advertised "(10 clients)" while the export registry held eleven, so the first screen a user sees understated the supported set. The reason the suite could not catch it is the more useful half: the assertion in tests/cli-help.test.ts froze the same literal, so code and test stayed self-consistent and wrong while the registry grew. The test now derives the number from EXPORT_CLIENT_IDS.length. help.ts keeps a literal on purpose. It currently imports only node:fs, node:path, node:url and the pure-data src/cli/registry.ts; importing the export registry there would pull node:os and the whole client-export module onto the `ocx --help` path. The assertion is the cheaper place to hold the invariant. --- src/cli/help.ts | 2 +- tests/cli-help.test.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/cli/help.ts b/src/cli/help.ts index c335c347b5..ca1efe8c01 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -60,7 +60,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (10 clients) + ocx export --client Print a client config wired to the running proxy (11 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, and updates diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index 639f6bd72d..ea1e49718b 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -4,6 +4,7 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { EXPORT_CLIENT_IDS } from "../src/clients/config-export"; import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -63,11 +64,17 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("Start the proxy server and sync models to Codex."); }); - test("top-level and export help advertise all eight clients including DSH", () => { + test("top-level help counts every export client and export help names them", () => { const topLevel = runCli([]); expectSpawnFinished(topLevel, "ocx help"); expect(topLevel.status).toBe(0); - expect(topLevel.stdout).toContain("(10 clients)"); + // Derived, not frozen: a hard-coded literal here agreed with a stale + // literal in help.ts, so the pair stayed self-consistent and wrong + // while the registry grew. help.ts keeps its literal on purpose — + // importing the export registry there would load node:os/node:path + // machinery on the `ocx --help` path — so this assertion is what + // holds the two in lockstep. + expect(topLevel.stdout).toContain(`(${EXPORT_CLIENT_IDS.length} clients)`); const exportHelp = runCli(["help", "export"]); expectSpawnFinished(exportHelp, "ocx help export"); From 42adf4996d2cf0a2e75fa234c364c8e73cfcbf1c Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 03:39:03 +0900 Subject: [PATCH 20/67] docs(integrations): document the Prime Agent client and sync client lists Adds the Prime Agent row to the destination table, updates the sentence that counts those rows so the page stays internally consistent, names Prime in the loopback-only list with its own reason (its provider block does accept headers; remote credential wiring is deferred), and documents PRIME_AGENT_CODING_AGENT_DIR and the relative-path refusal alongside the equivalent MiniMax paragraph. Also syncs the `--client` accepted-value list in the seven translated CLI references. An accepted-value list is correctness rather than translation: a stale one tells users a valid flag value will be rejected. Those lists were two clients behind, so they now name mcode and zcode as well as prime. --- .../src/content/docs/fr/reference/cli/agents.md | 2 +- .../src/content/docs/guides/integrations.md | 17 +++++++++++++---- .../src/content/docs/ja/reference/cli/agents.md | 2 +- .../src/content/docs/ko/reference/cli/agents.md | 2 +- .../src/content/docs/ru/reference/cli/agents.md | 2 +- .../src/content/docs/tr/reference/cli/agents.md | 2 +- .../content/docs/zh-cn/reference/cli/agents.md | 2 +- .../content/docs/zh-tw/reference/cli/agents.md | 2 +- 8 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 6e8fca4411..63b7ae6b8e 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -164,7 +164,7 @@ Gérez et appliquez la clôture du modèle Grok Build. ## Exportation de la configuration client -### `ocx export --client ` +### `ocx export --client ` Imprimez une configuration client connectée au proxy en cours d'exécution. La commande sérialise le bloc fournisseur `opencodex` — URL de base, liste de modèles et référence d’identifiant du client diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index f8b2c578d8..4633c3e450 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness and MiniMax Code from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code and Prime Agent from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Nine clients work this way, each with a switch: +file, and removes it again. Ten clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -17,6 +17,7 @@ file, and removes it again. Nine clients work this way, each with a switch: | Gajae Code | `~/.gjc/agent/models.yml` | YAML | new sessions, or when you open `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (default `~/.dsh/settings.yaml`) | YAML | hot reload | non-secret loopback bearer placeholder | | MiniMax Code | `~/.minimax/config.yaml` | YAML | new sessions, or after opening the model picker | loopback placeholder | +| Prime Agent | `~/.prime/agent/models.json` | JSON | new sessions | loopback placeholder | Managed DSH support has a compatibility floor of **DSH 0.1.0-rc.6**. OpenCodex owns only `llm-pi-ai.providers.opencodex`; Apply and Refresh replace that fragment, Disable removes only that @@ -30,6 +31,13 @@ It does not change `defaultModel`, the selected MiniMax credential source, or the user's MiniMax login. Choose a `custom_provider:opencodex/` entry in MCode after connecting it. +Prime Agent follows `PRIME_AGENT_CODING_AGENT_DIR` before falling back to +`~/.prime/agent`; a relative value is refused so the proxy and the agent cannot +disagree about which file is meant. Its managed block owns only +`providers.opencodex`, so other providers and any `modelOverrides` you have set +stay untouched. Prime Agent reads `models.json` when a session starts, so start +a new session after connecting it. + Paths honor each client's own environment override where it has one. For OMP, `OMP_PROFILE` wins over `PI_PROFILE` by presence, even when explicitly empty. A named profile uses `PI_CONFIG_DIR` as a directory name relative to the user's home and ignores `PI_CODING_AGENT_DIR`; without a named profile, @@ -113,11 +121,12 @@ changed value and calling it success. You will see the file named and nothing on disk will have moved. Editing that file by hand still works; it is only our automatic rewrite that declines. -**Pi, Kimi Code, Gajae Code, MiniMax Code and the managed DSH integration only work against a loopback bind.** +**Pi, Kimi Code, Gajae Code, MiniMax Code, Prime Agent and the managed DSH integration only work against a loopback bind.** The first four have no config field for the `x-opencodex-api-key` header a non-loopback bind requires. DSH has a generic headers map, but rc.6 does not document that dedicated admission header as a supported integration contract, so the managed writer fails closed instead of -guessing. Give them loopback access through an SSH tunnel or a local forwarder that adds the header. +guessing. Prime Agent's provider block does accept headers, but remote credential wiring is +deferred from its initial integration. Give them loopback access through an SSH tunnel or a local forwarder that adds the header. **The generated OMP integration is also deliberately loopback-only.** OMP does support provider-level headers, but this initial integration does not emit remote diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 7ea3a3deb1..30a2047ba0 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -125,7 +125,7 @@ Grok Build モデル フェンスを管理および適用します。 ## クライアント設定のエクスポート -### `ocx export --client ` +### `ocx export --client ` 実行中のプロキシに接続するクライアント設定を出力します。このコマンドは、ベース URL、モデル一覧、およびクライアントに応じた認証情報参照または `opencodex-loopback` プレースホルダーを含む `opencodex` プロバイダーブロックを、選択したクライアントのネイティブ形式でシリアル化します。 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 80325145c4..31049a24a0 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -131,7 +131,7 @@ Grok Build model fence를 관리하고 적용합니다. ## 클라이언트 설정 내보내기 -### `ocx export --client ` +### `ocx export --client ` 실행 중인 프록시에 연결할 client config를 출력합니다. 이 명령은 base URL, model list, 그리고 client에 따라 credential reference 또는 `opencodex-loopback` placeholder를 포함한 `opencodex` provider block을 선택한 client의 네이티브 형식으로 직렬화합니다. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index bba29e1f3a..3baf3a32a3 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -152,7 +152,7 @@ override, но файлы на диске никогда не меняются. ## Экспорт client config -### `ocx export --client ` +### `ocx export --client ` Печатает client config, направленный на работающий прокси. Команда сериализует блок провайдера `opencodex` в нативном формате выбранного клиента: base URL, список моделей и, diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 82949d8f9a..7d9efad990 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -191,7 +191,7 @@ Grok Build model çitini yönetin ve uygulayın. ## İstemci yapılandırma dışa aktarma -### `ocx export --client ` +### `ocx export --client ` Çalışan proxy'ye bağlı bir istemci yapılandırmasını yazdırın. Komut, `opencodex` sağlayıcı bloğunu — temel URL, model listesi ve istemcinin kimlik bilgisi diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index db3754ba69..0fa4a6cf63 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -132,7 +132,7 @@ ocx claude desktop import [--apply] Validate and import JSON ## Client config export -### `ocx export --client ` +### `ocx export --client ` 输出连接到正在运行代理的客户端配置。此命令会以所选客户端的原生格式序列化 `opencodex` provider 块,其中包含基础 URL、模型列表,以及该客户端适用的凭据引用或 `opencodex-loopback` 占位值。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index ebe59cbcd8..d8f1c1d43f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -130,7 +130,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ## 客戶端設定匯出 -### `ocx export --client ` +### `ocx export --client ` 印出連接到執行中代理的客戶端設定。此指令會用所選客戶端的原生格式,序列化含有 base URL、模型清單,以及適用的環境變數參考或 loopback 佔位符的 `opencodex` provider 區塊。 From 6cf3d2e448cfea3778676fd67774c0a2d9b50d35 Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 03:42:43 +0900 Subject: [PATCH 21/67] docs(clients): make the Prime path statement override-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both about the same failure mode: stating a path as if it were fixed when an environment override can move it. The dashboard's Prime description hard-coded ~/.prime/agent/models.json even though PRIME_AGENT_CODING_AGENT_DIR redirects it — and the panel already renders the resolved path directly beneath that sentence, so the literal was both redundant and capable of contradicting what the user could see. All nine locales now name the default and the override. The CLI reference's canonical-destination table also stopped at dsh. Widening the advertised `--client` set in the previous commit is what made that gap reachable for mcode and zcode, not just prime, so all three rows are added with their own overrides and the relative-path refusal, plus a paragraph noting that the three are loopback-only and that Prime and Pi produce the same document from different destinations. --- docs-site/src/content/docs/reference/cli/agents.md | 10 ++++++++++ gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- 10 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 62f35ec7a0..664be4d7c8 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -206,6 +206,9 @@ client applies its own defaults for those). | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | none — loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | | `dsh` | `$DSH_HOME/settings.yaml` (default `~/.dsh/settings.yaml`) | `settings.yaml` | none — non-secret loopback bearer placeholder | +| `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, then the legacy `MAVIS_DATA_DIR`, win when set; a relative value is refused) | `mcode-config.yaml` | none — loopback placeholder | +| `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` wins when set; a relative value is refused) | `config.json` | none — loopback placeholder | +| `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` wins when set; a relative value is refused) | `prime-models.json` | none — loopback placeholder | The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only `llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and @@ -218,6 +221,13 @@ hide the whole provider when an existing config contains an unset env reference. checks the generated placeholder on loopback. OMP supports provider-level headers, but this initial integration deliberately remains loopback-only; remote `x-opencodex-api-key` wiring is deferred. +The MCode, ZCode and Prime exports are loopback-only for the same reason and likewise carry the +`opencodex-loopback` placeholder rather than a real credential. Prime Agent reads the same +`models.json` contract Pi does, so the two exports produce the same document; only the destination +differs. A relative path in any of those three environment overrides is refused, because the proxy +and the client can have different working directories and would otherwise disagree about which +file is meant. + :::caution[Merge, never replace] `ocx export` never writes your real client config. The destination is printed for you to merge by hand, and `--out` refuses to overwrite an existing file without `--force`, because replacing a diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 22f5868a86..4b0fc3b39a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -969,7 +969,7 @@ export const de: Record = { "integrations.semantics.dsh": "OpenCodex verwaltet nur llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH lädt diesen Anbieter im laufenden Betrieb neu; Ihr Standardmodell und deepseek-official bleiben unverändert. Derzeit nur über Loopback; es werden keine echten Zugangsdaten geschrieben.", "integrations.semantics.mcode": "Verwaltet nur custom_provider.opencodex. Standardmodell und MiniMax-Anmeldung bleiben unverändert.", "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", - "integrations.semantics.prime": "Verwaltet nur providers.opencodex in ~/.prime/agent/models.json. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", + "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ca9e465fce..f8f5507062 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1458,7 +1458,7 @@ export const en = { "integrations.semantics.dsh": "OpenCodex manages only llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH hot reloads this provider; your default model and deepseek-official stay unchanged. Currently loopback-only; no real credential is written.", "integrations.semantics.mcode": "Manages only custom_provider.opencodex. Your default model and MiniMax login stay unchanged.", "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", - "integrations.semantics.prime": "Manages only providers.opencodex in ~/.prime/agent/models.json. Your other providers and model overrides stay unchanged. Applies to new sessions.", + "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5ec009b99a..9d0a83fdbe 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1431,7 +1431,7 @@ export const fr: Record = { "integrations.semantics.dsh": "OpenCodex gère uniquement llm-pi-ai.providers.opencodex dans $DSH_HOME/settings.yaml. DSH recharge ce fournisseur à chaud ; votre modèle par défaut et deepseek-official restent inchangés. Seule l’adresse de bouclage est actuellement prise en charge ; aucun identifiant réel n’est écrit.", "integrations.semantics.mcode": "Gère uniquement custom_provider.opencodex. Votre modèle par défaut et votre connexion MiniMax restent inchangés.", "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", - "integrations.semantics.prime": "Gère uniquement providers.opencodex dans ~/.prime/agent/models.json. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", + "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1e2ffc1c50..acc61bbd27 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1391,7 +1391,7 @@ export const ja: Record = { "integrations.semantics.dsh": "OpenCodex が管理するのは $DSH_HOME/settings.yaml 内の llm-pi-ai.providers.opencodex だけです。DSH はこのプロバイダーをホットリロードし、既定のモデルと deepseek-official は変更しません。現在はループバック専用で、実際の認証情報は書き込みません。", "integrations.semantics.mcode": "custom_provider.opencodex のみを管理します。既定モデルと MiniMax ログインは変更しません。", "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", - "integrations.semantics.prime": "~/.prime/agent/models.json の providers.opencodex のみを管理します。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", + "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3b3929f23d..46d57057da 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -993,7 +993,7 @@ export const ko: Record = { "integrations.semantics.dsh": "OpenCodex는 $DSH_HOME/settings.yaml의 llm-pi-ai.providers.opencodex만 관리합니다. DSH는 이 provider를 hot reload하며 기본 model과 deepseek-official은 변경하지 않습니다. 현재 loopback 전용이며 실제 credential을 기록하지 않습니다.", "integrations.semantics.mcode": "custom_provider.opencodex만 관리하며 기본 모델과 MiniMax 로그인은 변경하지 않습니다.", "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", - "integrations.semantics.prime": "~/.prime/agent/models.json의 providers.opencodex만 관리하며 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", + "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index e01db1b274..a097a9e458 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1442,7 +1442,7 @@ export const ru: Record = { "integrations.semantics.dsh": "OpenCodex управляет только llm-pi-ai.providers.opencodex в $DSH_HOME/settings.yaml. DSH применяет этот провайдер горячей перезагрузкой; модель по умолчанию и deepseek-official остаются без изменений. Сейчас поддерживается только loopback; реальные учётные данные не записываются.", "integrations.semantics.mcode": "Управляет только custom_provider.opencodex. Модель по умолчанию и вход MiniMax не меняются.", "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", - "integrations.semantics.prime": "Управляет только providers.opencodex в ~/.prime/agent/models.json. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", + "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9dca6cf326..b179272b62 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1448,7 +1448,7 @@ export const tr: Record = { "integrations.semantics.dsh": "OpenCodex yalnızca $DSH_HOME/settings.yaml içindeki llm-pi-ai.providers.opencodex bölümünü yönetir. DSH bu sağlayıcıyı çalışırken yeniden yükler; varsayılan modeliniz ve deepseek-official değişmez. Şimdilik yalnızca geri döngü desteklenir; gerçek kimlik bilgisi yazılmaz.", "integrations.semantics.mcode": "Yalnızca custom_provider.opencodex bölümünü yönetir. Varsayılan model ve MiniMax oturumu değişmez.", "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", - "integrations.semantics.prime": "Yalnızca ~/.prime/agent/models.json içindeki providers.opencodex bölümünü yönetir. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", + "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 5822504d5d..cf5d94b18a 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1967,7 +1967,7 @@ export const zhTW: Record = { "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 會熱重載該 provider;你的預設模型與 deepseek-official 維持不變。目前僅支援 loopback,且不會寫入真實憑證。", "integrations.semantics.mcode": "僅管理 custom_provider.opencodex,不會變更預設模型或 MiniMax 登入狀態。", "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", - "integrations.semantics.prime": "僅管理 ~/.prime/agent/models.json 中的 providers.opencodex,不會變更其他供應商或模型覆寫設定。對新工作階段生效。", + "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 174adb9564..51f7b8a366 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -986,7 +986,7 @@ export const zh: Record = { "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 会热重载该 provider;你的默认模型和 deepseek-official 保持不变。目前仅支持环回地址,且不会写入真实凭据。", "integrations.semantics.mcode": "仅管理 custom_provider.opencodex,不会更改默认模型或 MiniMax 登录状态。", "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", - "integrations.semantics.prime": "仅管理 ~/.prime/agent/models.json 中的 providers.opencodex,不会更改其他提供商或模型覆盖设置。对新会话生效。", + "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", From dba6acc059784f808dafbd9a381e28a2e685bdaf Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 04:16:25 +0900 Subject: [PATCH 22/67] docs(clients): finish the translated client tables Syncing only the `--client` heading in the translated CLI references left each of those pages contradicting itself: the heading named mcode, zcode and prime while the flag table two lines below still stopped at dsh. That is worse than leaving both stale, and it was mine to fix. All seven translations now carry the full accepted-value list in both the heading and the flag table, plus mcode, zcode and prime rows in the canonical-destination table with each client's own environment override and the relative-path refusal, phrased in that locale's existing wording for the placeholder column. Verified by pattern rather than by eye this time: every locale, English included, now reports the same eleven-client list in both places and eleven destination rows. --- docs-site/src/content/docs/fr/reference/cli/agents.md | 5 ++++- docs-site/src/content/docs/ja/reference/cli/agents.md | 5 ++++- docs-site/src/content/docs/ko/reference/cli/agents.md | 5 ++++- docs-site/src/content/docs/ru/reference/cli/agents.md | 5 ++++- docs-site/src/content/docs/tr/reference/cli/agents.md | 5 ++++- docs-site/src/content/docs/zh-cn/reference/cli/agents.md | 5 ++++- docs-site/src/content/docs/zh-tw/reference/cli/agents.md | 5 ++++- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 63b7ae6b8e..48dfc2d0ca 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -175,7 +175,7 @@ les modèles Codex peuvent actuellement voir. | Option | Actions | | --- | --- | -| `--client ` | Requis. Sélectionne le dialecte de configuration client. | +| `--client ` | Requis. Sélectionne le dialecte de configuration client. | | `--json` | Imprimez le document généré en tant que JSON sur la sortie standard pour les scripts. Il s'agit de JSON même lorsque le format natif du client sélectionné est YAML, TOML ou JSON5. | | `--out ` | Écrivez le format de configuration natif du client dans ``. Refuse de remplacer un fichier existant. | | `--force` | Autoriser `--out` à remplacer un fichier existant. | @@ -202,6 +202,9 @@ propres valeurs par défaut à ces lignes. | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | aucun — espace réservé de bouclage | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | | `dsh` | `$DSH_HOME/settings.yaml` (`~/.dsh/settings.yaml` par défaut) | `settings.yaml` | none — espace réservé pour le porteur de bouclage non secret | +| `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, puis l'ancien `MAVIS_DATA_DIR`, l'emportent une fois définis ; une valeur relative est refusée) | `mcode-config.yaml` | aucun — espace réservé de bouclage | +| `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `config.json` | aucun — espace réservé de bouclage | +| `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `prime-models.json` | aucun — espace réservé de bouclage | L'exportation DSH gérée nécessite DSH 0.1.0-rc.6 ou plus récent et ne possède que `llm-pi-ai.providers.opencodex`. DSH recharge à chaud ce fournisseur ; le modèle par défaut de l'utilisateur et diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 30a2047ba0..d5407a61fb 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -133,7 +133,7 @@ Grok Build モデル フェンスを管理および適用します。 |旗 |アクション | | --- | --- | -| `--client ` |必須。クライアントの設定形式を選択します。 | +| `--client ` |必須。クライアントの設定形式を選択します。 | | `--json` |構成 JSON のみを標準出力に出力するため、リダイレクトはバイト正確な出力をキャプチャします。 `--out` 書き込みメモを含むすべての診断は stderr に送られます。 | | `--out ` |設定を `` に書き込みます。既存のファイルの置き換えを拒否します。 | | `--force` | `--out` が既存のファイルを置き換えることを許可します。 | @@ -157,6 +157,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | なし - loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | | `dsh` | `$DSH_HOME/settings.yaml`(既定 `~/.dsh/settings.yaml`) | `settings.yaml` | なし — 秘密ではないループバック bearer プレースホルダー | +| `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`、次に旧 `MAVIS_DATA_DIR` が設定時に優先。相対値は拒否されます) | `mcode-config.yaml` | なし — loopback placeholder | +| `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` が設定時に優先。相対値は拒否されます) | `config.json` | なし — loopback placeholder | +| `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` が設定時に優先。相対値は拒否されます) | `prime-models.json` | なし — loopback placeholder | opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex が生成する Pi のエクスポートには環境変数が不要で、リテラルのプレースホルダー `opencodex-loopback` が入ります。この値は必須です。Pi はモデル リストを構築する際に `apiKey` を解決し、既存の設定に未設定の環境変数参照がある場合はプロバイダー全体を隠すためです。ループバックでは、生成されたプレースホルダーをプロキシが検査することはありません。 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 31049a24a0..67532f93ef 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -139,7 +139,7 @@ Grok Build model fence를 관리하고 적용합니다. | 플래그 | 동작 | | --- | --- | -| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | +| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | | `--json` | config JSON만 stdout에 출력하므로, redirect가 byte-exact 출력을 캡처합니다. `--out` write note를 포함한 모든 진단 메시지는 stderr로 갑니다. | | `--out ` | config를 ``에 씁니다. 기존 파일이 있으면 덮어쓰지 않습니다. | | `--force` | `--out`이 기존 파일을 덮어쓰도록 허용합니다. | @@ -163,6 +163,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | 없음 - loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | | `dsh` | `$DSH_HOME/settings.yaml`(기본값 `~/.dsh/settings.yaml`) | `settings.yaml` | 없음 — 비밀이 아닌 loopback bearer placeholder | +| `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, 그다음 레거시 `MAVIS_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `mcode-config.yaml` | 없음 — loopback placeholder | +| `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `config.json` | 없음 — loopback placeholder | +| `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `prime-models.json` | 없음 — loopback placeholder | opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 생성한 Pi 블록에는 환경 변수가 필요 없으며, 리터럴 placeholder인 `opencodex-loopback`이 들어갑니다. 이 값은 필수입니다. Pi는 모델 목록을 만들 때 `apiKey`를 해석하고, 기존 config에 설정되지 않은 env 참조가 있으면 provider 전체를 숨기기 때문입니다. 루프백에서 proxy는 생성된 placeholder를 검사하지 않습니다. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 3baf3a32a3..8015f24122 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -163,7 +163,7 @@ override, но файлы на диске никогда не меняются. | Флаг | Действие | | --- | --- | -| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | +| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | | `--json` | Печатать только JSON-конфиг в stdout, чтобы redirect сохранял побайтно точный вывод. Вся диагностика, включая заметку о записи через `--out`, идёт в stderr. | | `--out ` | Записать конфиг в ``. Перезаписывать существующий файл не позволит. | | `--force` | Разрешить `--out` заменить существующий файл. | @@ -190,6 +190,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | нет — loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | | `dsh` | `$DSH_HOME/settings.yaml` (по умолчанию `~/.dsh/settings.yaml`) | `settings.yaml` | нет — несекретная loopback bearer-заглушка | +| `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, затем устаревшая `MAVIS_DATA_DIR`, имеют приоритет, если заданы; относительное значение отклоняется) | `mcode-config.yaml` | нет — loopback placeholder | +| `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `config.json` | нет — loopback placeholder | +| `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `prime-models.json` | нет — loopback placeholder | opencode интерполирует `{env:OPENCODEX_OPENCODE_API_KEY}`. Сгенерированный opencodex экспорт для Pi не требует переменной окружения и несёт литеральную заглушку `opencodex-loopback`. Это значение diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 7d9efad990..74832a355c 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -203,7 +203,7 @@ yalnızca Codex'in şu anda görebildiği modelleri yayınlar. | Bayrak | Eylem | | --- | --- | -| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | +| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | | `--json` | Betikler için stdout üzerinde oluşturulan belgeyi JSON olarak yazdırın. Bu, seçilen istemcinin yerel formatı YAML, TOML veya JSON5 olsa bile JSON'dur. | | `--out ` | İstemcinin yerel yapılandırma formatını `` konumuna yazın. Mevcut bir dosyanın üzerine yazmayı reddeder. | | `--force` | `--out`'un mevcut bir dosyanın üzerine yazmasına izin verin. | @@ -230,6 +230,9 @@ için kendi varsayılanlarını uygular) gelir. | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | yok — geri döngü yer tutucusu | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | | `dsh` | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | `settings.yaml` | yok — gizli olmayan geri döngü bearer yer tutucusu | +| `mcode` | `~/.minimax/config.yaml` (ayarlandığında `MINIMAX_DATA_DIR`, ardından eski `MAVIS_DATA_DIR` öncelikli; göreli değer reddedilir) | `mcode-config.yaml` | yok — geri döngü yer tutucusu | +| `zcode` | `~/.zcode/v2/config.json` (ayarlandığında `ZCODE_DATA_DIR` öncelikli; göreli değer reddedilir) | `config.json` | yok — geri döngü yer tutucusu | +| `prime` | `~/.prime/agent/models.json` (ayarlandığında `PRIME_AGENT_CODING_AGENT_DIR` öncelikli; göreli değer reddedilir) | `prime-models.json` | yok — geri döngü yer tutucusu | opencode `{env:OPENCODEX_OPENCODE_API_KEY}` değerini enterpole eder. Üretilen Pi ve OMP dışa aktarımları bir ortam değişkeni gerektirmez: her biri değişmez diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 0fa4a6cf63..d509d3504b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -140,7 +140,7 @@ ocx claude desktop import [--apply] Validate and import JSON | 标志 | 动作 | | --- | --- | -| `--client ` | 必需。选择客户端配置格式。 | +| `--client ` | 必需。选择客户端配置格式。 | | `--json` | 仅在 stdout 打印配置 JSON,这样重定向即可捕获字节级精确输出。包括 `--out` 写入提示在内的所有诊断信息都会输出到 stderr。 | | `--out ` | 将配置写入 ``。拒绝替换已存在的文件。 | | `--force` | 允许 `--out` 替换已存在的文件。 | @@ -164,6 +164,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | 无 - loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | | `dsh` | `$DSH_HOME/settings.yaml`(默认 `~/.dsh/settings.yaml`) | `settings.yaml` | 无 — 非秘密环回 bearer 占位值 | +| `mcode` | `~/.minimax/config.yaml` (设置后 `MINIMAX_DATA_DIR` 优先,其次是旧的 `MAVIS_DATA_DIR`;相对路径会被拒绝) | `mcode-config.yaml` | 无 — loopback placeholder | +| `zcode` | `~/.zcode/v2/config.json` (设置后 `ZCODE_DATA_DIR` 优先;相对路径会被拒绝) | `config.json` | 无 — loopback placeholder | +| `prime` | `~/.prime/agent/models.json` (设置后 `PRIME_AGENT_CODING_AGENT_DIR` 优先;相对路径会被拒绝) | `prime-models.json` | 无 — loopback placeholder | opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi 导出不需要环境变量,而是携带字面占位值 `opencodex-loopback`。这个值是必需的:Pi 在构建模型列表时会解析 `apiKey`,如果已有配置包含未设置的环境变量引用,它就会隐藏整个 provider。回环上的代理从不校验生成的占位值。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index d8f1c1d43f..9bb6531bc6 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -138,7 +138,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON | 旗標 | 動作 | | --- | --- | -| `--client ` | 必填。選擇客戶端設定格式。 | +| `--client ` | 必填。選擇客戶端設定格式。 | | `--json` | 僅在 stdout 印出設定 JSON,使重導向能擷取逐位元組輸出。所有診斷訊息(含 `--out` 寫入提示)皆送至 stderr。 | | `--out ` | 將設定寫入 ``。拒絕覆寫既有檔案。 | | `--force` | 允許 `--out` 覆寫既有檔案。 | @@ -162,6 +162,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | 無——loopback 佔位符 | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | | `dsh` | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | `settings.yaml` | 無——非秘密的 loopback bearer 佔位符 | +| `mcode` | `~/.minimax/config.yaml` (設定後 `MINIMAX_DATA_DIR` 優先,其次為舊的 `MAVIS_DATA_DIR`;相對路徑會被拒絕) | `mcode-config.yaml` | 無——loopback 佔位符 | +| `zcode` | `~/.zcode/v2/config.json` (設定後 `ZCODE_DATA_DIR` 優先;相對路徑會被拒絕) | `config.json` | 無——loopback 佔位符 | +| `prime` | `~/.prime/agent/models.json` (設定後 `PRIME_AGENT_CODING_AGENT_DIR` 優先;相對路徑會被拒絕) | `prime-models.json` | 無——loopback 佔位符 | opencode 會插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 與 OMP 的匯出不需要環境變數, 而是帶有字面值 `opencodex-loopback`。DSH 匯出需要 DSH 0.1.0-rc.6 或更新版本,且只擁有 From 10c9498559afe2b149b5bd3bedfb45fde9521a44 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:18:19 +0900 Subject: [PATCH 23/67] fix(web-search): sanitize relayed citation sources --- src/bridge.ts | 39 ++++++++------ src/web-search/parse.ts | 42 ++++++++------- src/web-search/sources.ts | 60 +++++++++++++++++++++ tests/bridge.test.ts | 74 ++++++++++++++++++++++++++ tests/web-search-parse.test.ts | 30 +++++++++++ tests/web-search-sources.test.ts | 90 ++++++++++++++++++++++++++++++++ 6 files changed, 298 insertions(+), 37 deletions(-) create mode 100644 src/web-search/sources.ts create mode 100644 tests/web-search-sources.test.ts diff --git a/src/bridge.ts b/src/bridge.ts index 44745bd75f..8a85f57896 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -19,6 +19,7 @@ import { } from "./responses/thought-signature-replay"; import { resolveStallTimeoutSec } from "./stall-timeout"; import { usageDisplayTotalTokens } from "./usage/totals"; +import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources"; import { isTranslatorBudgetExceededError, releaseTranslatedEvent, @@ -1157,15 +1158,13 @@ export function bridgeToResponsesSSE( }); currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex }; } - closeCurrentWebSearch(event.status ?? "completed", event.queries, event.sources); + const safeSources = safeWebSearchSources(event.sources); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources); // Queue this search's sources for the next assistant message (dedup by URL). - if (event.sources) { - const seen = new Set(pendingWebSources.map(s => s.url)); - for (const s of event.sources) { - if (!seen.has(s.url)) { - seen.add(s.url); - chargeValue(s, "tool_search_sources"); - pendingWebSources.push(s); + if (safeSources.length > 0) { + for (const source of safeSources) { + if (appendSafeWebSearchSource(pendingWebSources, source)) { + chargeValue(source, "tool_search_sources"); } } } @@ -1566,6 +1565,7 @@ function buildResponseJSONWithBudget( const flushText = (inferredPhase?: OcxMessagePhase) => { if (!currentText) return; const phase = currentTextPhase ?? inferredPhase; + const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0); const annotations = pendingWebSources.map(s => ({ type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0, })); @@ -1576,6 +1576,7 @@ function buildResponseJSONWithBudget( ...(phase ? { phase } : {}), } as OutputItem; pushOutput(item, currentTextBytes); + budget?.releaseRetained(sourceBytes, { kind: "tool_search_sources" }); currentText = ""; currentTextBytes = 0; currentTextPhase = undefined; @@ -1820,27 +1821,26 @@ function buildResponseJSONWithBudget( // Batch/non-streaming output has no in_progress phase to animate — the search cell is a // single finalized item, emitted on `end`. Begin is a no-op here. break; - case "web_search_call_end": + case "web_search_call_end": { if (currentText) flushText("commentary"); if (currentSummaryReasoning) flushSummaryReasoning(); if (currentRawReasoning) flushRawReasoning(); flushToolCall(); + const safeSources = safeWebSearchSources(e.sources); pushOutput({ type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed", action: webSearchAction(e.queries), - ...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}), + ...(safeSources.length > 0 ? { sources: safeSources } : {}), }); - if (e.sources) { - const seen = new Set(pendingWebSources.map(s => s.url)); - for (const s of e.sources) { - if (!seen.has(s.url)) { - seen.add(s.url); - budget?.chargeRetained(bytesOf(JSON.stringify(s)), { kind: "tool_search_sources" }); - pendingWebSources.push(s); + if (safeSources.length > 0) { + for (const source of safeSources) { + if (appendSafeWebSearchSource(pendingWebSources, source)) { + budget?.chargeRetained(bytesOf(JSON.stringify(source)), { kind: "tool_search_sources" }); } } } break; + } case "error": errorEvent = e; sawTerminal = true; @@ -1872,6 +1872,11 @@ function buildResponseJSONWithBudget( if (budget) releaseTranslatedEvent(e, budget); } flushText(cleanDone && !errorEvent && !incompleteEvent ? "final_answer" : undefined); + if (pendingWebSources.length > 0) { + const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0); + pendingWebSources = []; + budget?.releaseRetained(sourceBytes, { kind: "tool_search_sources" }); + } flushSummaryReasoning(); flushRawReasoning(); // Open tool call on a failed/incomplete turn must not land as status:"completed" — and neither diff --git a/src/web-search/parse.ts b/src/web-search/parse.ts index 181751e199..0293593105 100644 --- a/src/web-search/parse.ts +++ b/src/web-search/parse.ts @@ -1,10 +1,12 @@ import { sseFieldValue } from "../lib/sse-decoder"; +import { + appendSafeWebSearchSource, + safeWebSearchSources, + type SafeWebSearchSource, +} from "./sources"; /** A single web source backing the sidecar's answer. */ -export interface WebSearchSource { - url: string; - title?: string; -} +export type WebSearchSource = SafeWebSearchSource; /** The sidecar's synthesized answer plus its sources (empty `sources` is fine). */ export interface WebSearchResult { @@ -36,8 +38,10 @@ export const MAX_SIDECAR_RESPONSE_BYTES = 64 * 1024; /** Push a `url_citation` annotation as a source, de-duplicated by URL. */ function collectAnnotation(ann: AnnotationLike | undefined, sources: WebSearchSource[], seen: Set): void { if (!ann || ann.type !== "url_citation" || typeof ann.url !== "string" || seen.has(ann.url)) return; - seen.add(ann.url); - sources.push({ url: ann.url, ...(ann.title ? { title: ann.title } : {}) }); + if (appendSafeWebSearchSource(sources, { + url: ann.url, + ...(ann.title !== undefined ? { title: ann.title } : {}), + })) seen.add(ann.url); } /** @@ -113,14 +117,14 @@ function cleanTitle(prefix: string): string { return title; } -function extractTrailingSources(text: string): { text: string; sources: WebSearchSource[] } { +function extractTrailingSources(text: string): { text: string; sources: WebSearchSource[]; stripped: boolean } { const lines = text.split("\n"); // Find the LAST line that is a "Sources:" header (markdown prefixes allowed). let headerIdx = -1; for (let i = lines.length - 1; i >= 0; i--) { if (isSourcesHeader(lines[i])) { headerIdx = i; break; } } - if (headerIdx === -1) return { text, sources: [] }; + if (headerIdx === -1) return { text, sources: [], stripped: false }; const sources: WebSearchSource[] = []; const seen = new Set(); // Track the last line index actually consumed as part of the source list so trailing prose after @@ -128,6 +132,7 @@ function extractTrailingSources(text: string): { text: string; sources: WebSearc let lastConsumed = headerIdx; // A title line whose URL is expected on a following line (multiline entry). let pendingTitle: string | null = null; + let consumedSourceLine = false; for (let i = headerIdx + 1; i < lines.length; i++) { const raw = lines[i].trim(); if (raw === "") { @@ -146,21 +151,21 @@ function extractTrailingSources(text: string): { text: string; sources: WebSearc } const url = cleanUrl(m[0]); if (!url) { break; } + consumedSourceLine = true; lastConsumed = i; // Title: text before the URL on this line, else a buffered title from a preceding line. const inlinePrefix = raw.slice(0, m.index); const title = cleanTitle(inlinePrefix) || (pendingTitle ? cleanTitle(pendingTitle) : ""); pendingTitle = null; if (seen.has(url)) continue; - seen.add(url); - sources.push(title ? { url, title } : { url }); + if (appendSafeWebSearchSource(sources, title ? { url, title } : { url })) seen.add(url); } - if (sources.length === 0) return { text, sources: [] }; + if (!consumedSourceLine) return { text, sources: [], stripped: false }; // Keep text before the header AND any prose after the consumed source lines. const before = lines.slice(0, headerIdx).join("\n").replace(/\s+$/, ""); const after = lines.slice(lastConsumed + 1).join("\n").replace(/^\s+/, ""); const body = after ? (before ? `${before}\n\n${after}` : after) : before; - return { text: body, sources }; + return { text: body, sources, stripped: true }; } /** Pull final text + url_citation sources from a completed Responses `output[]` array. */ @@ -283,22 +288,19 @@ export async function parseSidecarSSE(response: Response): Promise s.url)); + const sources = safeWebSearchSources(acc.final?.sources ?? []); for (const s of acc.streamSources) { - if (!seenMerge.has(s.url)) { seenMerge.add(s.url); sources.push(s); } + appendSafeWebSearchSource(sources, s); } // Hosted web_search usually omits url_citation annotations and lists sources in a trailing // `Sources:` markdown block instead. Pull those out (and strip the block from the answer so the // tool_result renderer doesn't print sources twice). Annotation titles win; text-block titles // only fill a gap. URL-deduped against annotation sources. - const { text: body, sources: textSources } = extractTrailingSources(typeof text === "string" ? text : ""); + const { text: body, sources: textSources, stripped } = extractTrailingSources(typeof text === "string" ? text : ""); for (const s of textSources) { - if (seenMerge.has(s.url)) continue; - seenMerge.add(s.url); - sources.push(s); + appendSafeWebSearchSource(sources, s); } - const finalText = textSources.length > 0 ? body : (typeof text === "string" ? text : ""); + const finalText = stripped ? body : (typeof text === "string" ? text : ""); if (!finalText.trim() && acc.error) return { text: "", sources, error: acc.error }; return { text: finalText, sources }; } diff --git a/src/web-search/sources.ts b/src/web-search/sources.ts new file mode 100644 index 0000000000..4b9d7abbb0 --- /dev/null +++ b/src/web-search/sources.ts @@ -0,0 +1,60 @@ +export interface SafeWebSearchSource { + url: string; + title?: string; +} + +export const MAX_WEB_SEARCH_SOURCES = 20; +export const MAX_WEB_SEARCH_URL_BYTES = 2_048; +export const MAX_WEB_SEARCH_TITLE_BYTES = 256; +export const MAX_WEB_SEARCH_SOURCE_BYTES = 16_384; + +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; +const encoder = new TextEncoder(); + +function byteLength(value: string): number { + return encoder.encode(value).byteLength; +} + +function serializedSourceBytes(source: SafeWebSearchSource): number { + return byteLength(JSON.stringify(source)); +} + +function safeTitle(value: unknown): string | undefined { + if (typeof value !== "string" + || value.trim().length === 0 + || CONTROL_CHARACTERS.test(value) + || byteLength(value) > MAX_WEB_SEARCH_TITLE_BYTES) return undefined; + return value; +} + +/** Add one client-safe citation while enforcing per-message count and byte budgets. */ +export function appendSafeWebSearchSource(target: SafeWebSearchSource[], source: unknown): boolean { + if (target.length >= MAX_WEB_SEARCH_SOURCES || !source || typeof source !== "object") return false; + const candidate = source as { url?: unknown; title?: unknown }; + if (typeof candidate.url !== "string" + || candidate.url.length === 0 + || candidate.url.trim() !== candidate.url + || CONTROL_CHARACTERS.test(candidate.url) + || byteLength(candidate.url) > MAX_WEB_SEARCH_URL_BYTES + || target.some(existing => existing.url === candidate.url)) return false; + + let parsed: URL; + try { parsed = new URL(candidate.url); } catch { return false; } + if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") + || parsed.username.length > 0 + || parsed.password.length > 0) return false; + + const title = safeTitle(candidate.title); + const next = title === undefined ? { url: candidate.url } : { url: candidate.url, title }; + const usedBytes = target.reduce((sum, item) => sum + serializedSourceBytes(item), 0); + if (usedBytes + serializedSourceBytes(next) > MAX_WEB_SEARCH_SOURCE_BYTES) return false; + target.push(next); + return true; +} + +export function safeWebSearchSources(sources: unknown): SafeWebSearchSource[] { + if (!Array.isArray(sources)) return []; + const safe: SafeWebSearchSource[] = []; + for (const source of sources) appendSafeWebSearchSource(safe, source); + return safe; +} diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 3342c1b27e..99a6abb7fc 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE, buildResponseJSON, setOwnedBudgetAbandonedMsForTests } from "../src/bridge"; import { + createTranslatorBudget, resetTranslatorAggregateForTests, + retainTranslatedEventBatch, translatorAggregateCurrentBytesForTests, translatorLiveBudgetCountForTests, } from "../src/lib/translator-budget"; @@ -1030,6 +1032,11 @@ describe("Responses bridge web_search_call native item", () => { ]), "routed/model")); const done = frames.find(f => f.event === "response.output_item.done" && (f.data.item as Record)?.type === "message"); + const searchDone = frames.find(f => f.event === "response.output_item.done" + && (f.data.item as Record)?.type === "web_search_call"); + expect((searchDone!.data.item as Record).sources).toEqual([ + { url: "https://nodejs.org", title: "Node.js" }, + ]); const item = done!.data.item as Record; const part = (item.content as Record[])[0]; expect(part.annotations).toEqual([{ @@ -1045,12 +1052,79 @@ describe("Responses bridge web_search_call native item", () => { { type: "done" }, ], "routed/model"); const output = json.output as Record[]; + expect(output.find(item => item.type === "web_search_call")?.sources).toEqual([ + { url: "https://nodejs.org", title: "Node.js" }, + ]); const message = output.find(item => item.type === "message") as Record; const part = (message.content as Record[])[0]; expect(part.annotations).toEqual([{ type: "url_citation", url: "https://nodejs.org", title: "Node.js", start_index: 0, end_index: 0, }]); }); + + test("unsafe and oversized search sources are absent from cells and annotations", async () => { + const sources = [ + { url: "javascript:alert(1)", title: "unsafe" }, + { url: "https://user:pass@credential.test/private" }, + { url: "https://control.test/path\u0000" }, + { url: "https://safe.test/docs", title: "Safe docs" }, + { url: "https://title.test", title: "bad\u0001title" }, + { url: "https://safe.test/docs", title: "duplicate" }, + ...Array.from({ length: 25 }, (_, index) => ({ url: `https://safe.test/${index}` })), + ]; + const events: AdapterEvent[] = [ + { type: "web_search_call_begin", id: "ws_safe" }, + { type: "web_search_call_end", id: "ws_safe", queries: ["docs"], sources }, + { type: "text_delta", text: "answer" }, + { type: "done" }, + ]; + + const frames = await collectSse(bridgeToResponsesSSE(replay(events), "routed/model")); + const streamingCell = frames.find(f => f.event === "response.output_item.done" + && (f.data.item as Record)?.type === "web_search_call")!.data.item as Record; + const streamingSources = streamingCell.sources as Record[]; + expect(streamingSources).toHaveLength(20); + expect(streamingSources.slice(0, 2)).toEqual([ + { url: "https://safe.test/docs", title: "Safe docs" }, + { url: "https://title.test" }, + ]); + expect(streamingSources.some(source => String(source.url).includes("credential"))).toBe(false); + + const streamingMessage = frames.find(f => f.event === "response.output_item.done" + && (f.data.item as Record)?.type === "message")!.data.item as Record; + const streamingAnnotations = (streamingMessage.content as Record[])[0].annotations as Record[]; + expect(streamingAnnotations.map(({ url, title }) => ({ url, ...(title ? { title } : {}) }))) + .toEqual(streamingSources); + + const json = buildResponseJSON(events, "routed/model"); + const output = json.output as Record[]; + const batchCell = output.find(item => item.type === "web_search_call")!; + expect(batchCell.sources).toEqual(streamingSources); + const batchMessage = output.find(item => item.type === "message")!; + const batchAnnotations = (batchMessage.content as Record[])[0].annotations as Record[]; + expect(batchAnnotations).toEqual(streamingAnnotations); + }); + + test("non-streaming citation transfer releases its temporary source ownership", () => { + const events: AdapterEvent[] = [ + { type: "web_search_call_begin", id: "ws_budget" }, + { type: "web_search_call_end", id: "ws_budget", queries: ["docs"], sources: [ + { url: "https://safe.test/docs", title: "Safe docs" }, + ] }, + { type: "text_delta", text: "answer" }, + { type: "done" }, + ]; + const budget = createTranslatorBudget(); + try { + retainTranslatedEventBatch(events, budget); + const json = buildResponseJSON(events, "routed/model", { translatorBudget: budget }); + const output = json.output as Record[]; + const outputBytes = output.reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item)), 0); + expect(budget.snapshot().currentBytes).toBe(outputBytes); + } finally { + budget.dispose(); + } + }); }); describe("Responses bridge stopReason threading (issue #246)", () => { diff --git a/tests/web-search-parse.test.ts b/tests/web-search-parse.test.ts index 13930286c3..f294d581c8 100644 --- a/tests/web-search-parse.test.ts +++ b/tests/web-search-parse.test.ts @@ -164,6 +164,36 @@ describe("parseSidecarSSE trailing Sources block", () => { ]); }); + test("sanitizes structured and trailing sources before returning them", async () => { + const text = "Answer.\n\nSources:\n" + + "- Credential URL: https://user:pass@private.test/path\n" + + "- Safe trailing URL: https://safe.test/trailing"; + const res = sse([ + { type: "response.completed", response: { output: [{ type: "message", content: [{ + type: "output_text", + annotations: [ + { type: "url_citation", url: "javascript:alert(1)", title: "unsafe" }, + { type: "url_citation", url: "https://safe.test/structured", title: "bad\u0001title" }, + ], + text, + }] }] } }, + ]); + + const out = await parseSidecarSSE(res); + expect(out.sources).toEqual([ + { url: "https://safe.test/structured" }, + { url: "https://safe.test/trailing", title: "Safe trailing URL" }, + ]); + expect(out.text).toBe("Answer."); + }); + + test("strips a recognized Sources block even when every citation is rejected", async () => { + const out = await parseCompletedText( + "Answer.\n\nSources:\n- Credential URL: https://user:pass@private.test/path", + ); + expect(out).toEqual({ text: "Answer.", sources: [] }); + }); + test("no Sources block leaves text and sources untouched", async () => { const text = "Just an answer mentioning https://example.com inline, no sources section."; const res = sse([ diff --git a/tests/web-search-sources.test.ts b/tests/web-search-sources.test.ts new file mode 100644 index 0000000000..cb81d71fd4 --- /dev/null +++ b/tests/web-search-sources.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_WEB_SEARCH_SOURCE_BYTES, + MAX_WEB_SEARCH_SOURCES, + MAX_WEB_SEARCH_TITLE_BYTES, + MAX_WEB_SEARCH_URL_BYTES, + appendSafeWebSearchSource, + safeWebSearchSources, +} from "../src/web-search/sources"; + +const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength; + +describe("web-search citation source sanitization", () => { + test("preserves ordinary HTTP(S) citations verbatim and deduplicates exact URLs", () => { + const sources = safeWebSearchSources([ + { url: "https://docs.example.test/a?q=one#two", title: "Docs" }, + { url: "http://example.test/plain" }, + { url: "https://docs.example.test/a?q=one#two", title: "Duplicate" }, + ]); + + expect(sources).toEqual([ + { url: "https://docs.example.test/a?q=one#two", title: "Docs" }, + { url: "http://example.test/plain" }, + ]); + }); + + test("rejects malformed, credential-bearing, non-HTTP, control, and padded URLs", () => { + expect(safeWebSearchSources([ + null, + { url: 42 }, + { url: "javascript:alert(1)" }, + { url: "data:text/html,unsafe" }, + { url: "https://user:pass@example.test/private" }, + { url: "https://control.test/path\u0000" }, + { url: " https://padded.test" }, + { url: "https://safe.test" }, + ])).toEqual([{ url: "https://safe.test" }]); + expect(safeWebSearchSources({ url: "https://not-an-array.test" })).toEqual([]); + }); + + test("keeps a safe URL but omits an invalid optional title", () => { + const exactUnicodeTitle = "😀".repeat(MAX_WEB_SEARCH_TITLE_BYTES / 4); + expect(byteLength(exactUnicodeTitle)).toBe(MAX_WEB_SEARCH_TITLE_BYTES); + expect(safeWebSearchSources([ + { url: "https://empty-title.test", title: "" }, + { url: "https://blank-title.test", title: " " }, + { url: "https://control-title.test", title: "bad\u0001title" }, + { url: "https://typed-title.test", title: 42 }, + { url: "https://large-title.test", title: `${exactUnicodeTitle}😀` }, + { url: "https://exact-title.test", title: exactUnicodeTitle }, + ])).toEqual([ + { url: "https://empty-title.test" }, + { url: "https://blank-title.test" }, + { url: "https://control-title.test" }, + { url: "https://typed-title.test" }, + { url: "https://large-title.test" }, + { url: "https://exact-title.test", title: exactUnicodeTitle }, + ]); + }); + + test("enforces the URL byte cap exactly", () => { + const prefix = "https://bytes.test/"; + const exact = `${prefix}${"a".repeat(MAX_WEB_SEARCH_URL_BYTES - byteLength(prefix))}`; + const sources: { url: string }[] = []; + expect(byteLength(exact)).toBe(MAX_WEB_SEARCH_URL_BYTES); + expect(appendSafeWebSearchSource(sources, { url: exact })).toBe(true); + expect(appendSafeWebSearchSource([], { url: `${exact}a` })).toBe(false); + }); + + test("enforces count and aggregate serialized-byte budgets", () => { + const countBounded = safeWebSearchSources( + Array.from({ length: MAX_WEB_SEARCH_SOURCES + 5 }, (_, index) => ({ + url: `https://count.test/${index}`, + })), + ); + expect(countBounded).toHaveLength(MAX_WEB_SEARCH_SOURCES); + + const aggregateBounded = safeWebSearchSources( + Array.from({ length: MAX_WEB_SEARCH_SOURCES }, (_, index) => ({ + url: `https://aggregate.test/${index}/${"a".repeat(1_000)}`, + })), + ); + const serializedBytes = aggregateBounded.reduce( + (sum, source) => sum + byteLength(JSON.stringify(source)), + 0, + ); + expect(aggregateBounded.length).toBeLessThan(MAX_WEB_SEARCH_SOURCES); + expect(serializedBytes).toBeLessThanOrEqual(MAX_WEB_SEARCH_SOURCE_BYTES); + }); +}); From 6c41388c4ac1a520d0e32f4558bb7b4c6060d29d Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 04:24:08 +0900 Subject: [PATCH 24/67] docs(clients): drop the stale loopback enumeration from four locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ja, ko, ru and zh-cn each restated the loopback-only set as "Pi, OMP, Kimi and DSH", which was already wrong for mcode and zcode and became wrong for prime with this PR. English and the other three locales carry a generic statement instead — configs hold either a documented environment reference or a non-secret loopback placeholder — so these four now match it rather than growing a list that has to be edited every time a client is added. The Gajae sentence in the same paragraph enumerated the same set as a comparison; it now simply says the generated Gajae integration remains loopback-only, which is what the English text says. --- docs-site/src/content/docs/ja/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ko/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ru/reference/cli/agents.md | 9 ++++----- docs-site/src/content/docs/zh-cn/reference/cli/agents.md | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index d5407a61fb..dcdccb529b 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -167,7 +167,7 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex `ocx export` は実際のクライアント設定を書き込むことはありません。宛先は手動でマージできるように出力されます。`--out` は、`--force` なしで既存のファイルを上書きすることを拒否します。これは、設定を置き換えると、その中にすでに含まれている他のプロバイダー、エージェント、および MCP エントリが破壊されるためです。 ::: -キーはシリアル化されません。opencode、Hermes、OpenClaw、Gajae の設定には環境参照のみが含まれるためシークレットは環境内に残り、Pi、OMP、Kimi、DSH の設定には認証情報ではなくループバック用プレースホルダーが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。プロキシがループバックを超えてバインドする場合は、対応する `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY`、または `OPENCODEX_OPENCLAW_API_KEY` を設定します。`OPENCODEX_GAJAE_API_KEY` は Gajae の provider 認証値を環境から渡しますが、remote admission header は送れないため、生成される Gajae 統合は Pi、OMP、Kimi、DSH と同様にループバック専用です。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。 +キーはシリアル化されません。生成される設定には、文書化された環境参照か、秘密ではないループバック用プレースホルダーのいずれかが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。プロキシがループバックを超えてバインドする場合は、対応する `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY`、または `OPENCODEX_OPENCLAW_API_KEY` を設定します。`OPENCODEX_GAJAE_API_KEY` は Gajae の provider 認証値を環境から渡しますが、remote admission header は送れないため、生成される Gajae 統合はループバック専用のままです。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。 同じペイロードが `GET /api/client-config` によって提供され、ダッシュボードの [API] タブにレンダリングされるため、CLI、API、および GUI は同じバイトを使用します。 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 67532f93ef..e6d3dd36ec 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -173,7 +173,7 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 `ocx export`는 실제 client config를 절대 쓰지 않습니다. 대상 경로는 손으로 병합하라고 출력되며, `--out`은 `--force` 없이 기존 파일을 덮어쓰지 않습니다. config를 바꾸어 덮어쓰면 이미 들어 있던 다른 provider, agent, MCP entry가 사라지기 때문입니다. ::: -어떤 key도 직렬화되지 않습니다. opencode, Hermes, OpenClaw, Gajae config에는 env reference만 들어가므로 secret은 환경 변수에 남고, Pi, OMP, Kimi, DSH config에는 인증 정보가 아니라 loopback placeholder가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. proxy가 loopback을 넘어 바인딩할 때는 해당하는 `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY`, `OPENCODEX_OPENCLAW_API_KEY`를 설정하십시오. `OPENCODEX_GAJAE_API_KEY`는 Gajae provider 인증 값을 환경에서 전달하지만 remote admission header를 보낼 수는 없으므로, 생성되는 Gajae 통합은 Pi, OMP, Kimi, DSH와 마찬가지로 loopback 전용입니다. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다. +어떤 key도 직렬화되지 않습니다. 생성되는 config에는 문서화된 env reference 또는 비밀이 아닌 loopback placeholder 중 하나가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. proxy가 loopback을 넘어 바인딩할 때는 해당하는 `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY`, `OPENCODEX_OPENCLAW_API_KEY`를 설정하십시오. `OPENCODEX_GAJAE_API_KEY`는 Gajae provider 인증 값을 환경에서 전달하지만 remote admission header를 보낼 수는 없으므로, 생성되는 Gajae 통합은 loopback 전용으로 남습니다. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다. 같은 payload는 `GET /api/client-config`로 제공되고 dashboard의 API 탭에도 렌더링되므로, CLI, API, GUI가 모두 같은 바이트를 사용합니다. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 8015f24122..900f495fdd 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -207,14 +207,13 @@ Pi не требует переменной окружения и несёт л MCP-записи. ::: -Никакой ключ никогда не сериализуется. Конфиги opencode, Hermes, OpenClaw и Gajae несут только -env-reference, так что секрет остаётся в вашем окружении, а конфиги Pi, OMP, Kimi и DSH несут -loopback-заглушку вместо учётных данных. Loopback-прокси (`127.0.0.1`, по умолчанию) вообще не +Никакой ключ никогда не сериализуется. Сгенерированные конфиги несут либо документированную +env-reference, либо несекретную loopback-заглушку. Loopback-прокси (`127.0.0.1`, по умолчанию) вообще не требует admission key. Если прокси слушает не на loopback, задайте соответствующую переменную `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY` или `OPENCODEX_OPENCLAW_API_KEY`. `OPENCODEX_GAJAE_API_KEY` передаёт provider credential Gajae через окружение, но не позволяет -отправить remote admission header, поэтому сгенерированная интеграция Gajae, как и Pi, OMP, Kimi и -DSH, работает только через loopback. Как выдаются admission key, описано в +отправить remote admission header, поэтому сгенерированная интеграция Gajae +работает только через loopback. Как выдаются admission key, описано в [Удалённом доступе](/reference/configuration/#remote-access). Ключи upstream-провайдеров — это совсем отдельная история и настраиваются в [Провайдерах](/guides/providers/). diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index d509d3504b..a24fa13171 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -174,7 +174,7 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi `ocx export` 从不写入你的真实客户端配置。该命令只会打印目标路径供你手动合并,而 `--out` 在没有 `--force` 的情况下拒绝覆盖已有文件,因为替换配置会破坏其中已有的其他 providers、agents 和 MCP 条目。 ::: -任何密钥都不会被序列化。opencode、Hermes、OpenClaw 和 Gajae 配置里只包含环境引用,因此密钥仍保留在你的环境中;Pi、OMP、Kimi 和 DSH 配置里携带的是环回占位值而不是任何凭据。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。当代理绑定到环回地址之外时,请设置对应的 `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY` 或 `OPENCODEX_OPENCLAW_API_KEY`。`OPENCODEX_GAJAE_API_KEY` 只会从环境中提供 Gajae provider 凭据,不能发送远程准入 header,因此生成的 Gajae 集成仍与 Pi、OMP、Kimi 和 DSH 一样仅支持环回。关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。 +任何密钥都不会被序列化。生成的配置里携带的要么是有文档记录的环境引用,要么是非机密的环回占位值。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。当代理绑定到环回地址之外时,请设置对应的 `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY` 或 `OPENCODEX_OPENCLAW_API_KEY`。`OPENCODEX_GAJAE_API_KEY` 只会从环境中提供 Gajae provider 凭据,不能发送远程准入 header,因此生成的 Gajae 集成仍仅支持环回。关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。 同一份负载会通过 `GET /api/client-config` 提供,并在仪表盘的 API 选项卡中渲染,因此 CLI、API 和 GUI 使用的是同一字节内容。 From 9a345f409ad23e1e2db792b7787d496c6de6ef8f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:35:17 +0900 Subject: [PATCH 25/67] fix(web-search): close citation review gaps --- src/bridge.ts | 21 +++++++++++++++++---- src/web-search/parse.ts | 15 ++++++++++++--- tests/bridge.test.ts | 29 +++++++++++++++++++++++++++++ tests/web-search-parse.test.ts | 18 ++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/bridge.ts b/src/bridge.ts index 8a85f57896..82c73a0bee 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -527,17 +527,22 @@ export function bridgeToResponsesSSE( // url_citation annotations on that message (the desktop app's Sources chip), then cleared so // they bind to exactly one message. Deduped by URL across multiple searches in the turn. let pendingWebSources: { url: string; title?: string }[] = []; + let pendingWebSourceBytes = 0; + const releasePendingWebSources = () => { + if (pendingWebSources.length === 0) return; + pendingWebSources = []; + budget?.releaseRetained(pendingWebSourceBytes, { kind: "tool_search_sources" }); + pendingWebSourceBytes = 0; + }; const takeWebAnnotations = (): { type: string; url: string; title?: string; start_index: number; end_index: number }[] => { if (pendingWebSources.length === 0) return []; const anns = pendingWebSources.map(s => ({ type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0, })); - const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0); const annotationBytes = bytesOf(JSON.stringify(anns)); const reservation = budget?.reserveTransient(annotationBytes, { kind: "retained_collectors" }); - pendingWebSources = []; reservation?.commitRetained(); - budget?.releaseRetained(sourceBytes, { kind: "tool_search_sources" }); + releasePendingWebSources(); return anns; }; @@ -793,6 +798,7 @@ export function bridgeToResponsesSSE( handlingTranslatorOverflow = true; abortCurrentToolCallForTranslatorOverflow(); currentWebSearch = null; + releasePendingWebSources(); const failure = adapterFailureFromEvent({ type: "error", status: 502, @@ -1164,7 +1170,7 @@ export function bridgeToResponsesSSE( if (safeSources.length > 0) { for (const source of safeSources) { if (appendSafeWebSearchSource(pendingWebSources, source)) { - chargeValue(source, "tool_search_sources"); + pendingWebSourceBytes += chargeValue(source, "tool_search_sources"); } } } @@ -1177,6 +1183,7 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) closeCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("completed", []); + releasePendingWebSources(); // Redacted-only turns (or hidden thinking without a trailing signature event) still // need their envelope-only reasoning item so the blocks replay next turn. flushHiddenReasoningEnvelope(); @@ -1240,6 +1247,7 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); + releasePendingWebSources(); flushHiddenReasoningEnvelope(); options?.onUsage?.(event.usage); await awaitThoughtSignatureDurability(); @@ -1269,6 +1277,7 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); + releasePendingWebSources(); const failure = adapterFailureFromEvent(event); if (event.usage) options?.onUsage?.(event.usage); await awaitThoughtSignatureDurability(); @@ -1303,6 +1312,7 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); + releasePendingWebSources(); emit("response.failed", { response: { ...responseSnapshot("failed", finishedItems), @@ -1332,6 +1342,7 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); + releasePendingWebSources(); options?.onUsage?.(undefined); await awaitThoughtSignatureDurability(); emit("response.incomplete", { @@ -1374,6 +1385,7 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); + releasePendingWebSources(); // #1926 gap 2 residual: this beat callback is synchronous, so the durability // barrier is not awaited on the stall-timeout kill path. The in-memory store is // already updated; only a crash between here and the queued write loses it, @@ -1426,6 +1438,7 @@ export function bridgeToResponsesSSE( clearOwnedWatchdog(); if (beat !== undefined) clearBeatInterval(beat); cancelUpstreamOnce(); + releasePendingWebSources(); disposeOwnedBudget(); }, }); diff --git a/src/web-search/parse.ts b/src/web-search/parse.ts index 0293593105..757c309f3e 100644 --- a/src/web-search/parse.ts +++ b/src/web-search/parse.ts @@ -55,7 +55,11 @@ function collectAnnotation(ann: AnnotationLike | undefined, sources: WebSearchSo * (`### Sources:`, `**Sources**`), a title line whose URL sits on the FOLLOWING line, and trailing * URL punctuation (`;`, `,`, `)`, `]`, `.`). Prose that follows the source list is preserved. */ -const URL_RE = /https?:\/\/[^\s<>()\[\]]+/; +const URL_RE = /https?:\/\/[^\s<>()\[\]]+/i; +// Recognize URI-like candidates separately from the HTTP(S)-only acceptance boundary. A rejected +// citation (for example `javascript:`) still belongs to the trailing Sources block and must not be +// left behind as ordinary assistant text. +const URI_LIKE_RE = /[a-z][a-z0-9+.-]*:[^\s<>()\[\]]+/i; // A "Sources:" / "Source:" header, allowing markdown prefixes (#, *, -, >) and bold/italic wrappers. const SOURCES_WORD_RE = /^sources?/i; @@ -137,10 +141,11 @@ function extractTrailingSources(text: string): { text: string; sources: WebSearc const raw = lines[i].trim(); if (raw === "") { // Blank line between header and first entry is fine; a blank AFTER entries ends the list. - if (sources.length > 0 || pendingTitle !== null) break; + if (consumedSourceLine || pendingTitle !== null) break; continue; } - const m = raw.match(URL_RE); + const httpMatch = raw.match(URL_RE); + const m = httpMatch ?? raw.match(URI_LIKE_RE); if (!m) { // A list-ish line with no URL may be a title whose URL is on the next line. Only treat it as a // pending title when it looks like a list item; otherwise it's prose → stop. @@ -149,6 +154,10 @@ function extractTrailingSources(text: string): { text: string; sources: WebSearc } break; } + // A non-HTTP URI embedded in prose is not sufficient to classify the line as a citation. + // Accept it as a consumed source line only when it is a list item or the whole line starts with + // the URI candidate, matching the existing bare-URL grammar. + if (!httpMatch && !/^[-*>\d.)]/.test(raw) && m.index !== 0) break; const url = cleanUrl(m[0]); if (!url) { break; } consumedSourceLine = true; diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 99a6abb7fc..22a3324580 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -1125,6 +1125,35 @@ describe("Responses bridge web_search_call native item", () => { budget.dispose(); } }); + + test("streaming source-only completion releases unconsumed citation ownership", async () => { + const events: AdapterEvent[] = [ + { type: "web_search_call_begin", id: "ws_source_only" }, + { type: "web_search_call_end", id: "ws_source_only", queries: ["docs"], sources: [ + { url: "https://safe.test/docs", title: "Safe docs" }, + ] }, + { type: "done" }, + ]; + const budget = createTranslatorBudget(); + try { + const frames = await collectSse(bridgeToResponsesSSE( + replay(events), + "routed/model", + undefined, + undefined, + undefined, + undefined, + undefined, + { translatorBudget: budget }, + )); + const terminal = frames.find(frame => frame.event === "response.completed")!; + const output = (terminal.data.response as Record).output as Record[]; + expect(output.map(item => item.type)).toEqual(["web_search_call"]); + expect(budget.snapshot().currentBytes).toBe(Buffer.byteLength(JSON.stringify(output[0]))); + } finally { + budget.dispose(); + } + }); }); describe("Responses bridge stopReason threading (issue #246)", () => { diff --git a/tests/web-search-parse.test.ts b/tests/web-search-parse.test.ts index f294d581c8..a64582942c 100644 --- a/tests/web-search-parse.test.ts +++ b/tests/web-search-parse.test.ts @@ -194,6 +194,24 @@ describe("parseSidecarSSE trailing Sources block", () => { expect(out).toEqual({ text: "Answer.", sources: [] }); }); + test("strips a trailing non-HTTP URI citation after rejecting it", async () => { + const out = await parseCompletedText( + "Answer.\n\nSources:\n- Unsafe: javascript:alert(1)", + ); + expect(out).toEqual({ text: "Answer.", sources: [] }); + }); + + test("preserves prose after a blank line following a rejected citation", async () => { + const out = await parseCompletedText( + "Answer.\n\nSources:\n- Credential URL: https://user:pass@private.test/path\n\n" + + "For details visit https://good.test/guide", + ); + expect(out).toEqual({ + text: "Answer.\n\nFor details visit https://good.test/guide", + sources: [], + }); + }); + test("no Sources block leaves text and sources untouched", async () => { const text = "Just an answer mentioning https://example.com inline, no sources section."; const res = sse([ From deacd53f79e1ff4eaea6baa28a4d849809e8d673 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:51:53 +0900 Subject: [PATCH 26/67] fix(cursor): catch EOF terminal budget overflow --- src/adapters/cursor/live-transport.ts | 2 +- tests/cursor-hardening.test.ts | 54 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 5775de025e..59a19475f7 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1129,7 +1129,7 @@ class LiveCursorTransport implements CursorTransport { } releaseBacklogLease(); settler.settleFinish(); - }, (err) => { + }).catch((err) => { failAndClear(err instanceof Error ? err : new Error(String(err))); }); }; diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 1de86d9738..6385fae22a 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -623,6 +623,60 @@ describe("Cursor live transport unexpected EOF", () => { expect(seenSessionId).toBe("cursor_from_gjc_session"); }); + test("settles as a failure when clean EOF synthesis exceeds the transport budget", async () => { + const textFrame = encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { + case: "textDelta", + value: create(TextDeltaUpdateSchema, { text: "x" }), + }, + }), + }, + }))); + const connectEnd = encodeConnectFrame(new TextEncoder().encode("{}"), { + flags: CONNECT_FLAG_END_STREAM, + }); + + await withDiscoveryServer(stream => { + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.end(Buffer.from(new Uint8Array([ + ...Array.from({ length: 37 }, () => [...textFrame]).flat(), + ...connectEnd, + ]))); + }, async baseUrl => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 1_000 }); + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl, apiKey: "test-token" }, + translatorBudget: budget, + firstFrameTimeoutMs: 2_000, + }); + const iterator = transport.run({ + modelId: "composer-2", + conversationId: "cursor_clean_eof_budget_test", + system: [], + messages: [{ role: "user", content: "hello" }], + })[Symbol.asyncIterator](); + let failure: Error | undefined; + try { + expect(await iterator.next()).toMatchObject({ value: { type: "text" } }); + await Bun.sleep(20); + while (!(await iterator.next()).done) {} + } catch (err) { + failure = err instanceof Error ? err : new Error(String(err)); + } finally { + await transport.close?.(); + } + + expect(failure).toMatchObject({ + name: "TranslatorBudgetExceededError", + code: "translation_buffer_limit", + }); + expect(budget.snapshot().currentBytes).toBe(0); + }); + }); + test("synthesizes done after createPlanRequestQuery text on clean Connect EOF", async () => { const planFrame = encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, { message: { From 4dd570b508b7588b9dd17192ccc5b7d9f98c1e37 Mon Sep 17 00:00:00 2001 From: snowyukitty Date: Fri, 21 Aug 2026 04:39:10 +0900 Subject: [PATCH 27/67] fix(google): validate candidate, content and part containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1332 made a malformed nested candidate terminal on the streaming path, on the rule that a claimed response is not padding. Three containers in the same adapter never got that rule, and each fails a different way. `content.parts` is read straight into a `for...of` in both parsers, so a container that is not an array of objects escapes as a raw `TypeError` — `{} is not iterable`, or `null is not an object` on `part.thoughtSignature`. The buffered `parseResponse` call site in `src/server/responses/core.ts` wraps the call in try/finally with no catch, so the exception leaves the turn unstructured. A `parts: "txt"` is worse than a crash: a string is iterable, so it is consumed character by character and the turn completes empty. `candidate.content` was never inspected at all. `content?.parts` reads `undefined` from any non-record, so a candidate shaped `content: [{ parts: [...] }]` — a plausible shape from anything that mirrors the request's repeated `contents` — dropped its own text and reported success. The buffered parser also never checked `candidates[0]`. `[null]`, `[5]` and `["x"]` all reached a bare `done`, reporting a claimed-but-malformed candidate to the caller as a successful empty turn — the opposite of what the same adapter does when streaming. A non-array `candidates` was not checked either: `"abc"` passed the emptiness check because its `length` is 3, and `{}`/`5` were reported as an absent candidate list rather than a malformed one. All three now terminate through the existing adapter error channel, carrying the structural reason, the part index and the offending value's type — never its contents. The two candidate errors #1332 already emitted get the same treatment, so one diagnostic type covers every rung; the wording #1332 chose is kept as the message prefix, so an existing log grep still matches. Going the other way, the streaming parser was treating a `null` `candidates` container as corruption and terminating on it. That is the #1219 failure mode one rung in: a `{"candidates":null}` frame between a content delta and the finish chunk killed a turn whose answer had already fully arrived, while the same stream with the key absent or the array empty completed. `null` now joins absence, as it already does on the buffered side. Absence stays legal, including the encodings of it that are not records: an absent, `null` or empty `parts`; an absent, `null` or empty-array `content` (`[]` is how a JSON writer with no distinct empty-object form spells an empty object, and it already means "no parts"); and an absent, `null` or empty `candidates` — while a non-null, non-array `candidates` stays terminal. --- .../src/content/docs/reference/adapters.md | 9 + src/adapters/google.ts | 157 ++++++++++++-- tests/google-hardening.test.ts | 193 +++++++++++++++++- 3 files changed, 345 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 0895ad07e8..c97bd7bae4 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -116,6 +116,15 @@ of the HTTP retry loop. opaque `thoughtSignature` values so tool-result continuations retain Gemini reasoning continuity. The signature cache is snapshotted to the config directory, so continuations also survive proxy restarts. +- **Malformed response shapes fail closed.** A claimed candidate, its `content`, or its + `content.parts` that is not the documented container terminates the turn with a + `google response contained invalid …` error naming the structural reason and the offending + value's type — never its contents. Absence is handled separately from corruption: an absent, + `null` or empty `content` or `parts` still completes the turn normally, a streaming chunk whose + `candidates` is absent, `null` or empty is skipped so the turn completes on a later terminal + frame, and a buffered response that carries no candidate at all returns + `google response contained no candidates`. A root `data: null` keepalive frame is still skipped as + padding. - **Inline image output:** when the model is one of the explicit image-capable chat IDs (`gemini-3.1-flash-image`, `gemini-2.0-flash-preview-image-generation`, or `gemini-3-pro-image-preview`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 5ce985dd4b..a24cb33b39 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -372,6 +372,79 @@ function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined : { type: "text_delta", text: part.text }; } +interface InvalidGoogleShapeDiagnostic { + reason: + | "candidates_not_array" + | "candidate_not_object" + | "content_not_object" + | "parts_not_array" + | "part_not_object"; + partIndex?: number; + valueType: string; +} + +function isGoogleRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function googleStructuralValueType(value: unknown): string { + if (value === null) return "null"; + return Array.isArray(value) ? "array" : typeof value; +} + +/** + * A candidate's `content` is claimed model output inside a well-formed frame, so it is governed by + * the #1332 nested-shape rule (fail closed) rather than #1240's root-frame padding rule (skip). + * + * Absence stays legal, and so does one encoding of it: an empty array is how a JSON writer with no + * distinct empty-object form spells an empty `content`, and it already behaves as "no parts". A + * NON-empty array is the opposite case — `content?.parts` silently reads `undefined` from it, so a + * candidate shaped `content: [{ parts: [...] }]` dropped its own text and completed as an empty + * turn. + */ +function diagnoseGoogleContent(content: unknown): InvalidGoogleShapeDiagnostic | undefined { + if (content === undefined || content === null || isGoogleRecord(content)) return undefined; + if (Array.isArray(content) && content.length === 0) return undefined; + return { reason: "content_not_object", valueType: googleStructuralValueType(content) }; +} + +/** + * `content.parts` sits one rung below the candidate guard added in #1332, and both parsers + * consumed it unchecked: `for (const part of {})` throws `{} is not iterable`, and a `[null]` + * element throws on `part.thoughtSignature`. A `parts` that is absent or `null` keeps its existing + * skip — only a present, non-null container is validated. + */ +function diagnoseGoogleParts(parts: unknown): InvalidGoogleShapeDiagnostic | undefined { + if (!Array.isArray(parts)) { + return { reason: "parts_not_array", valueType: googleStructuralValueType(parts) }; + } + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const part: unknown = parts[partIndex]; + if (!isGoogleRecord(part)) { + return { reason: "part_not_object", partIndex, valueType: googleStructuralValueType(part) }; + } + } + return undefined; +} + +function invalidGoogleShapeEvent( + diagnostic: InvalidGoogleShapeDiagnostic, +): Extract { + const at = diagnostic.partIndex !== undefined ? `; partIndex=${diagnostic.partIndex}` : ""; + // The subject names the rung that failed, so an operator reading a log can tell a broken + // candidate list from a well-formed candidate whose parts are broken. The candidate subject keeps + // the exact wording #1332 introduced as its prefix, so an existing grep still matches. + const subject = diagnostic.reason === "candidates_not_array" || diagnostic.reason === "candidate_not_object" + ? "candidates" + : diagnostic.reason === "content_not_object" + ? "content" + : "content parts"; + return { + type: "error", + message: `google response contained invalid ${subject} (${diagnostic.reason}${at}; valueType=${diagnostic.valueType})`, + }; +} + export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest // can stash the CCA model/session for parseStream's reasoning-replay observation. @@ -669,22 +742,34 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte sawTerminalSignal = true; } const rawCandidates = root.candidates; - if (rawCandidates === undefined) return "continue"; + // `null` is an absence encoding, not corruption, and terminating on it is the #1219 + // failure mode one rung in: a `{"candidates":null}` frame arriving between a content + // delta and the finish chunk killed a turn whose answer had already fully arrived. An + // absent key and an empty array are already skipped here; `null` joins them. A non-null + // non-array container is still claimed structure the parser cannot read, and stays + // terminal. + if (rawCandidates === undefined || rawCandidates === null) return "continue"; if (!Array.isArray(rawCandidates)) { - yield { type: "error", message: "google response contained invalid candidates" }; + yield invalidGoogleShapeEvent({ + reason: "candidates_not_array", + valueType: googleStructuralValueType(rawCandidates), + }); return "terminate"; } if (rawCandidates.length === 0) return "continue"; const rawCandidate = rawCandidates[0]; - if (rawCandidate === null || typeof rawCandidate !== "object" || Array.isArray(rawCandidate)) { + if (!isGoogleRecord(rawCandidate)) { // Unlike a root `data: null` keepalive, this is a claimed response candidate. Treat it // as terminal protocol corruption so the turn cannot complete after silently losing // a candidate or tool call (#1325). - yield { type: "error", message: "google response contained invalid candidates" }; + yield invalidGoogleShapeEvent({ + reason: "candidate_not_object", + valueType: googleStructuralValueType(rawCandidate), + }); return "terminate"; } const candidate = rawCandidate as { - content?: { parts?: unknown[] }; + content?: unknown; finishReason?: string; }; @@ -693,7 +778,24 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte sawTerminalSignal = true; } - const parts = candidate.content?.parts as GoogleResponsePart[] | undefined; + // One rung below the candidate guard above, same rule: this is claimed content, not + // padding, so it fails closed rather than being iterated or silently dropped (#1325). + const rawContent: unknown = candidate.content; + const invalidContent = diagnoseGoogleContent(rawContent); + if (invalidContent) { + yield invalidGoogleShapeEvent(invalidContent); + return "terminate"; + } + const rawParts: unknown = isGoogleRecord(rawContent) ? rawContent.parts : undefined; + let parts: GoogleResponsePart[] | undefined; + if (rawParts !== undefined && rawParts !== null) { + const invalidParts = diagnoseGoogleParts(rawParts); + if (invalidParts) { + yield invalidGoogleShapeEvent(invalidParts); + return "terminate"; + } + parts = rawParts as GoogleResponsePart[]; + } // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and // Antigravity use separate model namespaces so opaque provider state cannot cross routes. const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; @@ -924,23 +1026,52 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const events: AdapterEvent[] = []; - const candidates = json.candidates as { content?: { parts?: GoogleResponsePart[] }; finishReason?: string }[] | undefined; + const rawCandidates: unknown = json.candidates; + // Parity with the streaming path, which has rejected a non-array `candidates` since #1332. + // Buffered accepted `"abc"` outright (`"abc".length` is 3, so the emptiness check below + // passed and `candidates[0]` was the character `"a"`), and reported `{}`/`5` as an absent + // candidate list rather than a malformed one. + if (rawCandidates !== undefined && rawCandidates !== null && !Array.isArray(rawCandidates)) { + return finish([invalidGoogleShapeEvent({ + reason: "candidates_not_array", + valueType: googleStructuralValueType(rawCandidates), + })]); + } + const candidates = rawCandidates as { finishReason?: string }[] | undefined; if (!candidates?.length) { return finish([{ type: "error", message: "google response contained no candidates" }]); } + const rawCandidate: unknown = candidates[0]; + if (!isGoogleRecord(rawCandidate)) { + // The streaming parser already treats this as terminal protocol corruption (#1325/#1332). + // Buffered returned a bare `done`, so a claimed-but-malformed candidate was reported to + // the caller as a successful empty turn. + return finish([invalidGoogleShapeEvent({ + reason: "candidate_not_object", + valueType: googleStructuralValueType(rawCandidate), + })]); + } + const candidate = rawCandidate as { content?: unknown; finishReason?: string }; let toolCallsStarted = 0; const imageBudget = createImageBudget(); - if (candidates?.[0]?.content?.parts) { + const rawContent: unknown = candidate.content; + const invalidContent = diagnoseGoogleContent(rawContent); + if (invalidContent) return finish([invalidGoogleShapeEvent(invalidContent)]); + const rawParts: unknown = isGoogleRecord(rawContent) ? rawContent.parts : undefined; + if (rawParts !== undefined && rawParts !== null) { + const invalidParts = diagnoseGoogleParts(rawParts); + if (invalidParts) return finish([invalidGoogleShapeEvent(invalidParts)]); + const parts = rawParts as GoogleResponsePart[]; // Non-streaming Google-family response: observe thought signatures for the next turn, // using the same transport-scoped namespace as the streaming path. const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") && replayModel && replaySession) { - observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]); + observeAntigravityReplay(replayModel, replaySession, parts as unknown[]); } let pendingThoughtSig: string | undefined; - for (const part of candidates[0].content.parts) { + for (const part of parts) { const sig = part.thoughtSignature ?? part.thought_signature; if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingThoughtSig = sig; @@ -979,8 +1110,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call // (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done. if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist") - && isVertexTruncatedTurn(candidates?.[0]?.finishReason, toolCallsStarted)) { - return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]); + && isVertexTruncatedTurn(candidate.finishReason, toolCallsStarted)) { + return finish([{ type: "error", message: vertexTruncationErrorMessage(candidate.finishReason) }]); } const usage = json.usageMetadata as Record | undefined; @@ -988,7 +1119,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated // turn as completed — and, on a compaction turn, installs the half-written summary as // replacement history (#422). - const finishReason = candidates?.[0]?.finishReason as string | undefined; + const finishReason = candidate.finishReason as string | undefined; const stopReason = finishReason === "MAX_TOKENS" ? "max_tokens" : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "") diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 207dddd8bc..89d06a14c5 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -122,11 +122,202 @@ describe("google provider hardening", () => { expect(events).toEqual([{ type: "error", - message: "google response contained invalid candidates", + message: "google response contained invalid candidates (candidate_not_object; valueType=null)", }]); expect(events.some(event => event.type === "done")).toBe(false); }); + // `content.parts` sits one rung below the candidate guard above. It is claimed model output + // inside a well-formed frame, so it follows the same fail-closed rule rather than #1240's + // root-frame padding rule. Before this, each of these escaped as a raw TypeError. + const invalidPartsCases: [string, unknown, string][] = [ + ["an object container", {}, "google response contained invalid content parts (parts_not_array; valueType=object)"], + ["a number container", 5, "google response contained invalid content parts (parts_not_array; valueType=number)"], + ["a string container", "txt", "google response contained invalid content parts (parts_not_array; valueType=string)"], + ["a null element", [null], "google response contained invalid content parts (part_not_object; partIndex=0; valueType=null)"], + ["a number element", [5], "google response contained invalid content parts (part_not_object; partIndex=0; valueType=number)"], + ["an array element", [[]], "google response contained invalid content parts (part_not_object; partIndex=0; valueType=array)"], + ["a bad element after a good one", [{ text: "hi" }, null], "google response contained invalid content parts (part_not_object; partIndex=1; valueType=null)"], + ]; + + for (const [label, parts, message] of invalidPartsCases) { + test(`${label} in content.parts is a terminal stream error`, async () => { + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([ + { candidates: [{ content: { parts } }] }, + { candidates: [{ finishReason: "STOP" }] }, + ]), + )); + + expect(events).toEqual([{ type: "error", message }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test(`${label} in content.parts is a terminal non-streaming error`, async () => { + const events = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates: [{ content: { parts }, finishReason: "STOP" }] }), { status: 200 }), + ); + + expect(events).toEqual([{ type: "error", message }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + } + + // `content` itself has the same status as `parts`: claimed output the parser cannot read. The + // one tolerated non-record form is an empty array, which is how a JSON writer with no distinct + // empty-object form spells an empty `content`. A NON-empty array is where the payload used to + // disappear: `content?.parts` reads `undefined` from it, so the candidate completed empty. + const invalidContentCases: [string, unknown, string][] = [ + ["a number", 5, "google response contained invalid content (content_not_object; valueType=number)"], + ["a string", "txt", "google response contained invalid content (content_not_object; valueType=string)"], + ["a boolean", true, "google response contained invalid content (content_not_object; valueType=boolean)"], + ["a non-empty array holding the payload", [{ parts: [{ text: "lost" }] }], "google response contained invalid content (content_not_object; valueType=array)"], + ]; + + for (const [label, content, message] of invalidContentCases) { + test(`${label} as candidate content is a terminal stream error`, async () => { + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([ + { candidates: [{ content }] }, + { candidates: [{ finishReason: "STOP" }] }, + ]), + )); + + expect(events).toEqual([{ type: "error", message }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test(`${label} as candidate content is a terminal non-streaming error`, async () => { + const events = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates: [{ content, finishReason: "STOP" }] }), { status: 200 }), + ); + + expect(events).toEqual([{ type: "error", message }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + } + + test("a null candidates container mid-stream is absence, not corruption", async () => { + // The #1219 shape one rung in: before this, the frame between the content delta and the + // finish chunk terminated a turn whose answer had already fully arrived. + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([ + { candidates: [{ content: { parts: [{ text: "PONG" }] } }] }, + { candidates: null }, + { candidates: [{ finishReason: "STOP" }] }, + ]), + )); + + expect(events).toContainEqual({ type: "text_delta", text: "PONG" }); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(event => event.type === "error")).toBe(false); + }); + + test("a stream of nothing but null candidates still fails closed", async () => { + // Absence is not a terminal signal, so the truncation guard still owns this stream: skipping + // the frames must not turn a stream that never finished into a successful empty turn. + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([{ candidates: null }, { candidates: null }]), + )); + + expect(events.at(-1)).toEqual({ + type: "error", + message: "upstream stream ended without a terminal signal — possible truncation", + }); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test("a malformed nested candidate is a terminal non-streaming error too", async () => { + // The streaming parser has rejected these since #1332; the buffered parser returned a bare + // `done`, reporting a claimed-but-malformed candidate to the caller as a successful empty turn. + for (const [candidates, valueType] of [[[null], "null"], [[5], "number"], [["x"], "string"], [[[]], "array"]] as const) { + const events = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates }), { status: 200 }), + ); + + expect(events).toEqual([{ + type: "error", + message: `google response contained invalid candidates (candidate_not_object; valueType=${valueType})`, + }]); + } + }); + + test("a non-array candidates container is rejected rather than counted", async () => { + // `"abc".length` is 3, so the emptiness check passed and `candidates[0]` was the character + // `"a"`; `{}` and `5` were reported as an absent candidate list rather than a malformed one. + for (const [candidates, valueType] of [["abc", "string"], [{}, "object"], [5, "number"], [true, "boolean"]] as const) { + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([{ candidates }, { candidates: [{ finishReason: "STOP" }] }]), + )); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates }), { status: 200 }), + ); + + const expected = [{ + type: "error", + message: `google response contained invalid candidates (candidates_not_array; valueType=${valueType})`, + }]; + expect(streamEvents).toEqual(expected); + expect(responseEvents).toEqual(expected); + } + }); + + test("absent, null and empty containers stay legal on both paths", async () => { + // Absence is not corruption: a finish-only chunk, an explicit `null`, and an empty array are + // all ordinary shapes and must keep completing the turn. + for (const candidate of [ + { finishReason: "STOP" }, + { content: null, finishReason: "STOP" }, + { content: [], finishReason: "STOP" }, + { content: {}, finishReason: "STOP" }, + { content: { parts: null }, finishReason: "STOP" }, + { content: { parts: [] }, finishReason: "STOP" }, + ]) { + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([{ candidates: [candidate] }]), + )); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates: [candidate] }), { status: 200 }), + ); + + expect(streamEvents.some(event => event.type === "error")).toBe(false); + expect(streamEvents.at(-1)?.type).toBe("done"); + expect(responseEvents).toEqual([{ type: "done", usage: undefined }]); + } + }); + + test("an absent candidates list is still reported as absent, not malformed", async () => { + for (const body of [{}, { candidates: null }, { candidates: [] }]) { + const events = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify(body), { status: 200 }), + ); + + expect(events).toEqual([{ type: "error", message: "google response contained no candidates" }]); + } + }); + + test("well-formed parts still stream and buffer unchanged", async () => { + const payload = { + candidates: [{ + content: { parts: [{ text: "visible" }, { functionCall: { name: "lookup", args: { q: 1 } } }] }, + finishReason: "STOP", + }], + }; + + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream(sseResponse([payload]))); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify(payload), { status: 200 }), + ); + + for (const events of [streamEvents, responseEvents]) { + expect(events).toContainEqual({ type: "text_delta", text: "visible" }); + expect(events.some(event => event.type === "tool_call_start" && event.name === "lookup")).toBe(true); + expect(events).toContainEqual({ type: "tool_call_delta", arguments: JSON.stringify({ q: 1 }) }); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(event => event.type === "error")).toBe(false); + } + }); + test("EOF residual data frame without a trailing newline is parsed", async () => { const events = await collect(createGoogleAdapter(provider()).parseStream( new Response('data:{"candidates":[{"content":{"parts":[{"text":"final"}]},"finishReason":"STOP"}]}', { From 207f73302d16e7daa7b113b36f62e1faffd904da Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:55:56 +0900 Subject: [PATCH 28/67] fix(responses): bound retained tool-search item IDs --- src/server/responses-tool-search-repair.ts | 78 +++++++++-- tests/responses-tool-search-repair.test.ts | 148 ++++++++++++++++++++- 2 files changed, 211 insertions(+), 15 deletions(-) diff --git a/src/server/responses-tool-search-repair.ts b/src/server/responses-tool-search-repair.ts index 6c18708406..1116173531 100644 --- a/src/server/responses-tool-search-repair.ts +++ b/src/server/responses-tool-search-repair.ts @@ -1,5 +1,6 @@ import { isTranslatorBudgetExceededError, + TranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; import { @@ -24,6 +25,19 @@ type PendingArgumentBlock = { const MAX_PENDING_ARGUMENT_FRAMES = 256; const MAX_PENDING_ARGUMENT_BYTES = 1024 * 1024; +const MAX_CLASSIFIED_ITEM_IDS = 256; +const MAX_CLASSIFIED_ITEM_ID_BYTES = 256 * 1024; + +class ClassifiedItemIdCountExceededError extends Error { + readonly code = "translation_buffer_limit"; + readonly kind = "item_ids"; + readonly limitItems = MAX_CLASSIFIED_ITEM_IDS; + + constructor() { + super(`translator item_ids count exceeded ${MAX_CLASSIFIED_ITEM_IDS} items`); + this.name = "ClassifiedItemIdCountExceededError"; + } +} /** * Public Responses gateways stream a lowered search as a normal function lifecycle. Codex expects @@ -36,6 +50,7 @@ export function createRoutedToolSearchRestoreBlockRewrite( ): SseBlockRewrite { const routedItemIds = new Set(); const ordinaryItemIds = new Set(); + let classifiedItemIdBytes = 0; let pendingArguments: PendingArgumentBlock[] = []; let pendingArgumentBytes = 0; let passthrough = false; @@ -49,10 +64,45 @@ export function createRoutedToolSearchRestoreBlockRewrite( } pendingArguments = []; pendingArgumentBytes = 0; + if (classifiedItemIdBytes > 0) { + budget?.releaseRetained(classifiedItemIdBytes, { kind: "item_ids" }); + } + classifiedItemIdBytes = 0; routedItemIds.clear(); ordinaryItemIds.clear(); }; + const clearOrdinaryItemIds = (): void => { + let releasedBytes = 0; + for (const itemId of ordinaryItemIds) { + releasedBytes += Buffer.byteLength(JSON.stringify(itemId), "utf8"); + } + ordinaryItemIds.clear(); + classifiedItemIdBytes = Math.max(0, classifiedItemIdBytes - releasedBytes); + if (releasedBytes > 0) budget?.releaseRetained(releasedBytes, { kind: "item_ids" }); + }; + + const classifyItemId = (itemId: string, routed: boolean): void => { + const target = routed ? routedItemIds : ordinaryItemIds; + const previous = routed ? ordinaryItemIds : routedItemIds; + if (target.has(itemId)) return; + if (previous.delete(itemId)) { + target.add(itemId); + return; + } + + if (routedItemIds.size + ordinaryItemIds.size >= MAX_CLASSIFIED_ITEM_IDS) { + throw new ClassifiedItemIdCountExceededError(); + } + const retainedBytes = Buffer.byteLength(JSON.stringify(itemId), "utf8"); + if (classifiedItemIdBytes + retainedBytes > MAX_CLASSIFIED_ITEM_ID_BYTES) { + throw new TranslatorBudgetExceededError("item_ids", MAX_CLASSIFIED_ITEM_ID_BYTES); + } + budget?.chargeRetained(retainedBytes, { kind: "item_ids" }); + target.add(itemId); + classifiedItemIdBytes += retainedBytes; + }; + const retainPending = ( block: string, itemId: string | undefined, @@ -73,7 +123,7 @@ export function createRoutedToolSearchRestoreBlockRewrite( // that we forget what we already classified: an item restored to `tool_search_call` // upstream of here would otherwise start emitting `function_call_arguments.*` again and // the client would see a mixed private/public lifecycle for one call. - ordinaryItemIds.clear(); + clearOrdinaryItemIds(); return flushed; } if (retainedBytes > 0) { @@ -90,7 +140,7 @@ export function createRoutedToolSearchRestoreBlockRewrite( passthrough = true; // Same reasoning as the frame/byte overflow above: an already-restored routed item // must keep its frames suppressed even once buffering stops. - ordinaryItemIds.clear(); + clearOrdinaryItemIds(); return flushed; } } @@ -135,7 +185,11 @@ export function createRoutedToolSearchRestoreBlockRewrite( const rewrite: SseBlockRewrite = (block: string): readonly string[] => { if (disposed) return [block]; const payload = sseDataPayload(block); - if (payload === null || payload === "[DONE]") return [block]; + if (payload === null) return [block]; + if (payload === "[DONE]") { + releaseAll(); + return [block]; + } let parsed: unknown; try { parsed = JSON.parse(payload); @@ -145,11 +199,16 @@ export function createRoutedToolSearchRestoreBlockRewrite( if (!isPlainObject(parsed)) return [block]; const type = typeof parsed.type === "string" ? parsed.type : ""; + const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete"; // After overflow we stop BUFFERING unknown frames, but an item already restored to // `tool_search_call` must keep its public argument frames suppressed — otherwise the client // receives a private item followed by `function_call_arguments.*` for the same id, which is // exactly the mixed lifecycle this rewrite exists to prevent. Everything else passes through. if (passthrough) { + if (terminal) { + releaseAll(); + return [block]; + } const passthroughItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined; const isArgumentEvent = type === "response.function_call_arguments.delta" || type === "response.function_call_arguments.done"; @@ -169,22 +228,14 @@ export function createRoutedToolSearchRestoreBlockRewrite( ) { const itemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; const routed = names.has(parsed.item.name); - if (itemId) { - if (routed) { - routedItemIds.add(itemId); - ordinaryItemIds.delete(itemId); - } else { - ordinaryItemIds.add(itemId); - routedItemIds.delete(itemId); - } - } + if (itemId) classifyItemId(itemId, routed); const pending = takePending(itemId, outputIndex); const restored = routed ? restoreRoutedToolSearchCalls(parsed, names) : { value: parsed, changed: false }; const restoredBlock = restored.changed ? replaceSseDataPayload(block, JSON.stringify(restored.value)) : block; // Classification is retained past `output_item.done` for BOTH kinds, until the terminal - // event releases everything. + // event releases the bounded, budgeted state. // // `done` ends the item, not the id's relevance. Forgetting a ROUTED id let a trailing // `function_call_arguments.*` — which some upstreams emit after done — fall through to @@ -204,7 +255,6 @@ export function createRoutedToolSearchRestoreBlockRewrite( } if (argumentEvent && itemId && routedItemIds.has(itemId)) return []; - const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete"; if (!terminal) return [block]; const restored = restoreRoutedToolSearchCalls(parsed, names); releaseAll(); diff --git a/tests/responses-tool-search-repair.test.ts b/tests/responses-tool-search-repair.test.ts index a080574a3d..0ebf5bae15 100644 --- a/tests/responses-tool-search-repair.test.ts +++ b/tests/responses-tool-search-repair.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { isTranslatorBudgetExceededError } from "../src/lib/translator-budget"; import { restoreRoutedToolSearchCallsInJson, rewriteRoutedToolSearchForUpstream, @@ -237,7 +238,8 @@ describe("routed Responses tool-search compatibility", () => { arguments: {}, status: "in_progress", }); - expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().currentBytes) + .toBe(Buffer.byteLength(JSON.stringify("fc_search"), "utf8")); expect(rewrite(frame("response.function_call_arguments.done", { output_index: 0, @@ -628,4 +630,148 @@ describe("routed Responses tool-search compatibility", () => { })); expect(trailing).toHaveLength(1); }); + + test("bounds classified item ids by count and releases the retained charge", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedToolSearchRestoreBlockRewrite(new Set(["tool_search"]), budget); + let expectedRetainedBytes = 0; + + for (let index = 0; index < 256; index += 1) { + const itemId = `fc_${index}`; + expectedRetainedBytes += Buffer.byteLength(JSON.stringify(itemId), "utf8"); + rewrite(frame("response.output_item.done", { + output_index: index, + item: { type: "function_call", id: itemId, name: "tool_search", arguments: "{}" }, + })); + } + + const beforeOverflow = budget.snapshot().currentBytes; + expect(beforeOverflow).toBe(expectedRetainedBytes); + let overflow: unknown; + try { + rewrite(frame("response.output_item.done", { + output_index: 256, + item: { type: "function_call", id: "fc_overflow", name: "tool_search", arguments: "{}" }, + })); + } catch (error) { + overflow = error; + } + expect(isTranslatorBudgetExceededError(overflow)).toBe(true); + expect(overflow).toBeInstanceOf(Error); + expect((overflow as Error).message).toBe("translator item_ids count exceeded 256 items"); + expect(budget.snapshot().currentBytes).toBe(beforeOverflow); + + rewrite.dispose?.(); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("bounds classified item ids by serialized UTF-8 bytes", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedToolSearchRestoreBlockRewrite(new Set(["tool_search"]), budget); + const nearlyFullId = "x".repeat(256 * 1024 - 7); + rewrite(frame("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: nearlyFullId, name: "tool_search", arguments: "{}" }, + })); + expect(budget.snapshot().currentBytes).toBe(256 * 1024 - 5); + rewrite(frame("response.output_item.done", { + output_index: 1, + item: { type: "function_call", id: "界", name: "tool_search", arguments: "{}" }, + })); + expect(budget.snapshot().currentBytes).toBe(256 * 1024); + + let overflow: unknown; + try { + rewrite(frame("response.output_item.done", { + output_index: 2, + item: { + type: "function_call", + id: "y", + name: "tool_search", + arguments: "{}", + }, + })); + } catch (error) { + overflow = error; + } + if (!isTranslatorBudgetExceededError(overflow)) throw overflow; + expect(overflow.kind).toBe("item_ids"); + expect(overflow.limitBytes).toBe(256 * 1024); + expect(budget.snapshot().currentBytes).toBe(256 * 1024); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("deduplicates and reclassifies one item id without double charging", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedToolSearchRestoreBlockRewrite(new Set(["tool_search"]), budget); + const itemId = "fc_shared"; + const retainedBytes = Buffer.byteLength(JSON.stringify(itemId), "utf8"); + const emit = (event: "response.output_item.added" | "response.output_item.done", name: string) => { + rewrite(frame(event, { + output_index: 0, + item: { type: "function_call", id: itemId, name, arguments: "{}" }, + })); + expect(budget.snapshot().currentBytes).toBe(retainedBytes); + }; + + emit("response.output_item.added", "tool_search"); + emit("response.output_item.done", "tool_search"); + const argument = frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: itemId, + delta: "x", + }); + expect(rewrite(argument)).toEqual([]); + emit("response.output_item.added", "ordinary"); + expect(rewrite(argument)).toEqual([argument]); + emit("response.output_item.done", "tool_search"); + expect(rewrite(argument)).toEqual([]); + + rewrite(frame("response.completed", { + response: { id: "resp_done", status: "completed", output: [] }, + })); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("releases routed ids on terminal and DONE after pending passthrough", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 64 }); + const rewrite = createRoutedToolSearchRestoreBlockRewrite(new Set(["tool_search"]), budget); + const routedBytes = Buffer.byteLength(JSON.stringify("fc_search"), "utf8"); + const ordinaryBytes = Buffer.byteLength(JSON.stringify("fc_plain"), "utf8"); + rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_search", name: "tool_search", arguments: "{}" }, + })); + rewrite(frame("response.output_item.added", { + output_index: 1, + item: { type: "function_call", id: "fc_plain", name: "ordinary", arguments: "{}" }, + })); + expect(budget.snapshot().currentBytes).toBe(routedBytes + ordinaryBytes); + + const pending = frame("response.function_call_arguments.delta", { + output_index: 1, + item_id: "unknown", + delta: "x".repeat(256), + }); + expect(rewrite(pending)).toEqual([pending]); + expect(budget.snapshot().currentBytes).toBe(routedBytes); + rewrite(frame("response.completed", { + response: { id: "resp_done", status: "completed", output: [] }, + })); + expect(budget.snapshot().currentBytes).toBe(0); + + const doneBudget = createTestTranslatorBudget(); + const doneRewrite = createRoutedToolSearchRestoreBlockRewrite(new Set(["tool_search"]), doneBudget); + doneRewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_done", name: "tool_search", arguments: "{}" }, + })); + expect(doneBudget.snapshot().currentBytes).toBeGreaterThan(0); + expect(doneRewrite("data: [DONE]")).toEqual(["data: [DONE]"]); + expect(doneBudget.snapshot().currentBytes).toBe(0); + doneRewrite.dispose?.(); + expect(doneBudget.snapshot().currentBytes).toBe(0); + }); }); From 0866f2fa674f3e5009d99ce78bc031e3e9484971 Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 09:10:28 +0900 Subject: [PATCH 29/67] fix(clients): honor PI_CODING_AGENT_DIR for the pi client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pi` was the only client whose path resolution dropped its `env` argument: `EXPORT_CLIENTS.pi.destination` was `() => join(homedir(), ".pi", …)` while every other entry took `env =>`, and `INTEGRATION_CLIENTS.pi` discarded `_env` in both resolvers. The same variable therefore moved OMP's destination and left Pi's on the home default — no error, just a path reported as correct while naming a file Pi does not read. This is not a new claim about Pi's contract. `ompAgentDir` already reads `PI_CODING_AGENT_DIR` through `absoluteClientPath`, and the CLI reference already documents OMP's default profile as letting it win; only Pi's own resolver ignored it. Adds `piAgentDir` / `piConfigPath` in the shape the other clients use, refusing a relative override for the reason MCode's and ZCode's do, and routes the export destination and the integration `configPath` through them. `detectDir` is deliberately not routed through `piAgentDir` unconditionally. With no override the install signal stays `~/.pi`, as it has always been — narrowing it to `~/.pi/agent` would flip a user who has the former without the latter from installed to absent, which this change is not about. The existing detection test passes unmodified as a result. With an override there is no parent left to test, so the named directory becomes the signal. The `pi` row in the canonical-destination table now names the override, in English and the seven translations, matching how the other clients with overrides are documented. --- .../content/docs/fr/reference/cli/agents.md | 2 +- .../content/docs/ja/reference/cli/agents.md | 2 +- .../content/docs/ko/reference/cli/agents.md | 2 +- .../src/content/docs/reference/cli/agents.md | 2 +- .../content/docs/ru/reference/cli/agents.md | 2 +- .../content/docs/tr/reference/cli/agents.md | 2 +- .../docs/zh-cn/reference/cli/agents.md | 2 +- .../docs/zh-tw/reference/cli/agents.md | 2 +- src/clients/config-export.ts | 23 +++++- src/integrations/registry.ts | 16 +++- tests/pi-path-contract.test.ts | 73 +++++++++++++++++++ 11 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 tests/pi-path-contract.test.ts diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 6e8fca4411..f5e5806561 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -195,7 +195,7 @@ propres valeurs par défaut à ces lignes. | Client | Destination canonique | Télécharger le nom du fichier | Var.environnement | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME` gagne une fois défini) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | none — le bloc porte le littéral `opencodex-loopback` | +| `pi` | `~/.pi/agent/models.json` (`PI_CODING_AGENT_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `pi-models.json` | none — le bloc porte le littéral `opencodex-loopback` | | `omp` | `~/.omp/agent/models.yml` (`OMP_PROFILE` l'emporte sur `PI_PROFILE`, même lorsqu'il est vide ; les profils nommés utilisent le nom du répertoire `PI_CONFIG_DIR` relatif à la maison et ignorent `PI_CODING_AGENT_DIR`, tandis que le profil par défaut laisse `PI_CODING_AGENT_DIR` gagner) | `omp-models.yaml` | aucun — espace réservé de bouclage | | `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 7ea3a3deb1..62b8be0b2d 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -150,7 +150,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json |クライアント |正規の宛先 |ダウンロードファイル名 |環境変数 | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json` (設定すると `XDG_CONFIG_HOME` が勝ち) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | なし - ブロックにリテラル `opencodex-loopback` が入ります | +| `pi` | `~/.pi/agent/models.json` (`PI_CODING_AGENT_DIR` が設定時に優先。相対値は拒否されます) | `pi-models.json` | なし - ブロックにリテラル `opencodex-loopback` が入ります | | `omp` | `~/.omp/agent/models.yml` (デフォルト。空の場合も `OMP_PROFILE` が `PI_PROFILE` より優先されます) | `omp-models.yaml` | なし - リテラル `opencodex-loopback` | | `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | 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 80325145c4..aedaa6f60b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -156,7 +156,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json | 클라이언트 | 표준 대상 경로 | 다운로드 파일명 | 환경 변수 | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME`이 설정되어 있으면 우선합니다) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | 없음 - 블록에 리터럴 `opencodex-loopback`이 들어갑니다 | +| `pi` | `~/.pi/agent/models.json` (`PI_CODING_AGENT_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `pi-models.json` | 없음 - 블록에 리터럴 `opencodex-loopback`이 들어갑니다 | | `omp` | `~/.omp/agent/models.yml` (기본값. 빈 값이어도 `OMP_PROFILE`이 `PI_PROFILE`보다 우선합니다) | `omp-models.yaml` | 없음 - 리터럴 `opencodex-loopback` | | `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index e09159d1c5..01746335f1 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -199,7 +199,7 @@ client applies its own defaults for those). | Client | Canonical destination | Download filename | Env var | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME` wins when set) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | none — the block carries the literal `opencodex-loopback` | +| `pi` | `~/.pi/agent/models.json` (`PI_CODING_AGENT_DIR` wins when set; a relative value is refused) | `pi-models.json` | none — the block carries the literal `opencodex-loopback` | | `omp` | `~/.omp/agent/models.yml` (`OMP_PROFILE` wins over `PI_PROFILE`, even when empty; named profiles use the home-relative `PI_CONFIG_DIR` directory name and ignore `PI_CODING_AGENT_DIR`, while the default profile lets `PI_CODING_AGENT_DIR` win) | `omp-models.yaml` | none — loopback placeholder | | `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index bba29e1f3a..b3db7d104c 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -183,7 +183,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json | Клиент | Канонический путь | Имя скачиваемого файла | Переменная окружения | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME` имеет приоритет, если задан) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | нет — блок несёт литерал `opencodex-loopback` | +| `pi` | `~/.pi/agent/models.json` (`PI_CODING_AGENT_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `pi-models.json` | нет — блок несёт литерал `opencodex-loopback` | | `omp` | `~/.omp/agent/models.yml` (по умолчанию; `OMP_PROFILE` имеет приоритет над `PI_PROFILE`, даже если пуст) | `omp-models.yaml` | нет — литерал `opencodex-loopback` | | `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 82949d8f9a..4bc18174bc 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -223,7 +223,7 @@ için kendi varsayılanlarını uygular) gelir. | İstemci | Kurallı hedef | İndirme dosya adı | Ortam değişkeni | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME` ayarlandığında kazanır) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | yok — blok değişmez `opencodex-loopback` taşır | +| `pi` | `~/.pi/agent/models.json` (ayarlandığında `PI_CODING_AGENT_DIR` öncelikli; göreli değer reddedilir) | `pi-models.json` | yok — blok değişmez `opencodex-loopback` taşır | | `omp` | `~/.omp/agent/models.yml` (boş olduğunda bile `OMP_PROFILE`, `PI_PROFILE`'a üstün gelir; adlandırılmış profiller eve göre `PI_CONFIG_DIR` dizin adını kullanır ve `PI_CODING_AGENT_DIR`'i yok sayar, varsayılan profil ise `PI_CODING_AGENT_DIR`'in kazanmasına izin verir) | `omp-models.yaml` | yok — geri döngü yer tutucusu | | `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index db3754ba69..8491f76961 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -157,7 +157,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json | 客户端 | 规范目标路径 | 下载文件名 | 环境变量 | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json`(设置了 `XDG_CONFIG_HOME` 时以其为准) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | 无 - 块中携带字面值 `opencodex-loopback` | +| `pi` | `~/.pi/agent/models.json` (设置后 `PI_CODING_AGENT_DIR` 优先;相对路径会被拒绝) | `pi-models.json` | 无 - 块中携带字面值 `opencodex-loopback` | | `omp` | `~/.omp/agent/models.yml`(默认路径;即使为空,`OMP_PROFILE` 也优先于 `PI_PROFILE`) | `omp-models.yaml` | 无 - 字面值 `opencodex-loopback` | | `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index ebe59cbcd8..50f024cebd 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -155,7 +155,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json | 客戶端 | 標準目的地 | 下載檔名 | 環境變數 | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json`(`XDG_CONFIG_HOME` 設定時優先) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | 無——區塊帶有字面值 `opencodex-loopback` | +| `pi` | `~/.pi/agent/models.json` (設定後 `PI_CODING_AGENT_DIR` 優先;相對路徑會被拒絕) | `pi-models.json` | 無——區塊帶有字面值 `opencodex-loopback` | | `omp` | `~/.omp/agent/models.yml`(即使是空值,`OMP_PROFILE` 仍優先於 `PI_PROFILE`) | `omp-models.yaml` | 無——loopback 佔位符 | | `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 47f883159d..89ce9a820c 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -192,6 +192,27 @@ function ompProfileName(env: OpencodeLaunchEnv): string | undefined { return profile; } +/** + * Pi resolves its agent directory from `PI_CODING_AGENT_DIR`, falling back to + * `~/.pi/agent`. `ompAgentDir` below already reads that variable — OMP is a Pi + * derivative — so Pi's own resolver honoring it is what makes the two agree + * rather than a new claim about Pi's contract. + * + * A relative override is refused for the same reason MCode's and ZCode's are: a + * background proxy and a foreground client can have different working + * directories, and would otherwise disagree about which file is named. + */ +export function piAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const override = env.PI_CODING_AGENT_DIR?.trim(); + if (override) return absoluteClientPath(override, home, "PI_CODING_AGENT_DIR"); + return join(home, ".pi", "agent"); +} + +/** Pi's canonical custom-provider catalog. */ +export function piConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(piAgentDir(env, home), "models.json"); +} + /** Resolve the global Oh My Pi agent directory using OMP's own env precedence. */ export function ompAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { const profile = ompProfileName(env); @@ -1445,7 +1466,7 @@ export const EXPORT_CLIENTS: Record = { pi: { id: "pi", filename: "pi-models.json", - destination: () => join(homedir(), ".pi", "agent", "models.json"), + destination: env => piConfigPath(env), apiKeyEnv: "", exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.", build: buildPiClientConfig, diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 42fe51a28d..dcbfd8f7eb 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -27,6 +27,8 @@ import { opencodeGlobalConfigPath, openclawConfigPath, openclawHomeDir, + piAgentDir, + piConfigPath, zcodeConfigPath, zcodeHomeDir, type ExportClientId, @@ -77,8 +79,18 @@ export const INTEGRATION_CLIENTS: Record join(home, ".pi", "agent", "models.json"), - detectDir: (_env = process.env, home = homedir()) => join(home, ".pi"), + configPath: (env = process.env, home = homedir()) => piConfigPath(env, home), + /* + * Deliberately not `piAgentDir` unconditionally. Without an override the + * install signal stays `~/.pi`, which is what it has always been: narrowing + * it to `~/.pi/agent` would flip a user who has the former without the + * latter from installed to absent, and this change is about honoring the + * override, not about redefining detection. With an override there is no + * parent worth testing — the variable names the agent directory itself — so + * that directory becomes the signal. + */ + detectDir: (env = process.env, home = homedir()) => + env.PI_CODING_AGENT_DIR?.trim() ? piAgentDir(env, home) : join(home, ".pi"), }, omp: { id: "omp", diff --git a/tests/pi-path-contract.test.ts b/tests/pi-path-contract.test.ts new file mode 100644 index 0000000000..25356e499b --- /dev/null +++ b/tests/pi-path-contract.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + ompModelsConfigPath, + piAgentDir, + piConfigPath, +} from "../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../src/integrations/registry"; + +function withTempHome(run: (home: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "opencodex-pi-home-")); + try { + run(home); + } finally { + rmSync(home, { recursive: true, force: true }); + } +} + +describe("Pi path contract", () => { + test("falls back to ~/.pi/agent when no override is set", () => { + withTempHome(home => { + expect(piAgentDir({} as NodeJS.ProcessEnv, home)).toBe(join(home, ".pi", "agent")); + expect(piConfigPath({} as NodeJS.ProcessEnv, home)).toBe(join(home, ".pi", "agent", "models.json")); + }); + }); + + test("honors PI_CODING_AGENT_DIR", () => { + withTempHome(home => { + const env = { PI_CODING_AGENT_DIR: join(home, "elsewhere") } as NodeJS.ProcessEnv; + expect(piConfigPath(env, home)).toBe(join(home, "elsewhere", "models.json")); + expect(piAgentDir({ PI_CODING_AGENT_DIR: "~" } as NodeJS.ProcessEnv, home)).toBe(home); + expect(piAgentDir({ PI_CODING_AGENT_DIR: "~/alt" } as NodeJS.ProcessEnv, home)).toBe(join(home, "alt")); + }); + }); + + test("refuses a relative override", () => { + withTempHome(home => { + // The proxy and Pi can run from different working directories, so a + // relative value would name two different files. + expect(() => piConfigPath({ PI_CODING_AGENT_DIR: "relative" } as NodeJS.ProcessEnv, home)).toThrow(ClientPathError); + }); + }); + + /** + * The defect this file exists for: every other client threaded `env` into its + * destination, and `pi` alone dropped it, so the same variable moved OMP's + * path and left Pi's on the home default. + */ + test("the export destination and the integration resolvers all follow the override", () => { + withTempHome(home => { + const env = { PI_CODING_AGENT_DIR: join(home, "elsewhere") } as NodeJS.ProcessEnv; + expect(EXPORT_CLIENTS.pi.destination(env)).toBe(join(home, "elsewhere", "models.json")); + expect(INTEGRATION_CLIENTS.pi.configPath(env, home)).toBe(join(home, "elsewhere", "models.json")); + expect(INTEGRATION_CLIENTS.pi.detectDir(env, home)).toBe(join(home, "elsewhere")); + // Without an override the install signal is unchanged: still the home + // directory, not the agent directory beneath it. + expect(INTEGRATION_CLIENTS.pi.detectDir({} as NodeJS.ProcessEnv, home)).toBe(join(home, ".pi")); + }); + }); + + test("Pi and OMP now agree about the variable they already shared", () => { + withTempHome(home => { + const env = { PI_CODING_AGENT_DIR: join(home, "elsewhere") } as NodeJS.ProcessEnv; + // OMP's default profile has always honored it; Pi now does too. + expect(ompModelsConfigPath(env, home)).toBe(join(home, "elsewhere", "models.yml")); + expect(piConfigPath(env, home)).toBe(join(home, "elsewhere", "models.json")); + }); + }); +}); From 546ac8fcb7cc5d6f7e0a403f1f82ca2cf13a86a2 Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 10:45:51 +0900 Subject: [PATCH 30/67] fix(management): answer 400 when a client path override is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/client-config` resolved `spec.destination(process.env)` with no guard, so a `ClientPathError` escaped `handleManagementAPI` and the dashboard download saw a generic 500 — losing the message that says how to fix it. The CLI already surfaces that error readably, and `integrations/state.ts` and `integrations/writer.ts` already catch it on their paths; this route was the one that did not. The hole predates Pi: every client whose destination resolves an environment override could reach it, which is mcode, zcode and dsh. Honoring `PI_CODING_AGENT_DIR` in the previous commit added pi to that set, so the guard is written at the boundary for all of them rather than for pi alone. Adds two route-level regressions: a relative `PI_CODING_AGENT_DIR` answers 400 carrying the bounded message and no half-built envelope, and an accepted override still resolves through the route to the overridden destination. Both restore the process environment afterwards. CLI and integration refusal behavior is unchanged. --- src/server/management/model-routes.ts | 18 ++++++++- tests/management-client-config-route.test.ts | 41 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 9d7eaf664d..e12290b682 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -129,6 +129,7 @@ import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO, corsHeaders } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; import { + ClientPathError, EXPORT_CLIENTS, EXPORT_CLIENT_IDS, OPENCODE_PROVIDER_ID, @@ -228,10 +229,25 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { expect(body.config).toBeUndefined(); }, 15_000); + /** + * A client's own environment override can name a path the resolver refuses. + * The CLI already surfaces that as a readable error, and the integration + * state and writer paths already catch it — this route did not, so the + * exception escaped `handleManagementAPI` and the dashboard download saw a + * generic 500 with the corrective message stripped. The hole was reachable + * for every client whose destination resolves an override (mcode, zcode, + * dsh); Pi joined that set when its resolver started honoring + * `PI_CODING_AGENT_DIR`. + */ + test("a refused path override answers 400 with the bounded message, not a thrown 500", async () => { + const previous = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = "relative"; + try { + const response = await clientConfigApi(baseConfig(), "?client=pi"); + expect(response.status).toBe(400); + const body = await response.json() as { error: string; config?: unknown }; + expect(body.error).toContain("PI_CODING_AGENT_DIR"); + expect(body.error).toContain("absolute path"); + // The refusal must not leak a half-built envelope. + expect(body.config).toBeUndefined(); + } finally { + if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previous; + } + }, 15_000); + + test("an accepted override still resolves through the route", async () => { + const previous = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = "/tmp/opencodex-pi-route-fixture"; + try { + const response = await clientConfigApi(baseConfig(), "?client=pi"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + expect(body.destination).toBe("/tmp/opencodex-pi-route-fixture/models.json"); + } finally { + if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previous; + } + }, 15_000); + test("cross-origin admission is unchanged from every other /api route", async () => { const url = new URL("http://127.0.0.1:10100/api/client-config?client=opencode"); const response = await handleManagementAPI( From 9d770c86893d63af7540e65bf5a792e86a6edf00 Mon Sep 17 00:00:00 2001 From: umyunsang Date: Fri, 21 Aug 2026 11:35:11 +0900 Subject: [PATCH 31/67] fix(management): validate the client path before loading the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal guard sat after `loadExportModels` and `buildClientConfigText`, so a relative override combined with a busy or failing catalog answered 503 — or rethrew `CatalogGatherBusyError` — and the user never saw the message that says how to fix the path. The route also enumerated the catalog for input it was about to reject. A refused override is a property of the request, not of the catalog, so the block moves ahead of the load. The regression asserts both halves: with the catalog throwing and `PI_CODING_AGENT_DIR=relative`, the route answers 400 naming the variable rather than "catalog offline", and the provider enumeration is never touched. --- src/server/management/model-routes.ts | 36 ++++++++++++-------- tests/management-client-config-route.test.ts | 25 ++++++++++++++ 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index e12290b682..ca0b8708df 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -205,6 +205,27 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { } }, 15_000); + test("a refused override wins over a failing catalog, and skips the catalog work", async () => { + // The refusal is a property of the request, not of the catalog. Validating + // it after the load let 503 answer first and hid the corrective message. + const config = baseConfig(); + let providersRead = 0; + Object.defineProperty(config, "providers", { + get() { providersRead += 1; throw new Error("catalog offline"); }, + configurable: true, + }); + const previous = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = "relative"; + try { + const response = await clientConfigApi(config, "?client=pi"); + expect(response.status).toBe(400); + const body = await response.json() as { error: string }; + expect(body.error).toContain("PI_CODING_AGENT_DIR"); + expect(body.error).not.toContain("catalog offline"); + // Nothing enumerated the catalog for input that was going to be rejected. + expect(providersRead).toBe(0); + } finally { + if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previous; + } + }, 15_000); + test("cross-origin admission is unchanged from every other /api route", async () => { const url = new URL("http://127.0.0.1:10100/api/client-config?client=opencode"); const response = await handleManagementAPI( From 377f22991f8ef9a59fc341f0477c2f2014ecb26e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 21 Aug 2026 12:29:55 +0900 Subject: [PATCH 32/67] feat(sidecar): shared auth module with login-shaped flags and auth slots (#2188 L1) (#2203) * docs(devlog): roadmap unit for sidecar selection unification (#2188) Cleanup record (wp0), current-state research, verified hosted web-search protocol research (OpenAI/Anthropic/xAI/Gemini/vendors), and five audited decade docs mapping 1:1 onto the stacked PR layers. Audited by grok-4.6 (two FAIL rounds folded back in; final PASS). * feat(sidecar): shared auth module with login-shaped flags and auth slots (#2188) isCodexAuth requires a live stored ChatGPT credential (main token or a usable selectable pool account), not mere forward-provider presence. isAnthropicAuth extracts the predicate previously duplicated in findAnthropicSidecarProvider and findAnthropicVisionProvider; both now delegate. sidecarAuthSlots emits the fixed Luna/Haiku candidates that survive picker hiding. * refactor(sidecar): source the codex slot provider id from openai-tiers * docs(devlog): recast the wp0 cleanup plan as an executed historical record Maintainer review blocker on #2203: the tracked doc was written as an executable deletion plan with contradictory KEEP/REMOVE rows, no per-batch preflight, an incomplete protected set, an unpreserved detached commit, and codex/merge-loop-closeout listed as both KEEP and remote-delete. Rewritten as a non-executable EXECUTED RECORD: KEEP rows moved out of removal sections, the b2ac2500c preservation branch ordering made explicit, the 37-row remote list corrected to the 36 actually deleted (merge-loop-closeout excluded), and a mandatory 5-step protected-set preflight template appended for any future batch. Live-state post-check: 0 deleted remote refs resurrected, kept worktrees intact. * docs(devlog): preflight step 1 detects dirty worktrees via per-path status C-gate blocker: git worktree list --porcelain reports metadata, not cleanliness; the template now enumerates each path and runs git -C status --porcelain, protecting dirty paths and their attached branches. * docs(devlog): preflight step 3 protects dirty worktree paths, not only branches --- .../000_unit_overview.md | 56 ++++++++ .../000_wp0_branch_worktree_cleanup.md | 112 ++++++++++++++++ .../001_research_current_state.md | 46 +++++++ .../002_protocol_research.md | 42 ++++++ .../010_layer1_sidecar_auth.md | 40 ++++++ .../020_layer2_picker_candidates.md | 27 ++++ .../030_layer3_websearch_slots.md | 33 +++++ .../031_future_backend_research.md | 13 ++ .../040_layer4_write_gates_gui.md | 26 ++++ .../050_layer5_cli_and_final.md | 18 +++ src/sidecar/auth.ts | 92 +++++++++++++ src/vision/index.ts | 17 ++- src/web-search/index.ts | 15 +-- tests/sidecar-auth.test.ts | 124 ++++++++++++++++++ 14 files changed, 643 insertions(+), 18 deletions(-) create mode 100644 devlog/_plan/260820_sidecar_selection_unification/000_unit_overview.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/000_wp0_branch_worktree_cleanup.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/001_research_current_state.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/002_protocol_research.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/010_layer1_sidecar_auth.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/020_layer2_picker_candidates.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/030_layer3_websearch_slots.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/031_future_backend_research.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/040_layer4_write_gates_gui.md create mode 100644 devlog/_plan/260820_sidecar_selection_unification/050_layer5_cli_and_final.md create mode 100644 src/sidecar/auth.ts create mode 100644 tests/sidecar-auth.test.ts diff --git a/devlog/_plan/260820_sidecar_selection_unification/000_unit_overview.md b/devlog/_plan/260820_sidecar_selection_unification/000_unit_overview.md new file mode 100644 index 0000000000..c9e0c93b72 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/000_unit_overview.md @@ -0,0 +1,56 @@ +# 260820 — Sidecar selection unification (#2188) + +Roadmap for implementing issue #2188 as a stacked PR chain onto dev. +Session 01a01f4b; goalplan implement-github-issue-2188-feat-sidecar-unify-w. + +## Docs +- 000 (this file) — unit overview + stack map. +- 000_wp0 — branch/worktree cleanup record (executed, closed). +- 001 — research: current selection state at dev f2ebd3067. +- 010 — Layer 1: src/sidecar/auth.ts shared auth + slots (branch codex/sidecar-auth-slots → dev). +- 020 — Layer 2: src/sidecar/candidates.ts picker set + vision filter (codex/sidecar-picker-candidates → L1). +- 030 — Layer 3: web-search backend registry + candidate ∩ (no default-backend change) (codex/sidecar-websearch-slots → L2). +- 031 — future-backend research table + probe contracts (doc-only, inside L3 PR). +- 040 — Layer 4: write gates + GUI lists (codex/sidecar-write-gates → L3). +- 050 — Layer 5: ocx agent sidecar --list + docs-site + full validation (codex/sidecar-cli → L4). + +## Stack invariants (DEV-STACK) +Bottom targets dev; each child targets the branch below. Merge bottom-up; retarget children after parent lands. Each layer: own tests green + typecheck before PR; full suite at top layer. Every A/C gate: xai/grok-4.6 read-only reviewer, verdict binding. + +## Out of scope (issue-fixed) +Gemini/Grok/Zen/Exa executors, #2190 x_search, #398, types.ts-split rebases. + + +## AMENDMENT 1 (post-audit, auditor Hegel VERDICT: fail — all four blockers accepted) + +### B1 — isCodexAuth must mean LOGIN, not provider presence (fixes 010) +```ts +// src/sidecar/auth.ts +isCodexAuth = listOpenAiForwardSidecarCandidates(config).length > 0 + && ( isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) // live ~/.codex auth.json token + || (config.codexAccounts ?? []).some(a => isSelectableCodexPoolAccount(a) + && isCodexAccountUsable(config, a.id)) ) // any usable pool credential +``` +Symmetric with the Anthropic predicate (stored OAuth + !needsReauth). Uses src/codex/account-usability.ts:17 isCodexAccountUsable; no header/request context needed. Test: forward provider present but no live token & no pool creds → isCodexAuth false → Luna slot absent. + +### B2 — extend ocx agent sidecar, do NOT add ocx sidecar (fixes 001 + 050) +001 correction: src/cli/agent.ts:23 already ships `ocx agent sidecar ` → PUT /api/sidecar-settings. Layer 5 extends it: +- `ocx agent sidecar web --list` / `vision --list` print the exact candidate sets via a new GET consumer (same functions as GUI). +- Writes already flow through the PUT gate added in L4 (server-side gate covers CLI automatically — the "CLI cannot bypass" property comes from gating the shared route, not from a parallel client check). +- No new top-level command. 050's src/cli/sidecar.ts is WITHDRAWN. + +### B3 — claude-code webSearch write gate is REQUIRED in L4 (fixes 040) +- src/server/management/agent-settings-routes.ts:1064 writes webSearchSidecar.model ungated → same membership gate as /api/sidecar-settings (shared helper in src/server/management/web-search-sidecar-options.ts, mirroring vision-sidecar-options.ts placement; extraction happens IN L4, so L5 never restacks routes — also resolves the L5 write-gate.ts smell). +- `ocx claude config set --web-model` rides the same route → covered. +- Out of scope (explicit): `ocx config set webSearchSidecar.model` raw JSON writes bypass management gates by design (operator escape hatch, same as vision today). +- GUI contract: GET must send webSearchModels: [] when empty, never omit (dashboard-shared.ts:273 omission fallback would show the full union). Persisted-but-now-illegal model: display-grandfather into options (same as vision GET :115) but reject NEW writes. + +### B4 — default-backend decision split & pinned (fixes 030) +- resolveSidecarBackend(explicit) keeps today's contract: unset → openai, no auth argument (web-search-anthropic.test.ts:58 assertion unchanged). +- resolveVisionBackend keeps today's contract: unset → anthropic when OAuth credential exists. +- The "전역 플래그로 맞춘다" issue sentence is satisfied by both resolvers CONSUMING resolveSidecarAuth for credential presence (shared auth state), NOT by unifying their default preference. Changing dual-auth default preference is a user-facing behavior change #2188 never ordered → OUT OF SCOPE, recorded for a follow-up issue. +- L3 no longer touches src/vision/index.ts at all (removes the cross-surface smell). + +### Corrections +- 001: isCodexAuthContextUsable is auth-context.ts:612. + diff --git a/devlog/_plan/260820_sidecar_selection_unification/000_wp0_branch_worktree_cleanup.md b/devlog/_plan/260820_sidecar_selection_unification/000_wp0_branch_worktree_cleanup.md new file mode 100644 index 0000000000..c8a0370614 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/000_wp0_branch_worktree_cleanup.md @@ -0,0 +1,112 @@ +# WP0 cleanup — EXECUTED RECORD (session 01a01f4b, performed 2026-08-20) + +STATUS: HISTORICAL. Every deletion below was executed during wp0 and verified against +live state afterwards (post-check 2026-08-21: 33 local branches remain, 0 deleted +remote refs resurrected, 9 worktrees). Nothing in this file is a pending command. +Anyone repeating a cleanup of this shape MUST run the preflight template at the end +against CURRENT state first; this snapshot is not reusable as a target list. + +## Protected set (mechanical, applied before every batch) + +The protected set was computed mechanically, not by eye, and deletion aborted on any +intersection: `dev`, `main`, `preview`, every open-PR head (31 at snapshot time), +every branch checked out in any worktree, and every dirty worktree. KEEP entries +below are the rows the protected set excluded from their surrounding batch. + +## Worktrees removed (were all clean; content preserved in dev or a surviving ref) + +- /private/tmp/ocx-m2148.LzD2/wt (absorb-baseurl-override, merged) +- tmp.bxVhqaJyPc/sweeper (tmp-reclaim-1-sweeper, merged) +- tmp.bzZ2ssU8WM/wp1b (split-wp1b-type-clusters, merged) +- tmp.gLNBuhAyoP (detached d0cd99672, merge of two dev ancestors, nothing unique) +- tmp.LfX0NlBXvp/r1876 (ingw/fix-windows-v2-catalog-blocking-1852, merged) +- tmp.Mb171xHMCb/r2031 (ingw/fix-mimo-vision-1927, merged) +- tmp.pQMnjf3VMg/wp1 (split-wp1-types, merged) +- tmp.vSBe0MZ0LP/w2080 (pr2080, PR merged) +- tmp.xfjQ3jxADE/w1934 (pr1934, PR merged) +- tmp.2IOChwQmxR/wp1b + tmp.t3YTdy1JDC/wp1b (detached b2ac2500c) — removed ONLY + after preservation branch wip/wp1b-superseded-b2ac2500c was created at b2ac2500c + and verified (unique cherry patches; superseded by a0f8c0135 in dev). +- ~/.codex/worktrees/3a35 (devlog-release-2280, merged) +- ~/.codex/worktrees/3b3b (cursor-call-release-note-2, merged, upstream gone) +- ~/.codex/worktrees/83d5 (tmp-reclaim-2-doctor, merged) +- ~/.codex/worktrees/c6d8 (zcode-client, merged) +- ~/.codex/worktrees/fe69 (detached 63bfd149d, clean, reachable from many branches) +- ~/.codex/worktrees/land-1842 (clean, PR #1842 CLOSED, 0 unique patches) +- .tmp/pr-2045-review (b92bb611c; PR2045 merged into dev as 0161a66d9) +- .tmp/pr1903-review-8c38989f4 (PR1903 merged; commit kept by remotes/review/pr1903) + +## Worktrees kept (dirty or otherwise protected — never in a removal batch) + +- /private/tmp/opencodex-pr2068.uXcKNC (detached 5a4068bbd, kept by branch + pr2068-check; PR 2068 OPEN) +- tmp.UFYNSQT3qw/land1920 (DIRTY, 2 changes + 3 unique commits) +- ~/.codex/worktrees/648b (DIRTY, 1 change) +- ~/.codex/worktrees/71a2 (DIRTY, 1 change; split-wp2a-config-names stays checked out) + +## Local branches deleted + +- Merged into origin/dev (git branch --merged proof at snapshot): all 46 merged + branches except dev and split-wp2a-config-names (checked out in a kept worktree). +- Unmerged but 0 unique patches vs dev (git cherry all '-'): + absorb-account-entitlement-stacked, absorb-capability-evidence, + absorb-k12-short-window, absorb-xai-oauth-streaming, + consolidate-prompt-cache-retention, fix-bearer-admission-2132, land-1842 + (PR closed), land-1876, ocx/integration, ocx/verify-2167. + +## Local branches kept (unique commits or open PR) + +combo-quota-badges (PR1704 OPEN), compat-multiagent-v2-catalog (4u), devlog-merge-log +(1u), devlog-three-issues (PR2181 OPEN), external-vision (1u), +issue-quality-provider-defect-bug-label (1u), land-1920 (3u+dirty wt), +cursor-call-prerebase-260818 (2u), ocx-dev-verify (1u), ocx/rebuild-2178 (1u), wip/* +(unique, incl. wip/wp1b-superseded-b2ac2500c), pr2053-check (2u), pr2056-check (1u), +pr2068-check (PR OPEN), pr2072-check (PR OPEN), pr2101-probe (5u), pr2105tmp (3u), +codex/merge-loop-closeout (2 unique local commits), main, preview, dev. + +## Remote branches deleted (origin) + +- 36 merged-into-origin/dev refs, none an open-PR head. The original 37-row list + mistakenly included codex/merge-loop-closeout; the executed batch EXCLUDED it (it + is a local-only KEEP with 2 unique commits and had no remote ref to delete): + codex/absorb-agentrouter-language-framing, codex/absorb-antigravity-thought-signatures, + codex/absorb-baseurl-override, codex/absorb-claude-shell-hook-gate, + codex/absorb-fastwire-native-chat, codex/absorb-oauth-superseded-commit, + codex/absorb-openai-chat-padding-repeats, codex/absorb-opencode-free-static-headers, + codex/absorb-opencode-go-quota-siblings, codex/absorb-responses-id-backfill, + codex/absorb-shadow-helper-attribution, codex/absorb-tool-search-passthrough, + codex/audit-closeout, codex/audit-record, codex/audit-shadow-marker-leak, + codex/audit-tool-search-id, codex/devlog-audit, codex/devlog-release-2280, + codex/fix-admission-bearer-transport, codex/fix-audit-record-scan, + codex/fix-privacy-scan-devlog, codex/fix-subagent-roster-truncation, + codex/fix-windows-ci-shards, codex/harden-core-lab-guard, + codex/logs-intercepted-helper-attribution, codex/merge-loop-outcome, + codex/openai-chat-tool-call-heartbeat, codex/promote-2.28.0, codex/split-wp1-types, + codex/split-wp1b-type-clusters, codex/split-wp2a-config-names, + codex/sync-preview-2.28.0, codex/windows-shard-truncation-and-budgets, + ingw/docs-tool-search-troubleshooting-1872, ingw/fix-mimo-vision-1927, + ingw/fix-windows-v2-catalog-blocking-1852 +- Unmerged, 0 unique, PR closed/merged: codex/absorb-account-entitlement-stacked, + codex/absorb-capability-evidence, codex/absorb-k12-short-window, + codex/absorb-xai-oauth-streaming, codex/consolidate-prompt-cache-retention, + codex/fix-bearer-admission-2132, codex/land-1842, codex/land-1876 + +## Preflight template (mandatory for any future cleanup batch) + +Run immediately before EACH delete batch; abort the batch on any intersection: + +1. `git worktree list --porcelain` — collect worktree paths and checked-out branches + (this reports metadata only, NOT status). Then for EACH listed path run + `git -C status --porcelain`; any output marks that worktree dirty. + Protect both the dirty path and its attached branch (detached dirty worktrees + protect the path itself). +2. `gh pr list --state open --json headRefName` — collect every open-PR head. +3. Protected = {dev, main, preview} ∪ open-PR heads ∪ checked-out branches ∪ + dirty-worktree branches ∪ dirty-worktree PATHS (a detached dirty worktree has + no branch — its path itself is the protected row). `comm -12` the sorted + candidate list against sorted Protected; any overlap aborts the whole batch, + not just the row. +4. For unmerged candidates, re-prove 0 unique patches with `git cherry dev ` + at execution time; a snapshot proof is stale the moment the tree moves. +5. Detached commits require a durable preservation ref (branch or tag) verified with + `git rev-parse` BEFORE the containing worktree is removed. diff --git a/devlog/_plan/260820_sidecar_selection_unification/001_research_current_state.md b/devlog/_plan/260820_sidecar_selection_unification/001_research_current_state.md new file mode 100644 index 0000000000..147d1e4e6c --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/001_research_current_state.md @@ -0,0 +1,46 @@ +# 001 — Research: current sidecar selection state (#2188) + +Verified against dev @ f2ebd3067 (2026-08-20). + +## Auth pieces (scattered today) +- Codex/ChatGPT: `listOpenAiForwardSidecarCandidates` (src/providers/openai-sidecar.ts:56) — canonical forward provider, pinned baseUrl; `isCodexAuthContextUsable` (src/codex/auth-context.ts:613) — per-context account usability. +- Anthropic: `findAnthropicSidecarProvider` (src/web-search/index.ts:87) and `findAnthropicVisionProvider` (src/vision/index.ts:219) — DUPLICATED predicate: enabled + adapter==="anthropic" + authMode==="oauth" + active account needsReauth!==true. + +## Backend resolution asymmetry (issue-confirmed) +- Web-search `resolveSidecarBackend` (src/web-search/index.ts:104-108): explicit anthropic else openai. types/config.ts:787-796 comment claims "unset prefers anthropic" — WRONG vs code. +- Vision `resolveVisionBackend` (src/vision/index.ts:231-236): unset prefers anthropic when credential exists. Opposite default. + +## Picker sets +- `visibleNativeSlugs` (src/codex/catalog/metadata.ts:331): nativeOpenAiSlugs − disabled − alias-shadowed. +- `listManagementModelRows` (src/server/management/model-rows.ts:50): native rows (incl. disabled, flagged) + account-bound + routed catalog rows. +- `visionCandidateRows` (src/server/management/vision-sidecar-options.ts:45): rows.filter(disabled !== true) — close to picker policy but no auth-slot concept. + +## Vision candidate expansion defect +- `visionEligibleModelOptions` (src/vision/eligibility.ts:201): iterates ALL passed candidates + baselines. Candidates from visionCandidateRows = full catalog (all providers' rows), not picker-limited native set; anthropic side gated only by provider name match. + +## Write gates +- Vision: PUT /api/sidecar-settings (config-routes.ts:584) rejects via `visionDescriberIsProvablyBlind`. Claude-code override shares module. +- Web-search: config-routes.ts:604-606 persists webSearch.model verbatim. NO GATE. + +## GUI +- gui/src/pages/dashboard-overview-sections.tsx + use-dashboard-data.ts render webSearch/vision sidecar settings; GET /api/sidecar-settings returns visionModels options but NO webSearchModels options list. + +## CLI +- No ocx sidecar command exists (src/cli/ has no sidecar.ts; only GUI/PUT paths). + +## Tests nearby +tests/vision-eligibility.test.ts, tests/sidecar-settings-vision-filter.test.ts, tests/web-search.test.ts, tests/sidecar-settings-vision-controls.test.ts, tests/claude-sidecar-override.test.ts. + +## Executors that exist today (probe-relevant) +- openai: src/web-search/executor.ts (ChatGPT forward /responses hosted web_search). +- anthropic: src/web-search/anthropic-executor.ts (web_search_20250305 via OAuth Messages). +- NO gemini/grok/zen/exa executor → per #2188 filter rule 2, only openai+anthropic can be active web-search backends in this unit. + + +## CORRECTION (post-audit) +- isCodexAuthContextUsable is src/codex/auth-context.ts:612 (not 613). +- CLI: `ocx agent sidecar ` ALREADY EXISTS (src/cli/agent.ts:23) writing PUT /api/sidecar-settings. The gap is only: no --list surface, and the PUT it calls has no web-search membership gate. "No ocx sidecar command exists" above is WRONG. +- Additional ungated write path: PUT /api/claude-code writes webSearchSidecar.model verbatim (src/server/management/agent-settings-routes.ts:1064); `ocx claude config set --web-model` rides it. +- GUI contract: dashboard-shared.ts:273 — an OMITTED visionModels key falls back to the full openai+anthropic union; [] means none. Any new webSearchModels key must always be present. +- GET /api/sidecar-settings grandfathers the persisted vision model into options (config-routes.ts:115). + diff --git a/devlog/_plan/260820_sidecar_selection_unification/002_protocol_research.md b/devlog/_plan/260820_sidecar_selection_unification/002_protocol_research.md new file mode 100644 index 0000000000..d0db13bc7a --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/002_protocol_research.md @@ -0,0 +1,42 @@ +# 002 — Hosted web-search protocol research (규약, verified 2026-08-20) + +Luna swarm (5 lanes) + primary-source verification. All findings below were source-opened (official docs) unless flagged lead. Supersedes the doc-only table in 031 with verified wire contracts. + +## OpenAI Responses (current openai backend) +- Tool: `{"type":"web_search"}` (legacy: web_search_preview; preview models gpt-4o-*-search-preview shut down 2026-07-23). +- Output item `web_search_call`, id prefix `ws_`; action.type ∈ search|open_page|find_in_page; sources via include: ["web_search_call.action.sources"]. +- filters.allowed_domains / blocked_domains ≤ 100 each (web_search only, not preview); external_web_access toggle. +- SSE: response.web_search_call.in_progress|searching|completed (item_id, output_index, sequence_number). +- $10/1k calls (+ content tokens). [developers.openai.com web-search guide; platform pricing] + +## Anthropic Messages (current anthropic backend) +- Tool versions: web_search_20250305 (basic, direct-call default, ZDR-eligible), web_search_20260209 (dynamic filtering via code_execution_20260120; allowed_callers defaults to code-exec — direct use requires allowed_callers:["direct"]), web_search_20260318 (response-inclusion control). 20250305 NOT deprecated. +- Blocks: server_tool_use (id prefix srvtoolu_) → web_search_tool_result (tool_use_id pairing); encrypted_content MUST be replayed unchanged in continuations or 400. +- SSE: content_block_start(server_tool_use) → input_json_delta → content_block_stop → content_block_start(web_search_tool_result); usage server_tool_use.web_search_requests in message_delta. +- max_uses cap → web_search_tool_result_error(max_uses_exceeded); org-level enablement required else 400. +- NOT on Bedrock; Vertex basic-only. $10/1k searches, failures unbilled. [platform.claude.com web-search-tool, server-tools, streaming] + +## xAI Grok Responses (future backend candidate) +- POST api.x.ai/v1/responses; tools `{"type":"web_search"}` / `{"type":"x_search"}`; output items web_search_call / x_search_call (server-executed, NOT function_call). +- web_search: filters.allowed_domains ≤ 5, allowed/excluded mutually exclusive. x_search: allowed_x_handles ≤ 20 on tool object (NOT nested under filters). +- include: web_search_call.action.sources documented; x_search_call sources selector UNDOCUMENTED → live probe required. Id prefixes undocumented → treat opaque, live probe required (matches #2190). +- Responses SSE event names NOT documented (only SDK chunk.tool_calls) → probe required before relay implementation. +- Live Search: no formal deprecation notice found (issue #2188 text says 2026-01 deprecate — docs do not confirm; treat as legacy either way). $5/1k per tool. [docs.x.ai tools/*, pricing, release-notes] + +## Google Gemini (future backend candidate) +- Legacy generateContent: tools [{google_search: {}}] (older models: google_search_retrieval); response candidates[].groundingMetadata {webSearchQueries, searchEntryPoint.renderedContent, groundingChunks[].web{uri,title}, groundingSupports[].segment+groundingChunkIndices}. Chunk indices accumulate across stream. +- Interactions API: tools [{type:"google_search"}]; steps google_search_call (id ex. search_call_19201, arguments.queries[], search_type web_search|image_search|enterprise_web_search, optional signature) → google_search_result (call_id) → model_output with inline URL annotations. SSE: interaction.created, step.start|delta|stop, interaction.completed, done. +- Stateless clients must replay id + encrypted signature manually. Tool-choice: validated mode required with tool-context circulation; auto unsupported. +- Auth x-goog-api-key; standard-key support ends 2026-09. Pricing: Gemini 3.x 5,000 free searches/mo then $14/1k per actual query; ≤2.5 models $35/1k per grounded prompt. [ai.google.dev grounding, interactions-api, pricing] + +## Non-LLM vendors (Exa-class lane, #414) +- Exa: POST api.exa.ai/search, x-api-key or Bearer; {query, type, numResults, contents} → {requestId, results[{title,url,id,publishedDate,text/highlights/summary}], costDollars}. SSE only with outputSchema (OpenAI chat-chunk shaped). +- Tavily: POST api.tavily.com/search, Bearer tvly-*; plain JSON, no SSE. $0.008/credit. +- Brave: GET api.search.brave.com/res/v1/web/search, X-Subscription-Token; plain JSON. $5/1k. +- OpenCode upstream now uses MCP JSON-RPC (mcp.exa.ai/mcp, search.parallel.ai/mcp; tools/call name="web_search") — NOT a Responses hosted tool. Zen /zen/go/v1/responses hosted web_search: LEAD ONLY, inconsistent SSE observed, {"type":"remote_tool"} rejected by backend; #1616's probe claim needs fresh re-verification before any Zen backend work. + +## Consequences for this unit +1. 031's future-descriptor probe contracts updated by this doc (xAI/Gemini both need live probes for SSE + id shapes; Zen demoted to lead). +2. Anthropic executor (anthropic-executor.ts) currently pins web_search_20250305 — fine (not deprecated, ZDR-eligible, direct default). Upgrading to 20260209 would REQUIRE allowed_callers:["direct"] — record as follow-up, not this unit. +3. encrypted_content replay + srvtoolu_ pairing are existing executor obligations — verify tests cover replay-unchanged before touching anthropic paths in L3. + diff --git a/devlog/_plan/260820_sidecar_selection_unification/010_layer1_sidecar_auth.md b/devlog/_plan/260820_sidecar_selection_unification/010_layer1_sidecar_auth.md new file mode 100644 index 0000000000..ebe01e14ef --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/010_layer1_sidecar_auth.md @@ -0,0 +1,40 @@ +# 010 — Layer 1: shared sidecar auth module (wp2) + +Branch: codex/sidecar-auth-slots (base: dev). PR bottom of stack, targets dev. + +## New file: src/sidecar/auth.ts +```ts +export interface SidecarAuthState { + isCodexAuth: boolean; // ChatGPT LOGIN usable (not mere provider presence) + isAnthropicAuth: boolean; // enabled anthropic-adapter OAuth provider w/ active !needsReauth account + anthropicProviderName?: string; + anthropicProvider?: OcxProviderConfig; +} +export function resolveSidecarAuth(config: OcxConfig): SidecarAuthState +// isCodexAuth = listOpenAiForwardSidecarCandidates(config).length > 0 +// && ( isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) // live ~/.codex/auth.json token +// || (config.codexAccounts ?? []).some(a => +// isSelectableCodexPoolAccount(a) && isCodexAccountUsable(config, a.id)) ) +// (src/codex/account-usability.ts:17 — request-context-free, symmetric with Anthropic) +// isAnthropicAuth + provider = the shared predicate now duplicated in +// findAnthropicSidecarProvider (web-search/index.ts:87) and findAnthropicVisionProvider (vision/index.ts:219) + +export const AUTH_SLOT_MODELS = { codex: "gpt-5.6-luna", anthropic: "claude-haiku-4-5" } as const; +export function sidecarAuthSlots(auth: SidecarAuthState): Array<{ provider: string; id: string; slot: "codex" | "anthropic" }> +// codex slot when isCodexAuth; anthropic slot (provider = anthropicProviderName) when isAnthropicAuth +``` + +## Refactors (behavior-preserving) +- src/web-search/index.ts: findAnthropicSidecarProvider delegates to resolveSidecarAuth (keep export). +- src/vision/index.ts: findAnthropicVisionProvider delegates likewise. +- No caller behavior change in this layer. + +## Tests: tests/sidecar-auth.test.ts +- isCodexAuth FALSE when forward provider exists but no live main token and no usable pool account (the B1 pin). +- isCodexAuth TRUE with live main token; TRUE with usable selectable pool credential only. +- isAnthropicAuth false when: disabled, wrong adapter, key auth, needsReauth active account, no account set. +- Slots: hidden/disabled Luna & Haiku still emitted when auth present (core #2188 invariant). +- Delegation equivalence for both find* helpers. + +## Verify: bun x tsc --noEmit && bun test tests/sidecar-auth.test.ts tests/web-search.test.ts tests/vision-eligibility.test.ts + diff --git a/devlog/_plan/260820_sidecar_selection_unification/020_layer2_picker_candidates.md b/devlog/_plan/260820_sidecar_selection_unification/020_layer2_picker_candidates.md new file mode 100644 index 0000000000..7c04dac326 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/020_layer2_picker_candidates.md @@ -0,0 +1,27 @@ +# 020 — Layer 2: unified picker candidate function + vision set (wp3) + +Branch: codex/sidecar-picker-candidates (base: codex/sidecar-auth-slots). + +## New file: src/sidecar/candidates.ts +```ts +export interface SidecarCandidate { provider: string; id: string; native?: boolean; inputModalities?: string[]; authSlot?: boolean } +export async function pickerVisibleSidecarCandidates(config: OcxConfig, auth: SidecarAuthState): Promise +// = listManagementModelRows(config).filter(disabled !== true) (catalog outage → []) +// ∪ sidecarAuthSlots(auth) marked authSlot: true (added even when hidden/disabled/absent) +// de-dup by provider+id; auth-slot flag wins. +export function visionSidecarCandidates(config, all: SidecarCandidate[]): SidecarCandidate[] +// = all − provably text-only (modelAcceptsImageInput(config, c) === false) +// auth slots carry inputModalities ["text","image"] like baselineCandidate today. +``` + +## Changes +- src/server/management/vision-sidecar-options.ts: visionCandidateRows → wrapper over pickerVisibleSidecarCandidates (keeps export shape); visionModelOptionsFrom feeds visionEligibleModelOptions ONLY picker-visible+auth-slot candidates. Baseline injection in visionEligibleModelOptions stays but baselines == auth slots when auth present; without auth, current baseline fallback preserved (no regression for fresh installs). +- Keep visionDescriberIsProvablyBlind gate semantics unchanged (write gate ≠ suggestion list). + +## Tests: tests/sidecar-candidates.test.ts (+ update sidecar-settings-vision-filter.test.ts) +- Hidden native slug (disabledModels) disappears from options; Luna survives via auth slot. +- Routed provider's catalog row visible in rows appears; text-only proven row excluded. +- Catalog outage → auth slots + baselines only. + +## Verify: bun x tsc --noEmit && bun test tests/sidecar-candidates.test.ts tests/sidecar-settings-vision-filter.test.ts tests/vision-eligibility.test.ts tests/catalog-vision-sidecar-modalities.test.ts + diff --git a/devlog/_plan/260820_sidecar_selection_unification/030_layer3_websearch_slots.md b/devlog/_plan/260820_sidecar_selection_unification/030_layer3_websearch_slots.md new file mode 100644 index 0000000000..fbdf3cec87 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/030_layer3_websearch_slots.md @@ -0,0 +1,33 @@ +# 030 — Layer 3: web-search candidate set + backend registry (wp4) + +Branch: codex/sidecar-websearch-slots (base: codex/sidecar-picker-candidates). + +## New file: src/web-search/backends.ts (probe/executor registry) +```ts +export interface WebSearchBackendDescriptor { + backend: "openai" | "anthropic"; + hasExecutor: true; + probe: "chatgpt-forward" | "anthropic-oauth"; // auth presence == probe for these two + eligibleModel(candidate: SidecarCandidate): boolean; +} +export const WEB_SEARCH_BACKENDS: WebSearchBackendDescriptor[] // openai + anthropic only (001/002: no other executor) +export function webSearchSidecarCandidates(config, auth, all: SidecarCandidate[]): SidecarCandidate[] +// = (picker-visible ∪ auth slots) ∩ (backend active: auth flag true + executor exists + model family matches) +// openai backend: native rows + Luna slot; anthropic backend: anthropicProviderName rows + Haiku slot. +``` +Future backends (Gemini/Grok/Zen/Exa): descriptor probe contracts recorded in 002/031; NOT registered. + +## Config/docs alignment (NO default-behavior change — B4) +- src/types/config.ts:787-796: fix the lying comment to match code ("unset resolves to openai"). +- resolveSidecarBackend keeps EXACT contract: unset → openai, explicit-only anthropic (web-search-anthropic.test.ts:58 assertion UNTOUCHED). +- resolveVisionBackend UNTOUCHED (unset → anthropic-if-credential). L3 does NOT edit src/vision/index.ts. +- planWebSearch consumes resolveSidecarAuth for credential presence (replacing its inline findAnthropicSidecarProvider call) — presence only, not preference. resolveDefaultSidecarBackend is WITHDRAWN; dual-auth preference unification deferred to a follow-up issue. +- DEFAULT_SIDECAR_MODEL stays gpt-5.6-luna; DEFAULT_ANTHROPIC_SIDECAR_MODEL stays claude-sonnet-5; auth SLOTS stay Luna/Haiku (issue text). + +## Tests: tests/web-search-candidates.test.ts +- No Codex login (provider present, no token) → openai side inactive → native rows + Luna absent; Haiku present when anthropic auth. +- Hidden Luna + Codex login → present. Model outside both backend families → absent. +- resolveSidecarBackend(undefined) === "openai" still green (existing test untouched). + +## Verify: bun x tsc --noEmit && bun test tests/web-search-candidates.test.ts tests/web-search.test.ts tests/web-search-anthropic.test.ts + diff --git a/devlog/_plan/260820_sidecar_selection_unification/031_future_backend_research.md b/devlog/_plan/260820_sidecar_selection_unification/031_future_backend_research.md new file mode 100644 index 0000000000..acd147df65 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/031_future_backend_research.md @@ -0,0 +1,13 @@ +# 031 — Future web-search backend research table (doc-only) + +Recorded from issue #2188 research (2026-08-20, docs not live probes). These are NOT registered backends; each needs live probe + executor before entering WEB_SEARCH_BACKENDS (filter rule 2). + +| Candidate | Protocol | Tool/endpoint | Probe contract before activation | +| --- | --- | --- | --- | +| Gemini | Gemini/Interactions | google_search grounding | API-key probe: grounding metadata + citations round-trip; simultaneous-tool limit check | +| xAI Grok | Responses | { type: "web_search" } | api.x.ai key probe: 200 + web_search_call item + allowed_domains behavior | +| OpenCode Zen | Responses | hosted web_search | POST …/zen/go/v1/responses probe (#1616 evidence 2026-08-13) re-verified fresh | +| Exa-class vendors | own Search API | JSON → SidecarOutcome mapping | not an LLM; separate lane from hosted-tool probes (#414) | + +Each future descriptor must state: probe fn, executor module, eligibleModel predicate, id/citation wire mapping. Until then the union in config stays "openai" | "anthropic". + diff --git a/devlog/_plan/260820_sidecar_selection_unification/040_layer4_write_gates_gui.md b/devlog/_plan/260820_sidecar_selection_unification/040_layer4_write_gates_gui.md new file mode 100644 index 0000000000..d7a96e80dc --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/040_layer4_write_gates_gui.md @@ -0,0 +1,26 @@ +# 040 — Layer 4: management API write gates + GUI lists (wp5) + +Branch: codex/sidecar-write-gates (base: codex/sidecar-websearch-slots). + +## New file: src/server/management/web-search-sidecar-options.ts (extraction happens HERE, not L5) +- webSearchModelOptionsFor(config): candidate option list from webSearchSidecarCandidates. +- webSearchModelIsRejected(config, requested): membership gate — reject when requested is neither a candidate nor an auth-slot model; empty string always allowed (clears). +- Shared by BOTH routes below (mirrors vision-sidecar-options.ts pattern). + +## Server +- config-routes.ts GET /api/sidecar-settings: add webSearchModels — ALWAYS present, [] when empty (dashboard-shared.ts:273 omission fallback). Display-grandfather the persisted model into options (parity with vision GET :115) while rejecting NEW illegal writes. +- PUT /api/sidecar-settings webSearch.model: 400 with error naming the filter when webSearchModelIsRejected. +- PUT /api/claude-code (agent-settings-routes.ts:1064): SAME gate, REQUIRED — covers ocx claude config set --web-model. +- Out of scope (explicit): ocx config set webSearchSidecar.model raw JSON writes (operator escape hatch, same as vision). +- Vision PUT unchanged (provably-blind gate stays). + +## GUI +- use-dashboard-data.ts / dashboard-overview-sections.tsx: web-search model select consumes webSearchModels; [] renders empty-state, not full union. +- gui screenshot REQUIRED in PR body (enforce-target). + +## Tests +- new tests/sidecar-settings-web-search-gate.test.ts: PUT rejected for non-candidate; accepted for candidate + auth-slot; empty clears; claude-code route rejects the same id (今日 persists "claude-search" — update that fixture); GET always carries webSearchModels key. +- bun run lint:gui. + +## Verify: bun x tsc --noEmit && bun test tests/sidecar-settings-web-search-gate.test.ts tests/sidecar-settings-vision-controls.test.ts tests/claude-sidecar-override.test.ts && bun run lint:gui + diff --git a/devlog/_plan/260820_sidecar_selection_unification/050_layer5_cli_and_final.md b/devlog/_plan/260820_sidecar_selection_unification/050_layer5_cli_and_final.md new file mode 100644 index 0000000000..6cce459a02 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/050_layer5_cli_and_final.md @@ -0,0 +1,18 @@ +# 050 — Layer 5: CLI --list + docs + top-layer full validation (wp6) + +Branch: codex/sidecar-cli (base: codex/sidecar-write-gates). + +## CLI (extend EXISTING command — no new top-level command; src/cli/sidecar.ts WITHDRAWN) +- src/cli/agent.ts `ocx agent sidecar ` gains `--list`: prints the candidate sets from GET /api/sidecar-settings (webSearchModels / visionModels) — the exact sets the GUI sees, produced by the same server functions. +- Parse --list BEFORE the existing rejectArgs call (auditor note). +- Writes need no client-side gate: L4's server-side gate on the shared PUT covers CLI automatically. + +## Docs +- docs-site: web-search + vision pages updated (selection rules, auth slots, --list). English source only. + +## Final verification (top layer) +- bun run typecheck && bun run test (FULL suite) && bun run privacy:scan && bun run lint:gui +- gh pr list chain state recorded in goalplan c7. + +## Tests: extend existing CLI/agent tests for --list output parity with GET payload. + diff --git a/src/sidecar/auth.ts b/src/sidecar/auth.ts new file mode 100644 index 0000000000..57f8c60b57 --- /dev/null +++ b/src/sidecar/auth.ts @@ -0,0 +1,92 @@ +/** + * The one place that decides whether a sidecar may treat ChatGPT or Anthropic + * auth as PRESENT (#2188). Web-search and vision consumed two hand-rolled + * copies of the Anthropic predicate and no Codex-login predicate at all — + * provider presence was standing in for "logged in", which let a fresh install + * with the built-in forward provider but no credential offer Luna as a + * describer it could never run. + * + * Both flags are request-context-free: they read config plus the stored + * account state, never headers. Per-request usability (exact accounts, + * generation fences) stays in resolveFirstUsableOpenAiSidecar and the + * executors; this module only answers "is this side worth offering at all?". + */ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { listOpenAiForwardSidecarCandidates } from "../providers/openai-sidecar"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { isCodexAccountUsable } from "../codex/account-usability"; +import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../codex/account-id"; +import { getAccountSet } from "../oauth/store"; + +export interface SidecarAuthState { + /** ChatGPT login usable: canonical forward provider AND a live stored credential. */ + isCodexAuth: boolean; + /** Enabled anthropic-adapter OAuth provider whose active account is not marked for reauth. */ + isAnthropicAuth: boolean; + /** The provider an Anthropic-side executor would dispatch through, when isAnthropicAuth. */ + anthropicProviderName?: string; + anthropicProvider?: OcxProviderConfig; +} + +/** + * Fixed auth-slot models (#2188): logged-in sides keep these candidates even + * when the picker hides or disables them. The slot is the LOGIN's entitlement, + * not the catalog's. + */ +export const AUTH_SLOT_MODELS = { + codex: "gpt-5.6-luna", + anthropic: "claude-haiku-4-5", +} as const; + +export interface SidecarAuthSlot { + provider: string; + id: string; + slot: keyof typeof AUTH_SLOT_MODELS; +} + +/** Login-shaped, not provider-shaped: a forward provider with no credential is NOT Codex auth. */ +function hasUsableCodexLogin(config: OcxConfig): boolean { + if (listOpenAiForwardSidecarCandidates(config).length === 0) return false; + if (isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)) return true; + return (config.codexAccounts ?? []).some(account => + isSelectableCodexPoolAccount(account) && isCodexAccountUsable(config, account.id)); +} + +/** + * The predicate previously duplicated as findAnthropicSidecarProvider + * (web-search) and findAnthropicVisionProvider (vision): first enabled + * anthropic-adapter OAuth provider whose ACTIVE stored account holds a usable + * credential. getAccountSet + needsReauth, not getCredential — a terminally + * invalid account must not present as auth (audit F1). + */ +function findAnthropicAuthProvider( + config: OcxConfig, +): { providerName: string; provider: OcxProviderConfig } | undefined { + for (const [providerName, provider] of Object.entries(config.providers)) { + if (provider.disabled === true) continue; + if (provider.adapter !== "anthropic" || provider.authMode !== "oauth") continue; + const set = getAccountSet(providerName); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + if (active && active.needsReauth !== true) return { providerName, provider }; + } + return undefined; +} + +export function resolveSidecarAuth(config: OcxConfig): SidecarAuthState { + const anthropic = findAnthropicAuthProvider(config); + return { + isCodexAuth: hasUsableCodexLogin(config), + isAnthropicAuth: anthropic !== undefined, + ...(anthropic ? { anthropicProviderName: anthropic.providerName, anthropicProvider: anthropic.provider } : {}), + }; +} + +/** The auth-entitled fixed candidates. Emitted regardless of picker visibility. */ +export function sidecarAuthSlots(auth: SidecarAuthState): SidecarAuthSlot[] { + const slots: SidecarAuthSlot[] = []; + if (auth.isCodexAuth) slots.push({ provider: OPENAI_CODEX_PROVIDER_ID, id: AUTH_SLOT_MODELS.codex, slot: "codex" }); + if (auth.isAnthropicAuth && auth.anthropicProviderName) { + slots.push({ provider: auth.anthropicProviderName, id: AUTH_SLOT_MODELS.anthropic, slot: "anthropic" }); + } + return slots; +} diff --git a/src/vision/index.ts b/src/vision/index.ts index bc41ef33db..792e5db1b1 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -7,7 +7,7 @@ import { describeImage, type DescribeOutcome, type VisionSettings } from "./desc import { describeImageAnthropic } from "./anthropic-describe"; import { normalizeVisionReasoningForModel } from "./reasoning"; import type { CodexAuthContext } from "../codex/auth-context"; -import { getAccountSet } from "../oauth/store"; +import { resolveSidecarAuth } from "../sidecar/auth"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import type { SidecarOutcomeRecorder } from "../web-search/executor"; import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory"; @@ -215,15 +215,14 @@ export interface AnthropicVisionProvider { provider: OcxProviderConfig; } -/** First enabled Anthropic OAuth provider whose active stored account is not marked for reauth. */ +/** + * First enabled Anthropic OAuth provider whose active stored account is not marked for reauth. + * Delegates to the shared sidecar auth module (#2188) — same predicate as web-search. + */ export function findAnthropicVisionProvider(config: OcxConfig): AnthropicVisionProvider | undefined { - for (const [providerName, provider] of Object.entries(config.providers)) { - if (provider.disabled === true || provider.adapter !== "anthropic" || provider.authMode !== "oauth") continue; - const accountSet = getAccountSet(providerName); - const active = accountSet?.accounts.find(account => account.id === accountSet.activeAccountId); - if (active && active.needsReauth !== true) return { providerName, provider }; - } - return undefined; + const auth = resolveSidecarAuth(config); + if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined; + return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }; } export function resolveVisionBackend( diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 6278b8ca05..a713e74886 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -3,7 +3,7 @@ import { modelInList, toolChoiceToolPredicate } from "../types"; import { isModelTextOnly } from "../vision"; import type { SidecarSettings } from "./executor"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; -import { getAccountSet } from "../oauth/store"; +import { resolveSidecarAuth } from "../sidecar/auth"; import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout"; import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; @@ -83,16 +83,13 @@ export interface AnthropicSidecarProvider { * only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by * getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a * terminally-invalid account); token refresh happens later at executor time. + * Delegates to the shared sidecar auth module (#2188) so web-search and vision + * cannot drift on what "Anthropic auth present" means. */ export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined { - for (const [name, prov] of Object.entries(config.providers)) { - if (prov.disabled === true) continue; - if (prov.adapter !== "anthropic" || prov.authMode !== "oauth") continue; - const set = getAccountSet(name); - const active = set?.accounts.find(a => a.id === set.activeAccountId); - if (active && active.needsReauth !== true) return { providerName: name, provider: prov }; - } - return undefined; + const auth = resolveSidecarAuth(config); + if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined; + return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }; } /** diff --git a/tests/sidecar-auth.test.ts b/tests/sidecar-auth.test.ts new file mode 100644 index 0000000000..919ecf18e6 --- /dev/null +++ b/tests/sidecar-auth.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import * as storeModule from "../src/oauth/store"; +import * as usabilityModule from "../src/codex/account-usability"; + +// The shared auth module reads two stores: the OAuth account store (Anthropic +// side) and the Codex account credential state (ChatGPT side). Both are mocked +// at module level so no test touches disk. +let accountSets: Record; activeAccountId?: string }> = {}; +let usableCodexAccounts: Set = new Set(); + +mock.module("../src/oauth/store", () => ({ + ...storeModule, + getAccountSet: (provider: string) => accountSets[provider] ?? null, +})); +mock.module("../src/codex/account-usability", () => ({ + ...usabilityModule, + isCodexAccountUsable: (_config: unknown, accountId: string) => usableCodexAccounts.has(accountId), +})); + +import { AUTH_SLOT_MODELS, resolveSidecarAuth, sidecarAuthSlots } from "../src/sidecar/auth"; +import { findAnthropicSidecarProvider } from "../src/web-search"; +import { findAnthropicVisionProvider } from "../src/vision"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/account-id"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const forward: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }; +const anthropicOAuth: OcxProviderConfig = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }; + +function config(overrides: Partial = {}): OcxConfig { + return { port: 10100, defaultProvider: "openai", providers: { openai: forward }, ...overrides }; +} + +afterEach(() => { + accountSets = {}; + usableCodexAccounts = new Set(); +}); + +describe("isCodexAuth is login-shaped, not provider-shaped", () => { + test("forward provider present but no live token and no pool account -> false (B1 pin)", () => { + expect(resolveSidecarAuth(config()).isCodexAuth).toBe(false); + }); + + test("live main token -> true", () => { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + expect(resolveSidecarAuth(config()).isCodexAuth).toBe(true); + }); + + test("usable selectable pool credential alone -> true", () => { + usableCodexAccounts.add("acct-1"); + const cfg = config({ codexAccounts: [{ id: "acct-1", label: "one" } as never] }); + expect(resolveSidecarAuth(cfg).isCodexAuth).toBe(true); + }); + + test("credential without a canonical forward provider -> false", () => { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + const keyed: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://other.test/v1", authMode: "key", apiKey: "k" }; + const cfg = config({ providers: { openai: keyed } }); + expect(resolveSidecarAuth(cfg).isCodexAuth).toBe(false); + }); +}); + +describe("isAnthropicAuth mirrors the stored-OAuth predicate", () => { + const active = { accounts: [{ id: "a1" }], activeAccountId: "a1" }; + + test("enabled anthropic OAuth provider with healthy active account -> true, provider named", () => { + accountSets = { claude: active }; + const auth = resolveSidecarAuth(config({ providers: { openai: forward, claude: anthropicOAuth } })); + expect(auth.isAnthropicAuth).toBe(true); + expect(auth.anthropicProviderName).toBe("claude"); + }); + + test.each([ + ["disabled provider", { ...anthropicOAuth, disabled: true }, active], + ["wrong adapter", { ...anthropicOAuth, adapter: "openai-chat" } as OcxProviderConfig, active], + ["key auth", { ...anthropicOAuth, authMode: "key", apiKey: "k" } as OcxProviderConfig, active], + ["active account needs reauth", anthropicOAuth, { accounts: [{ id: "a1", needsReauth: true }], activeAccountId: "a1" }], + ["no account set", anthropicOAuth, undefined], + ])("%s -> false", (_name, provider, set) => { + accountSets = set ? { claude: set } : {}; + const auth = resolveSidecarAuth(config({ providers: { openai: forward, claude: provider } })); + expect(auth.isAnthropicAuth).toBe(false); + }); +}); + +describe("auth slots survive picker hiding (#2188 core invariant)", () => { + test("hidden/disabled Luna and Haiku still emitted when both logins present", () => { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; + const cfg = config({ + providers: { openai: forward, claude: anthropicOAuth }, + // Both slot models hidden from the picker: slots must not care. + disabledModels: [AUTH_SLOT_MODELS.codex, AUTH_SLOT_MODELS.anthropic], + }); + const slots = sidecarAuthSlots(resolveSidecarAuth(cfg)); + expect(slots).toEqual([ + { provider: "openai", id: "gpt-5.6-luna", slot: "codex" }, + { provider: "claude", id: "claude-haiku-4-5", slot: "anthropic" }, + ]); + }); + + test("no logins -> no slots", () => { + expect(sidecarAuthSlots(resolveSidecarAuth(config()))).toEqual([]); + }); +}); + +describe("find* helpers delegate to the shared predicate", () => { + test("web-search and vision return the same provider the shared module resolved", () => { + accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; + const cfg = config({ providers: { openai: forward, claude: anthropicOAuth } }); + const auth = resolveSidecarAuth(cfg); + const ws = findAnthropicSidecarProvider(cfg); + const vision = findAnthropicVisionProvider(cfg); + expect(ws?.providerName).toBe(auth.anthropicProviderName); + expect(vision?.providerName).toBe(auth.anthropicProviderName); + expect(ws?.provider).toBe(vision?.provider); + }); + + test("all three agree on absence", () => { + const cfg = config(); + expect(resolveSidecarAuth(cfg).isAnthropicAuth).toBe(false); + expect(findAnthropicSidecarProvider(cfg)).toBeUndefined(); + expect(findAnthropicVisionProvider(cfg)).toBeUndefined(); + }); +}); From a141859f76edc69d1c95d38afe09b5caa398801a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 21 Aug 2026 12:31:35 +0900 Subject: [PATCH 33/67] feat(sidecar): unified picker candidate set for both sidecars (#2188 L2) (#2204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidecar): unified picker candidate set consumed by the vision options (#2188) pickerVisibleSidecarCandidates = management picker rows (disabled rows dropped) ∪ auth slots, de-duplicated with the slot flag winning; a catalog outage degrades to the logged-in slots instead of an empty 500. visionSidecarCandidates applies rule 2 (drop provably text-only) on top. visionCandidateRows now wraps the unified set, so a hidden Luna/Haiku stays offerable for a logged-in side and the catalog-wide expansion of visionEligibleModelOptions is bounded by picker visibility. * fix(sidecar): keep write-gate evidence unfiltered in visionCandidateRows Rule 2 pre-filtering removed every text-only row before the PUT gate ran, deadening visionDescriberIsProvablyBlind's catalog-row proof step and flipping reject to allow for ids only proven blind by a picker row. Rule 2 stays in the options path; the gate reads the raw union. Adds the reject-path regression the reviewer demanded. --- .../management/vision-sidecar-options.ts | 29 ++-- src/sidecar/candidates.ts | 83 +++++++++++ tests/sidecar-candidates.test.ts | 131 ++++++++++++++++++ 3 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 src/sidecar/candidates.ts create mode 100644 tests/sidecar-candidates.test.ts diff --git a/src/server/management/vision-sidecar-options.ts b/src/server/management/vision-sidecar-options.ts index aefbb4dc86..7485f49551 100644 --- a/src/server/management/vision-sidecar-options.ts +++ b/src/server/management/vision-sidecar-options.ts @@ -17,7 +17,8 @@ import { type VisionSidecarBackend, } from "../../vision/eligibility"; import { listOpenAiForwardSidecarCandidates } from "../../providers/openai-sidecar"; -import { listManagementModelRows } from "./model-rows"; +import { pickerVisibleSidecarCandidates } from "../../sidecar/candidates"; +import { resolveSidecarAuth } from "../../sidecar/auth"; /** * Backends whose executor could actually run: openai forward, anthropic OAuth. @@ -41,17 +42,23 @@ export function enabledVisionBackends( return backends.length > 0 ? backends : ["openai", "anthropic"]; } -/** Visible catalog rows in the shape the eligibility predicate consumes. */ +/** + * Visible catalog rows in the shape the eligibility predicate consumes. + * Sourced from the unified picker set (#2188): picker-visible rows ∪ auth + * slots, UNFILTERED. Rule 2 (− provably text-only) belongs to the OPTIONS + * path only (visionEligibleModelOptions already applies it). The PUT gate + * consumes this list as EVIDENCE: a picker row proving an id text-only is + * exactly what visionDescriberIsProvablyBlind needs to reject that id, so + * pre-filtering here would deaden the gate (review F1: reject → allow flip). + */ export async function visionCandidateRows(config: OcxConfig): Promise { - let rows: Awaited> = []; - // A catalog outage must not 500 the settings route nor reject a write; with [] - // the option list degrades to the baselines, which is the intended floor. - try { rows = await listManagementModelRows(config); } catch { rows = []; } - return rows.filter(row => row.disabled !== true).map(row => ({ - provider: row.provider, - id: row.id, - ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), - ...(row.native ? { native: true } : {}), + const auth = resolveSidecarAuth(config); + const all = await pickerVisibleSidecarCandidates(config, auth); + return all.map(candidate => ({ + provider: candidate.provider, + id: candidate.id, + ...(candidate.inputModalities ? { inputModalities: candidate.inputModalities } : {}), + ...(candidate.native ? { native: true } : {}), })); } diff --git a/src/sidecar/candidates.ts b/src/sidecar/candidates.ts new file mode 100644 index 0000000000..bd7693d4c5 --- /dev/null +++ b/src/sidecar/candidates.ts @@ -0,0 +1,83 @@ +/** + * The unified picker-visibility candidate set both sidecars consume (#2188). + * + * Rule 1 (picker): a model may be OFFERED as a sidecar backend only if the + * management picker would show it — the same `listManagementModelRows` rows + * the GUI Models tab renders, minus disabled rows. Rule 1's only exception is + * the auth slots: a logged-in side keeps its fixed slot model (Luna/Haiku) + * even when the picker hides it, because the slot is the login's entitlement. + * + * Vision additionally applies rule 2 (− provably text-only); web-search + * applies its own rule 2 (∩ probed backend with an executor) in + * src/web-search/backends.ts. Both start from THIS set so the two sidecars + * cannot diverge on what "visible" means. + */ +import type { OcxConfig } from "../types"; +import { listManagementModelRows } from "../server/management/model-rows"; +import { modelAcceptsImageInput, type VisionCandidateModel } from "../vision/eligibility"; +import { sidecarAuthSlots, type SidecarAuthState } from "./auth"; + +export interface SidecarCandidate { + provider: string; + id: string; + native?: boolean; + inputModalities?: string[]; + /** True when this row is an auth-slot entitlement rather than a picker row. */ + authSlot?: boolean; +} + +/** + * (picker-visible rows) ∪ (auth slots). De-duplicated by provider+id with the + * auth-slot flag winning, so a slot model that is ALSO picker-visible still + * reads as slot-backed. A catalog outage degrades to slots only — the settings + * routes must not 500 and must keep the logged-in floor populated. + */ +export async function pickerVisibleSidecarCandidates( + config: OcxConfig, + auth: SidecarAuthState, +): Promise { + let rows: Awaited> = []; + try { rows = await listManagementModelRows(config); } catch { rows = []; } + const byKey = new Map(); + for (const row of rows) { + if (row.disabled === true) continue; + byKey.set(`${row.provider}/${row.id}`, { + provider: row.provider, + id: row.id, + ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), + ...(row.native ? { native: true } : {}), + }); + } + for (const slot of sidecarAuthSlots(auth)) { + byKey.set(`${slot.provider}/${slot.id}`, { + provider: slot.provider, + id: slot.id, + // Slot models are known image-capable; their only exclusion path is the + // provider's explicit consumer list (same stance as baselineCandidate). + inputModalities: ["text", "image"], + authSlot: true, + }); + } + return [...byKey.values()]; +} + +/** + * Rule 2 for vision: drop rows PROVABLY text-only. Unknown stays eligible — + * the picker filter (rule 1) already bounded the set, so permissive-unknown + * no longer expands to the whole catalog. + */ +export function visionSidecarCandidates( + config: Pick, + all: readonly SidecarCandidate[], +): SidecarCandidate[] { + return all.filter(candidate => modelAcceptsImageInput(config, toVisionCandidate(candidate)) !== false); +} + +function toVisionCandidate(candidate: SidecarCandidate): VisionCandidateModel { + return { + provider: candidate.provider, + id: candidate.id, + ...(candidate.inputModalities ? { inputModalities: candidate.inputModalities } : {}), + ...(candidate.native ? { native: true } : {}), + }; +} diff --git a/tests/sidecar-candidates.test.ts b/tests/sidecar-candidates.test.ts new file mode 100644 index 0000000000..ab45cafa52 --- /dev/null +++ b/tests/sidecar-candidates.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import * as storeModule from "../src/oauth/store"; +import * as usabilityModule from "../src/codex/account-usability"; +import * as modelRowsModule from "../src/server/management/model-rows"; + +let accountSets: Record; activeAccountId?: string }> = {}; +let usableCodexAccounts: Set = new Set(); +let managementRows: Array> | Error = []; + +mock.module("../src/oauth/store", () => ({ + ...storeModule, + getAccountSet: (provider: string) => accountSets[provider] ?? null, +})); +mock.module("../src/codex/account-usability", () => ({ + ...usabilityModule, + isCodexAccountUsable: (_config: unknown, accountId: string) => usableCodexAccounts.has(accountId), +})); +mock.module("../src/server/management/model-rows", () => ({ + ...modelRowsModule, + listManagementModelRows: async () => { + if (managementRows instanceof Error) throw managementRows; + return managementRows; + }, +})); + +import { pickerVisibleSidecarCandidates, visionSidecarCandidates } from "../src/sidecar/candidates"; +import { resolveSidecarAuth } from "../src/sidecar/auth"; +import { visionCandidateRows, visionDescriberIsProvablyBlind } from "../src/server/management/vision-sidecar-options"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/account-id"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const forward: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }; +const anthropicOAuth: OcxProviderConfig = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }; + +function config(overrides: Partial = {}): OcxConfig { + return { port: 10100, defaultProvider: "openai", providers: { openai: forward }, ...overrides }; +} + +afterEach(() => { + accountSets = {}; + usableCodexAccounts = new Set(); + managementRows = []; +}); + +function loginBoth(): void { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; +} + +describe("pickerVisibleSidecarCandidates", () => { + test("disabled rows disappear; hidden Luna survives via the auth slot", async () => { + loginBoth(); + managementRows = [ + { provider: "openai", id: "gpt-5.6-luna", disabled: true, native: true }, + { provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }, + { provider: "routed", id: "some-model", disabled: false }, + ]; + const cfg = config({ providers: { openai: forward, claude: anthropicOAuth } }); + const all = await pickerVisibleSidecarCandidates(cfg, resolveSidecarAuth(cfg)); + const ids = all.map(c => `${c.provider}/${c.id}`).sort(); + expect(ids).toEqual([ + "claude/claude-haiku-4-5", + "openai/gpt-5.6-luna", + "openai/gpt-5.6-terra", + "routed/some-model", + ]); + expect(all.find(c => c.id === "gpt-5.6-luna")?.authSlot).toBe(true); + }); + + test("picker-visible slot model keeps the slot flag (slot wins de-dup)", async () => { + loginBoth(); + managementRows = [{ provider: "openai", id: "gpt-5.6-luna", disabled: false, native: true }]; + const cfg = config({ providers: { openai: forward, claude: anthropicOAuth } }); + const all = await pickerVisibleSidecarCandidates(cfg, resolveSidecarAuth(cfg)); + expect(all.filter(c => c.id === "gpt-5.6-luna")).toHaveLength(1); + expect(all.find(c => c.id === "gpt-5.6-luna")?.authSlot).toBe(true); + }); + + test("catalog outage degrades to auth slots only, not a 500", async () => { + loginBoth(); + managementRows = new Error("catalog down"); + const cfg = config({ providers: { openai: forward, claude: anthropicOAuth } }); + const all = await pickerVisibleSidecarCandidates(cfg, resolveSidecarAuth(cfg)); + expect(all.map(c => c.id).sort()).toEqual(["claude-haiku-4-5", "gpt-5.6-luna"]); + }); + + test("no logins and empty catalog -> empty set", async () => { + const all = await pickerVisibleSidecarCandidates(config(), resolveSidecarAuth(config())); + expect(all).toEqual([]); + }); +}); + +describe("visionSidecarCandidates (rule 2: − provably text-only)", () => { + test("provably text-only row excluded; unknown stays; slots stay", async () => { + loginBoth(); + managementRows = [ + { provider: "routed", id: "text-model", disabled: false, inputModalities: ["text"] }, + { provider: "routed", id: "unknown-model", disabled: false }, + ]; + const cfg = config({ providers: { openai: forward, claude: anthropicOAuth } }); + const all = await pickerVisibleSidecarCandidates(cfg, resolveSidecarAuth(cfg)); + const vision = visionSidecarCandidates(cfg, all); + const ids = vision.map(c => c.id).sort(); + expect(ids).toEqual(["claude-haiku-4-5", "gpt-5.6-luna", "unknown-model"]); + }); + + test("noVisionModels consumer listing beats the slot's advertised modalities", async () => { + loginBoth(); + const blindLuna: OcxProviderConfig = { ...forward, noVisionModels: ["gpt-5.6-luna"] }; + const cfg = config({ providers: { openai: blindLuna, claude: anthropicOAuth } }); + const all = await pickerVisibleSidecarCandidates(cfg, resolveSidecarAuth(cfg)); + const vision = visionSidecarCandidates(cfg, all); + expect(vision.map(c => c.id)).not.toContain("gpt-5.6-luna"); + expect(vision.map(c => c.id)).toContain("claude-haiku-4-5"); + }); +}); + +describe("write-gate evidence is NOT pre-filtered (review F1 regression)", () => { + test("a picker row proving an id text-only still reaches the PUT gate and rejects it", async () => { + loginBoth(); + managementRows = [ + { provider: "routed", id: "row-proven-blind", disabled: false, inputModalities: ["text"] }, + ]; + const cfg = config({ providers: { openai: forward, claude: anthropicOAuth } }); + const candidates = await visionCandidateRows(cfg); + // The evidence row must survive into the candidates list... + expect(candidates.map(c => c.id)).toContain("row-proven-blind"); + // ...so the gate can prove blindness from it (neither vendor table knows this id). + expect(visionDescriberIsProvablyBlind(cfg, "row-proven-blind", candidates, undefined)).toBe(true); + }); +}); From bd79ab5a67ba7728e54292fa1efcfc12396b4bf4 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 21 Aug 2026 12:31:53 +0900 Subject: [PATCH 34/67] feat(web-search): backend registry and executor-runnable candidate set (#2188 L3) (#2206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web-search): backend registry and candidate intersection (#2188) WEB_SEARCH_BACKENDS registers only executor-backed, auth-probed backends (openai forward, anthropic stored-OAuth); future backends stay out until a live probe and executor land. webSearchSidecarCandidates = (picker ∪ auth slots) ∩ active backend family. planWebSearch consumes the shared auth state for presence; resolveSidecarBackend's explicit-or-openai preference is unchanged, and the config comment that claimed the opposite now tells the truth. * fix(web-search): restrict the openai backend to executor-runnable ids Account-bound selector/slug rows and custom openai-keyed rows reach the candidate set as provider openai, but the forward executor POSTs settings.model verbatim with no routeModel prefix-stripping, so those ids can never run. eligibleModel now requires a bare native slug or the auth slot. docs-site stops claiming anthropic-preferred for web-search (that default belongs to vision only). * docs(vision): describe the vision backend default on its own terms --- .../src/content/docs/guides/claude-code.md | 5 +- docs-site/src/content/docs/guides/sidecars.md | 9 +- .../docs/reference/configuration/server.md | 4 +- src/types/config.ts | 5 +- src/web-search/backends.ts | 72 ++++++++++ src/web-search/index.ts | 7 +- tests/web-search-candidates.test.ts | 124 ++++++++++++++++++ 7 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 src/web-search/backends.ts create mode 100644 tests/web-search-candidates.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 7c3a86dea3..6f38cc316d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -332,8 +332,9 @@ Both sidecars can use either backend: | `openai` | A small GPT model through the ChatGPT `forward` provider | A ChatGPT login and an enabled `authMode: "forward"` provider | | `anthropic` | Claude through stored Anthropic OAuth; web search uses `web_search_20250305` and vision sends the image to Claude for description | An enabled `adapter: "anthropic"`, `authMode: "oauth"` provider whose active stored account is not marked `needsReauth` | -An explicit `backend` always wins. When it is omitted, opencodex selects `anthropic` if a usable -stored Anthropic OAuth account exists; otherwise it selects `openai`. Explicitly selecting +An explicit `backend` always wins. When it is omitted, the **web-search** sidecar always selects +`openai` (`anthropic` runs only when explicitly configured), while the **vision** sidecar selects +`anthropic` if a usable stored Anthropic OAuth account exists, otherwise `openai`. Explicitly selecting `anthropic` without a usable credential **fails closed**: opencodex does not silently borrow ChatGPT credentials or switch backends. The OpenAI backend likewise stays off without both login auth and a forward provider. diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index ab533d2110..97dae40734 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -9,10 +9,11 @@ stored Anthropic OAuth provider. Sidecar errors become bounded tool results or i of failing the whole turn. :::note[Automatic backend selection] -Explicit `backend` config wins. When unset, opencodex uses `anthropic` if an enabled Anthropic OAuth -provider has an active account not marked `needsReauth`; otherwise it uses `openai`. Explicit -`anthropic` without that credential fails closed. `openai` requires both ChatGPT login auth and an -enabled `forward` provider. +Explicit `backend` config wins. The two sidecars default differently when `backend` is unset: +**web search** always defaults to `openai` — `anthropic` runs only when explicitly configured. +**Vision** defaults to `anthropic` if an enabled Anthropic OAuth provider has an active account not +marked `needsReauth`, otherwise `openai`. Explicit `anthropic` without that credential fails +closed. `openai` requires both ChatGPT login auth and an enabled `forward` provider. ::: ## Web-search sidecar diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index e36975cd62..6726bcfaf2 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -205,7 +205,7 @@ Images API paths and response shape expected by Codex. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Master switch. | -| `backend?` | `"openai" \| "anthropic"` | auto | Explicit wins; otherwise usable stored Anthropic OAuth selects `anthropic`, then `openai`. | +| `backend?` | `"openai" \| "anthropic"` | `openai` | Explicit wins; unset always resolves to `openai`. `anthropic` runs only when explicitly configured. | | `model?` | `string` | backend-dependent | `gpt-5.6-luna` for OpenAI or `claude-sonnet-5` for Anthropic. Legacy explicit `gpt-5.4-mini` migrates on start. | | `reasoning?` | `string` | `low` | Sidecar effort. `minimal` is rejected with web search. | | `maxSearchesPerTurn?` | `number` | `3` | Real searches allowed per main-model turn. | @@ -227,7 +227,7 @@ an inactivity guard, not a total generation deadline. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Master image-description switch. | -| `backend?` | `"openai" \| "anthropic"` | auto | Same explicit-first, Anthropic-credential-aware selection as web search. | +| `backend?` | `"openai" \| "anthropic"` | auto | Explicit wins; unset prefers a usable stored Anthropic OAuth credential, else `openai`. | | `model?` | `string` | backend-dependent | `gpt-5.4-mini` for OpenAI or `claude-sonnet-5` for Anthropic. | | `maxDescriptionsPerTurn?` | `number` | `8` | New description cache misses admitted per main turn. `0` disables calls; invalid values use default. | | `timeoutMs?` | `number` | `45000` | Sidecar fetch timeout. Integer 1–2147483647. | diff --git a/src/types/config.ts b/src/types/config.ts index f00bfbcdd4..473576910f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -790,8 +790,9 @@ export interface OcxWebSearchSidecarConfig { /** * Which backend actually runs the server-side search. "openai" replays the hosted web_search via * the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude - * model authenticated by the STORED anthropic OAuth credential. Unset resolves to "anthropic" when a - * usable anthropic OAuth credential exists, else "openai". + * model authenticated by the STORED anthropic OAuth credential. Unset resolves to "openai"; + * "anthropic" runs only when explicitly configured (auto-selecting it from credential availability + * once sent incompatible models to the Anthropic API — see resolveSidecarBackend). */ backend?: "openai" | "anthropic"; /** Sidecar model that runs the real server-side web_search (must be a native ChatGPT model). */ diff --git a/src/web-search/backends.ts b/src/web-search/backends.ts new file mode 100644 index 0000000000..f3daad939a --- /dev/null +++ b/src/web-search/backends.ts @@ -0,0 +1,72 @@ +/** + * Which backends may RUN a hosted web_search, and which candidate rows each + * can run (#2188 rule 2 for web-search: ∩ probed backend with an executor). + * + * A backend registers here only when BOTH hold: + * - an executor exists in this repository (src/web-search/executor.ts or + * anthropic-executor.ts today), and + * - its liveness probe passes. For the two shipped backends the probe IS + * auth presence — the ChatGPT forward path and the stored Anthropic OAuth + * path fail closed without a credential, so a live credential is the + * strongest pre-flight signal short of spending a search. + * + * Future backends (Gemini google_search, Grok web_search, Zen hosted search, + * Exa-class vendors) stay OUT of this table until a live probe and an + * executor land — the research and probe contracts are recorded in + * devlog/_plan/260820_sidecar_selection_unification/002 and 031. Documenting + * a tool is not the same as being able to run it. + */ +import type { OcxConfig } from "../types"; +import { AUTH_SLOT_MODELS, type SidecarAuthState } from "../sidecar/auth"; +import type { SidecarCandidate } from "../sidecar/candidates"; + +export interface WebSearchBackendDescriptor { + backend: "openai" | "anthropic"; + /** Liveness signal for this backend (auth presence for the shipped two). */ + isActive(auth: SidecarAuthState): boolean; + /** Which candidate rows this backend's executor can actually run. */ + eligibleModel(candidate: SidecarCandidate, auth: SidecarAuthState): boolean; +} + +export const WEB_SEARCH_BACKENDS: readonly WebSearchBackendDescriptor[] = [ + { + backend: "openai", + isActive: auth => auth.isCodexAuth, + // The ChatGPT forward executor runs BARE native slugs and the Codex auth + // slot — settings.model is POSTed verbatim to the forward /responses, so + // an account-bound "selector/slug" row (model-rows emits those as + // provider "openai", native true) or a custom openai-keyed row would + // persist an id the executor cannot run (review F1). The sidecar never + // calls routeModel; there is no prefix-stripping on this path. + eligibleModel: candidate => candidate.provider === "openai" + && (candidate.native === true || candidate.authSlot === true) + && !candidate.id.includes("/"), + }, + { + backend: "anthropic", + isActive: auth => auth.isAnthropicAuth, + // The stored-OAuth Messages executor dispatches through exactly ONE + // provider — the one the shared auth module resolved. Same-adapter keyed + // rows are unreachable, mirroring visionBackendForCandidate's stance. + eligibleModel: (candidate, auth) => candidate.provider === auth.anthropicProviderName, + }, +]; + +/** + * (picker-visible ∪ auth slots) ∩ (active backend able to run the row). + * The auth slots always survive their own side's activation: a logged-in + * side keeps Luna/Haiku even when the picker hides them. + */ +export function webSearchSidecarCandidates( + _config: OcxConfig, + auth: SidecarAuthState, + all: readonly SidecarCandidate[], +): SidecarCandidate[] { + const active = WEB_SEARCH_BACKENDS.filter(descriptor => descriptor.isActive(auth)); + return all.filter(candidate => active.some(descriptor => descriptor.eligibleModel(candidate, auth))); +} + +/** True when the id is one of the fixed auth-slot models (#2188 write-gate exception). */ +export function isWebSearchAuthSlotModel(id: string): boolean { + return id === AUTH_SLOT_MODELS.codex || id === AUTH_SLOT_MODELS.anthropic; +} diff --git a/src/web-search/index.ts b/src/web-search/index.ts index a713e74886..cfa1440b31 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -154,7 +154,12 @@ export function planWebSearch( const routedModelStallTimeoutMs = resolveRoutedModelStallTimeoutMs(cfg.routedModelStallTimeoutMs); // Same `?? 200_000` default the server applies when threading connectTimeoutMs into the loop. const connectTimeoutMs = config.connectTimeoutMs ?? 200_000; - const anthropicSidecar = findAnthropicSidecarProvider(config); + // Shared auth state (#2188): presence only — backend PREFERENCE stays with + // resolveSidecarBackend's explicit-or-openai contract. + const auth = resolveSidecarAuth(config); + const anthropicSidecar = auth.isAnthropicAuth && auth.anthropicProviderName && auth.anthropicProvider + ? { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider } + : undefined; const backend = resolveSidecarBackend(cfg.backend); const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES; const stallTimeoutSec = webSearchStallTimeoutSec( diff --git a/tests/web-search-candidates.test.ts b/tests/web-search-candidates.test.ts new file mode 100644 index 0000000000..8bc85ebdcd --- /dev/null +++ b/tests/web-search-candidates.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import * as storeModule from "../src/oauth/store"; +import * as usabilityModule from "../src/codex/account-usability"; +import * as modelRowsModule from "../src/server/management/model-rows"; + +let accountSets: Record; activeAccountId?: string }> = {}; +let usableCodexAccounts: Set = new Set(); +let managementRows: Array> = []; + +mock.module("../src/oauth/store", () => ({ + ...storeModule, + getAccountSet: (provider: string) => accountSets[provider] ?? null, +})); +mock.module("../src/codex/account-usability", () => ({ + ...usabilityModule, + isCodexAccountUsable: (_config: unknown, accountId: string) => usableCodexAccounts.has(accountId), +})); +mock.module("../src/server/management/model-rows", () => ({ + ...modelRowsModule, + listManagementModelRows: async () => managementRows, +})); + +import { isWebSearchAuthSlotModel, webSearchSidecarCandidates } from "../src/web-search/backends"; +import { pickerVisibleSidecarCandidates } from "../src/sidecar/candidates"; +import { resolveSidecarAuth } from "../src/sidecar/auth"; +import { resolveSidecarBackend } from "../src/web-search"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/account-id"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const forward: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }; +const anthropicOAuth: OcxProviderConfig = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }; + +function config(overrides: Partial = {}): OcxConfig { + return { port: 10100, defaultProvider: "openai", providers: { openai: forward, claude: anthropicOAuth }, ...overrides }; +} + +afterEach(() => { + accountSets = {}; + usableCodexAccounts = new Set(); + managementRows = []; +}); + +async function candidatesFor(cfg: OcxConfig) { + const auth = resolveSidecarAuth(cfg); + const all = await pickerVisibleSidecarCandidates(cfg, auth); + return webSearchSidecarCandidates(cfg, auth, all); +} + +describe("webSearchSidecarCandidates = (picker ∪ slots) ∩ active backend", () => { + test("no Codex login: openai side inactive — native rows and Luna absent, Haiku present", async () => { + accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; + managementRows = [ + { provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }, + { provider: "claude", id: "claude-sonnet-5", disabled: false }, + ]; + const ids = (await candidatesFor(config())).map(c => c.id).sort(); + expect(ids).toEqual(["claude-haiku-4-5", "claude-sonnet-5"]); + }); + + test("hidden Luna + Codex login: present via the slot", async () => { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + managementRows = [{ provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }]; + const cfg = config({ disabledModels: ["gpt-5.6-luna"] }); + const ids = (await candidatesFor(cfg)).map(c => c.id).sort(); + expect(ids).toEqual(["gpt-5.6-luna", "gpt-5.6-terra"]); + }); + + test("rows outside both backend families are excluded even when picker-visible", async () => { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; + managementRows = [ + { provider: "routed", id: "grok-4.6", disabled: false }, + { provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }, + ]; + const ids = (await candidatesFor(config())).map(c => c.id).sort(); + expect(ids).toEqual(["claude-haiku-4-5", "gpt-5.6-luna", "gpt-5.6-terra"]); + }); + + test("keyed same-adapter anthropic provider rows stay unreachable", async () => { + accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; + const keyedClaude: OcxProviderConfig = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key", apiKey: "k" }; + managementRows = [{ provider: "keyed-claude", id: "claude-sonnet-5", disabled: false }]; + const cfg = config({ providers: { openai: forward, claude: anthropicOAuth, "keyed-claude": keyedClaude } }); + const ids = (await candidatesFor(cfg)).map(c => c.id).sort(); + expect(ids).toEqual(["claude-haiku-4-5"]); + }); + + test("no logins at all -> empty candidate set", async () => { + managementRows = [{ provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }]; + expect(await candidatesFor(config())).toEqual([]); + }); + + test("account-bound selector/slug native rows are excluded (executor cannot run them)", async () => { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + managementRows = [ + { provider: "openai", id: "work/gpt-future-unlisted", disabled: false, native: true }, + { provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }, + ]; + const ids = (await candidatesFor(config())).map(c => c.id).sort(); + expect(ids).toEqual(["gpt-5.6-luna", "gpt-5.6-terra"]); + }); + + test("custom openai-keyed rows without the native flag are excluded", async () => { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + managementRows = [ + { provider: "openai", id: "my-custom-gpt", disabled: false }, + { provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }, + ]; + const ids = (await candidatesFor(config())).map(c => c.id).sort(); + expect(ids).toEqual(["gpt-5.6-luna", "gpt-5.6-terra"]); + }); +}); + +describe("existing contracts stay pinned", () => { + test("resolveSidecarBackend(undefined) is still openai (B4: no default change)", () => { + expect(resolveSidecarBackend(undefined)).toBe("openai"); + }); + + test("auth-slot membership helper knows exactly the two slot models", () => { + expect(isWebSearchAuthSlotModel("gpt-5.6-luna")).toBe(true); + expect(isWebSearchAuthSlotModel("claude-haiku-4-5")).toBe(true); + expect(isWebSearchAuthSlotModel("gpt-5.6-terra")).toBe(false); + }); +}); From 60f60b60c7513b8ca951cbe5f4b98585ac18bc76 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 21 Aug 2026 12:32:13 +0900 Subject: [PATCH 35/67] feat(management): web-search sidecar write gates and filtered gui lists (#2188 L4) (#2209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(management): web-search sidecar write gate and filtered option lists (#2188) web-search-sidecar-options.ts is the one policy module both writing routes share: PUT /api/sidecar-settings and the claude-code webSearch override now refuse a model outside (runnable candidates ∪ auth slots), naming the filter and attaching the allowed list. GET always carries webSearchModels — [] means none; omission is reserved for older servers so the dashboard's legacy fallback stays correct. The persisted model is display-grandfathered like vision. The dashboard picker consumes the server list instead of expanding the full openai+anthropic union. * fix(management): echo webSearchModels on PUT and prove the HTTP contract The dashboard rebuilds sidecar state from the PUT body; an omitted key reads as old-server and falls back to the full union — exactly the regression the gate exists to prevent. PUT now echoes the same helper as GET. allowedModels lists the always-legal auth slots. The claude-management known-good snapshot uses a slot id so its later assertions run against real persisted state. HTTP tests cover GET-always-present, PUT reject/accept/echo, and empty-string clear. * test(sidecar): repin test 14 to the new web-search membership gate * test(vision): use an auth-slot web-search id so the round-trip survives the gate * fix(sidecar): bind web-search options to runnable backend pairs Carry backend/model provenance on every server option and preserve it through the dashboard picker, including auth-slot rows absent from the ordinary model catalog. Validate the effective explicit pair on both sidecar-settings and Claude override PUT paths, including partial updates, while retaining empty clears, grandfathered display rows, and PUT option echo. Evidence: 50-pass four-suite management/sidecar gate; 9-pass GUI option regression; root tsc, GUI lint, and GUI production build green. --- .../assets/l4-websearch-picker-filtered.png | Bin 0 -> 189720 bytes gui/src/pages/dashboard-overview-sections.tsx | 6 +- gui/src/pages/dashboard-shared.ts | 71 +++++++ gui/src/pages/use-dashboard-data.ts | 21 +- gui/tests/vision-model-options.test.ts | 14 ++ .../management/agent-settings-routes.ts | 38 +++- src/server/management/config-routes.ts | 33 +++ .../management/web-search-sidecar-options.ts | 116 +++++++++++ tests/claude-management-api.test.ts | 36 +++- tests/sidecar-settings-vision-filter.test.ts | 13 +- .../sidecar-settings-web-search-gate.test.ts | 194 ++++++++++++++++++ tests/vision-anthropic.test.ts | 8 +- 12 files changed, 520 insertions(+), 30 deletions(-) create mode 100644 devlog/_plan/260820_sidecar_selection_unification/assets/l4-websearch-picker-filtered.png create mode 100644 src/server/management/web-search-sidecar-options.ts create mode 100644 tests/sidecar-settings-web-search-gate.test.ts diff --git a/devlog/_plan/260820_sidecar_selection_unification/assets/l4-websearch-picker-filtered.png b/devlog/_plan/260820_sidecar_selection_unification/assets/l4-websearch-picker-filtered.png new file mode 100644 index 0000000000000000000000000000000000000000..b57768804859dc04a35e8b9a225b745ea35c0201 GIT binary patch literal 189720 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

4^$f+rfN>B-f7h;G2Ep->jBN2}CY0|mW1 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

yZcFo9A@C;=IH!*`9D@Vf^MH4_vOy zJ9Gm2V(h?3Gw@aZOYvZ%iAo&gF+<9j=UE{~W{MqQLLF!B*)x7-riFp&2{Y9$SIg+7J;ZU*0<6n#u zKSA#>g1jj!zQL@Eo`1GGd7a@jo4#^HxRa+P0Wco!3FBgtpA1?`qG!~S;J{f$UIydi z@#CORUFkvei5s|l-Jgy<&`T)2#*+E@pIXIlUwWXuE5v2&2J!T@Vpn&o)L<9G??}6Z z)q5>({^AvSN5HdHP^mUr;05bcI2juT$fT)Mc#8iq(MW}dtTcPV6^boN(~IkS5es%N*xeYjgDPFv)%G}j{wF(j{8Jtn^_Vo3rcc*3k2?)}cfB*mh literal 0 HcmV?d00001 diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 7834743d0b..6b944ed5eb 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -15,7 +15,7 @@ import { requireJson, type SidecarPatch, shadowCallModelOptions, - sidecarBackendForModel, + webSearchSidecarSelectionForModel, updateJobLabel, visionEnabledPatch, visionMaxDescriptionsPatch, @@ -515,7 +515,9 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {