From 8260ff392a6fbec462b4377bcca35df7292e8b41 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:38:31 +0900 Subject: [PATCH 001/224] docs: plan V2 delegation contract reconciliation --- devlog/_plan/260912_v2_contracts/000_plan.md | 24 ++++++++++ .../260912_v2_contracts/010_plaintext.md | 34 +++++++++++++ .../_plan/260912_v2_contracts/020_recovery.md | 48 +++++++++++++++++++ .../260912_v2_contracts/030_verification.md | 9 ++++ 4 files changed, 115 insertions(+) create mode 100644 devlog/_plan/260912_v2_contracts/000_plan.md create mode 100644 devlog/_plan/260912_v2_contracts/010_plaintext.md create mode 100644 devlog/_plan/260912_v2_contracts/020_recovery.md create mode 100644 devlog/_plan/260912_v2_contracts/030_verification.md diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_plan/260912_v2_contracts/000_plan.md new file mode 100644 index 0000000000..a58b0249af --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/000_plan.md @@ -0,0 +1,24 @@ +# V2 delegation contracts + +This unit reconciles plaintext prevention (#2495) separately from encrypted task recovery (#3661). Eligible native parents may opt into plaintext V2 calls; recovery continues to use its existing authenticated, bounded path. The replacement candidates #4242/#4243 are compared against the exact issue contract before any adoption. + +Loop: satisfy-spec, triggered by the authorized v2 lane. Goal: scoped carry PRs and final cumulative hosted CI evidence. Non-goals: merges, issue closure, releases, installed service/config changes, native GitHub stacks, local product tests/build/typecheck/install. Local tests are NOT RUN by explicit instruction. Verification: source/diff checks during each cycle; Cross-platform CI on the final published head, with run IDs and conclusions retained. Stop: implementation, audit and CI evidence handed to the integration owner; no claim of integration. Outcomes: DONE with evidence, or an explicit unresolved acceptance/gate. Artifacts: this unit plus ignored `.tmp/v2/` evidence. Escalation: real tool denials and unresolved security/contract blockers are recorded; no new access/settings. Resource bounds: available account/tool permissions, this worktree only, no user token/time/agent-count cap. + +| Cycle | Outcome | Design | +|---|---|---| +| wp0 | Docs-only roadmap locked by independent design reflection and A review | this document | +| wp1 | Exact plaintext request/response contract and regression coverage | [010](010_plaintext.md) | +| wp2 | Bounded encrypted envelope handling and residual disposition | [020](020_recovery.md) | +| wp3 | Final cumulative hosted verification and durable handoff | [030](030_verification.md) | + +wp1 and wp2 are distinct capabilities; execution order does not itself create a PR dependency. Use independent dev-based PRs if neither consumes the other's changes. A shared final cumulative verification branch may be needed to prove composition; do not silently call intermediate CI final-tip evidence. + +Existing owners: `src/adapters/openai-responses.ts`, `src/server/responses/core.ts`, `src/server/responses/agent-task-recovery.ts`; tests remain under domain directories. Source-of-truth pages are mapped by `structure/INDEX.md`. Reuse these owners, not a second server/recovery subsystem. Do-nothing/config-only alternatives cannot provide the missing wire behavior. + +Generic supported inherited-model subagents provide independent design consultation and separate review. Native architect selection is unavailable and is not claimed. Original contributor attribution follows the adopted source, including Sigurd-git for #2496 and SB Yoon if any #4242 code is carried. Source PRs/issues remain open or closed in their current state until the integration owner decides. + +## Cycle record + +wp0: P entered with own session binding; roadmap in progress. Product validation NOT RUN. + +wp0 A: Gauss GO-WITH-FIXES (blockers=0); WP1-A01 cache ordering and WP2-A01 fragment owner folded into decade docs. Pasteur reflection ALIGNED; generic inherited-model consultation, native architect not selected. diff --git a/devlog/_plan/260912_v2_contracts/010_plaintext.md b/devlog/_plan/260912_v2_contracts/010_plaintext.md new file mode 100644 index 0000000000..0923313b6c --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/010_plaintext.md @@ -0,0 +1,34 @@ +# Plaintext V2 prevention + +Class C4 public wire/retention boundary; consumes wp0. Source proposal: #2496 at 1a4cb4aab14200ec2efa71aea00d2a55fc90aca7. Exact public patch is the starting implementation specification, ported to current owners below. #4242 and #4243 are alternatives, not automatically dependencies. + +| Action | Path | Before → after | +|---|---|---| +| NEW | `src/responses/plaintext-v2-agent-messages.ts` | no explicit canonical exception → #2496 request compiler and bounded restoration helper, corrected by D2–D4 below | +| MODIFY | `src/types/config.ts`, `src/config.ts` | absent flag → optional `plaintextV2AgentMessages?: boolean`, unset default; malformed reads drop only field, candidate writes reject | +| MODIFY | `src/types/request.ts` | absent route marker → optional request-local `_plaintextV2AgentMessages` | +| MODIFY | `src/adapters/base.ts`, `src/adapters/openai-responses.ts` | no alias capabilities → adapter-produced request-owned tool-name sets after canonical opt-in rewrite | +| MODIFY | `src/server/responses/core.ts` | direct native passthrough → final-route opt-in preparation, alias metadata refreshed after each build, restoration before client/cache on every JSON/SSE/WS path | +| MODIFY | `src/server/index.ts` | recovery-only warning → separate opt-in plaintext retention warning | +| NEW | `tests/responses/plaintext-v2-agent-messages.test.ts`, `tests/server/plaintext-v2-agent-messages-server.test.ts` | absent → port #2496 tests and add refusal/collision cases | +| MODIFY | `tests/server/config.test.ts`, `tests/server/agent-task-recovery.test.ts`, `tests/responses/ws-upstream.test.ts` | existing adjacent contracts → port applicable #2496 regression deltas | +| MODIFY | `scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json` | no new tests → register both new domain paths | +| MODIFY | English and zh-cn `guides/sub-agent-surface.md`, `reference/configuration/agents.md` under `docs-site/src/content/docs/` | recovery/V1 alternatives → config-only experimental plaintext contract and retention warning | +| MODIFY | applicable `structure/` owners from INDEX | current ownership descriptions → point to canonical plaintext contract without duplicating unrelated subsystem behavior | + +D1: preserve the explicit issue option; no management toggle or routed mirror catalog. +D2: `shouldPreparePlaintextV2AgentMessages`: true only for Responses wire, final canonical ChatGPT forward destination and default top-level collaboration catalog; additional_tools-only catalogs do not activate it. +D3: `preparePlaintextV2AgentMessages`: copy-on-write namespace + three tool aliases. Only `message.encrypted === true` is removed. Scan declaration/reference identity positions, including nested catalogs and qualified alias names, before any rewrite. Refuse all on any collision; foreign namespaces stay untouched. +D4: `restorePlaintextV2AgentMessageCalls*`: restore only request-generated identity capabilities. Preserve marker `encrypted_function_args: []`. Treat malformed JSON, unknown private identities, binding conflict and >10,000 identities as refusal. Bounded JSON returns 502; streams emit response.failed; refusal has no retry and no continuation write. Refresh state per turn/build; no connection/global alias state. + +Field chain: config type → config schema/save → config load/candidate validation → final route marker → adapter body serialization and AdapterRequest metadata → response restoration. Metadata is in-process only, never serialized as response fields or persisted with previous_response_id. Startup consumes config for warning. No new public state enum. + +Activation matrix: disabled/malformed flag, noncanonical/key/Anthropic/routed parent/V1/custom namespace unchanged; true canonical declaration rewritten without input mutation; each collision location leaves whole request unchanged; known aliases restored for JSON/SSE/WS and snapshots; same aliases under foreign namespace unchanged; malformed/overlimit/conflict terminal refused and not cached; next turn disabled and concurrent requests do not inherit prior metadata. Use hosted tests only; no live-account canary is claimed. + +Guard strength: runtime explicit option + compiler/restorer are code-path controls; operator can disable the option, which selects ordinary encrypted behavior. No credential authorization is added. Residual undocumented upstream behavior and plaintext retention are documented, not described as encryption guarantees. + +Port mapping verified against current tree: old `tests/config.test.ts` is now `tests/server/config.test.ts`; old `tests/ws-upstream.test.ts` is now `tests/responses/ws-upstream.test.ts`. A `git apply --check` of supporting #2496 hunks fails at current adapter/config/startup context; manual semantic port is required, not blind cherry-pick. Pure helper and new tests can use their full public source bodies with adjusted imports. Existing test helper `repo-root.ts` supplies repository paths instead of legacy relative directory inference. + +Exact integration replacements: `refreshRoutedNamespaceToolAliases` at core line 4702 becomes `refreshRequestToolAliases`, assigning both alias sets from each AdapterRequest or fresh empty sets. All seven current callers are renamed. At core `rememberPassthroughResponseChecked`, change `const restoredResponse = normalized...` to an intermediate normalized value; run plaintext restoration and return immediately on refusal before `rememberPassthroughResponse`. In blockRewrites, insert plaintext restoration immediately after `createResponsesSnapshotBlockRewrite`, before field backfill and guard. In bounded JSON, apply restoration after `normalizeFunctionCompletionJson` and before model rewrite; a refusal short-circuits before `rememberPassthroughResponseChecked`. At unsupported passthrough fallback, cancel response body and return safe 502 whenever request alias sets are nonempty. + +A synthesis WP1-A01: early raw inspection cannot authorize continuation for plaintext turns. Disable its cache callbacks when aliases are active. Publish only from a post-restoration/post-guard client block observer, and only after shared request-local stream validation accepts the terminal. Bounded JSON caches only its final restored value. Stream malformed/conflicting/overlimit rejection permanently prevents publication. diff --git a/devlog/_plan/260912_v2_contracts/020_recovery.md b/devlog/_plan/260912_v2_contracts/020_recovery.md new file mode 100644 index 0000000000..1d1495e058 --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/020_recovery.md @@ -0,0 +1,48 @@ +# Encrypted envelope recovery + +Class C4 authenticated plaintext boundary; consumes roadmap and independent envelope design. #3794 diagnostics and MESSAGE support are already present. This phase preserves them and never adds automatic outage retries. + +| Action | Path | Before → after | +|---|---|---| +| MODIFY | `src/server/responses/agent-task-recovery.ts` | single encryptedIndex/ciphertext → ordered bounded part descriptors and exact envelope snapshot; single backend recovery request; atomic input revalidation before replacement | +| MODIFY | `tests/server/agent-task-recovery.test.ts` | single-part coverage → ordered multipart, invalid/ambiguous fragments, size/count cap, input mutation and cache isolation cases | +| MODIFY | `docs-site/src/content/docs/reference/configuration/agents.md` | narrow recovery description → exact supported multipart shape, no blind retries and residual fragment limitations | +| MODIFY | relevant `structure/` owners | current single-part invariant → canonical bounded envelope contract | + +D5 proposal for design audit: accept a contiguous run of complete structurally valid Fernet strings, at most 32 parts and 2 MiB combined. Keep routing header singular and author/recipient equal to sender/task. Forward original complete token parts in their order to the same fixed backend endpoint once. Partial token strings remain unsupported unless source evidence establishes an unambiguous join contract; do not infer authentication from a plausible Fernet shape. + +`AgentEnvelope` replaces encryptedIndex with an ordered part list. The cache key hashes a length-delimited serialized token array (not ambiguous string concatenation). `recoveryPayload` maps these parts into its one input message. `injectAssignment` reruns envelope parsing and compares the full admitted snapshot (header, identities, positions, all ciphertext parts) before one content splice, then removes agent routing identity fields exactly as today. Existing admission is still before every cache read. Input mutation causes input_changed and cache discard. + +Creation → serialization → consumption: parser builds ordered part descriptors; recoveryPayload emits each validated whole part; cache key binds their order and boundaries; injection validates the original current input and writes one assignment. No new stored config or failure enum is needed; unsupported_envelope remains not attempted and existing typed request failures remain attempted/capacity outcomes. + +Activation matrix: one complete part unchanged; two complete ordered parts reach exactly one mocked backend call and one plaintext replacement; swapped tokens have distinct cache identity; wrong sender/recipient/header rejected without fetch; interleaved plaintext/noncontiguous encrypted parts rejected; empty, malformed, excessive count or total bytes rejected; delayed input mutation refuses assignment; HTTP 5xx yields the existing typed reason after one call; no retry budget increase. Reuse existing helper fixtures; no test execution locally. + +Fragment disposition: this unit does not concatenate split tokens. #3661 contains no fragment association or representation evidence. The runtime's existing plaintext-in-encrypted-slot compatibility must remain. Add end-to-end regression coverage for a consecutive encrypted run whose exact concatenation is structurally one Fernet token: classify that narrowly as unreadable and refuse without recovery, while ordinary plaintext slots still normalize. If no sound discriminator is found, retain the issue residual explicitly; never claim full #3661 closure from whole-token support. + + +Concrete replacement contract: + +```ts +// AgentEnvelope +// - encryptedIndex: number; ciphertext: string; +// + encryptedStartIndex: number; ciphertexts: readonly string[]; +// + inputSnapshot: string; +// Parser: collect {index, token} only when token list has exactly one member +// and that member === raw encrypted_content. Reject missing header, +// >32 entries, >2 MiB aggregate, and nonconsecutive indexes. Capture +// JSON.stringify(item) at admission after all identity checks. +// Cache replaces .update(envelope.ciphertext) with +.update(JSON.stringify(envelope.ciphertexts)) +// Fixed recovery endpoint content replaces its single encrypted part with +...envelope.ciphertexts.map(encrypted_content => ({ + type: "encrypted_content", encrypted_content, +})) +// Injection verifies original item bytes before touching content: +if (JSON.stringify(item) !== envelope.inputSnapshot) return false; +content.splice(envelope.encryptedStartIndex, envelope.ciphertexts.length, + { type: "input_text", text: assignment }); +``` + +The snapshot is request-local and not logged/persisted. JSON request parsing is the input boundary, so getters/cycles are not supported client states. Tests use the existing Request/recovery public entrypoints, not exported parser internals. + +Reflection amendment: also MODIFY `src/server/responses/encrypted-payload.ts` only for the narrow multi-slot discriminator and MODIFY `tests/server/agent-task-recovery.test.ts` with `post()` integration assertions that recovery is not attempted and routed fetch is absent. Whole-token recovery tests remain at the recovery API. The discriminator runs before sanitization; no fragment text is forwarded when it matches. General malformed payload detection remains outside this claim. diff --git a/devlog/_plan/260912_v2_contracts/030_verification.md b/devlog/_plan/260912_v2_contracts/030_verification.md new file mode 100644 index 0000000000..cf5ec964fd --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/030_verification.md @@ -0,0 +1,9 @@ +# Final hosted verification and handoff + +Consumes published implementation heads. No product change is planned unless exact hosted failure or independent audit identifies a defect; then amend this design with the concrete source delta before repair. + +MODIFY this unit's cycle records with actual outcomes. MODIFY ignored `.tmp/v2/handoff.md` and NEW ignored `.tmp/v2/final-ci.json` with own branch/worktree/session, original dispositions, credit, carry PR URLs, exact heads, chain order if any, remaining issue acceptance, unresolved review/security judgments and local NOT RUN. + +Commands: `git diff --check` observes whitespace only. `gh pr view` observes live head/base/reviews. `gh run list --commit ` finds hosted runs; `gh run view --json headSha,status,conclusion,jobs,url` provides final evidence. Inspect `.github/workflows/ci.yml` or actual workflow source for full lane dispatch. Do not claim skipped/cancelled jobs passed. CI failure repairs are additional PABCD cycles when they form a separate work-phase. + +Before publish, inspect exact diff and original contributor commits; push only owned branches using `git push --no-verify`. Populate Summary/Verification/Checklist template honestly with NOT RUN local tests. No closure or merge. Capture remote PR head equality with local final SHA and final Cross-platform CI result. A source scan or receipt wrapper is not product test evidence. Independent review has a source SHA and limitations. Any live upstream canary absent remains explicit. From 40723eaf4fced31e90163308f8a1239d0a1ce9a9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:39:15 +0900 Subject: [PATCH 002/224] docs: record roadmap cycle completion evidence --- devlog/_plan/260912_v2_contracts/000_plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_plan/260912_v2_contracts/000_plan.md index a58b0249af..5ba4c2022d 100644 --- a/devlog/_plan/260912_v2_contracts/000_plan.md +++ b/devlog/_plan/260912_v2_contracts/000_plan.md @@ -22,3 +22,5 @@ Generic supported inherited-model subagents provide independent design consultat wp0: P entered with own session binding; roadmap in progress. Product validation NOT RUN. wp0 A: Gauss GO-WITH-FIXES (blockers=0); WP1-A01 cache ordering and WP2-A01 fragment owner folded into decade docs. Pasteur reflection ALIGNED; generic inherited-model consultation, native architect not selected. + +wp0 check correction: initial D was refused because the roadmap task had not yet been marked done. The subsequent P command re-entered planning; no completed cycle is claimed for that attempt. Re-audit retains the unchanged independent verdict, and a fresh docs-only B/C/D closes the actual cycle after recording its task outcome. From 55a9520f7144d3fc267aa7c4bb2a39fddfc0e8e6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:48:33 +0900 Subject: [PATCH 003/224] feat: restore opt-in plaintext V2 delegation identities Adapt #2496 at 1a4cb4aab14200ec2efa71aea00d2a55fc90aca7 to current Responses routing, with conservative collision and response refusal handling. Local product tests NOT RUN by maintainer instruction; hosted validation follows. Co-authored-by: Sigurd --- .../260912_v2_contracts/010_plaintext.md | 4 + .../content/docs/guides/sub-agent-surface.md | 14 + .../docs/reference/configuration/agents.md | 52 ++ .../docs/zh-cn/guides/sub-agent-surface.md | 11 + .../zh-cn/reference/configuration/agents.md | 37 + scripts/test-layout/layout.json | 10 +- src/adapters/base.ts | 4 + src/adapters/openai-responses.ts | 13 + src/config.ts | 24 + src/responses/plaintext-v2-agent-messages.ts | 872 ++++++++++++++++++ src/server/index.ts | 8 + src/server/responses/core.ts | 83 +- src/types/config.ts | 2 + src/types/request.ts | 2 + structure/adapters/registry.md | 3 + structure/catalog.md | 3 + structure/clients/claude-desktop.md | 3 + structure/config.md | 3 + structure/data-planes/images.md | 3 + structure/data-planes/inbound-compat.md | 3 + structure/gui-and-management-api.md | 3 + structure/ops/docs-and-release.md | 3 + structure/ops/service-and-sidecars.md | 3 + structure/overview.md | 3 + structure/providers/chat-compat.md | 3 + structure/providers/cursor.md | 3 + structure/providers/kiro.md | 3 + structure/providers/openai-tiers.md | 3 + structure/providers/xai-grok.md | 3 + structure/runtime.md | 3 + structure/subagents.md | 20 + structure/transports/inventory.md | 3 + structure/transports/responses.md | 3 + structure/transports/streaming-health.md | 3 + tests/fixtures/test-layout-expected.json | 8 +- .../plaintext-v2-agent-messages.test.ts | 834 +++++++++++++++++ tests/responses/ws-upstream.test.ts | 204 ++++ tests/server/config.test.ts | 40 + ...plaintext-v2-agent-messages-server.test.ts | 621 +++++++++++++ 39 files changed, 2898 insertions(+), 22 deletions(-) create mode 100644 src/responses/plaintext-v2-agent-messages.ts create mode 100644 tests/responses/plaintext-v2-agent-messages.test.ts create mode 100644 tests/server/plaintext-v2-agent-messages-server.test.ts diff --git a/devlog/_plan/260912_v2_contracts/010_plaintext.md b/devlog/_plan/260912_v2_contracts/010_plaintext.md index 0923313b6c..4551da65cb 100644 --- a/devlog/_plan/260912_v2_contracts/010_plaintext.md +++ b/devlog/_plan/260912_v2_contracts/010_plaintext.md @@ -32,3 +32,7 @@ Port mapping verified against current tree: old `tests/config.test.ts` is now `t Exact integration replacements: `refreshRoutedNamespaceToolAliases` at core line 4702 becomes `refreshRequestToolAliases`, assigning both alias sets from each AdapterRequest or fresh empty sets. All seven current callers are renamed. At core `rememberPassthroughResponseChecked`, change `const restoredResponse = normalized...` to an intermediate normalized value; run plaintext restoration and return immediately on refusal before `rememberPassthroughResponse`. In blockRewrites, insert plaintext restoration immediately after `createResponsesSnapshotBlockRewrite`, before field backfill and guard. In bounded JSON, apply restoration after `normalizeFunctionCompletionJson` and before model rewrite; a refusal short-circuits before `rememberPassthroughResponseChecked`. At unsupported passthrough fallback, cancel response body and return safe 502 whenever request alias sets are nonempty. A synthesis WP1-A01: early raw inspection cannot authorize continuation for plaintext turns. Disable its cache callbacks when aliases are active. Publish only from a post-restoration/post-guard client block observer, and only after shared request-local stream validation accepts the terminal. Bounded JSON caches only its final restored value. Stream malformed/conflicting/overlimit rejection permanently prevents publication. + +WP1 implementation-P revalidation: prior D locked the roadmap. Reuse `createSseInspector` for restored client blocks: append a final block observer after the alias restorer and undeclared-tool guard, feed `${block}\n\n`, and dispose with the composed rewrite. Raw inspector callbacks are suppressed only for active plaintext aliases. The existing collector reconstructs output from accepted events. This gives one validated publication path rather than parallel raw/client cache decisions. Keep the final marker-preserving restorer before this collector. Unknown private identities throw before collection; terminal-only valid complete snapshots are accepted, so there is no invented requirement for prior added frames. + +Exact source-of-truth canonical owner is `structure/subagents.md`; add concise links from the mapped affected owners `runtime.md`, `config.md`, `overview.md`, `catalog.md`, `transports/responses.md`, `transports/streaming-health.md`, `transports/inventory.md`, `data-planes/images.md`, `data-planes/inbound-compat.md`, `providers/openai-tiers.md`, `providers/cursor.md`, `providers/chat-compat.md`, `providers/kiro.md`, `providers/xai-grok.md`, `adapters/registry.md`, `gui-and-management-api.md`, `clients/claude-desktop.md`, `ops/service-and-sidecars.md`, and `ops/docs-and-release.md` where the source-area map requires same-change synchronization. Links distinguish unchanged surfaces from the canonical new contract. diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 49faeb9370..912268325f 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -328,3 +328,17 @@ tier that Codex converts to `max`; opencodex then maps or clamps the value for t The model context cap is independent of sub-agent mode. Configure it on the Models page; native OpenAI models retain their real context windows. + +The experimental `plaintextV2AgentMessages` field is unset in a fresh config and runs only when set +to `true`. The caller must use the Responses wire, and the final destination must use +`adapter: "openai-responses"`, `authMode: "forward"`, and the exact base URL +`https://chatgpt.com/backend-api/codex`. OpenAI API-key providers, custom compatible gateways, +routes to other providers, and non-Responses callers are excluded. For an eligible new native +ChatGPT v2 tool call, the option assigns request-scoped aliases to the namespace and three reserved +message-tool names, removes the message marker, and restores the original identities in the +response. It handles +`spawn_agent`, `send_message`, and `followup_task` and adds no recovery request. HTTPS remains +encrypted, but task text can be retained in Codex history, routed-provider requests, and local +response/debug state. Existing ciphertext is unchanged, and the option depends on undocumented +ChatGPT and Codex behavior. See +[Agent configuration: Plaintext v2 agent messages](/reference/configuration/agents/#plaintext-v2-agent-messages). diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 24dd9dec16..04dbfcbeb7 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -37,6 +37,7 @@ still depends on upstream support for your account. | `subagentModelFallbackPollMs?` | `number` | `60000` | Availability-probe cache interval. Values below 1000 ms fall back to the default. | | `effortCap?` | `string` | — | Hard ceiling for qualifying v2 main turns and marked spawned-child turns. Accepts `low` through `ultra`. | | `subagentEffortCap?` | `string` | — | Additional ceiling for spawned-child turns only. When both caps apply, the lower wins. | +| `plaintextV2AgentMessages?` | `boolean` | — (unset) | Experimental opt-in. It runs only when explicitly set to `true` and asks eligible native ChatGPT v2 parents to emit `spawn_agent`, `send_message`, and `followup_task` message arguments as plaintext. See [Plaintext v2 agent messages](#plaintext-v2-agent-messages). | | `agentTaskRecovery?` | `object` | — | Experimental opt-in recovery for backend-encrypted v2 tasks sent to routed providers. Disabled unless `enabled: true`; see [Encrypted v2 task recovery](#encrypted-v2-task-recovery). | Manage the surface with the dashboard or @@ -162,6 +163,57 @@ on a mid-thread model switch. } ``` +## Plaintext v2 agent messages + +`plaintextV2AgentMessages` is unset in a fresh config and runs only when explicitly set to `true`. +The caller must use the Responses wire, and the final destination must use the canonical ChatGPT +Codex forward transport: `adapter: "openai-responses"`, `authMode: "forward"`, and the exact base URL +`https://chatgpt.com/backend-api/codex`. OpenAI API-key providers, custom OpenAI-compatible +gateways, routes whose final destination is another provider, and non-Responses callers are never +rewritten. + +For an eligible v2 request, opencodex recognizes the catalog by a top-level `collaboration` +namespace with a direct `spawn_agent` child. It removes +`parameters.properties.message.encrypted: true`, when present, only from `spawn_agent`, +`send_message`, and `followup_task`. ChatGPT reserves both the `collaboration` namespace and those +three tool names, so the request uses fixed private aliases for all four identities. Before making +that change, opencodex checks top-level and `additional_tools` catalogs, nested namespaces, +`tool_search_output` declarations, `tool_choice`, and prior call items for the private namespace and +fixed aliases. Any conflict leaves the entire request unchanged. OpenCodex restores only the +request-scoped aliases in JSON, SSE, and WebSocket responses before Codex receives the tool call. +The `encrypted_function_args: []` field is preserved so compatible Codex clients recognize the +message as plaintext. + +This path adds no recovery request and therefore does not spend the extra ChatGPT quota used by a +cache miss in `agentTaskRecovery`. It cannot change tasks that are already encrypted. If the request +already declares the private alias or a conflicting reference, opencodex leaves that request +unchanged; separately enabled recovery can still handle a routed task that is later encrypted. If +ChatGPT rejects or ignores the modified schema, or the Codex client does not recognize the plaintext +response fields, the call can fail. OpenCodex does not retry the parent request with the original +schema because doing so could duplicate quota use or tool calls. + +Restoration uses a 10,000-identity traversal budget for each response payload. If a payload exhausts +that budget, bounded JSON returns HTTP 502 and a stream returns `response.failed`; neither path +sends the private aliases to Codex or saves the refused response for `previous_response_id` +continuation. + +For successfully rewritten calls, the option removes application-layer encryption from agent +message arguments. HTTPS still encrypts network transport, but message text can appear in Codex +task history, routed-provider requests, `responses-state.json` or its spill files, and +`usage-debug.jsonl` when debug capture is enabled. The behavior depends on undocumented ChatGPT +schema and response fields and may stop working after a backend or client update. Startup prints a +warning while it is enabled. + +```json +{ + "plaintextV2AgentMessages": true +} +``` + +The equivalent CLI command is `ocx config set plaintextV2AgentMessages true`. Restart the proxy +after changing the setting. + + ## Encrypted v2 task recovery `agentTaskRecovery` is an experimental compatibility path for backend-encrypted v2 tasks that reach diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index b0d1dc6536..390ad72d4a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -172,3 +172,14 @@ curl -X PUT http://localhost:10100/api/injection-model \ ### 上下文上限 模型上下文上限与子代理模式无关。请在 Models 页面配置它;原生 OpenAI 模型会保留其真实的上下文窗口。 + +新配置不会写入实验性的 `plaintextV2AgentMessages` 字段,只有显式设置为 `true` 才会启用。 +调用方必须使用 Responses 格式,最终目标必须采用 `adapter: "openai-responses"`、 +`authMode: "forward"` 和准确的基础地址 `https://chatgpt.com/backend-api/codex`。OpenAI API key +provider、自定义兼容网关、最终发往其他 provider 的请求,以及非 Responses 调用都不会被改写。 +对于符合条件的新原生 ChatGPT v2 工具调用,该选项会临时改写 namespace 和三个保留工具名, +并删除消息字段的加密标记;响应返回 Codex 前会恢复原 namespace 与工具名。它处理 +`spawn_agent`、`send_message` 和 `followup_task`,不会增加恢复请求。 +HTTPS 仍会加密网络传输,但任务文字可能保存在 Codex 历史、外部模型请求和本地响应或调试文件中。 +已有密文不会改变。该选项依赖 ChatGPT 和 Codex 未公开的行为。详见 +[明文 v2 代理消息](/zh-cn/reference/configuration/agents/#明文-v2-代理消息)。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 094e6df720..fd0b26dff0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -21,6 +21,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | `subagentModelFallbackPollMs?` | `number` | `60000` | 可用性探测缓存间隔。低于 1000 ms 的值会回退到默认值。 | | `effortCap?` | `string` | — | 对符合条件的 v2 主轮次和标记的派生子轮次设置硬上限。接受 `low` 到 `ultra`。 | | `subagentEffortCap?` | `string` | — | 仅针对派生子轮次的额外上限。两个上限同时适用时,较低者生效。 | +| `plaintextV2AgentMessages?` | `boolean` | —(未设置) | 实验性选项。只有显式设置为 `true` 才会启用。符合条件的新 `spawn_agent`、`send_message` 和 `followup_task` 调用会使用明文消息参数。详见[明文 v2 代理消息](#明文-v2-代理消息)。 | 通过仪表板或 `ocx v2 status|on|off|mode |threads ` 管理该界面。模式变更会应用于新会话。`maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 字段,不是 `config.json` 键;`ocx v2 threads ` 会在启用 v2 后,将 `max_concurrent_threads_per_session` 写入 Codex 的 `$CODEX_HOME/config.toml` 中的 `[features.multi_agent_v2]` 下。 @@ -80,6 +81,42 @@ opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或 } ``` +## 明文 v2 代理消息 + +新配置不会写入 `plaintextV2AgentMessages`。只有显式设置为 `true` 才会启用。调用方必须使用 Responses +格式,最终目标必须采用规范的 ChatGPT Codex 转发配置,即 `adapter: "openai-responses"`、 +`authMode: "forward"` 和准确的基础地址 `https://chatgpt.com/backend-api/codex`。OpenAI API key +provider、自定义 OpenAI 兼容网关、最终发往其他 provider 的请求,以及非 Responses 调用都不会被改写。 + +对于符合条件的 v2 请求,opencodex 只识别顶层 `collaboration` namespace,而且它必须直接包含 +`spawn_agent`。原生 ChatGPT 收到请求前,opencodex 会删除 `spawn_agent`、`send_message` 和 +`followup_task` 中已有的 `parameters.properties.message.encrypted: true`。ChatGPT 会按保留的 +`collaboration` namespace 和三个工具名处理消息,因此请求会给这四个名称使用固定的临时别名。 +修改前,opencodex 会检查顶层和 `additional_tools` 工具目录、嵌套 namespace、 +`tool_search_output` 声明、`tool_choice` 和历史调用项。只要发现私有 namespace 或固定别名冲突, +整个请求就保持原样。opencodex 只会在 JSON、SSE 和 WebSocket 响应中恢复本次请求生成的别名, +并保留 `encrypted_function_args: []`,让兼容的 Codex 客户端把参数识别为明文。 + +这个选项不会增加恢复请求,也不会使用 `agentTaskRecovery` 在缓存未命中时产生的额外 ChatGPT +配额。它只能影响新工具调用,不能修改已有密文。请求已占用私有名称或有冲突引用时,opencodex 会保持该请求不变;若它后来生成加密的路由子任务,单独启用的 `agentTaskRecovery` 仍可处理。ChatGPT 拒绝或忽略修改后的 schema,或 Codex 客户端不识别明文响应字段时,调用可能失败。opencodex 不会用原 schema 自动重发父请求,因为重发可能重复消耗配额或重复执行工具。 + +每个响应的恢复检查最多处理 10,000 个身份位置。达到限制时,JSON 响应返回 HTTP 502,流式响应返回 +`response.failed`。这两种情况都不会把私有别名发给 Codex,也不会保存该响应供后续 +`previous_response_id` 继续使用。 + +成功改写后,这个选项会取消代理消息参数的应用层加密。HTTPS 仍会加密网络传输,但消息文字可能出现在 Codex +任务历史、外部模型请求、`responses-state.json` 及其 spill 文件,以及启用调试记录时的 +`usage-debug.jsonl`。该行为依赖 ChatGPT 未公开的 schema 和响应字段,后端或客户端更新后可能失效。 +服务启动时会打印警告。 + +```json +{ + "plaintextV2AgentMessages": true +} +``` + +等价命令是 `ocx config set plaintextV2AgentMessages true`。修改后重启代理。 + ## Effort 上限 上限只适用于 v2 协作功能:当主轮次的工具暴露 v2 时,它就符合条件;当子轮次在 `x-codex-turn-metadata` 中带有 codex-rs 的精确 `x-openai-subagent: collab_spawn` 或 `"subagent_kind": "thread_spawn"` 标记时,它也符合条件,即使叶子工具已经不再暴露协作。V1 主轮次、`multiAgentMode: "v1"`、压缩、审查以及记忆整合轮次都会绕过上限。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ecbbf5862a..2466a856ca 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -531,6 +531,7 @@ "consume-for-inspection-cancel.test.ts": "server", "container-bootstrap.test.ts": "service", "context-cap-unknown-window.test.ts": "providers", + "context-window-seed-repair.test.ts": "providers", "continuation-dedup.test.ts": "responses", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", @@ -668,7 +669,6 @@ "gemini-inline.test.ts": "images", "gemini-web-search.test.ts": "adapters/google", "generic-oauth-failover.test.ts": "oauth", - "pool-kernel-generic-sweep.test.ts": "oauth", "github-copilot-account-origin.test.ts": "providers/github-copilot", "github-copilot-oauth.test.ts": "providers/github-copilot", "github-copilot-sse-rewrite.test.ts": "providers/github-copilot", @@ -856,7 +856,6 @@ "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", - "context-window-seed-repair.test.ts": "providers", "model-selection-guidance.test.ts": "cli", "model-visibility-management-api.test.ts": "codex-integration", "models-feedback-callback.test.ts": "gui", @@ -942,9 +941,9 @@ "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", - "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", + "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", @@ -980,9 +979,12 @@ "pi-path-contract.test.ts": "clients", "pinned-http.test.ts": "lib", "pinned-https-get.test.ts": "images", + "plaintext-v2-agent-messages-server.test.ts": "server", + "plaintext-v2-agent-messages.test.ts": "responses", "plan-video.test.ts": "videos", "plan.test.ts": "images", "policy-execution.test.ts": "routing", + "pool-kernel-generic-sweep.test.ts": "oauth", "port-reclaim.test.ts": "server", "ports.test.ts": "server", "prime-client.test.ts": "clients", @@ -1189,7 +1191,6 @@ "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", - "structure-ssot.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", @@ -1221,6 +1222,7 @@ "storage-worker-teardown-isolate.test.ts": "storage", "stream-aborted-marker.test.ts": "server", "strict-semver.test.ts": "lib", + "structure-ssot.test.ts": "ci-workflows", "subagent-context-staleness.test.ts": "routing", "subagent-defaults.test.ts": "routing", "subagent-fallback-handle-responses.test.ts": "routing", diff --git a/src/adapters/base.ts b/src/adapters/base.ts index f5a22b2969..6df57d9a89 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -91,6 +91,10 @@ export interface AdapterRequest { convertedRoutedToolSearchNames?: ReadonlySet; /** Upstream-only aliases for namespace tools flattened in this request. */ convertedRoutedNamespaceToolAliases?: ReadonlyMap; + /** Request-declared collaboration child names eligible for plaintext-v2 alias restoration. */ + plaintextV2AgentMessageToolNames?: ReadonlySet; + /** Collaboration message-tool names actually rewritten to fixed aliases in this request. */ + plaintextV2AgentMessageAliasedToolNames?: ReadonlySet; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 30e1c14c85..8d6d3a027f 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -24,6 +24,7 @@ import type { TranslatorBudget } from "../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; +import { preparePlaintextV2AgentMessages } from "../responses/plaintext-v2-agent-messages"; import { openaiResponsesUrl } from "./openai-responses-url"; import { normalizeResponsesCodeMode } from "./responses-code-mode"; import { stripUnicodePropertyPatterns } from "./responses-tool-schema"; @@ -2370,6 +2371,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let routedCustomToolRepairNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; let convertedRoutedNamespaceToolAliases: Map | undefined; + let plaintextV2AgentMessageToolNames: ReadonlySet | undefined; + let plaintextV2AgentMessageAliasedToolNames: ReadonlySet | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -2479,6 +2482,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Run after routed compaction so nested input_image parts are replaced before a malformed // tool output is flattened to text and can no longer be inspected structurally. outBody = repairUnidentifiedToolOutputItems(outBody); + if (parsed._plaintextV2AgentMessages === true && isCanonicalOpenAiForwardProvider(provider)) { + const prepared = preparePlaintextV2AgentMessages(outBody); + outBody = prepared.body; + if (prepared.namespaceAliased) { + plaintextV2AgentMessageToolNames = prepared.toolNames; + plaintextV2AgentMessageAliasedToolNames = prepared.aliasedAgentMessageToolNames; + } + } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas( stripSparkCompatibility( @@ -2571,6 +2582,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), + ...(plaintextV2AgentMessageToolNames ? { plaintextV2AgentMessageToolNames } : {}), + ...(plaintextV2AgentMessageAliasedToolNames ? { plaintextV2AgentMessageAliasedToolNames } : {}), ...(tierLog ? { tierLog } : {}), }; }, diff --git a/src/config.ts b/src/config.ts index 5e81a7e5f1..59b66bf4f3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1269,6 +1269,7 @@ const configSchema = z.object({ contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), // Invalid optional recovery config must not discard unrelated provider/account state. + plaintextV2AgentMessages: z.boolean().optional().catch(undefined), agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined), // Same rationale: a bad notify section must not cost the operator their providers. quotaResetNotify: quotaResetNotifySchema.optional().catch(undefined), @@ -2159,6 +2160,17 @@ function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void { if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); } +function malformedPlaintextV2AgentMessagesWarning(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || raw.plaintextV2AgentMessages === undefined || typeof raw.plaintextV2AgentMessages === "boolean") return null; + return "plaintextV2AgentMessages ignored: expected a boolean"; +} + +function warnDegradedPlaintextV2AgentMessages(value: unknown): void { + const warning = malformedPlaintextV2AgentMessagesWarning(value); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + function malformedAgentTaskRecoveryWarning(rawParsed: unknown): string | null { const raw = rawConfigRecord(rawParsed); if (!raw || !Object.hasOwn(raw, "agentTaskRecovery")) return null; @@ -2416,6 +2428,7 @@ export function loadConfig(): OcxConfig { warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); warnDegradedAgentTaskRecovery(parsed); warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); @@ -2445,6 +2458,7 @@ export function loadConfig(): OcxConfig { warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); warnDegradedAgentTaskRecovery(parsed); warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); @@ -2470,6 +2484,7 @@ export function loadConfig(): OcxConfig { warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); warnDegradedAgentTaskRecovery(parsed); warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); @@ -2621,6 +2636,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (notifyWarning) warnings.push(notifyWarning); const codexPoolWarning = malformedCodexPoolWarning(rawParsed); if (codexPoolWarning) warnings.push(codexPoolWarning); + const plaintextWarning = malformedPlaintextV2AgentMessagesWarning(rawParsed); + if (plaintextWarning) warnings.push(plaintextWarning); if (syncDisabledReason) { warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); } @@ -2702,6 +2719,12 @@ function upstreamHostCircuitThresholdError(value: unknown): string | null { return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`; } +function plaintextV2AgentMessagesError(value: unknown): string | null { + return malformedPlaintextV2AgentMessagesWarning(value) + ? "schema_invalid: plaintextV2AgentMessages: must be a boolean or omitted" + : null; +} + function agentTaskRecoveryError(value: unknown): string | null { const raw = rawConfigRecord(value); if (!raw || !Object.hasOwn(raw, "agentTaskRecovery") || raw.agentTaskRecovery === undefined) return null; @@ -2993,6 +3016,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value) ?? upstreamHostCircuitThresholdError(value) + ?? plaintextV2AgentMessagesError(value) ?? agentTaskRecoveryError(value) ?? quotaResetNotifyError(value) ?? codexPoolError(value) diff --git a/src/responses/plaintext-v2-agent-messages.ts b/src/responses/plaintext-v2-agent-messages.ts new file mode 100644 index 0000000000..70ad82ab6f --- /dev/null +++ b/src/responses/plaintext-v2-agent-messages.ts @@ -0,0 +1,872 @@ +const COLLABORATION_NAMESPACE = "collaboration"; +export const PLAINTEXT_V2_COLLABORATION_NAMESPACE = "collaboration-optimize"; +const COLLABORATION_NAME_PREFIX = `${COLLABORATION_NAMESPACE}__`; +const COLLABORATION_DOTTED_NAME_PREFIX = `${COLLABORATION_NAMESPACE}.`; +const PLAINTEXT_V2_COLLABORATION_NAME_PREFIX = `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__`; +const PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX = `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}.`; + +const PLAINTEXT_V2_AGENT_MESSAGE_TOOLS = new Set([ + "spawn_agent", + "send_message", + "followup_task", +]); + +const PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES = new Map([ + ["spawn_agent", "start_delegated_task"], + ["send_message", "deliver_delegated_message"], + ["followup_task", "continue_delegated_task"], +]); + +const PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES = new Map( + [...PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES].map(([name, alias]) => [alias, name]), +); + +export function shouldPreparePlaintextV2AgentMessages(args: { + enabled: boolean; + inboundWire: string; + canonicalChatGpt: boolean; + requestBody: unknown; +}): boolean { + return args.enabled + && args.inboundWire === "responses" + && args.canonicalChatGpt + && hasPlaintextV2CollaborationCatalog(args.requestBody); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function responseToolCatalogs(body: Record): unknown[][] { + const catalogs: unknown[][] = []; + if (Array.isArray(body.tools)) catalogs.push(body.tools); + if (!Array.isArray(body.input)) return catalogs; + for (const item of body.input) { + if ( + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + ) { + catalogs.push(item.tools); + } + } + return catalogs; +} + +function collaborationCatalogInfo(catalogs: readonly unknown[][]): { + hasV2Catalog: boolean; + toolNames: Set; + aliasedAgentMessageToolNames: Set; +} { + let hasV2Catalog = false; + const toolNames = new Set(); + const aliasedAgentMessageToolNames = new Set(); + for (const tools of catalogs) { + for (const tool of tools) { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || tool.name !== COLLABORATION_NAMESPACE + || !Array.isArray(tool.tools) + ) { + continue; + } + for (const child of tool.tools) { + if ( + isPlainObject(child) + && (child.type === "function" || child.type === "custom") + && typeof child.name === "string" + ) { + toolNames.add(child.name); + if ( + child.type === "function" + && PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.has(child.name) + ) { + aliasedAgentMessageToolNames.add(child.name); + } + if (child.type === "function" && child.name === "spawn_agent") hasV2Catalog = true; + } + } + } + } + return { hasV2Catalog, toolNames, aliasedAgentMessageToolNames }; +} + +export function hasPlaintextV2CollaborationCatalog(body: unknown): boolean { + if (!isPlainObject(body)) return false; + return Array.isArray(body.tools) && collaborationCatalogInfo([body.tools]).hasV2Catalog; +} + +function hasOptimizedNamespaceConflict(catalogs: readonly unknown[][]): boolean { + const pending = [...catalogs]; + while (pending.length > 0) { + const tools = pending.pop()!; + for (const tool of tools) { + if (!isPlainObject(tool)) continue; + if ( + typeof tool.name === "string" + && ( + tool.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || tool.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX) + || tool.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX) + ) + ) { + return true; + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) pending.push(tool.tools); + } + } + return false; +} + +function isToolIdentity(value: Record): boolean { + return value.type === "function" + || value.type === "custom" + || value.type === "function_call" + || value.type === "custom_tool_call"; +} + +function isOptimizedToolIdentity(value: unknown): boolean { + if (!isPlainObject(value)) return false; + if ( + isToolIdentity(value) + && ( + value.namespace === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || ( + typeof value.name === "string" + && ( + value.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || value.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX) + || value.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX) + ) + ) + ) + ) { + return true; + } + return value.type === "namespace" && value.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE; +} + +function hasOptimizedReferenceConflict(body: Record): boolean { + if (isOptimizedToolIdentity(body.tool_choice)) return true; + if ( + isPlainObject(body.tool_choice) + && Array.isArray(body.tool_choice.tools) + && body.tool_choice.tools.some(isOptimizedToolIdentity) + ) { + return true; + } + if (!Array.isArray(body.input)) return false; + return body.input.some(item => ( + isPlainObject(item) + && (item.type === "function_call" || item.type === "custom_tool_call") + && isOptimizedToolIdentity(item) + )); +} + +function hasToolSearchCollaborationConflict(body: Record): boolean { + if (!Array.isArray(body.input)) return false; + for (const item of body.input) { + if (!isPlainObject(item) || item.type !== "tool_search_output" || !Array.isArray(item.tools)) { + continue; + } + const pending = [item.tools]; + while (pending.length > 0) { + const tools = pending.pop()!; + for (const tool of tools) { + if (!isPlainObject(tool)) continue; + if ( + typeof tool.name === "string" + && (tool.name === COLLABORATION_NAMESPACE + || tool.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || tool.name.startsWith(COLLABORATION_NAME_PREFIX) + || tool.name.startsWith(COLLABORATION_DOTTED_NAME_PREFIX) + || tool.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX) + || tool.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX)) + ) { + return true; + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) pending.push(tool.tools); + } + } + } + return false; +} + +function hasAgentMessageEncryptionMarker(tool: Record): boolean { + return tool.type === "function" + && typeof tool.name === "string" + && PLAINTEXT_V2_AGENT_MESSAGE_TOOLS.has(tool.name) + && isPlainObject(tool.parameters) + && isPlainObject(tool.parameters.properties) + && isPlainObject(tool.parameters.properties.message) + && tool.parameters.properties.message.encrypted === true; +} + +function rewriteAgentMessageToolDeclaration(tool: Record): Record { + const alias = tool.type === "function" && typeof tool.name === "string" + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.get(tool.name) + : undefined; + if (!alias) return tool; + + let rewritten: Record = { ...tool, name: alias }; + if ( + hasAgentMessageEncryptionMarker(tool) + && isPlainObject(tool.parameters) + && isPlainObject(tool.parameters.properties) + && isPlainObject(tool.parameters.properties.message) + ) { + const { encrypted: _encrypted, ...messageSchema } = tool.parameters.properties.message; + rewritten = { + ...rewritten, + parameters: { + ...tool.parameters, + properties: { + ...tool.parameters.properties, + message: messageSchema, + }, + }, + }; + } + return rewritten; +} + +function hasPrivateToolName(name: string): boolean { + return name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX) + || name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX) + || PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.has(name.split(/__|\./).at(-1)!); +} + +function hasAgentMessageToolAliasCatalogConflict( + body: Record, + catalogs: readonly unknown[][], +): boolean { + const pending = [...catalogs]; + if (Array.isArray(body.input)) { + for (const item of body.input) { + if ( + isPlainObject(item) + && item.type === "tool_search_output" + && Array.isArray(item.tools) + ) { + pending.push(item.tools); + } + } + } + while (pending.length > 0) { + const tools = pending.pop()!; + for (const tool of tools) { + if (!isPlainObject(tool)) continue; + if ( + typeof tool.name === "string" + && hasPrivateToolName(tool.name) + ) { + return true; + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) pending.push(tool.tools); + } + } + return false; +} + +function hasAgentMessageToolAliasReference(value: unknown): boolean { + if (!isPlainObject(value) || !isToolIdentity(value) || typeof value.name !== "string") { + return false; + } + if (hasPrivateToolName(value.name)) return true; + for (const prefix of [COLLABORATION_NAME_PREFIX, COLLABORATION_DOTTED_NAME_PREFIX]) { + if ( + value.name.startsWith(prefix) + && PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.has(value.name.slice(prefix.length)) + ) { + return true; + } + } + return false; +} + +function hasAgentMessageToolAliasReferenceConflict(body: Record): boolean { + if (hasAgentMessageToolAliasReference(body.tool_choice)) return true; + if ( + isPlainObject(body.tool_choice) + && Array.isArray(body.tool_choice.tools) + && body.tool_choice.tools.some(hasAgentMessageToolAliasReference) + ) { + return true; + } + if (!Array.isArray(body.input)) return false; + return body.input.some(item => ( + isPlainObject(item) + && (item.type === "function_call" || item.type === "custom_tool_call") + && hasAgentMessageToolAliasReference(item) + )); +} + +function hasFlattenedCollaborationDeclarationConflict( + catalogs: readonly unknown[][], + collaborationToolNames: ReadonlySet, +): boolean { + const qualifiedNames = new Set( + [...collaborationToolNames].flatMap(name => [ + `${COLLABORATION_NAME_PREFIX}${name}`, + `${COLLABORATION_DOTTED_NAME_PREFIX}${name}`, + ]), + ); + const pending = catalogs.map(tools => ({ tools, collaborationNamespace: false })); + while (pending.length > 0) { + const { tools, collaborationNamespace } = pending.pop()!; + for (const tool of tools) { + if (!isPlainObject(tool)) continue; + if ( + !collaborationNamespace + && (tool.type === "function" || tool.type === "custom") + && typeof tool.name === "string" + && qualifiedNames.has(tool.name) + ) { + return true; + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) { + pending.push({ + tools: tool.tools, + collaborationNamespace: tool.name === COLLABORATION_NAMESPACE, + }); + } + } + } + return false; +} + +function rewriteToolCatalog(tools: unknown[]): { + tools: unknown[]; + namespaceAliased: boolean; +} { + let namespaceAliased = false; + let changed = false; + const rewritten = tools.map(tool => { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || tool.name !== COLLABORATION_NAMESPACE + || !Array.isArray(tool.tools) + ) { + return tool; + } + const childTools = tool.tools.map(child => ( + isPlainObject(child) ? rewriteAgentMessageToolDeclaration(child) : child + )); + namespaceAliased = true; + changed = true; + return { + ...tool, + name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + tools: childTools, + }; + }); + return { tools: changed ? rewritten : tools, namespaceAliased }; +} + +function aliasCollaborationReference( + value: unknown, + collaborationToolNames: ReadonlySet, +): unknown { + if (!isPlainObject(value)) return value; + const type = value.type; + const canCarryNamespace = isToolIdentity(value); + let rewritten = value; + if (canCarryNamespace && value.namespace === COLLABORATION_NAMESPACE) { + const name = (type === "function" || type === "function_call") && typeof value.name === "string" + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.get(value.name) ?? value.name + : value.name; + rewritten = { ...rewritten, namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, name }; + } + if (type === "namespace" && value.name === COLLABORATION_NAMESPACE) { + rewritten = { ...rewritten, name: PLAINTEXT_V2_COLLABORATION_NAMESPACE }; + } else if ( + canCarryNamespace + && typeof value.name === "string" + && value.name.startsWith(COLLABORATION_NAME_PREFIX) + && collaborationToolNames.has(value.name.slice(COLLABORATION_NAME_PREFIX.length)) + ) { + const childName = value.name.slice(COLLABORATION_NAME_PREFIX.length); + rewritten = { + ...rewritten, + name: `${PLAINTEXT_V2_COLLABORATION_NAME_PREFIX}${ + (type === "function" || type === "function_call") + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.get(childName) ?? childName + : childName + }`, + }; + } else if ( + canCarryNamespace + && typeof value.name === "string" + && value.name.startsWith(COLLABORATION_DOTTED_NAME_PREFIX) + && collaborationToolNames.has(value.name.slice(COLLABORATION_DOTTED_NAME_PREFIX.length)) + ) { + const childName = value.name.slice(COLLABORATION_DOTTED_NAME_PREFIX.length); + rewritten = { + ...rewritten, + name: `${PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX}${ + (type === "function" || type === "function_call") + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.get(childName) ?? childName + : childName + }`, + }; + } + return rewritten; +} + +function aliasCollaborationToolChoice( + toolChoice: unknown, + collaborationToolNames: ReadonlySet, +): unknown { + if (!isPlainObject(toolChoice)) return toolChoice; + let rewritten = aliasCollaborationReference( + toolChoice, + collaborationToolNames, + ) as Record; + if (!Array.isArray(toolChoice.tools)) return rewritten; + let toolsChanged = false; + const tools = toolChoice.tools.map(tool => { + const aliased = aliasCollaborationReference(tool, collaborationToolNames); + toolsChanged ||= aliased !== tool; + return aliased; + }); + if (toolsChanged) rewritten = { ...rewritten, tools }; + return rewritten; +} + +/** + * Prepare v2 collaboration tools for plaintext messages on the canonical ChatGPT wire. + * + * ChatGPT reserves both `collaboration` and the three message-tool names. The request therefore + * uses fixed, request-scoped aliases for both, then restores every identity before Codex sees it. + */ +export function preparePlaintextV2AgentMessages(body: unknown): { + body: unknown; + namespaceAliased: boolean; + toolNames: ReadonlySet; + aliasedAgentMessageToolNames: ReadonlySet; +} { + if (!isPlainObject(body)) { + return { + body, + namespaceAliased: false, + toolNames: new Set(), + aliasedAgentMessageToolNames: new Set(), + }; + } + const catalogs = responseToolCatalogs(body); + const catalogInfo = collaborationCatalogInfo(catalogs); + if ( + !hasPlaintextV2CollaborationCatalog(body) + || hasOptimizedNamespaceConflict(catalogs) + || hasOptimizedReferenceConflict(body) + || hasToolSearchCollaborationConflict(body) + || hasFlattenedCollaborationDeclarationConflict(catalogs, catalogInfo.toolNames) + || hasAgentMessageToolAliasCatalogConflict(body, catalogs) + || hasAgentMessageToolAliasReferenceConflict(body) + ) { + return { + body, + namespaceAliased: false, + toolNames: new Set(), + aliasedAgentMessageToolNames: new Set(), + }; + } + + let namespaceAliased = false; + let tools = body.tools; + if (Array.isArray(body.tools)) { + const rewritten = rewriteToolCatalog(body.tools); + tools = rewritten.tools; + namespaceAliased ||= rewritten.namespaceAliased; + } + + let input = body.input; + if (Array.isArray(body.input)) { + let inputChanged = false; + const rewrittenInput = body.input.map(item => { + if ( + !isPlainObject(item) + || item.type !== "additional_tools" + || !Array.isArray(item.tools) + ) { + return item; + } + const rewritten = rewriteToolCatalog(item.tools); + namespaceAliased ||= rewritten.namespaceAliased; + if (rewritten.tools === item.tools) return item; + inputChanged = true; + return { ...item, tools: rewritten.tools }; + }); + if (inputChanged) input = rewrittenInput; + } + + let toolChoice = body.tool_choice; + if (namespaceAliased) { + toolChoice = aliasCollaborationToolChoice(body.tool_choice, catalogInfo.toolNames); + if (Array.isArray(input)) { + let inputChanged = false; + const aliasedInput = input.map(item => { + if (!isPlainObject(item)) return item; + if (item.type !== "function_call" && item.type !== "custom_tool_call") return item; + const aliased = aliasCollaborationReference(item, catalogInfo.toolNames); + inputChanged ||= aliased !== item; + return aliased; + }); + if (inputChanged) input = aliasedInput; + } + } + + if (!namespaceAliased || (tools === body.tools && input === body.input && toolChoice === body.tool_choice)) { + return { + body, + namespaceAliased: false, + toolNames: new Set(), + aliasedAgentMessageToolNames: new Set(), + }; + } + return { + body: { + ...body, + ...(tools !== body.tools ? { tools } : {}), + ...(input !== body.input ? { input } : {}), + ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), + }, + namespaceAliased, + toolNames: new Set(catalogInfo.toolNames), + aliasedAgentMessageToolNames: new Set(catalogInfo.aliasedAgentMessageToolNames), + }; +} + +const MAX_RESTORED_TOOL_IDENTITIES = 10_000; +export const PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE = + "plaintext V2 agent-message response could not be restored within safe identity limits"; + +export class PlaintextV2AgentMessageRestoreOverflowError extends Error { + constructor() { + super(PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); + this.name = "PlaintextV2AgentMessageRestoreOverflowError"; + } +} + +type RestoreOutcome = { + value: unknown; + changed: boolean; + overflow: boolean; +}; + +type RestoreContext = { + toolNames: ReadonlySet; + aliasedAgentMessageToolNames: ReadonlySet; + remainingIdentities: number; +}; + +const unchanged = (value: unknown): RestoreOutcome => ({ value, changed: false, overflow: false }); + +function reserveIdentities(context: RestoreContext, count: number): boolean { + if (count > context.remainingIdentities) return false; + context.remainingIdentities -= count; + return true; +} + +function declaredChildName( + name: unknown, + toolNames: ReadonlySet, + aliasedAgentMessageToolNames: ReadonlySet, + allowAgentMessageAlias: boolean, +): string | undefined { + if (typeof name !== "string") return undefined; + if (allowAgentMessageAlias) { + const restoredName = PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.get(name); + if (restoredName) { + return aliasedAgentMessageToolNames.has(restoredName) && toolNames.has(restoredName) + ? restoredName + : undefined; + } + } + if (toolNames.has(name)) return name; + for (const prefix of [ + PLAINTEXT_V2_COLLABORATION_NAME_PREFIX, + PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX, + ]) { + if (!name.startsWith(prefix)) continue; + const childName = name.slice(prefix.length); + const restoredAlias = allowAgentMessageAlias + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.get(childName) + : undefined; + if (restoredAlias && !aliasedAgentMessageToolNames.has(restoredAlias)) return undefined; + const restoredChildName = restoredAlias ?? childName; + return toolNames.has(restoredChildName) ? restoredChildName : undefined; + } + return undefined; +} + +function restoreToolIdentity( + value: unknown, + context: RestoreContext, + allowNamespaceDeclaration = false, +): RestoreOutcome { + if (!isPlainObject(value)) return unchanged(value); + if (!reserveIdentities(context, 1)) return { ...unchanged(value), overflow: true }; + + if ( + allowNamespaceDeclaration + && value.type === "namespace" + && value.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + ) { + const children = restoreIdentityList(value.tools, context, false); + if (children.overflow) return { ...unchanged(value), overflow: true }; + return { + value: { + ...value, + name: COLLABORATION_NAMESPACE, + ...(children.changed ? { tools: children.value } : {}), + }, + changed: true, + overflow: false, + }; + } + + const identityType = value.type; + if ( + identityType !== "function" + && identityType !== "custom" + && identityType !== "function_call" + && identityType !== "custom_tool_call" + && identityType !== "response.function_call_arguments.done" + ) { + return unchanged(value); + } + + const allowAgentMessageAlias = ( + identityType === "function" + || identityType === "function_call" + || identityType === "response.function_call_arguments.done" + ) && ( + value.namespace === undefined + || value.namespace === PLAINTEXT_V2_COLLABORATION_NAMESPACE + ); + if (value.namespace !== undefined && value.namespace !== PLAINTEXT_V2_COLLABORATION_NAMESPACE) { + return unchanged(value); + } + const childName = declaredChildName( + value.name, + context.toolNames, + context.aliasedAgentMessageToolNames, + allowAgentMessageAlias, + ); + if (!childName) { + const privateIdentity = value.namespace === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || (typeof value.name === "string" && hasPrivateToolName(value.name)); + return { ...unchanged(value), overflow: privateIdentity }; + } + + let restored = value; + let changed = false; + if (value.namespace === PLAINTEXT_V2_COLLABORATION_NAMESPACE) { + restored = { ...restored, namespace: COLLABORATION_NAMESPACE }; + changed = true; + } + if (allowAgentMessageAlias && PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.has(value.name as string)) { + restored = { ...restored, name: childName }; + changed = true; + } else if (typeof value.name === "string" && value.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX)) { + restored = { ...restored, name: `${COLLABORATION_NAME_PREFIX}${childName}` }; + changed = true; + } else if ( + typeof value.name === "string" + && value.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX) + ) { + restored = { ...restored, name: `${COLLABORATION_DOTTED_NAME_PREFIX}${childName}` }; + changed = true; + } + return { value: restored, changed, overflow: false }; +} + +function restoreIdentityList( + values: unknown, + context: RestoreContext, + allowNamespaceDeclaration: boolean, +): RestoreOutcome { + if (!Array.isArray(values)) return unchanged(values); + if (values.length > context.remainingIdentities) { + return { ...unchanged(values), overflow: true }; + } + let restored: unknown[] | undefined; + for (let index = 0; index < values.length; index += 1) { + const result = restoreToolIdentity(values[index], context, allowNamespaceDeclaration); + if (result.overflow) return { ...unchanged(values), overflow: true }; + if (!result.changed) continue; + restored ??= values.slice(); + restored[index] = result.value; + } + return restored + ? { value: restored, changed: true, overflow: false } + : unchanged(values); +} + +function restoreToolChoice(value: unknown, context: RestoreContext): RestoreOutcome { + const direct = restoreToolIdentity(value, context); + if (direct.overflow || !isPlainObject(value) || !Array.isArray(value.tools)) return direct; + const tools = restoreIdentityList(value.tools, context, false); + if (tools.overflow) return { ...unchanged(value), overflow: true }; + if (!tools.changed) return direct; + const base = direct.value as Record; + return { value: { ...base, tools: tools.value }, changed: true, overflow: false }; +} + +function restoreResponseSnapshot(value: unknown, context: RestoreContext): RestoreOutcome { + if (!isPlainObject(value)) return unchanged(value); + const output = restoreIdentityList(value.output, context, false); + if (output.overflow) return { ...unchanged(value), overflow: true }; + const tools = restoreIdentityList(value.tools, context, true); + if (tools.overflow) return { ...unchanged(value), overflow: true }; + const toolChoice = restoreToolChoice(value.tool_choice, context); + if (toolChoice.overflow) return { ...unchanged(value), overflow: true }; + if (!output.changed && !tools.changed && !toolChoice.changed) return unchanged(value); + return { + value: { + ...value, + ...(output.changed ? { output: output.value } : {}), + ...(tools.changed ? { tools: tools.value } : {}), + ...(toolChoice.changed ? { tool_choice: toolChoice.value } : {}), + }, + changed: true, + overflow: false, + }; +} + +/** + * Restore request-scoped collaboration aliases only at documented Responses identity positions. + * Tool arguments, tool results, and extension metadata are deliberately opaque. + */ +export function restorePlaintextV2AgentMessageCalls( + value: unknown, + toolNames: ReadonlySet, + aliasedAgentMessageToolNames: ReadonlySet = toolNames, +): { value: unknown; changed: boolean; overflowed: boolean } { + if (toolNames.size === 0 || !isPlainObject(value)) { + return { value, changed: false, overflowed: false }; + } + const context: RestoreContext = { + toolNames, + aliasedAgentMessageToolNames, + remainingIdentities: MAX_RESTORED_TOOL_IDENTITIES, + }; + + const rootIdentity = restoreToolIdentity(value, context); + if (rootIdentity.overflow) return { value, changed: false, overflowed: true }; + const root = rootIdentity.value as Record; + const item = restoreToolIdentity(root.item, context); + if (item.overflow) return { value, changed: false, overflowed: true }; + const response = restoreResponseSnapshot(root.response, context); + if (response.overflow) return { value, changed: false, overflowed: true }; + const snapshot = restoreResponseSnapshot(root, context); + if (snapshot.overflow) return { value, changed: false, overflowed: true }; + + let restored = snapshot.value as Record; + let changed = rootIdentity.changed || snapshot.changed; + if (item.changed) { + restored = { ...restored, item: item.value }; + changed = true; + } + if (response.changed) { + restored = { ...restored, response: response.value }; + changed = true; + } + return changed + ? { value: restored, changed: true, overflowed: false } + : { value, changed: false, overflowed: false }; +} + +export function restorePlaintextV2AgentMessageCallsInJsonResult( + payload: string, + toolNames: ReadonlySet, + aliasedAgentMessageToolNames: ReadonlySet = toolNames, +): { value: string; changed: boolean; overflowed: boolean } { + if (toolNames.size === 0 || payload === "[DONE]") { + return { value: payload, changed: false, overflowed: false }; + } + let value: unknown; + try { + value = JSON.parse(payload); + } catch { + return { value: payload, changed: false, overflowed: true }; + } + if (!isPlainObject(value)) return { value: payload, changed: false, overflowed: true }; + const restored = restorePlaintextV2AgentMessageCalls( + value, + toolNames, + aliasedAgentMessageToolNames, + ); + if (restored.overflowed) return { value: payload, changed: false, overflowed: true }; + return restored.changed + ? { value: JSON.stringify(restored.value), changed: true, overflowed: false } + : { value: payload, changed: false, overflowed: false }; +} + +export function restorePlaintextV2AgentMessageCallsInJson( + payload: string, + toolNames: ReadonlySet, + aliasedAgentMessageToolNames: ReadonlySet = toolNames, +): string { + const restored = restorePlaintextV2AgentMessageCallsInJsonResult( + payload, + toolNames, + aliasedAgentMessageToolNames, + ); + if (restored.overflowed) throw new PlaintextV2AgentMessageRestoreOverflowError(); + return restored.value; +} + +export function createPlaintextV2AgentMessageCallRestoreRewrite( + toolNames: ReadonlySet, + aliasedAgentMessageToolNames: ReadonlySet = toolNames, +): (payload: string) => string { + const bindings = new Map(); + let refused = false; + return payload => { + if (refused) throw new PlaintextV2AgentMessageRestoreOverflowError(); + try { + const restored = restorePlaintextV2AgentMessageCallsInJson(payload, toolNames, aliasedAgentMessageToolNames); + if (payload === "[DONE]" || toolNames.size === 0) return restored; + const value = JSON.parse(restored) as Record; + const bind = (item: unknown, outputIndex?: unknown): void => { + if (!isPlainObject(item) || typeof item.name !== "string") return; + if (item.type !== "function_call" && item.type !== "response.function_call_arguments.done") return; + let namespace = typeof item.namespace === "string" ? item.namespace : ""; + let name = item.name; + for (const separator of ["__", "."]) { + const prefix = `${COLLABORATION_NAMESPACE}${separator}`; + if (name.startsWith(prefix) && (!namespace || namespace === COLLABORATION_NAMESPACE)) { + namespace = COLLABORATION_NAMESPACE; + name = name.slice(prefix.length); + } + } + const identity = JSON.stringify([namespace, name]); + const keys = [ + typeof item.id === "string" ? `id:${item.id}` : undefined, + typeof item.item_id === "string" ? `id:${item.item_id}` : undefined, + typeof item.call_id === "string" ? `call:${item.call_id}` : undefined, + typeof outputIndex === "number" ? `index:${outputIndex}` : undefined, + ].filter((key): key is string => key !== undefined); + for (const key of keys) { + const prior = bindings.get(key); + if (prior !== undefined && prior !== identity) throw new PlaintextV2AgentMessageRestoreOverflowError(); + if (prior === undefined && bindings.size >= MAX_RESTORED_TOOL_IDENTITIES) throw new PlaintextV2AgentMessageRestoreOverflowError(); + bindings.set(key, identity); + } + }; + bind(value, value.output_index); + bind(value.item, value.output_index); + const response = isPlainObject(value.response) ? value.response : value; + if (Array.isArray(response.output)) response.output.forEach((item, index) => bind(item, index)); + return restored; + } catch (error) { + refused = true; + throw error; + } + }; +} diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..8457c3a911 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -657,6 +657,13 @@ export function warnAgentTaskRecoveryStartup(config: { console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); } +export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void { + if (config.plaintextV2AgentMessages !== true) return; + console.warn("⚠️ Experimental plaintext V2 agent messages are enabled."); + console.warn(" Eligible ChatGPT collaboration calls may carry plaintext message arguments. HTTPS remains encrypted, but task text may be retained in Codex history, selected providers, and local response/debug state."); + console.warn(" This depends on undocumented ChatGPT and Codex behavior; it does not decrypt existing tasks."); +} + export function startServer(port?: number, deps: StartServerDeps = {}): Server { const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret(); // Captured before loadConfig() starts the optional ACL flight so stop() drains the same dir @@ -669,6 +676,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + let plaintextV2AgentMessageToolNames: ReadonlySet = new Set(); + let plaintextV2AgentMessageAliasedToolNames: ReadonlySet = new Set(); + const refreshRequestToolAliases = (builtRequest: AdapterRequest): void => { routedNamespaceToolAliases = builtRequest.convertedRoutedNamespaceToolAliases ?? new Map(); + plaintextV2AgentMessageToolNames = builtRequest.plaintextV2AgentMessageToolNames ?? new Set(); + plaintextV2AgentMessageAliasedToolNames = builtRequest.plaintextV2AgentMessageAliasedToolNames ?? new Set(); }; if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { @@ -4797,7 +4814,7 @@ async function handleResponsesInner( // would incorrectly disable restoration for the exact ambiguous-name case the alias fixes. routedToolSearchNames.add(name); } - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); // #1700: the bridged paths refuse a call to a tool the request never declared // (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed // provider's top-level `apply_patch` — which under Codex code mode exists only as a nested @@ -4983,7 +5000,7 @@ async function handleResponsesInner( } // The snapshot callback opts the inspector into output reconstruction. Compaction // has no continuation cache, so use the parsed terminal here without adding retention. - if (!rememberPassthroughResponse && payload && typeof payload === "object" + if (plaintextV2AgentMessageToolNames.size === 0 && !rememberPassthroughResponse && payload && typeof payload === "object" && "type" in payload && payload.type === "response.completed" && "response" in payload && payload.response && typeof payload.response === "object" && !Array.isArray(payload.response)) { @@ -5000,9 +5017,14 @@ async function handleResponsesInner( routedCustomToolRepairNames, declaredWireToolNames, ).value; - const restoredResponse = (functionRepairSchemas.size > 0 + const normalizedResponse = (functionRepairSchemas.size > 0 ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) : restored) as { id?: unknown; output?: unknown; status?: unknown }; + const plaintextRestore = restorePlaintextV2AgentMessageCalls( + normalizedResponse, plaintextV2AgentMessageToolNames, plaintextV2AgentMessageAliasedToolNames, + ); + if (plaintextRestore.overflowed) return; + const restoredResponse = plaintextRestore.value as typeof normalizedResponse; // Replay overlap compares the items the client echoes, including visible reasoning shape. const replayResponse = parsed.options.hideThinkingSummary !== true && routeUsesContentChannelReasoning(route.provider, route.modelId) @@ -5249,7 +5271,7 @@ async function handleResponsesInner( headers: selectedForwardHeaders, translatorBudget, }); - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); } catch (err) { @@ -5366,7 +5388,7 @@ async function handleResponsesInner( headers: selectedForwardHeaders, translatorBudget, }); - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); refreshUndeclaredToolGuard(request); @@ -5487,7 +5509,7 @@ async function handleResponsesInner( headers: selectedForwardHeaders, translatorBudget, }); - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); } catch (err) { @@ -5710,7 +5732,7 @@ async function handleResponsesInner( if (retry.kind === "retried") { authCtx = retry.authCtx; request = retry.request; - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); refreshUndeclaredToolGuard(request); upstreamResponse = retry.upstreamResponse; selectedForwardHeaders = retry.selectedForwardHeaders; @@ -5791,7 +5813,7 @@ async function handleResponsesInner( // treating a successful body as SSE when the caller requested streaming. const passthroughCt = headers.get("content-type")?.toLowerCase(); const isEventStream = passthroughCt?.includes("text/event-stream") - || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream); + || (plaintextV2AgentMessageToolNames.size === 0 && upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream); const recordTerminalOutcome = codexForwardTerminalOutcomeRecorder( config, authCtx, @@ -6010,6 +6032,18 @@ async function handleResponsesInner( // injection at the block level, after payload rewrites. Defaults come // from the finalized OUTBOUND body — the normalized internal tool shapes // are not the Responses wire shapes the snapshot must mirror. + // Only validated client blocks may publish plaintext continuation state. + // Raw inspection precedes rewriting on eager relays, so it cannot own this write. + const plaintextInspector = plaintextV2AgentMessageToolNames.size > 0 + ? createSseInspector({ onCompletedResponse: rememberPassthroughResponseChecked }) + : undefined; + const plaintextEncoder = plaintextInspector ? new TextEncoder() : undefined; + const rememberPlaintextBlock = plaintextInspector + ? Object.assign((block: string): readonly string[] => { + plaintextInspector.feed(plaintextEncoder!.encode(`${block}\n\n`)); + return [block]; + }, { dispose: () => plaintextInspector.dispose() }) + : undefined; const blockRewrites = [ payloadRewrites.length > 0 ? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites)) @@ -6037,6 +6071,11 @@ async function handleResponsesInner( snapshotRepairEnabled ? createResponsesSnapshotBlockRewrite(outboundRequestBody, translatorBudget) : undefined, + plaintextV2AgentMessageToolNames.size > 0 + ? payloadRewriteAsBlockRewrite(createPlaintextV2AgentMessageCallRestoreRewrite( + plaintextV2AgentMessageToolNames, plaintextV2AgentMessageAliasedToolNames, + )) + : undefined, createResponsesFieldBackfillBlockRewrite(), functionRepairSchemas.size > 0 ? createResponsesFunctionToolRepairBlockRewrite(functionRepairSchemas, translatorBudget) @@ -6051,6 +6090,7 @@ async function handleResponsesInner( declaredBareWireToolNames, ) : undefined, + rememberPlaintextBlock, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); const clientBlockRewrite = blockRewrites.length > 0 ? composeSseBlockRewrites(...blockRewrites) @@ -6098,7 +6138,7 @@ async function handleResponsesInner( const inspector = createSseInspector({ onTerminal: reportNativeTerminal, logCtx, - onCompletedResponse: rememberPassthroughResponse ? rememberPassthroughResponseChecked : undefined, + onCompletedResponse: rememberPassthroughResponse && plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, onParsedPayload: noteInspectedPayload, onFirstOutput: options.onFirstOutput, pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled, @@ -6197,7 +6237,7 @@ async function handleResponsesInner( responseCompletionCancelled = true; options.onNativePassthroughCancel?.(); }, - rememberPassthroughResponse ? rememberPassthroughResponseChecked : undefined, + rememberPassthroughResponse && plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, options.onFirstOutput, inspectionConsumerOptions, ); @@ -6207,7 +6247,7 @@ async function handleResponsesInner( logCtx, turnAc.signal, () => unregisterTurn(turnAc), - rememberPassthroughResponse ? rememberPassthroughResponseChecked : undefined, + rememberPassthroughResponse && plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, options.onFirstOutput, inspectionConsumerOptions, ); @@ -6249,6 +6289,7 @@ async function handleResponsesInner( } const text = bounded.text; inspectResponseLogJson(logCtx, text); + let plaintextV2RestoreFailed = false; let clientJson = (() => { const restoredNamespace = restoreRoutedNamespaceCallsInJson( scrubSelfNamedToolCallNamespaceInJson( @@ -6271,7 +6312,12 @@ async function handleResponsesInner( restored, routedToolSearchNames, ); - const repaired = normalizeFunctionCompletionJson(restoredToolSearch); + const normalizedJson = normalizeFunctionCompletionJson(restoredToolSearch); + const plaintextRestore = restorePlaintextV2AgentMessageCallsInJsonResult( + normalizedJson, plaintextV2AgentMessageToolNames, plaintextV2AgentMessageAliasedToolNames, + ); + plaintextV2RestoreFailed = plaintextRestore.overflowed; + const repaired = plaintextRestore.value; const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(repaired, parsed._responseModelId) : repaired; @@ -6283,6 +6329,9 @@ async function handleResponsesInner( ? rewriteReasoningSummaryInJsonString(modelRewritten) : modelRewritten; })(); + if (plaintextV2RestoreFailed) { + return formatErrorResponse(502, "upstream_error", PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); + } // #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and // the reframed-SSE branch below are built from this body, so one check covers them. This // runs BEFORE the continuation cache write below: a refused turn must not become state a @@ -6390,6 +6439,10 @@ async function handleResponsesInner( headers, }); } + if (plaintextV2AgentMessageToolNames.size > 0) { + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already closed */ } + return formatErrorResponse(502, "upstream_error", "plaintext V2 agent-message response used an unsupported content type"); + } // An unclassified passthrough body is relayed directly and has no bounded completion observer; // use the same non-error-status success boundary as SSE instead of retaining per-stream state. commitReasoningReplayServingRoute(); @@ -7158,7 +7211,7 @@ async function handleResponsesInner( Math.max(1, budget - transientSendsUsed); try { initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); - refreshRoutedNamespaceToolAliases(initialRequest); + refreshRequestToolAliases(initialRequest); recordAdapterReasoning(logCtx, initialRequest); recordAdapterTier(logCtx, initialRequest); inputTokenEstimate = typeof initialRequest.usageLog?.inputTokens === "number" @@ -7303,7 +7356,7 @@ async function handleResponsesInner( sameTargetParsed = parsed; sameTargetToken = transportToken; } - refreshRoutedNamespaceToolAliases(retryRequest); + refreshRequestToolAliases(retryRequest); const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number" ? retryRequest.usageLog.inputTokens : undefined; diff --git a/src/types/config.ts b/src/types/config.ts index acfa35f868..610122b420 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -636,6 +636,8 @@ export interface OcxConfig { * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92). */ keepNativeChatGptOnV1?: boolean; + /** Experimental plaintext delivery for native v2 collaboration messages; disabled unless true. */ + plaintextV2AgentMessages?: boolean; /** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */ agentTaskRecovery?: { enabled?: boolean; diff --git a/src/types/request.ts b/src/types/request.ts index 480a0509ad..cec8294a50 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -79,6 +79,8 @@ export interface OcxParsedRequest { * prepareOpaqueBlobRecovery after an authoritative rejection; consumers strip replayed blobs. */ _stripReasoningEncryptedContent?: boolean; + /** Final-route opt-in: emit v2 collaboration message arguments as plaintext on ChatGPT. */ + _plaintextV2AgentMessages?: boolean; /** * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation. * When absent (single-operator local proxy), derivation stays local-scoped. diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index dd98190184..b5da609df1 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -52,3 +52,6 @@ request when a node carries both. Codex's own deferred tool catalog emits exactl so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/catalog.md b/structure/catalog.md index 174e506470..7b98052910 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -264,3 +264,6 @@ provider wire mapping; unpinned native requests retain their existing pass-throu Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..333cc1699d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,6 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/config.md b/structure/config.md index 48a29a7817..da9cfda750 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,6 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 270e6c38de..a0e3e1d7d3 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -69,3 +69,6 @@ injects summary generation into a request, and config validation rejects a deliv conflicts with `modelSupportsReasoningSummaries: false` for the same model. > Decision record: [ADR-0045](../decisions/ADR-0045-standalone-images.md) + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7c90c2f74d..c0d153102f 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -89,3 +89,6 @@ copies an authoritative catalog context window into `limit.context` and a nonemp reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent instead of falling back to OpenCodex guesses, and the integration does not write the removed `thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..f1623ec56f 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -511,3 +511,6 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..5a8efccdc2 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,6 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index dd5f58e345..accf8b8ea3 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -132,3 +132,6 @@ Binary detection decodes only the supplied buffer view; malformed UTF-8 can itse so the flag does not identify the peer responsible for corruption. Existing diagnostic files are not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain client responsibilities. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..0dd160a44e 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,6 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e0b87add2d..ae59c9cc2f 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -264,3 +264,6 @@ fragments are not guessed onto pending ID-only calls. parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum safe-integer boundary, invalid index types, missing/null continuations and UTF-8 byte-limit boundaries. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index be42793e0b..49c30eab97 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -82,3 +82,6 @@ constraints cannot widen the canonical shape. Bare shell bridge names are reject on the freeform path. Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in `tests/providers/cursor/cursor-tool-definitions.test.ts`. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 4650e912dc..d354f7a551 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -59,3 +59,6 @@ positive value overwrites an earlier one. Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried `tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently ignored because a credit is not a token count. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 9320132a04..48e36bbab8 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -389,3 +389,6 @@ model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index d9155185fc..942386645e 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -49,3 +49,6 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay - **Authentication:** `Authorization: Bearer ` + `X-XAI-Token-Auth: xai-grok-cli`. No cookies required. - **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes. - **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 — a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..5bf47b1f4d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -188,3 +188,6 @@ not an authentication or entitlement decision. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/subagents.md b/structure/subagents.md index d9e6eaf1d2..f883b7f553 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -198,3 +198,23 @@ Native Codex advertisements still follow display priority; private guidance rank Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +## Plaintext V2 agent messages + +`src/responses/plaintext-v2-agent-messages.ts` owns the experimental, configuration-only +`plaintextV2AgentMessages` request compiler and response restoration. The default is unset; +only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. +A default top-level collaboration catalog is required. The compiler preserves caller objects, +aliases the namespace and three message functions, and removes only their true encryption marker. +Declaration/reference collisions refuse the whole rewrite without changing the request. + +`src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core +refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after +snapshot repair. Malformed, conflicting, unsupported or over-limit responses fail closed without +retrying the model. Raw stream inspection cannot publish plaintext continuation state: only +restored client blocks reach its dedicated bounded collector. Foreign namespaces and opaque +argument/metadata values remain unchanged; the empty encrypted-function-args marker is preserved. + +Startup warns that task text can remain in Codex history, selected-provider requests and local +response/debug state. This is application-level plaintext over HTTPS, depends on undocumented +upstream behavior, and does not decrypt existing tasks or replace authenticated recovery. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..db219d6d4b 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,6 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..d85342ab73 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,6 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 42d3442e99..4a6c650d9c 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -189,3 +189,6 @@ WebSocket clients observe the same canonical lifecycle. `ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket frame rather than always emitting `response.completed`. If the response status is `failed`, a `response.failed` frame is sent; otherwise `response.completed` carries through the original status. + +The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b92e85757c..18990aacbf 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -366,6 +366,7 @@ "consume-for-inspection-cancel.test.ts": "server", "container-bootstrap.test.ts": "service", "context-cap-unknown-window.test.ts": "providers", + "context-window-seed-repair.test.ts": "providers", "continuation-dedup.test.ts": "responses", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", @@ -690,7 +691,6 @@ "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", - "context-window-seed-repair.test.ts": "providers", "model-selection-guidance.test.ts": "cli", "model-visibility-management-api.test.ts": "codex-integration", "models-feedback-callback.test.ts": "gui", @@ -776,9 +776,9 @@ "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", - "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", + "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", @@ -814,6 +814,8 @@ "pi-path-contract.test.ts": "clients", "pinned-http.test.ts": "lib", "pinned-https-get.test.ts": "images", + "plaintext-v2-agent-messages-server.test.ts": "server", + "plaintext-v2-agent-messages.test.ts": "responses", "plan-video.test.ts": "videos", "plan.test.ts": "images", "policy-execution.test.ts": "routing", @@ -1024,7 +1026,6 @@ "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", - "structure-ssot.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", @@ -1056,6 +1057,7 @@ "storage-worker-teardown-isolate.test.ts": "storage", "stream-aborted-marker.test.ts": "server", "strict-semver.test.ts": "lib", + "structure-ssot.test.ts": "ci-workflows", "subagent-context-staleness.test.ts": "routing", "subagent-defaults.test.ts": "routing", "subagent-fallback-handle-responses.test.ts": "routing", diff --git a/tests/responses/plaintext-v2-agent-messages.test.ts b/tests/responses/plaintext-v2-agent-messages.test.ts new file mode 100644 index 0000000000..7e7a72e58b --- /dev/null +++ b/tests/responses/plaintext-v2-agent-messages.test.ts @@ -0,0 +1,834 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { + PlaintextV2AgentMessageRestoreOverflowError, + PLAINTEXT_V2_COLLABORATION_NAMESPACE, + preparePlaintextV2AgentMessages, + restorePlaintextV2AgentMessageCalls, + restorePlaintextV2AgentMessageCallsInJson, + restorePlaintextV2AgentMessageCallsInJsonResult, + shouldPreparePlaintextV2AgentMessages, +} from "../../src/responses/plaintext-v2-agent-messages"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +function collaborationTool(name: string, encrypted: boolean = true): Record { + return { + type: "function", + name, + parameters: { + type: "object", + properties: { + message: { + type: "string", + encrypted, + const: { encrypted: true }, + }, + encrypted: { type: "boolean" }, + }, + required: ["message"], + }, + }; +} + +describe("plaintext v2 agent message request preparation", () => { + test("strips only the three message markers and aliases collaboration catalogs", () => { + const body = { + model: "gpt-5.6-sol", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + collaborationTool("spawn_agent"), + collaborationTool("send_message"), + collaborationTool("followup_task", false), + collaborationTool("wait_agent"), + ], + }], + input: [{ + type: "additional_tools", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("followup_task")], + }], + }], + }; + const before = structuredClone(body); + + const prepared = preparePlaintextV2AgentMessages(body); + const result = prepared.body as typeof body; + const namespace = result.tools[0] as typeof body.tools[0]; + const spawn = namespace.tools[0] as ReturnType; + const send = namespace.tools[1] as ReturnType; + const followup = namespace.tools[2] as ReturnType; + const wait = namespace.tools[3] as ReturnType; + const additionalNamespace = result.input[0].tools[0] as { + name: string; + tools: Array>; + }; + const additional = additionalNamespace.tools[0]!; + const message = (tool: Record) => ( + ((tool.parameters as Record).properties as Record>).message + ); + + expect(prepared.namespaceAliased).toBe(true); + expect([...prepared.toolNames].sort()).toEqual([ + "followup_task", + "send_message", + "spawn_agent", + "wait_agent", + ]); + expect(namespace.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(additionalNamespace.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(spawn.name).toBe("start_delegated_task"); + expect(send.name).toBe("deliver_delegated_message"); + expect(followup.name).toBe("continue_delegated_task"); + expect(additional.name).toBe("continue_delegated_task"); + expect(wait.name).toBe("wait_agent"); + expect(message(spawn).encrypted).toBeUndefined(); + expect(message(send).encrypted).toBeUndefined(); + expect(message(additional).encrypted).toBeUndefined(); + expect(message(followup).encrypted).toBe(false); + expect(message(wait).encrypted).toBe(true); + expect(message(spawn).const).toEqual({ encrypted: true }); + expect(((spawn.parameters as Record).properties as Record).encrypted) + .toEqual({ type: "boolean" }); + expect(body).toEqual(before); + }); + + test("does not reinterpret a flat same-named function as the Codex v2 catalog", () => { + const body = { tools: [collaborationTool("spawn_agent")] }; + const prepared = preparePlaintextV2AgentMessages(body); + const tool = (prepared.body as typeof body).tools[0] as Record; + const message = ((tool.parameters as Record).properties as Record>).message; + + expect(message.encrypted).toBe(true); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("does not strip same-named tools from another namespace", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { + type: "namespace", + name: "private_mail", + tools: [collaborationTool("send_message")], + }, + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + const privateTool = (prepared.body as typeof body).tools[1]!.tools[0] as Record; + const privateMessage = ((privateTool.parameters as Record).properties as Record>).message; + + expect(privateMessage.encrypted).toBe(true); + expect(privateTool.name).toBe("send_message"); + }); + + test("does not strip an independent flat tool beside a collaboration namespace", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + collaborationTool("send_message"), + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + const flatTool = (prepared.body as typeof body).tools[1] as Record; + const flatMessage = ((flatTool.parameters as Record).properties as Record>).message; + expect(flatMessage.encrypted).toBe(true); + expect(flatTool.name).toBe("send_message"); + }); + + test("aliases selectors and replayed calls with the rewritten collaboration catalog", () => { + const replayedCall = { + type: "function_call", + call_id: "call-old", + namespace: "collaboration", + name: "spawn_agent", + arguments: "{}", + }; + const replayedOutput = { + type: "function_call_output", + call_id: "call-old", + output: { namespace: "collaboration" }, + }; + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent"), collaborationTool("send_message")], + }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", namespace: "collaboration", name: "send_message" }, + { type: "function", namespace: "private_mail", name: "send_message" }, + ], + }, + input: [replayedCall, replayedOutput], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + const result = prepared.body as typeof body; + + expect(result.tools[0]!.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(result.tool_choice.tools[0]!.namespace).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(result.tool_choice.tools[0]!.name).toBe("deliver_delegated_message"); + expect(result.tool_choice.tools[1]!.namespace).toBe("private_mail"); + expect(result.input[0]!.namespace).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(result.input[0]!.name).toBe("start_delegated_task"); + expect(result.input[1]).toEqual(replayedOutput); + }); + + test("aliases a forced collaboration tool choice", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent"), collaborationTool("followup_task")], + }], + tool_choice: { type: "function", namespace: "collaboration", name: "followup_task" }, + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect((prepared.body as typeof body).tool_choice.namespace) + .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect((prepared.body as typeof body).tool_choice.name).toBe("continue_delegated_task"); + }); + + test("aliases both supported qualified-name forms using declared child names", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent"), collaborationTool("send_message")], + }], + tool_choice: { type: "function", name: "collaboration__spawn_agent" }, + input: [{ + type: "function_call", + call_id: "call-send", + name: "collaboration.send_message", + arguments: "{}", + }], + }; + + const result = preparePlaintextV2AgentMessages(body).body as typeof body; + expect(result.tool_choice.name) + .toBe(`${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`); + expect(result.input[0]!.name) + .toBe(`${PLAINTEXT_V2_COLLABORATION_NAMESPACE}.deliver_delegated_message`); + }); + + test("aliases every duplicate collaboration declaration in one request", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "wait_agent", parameters: { type: "object" } }], + }, + ], + tool_choice: { type: "function", namespace: "collaboration", name: "wait_agent" }, + }; + + const result = preparePlaintextV2AgentMessages(body).body as typeof body; + expect(result.tools.map(tool => tool.name)).toEqual([ + PLAINTEXT_V2_COLLABORATION_NAMESPACE, + PLAINTEXT_V2_COLLABORATION_NAMESPACE, + ]); + expect(result.tool_choice.namespace).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + }); + + test("does not reinterpret an independent flattened-looking tool name", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { type: "function", name: "collaboration__audit", parameters: { type: "object" } }, + ], + tool_choice: { + type: "allowed_tools", + tools: [{ type: "function", name: "collaboration__audit" }], + }, + input: [{ + type: "function_call", + call_id: "call-audit", + name: "collaboration__audit", + arguments: "{}", + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + const result = prepared.body as typeof body; + expect(result.tool_choice.tools[0]!.name).toBe("collaboration__audit"); + expect(result.input[0]!.name).toBe("collaboration__audit"); + }); + + test("skips aliasing when a flat declaration collides with a namespace child", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { type: "function", name: "collaboration__spawn_agent", parameters: { type: "object" } }, + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("aliases a recognized collaboration catalog even when the marker is already absent", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent", false)], + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect((prepared.body as typeof body).tools[0]!.name) + .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect((prepared.body as typeof body).tools[0]!.tools[0]!.name) + .toBe("start_delegated_task"); + expect(prepared.namespaceAliased).toBe(true); + }); + + test("leaves the whole request untouched when a collaboration child already uses a private tool alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + collaborationTool("spawn_agent"), + collaborationTool("start_delegated_task"), + ], + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("leaves the whole request untouched when a top-level tool uses a fixed message alias", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { type: "function", name: "start_delegated_task", parameters: { type: "object" } }, + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("leaves the whole request untouched when another namespace uses a fixed message alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + input: [{ + type: "additional_tools", + tools: [{ + type: "namespace", + name: "foreign", + tools: [{ type: "function", name: "deliver_delegated_message", parameters: { type: "object" } }], + }], + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("scans tool-search declarations and foreign references for fixed message aliases", () => { + const catalog = [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }]; + const cases = [ + { + tools: catalog, + input: [{ + type: "tool_search_output", + tools: [{ type: "function", name: "continue_delegated_task", parameters: { type: "object" } }], + }], + }, + { + tools: catalog, + tool_choice: { type: "function", name: "start_delegated_task" }, + }, + { + tools: catalog, + input: [{ + type: "function_call", + namespace: "foreign", + name: "deliver_delegated_message", + call_id: "foreign-call", + arguments: "{}", + }], + }, + ]; + + for (const body of cases) { + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + } + }); + + test("leaves the whole request untouched when replay history already uses a private tool alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + input: [{ + type: "function_call", + call_id: "call-private-name", + namespace: "collaboration", + name: "start_delegated_task", + arguments: "{}", + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("leaves the whole request untouched when the private alias already exists", () => { + const body = { + tools: [ + { type: "namespace", name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, tools: [] }, + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + expect(JSON.stringify(prepared.body)).toContain('"encrypted":true'); + }); + + test("leaves the request untouched when replay history already uses the private alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + input: [{ + type: "function_call", + call_id: "call-private", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "audit", + arguments: "{}", + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("leaves the request untouched when tool-search history declares the private alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + input: [{ + type: "tool_search_output", + tools: [{ + type: "namespace", + name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + tools: [], + }], + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("does not treat a collaboration namespace nested under another namespace as Codex v2", () => { + const depth = 20_000; + const collaboration = { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + } as Record; + let root: Record = collaboration; + for (let index = 0; index < depth; index++) { + root = { type: "namespace", name: `nest-${index}`, tools: [root] }; + } + + const prepared = preparePlaintextV2AgentMessages({ tools: [root] }); + expect(prepared.namespaceAliased).toBe(false); + }); +}); + +describe("plaintext v2 agent message response restoration", () => { + const declaredToolNames = new Set(["spawn_agent", "send_message"]); + + test("restores tool identities and preserves the plaintext proof and user data", () => { + const payload = JSON.stringify({ + type: "response.completed", + response: { + tool_choice: { + type: "function", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + }, + tools: [{ + type: "namespace", + name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + tools: [{ type: "function", name: "start_delegated_task" }], + }], + output: [ + { + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: JSON.stringify({ + message: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + }), + encrypted_function_args: [], + }, + { + type: "function_call", + name: `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__deliver_delegated_message`, + arguments: "{}", + encrypted_function_args: [], + }, + { + type: "function_call_output", + output: { namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE }, + }, + ], + }, + }); + + const restored = JSON.parse( + restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), + ) as { + response: { + tool_choice: Record; + tools: Array>; + output: Array>; + }; + }; + const [namespaced, flattened, toolOutput] = restored.response.output; + + expect(namespaced!.namespace).toBe("collaboration"); + expect(namespaced!.name).toBe("spawn_agent"); + expect(namespaced!.encrypted_function_args).toEqual([]); + expect(JSON.parse(namespaced!.arguments as string).message) + .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(flattened!.name).toBe("collaboration__send_message"); + expect(flattened!.encrypted_function_args).toEqual([]); + expect(toolOutput!.output).toEqual({ namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE }); + expect(restored.response.tool_choice.namespace).toBe("collaboration"); + expect(restored.response.tool_choice.name).toBe("spawn_agent"); + expect(restored.response.tools[0]!.name).toBe("collaboration"); + expect((restored.response.tools[0]!.tools as Array>)[0]!.name) + .toBe("spawn_agent"); + }); + + test("restores the identity on streamed function-call argument completion", () => { + const payload = JSON.stringify({ + type: "response.function_call_arguments.done", + item_id: "fc-spawn", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`, + arguments: JSON.stringify({ message: PLAINTEXT_V2_COLLABORATION_NAMESPACE }), + encrypted_function_args: [], + }); + + const restored = JSON.parse( + restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), + ) as Record; + expect(restored.namespace).toBe("collaboration"); + expect(restored.name).toBe("collaboration__spawn_agent"); + expect(JSON.parse(restored.arguments as string).message) + .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(restored.encrypted_function_args).toEqual([]); + }); + + test("is byte-identical for invalid JSON and payloads without the private alias", () => { + for (const payload of ["not json", '{"type":"response.completed"}']) { + expect(restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames)).toBe(payload); + } + }); + + test("restores an unqualified private tool alias in streamed JSON", () => { + const payload = JSON.stringify({ + type: "function_call", + name: "start_delegated_task", + arguments: JSON.stringify({ message: "plain assignment" }), + encrypted_function_args: [], + }); + + const restored = JSON.parse( + restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), + ) as Record; + expect(restored.name).toBe("spawn_agent"); + expect(restored.encrypted_function_args).toEqual([]); + }); + + test("does not restore a fixed alias authenticated by another namespace", () => { + const payload = JSON.stringify({ + type: "function_call", + namespace: "foreign", + name: "start_delegated_task", + arguments: "{}", + }); + + expect(restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames)).toBe(payload); + }); + + test("restores only message aliases that this request actually generated", () => { + const prepared = preparePlaintextV2AgentMessages({ + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + collaborationTool("spawn_agent"), + { type: "custom", name: "send_message" }, + ], + }], + }); + const payload = JSON.stringify({ + type: "function_call", + name: "deliver_delegated_message", + arguments: "{}", + }); + + expect([...prepared.aliasedAgentMessageToolNames]).toEqual(["spawn_agent"]); + expect(() => restorePlaintextV2AgentMessageCallsInJson( + payload, + prepared.toolNames, + prepared.aliasedAgentMessageToolNames, + )).toThrow(PlaintextV2AgentMessageRestoreOverflowError); + }); + + test("leaves foreign calls and nested extension metadata untouched", () => { + const extensionCall = { + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + }; + const payload = JSON.stringify({ + type: "response.completed", + response: { + output: [ + { + type: "function_call", + namespace: "foreign", + name: "audit", + arguments: "{}", + }, + { + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: "{}", + }, + ], + metadata: { + nested: extensionCall, + values: Array.from({ length: 20_000 }, (_, index) => index), + }, + }, + }); + + const restored = JSON.parse( + restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), + ) as { + response: { + output: Array>; + metadata: { nested: Record; values: number[] }; + }; + }; + + expect(restored.response.output[0]!.namespace).toBe("foreign"); + expect(restored.response.output[1]!.namespace).toBe("collaboration"); + expect(restored.response.metadata.nested).toEqual(extensionCall); + expect(restored.response.metadata.values).toHaveLength(20_000); + }); + + test("fails closed when known identity arrays exceed the work limit", () => { + const value = { + output: Array.from({ length: 10_001 }, () => ({ + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + })), + }; + const payload = JSON.stringify(value); + + expect(restorePlaintextV2AgentMessageCallsInJsonResult(payload, declaredToolNames)).toEqual({ + value: payload, + changed: false, + overflowed: true, + }); + expect(() => restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames)) + .toThrow(PlaintextV2AgentMessageRestoreOverflowError); + expect(restorePlaintextV2AgentMessageCalls(value, declaredToolNames)).toEqual({ + value, + changed: false, + overflowed: true, + }); + }); +}); + +describe("plaintext v2 agent message route policy", () => { + test("requires an explicit opt-in, Responses inbound, canonical ChatGPT, and a v2 catalog", () => { + const requestBody = { tools: [{ type: "namespace", name: "collaboration", tools: [collaborationTool("spawn_agent")] }] }; + const baseline = { + enabled: true, + inboundWire: "responses", + canonicalChatGpt: true, + requestBody, + }; + expect(shouldPreparePlaintextV2AgentMessages(baseline)).toBe(true); + const additionalOnly = { input: [{ type: "additional_tools", tools: requestBody.tools }] }; + expect(shouldPreparePlaintextV2AgentMessages({ ...baseline, requestBody: additionalOnly })).toBe(false); + expect(preparePlaintextV2AgentMessages(additionalOnly).namespaceAliased).toBe(false); + expect(shouldPreparePlaintextV2AgentMessages({ ...baseline, enabled: false })).toBe(false); + expect(shouldPreparePlaintextV2AgentMessages({ ...baseline, inboundWire: "anthropic" })).toBe(false); + expect(shouldPreparePlaintextV2AgentMessages({ ...baseline, canonicalChatGpt: false })).toBe(false); + expect(shouldPreparePlaintextV2AgentMessages({ + ...baseline, + requestBody: { tools: [collaborationTool("spawn_agent")] }, + })).toBe(false); + }); +}); + +describe("canonical Responses adapter plaintext v2 integration", () => { + const provider = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward" as const, + }; + + function build(enabled: boolean) { + const rawBody = { + model: "gpt-5.6-sol", + store: false, + stream: true, + input: "delegate", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + }; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + ...(enabled ? { _plaintextV2AgentMessages: true } : {}), + }, { headers: new Headers({ authorization: "Bearer test" }) }); + return { request, rawBody }; + } + + test("changes only the serialized upstream body when enabled", () => { + const { request, rawBody } = build(true); + const sent = JSON.parse(request.body) as typeof rawBody; + const namespace = sent.tools[0]!; + const spawn = namespace.tools[0] as Record; + const message = ((spawn.parameters as Record).properties as Record>).message; + + expect(namespace.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(spawn.name).toBe("start_delegated_task"); + expect(message.encrypted).toBeUndefined(); + expect([...(request.plaintextV2AgentMessageToolNames ?? [])]).toEqual(["spawn_agent"]); + expect([...(request.plaintextV2AgentMessageAliasedToolNames ?? [])]).toEqual(["spawn_agent"]); + expect(rawBody.tools[0]!.name).toBe("collaboration"); + expect(JSON.stringify(rawBody)).toContain('"encrypted":true'); + }); + + test("keeps the upstream collaboration schema unchanged when disabled", () => { + const { request, rawBody } = build(false); + const sent = JSON.parse(request.body); + + expect(sent).toEqual(rawBody); + expect(request.plaintextV2AgentMessageToolNames).toBeUndefined(); + expect(request.plaintextV2AgentMessageAliasedToolNames).toBeUndefined(); + }); +}); + +describe("plaintext V2 refusal boundaries", () => { + const names = new Set(["spawn_agent", "send_message"]); + test("rejects malformed and non-object JSON even without literal aliases", () => { + for (const payload of ["{broken", "[]", "null", '"opaque"']) { + expect(restorePlaintextV2AgentMessageCallsInJsonResult(payload, names).overflowed).toBe(true); + } + }); + test("rejects unknown private identities instead of leaking an unrestorable alias", () => { + expect(() => restorePlaintextV2AgentMessageCallsInJson(JSON.stringify({ + output: [{ type: "function_call", namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, name: "undeclared", arguments: "{}" }], + }), names)).toThrow(PlaintextV2AgentMessageRestoreOverflowError); + }); + test("qualified aliases in nested foreign catalogs prevent any request rewrite", () => { + for (const separator of ["__", "."]) { + const body = { tools: [ + { type: "namespace", name: "collaboration", tools: [collaborationTool("spawn_agent")] }, + { type: "namespace", name: "foreign", tools: [{ type: "function", name: `other${separator}start_delegated_task` }] }, + ] }; + expect(preparePlaintextV2AgentMessages(body).body).toBe(body); + } + }); + test("preserves qualified names explicitly authenticated by a foreign namespace", () => { + const payload = JSON.stringify({ type: "function_call", namespace: "foreign", name: `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`, arguments: "{}" }); + expect(restorePlaintextV2AgentMessageCallsInJson(payload, names)).toBe(payload); + }); +}); diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 80c6291780..bbafa3cfa8 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -1,3 +1,7 @@ +import { + PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE, + PLAINTEXT_V2_COLLABORATION_NAMESPACE, +} from "../../src/responses/plaintext-v2-agent-messages"; import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; import { providerFetch } from "../../src/server/responses/fetch-helpers"; import { handleResponses } from "../../src/server/responses"; @@ -346,6 +350,206 @@ describe("handleResponses Codex WS relay selection", () => { }); } + function plaintextV2CollaborationRequest(): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer test" }, + body: JSON.stringify({ + model: "gpt-5.5", + stream: true, + tools: [{ type: "namespace", name: "collaboration", tools: [{ + type: "function", name: "spawn_agent", parameters: { + type: "object", properties: { message: { type: "string", encrypted: true } }, + }, + }, { type: "function", name: "send_message", parameters: { type: "object" } }] }], + input: [ + { + type: "additional_tools", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + { + type: "function", + name: "spawn_agent", + parameters: { + type: "object", + properties: { message: { type: "string", encrypted: true } }, + }, + }, + { type: "function", name: "send_message", parameters: { type: "object" } }, + ], + }], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "delegate" }] }, + ], + }), + }); + } + + test("plaintext v2 collaboration rewriting applies to WS requests and responses", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { + data: JSON.stringify({ + type: "response.created", + response: { + id: "r-plaintext-v2-ws", + object: "response", + status: "in_progress", + output: [], + }, + }), + }); + ws.emit("message", { + data: JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item: { + type: "function_call", + id: "fc_spawn", + call_id: "call-spawn", + namespace: "collaboration-optimize", + name: "start_delegated_task", + arguments: "", + encrypted_function_args: [], + status: "in_progress", + }, + }), + }); + ws.emit("message", { + data: JSON.stringify({ + type: "response.function_call_arguments.done", + item_id: "fc_spawn", + output_index: 0, + namespace: "collaboration-optimize", + name: "collaboration-optimize__start_delegated_task", + arguments: JSON.stringify({ message: "plain WS assignment" }), + encrypted_function_args: [], + }), + }); + ws.emit("message", { + data: JSON.stringify({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "function_call", + id: "fc_spawn", + call_id: "call-spawn", + namespace: "collaboration-optimize", + name: "start_delegated_task", + arguments: JSON.stringify({ message: "plain WS assignment" }), + encrypted_function_args: [], + status: "completed", + }, + }), + }); + ws.emit("message", { + data: JSON.stringify({ + type: "response.completed", + response: { + id: "r-plaintext-v2-ws", + status: "completed", + output: [{ + type: "function_call", + id: "fc_spawn", + call_id: "call-spawn", + namespace: "collaboration-optimize", + name: "start_delegated_task", + arguments: JSON.stringify({ message: "plain WS assignment" }), + encrypted_function_args: [], + status: "completed", + }], + }, + }), + }); + }); + const config = { ...forwardConfig(), plaintextV2AgentMessages: true } as OcxConfig; + const request = plaintextV2CollaborationRequest(); + + const response = await handleResponses(request, config, { model: "", provider: "" }, { + codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME, + }); + expect(FakeWebSocket.instances).toHaveLength(1); + const frame = JSON.parse(FakeWebSocket.instances[0]!.sent[0]!) as { + type: string; + stream?: unknown; + input: Array>; + }; + const additionalTools = frame.input.find(item => item.type === "additional_tools") as { + tools: Array<{ + name: string; + tools: Array<{ + name: string; + parameters: { properties: { message: Record } }; + }>; + }>; + }; + expect(frame.type).toBe("response.create"); + expect(frame.stream).toBeUndefined(); + expect(additionalTools.tools[0]!.name).toBe("collaboration-optimize"); + expect(additionalTools.tools[0]!.tools[0]!.name).toBe("start_delegated_task"); + expect(additionalTools.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBeUndefined(); + + expect(isEagerRelaySseResponse(response)).toBe(true); + const clientText = await response.text(); + expect(clientText).toContain("response.function_call_arguments.done"); + const argumentDoneLine = clientText.split("\n") + .find(line => line.includes('"response.function_call_arguments.done"'))!; + const argumentDone = JSON.parse(argumentDoneLine.replace(/^data: /, "")) as Record; + expect(argumentDone.namespace).toBe("collaboration"); + expect(argumentDone.name).toBe("collaboration__spawn_agent"); + expect(argumentDone.encrypted_function_args).toEqual([]); + const completedLine = clientText.split("\n") + .find(line => line.includes('"response.completed"'))!; + const completed = JSON.parse(completedLine.replace(/^data: /, "")) as { + response: { output: Array> }; + }; + expect(completed.response.output[0]!.namespace).toBe("collaboration"); + expect(completed.response.output[0]!.name).toBe("spawn_agent"); + expect(completed.response.output[0]!.encrypted_function_args).toEqual([]); + }); + + test("plaintext v2 restoration overflow fails closed on the WS upstream path", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { + data: JSON.stringify({ + type: "response.completed", + response: { + id: "r-plaintext-v2-ws-overflow", + status: "completed", + output: Array.from({ length: 10_000 }, (_, index) => ({ + type: "function_call", + call_id: `call-${index}`, + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: "{}", + })), + }, + }), + }); + }); + const config = { ...forwardConfig(), plaintextV2AgentMessages: true } as OcxConfig; + + const response = await handleResponses( + plaintextV2CollaborationRequest(), + config, + { model: "", provider: "" }, + { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }, + ); + + expect(FakeWebSocket.instances).toHaveLength(1); + expect(isEagerRelaySseResponse(response)).toBe(true); + const clientText = await response.text(); + expect(clientText).toContain("event: response.failed"); + expect(clientText).toContain(PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); + expect(clientText).toContain("data: [DONE]"); + expect(clientText).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientText).not.toContain("start_delegated_task"); + expect(FakeWebSocket.instances[0]!.closed).toBe(true); + }); + test("a successful WS upgrade bypasses the configured legacy tee path", async () => { installFake(ws => { ws.emit("open", {}); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index b096b5857c..aa36e55d16 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -1216,6 +1216,46 @@ describe("opencodex config defaults", () => { } }); + test("plaintextV2AgentMessages is explicit and degrades invalid hand edits", () => { + const base = { + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + }, + }, + defaultProvider: "custom", + }; + expect(getDefaultConfig().plaintextV2AgentMessages).toBeUndefined(); + + writeConfig({ ...base, plaintextV2AgentMessages: true }); + expect(loadConfig()).toMatchObject({ ...base, plaintextV2AgentMessages: true }); + expect(validateConfigCandidate({ ...base, plaintextV2AgentMessages: true })).toMatchObject({ + ok: true, + config: { plaintextV2AgentMessages: true }, + }); + + for (const invalid of [null, "true", 1, {}]) { + writeConfig({ ...base, plaintextV2AgentMessages: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics).toMatchObject({ + source: "file", + error: null, + config: base, + }); + expect(diagnostics.config.plaintextV2AgentMessages).toBeUndefined(); + expect(diagnostics.warnings).toContain( + "plaintextV2AgentMessages ignored: expected a boolean", + ); + expect(validateConfigCandidate({ ...base, plaintextV2AgentMessages: invalid })).toMatchObject({ + ok: false, + error: expect.stringContaining("plaintextV2AgentMessages"), + }); + expect(backupNames()).toEqual([]); + } + }); + test("native subagent-default sync is opt-in and ignores malformed opt-ins without falling back", () => { const base = { port: 12345, diff --git a/tests/server/plaintext-v2-agent-messages-server.test.ts b/tests/server/plaintext-v2-agent-messages-server.test.ts new file mode 100644 index 0000000000..e70946cafc --- /dev/null +++ b/tests/server/plaintext-v2-agent-messages-server.test.ts @@ -0,0 +1,621 @@ +import { warnPlaintextV2AgentMessagesStartup } from "../../src/server"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { + PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE, + PLAINTEXT_V2_COLLABORATION_NAMESPACE, +} from "../../src/responses/plaintext-v2-agent-messages"; +import { clearResponseStateForTests } from "../../src/responses/state"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +beforeEach(() => { clearResponseStateForTests(); }); +afterEach(() => { + globalThis.fetch = originalFetch; + clearResponseStateForTests(); +}); + +function config( + enabled: boolean, + snapshotRepair = false, + streamMode?: "auto" | "legacy-tee" | "eager-relay", +): OcxConfig { + return { + defaultProvider: "native", + providers: { + native: { + adapter: "openai-responses", + baseUrl: CODEX_FORWARD_BASE_URL, + authMode: "forward", + ...(snapshotRepair ? { responsesSnapshotRepair: true } : {}), + }, + }, + plaintextV2AgentMessages: enabled, + ...(streamMode ? { streamMode } : {}), + } as OcxConfig; +} + +function collaborationRequest(options: { + input?: unknown[]; + model?: string; + previousResponseId?: string; + toolChoice?: unknown; +} = {}): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: options.model ?? "native/gpt-5.6-sol", + store: false, + stream: true, + input: options.input ?? [{ + type: "message", + role: "user", + content: [{ type: "input_text", text: "delegate" }], + }], + ...(options.previousResponseId ? { previous_response_id: options.previousResponseId } : {}), + ...(options.toolChoice ? { tool_choice: options.toolChoice } : {}), + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + { + type: "function", + name: "spawn_agent", + parameters: { + type: "object", + properties: { message: { type: "string", encrypted: true } }, + required: ["message"], + }, + }, + { type: "function", name: "send_message", parameters: { type: "object" } }, + ], + }], + }), + }); +} + +async function withPoolHome(run: () => Promise): Promise { + const home = mkdtempSync(join(tmpdir(), "ocx-plaintext-v2-pool-")); + const previousOpencodexHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountQuota(); + try { + return await run(); + } finally { + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountQuota(); + rmSync(home, { recursive: true, force: true }); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } +} + +function completedResponsePayload(id = "resp-plaintext-v2") { + return { + id, + status: "completed", + output: [{ + type: "function_call", + call_id: "call-spawn", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: JSON.stringify({ message: "plain assignment" }), + encrypted_function_args: [], + }], + }; +} + +function overLimitResponsePayload(id = "resp-plaintext-v2-overflow") { + return { + id, + status: "completed", + output: Array.from({ length: 10_000 }, (_, index) => ({ + type: "function_call", + call_id: `call-${index}`, + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: "{}", + })), + }; +} + +describe("plaintext v2 agent messages at the Responses server boundary", () => { + test("rewrites the canonical request and restores every SSE response snapshot", async () => { + const sentBodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + const response = completedResponsePayload(); + return new Response( + `event: response.output_item.added\ndata: ${JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item: response.output[0], + })}\n\nevent: response.function_call_arguments.done\ndata: ${JSON.stringify({ + type: "response.function_call_arguments.done", + item_id: "fc-spawn", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`, + arguments: JSON.stringify({ message: "plain assignment" }), + encrypted_function_args: [], + })}\n\nevent: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response, + })}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + + const response = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + const sentBody = JSON.parse(sentBodies[0]!) as { + tools: Array<{ + name: string; + tools: Array<{ + name: string; + parameters: { properties: { message: Record } }; + }>; + }>; + }; + + expect(sentBodies).toHaveLength(1); + expect(sentBody.tools[0]!.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(sentBody.tools[0]!.tools[0]!.name).toBe("start_delegated_task"); + expect(sentBody.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBeUndefined(); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientBody).toContain('"namespace":"collaboration"'); + expect(clientBody).toContain('"name":"collaboration__spawn_agent"'); + expect(clientBody).toContain('"encrypted_function_args":[]'); + }); + + test("restores the namespace in bounded JSON responses", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify(completedResponsePayload()), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + + const response = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientBody).toContain('"namespace":"collaboration"'); + expect(clientBody).toContain('"encrypted_function_args":[]'); + }); + + test("rejects an unclassified successful response while restoration is required", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify(completedResponsePayload()), + { status: 200 }, + )) as typeof fetch; + + const response = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(response.status).toBe(502); + expect(clientBody).toContain("unsupported content type"); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientBody).not.toContain("start_delegated_task"); + }); + + test("restores aliases after SSE snapshot repair copies request tools and tool choice", async () => { + globalThis.fetch = (async () => { + const response = completedResponsePayload("resp-snapshot-sse"); + return new Response( + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response, + })}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + + const response = await handleResponses( + collaborationRequest({ + toolChoice: { type: "function", namespace: "collaboration", name: "spawn_agent" }, + }), + config(true, true), + { model: "", provider: "" }, + ); + const completedLine = (await response.text()).split("\n") + .find(line => line.includes('"response.completed"'))!; + const completed = JSON.parse(completedLine.replace(/^data: /, "")) as { + response: { + tool_choice: { namespace: string }; + tools: Array<{ name: string }>; + output: Array<{ namespace: string; encrypted_function_args: unknown[] }>; + }; + }; + + expect(completed.response.tool_choice.namespace).toBe("collaboration"); + expect(completed.response.tools[0]!.name).toBe("collaboration"); + expect(completed.response.output[0]!.namespace).toBe("collaboration"); + expect(completed.response.output[0]!.encrypted_function_args).toEqual([]); + }); + + test("restores aliases after bounded JSON snapshot repair", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify(completedResponsePayload("resp-snapshot-json")), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const response = await handleResponses( + collaborationRequest({ + toolChoice: { type: "function", namespace: "collaboration", name: "spawn_agent" }, + }), + config(true, true), + { model: "", provider: "" }, + ); + const completed = await response.json() as { + tool_choice: { namespace: string }; + tools: Array<{ name: string }>; + output: Array<{ namespace: string; encrypted_function_args: unknown[] }>; + }; + + expect(completed.tool_choice.namespace).toBe("collaboration"); + expect(completed.tools[0]!.name).toBe("collaboration"); + expect(completed.output[0]!.namespace).toBe("collaboration"); + expect(completed.output[0]!.encrypted_function_args).toEqual([]); + }); + + test("keeps the marker and reserved namespace when the option is disabled", async () => { + const sentBodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + await handleResponses(collaborationRequest(), config(false), { model: "", provider: "" }); + const sentBody = JSON.parse(sentBodies[0]!) as { + tools: Array<{ name: string; tools: Array<{ parameters: { properties: { message: Record } } }> }>; + }; + + expect(sentBodies).toHaveLength(1); + expect(sentBody.tools[0]!.name).toBe("collaboration"); + expect(sentBody.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBe(true); + }); + + test("keeps the whole request unchanged when tool-search history conflicts with the alias", async () => { + const sentBodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + await handleResponses(collaborationRequest({ + input: [{ + type: "tool_search_output", + tools: [{ + type: "namespace", + name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + tools: [], + }], + }], + }), config(true), { model: "", provider: "" }); + + const sent = JSON.parse(sentBodies[0]!) as { + tools: Array<{ + name: string; + tools: Array<{ parameters: { properties: { message: Record } } }>; + }>; + }; + expect(sent.tools[0]!.name).toBe("collaboration"); + expect(sent.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBe(true); + }); + + test("rebuilds the plaintext alias after a canonical pool quota retry", async () => { + await withPoolHome(async () => { + const poolConfig = { + defaultProvider: "openai", + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: CODEX_FORWARD_BASE_URL, + authMode: "forward", + codexAccountMode: "pool", + }, + }, + codexAccounts: ["pool-a", "pool-b"].map(id => ({ + id, + email: `${id}@example.test`, + isMain: false, + chatgptAccountId: `${id}_chatgpt`, + })), + plaintextV2AgentMessages: true, + } as OcxConfig; + for (const [index, id] of ["pool-a", "pool-b"].entries()) { + saveCodexAccountCredential(id, { + accessToken: `${id}-access-token`, + refreshToken: `${id}-refresh-token`, + expiresAt: Date.now() + 300_000, + chatgptAccountId: `${id}_chatgpt`, + }); + updateAccountQuota(id, 10 + index * 10); + } + + const sentBodies: string[] = []; + const entitlementSnapshot = { + modelsByAccount: new Map([ + ["pool-a", new Set(["gpt-5.6-sol"])], + ["pool-b", new Set(["gpt-5.6-sol"])], + ]), + confirmedAccountIds: new Set(["pool-a", "pool-b"]), + credentialIdentities: new Map(), + }; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + if (sentBodies.length === 1) { + return Response.json({ error: { message: "rate limited" } }, { + status: 429, + headers: { "retry-after": "1" }, + }); + } + return Response.json(completedResponsePayload("resp-pool-retry")); + }) as typeof fetch; + + const response = await handleResponses( + collaborationRequest({ model: "gpt-5.6-sol" }), + poolConfig, + { model: "", provider: "" }, + { resolveCodexModelEntitlements: async () => entitlementSnapshot }, + ); + const clientBody = await response.text(); + + expect(sentBodies).toHaveLength(2); + for (const body of sentBodies) { + const sent = JSON.parse(body) as { tools: Array<{ name: string }> }; + expect(sent.tools[0]!.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + } + expect(clientBody).toContain('"namespace":"collaboration"'); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + }); + }); + + test("fails closed for over-limit streamed responses in both relay modes", async () => { + for (const streamMode of ["legacy-tee", "eager-relay"] as const) { + globalThis.fetch = (async () => new Response( + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: overLimitResponsePayload(`resp-${streamMode}`), + })}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + )) as typeof fetch; + + const response = await handleResponses( + collaborationRequest(), + config(true, false, streamMode), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(response.status).toBe(200); + expect(clientBody).toContain('"type":"response.failed"'); + expect(clientBody).toContain(PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); + expect(clientBody).toContain("data: [DONE]"); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientBody).not.toContain("start_delegated_task"); + } + }); + + test("rejects over-limit bounded JSON before HTTP or WebSocket reframing", async () => { + const fixtureId = "plaintext-v2-bounded-json-fixture"; + const fixtureModel = "fixture-model"; + const mutableRegistry = PROVIDER_REGISTRY as unknown as Array>; + mutableRegistry.push({ + id: fixtureId, + label: "Plaintext V2 bounded JSON fixture", + baseUrl: CODEX_FORWARD_BASE_URL, + adapter: "openai-responses", + authKind: "forward", + models: [fixtureModel], + defaultModel: fixtureModel, + modelResponsesUpstreamStreaming: { [fixtureModel]: false }, + }); + const fixtureConfig = { + defaultProvider: fixtureId, + providers: { + [fixtureId]: { + adapter: "openai-responses", + baseUrl: CODEX_FORWARD_BASE_URL, + authMode: "forward", + }, + }, + plaintextV2AgentMessages: true, + } as OcxConfig; + + try { + for (const inboundTransport of [undefined, "websocket"] as const) { + globalThis.fetch = (async () => Response.json(overLimitResponsePayload())) as typeof fetch; + const response = await handleResponses( + collaborationRequest({ model: `${fixtureId}/${fixtureModel}` }), + fixtureConfig, + { model: "", provider: "" }, + inboundTransport + ? { inboundWire: "responses", inboundTransport } + : undefined, + ); + const clientBody = await response.text(); + + expect(response.status).toBe(502); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(clientBody).toContain(PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientBody).not.toContain("start_delegated_task"); + expect(clientBody).not.toContain("data: [DONE]"); + } + } finally { + const index = mutableRegistry.findIndex(entry => entry.id === fixtureId); + if (index >= 0) mutableRegistry.splice(index, 1); + } + }); + + test("rejects an over-limit JSON response and does not retain it for continuation", async () => { + const sentBodies: string[] = []; + let requestIndex = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + requestIndex += 1; + const payload = requestIndex === 1 + ? overLimitResponsePayload() + : { id: "resp-after-overflow", status: "completed", output: [] }; + return Response.json(payload); + }) as typeof fetch; + + const first = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + const firstBody = await first.text(); + expect(first.status).toBe(502); + expect(firstBody).toContain(PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); + expect(firstBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(firstBody).not.toContain("start_delegated_task"); + + const second = await handleResponses( + collaborationRequest({ + previousResponseId: "resp-plaintext-v2-overflow", + input: [{ + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }], + }), + config(false), + { model: "", provider: "" }, + ); + const secondBody = await second.text(); + expect({ status: second.status, body: secondBody, sends: sentBodies.length }).toEqual({ + status: 400, + body: expect.stringContaining("continuation state is unavailable or expired"), + sends: 1, + }); + }); + + test("stores the client namespace so disabling the option cannot replay the private alias", async () => { + const sentBodies: string[] = []; + let requestIndex = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + requestIndex += 1; + const payload = requestIndex === 1 + ? completedResponsePayload("resp-toggle-plaintext-v2") + : { id: "resp-after-toggle", status: "completed", output: [] }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const first = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + await first.text(); + const second = await handleResponses( + collaborationRequest({ + previousResponseId: "resp-toggle-plaintext-v2", + input: [{ type: "function_call_output", call_id: "call-spawn", output: "done" }], + }), + config(false), + { model: "", provider: "" }, + ); + await second.text(); + + const replay = JSON.parse(sentBodies[1]!) as { input: Array> }; + const replayedCall = replay.input.find(item => item.type === "function_call"); + expect(replayedCall?.namespace).toBe("collaboration"); + expect(JSON.stringify(replay)).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + }); +}); + + +test("plaintext startup warning requires explicit opt-in and names retention", () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.join(" ")); }; + try { + warnPlaintextV2AgentMessagesStartup({}); + warnPlaintextV2AgentMessagesStartup({ plaintextV2AgentMessages: false }); + expect(warnings).toEqual([]); + warnPlaintextV2AgentMessagesStartup({ plaintextV2AgentMessages: true }); + expect(warnings.join(" ")).toContain("Codex history"); + expect(warnings.join(" ")).toContain("HTTPS"); + expect(warnings.join(" ")).toContain("local response/debug state"); + } finally { console.warn = original; } +}); + +for (const streamMode of ["legacy-tee", "eager-relay"] as const) { + for (const refusal of ["malformed", "unknown-alias", "conflicting-binding"] as const) { + test(`${streamMode} ${refusal} is refused without caching or retry`, async () => { + let sends = 0; + const sent: string[] = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + sends += 1; + sent.push(String(init?.body)); + if (sends > 1) return new Response(JSON.stringify({ id: "after-refusal", status: "completed", output: [] }), { headers: { "content-type": "application/json" } }); + const completed = completedResponsePayload("refused-plaintext"); + const first = { type: "response.output_item.added", output_index: 0, item: completed.output[0] }; + const invalid = refusal === "malformed" ? "{malformed" + : JSON.stringify({ type: "response.output_item.done", output_index: 0, item: { + ...completed.output[0], name: refusal === "unknown-alias" ? "unknown_private" : "deliver_delegated_message", + } }); + return new Response(`data: ${JSON.stringify(first)}\n\ndata: ${invalid}\n\ndata: ${JSON.stringify({ type: "response.completed", response: completed })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const response = await handleResponses(collaborationRequest(), config(true, false, streamMode), { model: "", provider: "" }); + const text = await response.text(); + expect(text).toContain("response.failed"); + expect(text).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(text).not.toContain("start_delegated_task"); + expect(sends).toBe(1); + const next = await handleResponses(collaborationRequest({ previousResponseId: "refused-plaintext", input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "next" }] }] }), config(false), { model: "", provider: "" }); + await next.text(); + expect(sent[1]).not.toContain("plain assignment"); + expect(sends).toBe(2); + }); + } +} + +test("malformed bounded JSON is a single-attempt 502", async () => { + let sends = 0; + globalThis.fetch = (async () => { sends += 1; return new Response("{malformed", { headers: { "content-type": "application/json" } }); }) as typeof fetch; + const response = await handleResponses(collaborationRequest(), config(true), { model: "", provider: "" }); + expect(response.status).toBe(502); + expect(await response.text()).not.toContain("{malformed"); + expect(sends).toBe(1); +}); From 9cab1aebc18d5c516f8d5dfccbbf7d8aebdcdcc2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:48:55 +0900 Subject: [PATCH 004/224] fix(start): report auxiliary listener failures without public-port retries --- .../src/content/docs/guides/remote-hub.md | 2 + scripts/test-layout/layout.json | 1 + src/cli/index.ts | 4 +- src/config.ts | 24 ++++++++ src/server/index.ts | 5 +- src/server/ports.ts | 17 ++++++ structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/openai-tiers.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-start-auxiliary-bind.test.ts | 58 +++++++++++++++++++ tests/config/config-load-degrade.test.ts | 47 ++++++++++++++- tests/fixtures/test-layout-expected.json | 1 + .../loopback-listener-integration.test.ts | 21 +++++-- tests/server/ports.test.ts | 13 ++++- 28 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 tests/cli/cli-start-auxiliary-bind.test.ts diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..9a7dd93195 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -722,3 +722,5 @@ For a service rollback, stop the branch service and repair the prior release aga session, not a client data key. - **Outstanding revocation after disconnect:** use the hub dashboard's **Integrations → API Keys** page. It is the sole post-disconnect revocation path. + +If an auxiliary listener cannot bind, startup names `unauthenticatedLoopbackListener` or `hub.managementIngress` and the actual address. Correct that listener or free its address; changing only the public proxy port does not repair a fixed auxiliary port. Malformed hand-edited listener blocks warn and remain disabled while unrelated settings are preserved. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ecbbf5862a..9b229150ee 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -357,6 +357,7 @@ "cli-registry.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", + "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", diff --git a/src/cli/index.ts b/src/cli/index.ts index 97a80f32c8..93c963833b 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -63,7 +63,7 @@ import { import { requestBoundSystemRestart } from "./system-restart-client"; import { installCrashGuards } from "../lib/crash-guard"; import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch"; -import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; +import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; import { isApiAuthRequired } from "../server/auth-cors"; @@ -391,7 +391,7 @@ async function handleStart(options: { block?: boolean } = {}) { scheduleCatalogPrewarm(); break; } catch (err) { - if (!isAddrInUse(err) || attempt >= 2) throw err; + if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; if (requestedPort !== undefined) { console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); const hostname = loadConfig().hostname ?? "127.0.0.1"; diff --git a/src/config.ts b/src/config.ts index 5e81a7e5f1..8209ff794f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1959,6 +1959,26 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void { } } +function degradedListenerWarnings(rawParsed: unknown, validated: OcxConfig): string[] { + const raw = rawConfigRecord(rawParsed); + if (!raw) return []; + const warnings: string[] = []; + if (raw.unauthenticatedLoopbackListener !== undefined && validated.unauthenticatedLoopbackListener === undefined) { + warnings.push("unauthenticatedLoopbackListener ignored: invalid listener configuration; repair config.json before enabling the listener"); + } + const hub = rawConfigRecord(raw.hub); + if (hub?.managementIngress !== undefined && validated.hub?.managementIngress === undefined) { + warnings.push("hub.managementIngress ignored: invalid management listener configuration; repair config.json before enabling the listener"); + } + return warnings; +} + +function warnDegradedListeners(rawParsed: unknown, validated: OcxConfig): void { + for (const warning of degradedListenerWarnings(rawParsed, validated)) { + console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); + } +} + /** * Companion to {@link warnDegradedStreamMode} for a malformed selection-order map. * Priority is a preference, so the schema drops the whole map rather than failing @@ -2409,6 +2429,7 @@ export function loadConfig(): OcxConfig { warnInheritedFastWireConflicts(configPath, config); warnDegradedStreamMode(parsed, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2438,6 +2459,7 @@ export function loadConfig(): OcxConfig { const config = normalizeApiKeyIds(retryResult.data as OcxConfig); warnInheritedFastWireConflicts(configPath, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2463,6 +2485,7 @@ export function loadConfig(): OcxConfig { const config = normalizeApiKeyIds(salvaged.parsed); warnInheritedFastWireConflicts(configPath, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2597,6 +2620,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf const warnings = configPlaceholderWarnings(normalized); warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); + warnings.push(...degradedListenerWarnings(rawParsed, normalized)); const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning); if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..f499a624e4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,3 +1,4 @@ +import { AuxiliaryListenerBindError } from "./ports"; import { markActivity } from "../lib/sidecar-tracker"; import { knownModelIdsForProvider } from "../router"; import { @@ -2525,7 +2526,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { const server = createServer(); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index dd98190184..d35eee8aed 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -52,3 +52,5 @@ request when a node carries both. Codex's own deferred tool catalog emits exactl so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/catalog.md b/structure/catalog.md index 174e506470..120160f5b4 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -264,3 +264,5 @@ provider wire mapping; unpinned native requests retain their existing pass-throu Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..410efd42e0 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,5 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..41308d716b 100644 --- a/structure/config.md +++ b/structure/config.md @@ -55,6 +55,8 @@ matters for maintainers is which groups exist and who resolves them: Env values are resolved through `src/config.ts`, so a config value naming an env var never persists the secret itself. +Malformed optional data-loopback and nested hub-management listener blocks are disabled in memory and reported by load-time warnings and read-only config diagnostics. The warning names only the field; unrelated providers and keys survive. Explicit writes remain strictly validated. + ## Config injection `src/codex/inject.ts` writes one of two forms. The choice is not cosmetic: it decides whether Codex diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 270e6c38de..39253e4146 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -69,3 +69,5 @@ injects summary generation into a request, and config validation rejects a deliv conflicts with `modelSupportsReasoningSummaries: false` for the same model. > Decision record: [ADR-0045](../decisions/ADR-0045-standalone-images.md) + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7c90c2f74d..9ef1e9e823 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -89,3 +89,5 @@ copies an authoritative catalog context window into `limit.context` and a nonemp reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent instead of falling back to OpenCodex guesses, and the integration does not write the removed `thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..64c04782f9 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -511,3 +511,5 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..d5b43fade6 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index dd5f58e345..a80f9c613f 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -132,3 +132,5 @@ Binary detection decodes only the supplied buffer view; malformed UTF-8 can itse so the flag does not identify the peer responsible for corruption. Existing diagnostic files are not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain client responsibilities. + +Auxiliary listener startup failures report their own effective address and do not trigger public-port retries; the synchronous rollback contract is described in [Runtime](../runtime.md#lifecycle). diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..864e1b4cdb 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 9320132a04..b2e9e3b04d 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -389,3 +389,5 @@ model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index d9155185fc..c9a7c547d2 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -49,3 +49,5 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay - **Authentication:** `Authorization: Bearer ` + `X-XAI-Token-Auth: xai-grok-cli`. No cookies required. - **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes. - **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 — a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..c830511887 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -71,6 +71,8 @@ The hub-management socket is enabled only by `runtimeRole: "hub"` plus `hub.managementIngress.enabled`, always binds `127.0.0.1`, and default-denies everything except GUI, session bootstrap/exchange, and `/api/*`. +Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. + A failed optional bind initiates rollback of every earlier socket; normal stop joins all bound sockets before lifecycle release. The existing launchd/systemd installer remains the service owner and continues loading the data token from `service-api-token`; hub mode adds no service-manager diff --git a/structure/subagents.md b/structure/subagents.md index d9e6eaf1d2..4bd96713eb 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -198,3 +198,5 @@ Native Codex advertisements still follow display priority; private guidance rank Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..905e73ccf1 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,5 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..f82070117e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,5 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 42d3442e99..c1597db686 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -189,3 +189,5 @@ WebSocket clients observe the same canonical lifecycle. `ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket frame rather than always emitting `response.completed`. If the response status is `failed`, a `response.failed` frame is sent; otherwise `response.completed` carries through the original status. + +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). diff --git a/tests/cli/cli-start-auxiliary-bind.test.ts b/tests/cli/cli-start-auxiliary-bind.test.ts new file mode 100644 index 0000000000..99b8e62236 --- /dev/null +++ b/tests/cli/cli-start-auxiliary-bind.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findAvailablePort } from "../../src/server/ports"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { watchdogMs } from "../helpers/ci-watchdog"; + +const DEADLINE = watchdogMs(20_000); + +for (const listener of ["unauthenticatedLoopbackListener", "hub.managementIngress"] as const) { + for (const pinned of [false, true]) { + test(`${listener} failure never retries the public port (${pinned ? "pinned" : "soft"})`, async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-auxiliary-bind-")); + const home = join(root, "home"); + const ocxHome = join(root, "ocx"); + const codexHome = join(root, "codex"); + for (const path of [home, ocxHome, codexHome]) mkdirSync(path); + const occupied = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("occupied") }); + const auxiliaryPort = occupied.port!; + const publicPort = await findAvailablePort(0, "127.0.0.1"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + port: publicPort, hostname: "127.0.0.1", providers: {}, defaultProvider: "openai", + codexAutoStart: false, syncResumeHistory: false, + clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, + claudeCode: { systemEnv: false }, + ...(listener === "hub.managementIngress" + ? { runtimeRole: "hub", hub: { managementIngress: { enabled: true, port: auxiliaryPort } } } + : { unauthenticatedLoopbackListener: { enabled: true, port: auxiliaryPort } }), + })); + const child = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", ...(pinned ? ["--port", String(publicPort)] : [])], { + cwd: root, + env: { HOME: home, USERPROFILE: home, OPENCODEX_HOME: ocxHome, CODEX_HOME: codexHome, + PATH: process.env.PATH ?? "", NO_PROXY: "127.0.0.1,localhost" }, + stdout: "pipe", stderr: "pipe", + }); + const deadline = setTimeout(() => child.kill(), DEADLINE); + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + const output = stdout + stderr; + expect(code).not.toBe(0); + expect(output).toContain(`${listener} at 127.0.0.1:${auxiliaryPort}`); + expect(output).not.toContain("picking another"); + expect(output).not.toContain("waiting to retry the same port"); + const rebound = Bun.serve({ port: publicPort, hostname: "127.0.0.1", fetch: () => new Response("free") }); + await rebound.stop(true); + } finally { + clearTimeout(deadline); + if (child.exitCode === null) { child.kill(); await child.exited; } + await occupied.stop(true); + removeTreeWithRetry(root); + } + }, DEADLINE + 10_000); + } +} diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 4d8cca68f7..df0e333c46 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, getDefaultConfig, loadConfig, + readConfigDiagnostics, saveConfig, validateConfigCandidate, } from "../../src/config"; @@ -139,3 +140,47 @@ test("Fast rows default on for fresh and omitted config; explicit false and malf expect(loaded.providers.xai.note).toBe("keep me"); } }); + + +test.each([ + { enabled: "true" }, + { enabled: true, port: 70000 }, + "secret-shaped-malformed-listener-value", +])("malformed optional listeners warn without discarding unrelated settings: %j", listener => { + const config = { ...candidate(undefined), + apiKeys: [{ id: "preserved", name: "preserved", key: "fixture-key", createdAt: "2026-01-01" }], + unauthenticatedLoopbackListener: listener, + hub: { managementIngress: listener }, + }; + const bytes = JSON.stringify(config); + writeFileSync(getConfigPath(), bytes); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const loaded = loadConfig(); + expect(loaded.providers.xai.note).toBe("keep me"); + expect(loaded.apiKeys?.[0]?.id).toBe("preserved"); + expect(loaded.unauthenticatedLoopbackListener).toBeUndefined(); + expect(loaded.hub?.managementIngress).toBeUndefined(); + const messages = warn.mock.calls.flat().join("\n"); + expect(messages).toContain("unauthenticatedLoopbackListener ignored"); + expect(messages).toContain("hub.managementIngress ignored"); + expect(messages).not.toContain("secret-shaped-malformed-listener-value"); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.warnings?.join("\n")).toContain("unauthenticatedLoopbackListener ignored"); + expect(diagnostics.warnings?.join("\n")).toContain("hub.managementIngress ignored"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { warn.mockRestore(); } +}); + +test.each([undefined, { enabled: false }])("absent or disabled listeners do not produce degradation warnings: %j", listener => { + writeFileSync(getConfigPath(), JSON.stringify({ ...candidate(undefined), + unauthenticatedLoopbackListener: listener, hub: { managementIngress: listener }, + })); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + loadConfig(); + const messages = warn.mock.calls.flat().join("\n"); + expect(messages).not.toContain("Listener ignored"); + expect(messages).not.toContain("managementIngress ignored"); + } finally { warn.mockRestore(); } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b92e85757c..95b30cde0d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -192,6 +192,7 @@ "cli-registry.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", + "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 3b616d822e..3664b5f439 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -17,6 +17,7 @@ import { saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { runListenerShutdown } from "../../src/server/lifecycle"; import { + AuxiliaryListenerBindError, findAvailablePort, PortUnavailableError, setEphemeralPortAllocatorForTests, @@ -182,17 +183,21 @@ describe("hub management ingress", () => { }); test("a failed management bind rolls back both earlier listeners", async () => { - const managementPort = await freePort(); - const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); const squatter = Bun.serve({ - port: managementPort, + port: 0, hostname: "127.0.0.1", fetch: () => new Response("occupied"), }); + const managementPort = squatter.port!; + const loopbackPort = await freePort(); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); saveConfig(hubIngressConfig(managementPort, loopbackPort)); try { - expect(() => startServer(publicPort)).toThrow(); + let failure: unknown; + try { startServer(publicPort); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(AuxiliaryListenerBindError); + expect(failure).toMatchObject({ listener: "hub.managementIngress", port: managementPort, hostname: "127.0.0.1" }); + expect((failure as Error).cause).toBeDefined(); for (const port of [publicPort, loopbackPort]) { const rebound = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("ok") }); await rebound.stop(true); @@ -759,7 +764,11 @@ describe("unauthenticated loopback listener", () => { }); saveConfig(baseConfig(loopbackPort)); try { - expect(() => startServer(publicPort)).toThrow(); + let failure: unknown; + try { startServer(publicPort); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(AuxiliaryListenerBindError); + expect(failure).toMatchObject({ listener: "unauthenticatedLoopbackListener", port: loopbackPort, hostname: "127.0.0.1" }); + expect((failure as Error).cause).toBeDefined(); const rebound = Bun.serve({ port: publicPort, diff --git a/tests/server/ports.test.ts b/tests/server/ports.test.ts index 6471244c56..f0acfa250f 100644 --- a/tests/server/ports.test.ts +++ b/tests/server/ports.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer, type Server } from "node:net"; import { pathToFileURL } from "node:url"; -import { findAvailablePort, isAddrInUse, isPortAvailable, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../../src/server/ports"; +import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, isPortAvailable, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../../src/server/ports"; import { repoPath, repoRoot } from "../helpers/repo-root"; // Prototype overrides exist only inside the disposable child process. @@ -233,3 +233,14 @@ describe("port selection", () => { expect(await isPortAvailable(54321, "192.0.2.1")).toBe(false); }); }); + + +test("auxiliary bind diagnostics preserve non-conflict causes without claiming a busy port", () => { + const cause = Object.assign(new Error("permission denied"), { code: "EACCES" }); + const failure = new AuxiliaryListenerBindError("hub.managementIngress", 12345, "127.0.0.1", cause); + expect(failure.cause).toBe(cause); + expect(failure.message).toContain("hub.managementIngress at 127.0.0.1:12345"); + expect(failure.message).not.toContain("busy"); + expect(isAddrInUse(failure)).toBe(false); + expect(isAddrInUse({ code: "EADDRINUSE" })).toBe(true); +}); From 9cb5339e6c368bc2e758c4c9096ed804cf557e20 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:49:28 +0900 Subject: [PATCH 005/224] fix(responses): retry exact Console upload rejections once Co-authored-by: Eran Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 8 + gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Logs.tsx | 4 +- scripts/test-layout/layout.json | 1 + src/providers/opencode-zen-rate-limit.ts | 57 +++++ src/server/responses/core.ts | 106 ++++++++- src/usage/log.ts | 2 + structure/transports/responses.md | 4 + tests/fixtures/test-layout-expected.json | 1 + .../providers/opencode-zen-rate-limit.test.ts | 72 ++++++ .../responses-console-go-upload-retry.test.ts | 210 ++++++++++++++++++ 19 files changed, 468 insertions(+), 6 deletions(-) create mode 100644 tests/responses/responses-console-go-upload-retry.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index ed856803ad..a91d05e0d2 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,14 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Console upload rejections + +An exact Console or Console Go `Invalid upload request.` HTTP 400 from a canonical +OpenCode Zen/Go generation endpoint receives one retry after 800 ms. The proxy reuses +the same serialized request and records the recovery in Logs. Other 400 errors, +custom destinations, cancellations and repeated upload rejections remain failures. +This does not retry filtered model responses or interrupted streams. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fdb18eb6ed..d92f72d2d3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -796,6 +796,7 @@ export const de: Record = { "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth ratenbegrenzt (429)", "logs.detail.attempt.recovery.image413": "Bildnutzlast zu groß (413)", "logs.detail.attempt.recovery.emptyCompletion": "Wiederholung nach leerer Antwort", + "logs.detail.attempt.recovery.consoleGoUpload": "Console-Upload erneut versucht", "logs.detail.attempt.recovery.unknown": "Unbekannter Wiederherstellungsgrund", "logs.detail.reason.usage_missing": "Nutzung wurde nicht gemeldet.", "logs.detail.reason.usage_unsupported": "Dieser Anbieter meldet keine Nutzung.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1847a7af7e..6a9b0eac21 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -845,6 +845,7 @@ export const en = { "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth rate-limited (429)", "logs.detail.attempt.recovery.image413": "Image payload too large (413)", "logs.detail.attempt.recovery.emptyCompletion": "Empty completion retry", + "logs.detail.attempt.recovery.consoleGoUpload": "Console upload retry", "logs.detail.attempt.recovery.unknown": "Unknown recovery reason", "logs.detail.reason.usage_missing": "Usage was not reported.", "logs.detail.reason.usage_unsupported": "This provider does not report usage.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e465adbb10..f2a2e6ed71 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -821,6 +821,7 @@ export const fr: Record = { "logs.detail.attempt.recovery.transient5xx": "Erreur 5xx temporaire", "logs.detail.attempt.recovery.connectionReset": "Réinitialisation de la connexion", "logs.detail.attempt.recovery.emptyCompletion": "Nouvelle tentative après une réponse vide", + "logs.detail.attempt.recovery.consoleGoUpload": "Nouvelle tentative d’envoi Console", "logs.detail.attempt.recovery.oauth401": "Réauthentification OAuth", "logs.detail.attempt.recovery.key429": "Clé soumise à une limitation de débit (429)", "logs.detail.attempt.recovery.rateLimit429": "Limitation de débit (429)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9a3d9f58b..14e82062c6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -758,6 +758,7 @@ export const ja: Record = { "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth レート制限 (429)", "logs.detail.attempt.recovery.image413": "画像ペイロードが大きすぎます (413)", "logs.detail.attempt.recovery.emptyCompletion": "空の完了を再試行", + "logs.detail.attempt.recovery.consoleGoUpload": "Console アップロード再試行", "logs.detail.attempt.recovery.unknown": "不明なリカバリ理由", "logs.detail.reason.usage_missing": "使用量が報告されませんでした。", "logs.detail.reason.usage_unsupported": "このプロバイダーは使用量を報告しません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67ee251970..070ce4881f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -827,6 +827,7 @@ export const ko: Record = { "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth 요청 한도 초과 (429)", "logs.detail.attempt.recovery.image413": "이미지 페이로드가 너무 큼 (413)", "logs.detail.attempt.recovery.emptyCompletion": "빈 응답 재시도", + "logs.detail.attempt.recovery.consoleGoUpload": "Console 업로드 재시도", "logs.detail.attempt.recovery.unknown": "알 수 없는 복구 사유", "logs.detail.reason.usage_missing": "usage가 보고되지 않았습니다.", "logs.detail.reason.usage_unsupported": "이 프로바이더는 usage 보고를 지원하지 않습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56d43fc301..7a7f319edd 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -813,6 +813,7 @@ export const ru: Record = { "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth ограничен (429)", "logs.detail.attempt.recovery.image413": "Слишком большой размер изображения (413)", "logs.detail.attempt.recovery.emptyCompletion": "Повтор пустого завершения", + "logs.detail.attempt.recovery.consoleGoUpload": "Повтор загрузки Console", "logs.detail.attempt.recovery.unknown": "Неизвестная причина восстановления", "logs.detail.reason.usage_missing": "Данные об использовании не были сообщены.", "logs.detail.reason.usage_unsupported": "Этот провайдер не сообщает данные об использовании.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b627813bb9..fc976f6d29 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -832,6 +832,7 @@ export const tr: Record = { "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth kısıtlandı (429)", "logs.detail.attempt.recovery.image413": "Görsel boyutu çok büyük (413)", "logs.detail.attempt.recovery.emptyCompletion": "Boş tamamlama yeniden denemesi", + "logs.detail.attempt.recovery.consoleGoUpload": "Console yüklemesi yeniden denendi", "logs.detail.attempt.recovery.unknown": "Bilinmeyen kurtarma nedeni", "logs.detail.reason.usage_missing": "Kullanım bildirilmedi.", "logs.detail.reason.usage_unsupported": "Bu sağlayıcı kullanım bildirmeyebilir.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ce06556fd4..dae33c6635 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2210,6 +2210,7 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth 被限流 (429)", "logs.detail.attempt.recovery.image413": "圖片承載過大 (413)", "logs.detail.attempt.recovery.emptyCompletion": "空白完成重試", + "logs.detail.attempt.recovery.consoleGoUpload": "Console 上傳重試", "logs.detail.attempt.recovery.unknown": "未知的復原原因", "logs.detail.estimate.provider_cost_overlay": "已使用供應商設定的價格覆蓋。", "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 Priority 價格;目前顯示的估算是已知下限。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ae2fdfec92..46ee7b4fde 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -808,6 +808,7 @@ export const zh: Record = { "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth 被限流 (429)", "logs.detail.attempt.recovery.image413": "图片载荷过大 (413)", "logs.detail.attempt.recovery.emptyCompletion": "空完成重试", + "logs.detail.attempt.recovery.consoleGoUpload": "Console 上传重试", "logs.detail.attempt.recovery.unknown": "未知的恢复原因", "logs.detail.reason.usage_missing": "未上报 usage。", "logs.detail.reason.usage_unsupported": "该提供方不支持上报 usage。", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 774efc455a..d7fc3ab5c4 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -113,7 +113,8 @@ type AttemptRecoveryKind = | "rate-limit-429" | "anthropic-oauth-429" | "image-413" - | "empty-completion"; + | "empty-completion" + | "console-go-upload-retry"; interface LogAttempt { ordinal: number; @@ -307,6 +308,7 @@ const RECOVERY_KIND_KEYS = { "anthropic-oauth-429": "logs.detail.attempt.recovery.anthropicOauth429", "image-413": "logs.detail.attempt.recovery.image413", "empty-completion": "logs.detail.attempt.recovery.emptyCompletion", + "console-go-upload-retry": "logs.detail.attempt.recovery.consoleGoUpload", } as const satisfies Record; /** Map a metric-unavailable reason to its i18n key. */ diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ecbbf5862a..c3cb051eb8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1075,6 +1075,7 @@ "responses-account-label.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", "responses-compaction.test.ts": "responses", + "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", diff --git a/src/providers/opencode-zen-rate-limit.ts b/src/providers/opencode-zen-rate-limit.ts index c4dbb10319..21c248275c 100644 --- a/src/providers/opencode-zen-rate-limit.ts +++ b/src/providers/opencode-zen-rate-limit.ts @@ -175,3 +175,60 @@ export function enrichOpenCodeZenUpstreamMessage( ): string { return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts); } + +/** The effective HTTP endpoint, never the configured row name, identifies Console. */ +export function isConsoleGoDestination(outboundUrl: string | undefined): boolean { + if (!outboundUrl) return false; + try { + const url = new URL(outboundUrl); + return url.protocol === "https:" && url.hostname === "opencode.ai" + && url.port === "" && url.username === "" && url.password === "" + && /^\/zen\/(?:go\/)?v1\/(?:responses|chat\/completions|messages)$/.test(url.pathname); + } catch { + return false; + } +} + +/** + * The canonical refusal envelope, as served on both Console routes: + * {"error":{"param":null,"type":"invalid_request_error","message":"Error from provider + * (Console Go): Upstream request failed: [invalid_request_error] Invalid upload request."}} + * + * The gateway names itself Console on the Zen key route and Console Go on the Go route, so the + * anchor is the shared product name plus the refusal sentence. The message is matched whole: a + * bare string, a suffix, or any other envelope is a different refusal and must not be replayed. + * Being stricter than necessary is the safe direction: a missed match leaves the turn failing + * exactly as it does today, while a loose match spends an extra request on unrelated 400s. + */ +const CONSOLE_UPLOAD_REFUSALS = new Set([ + "Error from provider (Console Go): Upstream request failed: [invalid_request_error] Invalid upload request.", + "Error from provider (Console): Upstream request failed: [invalid_request_error] Invalid upload request.", +]); + +/** + * True only for the canonical Console upload refusal on a canonical Console destination. + * Route-gated on purpose: the message alone would let any other upstream that happens to answer + * with this English sentence trigger a second send from an unrelated provider. + */ +export function isTransientConsoleGoUploadRejection(opts: { + status: number; + errorBody: string | undefined; + outboundUrl?: string; +}): boolean { + if (opts.status !== 400 || !opts.errorBody) return false; + if (!isConsoleGoDestination(opts.outboundUrl)) return false; + let payload: unknown; + try { + payload = JSON.parse(opts.errorBody); + } catch { + return false; + } + const error = (payload as { error?: unknown } | null)?.error; + if (!error || typeof error !== "object" || Array.isArray(error)) return false; + // The whole envelope, not just the sentence: Console always answers this refusal as + // invalid_request_error with a null param, so a partial envelope is a different error. + const envelope = error as { param?: unknown; type?: unknown; message?: unknown }; + if (envelope.type !== "invalid_request_error" || envelope.param !== null) return false; + // Matched without trimming: padding means the gateway wrapped or appended something. + return typeof envelope.message === "string" && CONSOLE_UPLOAD_REFUSALS.has(envelope.message); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1f78570eae..8fc4e8066d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -102,7 +102,10 @@ import { } from "../../lib/errors"; import { injectionDebugLog } from "../../lib/injection-debug-log"; import { resolveClientRetryAfter } from "../../lib/retry-after"; -import { enrichOpenCodeZenUpstreamMessage } from "../../providers/opencode-zen-rate-limit"; +import { + enrichOpenCodeZenUpstreamMessage, + isTransientConsoleGoUploadRejection, +} from "../../providers/opencode-zen-rate-limit"; import { CODE_MODE_EXEC_TOOL_NAME, modelInList, namespacedToolName } from "../../types"; import type { AdapterEvent, @@ -213,6 +216,7 @@ import { fetchWithTransientRetry, isNonReplayableResponse, prepareSameTarget429Wait, + sleepWithAbort, } from "../../lib/upstream-retry"; import { ForwardAdmissionCredentialError, @@ -848,6 +852,31 @@ async function opaqueBlobRejectionBodyForRecovery( } } +/** + * Backoff for the single exact-request replay after a canonical Console upload rejection. + */ +const CONSOLE_GO_UPLOAD_RETRY_DELAY_MS = 800; + +/** + * Peek the upstream error body for the Console Go transient-400 recovery. Only a complete, + * display-safe body may drive a retry decision (same contract as + * opaqueBlobRejectionBodyForRecovery), and reading a clone leaves the original response intact + * for the caller's own error surface when no retry is taken. + */ +async function consoleGoUploadRejectionBody( + response: Response, + alreadyAttempted: boolean, + signal: AbortSignal, +): Promise { + if (isNonReplayableResponse(response) || response.status !== 400 || alreadyAttempted) return undefined; + try { + const body = await readBoundedResponseBody(response.clone(), { signal }); + return body.displaySafe && !body.truncated ? body.text : undefined; + } catch { + return undefined; + } +} + /** * Materialize an upstream error body only when the bounded reader observed a complete, * display-safe payload. Partial timeout and over-limit prefixes are attacker-controlled, @@ -5231,6 +5260,9 @@ async function handleResponsesInner( const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; let oauth401ReplayAttempted = false; let codex401ReplayKind: "main" | "stored" | null = null; + // Console Go answers a transient 400 "Invalid upload request." for bodies it accepts + // moments later; at most one byte-identical replay is allowed per request. + const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false }; const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider); let rateLimitRetries = 0; const rebuildAndRefetch = async ( @@ -5245,10 +5277,12 @@ async function handleResponsesInner( return { failed: formatErrorResponse(502, "upstream_error", "Recovery changed the provider wire unexpectedly") }; } try { - request = await retryAdapter.buildRequest(parsed, { - headers: selectedForwardHeaders, - translatorBudget, - }); + if (recovery !== "console-go-upload-retry") { + request = await retryAdapter.buildRequest(parsed, { + headers: selectedForwardHeaders, + translatorBudget, + }); + } refreshRoutedNamespaceToolAliases(request); recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); @@ -5778,6 +5812,36 @@ async function handleResponsesInner( logCtx.terminalIncompleteReason = preflightLog.terminalIncompleteReason; } } + // Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds + // later with 400 invalid_request_error / "Invalid upload request." Replay the byte-identical + // request once after the exact gateway rejection. Single-shot guard. + // This recovery reuses the captured request; other recovery kinds still rebuild. + if (!consoleGoUploadRetryGuard.attempted) { + const uploadRejectionBody = await consoleGoUploadRejectionBody( + upstreamResponse, + consoleGoUploadRetryGuard.attempted, + upstream.signal, + ); + if (uploadRejectionBody !== undefined + && isTransientConsoleGoUploadRejection({ + status: upstreamResponse.status, + errorBody: uploadRejectionBody, + outboundUrl: request.url, + })) { + consoleGoUploadRetryGuard.attempted = true; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + if (!upstream.signal.aborted) { + try { + await sleepWithAbort(CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, upstream.signal); + } catch { return clientCancelledResponse(); } + } + if (upstream.signal.aborted) return clientCancelledResponse(); + const result = await rebuildAndRefetch("console-go-upload-retry"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue passthroughRecovery; + } + } break; } const headers = sanitizePassthroughHeaders(upstreamResponse.headers); @@ -7267,6 +7331,9 @@ async function handleResponsesInner( // 413→429 rotation cannot silently undo the tightening. let imageRetryAttempted = false; const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; + // Console Go answers a transient 400 "Invalid upload request." for bodies it accepts + // moments later; at most one byte-identical replay is allowed per request. + const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false }; let oauth401ReplayAttempted = false; /** * Rebuild the request from the current parsed input (and any image-tier bias) and refetch @@ -7651,6 +7718,35 @@ async function handleResponsesInner( upstreamResponse = result; continue recovery; } + // Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds + // later with 400 invalid_request_error / "Invalid upload request." Replay the + // byte-identical request once after the exact gateway rejection. + if (!consoleGoUploadRetryGuard.attempted) { + const uploadRejectionBody = await consoleGoUploadRejectionBody( + upstreamResponse, + consoleGoUploadRetryGuard.attempted, + upstream.signal, + ); + if (uploadRejectionBody !== undefined + && isTransientConsoleGoUploadRejection({ + status: upstreamResponse.status, + errorBody: uploadRejectionBody, + outboundUrl: sameTargetRequest?.url, + })) { + consoleGoUploadRetryGuard.attempted = true; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + if (!upstream.signal.aborted) { + try { + await sleepWithAbort(CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, upstream.signal); + } catch { cleanupUpstreamAbort(); return clientCancelledResponse(); } + } + if (upstream.signal.aborted) { cleanupUpstreamAbort(); return clientCancelledResponse(); } + const result = await rebuildAndRefetch("console-go-upload-retry"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue recovery; + } + } break; } if (!upstreamResponse.ok) { diff --git a/src/usage/log.ts b/src/usage/log.ts index 2944c22f9a..953195b7ca 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -70,6 +70,7 @@ export type AttemptRecoveryKind = | "anthropic-oauth-429" | "oauth-account-429" | "image-413" + | "console-go-upload-retry" | "opaque-blob-rejection" | "empty-completion"; @@ -309,6 +310,7 @@ const ATTEMPT_RECOVERY_KINDS = new Set([ "anthropic-oauth-429", "oauth-account-429", "image-413", + "console-go-upload-retry", "opaque-blob-rejection", "empty-completion", ]); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..8a989a27fb 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -471,6 +471,10 @@ retried. Guarded paths: the ChatGPT passthrough and generic adapter fetch in fallback. Adapters with their own `fetchResponse` (kiro, cursor, google) keep their own retry policies; kiro imports the shared abort/sleep helpers from this module. +## Console upload rejection recovery + +`src/providers/opencode-zen-rate-limit.ts` recognizes the complete Console upload-rejection envelope only at the effective HTTPS opencode.ai Zen/Go generation endpoint. A provider row name cannot authorize another destination. The two recovery loops in `src/server/responses/core.ts` wait 800 ms and replay the captured serialized request once; cancellation, nonreplayable responses, other errors and a second upload rejection keep their failure semantics. The recovery kind is persisted as `console-go-upload-retry` and has a localized Logs label. + ## Same-provider combo quota fallback For a failover combo with multiple models on the same Codex-login OpenAI provider, a pre-stream diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b92e85757c..1307913a4c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -910,6 +910,7 @@ "responses-account-label.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", "responses-compaction.test.ts": "responses", + "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", diff --git a/tests/providers/opencode-zen-rate-limit.test.ts b/tests/providers/opencode-zen-rate-limit.test.ts index 8751082c2a..20a59bd61a 100644 --- a/tests/providers/opencode-zen-rate-limit.test.ts +++ b/tests/providers/opencode-zen-rate-limit.test.ts @@ -6,6 +6,7 @@ import { enrichOpenCodeZenFreeTierMessage, enrichOpenCodeZenRateLimitMessage, enrichOpenCodeZenUpstreamMessage, + isTransientConsoleGoUploadRejection, isOpenCodeZenFreeTierLockIn, isOpenCodeZenRateLimitProvider, } from "../../src/providers/opencode-zen-rate-limit"; @@ -230,3 +231,74 @@ describe("opencode-free keyless tier lock-in (#4121)", () => { expect(lockedOut).not.toContain(OPENCODE_ZEN_OBSERVED_RPM_HINT); }); }); + +describe("Console Go transient upload refusal", () => { + // The observed Go-route refusal, byte-for-byte as Console serves it. + const GO_MESSAGE = "Error from provider (Console Go): Upstream request failed: [invalid_request_error] Invalid upload request."; + // The Zen key route names the same gateway without the Go suffix. + const ZEN_MESSAGE = "Error from provider (Console): Upstream request failed: [invalid_request_error] Invalid upload request."; + const envelope = (message: string) => JSON.stringify({ model: "muse-spark-1.3-contributor", error: { param: null, type: "invalid_request_error", message } }); + const GO_ROUTE = { outboundUrl: "https://opencode.ai/zen/go/v1/responses" }; + const ZEN_ROUTE = { outboundUrl: "https://opencode.ai/zen/v1/responses" }; + + test("accepts the canonical refusal on both canonical Console routes", () => { + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), ...GO_ROUTE })).toBe(true); + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(ZEN_MESSAGE), ...ZEN_ROUTE })).toBe(true); + // A custom row pointed at the same destination is still Console: the base URL decides. + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), outboundUrl: "https://opencode.ai/zen/go/v1/responses" })).toBe(true); + }); + + test("rejects the refusal text from a non-Console route", () => { + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), outboundUrl: "https://api.deepseek.com/v1/responses" })).toBe(false); + // opencode.ai without the /zen segment is not the Console gateway. + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), outboundUrl: "https://opencode.ai/v1/responses" })).toBe(false); + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE) })).toBe(false); + }); + + test("requires the effective canonical HTTPS endpoint", () => { + for (const outboundUrl of [ + "http://opencode.ai/zen/go/v1/responses", + "https://opencode.ai:8443/zen/go/v1/responses", + "https://opencode.ai.evil.test/zen/go/v1/responses", + "https://opencode.ai/zen-other/v1/responses", + "https://opencode.ai/zen/go/v1/models", + "https://user:pass@opencode.ai/zen/go/v1/responses", + ]) expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), outboundUrl })).toBe(false); + }); + + test("rejects noncanonical envelopes, suffixes, and other statuses", () => { + // A bare string is not the structured envelope. + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: JSON.stringify({ error: "Invalid upload request." }), ...GO_ROUTE })).toBe(false); + // A suffix means the gateway said something else; do not guess. + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE + " Please retry."), ...GO_ROUTE })).toBe(false); + // Different status: only the gateway 400 is the flap. + expect(isTransientConsoleGoUploadRejection({ status: 500, errorBody: envelope(GO_MESSAGE), ...GO_ROUTE })).toBe(false); + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: undefined, ...GO_ROUTE })).toBe(false); + // Other 400s on the same wire are verdicts on the request, not flaps. + expect(isTransientConsoleGoUploadRejection({ + status: 400, + errorBody: envelope("Error from provider (Console Go): Upstream request failed: [invalid_request_error] reasoning_effort max requires an active Muse Code subscription for model muse-spark-1.3-contributor."), + ...GO_ROUTE, + })).toBe(false); + expect(isTransientConsoleGoUploadRejection({ + status: 400, + errorBody: JSON.stringify({ type: "error", error: { type: "MissingSessionID", message: "Request is missing x-opencode-session" } }), + ...GO_ROUTE, + })).toBe(false); + }); + + test("rejects partial envelopes and padded messages", () => { + const withError = (error: unknown) => JSON.stringify({ model: "muse-spark-1.3-contributor", error }); + // type carries the refusal identity; a partial envelope is a different error. + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: null, message: GO_MESSAGE }), ...GO_ROUTE })).toBe(false); + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: null, type: "server_error", message: GO_MESSAGE }), ...GO_ROUTE })).toBe(false); + // param must be present and null. + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ type: "invalid_request_error", message: GO_MESSAGE }), ...GO_ROUTE })).toBe(false); + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: "input", type: "invalid_request_error", message: GO_MESSAGE }), ...GO_ROUTE })).toBe(false); + // Padding means the gateway wrapped or appended something. + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: null, type: "invalid_request_error", message: " " + GO_MESSAGE }), ...GO_ROUTE })).toBe(false); + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: null, type: "invalid_request_error", message: GO_MESSAGE + "\n" }), ...GO_ROUTE })).toBe(false); + // The exact canonical envelope still matches. + expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), ...GO_ROUTE })).toBe(true); + }); +}); diff --git a/tests/responses/responses-console-go-upload-retry.test.ts b/tests/responses/responses-console-go-upload-retry.test.ts new file mode 100644 index 0000000000..e475e53139 --- /dev/null +++ b/tests/responses/responses-console-go-upload-retry.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleResponses } from "../../src/server/responses/core"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const originalOpenCodexHome = process.env.OPENCODEX_HOME; + +/** The exact Console Go rejection for a payload it accepts moments later. */ +const UPLOAD_REFUSAL = JSON.stringify({ + model: "muse-spark-1.3-contributor", + error: { + param: null, + type: "invalid_request_error", + message: "Error from provider (Console Go): Upstream request failed: [invalid_request_error] Invalid upload request.", + }, +}); + +/** A deterministic 400 on the same wire: a verdict on the request, never a flap. */ +const EFFORT_REFUSAL = JSON.stringify({ + model: "muse-spark-1.3-contributor", + error: { + param: "reasoning.effort", + type: "invalid_request_error", + message: "Error from provider (Console Go): Upstream request failed: [invalid_request_error] reasoning_effort max requires an active Muse Code subscription for model muse-spark-1.3-contributor.", + }, +}); + +let testDir = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-console-go-upload-retry-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + removeTreeWithRetry(testDir); +}); + +function config(): OcxConfig { + return { + defaultProvider: "go", + providers: { + go: { + adapter: "openai-responses", + baseUrl: "https://opencode.ai/zen/go/v1", + authMode: "key", + apiKey: "go-test-key", + }, + other: { + adapter: "openai-responses", + baseUrl: "https://other.example.test/v1", + authMode: "key", + apiKey: "other-test-key", + }, + }, + } as OcxConfig; +} + +function request(stream = false, provider = "go"): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "session_id": "thread-console-go-upload-retry", + }, + body: JSON.stringify({ + model: provider + "/muse-spark-1.3-contributor", + stream, + store: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ], + }), + }); +} + +function refusal(status = 400, body = UPLOAD_REFUSAL): Response { + return new Response(body, { status, headers: { "content-type": "application/json" } }); +} + +function success(id: string): Response { + return Response.json({ id, object: "response", status: "completed", model: "muse-spark-1.3-contributor", output: [] }); +} + +describe("Console Go transient upload refusal recovery", () => { + test("replays the refusal once and serves the retry with a byte-identical body", async () => { + const outbound: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(String(init?.body)); + return outbound.length === 1 ? refusal() : success("resp-upload-retry-recovered"); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(), config(), logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + // The replay must preserve the exact serialized request. + expect(outbound[1]).toBe(outbound[0]); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["console-go-upload-retry"]); + }); + + test("does not replay a different 400 from the same wire", async () => { + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return refusal(400, EFFORT_REFUSAL); + }) as typeof fetch; + + const response = await handleResponses(request(), config(), { model: "", provider: "" }); + + expect(response.status).toBe(400); + expect(sends).toBe(1); + }); + + test("keeps a repeated refusal visible after the single bounded replay", async () => { + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return refusal(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(), config(), logCtx); + + expect(response.status).toBe(400); + expect(sends).toBe(2); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["console-go-upload-retry"]); + }); + + test("does not replay the same refusal text from a non-Console provider", async () => { + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return refusal(); + }) as typeof fetch; + + const response = await handleResponses(request(false, "other"), config(), { model: "", provider: "" }); + + expect(response.status).toBe(400); + expect(sends).toBe(1); + }); +}); + + +describe("Console destination and translated recovery controls", () => { + test("a canonical row name cannot authorize another host", async () => { + const cfg = config(); + cfg.providers["opencode-go"] = { ...cfg.providers.go!, baseUrl: "https://other.example.test/v1" }; + let sends = 0; + globalThis.fetch = (async () => { sends++; return refusal(); }) as typeof fetch; + const response = await handleResponses(request(false, "opencode-go"), cfg, { model: "", provider: "" }); + expect(response.status).toBe(400); + expect(sends).toBe(1); + expect(await response.text()).toContain("Invalid upload request."); + }); + + for (const adapter of ["openai-responses", "openai-chat"] as const) { + for (const stream of [false, true]) { + test(`${adapter} stream=${stream} replays identical bytes once`, async () => { + const cfg = config(); + cfg.providers.go!.adapter = adapter; + const outbound: string[] = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + outbound.push(String(init?.body)); + if (outbound.length === 1) return refusal(); + if (adapter === "openai-responses") { + const completed = { id: "resp_fixture", object: "response", status: "completed", output: [] }; + return stream ? new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: completed })}\n\n`, { headers: { "content-type": "text/event-stream" } }) : Response.json(completed); + } + const chunk = { id: "chat_fixture", object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: "answer" }, finish_reason: "stop" }] }; + return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(request(stream), cfg, logCtx); + const body = await response.text(); + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(outbound[1]).toBe(outbound[0]); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["console-go-upload-retry"]); + expect(body).toContain("completed"); + }); + } + test(`${adapter} abort during backoff sends no replay`, async () => { + const cfg = config(); cfg.providers.go!.adapter = adapter; + const controller = new AbortController(); + let sends = 0; + globalThis.fetch = (async () => { sends++; return refusal(); }) as typeof fetch; + const originalTimeout = globalThis.setTimeout; + const spy = spyOn(globalThis, "setTimeout").mockImplementation(((handler: TimerHandler, ms?: number, ...args: unknown[]) => { + if (ms === 800) queueMicrotask(() => controller.abort()); + return originalTimeout(handler, ms, ...args); + }) as typeof setTimeout); + try { + const response = await handleResponses(request(), cfg, { model: "", provider: "" }, { abortSignal: controller.signal }); + expect(controller.signal.aborted).toBe(true); + expect(response.status).toBe(499); + expect(sends).toBe(1); + } finally { spy.mockRestore(); } + }); + } +}); From d1494b0acfdad0c4db8cd10e84d977fc9e13d928 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:50:09 +0900 Subject: [PATCH 006/224] test(responses): cover Console nonreplayable and buffered controls --- .../responses-console-go-upload-retry.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/responses/responses-console-go-upload-retry.test.ts b/tests/responses/responses-console-go-upload-retry.test.ts index e475e53139..4ece7bd0af 100644 --- a/tests/responses/responses-console-go-upload-retry.test.ts +++ b/tests/responses/responses-console-go-upload-retry.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { markResponseNonReplayable } from "../../src/lib/upstream-retry"; import { handleResponses } from "../../src/server/responses/core"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; @@ -176,6 +177,7 @@ describe("Console destination and translated recovery controls", () => { const completed = { id: "resp_fixture", object: "response", status: "completed", output: [] }; return stream ? new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: completed })}\n\n`, { headers: { "content-type": "text/event-stream" } }) : Response.json(completed); } + if (!stream) return Response.json({ id: "chat_fixture", object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: "answer" }, finish_reason: "stop" }] }); const chunk = { id: "chat_fixture", object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: "answer" }, finish_reason: "stop" }] }; return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); }) as typeof fetch; @@ -208,3 +210,23 @@ describe("Console destination and translated recovery controls", () => { }); } }); + + +describe("Console nonreplayable response boundary", () => { + for (const adapter of ["openai-responses", "openai-chat"] as const) { + test(`${adapter} does not replay a marked response`, async () => { + const cfg = config(); cfg.providers.go!.adapter = adapter; + let sends = 0; + globalThis.fetch = (async () => { + sends++; + const response = refusal(); + markResponseNonReplayable(response); + return response; + }) as typeof fetch; + const response = await handleResponses(request(), cfg, { model: "", provider: "" }); + expect(response.status).toBe(400); + expect(sends).toBe(1); + expect(await response.text()).toContain("Invalid upload request."); + }); + } +}); From c597591b709e9b7eebe23888da8b1051193bf549 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:50:13 +0900 Subject: [PATCH 007/224] fix(start): retain degraded-listener evidence in salvage diagnostics --- src/config.ts | 5 ++++- tests/cli/cli-start-auxiliary-bind.test.ts | 4 +++- tests/config/config-load-degrade.test.ts | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 8209ff794f..23c3e80c69 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3074,10 +3074,13 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { // that ignores the error and writes it back preserves what the operator configured. const salvaged = salvageConfigCandidate(merged, retryResult.error); if (salvaged) { + const config = normalizeApiKeyIds(salvaged.parsed); + const warnings = degradedListenerWarnings(parsed, config); return { - config: normalizeApiKeyIds(salvaged.parsed), + config, source: "fallback", error: schemaDiagnosticsError(result.error), + ...(warnings.length > 0 ? { warnings } : {}), }; } diff --git a/tests/cli/cli-start-auxiliary-bind.test.ts b/tests/cli/cli-start-auxiliary-bind.test.ts index 99b8e62236..7f37113014 100644 --- a/tests/cli/cli-start-auxiliary-bind.test.ts +++ b/tests/cli/cli-start-auxiliary-bind.test.ts @@ -35,12 +35,14 @@ for (const listener of ["unauthenticatedLoopbackListener", "hub.managementIngres PATH: process.env.PATH ?? "", NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", }); - const deadline = setTimeout(() => child.kill(), DEADLINE); + let timedOut = false; + const deadline = setTimeout(() => { timedOut = true; child.kill(); }, DEADLINE); try { const [code, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]); const output = stdout + stderr; + expect(timedOut, "CLI must exit on its own before the watchdog").toBe(false); expect(code).not.toBe(0); expect(output).toContain(`${listener} at 127.0.0.1:${auxiliaryPort}`); expect(output).not.toContain("picking another"); diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index df0e333c46..b05a270b20 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -184,3 +184,19 @@ test.each([undefined, { enabled: false }])("absent or disabled listeners do not expect(messages).not.toContain("managementIngress ignored"); } finally { warn.mockRestore(); } }); + +test("salvaged diagnostics retain listener warnings alongside the routing error", () => { + const bytes = JSON.stringify({ ...candidate(undefined), + routingProfiles: { bad: { candidates: [{ provider: "xai", model: "model" }] } }, + unauthenticatedLoopbackListener: { enabled: "true" }, + hub: { managementIngress: { enabled: true, port: 70000 } }, + }); + writeFileSync(getConfigPath(), bytes); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("routingProfiles"); + expect(diagnostics.config.providers.xai.note).toBe("keep me"); + expect(diagnostics.warnings?.join("\n")).toContain("unauthenticatedLoopbackListener ignored"); + expect(diagnostics.warnings?.join("\n")).toContain("hub.managementIngress ignored"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); +}); From d95743b5eaec4e9ba8f8ae886b5b829144662ada Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:51:23 +0900 Subject: [PATCH 008/224] test(config): activate routing-profile salvage in listener regression --- tests/config/config-load-degrade.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index b05a270b20..7e90c4177a 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -187,7 +187,7 @@ test.each([undefined, { enabled: false }])("absent or disabled listeners do not test("salvaged diagnostics retain listener warnings alongside the routing error", () => { const bytes = JSON.stringify({ ...candidate(undefined), - routingProfiles: { bad: { candidates: [{ provider: "xai", model: "model" }] } }, + routingProfiles: { bad: { candidates: [] } }, unauthenticatedLoopbackListener: { enabled: "true" }, hub: { managementIngress: { enabled: true, port: 70000 } }, }); From dcd2d0740301785ec624168073c7bfc59c10bb9f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:53:36 +0900 Subject: [PATCH 009/224] fix(search): retry clean empty answers without masking truncation Co-authored-by: Cortes Ventures --- .../content/docs/reference/proxy-formats.md | 7 ++ src/web-search/loop.ts | 58 ++++++++- structure/runtime.md | 4 + tests/web-search/web-search.test.ts | 116 ++++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..1bd63cac79 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,13 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Empty search answers + +After hosted search, a clean but empty forced-answer pass receives one additional answer +attempt with tools removed and existing results retained. This can incur another model +request. A second empty answer fails; malformed calls and provider refusal or truncation +outcomes are preserved without this retry. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 99b275ed8d..8feceb4c27 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -3,6 +3,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx import { namespacedToolName, toolChoiceToolPredicate } from "../types"; import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; +import { isTruncatedStopReason } from "../responses/truncated-stop-reason"; import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; @@ -230,6 +231,24 @@ function forcedAnswerNudge(): OcxMessage { }; } +/** + * Transient developer-role nudge for the ONE recovery pass after a forced answer came back empty. + * The recovery also removes every tool, so the model has nothing to call and can only return text; + * this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge} + * it is iteration-local and never touches the persisted `messages`. + */ +function forcedAnswerRetryNudge(): OcxMessage { + return { + role: "developer", + content: + "Your previous response contained no usable answer. Web search has finished for this turn and " + + "no tools are available for this response. Answer the user's question now in assistant text, " + + "using the web search results already gathered above. If those results are insufficient, say " + + "what is missing instead of returning an empty response.", + timestamp: Date.now(), + }; +} + function jsonError(status: number, message: string): Response { return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { status, @@ -370,7 +389,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 0 + let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0 ? [...messages, forcedAnswerNudge()] : messages; + // #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the + // model nothing to call, and the extra developer turn asks it for the text it just failed to + // produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry + // cannot repeat the same empty or tool-shaped response. + const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0; + if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()]; const iterParsed: OcxParsedRequest = { ...parsed, stream: true, - context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools }, + ...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}), + context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : forceAnswer ? toolsNoWebSearch : allTools }, }; // One cumulative header deadline spans every pool-key 429 rotation in this model iteration. // clear() stops only its timer after final headers; the direct turn signal remains attached to @@ -847,9 +875,33 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise event.type === "done"); + if (terminalEvent?.type === "done" && isTruncatedStopReason(terminalEvent.stopReason)) { + // A provider refusal or truncation is authoritative, even without text. + // Preserve it once; neither an empty-answer retry nor a generic 502 applies. + yield* replay(split.passthrough.slice(split.streamedPassthroughCount)); + return; + } if (terminalEvent?.type === "done" && (split.hasMalformedToolCall || (!split.hasRealToolCall && !hasVisibleAssistantText(split.passthrough)))) { + // #1001 fixed the silent success by failing here. A malformed call still fails: it + // reports a protocol problem, and replaying it would only re-ask an unwell upstream. + // Silence is different — it is recoverable, so retry exactly once with the results + // already gathered before failing the turn. + console.warn("[web-search-loop] unusable forced answer", JSON.stringify({ + model: parsed.modelId, + recoveryAttempt: emptyAnswerRetries, + searchCalls: split.calls.length, + malformed: split.hasMalformedToolCall, + stopReason: terminalEvent.stopReason, + eventTypes: [...new Set(split.passthrough.map(event => event.type))], + })); + if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) { + emptyAnswerRetries++; + console.warn("[web-search-loop] empty forced answer — retrying once without tools"); + yield { type: "heartbeat" }; + continue; + } throw new LoopError(502, "forced-answer pass produced no usable assistant output"); } } diff --git a/structure/runtime.md b/structure/runtime.md index 495745051e..ea8e98e277 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -192,3 +192,7 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +### Empty forced search answers + +`src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail, and recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index e182308a0d..5c41ad5e82 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -133,6 +133,122 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = expect(frames.some(frame => frame.event === "response.completed")).toBe(true); expect(frames.some(frame => frame.event === "response.failed")).toBe(false); }); + + // #1001 chose to fail rather than complete silently, which turned silence into a dead turn: + // the user sees "stream disconnected before completion: forced-answer pass produced no usable + // assistant output". Silence is recoverable, so the pass is retried once with no tools before + // the same error is reported. Malformed calls still fail immediately. + describe("empty forced answer recovery", () => { + function sequenceAdapter(passes: AdapterEvent[][], seen: OcxParsedRequest[]): ProviderAdapter { + let pass = 0; + return { + name: "sequence", + buildRequest: (request) => { + seen.push(request); + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + for (const event of passes[Math.min(pass++, passes.length - 1)] ?? []) yield event; + }, + async parseResponse() { + throw new Error("parseResponse must be unreachable"); + }, + }; + } + + async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false) { + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }, ...(ordinaryTool ? [{ type: "function", name: "fixture", parameters: { type: "object", properties: {} } }] : [])] }), + adapter: sequenceAdapter(passes, seen), + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + }); + return collectSse(response.body!); + } + + test("an empty forced pass is retried once and completes", async () => { + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ]); + expect(frames.some(frame => frame.event === "response.completed")).toBe(true); + expect(frames.some(frame => frame.event === "response.failed")).toBe(false); + }); + + test("the recovery pass asks for text with every tool removed", async () => { + const seen: OcxParsedRequest[] = []; + await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ], seen); + // The search pass plus the empty forced pass plus exactly one recovery — no extra upstream call. + expect(seen).toHaveLength(3); + const recovery = seen[2]!; + expect(recovery.options.toolChoice).toBe("none"); + expect(recovery.context.tools).toEqual([]); + // The results gathered by the search reach the recovery turn as a tool result ... + expect(recovery.context.messages.filter(message => message.role === "toolResult")).toHaveLength(1); + // ... and the recovery turn carries the developer nudge that asks for the missing text. + expect(recovery.context.messages.some(message => + message.role === "developer" && String(message.content).includes("no tools are available"))) + .toBe(true); + }); + + test("recovery removes ordinary tools as well as web search", async () => { + const seen: OcxParsedRequest[] = []; + await drivePasses([webSearchFirstPass, [{ type: "done" }], [{ type: "text_delta", text: "answer" }, { type: "done" }]], seen, true); + expect(seen).toHaveLength(3); + expect(seen[1]!.context.tools.length).toBeGreaterThan(0); + expect(seen[2]!.context.tools).toEqual([]); + expect(seen[2]!.options.toolChoice).toBe("none"); + }); + + for (const [stopReason, reason] of [["refusal", "content_filter"], ["content_filter", "content_filter"], ["max_tokens", "max_output_tokens"], ["length", "max_output_tokens"]]) { + for (const partial of [false, true]) { + test(`${stopReason} partial=${partial} stays authoritative without a retry`, async () => { + const seen: OcxParsedRequest[] = []; + const terminalPass: AdapterEvent[] = [ + ...(partial ? [{ type: "text_delta" as const, text: "partial answer" }] : []), + { type: "done", stopReason }, + ]; + const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); + expect(seen).toHaveLength(2); + expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event)).map(frame => frame.event)).toEqual(["response.incomplete"]); + expect(frames.find(frame => frame.event === "response.incomplete")!.data.response.incomplete_details.reason).toBe(reason); + if (partial) expect(frames.filter(frame => frame.event === "response.output_text.delta").map(frame => frame.data.delta).join("")).toBe("partial answer"); + }); + } + } + + test("a persistent empty forced pass still fails after the one recovery", async () => { + const seen: OcxParsedRequest[] = []; + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "done" }], + ], seen); + expect(seen).toHaveLength(3); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + expect(frames.some(frame => frame.event === "response.completed")).toBe(false); + }); + + test("a malformed forced call is not retried", async () => { + const seen: OcxParsedRequest[] = []; + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "tool_call_start", id: "", name: "" }, { type: "tool_call_end" }, { type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ], seen); + expect(seen).toHaveLength(2); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + }); + }); }); const routedProvider: OcxProviderConfig = { From e41636b45cd6c5fd5e81533e325593ef9391d5e1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:54:17 +0900 Subject: [PATCH 010/224] fix: bind plaintext restoration across sparse stream identities --- src/responses/plaintext-v2-agent-messages.ts | 46 ++++++++++++--- .../plaintext-v2-agent-messages.test.ts | 57 +++++++++++++++++-- ...plaintext-v2-agent-messages-server.test.ts | 25 +++++++- 3 files changed, 113 insertions(+), 15 deletions(-) diff --git a/src/responses/plaintext-v2-agent-messages.ts b/src/responses/plaintext-v2-agent-messages.ts index 70ad82ab6f..6863e28c1f 100644 --- a/src/responses/plaintext-v2-agent-messages.ts +++ b/src/responses/plaintext-v2-agent-messages.ts @@ -373,6 +373,7 @@ function aliasCollaborationReference( if (!isPlainObject(value)) return value; const type = value.type; const canCarryNamespace = isToolIdentity(value); + if (canCarryNamespace && value.namespace !== undefined && value.namespace !== COLLABORATION_NAMESPACE) return value; let rewritten = value; if (canCarryNamespace && value.namespace === COLLABORATION_NAMESPACE) { const name = (type === "function" || type === "function_call") && typeof value.name === "string" @@ -616,6 +617,7 @@ function restoreToolIdentity( && value.type === "namespace" && value.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE ) { + if (value.tools !== undefined && !Array.isArray(value.tools)) return { ...unchanged(value), overflow: true }; const children = restoreIdentityList(value.tools, context, false); if (children.overflow) return { ...unchanged(value), overflow: true }; return { @@ -708,9 +710,12 @@ function restoreIdentityList( } function restoreToolChoice(value: unknown, context: RestoreContext): RestoreOutcome { - const direct = restoreToolIdentity(value, context); + if (isPlainObject(value) && value.tools !== undefined && !Array.isArray(value.tools)) { + return { ...unchanged(value), overflow: true }; + } + const direct = restoreToolIdentity(value, context, true); if (direct.overflow || !isPlainObject(value) || !Array.isArray(value.tools)) return direct; - const tools = restoreIdentityList(value.tools, context, false); + const tools = restoreIdentityList(value.tools, context, true); if (tools.overflow) return { ...unchanged(value), overflow: true }; if (!tools.changed) return direct; const base = direct.value as Record; @@ -719,6 +724,10 @@ function restoreToolChoice(value: unknown, context: RestoreContext): RestoreOutc function restoreResponseSnapshot(value: unknown, context: RestoreContext): RestoreOutcome { if (!isPlainObject(value)) return unchanged(value); + if ((value.output !== undefined && !Array.isArray(value.output)) + || (value.tools !== undefined && !Array.isArray(value.tools))) { + return { ...unchanged(value), overflow: true }; + } const output = restoreIdentityList(value.output, context, false); if (output.overflow) return { ...unchanged(value), overflow: true }; const tools = restoreIdentityList(value.tools, context, true); @@ -825,7 +834,8 @@ export function createPlaintextV2AgentMessageCallRestoreRewrite( toolNames: ReadonlySet, aliasedAgentMessageToolNames: ReadonlySet = toolNames, ): (payload: string) => string { - const bindings = new Map(); + type Binding = { namespace: string; name: string; keys: Set }; + const bindings = new Map(); let refused = false; return payload => { if (refused) throw new PlaintextV2AgentMessageRestoreOverflowError(); @@ -845,18 +855,38 @@ export function createPlaintextV2AgentMessageCallRestoreRewrite( name = name.slice(prefix.length); } } - const identity = JSON.stringify([namespace, name]); const keys = [ typeof item.id === "string" ? `id:${item.id}` : undefined, typeof item.item_id === "string" ? `id:${item.item_id}` : undefined, typeof item.call_id === "string" ? `call:${item.call_id}` : undefined, typeof outputIndex === "number" ? `index:${outputIndex}` : undefined, ].filter((key): key is string => key !== undefined); - for (const key of keys) { + const groups = [...new Set(keys.flatMap(key => { const prior = bindings.get(key); - if (prior !== undefined && prior !== identity) throw new PlaintextV2AgentMessageRestoreOverflowError(); - if (prior === undefined && bindings.size >= MAX_RESTORED_TOOL_IDENTITIES) throw new PlaintextV2AgentMessageRestoreOverflowError(); - bindings.set(key, identity); + return prior ? [prior] : []; + }))]; + for (const group of groups) { + if (group.name !== name || (group.namespace && namespace && group.namespace !== namespace)) { + throw new PlaintextV2AgentMessageRestoreOverflowError(); + } + namespace ||= group.namespace; + } + // All coordinates for a call share the same refined identity, including + // coordinates omitted by this particular sparse event. Merge smaller groups + // into the largest to bound repeated cross-coordinate refinement work. + groups.sort((left, right) => right.keys.size - left.keys.size); + const binding: Binding = groups[0] ?? { namespace, name, keys: new Set() }; + binding.namespace = namespace; + for (const group of groups.slice(1)) { + for (const key of group.keys) { + binding.keys.add(key); + bindings.set(key, binding); + } + } + for (const key of keys) { + if (!bindings.has(key) && bindings.size >= MAX_RESTORED_TOOL_IDENTITIES) throw new PlaintextV2AgentMessageRestoreOverflowError(); + binding.keys.add(key); + bindings.set(key, binding); } }; bind(value, value.output_index); diff --git a/tests/responses/plaintext-v2-agent-messages.test.ts b/tests/responses/plaintext-v2-agent-messages.test.ts index 7e7a72e58b..763a405887 100644 --- a/tests/responses/plaintext-v2-agent-messages.test.ts +++ b/tests/responses/plaintext-v2-agent-messages.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; import { PlaintextV2AgentMessageRestoreOverflowError, + createPlaintextV2AgentMessageCallRestoreRewrite, PLAINTEXT_V2_COLLABORATION_NAMESPACE, preparePlaintextV2AgentMessages, restorePlaintextV2AgentMessageCalls, @@ -600,10 +601,10 @@ describe("plaintext v2 agent message response restoration", () => { expect(restored.encrypted_function_args).toEqual([]); }); - test("is byte-identical for invalid JSON and payloads without the private alias", () => { - for (const payload of ["not json", '{"type":"response.completed"}']) { - expect(restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames)).toBe(payload); - } + test("rejects invalid JSON but preserves valid payloads without aliases", () => { + expect(() => restorePlaintextV2AgentMessageCallsInJson("not json", declaredToolNames)).toThrow(PlaintextV2AgentMessageRestoreOverflowError); + const payload = '{"type":"response.completed"}'; + expect(restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames)).toBe(payload); }); test("restores an unqualified private tool alias in streamed JSON", () => { @@ -832,3 +833,51 @@ describe("plaintext V2 refusal boundaries", () => { expect(restorePlaintextV2AgentMessageCallsInJson(payload, names)).toBe(payload); }); }); + +test("plaintext restoration refuses malformed identity arrays", () => { + for (const value of [{ output: { name: "start_delegated_task" } }, { tools: "collaboration-optimize" }]) { + expect(restorePlaintextV2AgentMessageCallsInJsonResult(JSON.stringify(value), new Set(["spawn_agent"])).overflowed).toBe(true); + } +}); + + +test("restores namespace selectors and allowed namespace choices", () => { + const names = new Set(["spawn_agent"]); + for (const choice of [ + { type: "namespace", name: PLAINTEXT_V2_COLLABORATION_NAMESPACE }, + { type: "allowed_tools", tools: [{ type: "namespace", name: PLAINTEXT_V2_COLLABORATION_NAMESPACE }] }, + ]) { + const restored = restorePlaintextV2AgentMessageCallsInJson(JSON.stringify({ tool_choice: choice }), names); + expect(restored).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(restored).toContain('"collaboration"'); + } +}); + +test("sparse argument events inherit only a compatible existing binding", () => { + const rewrite = createPlaintextV2AgentMessageCallRestoreRewrite(new Set(["spawn_agent", "send_message"])); + rewrite(JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { + type: "function_call", id: "fc1", namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, name: "start_delegated_task", + } })); + expect(JSON.parse(rewrite(JSON.stringify({ type: "response.function_call_arguments.done", item_id: "fc1", name: "start_delegated_task", arguments: "{}" }))).name).toBe("spawn_agent"); + expect(() => rewrite(JSON.stringify({ type: "response.function_call_arguments.done", item_id: "fc1", name: "deliver_delegated_message", arguments: "{}" }))).toThrow(PlaintextV2AgentMessageRestoreOverflowError); +}); + +test("request replay preserves explicit foreign namespace identity", () => { + const replay = { type: "function_call", namespace: "foreign", name: "collaboration__spawn_agent", arguments: "{}" }; + const body = { tools: [{ type: "namespace", name: "collaboration", tools: [collaborationTool("spawn_agent")] }], input: [replay] }; + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.namespaceAliased).toBe(true); + expect((prepared.body as typeof body).input[0]).toBe(replay); +}); + + +test("namespace refinement follows every bound coordinate", () => { + const rewrite = createPlaintextV2AgentMessageCallRestoreRewrite(new Set(["spawn_agent"])); + rewrite(JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { + type: "function_call", id: "fc1", call_id: "c1", name: "start_delegated_task", + } })); + rewrite(JSON.stringify({ type: "response.function_call_arguments.done", item_id: "fc1", namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, name: "start_delegated_task", arguments: "{}" })); + expect(() => rewrite(JSON.stringify({ type: "response.completed", response: { output: [{ + type: "function_call", call_id: "c1", namespace: "foreign", name: "spawn_agent", arguments: "{}", + }] } }))).toThrow(PlaintextV2AgentMessageRestoreOverflowError); +}); diff --git a/tests/server/plaintext-v2-agent-messages-server.test.ts b/tests/server/plaintext-v2-agent-messages-server.test.ts index e70946cafc..6eba4cd552 100644 --- a/tests/server/plaintext-v2-agent-messages-server.test.ts +++ b/tests/server/plaintext-v2-agent-messages-server.test.ts @@ -604,9 +604,10 @@ for (const streamMode of ["legacy-tee", "eager-relay"] as const) { expect(text).not.toContain("start_delegated_task"); expect(sends).toBe(1); const next = await handleResponses(collaborationRequest({ previousResponseId: "refused-plaintext", input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "next" }] }] }), config(false), { model: "", provider: "" }); - await next.text(); - expect(sent[1]).not.toContain("plain assignment"); - expect(sends).toBe(2); + expect(next.status).toBe(400); + expect(await next.text()).toContain("previous_response_not_found"); + expect(sent).toHaveLength(1); + expect(sends).toBe(1); }); } } @@ -619,3 +620,21 @@ test("malformed bounded JSON is a single-attempt 502", async () => { expect(await response.text()).not.toContain("{malformed"); expect(sends).toBe(1); }); + +test("cross-coordinate namespace conflict cannot publish continuation", async () => { + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + const events = [ + { type: "response.output_item.added", output_index: 0, item: { type: "function_call", id: "fc1", call_id: "c1", name: "start_delegated_task", arguments: "" } }, + { type: "response.function_call_arguments.done", item_id: "fc1", namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, name: "start_delegated_task", arguments: "{}" }, + { type: "response.completed", response: { id: "refused-coordinates", status: "completed", output: [{ type: "function_call", call_id: "c1", namespace: "foreign", name: "spawn_agent", arguments: "{}" }] } }, + ]; + return new Response(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const response = await handleResponses(collaborationRequest(), config(true, false, "eager-relay"), { model: "", provider: "" }); + expect(await response.text()).toContain("response.failed"); + const next = await handleResponses(collaborationRequest({ previousResponseId: "refused-coordinates" }), config(false), { model: "", provider: "" }); + expect(next.status).toBe(400); + expect(sends).toBe(1); +}); From 45f703f356b20172ffd9a7301a9ecaf967deccdf Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:54:48 +0900 Subject: [PATCH 011/224] test(search): narrow terminal fixture projection --- tests/web-search/web-search.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index 5c41ad5e82..61abb904fd 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -219,8 +219,9 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = ]; const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); expect(seen).toHaveLength(2); - expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event)).map(frame => frame.event)).toEqual(["response.incomplete"]); - expect(frames.find(frame => frame.event === "response.incomplete")!.data.response.incomplete_details.reason).toBe(reason); + expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event ?? "")).map(frame => frame.event)).toEqual(["response.incomplete"]); + const terminalResponse = frames.find(frame => frame.event === "response.incomplete")!.data.response as { incomplete_details: { reason: string } }; + expect(terminalResponse.incomplete_details.reason).toBe(reason); if (partial) expect(frames.filter(frame => frame.event === "response.output_text.delta").map(frame => frame.data.delta).join("")).toBe("partial answer"); }); } From 3652da790b58177f5ae7eebecb9c8ba3527e9109 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:55:23 +0900 Subject: [PATCH 012/224] test(search): exercise live output truncation without duplicate replay --- tests/web-search/web-search.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index 61abb904fd..9bf7c02444 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -157,7 +157,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = }; } - async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false) { + async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false, liveOutput = false) { const response = await runWithWebSearch({ parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }, ...(ordinaryTool ? [{ type: "function", name: "fixture", parameters: { type: "object", properties: {} } }] : [])] }), adapter: sequenceAdapter(passes, seen), @@ -166,6 +166,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, + streamRoutedModelOutput: liveOutput, }); return collectSse(response.body!); } @@ -217,7 +218,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = ...(partial ? [{ type: "text_delta" as const, text: "partial answer" }] : []), { type: "done", stopReason }, ]; - const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); + const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen, false, true); expect(seen).toHaveLength(2); expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event ?? "")).map(frame => frame.event)).toEqual(["response.incomplete"]); const terminalResponse = frames.find(frame => frame.event === "response.incomplete")!.data.response as { incomplete_details: { reason: string } }; From 28c13d0c0991647c817357a32fba50bc5946431c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:55:59 +0900 Subject: [PATCH 013/224] fix(usage): retain readable totals with incomplete-history notices Carries #4111 at 2f07acb58b3e73f48cea38334f301b430a8634cd. Preserve positive omission evidence through retained aggregates and consumer caches; refuse most-used ordering from an incomplete snapshot. Replace fixed-delay GUI test completion with an observed predicate. Local suites NOT RUN; hosted verification follows. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- devlog/_plan/260912_operations/030_totals.md | 8 +- .../content/docs/fr/guides/web-dashboard.md | 2 + .../content/docs/fr/reference/cli/agents.md | 2 + .../docs/fr/reference/management-api.md | 2 + .../src/content/docs/guides/web-dashboard.md | 5 + .../content/docs/ja/guides/web-dashboard.md | 2 + .../content/docs/ja/reference/cli/agents.md | 2 + .../docs/ja/reference/management-api.md | 2 + .../content/docs/ko/guides/web-dashboard.md | 2 + .../content/docs/ko/reference/cli/agents.md | 2 + .../docs/ko/reference/management-api.md | 2 + .../src/content/docs/reference/cli/agents.md | 5 + .../content/docs/reference/management-api.md | 12 +- .../content/docs/ru/guides/web-dashboard.md | 2 + .../content/docs/ru/reference/cli/agents.md | 2 + .../docs/ru/reference/management-api.md | 2 + .../content/docs/tr/guides/web-dashboard.md | 2 + .../content/docs/tr/reference/cli/agents.md | 2 + .../docs/tr/reference/management-api.md | 2 + .../docs/zh-cn/guides/web-dashboard.md | 2 + .../docs/zh-cn/reference/cli/agents.md | 2 + .../docs/zh-cn/reference/management-api.md | 2 + .../docs/zh-tw/guides/web-dashboard.md | 2 + .../docs/zh-tw/reference/cli/agents.md | 2 + .../docs/zh-tw/reference/management-api.md | 2 + gui/src/components/AddProviderModal.tsx | 6 +- .../apikeys-workspace/ApiKeysListPanel.tsx | 7 +- .../apikeys-workspace/ApiKeysWorkspace.tsx | 12 +- .../ProviderWorkspaceShell.tsx | 11 +- .../components/usage-incomplete-notice.tsx | 10 ++ 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/ApiKeys.tsx | 7 +- gui/src/pages/Models.tsx | 3 +- gui/src/pages/Usage.tsx | 5 +- gui/src/pages/dashboard-overview-head.tsx | 2 + gui/src/pages/dashboard-shared.ts | 2 +- gui/src/usage-summary-resource.ts | 15 +++ gui/tests/apikeys-workspace.test.tsx | 20 +++ gui/tests/model-picker-order-editor.test.tsx | 36 ++++++ gui/tests/usage-custom-range.test.tsx | 19 +++ gui/tests/usage-incomplete-consumers.test.tsx | 120 ++++++++++++++++++ src/cli/usage-report.ts | 9 +- src/server/management/api-key-usage.ts | 9 +- src/server/management/logs-usage-routes.ts | 2 + src/server/management/oauth-account-routes.ts | 3 +- .../management/usage-aggregate-cache.ts | 29 ++--- src/server/management/usage-summary-cache.ts | 2 + structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 17 ++- structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-usage-report.test.ts | 29 +++++ tests/server/api-key-attribution.test.ts | 16 ++- tests/server/api-usage.test.ts | 19 ++- tests/usage/usage-aggregate-cache.test.ts | 39 +++++- 76 files changed, 528 insertions(+), 52 deletions(-) create mode 100644 gui/src/components/usage-incomplete-notice.tsx create mode 100644 gui/tests/usage-incomplete-consumers.test.tsx diff --git a/devlog/_plan/260912_operations/030_totals.md b/devlog/_plan/260912_operations/030_totals.md index 71d2615772..e3baa47109 100644 --- a/devlog/_plan/260912_operations/030_totals.md +++ b/devlog/_plan/260912_operations/030_totals.md @@ -2,10 +2,14 @@ Class C3; dependency roadmap. Adopt public #4111 final diff (2f07acb58b3e73f48cea38334f301b430a8634cd) after current-base and latest-review audit; preserve luvs01 credit. Source diff and metadata are in ignored .tmp/operations/pr-4111.diff/json, fetched directly from GitHub. -MODIFY src/server/management/usage-aggregate-cache.ts: replace four oversizedRows throws with retained usageIncomplete boolean, set on full scan, OR on append, preserve in resultFrom; cache API-key snapshots with diagnostic. MODIFY api-key-usage.ts: keep readable accumulator output and attach usageIncomplete:true / usageIncompleteReason:oversized_rows instead of throwing. MODIFY logs-usage-routes.ts: serialize diagnostics on filtered and unfiltered summaries. MODIFY usage-summary-cache.ts CachedUsageSummary and oauth-account-routes.ts GET /api/keys to retain/serialize flags. Other IO/mutation errors still fail. +MODIFY src/server/management/usage-aggregate-cache.ts: replace four oversizedRows throws with retained usageIncomplete boolean, set on full scan, OR on append, preserve in resultFrom; cache API-key snapshots with diagnostic. MODIFY api-key-usage.ts: keep readable accumulator output and attach usageIncomplete:true / usageIncompleteReason:oversized_rows instead of throwing. MODIFY logs-usage-routes.ts: serialize diagnostics on filtered and unfiltered summaries. MODIFY usage-summary-cache.ts CachedUsageSummary and oauth-account-routes.ts GET /api/keys to retain/serialize flags. Preserve existing non-oversized failure behavior: API-key rollups still return their existing zero fallback on IO failure, and /api/usage retains error:read_failed. MODIFY src/cli/usage-report.ts: warnings precede totals/no-match branch; incomplete no-match says skipped records may match. MODIFY gui/src/usage-summary-resource.ts shared optional diagnostic type; NEW components/usage-incomplete-notice.tsx; extend consumers Usage, dashboard overview, Models, AddProviderModal, ProviderWorkspaceShell, ApiKeysWorkspace/ListPanel and ApiKeys. Incomplete keys do not claim inactivity; warnings survive consumer caching. Add all locale keys. Full field chain: scanner oversizedRows -> retained aggregate boolean/API key snapshot -> route JSON/cache -> shared GUI/CLI input types -> every totals/ranking/key activity consumer. -MODIFY existing tests/cli/cli-usage-report.test.ts, tests/server/api-usage.test.ts, tests/server/api-key-attribution.test.ts, tests/usage/usage-aggregate-cache.test.ts and GUI usage/custom-range/model-picker/key-workspace tests; NEW gui/tests/usage-incomplete-consumers.test.tsx. Activation: good + oversized + good rows yields readable sums and warning; append oversized sticky flag, full clean rewrite clears it, missing filter matches stays uncertain, loading/stale consumers retain warning. Do not turn IO errors into zero totals. +MODIFY existing tests/cli/cli-usage-report.test.ts, tests/server/api-usage.test.ts, tests/server/api-key-attribution.test.ts, tests/usage/usage-aggregate-cache.test.ts and GUI usage/custom-range/model-picker/key-workspace tests; NEW gui/tests/usage-incomplete-consumers.test.tsx. Activation: good + oversized + good rows yields readable sums and warning; append oversized sticky flag, full clean rewrite clears it, missing filter matches stays uncertain, loading/stale consumers retain warning. Do not introduce any new IO-error fallback. MODIFY structure/gui-and-management-api.md and relevant mapped contract pointers; public management API, CLI agents and web-dashboard guides in all existing translated paths from original diff. Hosted full CI and dashboard evidence certify final tip; local suites/build/typecheck NOT RUN. This does not implement hub client-scoped CLI usage (#4205). + +Design reflection OPS-TOTALS01..05 accepted: positive-only flags do not prove completeness when absent; invalidRows is not sticky. Models rejects most-used ordering before PUT when usage is incomplete and keeps other modes available. Include dashboard-shared.ts as type carrier, keys-first/usage-first seeding, unfinished suffix without duplicates, empty/no-attribution and incomplete-to-clean recovery cases. Reuse existing Notice warn presentation and all locale modules, without new visual tokens or motion (ops dashboard, variance2/motion1, existing density). Remote build preview supplies rendered evidence; local product build/tests NOT RUN. + +A amendment: replace the newly carried fixed25ms wait in gui/tests/usage-incomplete-consumers.test.tsx with bounded condition-driven completion (act and event-loop turns, asserting rendered expected condition before return). No timer delay is accepted as proof of rendering. diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 2e333e39d8..2d4d753bb5 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -57,6 +57,8 @@ gestionnaire de mots de passe. | **Stockage** | Consultez en lecture seule la répartition du disque de CODEX_HOME — sessions, archives, bases de données et pièces jointes. Pour le nettoyage facultatif des archives, prévisualisez les N % les plus anciennes, puis placez-les en quarantaine dans `CODEX_HOME/.trash` (par défaut) ou supprimez-les définitivement après avoir coché une case explicite. **La stratégie de nettoyage automatique** est facultative et **désactivée par défaut** (`storageCleanupPolicy.enabled`) ; configurez son seuil, sa cible, sa planification et son mode sur la page **Stockage**, ou lancez **Exécuter maintenant**. Les entrées mises en quarantaine peuvent être restaurées depuis cette page (JSONL et fils). Les sessions actives restent en lecture seule. Le nettoyage et la restauration sont refusés tant que Codex verrouille le fichier `state_*.sqlite` le plus récent ou actif. | | **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). Sur Windows avec le backend Planificateur de tâches, le tableau de bord refuse et vous demande d'exécuter `ocx stop` : le wrapper peut relancer le proxy après la fin de la tâche, et seul un stop exécuté hors du proxy peut vérifier cette fenêtre de redémarrage avant de restaurer votre configuration client. Rien n'est modifié en cas de refus. | +Les vues Utilisation, Tableau de bord, Fournisseurs, Catalogue des fournisseurs et Clés API signalent les enregistrements exclus, même sans résultat lisible. Les décomptes, les dates et les classements reposent uniquement sur les lignes lisibles. L’enregistrement de l’ordre des modèles par utilisation est refusé si l’historique est incomplet : choisissez un autre ordre ou réparez l’historique avant de réessayer. + ### Filtrer les requêtes Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous. 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 0d781042d9..69d4ac39d0 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -98,6 +98,8 @@ Inspectez les requêtes de proxy, l’utilisation, le stockage, la mémoire et l ocx observe usage --range 30d --json ``` +Si certains enregistrements ne peuvent pas être inclus, la sortie lisible affiche un avertissement, même sans ligne lisible. Les totaux affichés ne reflètent que les enregistrements lisibles. Si un filtre ne trouve aucune correspondance lisible, la sortie affiche l'avertissement et des indications au lieu des lignes de totaux ; les enregistrements ignorés peuvent contenir des correspondances. `--json` préserve le diagnostic `usageIncomplete` et sa raison. + ### `ocx debug ` Lisez ou modifiez les remplacements de débogage d'exécution via la gestion du proxy en cours d'exécution API. diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index d50b11a50c..e7da3b7bfb 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -155,6 +155,8 @@ Voir [Combos](/fr/guides/combos/) pour les stratégies cibles, les temps de rech | `POST /api/storage/cleanup-policy/run` | Démarrer une exécution manuelle de la politique de nettoyage | 409 `already_running` ; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Point d'ancrage du flux de stratégie réservé aux tests | 404 `not_found` en cas d'indisponibilité | +Si une ligne dépasse la limite de taille du parseur, `GET /api/usage` et `GET /api/keys` conservent les agrégats lisibles et ajoutent `usageIncomplete: true` avec `usageIncompleteReason: "oversized_rows"` au niveau de la réponse. Ce diagnostic reste présent dans le cache et après les ajouts incrémentaux, même sans résultat ni correspondance de filtre ; une reconstruction le recalcule. Les identifiants de fournisseur, de modèle et de clé API ne sont pas raccourcis. L’absence du champ ne prouve pas la validité de toutes les lignes. Ce signal est distinct de `historyTruncated`, `entriesTruncated` et de la couverture de mesure des tokens. + Pour `GET /api/usage?range=30d&surface=codex`, `accounts` contient une ligne par libellé de pool Codex observé. Chaque ligne indique `accountLogLabel`, le total de jetons, `usageCoverageRatio` et une valeur facultative `estimatedCostUsd` calculée selon les tarifs d'affichage actuellement configurés. Les substitutions `modelCosts` actives de l'utilisateur diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 7e86901233..2161f81358 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -96,6 +96,11 @@ badge or the version value to read the full value. | **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. | +If some usage records cannot be included, the Usage page, Dashboard, provider workspace, provider +catalog, and API key views show a warning even when no readable records remain. Counts, dates, and +usage rankings reflect readable records only. **Models → Most used snapshot → Apply order** refuses +to save an incomplete snapshot; choose another order or repair the history before retrying. + ### Account selection Account selection is shared with request routing. Selecting an OAuth account takes effect on the diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 310baa4a5a..304c9646b8 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。隔離エントリは Storage ページから復元可能(JSONL + スレッド)。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中はクリーンアップと復元を拒否。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。ただし Windows のタスク スケジューラ バックエンドではダッシュボードが拒否し、`ocx stop` の実行を促します。タスク終了後もラッパーがプロキシを再起動しうるため、クライアント設定を戻す前にその再起動区間を確認できるのはプロキシの外で動く stop だけです。拒否されたときは何も変更されません。 | +使用量、ダッシュボード、プロバイダー画面、プロバイダーカタログ、API キー画面は、読み取れる記録がなくても除外された使用履歴の警告を表示します。回数、日付、使用順位は読み取れる記録のみを反映します。履歴が不完全な場合はモデルの使用回数順の保存を拒否します。別の順序を選ぶか、履歴を修復してから再試行してください。 + ### リクエストログの絞り込み Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。 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 a74f20a817..e2431ff41f 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -69,6 +69,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +一部の使用履歴を集計できない場合、人向けの出力は読み取れる行がない場合も警告を表示します。表示される合計値は読み取れる記録のみを反映します。フィルターに一致する読み取れる記録がない場合は、合計欄の代わりに警告と案内を表示します。除外した記録には一致するものが含まれる可能性があります。`--json` は応答の `usageIncomplete` 診断と理由をそのまま保持します。 + ### `ocx debug ` 実行中のプロキシの管理 API を通じて、ランタイム デバッグ オーバーライドを読み取りまたは変更します。 diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 35ebec9131..4398ff8118 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -134,6 +134,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` |手動クリーンアップ ポリシーの実行を開始します。 409 `already_running`; 500`cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` |テスト専用ポリシー ストリーム フック | 404 `not_found` 利用できない場合 | +行が既存のパーサーのサイズ上限を超えた場合、`GET /api/usage` と `GET /api/keys` は読み取れる行の集計を維持し、応答全体に `usageIncomplete: true` と `usageIncompleteReason: "oversized_rows"` を追加します。この診断はキャッシュや増分追記後も維持され、結果が空または一致なしでも返されます。再構築時には再計算されます。プロバイダー、モデル、API キーの識別子は短縮しません。フラグがないことは全行が有効だった証明にはなりません。`historyTruncated`、`entriesTruncated`、トークン測定カバレッジとは別の情報です。 + `models`、`providers`、および `days[].models` の各行にも `cacheHitRate` が含まれます。これは、プロバイダーのプロンプト キャッシュから供給された入力トークンの割合で、`[0, 1]` の範囲に制限されます。プロバイダーがキャッシュ テレメトリを報告しなかった場合、または行に入力トークンがない場合は、`0` ではなく `null` になります。「キャッシュ データなし」と「実際のヒット率 0%」は異なる事実であり、それらを同じように描画するチャートは誤解を招くためです。 :::caution diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index efdd80179f..783a5a19f0 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. Storage 페이지에서 격리 항목을 복원할 수 있습니다(JSONL + 스레드). 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 정리와 복원을 거절합니다. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). 단, Windows 작업 스케줄러로 관리되는 경우에는 대시보드가 거절하고 `ocx stop`을 안내합니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 클라이언트 설정을 되돌리기 전에 그 재시작 구간을 확인할 수 있는 건 프록시 바깥에서 도는 stop뿐입니다. 거절될 때는 아무것도 바뀌지 않습니다. | +Usage, Dashboard, 공급자 작업 화면·카탈로그, API 키 화면은 읽을 수 있는 기록이 없어도 일부 기록 제외 경고를 표시합니다. 횟수·날짜·사용 순위는 읽을 수 있는 기록만 반영합니다. 이력이 불완전하면 모델의 ‘많이 사용한 순서’ 저장을 거절합니다. 다른 순서를 선택하거나 이력을 복구한 뒤 다시 시도하세요. + ### 요청 로그 필터 Logs에서는 클라이언트 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간, 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 82f776b782..12afadd2e9 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -94,6 +94,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +일부 사용량 기록을 집계하지 못하면 일반 출력은 읽을 수 있는 행이 없어도 경고합니다. 표시되는 합계는 읽을 수 있는 기록만 반영합니다. 필터에 일치하는 읽을 수 있는 기록이 없으면 합계 항목 대신 경고와 안내를 표시하며, 제외된 기록에는 일치하는 항목이 있을 수 있습니다. `--json`은 응답의 `usageIncomplete` 진단과 사유를 그대로 유지합니다. + ### `ocx debug ` 실행 중인 프록시의 관리 API를 통해 런타임 디버그 override를 읽거나 변경합니다. diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index c0056af61d..b806e10d17 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -138,6 +138,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` | 수동 cleanup-policy 실행을 시작합니다 | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 테스트 전용 policy stream 훅입니다 | 사용할 수 없으면 404 `not_found` | +행이 기존 파서의 크기 제한을 넘으면 `GET /api/usage`와 `GET /api/keys`는 읽을 수 있는 행의 집계를 유지하고 응답 전체에 `usageIncomplete: true`, `usageIncompleteReason: "oversized_rows"`를 추가합니다. 이 진단은 캐시와 증분 추가에서도 유지되며, 빈 결과나 필터 일치 결과가 없는 경우에도 반환됩니다. 재구축 시에는 다시 계산합니다. 행을 맞추기 위해 공급자·모델·API 키 식별자를 줄이지 않습니다. 플래그가 없다고 모든 기록이 유효했다는 뜻은 아닙니다. `historyTruncated`, `entriesTruncated`, 토큰 측정 커버리지와는 별개입니다. + `models`, `providers`, `days[].models`의 행에도 `cacheHitRate`가 포함됩니다. 이 값은 공급자의 프롬프트 캐시에서 제공된 입력 토큰의 비율이며 `[0, 1]` 범위로 제한됩니다. 공급자가 캐시 텔레메트리를 보고하지 않았거나 행에 입력 토큰이 없으면 `0`이 아니라 항상 `null`입니다. "캐시 데이터 없음"과 "실제 적중률 0%"는 서로 다른 사실이며, diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 32399c59df..3909ef90c5 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -162,6 +162,11 @@ separately, and requests with no matching price row are counted as ocx usage --range today --provider xai ``` +When some usage records cannot be included, human output warns, including when there are zero readable rows. +Any displayed totals reflect readable records only. If a filter has no readable matches, the output shows +the warning and guidance instead of total lines; skipped records may contain matches. +`--json` preserves the response-level `usageIncomplete` diagnostic and reason. + ### `ocx debug ` Read or change runtime debug overrides through the running proxy's management API. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index cd8b2450c2..87a54b749b 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -191,7 +191,7 @@ by the current window size. | `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — | | `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — | | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | -| `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | +| `GET /api/usage` | Scan the usage ledger into compact aggregates of readable rows, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | @@ -202,6 +202,14 @@ by the current window size. | `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable | +If a scanned row exceeds the existing parser size limit, `GET /api/usage` and `GET /api/keys` +keep the readable-row aggregates and add `usageIncomplete: true` with +`usageIncompleteReason: "oversized_rows"` at response level. This diagnostic survives cached +responses and incremental appends, including empty or unmatched results; a rebuild recalculates it. +No provider, model, or API-key identifier is shortened to make a row fit. An absent flag is not proof +that every ledger record was valid. This is separate from `historyTruncated`, `entriesTruncated`, +and token measurement coverage. + New xAI attempts in `usage.jsonl` include a request-time `credentialSource`: `grok-oauth` for the resolved Grok CLI OAuth transport, or `xai-api-key` for the public xAI API key transport. This fixed label contains no credential or account identifier. It belongs to @@ -214,7 +222,7 @@ The log reports usage, not subscription invoice amounts. snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather than every normalized request row. Later refreshes validate the previous line boundary and fold only newly appended complete rows. Concurrent callers share the same refresh. Range and surface predicates -are applied to the complete aggregate, so the former read-byte window and parsed-row cap cannot omit +are applied to the readable-row aggregate, so the former read-byte window and parsed-row cap cannot omit an earlier file prefix from 7-day, 30-day, or all-history totals. `managementUsageMaxReadBytes` remains accepted for compatibility with bounded legacy readers, but changing it no longer expands or reduces the history summarized by this endpoint. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 4600b51ace..a40ab67ec4 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Записи карантина можно восстановить со страницы Storage (JSONL + threads). Активные сессии только для чтения. Очистка и восстановление отклоняются, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). На Windows с бэкендом планировщика заданий дашборд отказывает и просит выполнить `ocx stop`: обёртка может перезапустить прокси после завершения задачи, и проверить это окно перезапуска до восстановления клиентской конфигурации способен только stop, работающий вне прокси. При отказе ничего не изменяется. | +Страницы использования, дашборда, провайдеров, каталога провайдеров и API-ключей предупреждают об исключённых записях, даже если читаемых строк нет. Счётчики, даты и рейтинги основаны только на читаемых записях. Сохранение порядка моделей по частоте использования отклоняется при неполной истории: выберите другой порядок или восстановите историю перед повтором. + ### Фильтрация запросов Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи. 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 eadeb03d61..3cb310a90a 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -79,6 +79,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +Если часть записей нельзя учесть, человекочитаемый вывод показывает предупреждение, даже если нет читаемых строк. Отображаемые итоги учитывают только читаемые записи. Если фильтр не находит читаемых совпадений, вместо строк итогов выводятся предупреждение и подсказки; пропущенные записи могут содержать совпадения. `--json` сохраняет диагностику `usageIncomplete` и её причину из ответа. + ### `ocx debug ` Прочитать или изменить runtime debug-override'ы через management API работающего прокси. diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index e91a12222a..978744bbb0 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -156,6 +156,8 @@ GUI-сессия в стиле loopback не выпускается. | `POST /api/storage/cleanup-policy/run` | Запустить manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Тестовый policy-stream hook | 404 `not_found`, когда недоступен | +Если строка превышает существующий лимит размера парсера, `GET /api/usage` и `GET /api/keys` сохраняют агрегаты читаемых строк и добавляют в ответ `usageIncomplete: true` и `usageIncompleteReason: "oversized_rows"`. Диагностика сохраняется в кеше и при инкрементальных добавлениях, в том числе для пустых результатов и отсутствующих совпадений; при перестроении она вычисляется заново. Идентификаторы провайдеров, моделей и API-ключей не сокращаются. Отсутствие флага не доказывает корректность всех строк. Это отдельный сигнал от `historyTruncated`, `entriesTruncated` и покрытия измерений токенов. + Строки в `models`, `providers` и `days[].models` также содержат `cacheHitRate` — долю входных токенов, полученных из кэша промптов провайдера и ограниченную диапазоном `[0, 1]`. Значение равно `null`, а не `0`, если провайдер не передал телеметрию кэша или в строке нет входных токенов: отсутствие diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index ebdf946ffd..cfc04ca1df 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -60,6 +60,8 @@ kararıdır. | **Depolama** | Salt okunur CODEX_HOME disk dökümü (oturumlar, arşivler, DB'ler, ekler). İsteğe bağlı arşivlenmiş temizleme: en eski %N'yi önizleyin, ardından `CODEX_HOME/.trash` konumuna karantinaya alın (varsayılan) veya açık bir onay kutusu arkasında kalıcı olarak silin. **Otomatik temizleme politikası** isteğe bağlıdır ve **varsayılan olarak KAPALIDIR** (`storageCleanupPolicy.enabled`); Depolama sayfasında eşik/hedef/zamanlama/mod yapılandırın veya **Şimdi çalıştır (Run now)**'ı tetikleyin. Karantinaya alınan girdiler Depolama sayfasından geri yüklenebilir (JSONL + iş parçacıkları). Aktif oturumlar salt okunur kalır. Codex en yeni/aktif `state_*.sqlite` dosyasını kilitli tuttuğu sürece temizleme ve geri yükleme reddedilir. | | **Durdur** | Proxy'yi ve kurulu arka plan servisini zarif bir şekilde durdurun, yerel Codex'i geri yükleyin ve çıkın (`POST /api/stop`). Windows'ta Görev Zamanlayıcı arka ucunda panel reddeder ve `ocx stop` çalıştırmanızı ister: görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir ve bu yeniden başlatma penceresini istemci yapılandırmanız geri yüklenmeden önce yalnızca proxy dışında çalışan bir stop doğrulayabilir. Reddedildiğinde hiçbir şey değiştirilmez. | +Kullanım, panel, sağlayıcı çalışma alanı, sağlayıcı kataloğu ve API anahtarı görünümleri, okunabilir kayıt kalmasa bile dışlanan kayıtlar için uyarı gösterir. Sayılar, tarihler ve kullanım sıralamaları yalnızca okunabilir kayıtlara dayanır. Geçmiş eksikse en çok kullanılan model sırası kaydedilmez; başka bir sıra seçin veya yeniden denemeden önce geçmişi onarın. + ### İstek günlüklerini filtreleme Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner. 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 9f38ae6495..f4564d3bba 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -111,6 +111,8 @@ verilerini inceleyin. Doğrudan takma adlar şunlardır: ocx observe usage --range 30d --json ``` +Bazı kullanım kayıtları dahil edilemiyorsa okunabilir çıktı, okunabilir satır olmadığında da uyarı gösterir. Gösterilen toplamlar yalnızca okunabilir kayıtları yansıtır. Filtreyle eşleşen okunabilir kayıt yoksa toplam satırları yerine uyarı ve yönlendirme gösterilir; atlanan kayıtlar eşleşme içerebilir. `--json`, yanıttaki `usageIncomplete` tanısını ve nedenini korur. + ### `ocx debug ` Çalışan proxy'nin yönetim API'si aracılığıyla çalışma zamanı hata ayıklama diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index bfdd83dcd9..354310f01e 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -163,6 +163,8 @@ Hedef stratejileri, soğuma süreleri, takma adlar ve yönlendirme hataları iç | `POST /api/storage/cleanup-policy/run` | Manuel bir temizleme politikası çalıştırması başlatın | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Yalnızca test amaçlı politika akış kancası | Kullanılamadığında 404 `not_found` | +Bir satır mevcut ayrıştırıcı boyut sınırını aşarsa `GET /api/usage` ve `GET /api/keys` okunabilir satır toplamlarını korur ve yanıt düzeyinde `usageIncomplete: true` ile `usageIncompleteReason: "oversized_rows"` ekler. Bu tanı, boş veya eşleşmeyen sonuçlar dahil önbellekte ve artımlı eklemelerde korunur; yeniden oluşturma sırasında tekrar hesaplanır. Sağlayıcı, model ve API anahtarı kimlikleri kısaltılmaz. Bayrağın bulunmaması tüm kayıtların geçerli olduğunu kanıtlamaz. Bu bilgi `historyTruncated`, `entriesTruncated` ve token ölçüm kapsamından ayrıdır. + `GET /api/usage?range=30d&surface=codex` için `accounts`, gözlemlenen her Codex havuz etiketi için bir satır içerir. Her satır `accountLogLabel`, belirteç toplamları, `usageCoverageRatio` ve geçerli olarak yapılandırılmış görüntüleme diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index dbb6383120..6f99e516c1 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -47,6 +47,8 @@ bun run dev:gui | **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。可在 Storage 页从隔离区恢复(JSONL + 线程)。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理与恢复。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。在使用任务计划程序后端的 Windows 上,仪表板会拒绝并提示改用 `ocx stop`:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口。被拒绝时不会做任何更改。 | +用量、仪表板、供应商工作区、供应商目录和 API 密钥页面会提示部分记录被排除,即使没有可读取的记录。次数、日期和使用排名仅反映可读取的记录。历史不完整时,无法保存模型的最常用排序;请选择其他排序或修复历史后重试。 + ### 筛选请求日志 Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。 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 f141dee649..2153f895c5 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 @@ -75,6 +75,8 @@ API key,且绝不会回退到 native alias。启用这组兼容选项前,请 ocx observe usage --range 30d --json ``` +部分用量记录无法计入时,人类可读输出会显示警告,即使没有可读取的记录也是如此。显示的总数仅反映可读取的记录。如果筛选条件没有匹配到可读取的记录,输出将显示警告和提示,而不显示总数行;被跳过的记录可能包含匹配项。`--json` 原样保留响应中的 `usageIncomplete` 诊断及原因。 + ### `ocx debug ` 通过正在运行的代理的管理 API 读取或更改运行时调试覆盖项。 diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 84afe751a3..480accdf33 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -138,6 +138,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` | 启动一次手动清理策略运行 | 409 `already_running`;500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 仅测试用的策略流钩子 | 不可用时返回 404 `not_found` | +如果某行超过现有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 会保留可读取行的汇总,并在响应级别添加 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。缓存和增量追加会保留该诊断,即使结果为空或没有筛选匹配;重建时会重新计算。不会缩短供应商、模型或 API 密钥标识来容纳该行。没有此标记不代表所有记录均有效。它与 `historyTruncated`、`entriesTruncated` 及 token 测量覆盖率相互独立。 + `models`、`providers` 和 `days[].models` 中的记录也带有 `cacheHitRate`:它表示由提供方提示缓存提供的输入 token 比例,并限制在 `[0, 1]` 范围内。当提供方未报告缓存遥测数据或该记录没有输入 token 时,其值为 `null`,绝不会是 `0`,因为“没有缓存数据”与“实际命中率为 0%”是不同的事实,将两者显示为相同结果的图表会产生误导。 :::caution diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 358624f2f9..29b324830f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -51,6 +51,8 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 | **Usage / Debug** | 檢視 token usage 覆蓋率與趨勢,或啟用可選的 provider transport 和 usage 提取診斷。 | | **Stop** | 優雅地停止代理和已安裝的後臺服務,恢復原生 Codex 並退出(`POST /api/stop`)。在使用工作排程器後端的 Windows 上,儀表板會拒絕並提示改用 `ocx stop`:工作結束後包裝程序仍可能重新啟動 Proxy,只有執行在 Proxy 之外的 stop 才能在還原用戶端設定前確認這個重啟視窗。被拒絕時不會做任何變更。 | +用量、儀表板、供應商工作區、供應商目錄和 API 金鑰頁面會提示部分記錄被排除,即使沒有可讀取的記錄。次數、日期和使用排名僅反映可讀取的記錄。歷史不完整時,無法儲存模型的最常用排序;請選擇其他排序或修復歷史後重試。 + ### 篩選請求日誌 Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。 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 d9585b2520..6a724b7d16 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 @@ -70,6 +70,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +部分用量記錄無法納入時,人類可讀輸出會顯示警告,即使沒有可讀取的記錄也是如此。顯示的總數僅反映可讀取的記錄。如果篩選條件沒有符合的可讀取記錄,輸出將顯示警告和提示,而不顯示總數列;被略過的記錄可能包含符合項目。`--json` 原樣保留回應中的 `usageIncomplete` 診斷及原因。 + ### `ocx debug ` 透過執行中代理的管理 API 讀取或變更執行階段除錯覆寫。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 8ea6cd79dd..4342a698e0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -134,6 +134,8 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `POST /api/storage/cleanup-policy/run` | 啟動手動清理政策執行 | 409 `already_running`;500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 僅測試的政策串流 hook | 不可用時 404 `not_found` | +如果某行超過現有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 會保留可讀取行的彙總,並在回應層級加入 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。快取和增量附加會保留此診斷,即使結果為空或沒有篩選符合項目;重建時會重新計算。不會縮短供應商、模型或 API 金鑰識別碼來容納該行。沒有此標記不代表所有記錄均有效。它與 `historyTruncated`、`entriesTruncated` 及 token 測量覆蓋率相互獨立。 + `models`、`providers` 及 `days[].models` 中的列也帶有 `cacheHitRate`:表示由供應商提示快取提供的輸入權杖比例,並限制在 `[0, 1]`。當供應商未回報快取遙測資料,或該列沒有輸入權杖時,其值為 `null`,絕不會是 `0`;因為「沒有快取資料」與「確實為 0% 的命中率」是不同事實,若圖表將兩者呈現為相同狀態,便會造成誤導。 :::caution diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 09f4fcb1d6..3ff9c631ca 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,4 +1,5 @@ -import { usageSummary30dResourceKey } from "../usage-summary-resource"; +import { usageSummary30dResourceKey, type UsageReadMetadata } from "../usage-summary-resource"; +import { UsageIncompleteNotice } from "./usage-incomplete-notice"; import { useEffect, useMemo, useReducer, useRef } from "react"; import { IconX } from "../icons"; import { useT } from "../i18n/shared"; @@ -89,7 +90,7 @@ export default function AddProviderModal({ async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); if (!res.ok) throw new Error(String(res.status)); - return await res.json() as { providers?: Array<{ provider: string; requests: number }> }; + return await res.json() as UsageReadMetadata & { providers?: Array<{ provider: string; requests: number }> }; }, { deadlineMs: 60_000 }, // shared usage-summary key: all four subscribers raise the deadline together ); @@ -250,6 +251,7 @@ export default function AddProviderModal({ + {!preset && } {!preset ? ( + {keysLoading ? (
) : keys.length === 0 ? ( @@ -83,7 +88,7 @@ export default function ApiKeysListPanel({ ? "—" : k.usage.lastUsedAt ? formatCreatedDate(k.usage.lastUsedAt, localeTag) - : t("api.attribution.neverUsed")} + : t(usageMetadata?.usageIncomplete ? "api.attribution.noRecordedUse" : "api.attribution.neverUsed")} ))} diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx index dba0e23591..5b670428e3 100644 --- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -24,6 +24,8 @@ import { } from "../../pages/api-keys-panels"; import ClientConfigPanel from "./ClientConfigPanel"; import ApiKeysListPanel from "./ApiKeysListPanel"; +import type { UsageReadMetadata } from "../../usage-summary-resource"; +import { UsageIncompleteNotice } from "../usage-incomplete-notice"; export interface ApiKeysWorkspaceProps { keys: ApiKeyEntry[]; @@ -33,6 +35,7 @@ export interface ApiKeysWorkspaceProps { * statement from a key whose counters read zero. */ attributionSince?: string; historyTruncated?: boolean; + usageMetadata?: UsageReadMetadata; authMatrix: ApiAuthMatrixRow[]; keysLoading: boolean; keysLoadFailed: boolean; @@ -80,6 +83,7 @@ export default function ApiKeysWorkspace({ apiBase, attributionSince, historyTruncated, + usageMetadata, authMatrix, keysLoading, keysLoadFailed, @@ -397,6 +401,7 @@ export default function ApiKeysWorkspace({

{t("api.attribution.title")}

+ {/* Branch on the DATASET field, not on `usage`: a key with zero requests under a live dataset really was used zero times, which is not the same as having nothing to attribute. */} @@ -411,17 +416,17 @@ export default function ApiKeysWorkspace({
{selected.usage.requests7d.toLocaleString(localeTag)}
-
{historyTruncated ? t("api.attribution.totalRequestsAvailable") : t("api.attribution.totalRequests")}
+
{historyTruncated || usageMetadata?.usageIncomplete ? t("api.attribution.totalRequestsAvailable") : t("api.attribution.totalRequests")}
{selected.usage.totalRequests.toLocaleString(localeTag)}
{t("api.attribution.lastUsed")}
{selected.usage.lastUsedAt ? formatCreatedDate(selected.usage.lastUsedAt, localeTag) - : t("api.attribution.neverUsed")}
+ : t(usageMetadata?.usageIncomplete ? "api.attribution.noRecordedUse" : "api.attribution.neverUsed")}
-
{historyTruncated ? t("api.attribution.sinceAvailable") : t("api.attribution.since")}
+
{historyTruncated || usageMetadata?.usageIncomplete ? t("api.attribution.sinceAvailable") : t("api.attribution.since")}
{formatCreatedDate(attributionSince, localeTag)}
@@ -465,6 +470,7 @@ export default function ApiKeysWorkspace({ keysLoading={keysLoading} keysLoadFailed={keysLoadFailed} attributionSince={attributionSince} + usageMetadata={usageMetadata} localeTag={localeTag} busy={mutationPending} onSelect={id => { diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 73f0aad616..e478656baa 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -7,7 +7,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useKeyedClientResource } from "../../client-resource"; import { createBoundedFetch } from "../../bounded-fetch"; -import { usageSummary30dResourceKey } from "../../usage-summary-resource"; +import { readUsageMetadata, usageSummary30dResourceKey, type UsageReadMetadata } from "../../usage-summary-resource"; +import { UsageIncompleteNotice } from "../usage-incomplete-notice"; import { useT } from "../../i18n/shared"; import { IconFilter, IconSearch, IconBoxes, IconGlobe, IconLock, IconKey, IconTrash } from "../../icons"; import { @@ -150,6 +151,9 @@ export default function ProviderWorkspaceShell({ const [modelsLoadFailed, setModelsLoadFailed] = useState(false); const quotasCacheKey = `ocx.providers.quotas.v1:${apiBase}`; const usageCacheKey = `ocx.providers.usage.v2:${apiBase}`; + const [usageMetadata, setUsageMetadata] = useState(() => ( + readUsageMetadata(readSessionListCache(usageCacheKey)) + )); const [usageTotals, setUsageTotals] = useState>(() => ( readSessionListCache<{ totals: Record }>(usageCacheKey)?.totals ?? {} )); @@ -235,7 +239,9 @@ export default function ProviderWorkspaceShell({ setUsageTotals(byProvider); const byProviderModels = buildProviderModelUsage(data.models ?? [], byProvider); setUsageModels(byProviderModels); - writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels }); + const metadata = readUsageMetadata(data); + setUsageMetadata(metadata); + writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels, ...metadata }); setUsageLoading(false); }, 0); return () => { cancelled = true; window.clearTimeout(timeout); }; @@ -561,6 +567,7 @@ export default function ProviderWorkspaceShell({
+ {!jsonEditor?.open && } {jsonEditor?.open ? ( {t("usage.incomplete")} + : null; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 868e2a2855..ea0e2d3a8c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,9 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Anzahlen, Datumsangaben und Ranglisten beruhen nur auf lesbaren Datensätzen.", + "models.pickerOrder.usageIncomplete": "Die Reihenfolge nach Nutzung kann wegen unvollständiger Nutzungsdaten nicht gespeichert werden. Wählen Sie eine andere Reihenfolge oder reparieren Sie zuerst den Verlauf.", + "api.attribution.noRecordedUse": "Keine Nutzung in lesbaren Datensätzen", "models.pickerOrder.label": "Modellreihenfolge", "models.pickerOrder.default": "Standard", "models.pickerOrder.alphabetical": "A–Z nach Modell", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index dfa9ad90e9..ce9003e4c5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,9 @@ * `{var}` are plain interpolations. */ export const en = { + "usage.incomplete": "Some usage records could not be included. Counts, dates, and rankings reflect readable records only.", + "models.pickerOrder.usageIncomplete": "Cannot save most-used order because usage history is incomplete. Choose another order or repair the history first.", + "api.attribution.noRecordedUse": "No use in readable records", "models.pickerOrder.label": "Picker order", "models.pickerOrder.default": "Default", "models.pickerOrder.alphabetical": "A–Z by model", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2cf33a7a96..bec627f086 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les totaux, dates et classements reposent uniquement sur les enregistrements lisibles.", + "models.pickerOrder.usageIncomplete": "Impossible d’enregistrer l’ordre par utilisation : l’historique est incomplet. Choisissez un autre ordre ou réparez d’abord l’historique.", + "api.attribution.noRecordedUse": "Aucune utilisation dans les enregistrements lisibles", "models.pickerOrder.label": "Ordre des modèles", "models.pickerOrder.default": "Par défaut", "models.pickerOrder.alphabetical": "A–Z par modèle", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0f51bbb2d9..169579a211 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "usage.incomplete": "一部の使用履歴を集計できませんでした。回数、日付、順位は読み取れる記録のみを反映しています。", + "models.pickerOrder.usageIncomplete": "使用履歴が不完全なため、使用回数順を保存できません。別の順序を選ぶか、履歴を修復してください。", + "api.attribution.noRecordedUse": "読み取れる記録に使用履歴なし", "models.pickerOrder.label": "モデル選択順", "models.pickerOrder.default": "デフォルト", "models.pickerOrder.alphabetical": "モデル名のA–Z順", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1db3ad6a32..68fb26b955 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "usage.incomplete": "일부 사용량 기록을 집계하지 못했습니다. 횟수, 날짜, 순위는 읽을 수 있는 기록만 반영합니다.", + "models.pickerOrder.usageIncomplete": "사용량 이력이 불완전해 많이 사용한 순서를 저장할 수 없습니다. 다른 순서를 선택하거나 이력을 복구하세요.", + "api.attribution.noRecordedUse": "읽을 수 있는 기록에 사용 내역 없음", "models.pickerOrder.label": "모델 선택 순서", "models.pickerOrder.default": "기본값", "models.pickerOrder.alphabetical": "모델 이름순", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index fc6f61c152..a182aee2e3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "usage.incomplete": "Часть записей об использовании не удалось учесть. Счётчики, даты и рейтинги основаны только на читаемых записях.", + "models.pickerOrder.usageIncomplete": "Нельзя сохранить порядок по частоте использования: история неполная. Выберите другой порядок или сначала восстановите историю.", + "api.attribution.noRecordedUse": "В читаемых записях использование не найдено", "models.pickerOrder.label": "Порядок моделей", "models.pickerOrder.default": "По умолчанию", "models.pickerOrder.alphabetical": "По имени A–Z", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ee6ae93adf..efe35aa483 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,9 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "usage.incomplete": "Bazı kullanım kayıtları dahil edilemedi. Sayılar, tarihler ve sıralamalar yalnızca okunabilir kayıtlara dayanır.", + "models.pickerOrder.usageIncomplete": "Kullanım geçmişi eksik olduğundan en çok kullanılan sıralaması kaydedilemiyor. Başka bir sıralama seçin veya önce geçmişi onarın.", + "api.attribution.noRecordedUse": "Okunabilir kayıtlarda kullanım yok", "models.pickerOrder.label": "Model sırası", "models.pickerOrder.default": "Varsayılan", "models.pickerOrder.alphabetical": "Model adına göre A–Z", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 8f38f5c0f4..1457455cc4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,9 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "usage.incomplete": "部分用量記錄無法納入。次數、日期和排名僅反映可讀取的記錄。", + "models.pickerOrder.usageIncomplete": "用量歷史不完整,無法儲存最常用排序。請選擇其他排序或先修復歷史記錄。", + "api.attribution.noRecordedUse": "可讀取的記錄中沒有使用記錄", "models.pickerOrder.label": "模型選擇順序", "models.pickerOrder.default": "預設", "models.pickerOrder.alphabetical": "依模型名稱 A–Z", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1cfd82623c..c88aff94f1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "usage.incomplete": "部分用量记录无法计入。次数、日期和排名仅反映可读取的记录。", + "models.pickerOrder.usageIncomplete": "用量历史不完整,无法保存最常用排序。请选择其他排序或先修复历史记录。", + "api.attribution.noRecordedUse": "可读取的记录中没有使用记录", "models.pickerOrder.label": "模型选择顺序", "models.pickerOrder.default": "默认", "models.pickerOrder.alphabetical": "按模型名 A–Z", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index d10ff3c33d..ccfedc11ce 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { Notice } from "../ui"; +import { readUsageMetadata, type UsageReadMetadata } from "../usage-summary-resource"; import { useI18n, LOCALES } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; @@ -27,7 +28,7 @@ import { type ModelTests, } from "./api-keys-utils"; -interface KeysResponse { +interface KeysResponse extends UsageReadMetadata { // `usage` is optional on the wire only so a malformed payload lands in // fetchKeys' validator rather than at the type boundary. A row without it is // rejected, not defaulted: zeroes would assert "never used" about data we @@ -53,7 +54,7 @@ interface StartRotationResponse extends CreateKeyResponse { rotationId?: unknown; } -type CachedKeysShape = { +type CachedKeysShape = UsageReadMetadata & { keys: ApiKeyEntry[]; endpoints: ApiEndpointInfo; claudeCodeEnabled: boolean; @@ -159,6 +160,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a claudeCodeEnabled: data.claudeCodeEnabled !== false, ...(data.attributionSince ? { attributionSince: data.attributionSince } : {}), ...(data.historyTruncated === true ? { historyTruncated: true } : {}), + ...readUsageMetadata(data), authMatrix: data.authMatrix, }; // Prefixes only — never the secret key material. @@ -500,6 +502,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a apiBase={apiBase} attributionSince={attributionSince} historyTruncated={historyTruncated} + usageMetadata={readUsageMetadata(keysData)} authMatrix={authMatrix} keysLoading={false} keysLoadFailed={keysState.showError} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 0150cd29b0..3a4fb03db6 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1920,8 +1920,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; if (mode === "most-used") { const response = await fetch(`${apiBase}/api/usage?range=all&surface=all`, { signal: bounded.signal }); if (!current()) return; - const payload = await readJsonOrThrow<{ models?: unknown }>(response, t("models.pickerOrder.usageFailed")); + const payload = await readJsonOrThrow<{ models?: unknown; usageIncomplete?: unknown }>(response, t("models.pickerOrder.usageFailed")); if (!current()) return; + if (payload?.usageIncomplete === true) throw new Error(t("models.pickerOrder.usageIncomplete")); if (!isModelPickerUsage(payload?.models)) throw new Error(t("models.pickerOrder.usageFailed")); usage = payload.models; } diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 96f0f1db0c..900ab6eac0 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; +import type { UsageReadMetadata } from "../usage-summary-resource"; +import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; @@ -79,7 +81,7 @@ interface UsageProvider { class UsageWindowMismatchError extends Error {} -interface UsageResponse { +interface UsageResponse extends UsageReadMetadata { range: Range; surface: UsageSurface; since: number | null; @@ -989,6 +991,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas ) : ( <> {state.showError && {t(connected ? "usage.hubOffline" : "usage.loadError")}} + {data?.historyTruncated && ( // Naming the loaded window is the point: without it, `30d` and "Available history" // look identical on a busy installation even though both may cover far less than diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 44b3f8ac3c..c956ca1f4b 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -4,6 +4,7 @@ import { formatTokens } from "../format-tokens"; import { formatUptime } from "../formatUptime"; import { navigateHash } from "../hash-routing"; import type { useDashboardData } from "./use-dashboard-data"; +import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; type Dash = ReturnType; @@ -113,6 +114,7 @@ export function DashboardOverviewHead({ + {projectConfigWarnings.length > 0 && (
diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 029e39b2da..ae24f861a2 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -123,7 +123,7 @@ export interface SidecarPatch { }; } export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } -export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } +export type UsageSummary30d = import("../usage-summary-resource").UsageReadMetadata & { summary: { requests: number; totalTokens: number; coverageRatio: number } }; export type UpdateChannel = "latest" | "preview"; export type Installer = "npm" | "bun" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; diff --git a/gui/src/usage-summary-resource.ts b/gui/src/usage-summary-resource.ts index 568860230c..e427258194 100644 --- a/gui/src/usage-summary-resource.ts +++ b/gui/src/usage-summary-resource.ts @@ -1,3 +1,18 @@ +/** Positive diagnostics only: an older response without the flag proves no completeness. */ +export interface UsageReadMetadata { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; +} + +export function readUsageMetadata(value: unknown): UsageReadMetadata { + if (!value || typeof value !== "object" || !("usageIncomplete" in value) || value.usageIncomplete !== true) return {}; + return { + usageIncomplete: true, + ...("usageIncompleteReason" in value && value.usageIncompleteReason === "oversized_rows" + ? { usageIncompleteReason: "oversized_rows" as const } : {}), + }; +} + export function usageSummary30dResourceKey(apiBase: string, surface: "all" | "codex" = "all"): string { return surface === "codex" ? ["usage-summary-30d", apiBase, "codex"].join(":") diff --git a/gui/tests/apikeys-workspace.test.tsx b/gui/tests/apikeys-workspace.test.tsx index 56ffeacaf1..4695e09430 100644 --- a/gui/tests/apikeys-workspace.test.tsx +++ b/gui/tests/apikeys-workspace.test.tsx @@ -135,6 +135,26 @@ function keyButton(container: HTMLElement, name: string): HTMLButtonElement { .find(el => el.textContent === name)!; } +test("incomplete usage qualifies key list and detail without asserting never used", async () => { + const { root, container, rerender } = await mountWorkspace({ + usageMetadata: { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }, + }); + try { + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("No use in readable records"); + await act(async () => { keyButton(container, "beta").click(); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("Requests in available history"); + expect(container.textContent).toContain("No use in readable records"); + await rerender({ attributionSince: undefined }); + expect(container.textContent).toContain("Some usage records could not be included"); + await rerender({ keys: [] }); + expect(container.textContent).toContain("Some usage records could not be included"); + await rerender({ usageMetadata: {} }); + expect(container.textContent).not.toContain("Some usage records could not be included"); + } finally { await act(async () => { root.unmount(); }); } +}); + test("workspace overview navigation preserves pending secret and resets delete confirm", async () => { const { root, container } = await mountWorkspace({ newKey: FULL_SECRET, diff --git a/gui/tests/model-picker-order-editor.test.tsx b/gui/tests/model-picker-order-editor.test.tsx index ea9f808cb7..0789264470 100644 --- a/gui/tests/model-picker-order-editor.test.tsx +++ b/gui/tests/model-picker-order-editor.test.tsx @@ -388,3 +388,39 @@ test("Models pins cache-inferred Custom across late parent GET publication, then await act(async () => { root!.render(); }); expect(host.querySelector(".picker-order-editor")).toBeNull(); }); + +test("Models refuses an incomplete most-used snapshot before PUT and accepts a later readable snapshot", async () => { + const modelRows = ids.map(row => ({ ...row, disabled: false })); + const catalog = { models: modelRows, providers: [{ name: "p" }], selectedModels: {}, disabled: [], contextCaps: {}, contextCapValue: 350_000 }; + const settings = { ...initial(), pickerOrderMode: "most-used" }; + win.sessionStorage.setItem("ocx.models.catalog.v1:/a", JSON.stringify(catalog)); + win.sessionStorage.setItem("ocx.models.catalog.v1:/a:picker-order", JSON.stringify(settings)); + const deferredFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.includes("/api/usage?") || init?.method === "PUT") return deferredFetch(input, init); + const payload = path.endsWith("/api/subagent-models") ? settings + : path.endsWith("/api/models") ? modelRows + : path.endsWith("/api/providers") ? catalog.providers + : path.endsWith("/api/provider-context-caps") ? { caps: {} } + : path.endsWith("/api/selected-models") ? { selected: {} } + : path.endsWith("/api/aliases") ? { providers: {}, models: {}, defaults: { global: false, providers: {} } } + : undefined; + return Promise.resolve(payload === undefined ? new Response(null, { status: 404 }) : Response.json(payload)); + } }); + const { createRoot } = await import("react-dom/client"); + await act(async () => { root = createRoot(host); root.render(); }); + await click("Apply order"); + expect(requests[0]?.url).toBe("/a/api/usage?range=all&surface=all"); + const models = [{ provider: "p", model: "b", requests: 3 }]; + await reply(0, { models, usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + expect(host.textContent).toContain("Cannot save most-used order because usage history is incomplete"); + expect(requests.map(r => r.method)).toEqual(["GET"]); + expect(button("Apply order").disabled).toBe(false); + await click("Apply order"); + await reply(1, { models }); + expect(requests[2]?.url).toBe("/a/api/subagent-models"); + expect(requests[2]?.method).toBe("PUT"); + expect(requests[2]?.body).toEqual({ pickerOrder: ["p/b", "p/a", "p/c", "p/f"], pickerOrderMode: "most-used" }); + await reply(2, { ok: true, pickerOrder: ["p/b", "p/a", "p/c", "p/f"], pickerOrderMode: "most-used" }); +}); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index ea4e98e51c..78a3e9b165 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -85,6 +85,25 @@ async function respond(index: number, marker: string, date?: string) { await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); }); } +test("incomplete usage notice survives held cache and remains visible with no readable rows", async () => { + await mount(); + const partial = { ...report(requests[0], "readable-model"), usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; + await act(async () => { requests[0].resolve(Response.json(partial)); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("readable-model"); + expect(sessionEntries().some(([, value]) => value?.includes('"usageIncomplete":true'))).toBe(true); + await act(async () => { root!.unmount(); }); + root = undefined; + clearClientResourceStoresForTests(); + await mount(); + expect(container.textContent).toContain("Some usage records could not be included"); + await act(async () => { requests[1].resolve(Response.json({ ...partial, + summary: { ...partial.summary, requests: 0, totalTokens: 0 }, days: [], models: [], + })); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).not.toContain("readable-model"); +}); + const toggle = () => container.querySelector(".usage-range-toggle")!; const form = () => container.querySelector('form[aria-label="Custom date range"]')!; const startInput = () => form().querySelectorAll('input[type="datetime-local"]')[0]; diff --git a/gui/tests/usage-incomplete-consumers.test.tsx b/gui/tests/usage-incomplete-consumers.test.tsx new file mode 100644 index 0000000000..5fdb4b01ae --- /dev/null +++ b/gui/tests/usage-incomplete-consumers.test.tsx @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { readSessionListCache } from "../src/session-list-cache"; +import { readUsageMetadata } from "../src/usage-summary-resource"; +import { DashboardOverviewHead } from "../src/pages/dashboard-overview-head"; +import ProviderWorkspaceShell from "../src/components/provider-workspace/ProviderWorkspaceShell"; +import AddProviderModal from "../src/components/AddProviderModal"; +import ApiKeys from "../src/pages/ApiKeys"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Map; +let win: Window, host: HTMLElement, root: Root | null; +let usageBody: Record, keysBody: Record; +let hold = false; +const partial = { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; +const warning = "Some usage records could not be included"; + +beforeEach(() => { + clearClientResourceStoresForTests(); + previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + win.localStorage.setItem("ocx-lang", "en"); + const values = { document: win.document, window: win, navigator: win.navigator, + localStorage: win.localStorage, sessionStorage: win.sessionStorage, IS_REACT_ACT_ENVIRONMENT: true }; + for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, value }); + root = null; hold = false; + usageBody = { ...partial, providers: [], models: [] }; + keysBody = { ...partial, keys: [], authMatrix: [{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }] }; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + if (hold) return new Promise((_resolve, reject) => { + if (init?.signal?.aborted) reject(new Error("aborted")); + else init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + const path = String(input); + const body = path.includes("/api/usage?") ? usageBody + : path.endsWith("/api/keys") ? keysBody + : path.endsWith("/api/models") ? [] + : path.endsWith("/v1/models") ? { data: [] } + : path.endsWith("/api/selected-models") ? { selected: {}, available: {}, liveModelCounts: {} } + : path.includes("/api/provider-quotas") ? { reports: [] } + : path.endsWith("/api/oauth/providers") ? { providers: [] } + : path.endsWith("/api/provider-presets") ? { providers: [{ id: "test", label: "Test", adapter: "openai-chat", baseUrl: "https://example.test", auth: "key" }] } + : {}; + return Response.json(body); + } }); + host = document.createElement("div"); document.body.append(host); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + clearClientResourceStoresForTests(); + win.close(); + for (const key of globals) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function mount(node: ReactNode) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root ??= createRoot(host); root.render({node}); }); + const deadline = Date.now() + 5_000; + while (!host.textContent?.includes(warning) && Date.now() < deadline) { + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } + expect(host.textContent, "expected usage notice must finish rendering").toContain(warning); +} +async function remountFromCache(node: ReactNode) { + await act(async () => { root!.unmount(); }); root = null; + clearClientResourceStoresForTests(); hold = true; + await mount(node); +} + +test("metadata reader preserves positive diagnostics without inferring completeness or copying fields", () => { + for (const value of [null, {}, { usageIncomplete: false }, { usageIncomplete: "true" }]) expect(readUsageMetadata(value)).toEqual({}); + expect(readUsageMetadata({ ...partial, models: [1], token: "private" })).toEqual(partial); + expect(readUsageMetadata({ usageIncomplete: true, usageIncompleteReason: "future_reason" })).toEqual({ usageIncomplete: true }); +}); + +test("Dashboard warns even when no readable requests remain", async () => { + await mount( {}} switchMaMode={async () => {}} maError={null} />); + expect(host.textContent).toContain(warning); +}); + +test("provider usage projection retains incomplete metadata through a cache-only revisit", async () => { + usageBody = { ...partial, providers: [{ provider: "test", requests: 7, totalTokens: 123 }], models: [] }; + const node = {}} onAddProvider={() => {}} />; + await mount(node); + expect(host.textContent).toContain(warning); + const cached = readSessionListCache>("ocx.providers.usage.v2:/provider"); + expect(cached).toMatchObject({ ...partial, totals: { test: { requests: 7, totalTokens: 123 } } }); + await remountFromCache(node); + expect(host.textContent).toContain(warning); +}); + +test("provider catalog explains that its usage ranking can be incomplete without any readable rows", async () => { + await mount( {}} onAdded={() => {}} />); + expect(host.textContent).toContain(warning); +}); + +test("API key fetch and session cache retain incomplete metadata even without attribution or keys", async () => { + const node = ; + await mount(node); + expect(host.textContent).toContain(warning); + const cached = readSessionListCache>("ocx.apikeys.list.v2:/keys"); + expect(cached).toMatchObject({ ...partial, keys: [] }); + expect(cached).not.toHaveProperty("attributionSince"); + await remountFromCache(node); + expect(host.textContent).toContain(warning); +}); diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts index 3311a781a1..d9c6652e23 100644 --- a/src/cli/usage-report.ts +++ b/src/cli/usage-report.ts @@ -21,6 +21,8 @@ interface CostRow { } interface UsageReportInput { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; range?: string; surface?: string; since?: number | null; @@ -109,11 +111,16 @@ function describeScope(data: UsageReportInput): string { export function formatUsageReport(data: UsageReportInput): string[] { const summary = data.summary ?? {}; const lines: string[] = [describeScope(data), ""]; + if (data.usageIncomplete === true) { + lines.push("WARNING: Usage is incomplete; some records could not be included. Totals and rankings reflect readable records only.", ""); + } if (data.filter && !data.filter.matched) { const what = [data.filter.provider && `provider "${data.filter.provider}"`, data.filter.model && `model "${data.filter.model}"`] .filter(Boolean).join(" and "); - lines.push(`No usage recorded for ${terminalText(what)} in this range.`); + lines.push(data.usageIncomplete === true + ? `No matching readable usage records for ${terminalText(what)} in this range; skipped records may contain matches.` + : `No usage recorded for ${terminalText(what)} in this range.`); lines.push("Check the spelling against `ocx usage --json`, or widen --range."); return lines.map(terminalText); } diff --git a/src/server/management/api-key-usage.ts b/src/server/management/api-key-usage.ts index 6a8664dee2..7314ac2a20 100644 --- a/src/server/management/api-key-usage.ts +++ b/src/server/management/api-key-usage.ts @@ -19,6 +19,9 @@ export type ApiKeyUsage = export interface ApiKeyUsageSnapshot { rollup: Map; historyTruncated?: true; + /** Positive evidence of skipped oversized rows; absence is not a completeness guarantee. */ + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; /** * Earliest row carrying a recognized `admissionKind`. A property of the DATA * SET, not of a key, so it is singular and lives beside the map: it is what @@ -223,9 +226,11 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte const flight = (async (): Promise => { const accumulator = createApiKeyUsageAccumulator(configuredIds, now); const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); - if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); return cacheApiKeyUsageFromRollup( - accumulator.snapshot(), + { + ...accumulator.snapshot(), + ...(scan.oversizedRows > 0 ? { usageIncomplete: true as const, usageIncompleteReason: "oversized_rows" as const } : {}), + }, configuredIds, usageLogIdentityKey(scan.revision), scan.revision?.size ?? 0, diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 72a019d0a2..cd1e3ed3b0 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -225,6 +225,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise k.id), config.managementUsageMaxReadBytes); + const { rollup, attributionSince, historyTruncated, usageIncomplete, usageIncompleteReason } = await readApiKeyUsageRollup(keys.map(k => k.id), config.managementUsageMaxReadBytes); return jsonResponse({ // 8 random hex past the fixed `ocx_data_` literal: enough to tell two keys // apart in a list, with 128 bits of the tail still unrevealed. Masking only @@ -853,6 +853,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // Dataset-level and singular: it describes the usage log, not any one key. ...(attributionSince ? { attributionSince } : {}), ...(historyTruncated ? { historyTruncated: true } : {}), + ...(usageIncomplete ? { usageIncomplete: true, usageIncompleteReason } : {}), authMatrix: AUTH_MATRIX, ...endpoints, }, 200, req, config); diff --git a/src/server/management/usage-aggregate-cache.ts b/src/server/management/usage-aggregate-cache.ts index 51e02781f1..1cabf9b85b 100644 --- a/src/server/management/usage-aggregate-cache.ts +++ b/src/server/management/usage-aggregate-cache.ts @@ -23,6 +23,7 @@ import { interface RetainedUsageAggregate { accumulator: UsageSummaryAccumulator; + usageIncomplete: boolean; revision: UsageLogRevision | null; identityKey: string; revisionKey: string; @@ -35,6 +36,7 @@ interface RetainedUsageAggregate { export interface UsageAggregateResult { accumulator: UsageSummaryAccumulator; + usageIncomplete: boolean; revision: UsageLogRevision | null; processedThroughBytes: number; overlayVersion: number; @@ -75,6 +77,7 @@ function resultFrom( ): UsageAggregateResult { return { accumulator: state.accumulator, + usageIncomplete: state.usageIncomplete, revision: state.revision, processedThroughBytes: state.processedThroughBytes, overlayVersion: state.overlayVersion, @@ -99,6 +102,7 @@ function makeRetainedAggregate( ): RetainedUsageAggregate { return { accumulator, + usageIncomplete: scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), @@ -126,9 +130,6 @@ async function rebuildAggregate(options: UsageAggregateOptions): Promise 0) { - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { lastError = new Error("usage aggregation inputs changed during rebuild"); continue; @@ -138,7 +139,10 @@ async function rebuildAggregate(options: UsageAggregateOptions): Promise candidate.add(entry), }); - if (scan.oversizedRows > 0) { - if (retainedAggregate === state) retainedAggregate = null; - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { if (retainedAggregate === state) retainedAggregate = null; rebuildAfterUnpin = true; @@ -200,6 +200,9 @@ async function appendAggregate( const next: RetainedUsageAggregate = { ...state, accumulator: candidate, + // A partial unterminated row can be scanned again on the next append. + // Preserve a boolean diagnostic rather than double-counting omissions. + usageIncomplete: state.usageIncomplete || scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), @@ -329,7 +332,6 @@ async function rebuildFilteredAggregate( const accumulator = createUsageSummaryAccumulator({ filter, mode: "row-unique", window }); try { const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); - if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { lastError = new Error("usage aggregation inputs changed during filtered scan"); continue; @@ -362,10 +364,6 @@ async function appendFilteredAggregate( expectedProcessedThroughDigest: state.processedThroughDigest, onEntry: entry => candidate.add(entry), }); - if (scan.oversizedRows > 0) { - if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); rebuildAfterUnpin = true; @@ -373,6 +371,7 @@ async function appendFilteredAggregate( const next: RetainedUsageAggregate = { ...state, accumulator: candidate, + usageIncomplete: state.usageIncomplete || scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), diff --git a/src/server/management/usage-summary-cache.ts b/src/server/management/usage-summary-cache.ts index 1e6815c0a2..80f35398a3 100644 --- a/src/server/management/usage-summary-cache.ts +++ b/src/server/management/usage-summary-cache.ts @@ -2,6 +2,8 @@ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../../l import type { UsageSummary } from "../../usage/summary"; export type CachedUsageSummary = UsageSummary & { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; historyTruncated: boolean; truncatedPrefixBytes: number; entriesTruncated: boolean; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index dd98190184..019de2f49d 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -52,3 +52,5 @@ request when a node carries both. Codex's own deferred tool catalog emits exactl so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/catalog.md b/structure/catalog.md index 174e506470..e83c54be42 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -264,3 +264,5 @@ provider wire mapping; unpinned native requests retain their existing pass-throu Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..9aa9059ed9 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,5 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/config.md b/structure/config.md index 48a29a7817..59ef399738 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 270e6c38de..515486a912 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -69,3 +69,5 @@ injects summary generation into a request, and config validation rejects a deliv conflicts with `modelSupportsReasoningSummaries: false` for the same model. > Decision record: [ADR-0045](../decisions/ADR-0045-standalone-images.md) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7c90c2f74d..5f809107f3 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -89,3 +89,5 @@ copies an authoritative catalog context window into `limit.context` and a nonemp reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent instead of falling back to OpenCodex guesses, and the integration does not write the removed `thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/design-methodology.md b/structure/design-methodology.md index bcaf940d72..8e5df2250d 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -36,3 +36,5 @@ surfaces, run through all 3 stages in order. - Design methodology: Product-Personality-Selection (dev-uiux-design §1) - 6 design dials: mood, lightness, density, shape, typography, motion - 7 axes total: design → domain → feature/data/security/ops/cost (derived) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..6b419367ee 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -122,7 +122,7 @@ this document owns is which module holds which area and what invariant that area | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | -| Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | +| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | @@ -408,7 +408,7 @@ An opt-in shadow-call rewrite persists the bounded, redacted original helper mod request content or inferring a helper subtype from timing. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. -The management route streams the complete ledger from its beginning in fixed 1 MiB chunks on a +The management route scans the ledger from its beginning in fixed 1 MiB chunks on a cold rebuild, then retains compact numeric aggregate state and resumes at the last verified LF for ordinary appends. It does not retain the full input or a normalized object for every request, and neither the old byte window nor the parsed-entry cap can discard an earlier prefix before range and @@ -445,6 +445,19 @@ large existing log. The first read is proportional to ledger size; steady-state proportional to newly appended bytes. The Dashboard polls its 30-day usage summary independently once per minute, so usage work cannot delay health/provider/settings state or run every five seconds. +An oversized row is skipped within the existing scanner bound, without shortening provider, +model, or API-key identities. Base and filtered accumulators retain normal rows and a positive +`usageIncomplete` diagnostic. Append publication ORs the previous flag with the new scan; a rebuild +recalculates it. Summary-cache hits and direct or aggregate-seeded API-key rollups preserve the +response-level `usageIncomplete: true` / `usageIncompleteReason: "oversized_rows"` metadata, even +when no normal rows or attributed keys remain. Invalid-row counters are not a sticky diagnostic: +they also include temporarily torn suffixes. Absence of the flag is not a completeness guarantee. +The GUI preserves the metadata in held/session caches and warns in Usage, Dashboard, provider +workspace/catalog, and key list/detail views. Human CLI output warns before no-match early returns; +JSON remains unchanged. Saving a most-used model-order snapshot refuses an incomplete response. +No warning is attached to separate provider quota data. Legacy truncation fields and measurement +coverage keep their existing meanings; file-read/mutation failures still fail closed. + `usage.jsonl` is an append-only runtime ledger. A manual in-place edit earlier than the trailing 64 KiB checkpoint followed by file growth is intentionally outside the incremental detector's contract: validating arbitrary historical rewrites on every refresh would require rereading the diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..18e10554d5 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index dd5f58e345..174dabe7c6 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -132,3 +132,5 @@ Binary detection decodes only the supplied buffer view; malformed UTF-8 can itse so the flag does not identify the peer responsible for corruption. Existing diagnostic files are not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain client responsibilities. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..c9f672d6dc 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index d9155185fc..fdd7d56e47 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -49,3 +49,5 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay - **Authentication:** `Authorization: Bearer ` + `X-XAI-Token-Auth: xai-grok-cli`. No cookies required. - **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes. - **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 — a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..0cb162ee97 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -188,3 +188,5 @@ not an authentication or entitlement decision. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/subagents.md b/structure/subagents.md index d9e6eaf1d2..6c8f68e48a 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -198,3 +198,5 @@ Native Codex advertisements still follow display priority; private guidance rank Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..fa6af1b5fc 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,5 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..111355e033 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,5 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 42d3442e99..c3cd77c524 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -189,3 +189,5 @@ WebSocket clients observe the same canonical lifecycle. `ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket frame rather than always emitting `response.completed`. If the response status is `failed`, a `response.failed` frame is sent; otherwise `response.completed` carries through the original status. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/tests/cli/cli-usage-report.test.ts b/tests/cli/cli-usage-report.test.ts index 5399137a57..0de6bc7950 100644 --- a/tests/cli/cli-usage-report.test.ts +++ b/tests/cli/cli-usage-report.test.ts @@ -84,6 +84,24 @@ describe("formatUsageReport", () => { expect(JSON.parse(out)).toEqual(malformed); }); + test("incomplete usage retains readable totals and warns even with no data or no match", () => { + const partial = { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; + const out = formatUsageReport(payload(partial) as never).join("\n"); + expect(out).toContain("WARNING: Usage is incomplete"); + expect(out).toContain("Requests 1,447"); + expect(out).toContain("grok-4.6"); + expect(out.indexOf("WARNING:")).toBeLessThan(out.indexOf("Requests")); + const empty = payload({ ...partial, summary: { requests: 0, totalTokens: 0 }, providers: [], models: [], days: [] }); + expect(formatUsageReport(empty as never).join("\n")).toContain("WARNING: Usage is incomplete"); + const noMatch = formatUsageReport({ ...empty, + filter: { provider: "nope", model: null, matched: false, comboOverlap: false }, + } as never).join("\n"); + expect(noMatch).toContain("WARNING: Usage is incomplete"); + expect(noMatch).toContain("skipped records may contain matches"); + expect(noMatch).not.toContain("No usage recorded"); + expect(formatUsageReport(payload() as never).join("\n")).not.toContain("WARNING: Usage is incomplete"); + }); + test("prints per-provider and per-model cost, not an item count", () => { const out = formatUsageReport(payload() as never).join("\n"); expect(out).toContain("~$12.3456"); @@ -168,6 +186,17 @@ describe("formatUsageReport", () => { }); describe("ocx usage command", () => { + test("incomplete usage succeeds with human warning and unchanged JSON metadata", async () => { + const body = payload({ usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + const human = await run(["usage"], body); + expect(human.code).toBe(0); + expect(human.out).toContain("WARNING: Usage is incomplete"); + expect(human.out).toContain("grok-4.6"); + const json = await run(["usage", "--json"], body); + expect(json.code).toBe(0); + expect(JSON.parse(json.out)).toEqual(body); + }); + test("duplicate, inline and stray custom-bound arguments do not echo credential-shaped values", async () => { const secret = "sk-" + "a".repeat(40); const errors: string[] = []; diff --git a/tests/server/api-key-attribution.test.ts b/tests/server/api-key-attribution.test.ts index 6be92c4652..469d98f485 100644 --- a/tests/server/api-key-attribution.test.ts +++ b/tests/server/api-key-attribution.test.ts @@ -399,7 +399,7 @@ describe("attribution reaches usage.jsonl", () => { } }); - test("an oversized usage row cannot seed a partial key rollup", async () => { + test.each(["keys-first", "usage-first"])("an oversized usage row preserves an explicitly incomplete key rollup: %s", async order => { saveConfig(remoteConfig()); const now = Date.now(); const oversized = { @@ -428,11 +428,21 @@ describe("attribution reaches usage.jsonl", () => { writeFileSync(usageLogPath(), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); const server = startServer(0); try { + if (order === "usage-first") { + const usage = await fetch(new URL("/api/usage?range=all", server.url), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }).then(res => res.json()); + expect(usage).toMatchObject({ usageIncomplete: true }); + } const payload = await keysGet(server); const keys = payload.keys as Array>; expect((keys.find(key => key.id === "key-one")!.usage as Record).totalRequests).toBe(0); - expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(0); - expect(payload.attributionSince).toBeUndefined(); + expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(1); + expect(payload.attributionSince).toBe(new Date(now).toISOString()); + expect(payload).toMatchObject({ usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + expect(await keysGet(server)).toMatchObject({ + usageIncomplete: true, usageIncompleteReason: "oversized_rows", attributionSince: payload.attributionSince, + }); } finally { await server.stop(true); } diff --git a/tests/server/api-usage.test.ts b/tests/server/api-usage.test.ts index fa5c0ee2e2..9615c622ae 100644 --- a/tests/server/api-usage.test.ts +++ b/tests/server/api-usage.test.ts @@ -868,7 +868,7 @@ describe("GET /api/usage", () => { } }); - test("an oversized row fails closed instead of caching a partial aggregate", async () => { + test("an oversized row preserves normal usage with explicit incomplete cached and filtered results", async () => { const now = Date.now(); const oversized = { requestId: "ocx-oversized", @@ -896,11 +896,18 @@ describe("GET /api/usage", () => { writeFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); const server = startServer(0); try { - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(body.error).toBe("read_failed"); - expect(body.summary.requests).toBe(0); - expect(body.historyTruncated).toBe(false); - expect(getUsageSummaryCacheEntry("all:all")).toBeUndefined(); + for (const query of ["range=all", "range=all", "range=7d", "range=all&model=gpt-5.5"]) { + const response = await fetch(new URL(`/api/usage?${query}`, server.url)); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.error).toBeUndefined(); + expect(body.summary.requests).toBe(1); + expect(body.summary.totalTokens).toBe(2); + expect(body).toMatchObject({ + historyTruncated: false, usageIncomplete: true, usageIncompleteReason: "oversized_rows", + }); + } + expect(getUsageSummaryCacheEntry("all:all")?.summary).toMatchObject({ usageIncomplete: true }); } finally { await server.stop(true); } diff --git a/tests/usage/usage-aggregate-cache.test.ts b/tests/usage/usage-aggregate-cache.test.ts index cdaea2c23b..c2a539bf94 100644 --- a/tests/usage/usage-aggregate-cache.test.ts +++ b/tests/usage/usage-aggregate-cache.test.ts @@ -129,6 +129,28 @@ describe("retained usage aggregate cache", () => { expect(report.summary.unmeteredRequests).toBe(1); }); + test.each(["base", "filtered"])("an oversized unfinished suffix stays incomplete without duplicating rows: %s", async scope => { + const path = join(testDir, "usage.jsonl"); + const read = () => scope === "filtered" ? getFilteredUsageAggregate({ provider: "openai" }) : getUsageAggregate({ now: NOW }); + writeFileSync(path, line("one")); + expect(requests(await read())).toBe(1); + appendFileSync(path, JSON.stringify({ + ...entry("oversized"), padding: "x".repeat(usageLedgerScannerModule.USAGE_LEDGER_MAX_LINE_BYTES), + })); + const unfinished = await read(); + expect(unfinished).toMatchObject({ usageIncomplete: true }); + expect(requests(unfinished)).toBe(1); + appendFileSync(path, "\n" + line("two")); + const completed = await read(); + expect(completed).toMatchObject({ usageIncomplete: true }); + expect(requests(completed)).toBe(2); + expect(await read()).toMatchObject({ update: "unchanged", usageIncomplete: true }); + writeFileSync(path, line("replacement")); + const rebuilt = await read(); + expect(rebuilt).toMatchObject({ update: "rebuild", usageIncomplete: false }); + expect(requests(rebuilt)).toBe(1); + }); + test("custom cache keys isolate both endpoints and never poison preset aggregates", async () => { const path = join(testDir, "usage.jsonl"); const rows = [NOW - 2_000, NOW - 1_000, NOW].map((timestamp, index) => ({ ...entry(String(index)), timestamp })); @@ -392,7 +414,7 @@ describe("retained usage aggregate cache", () => { } }); - test("an oversized append result never publishes its partially-fed candidate", async () => { + test("an oversized append retains normal rows and its incomplete marker until rebuild", async () => { writeFileSync(join(testDir, "usage.jsonl"), line("one")); const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; let forceOversizedAppend = false; @@ -412,14 +434,21 @@ describe("retained usage aggregate cache", () => { appendFileSync(join(testDir, "usage.jsonl"), line("two")); forceOversizedAppend = true; - await expect(getUsageAggregate({ now: NOW })).rejects.toThrow("oversized row"); + const partial = await getUsageAggregate({ now: NOW }); + expect(partial).toMatchObject({ update: "append", usageIncomplete: true }); + expect(requests(partial)).toBe(2); expect(requests(original)).toBe(1); - expect(usageAggregateRetainedStats().count).toBe(0); + expect(original).toMatchObject({ usageIncomplete: false }); + expect(usageAggregateRetainedStats().count).toBe(1); forceOversizedAppend = false; + const unchanged = await getUsageAggregate({ now: NOW }); + expect(unchanged).toMatchObject({ update: "unchanged", usageIncomplete: true }); + expect(requests(unchanged)).toBe(2); + writeFileSync(join(testDir, "usage.jsonl"), line("replaced")); const rebuilt = await getUsageAggregate({ now: NOW }); - expect(rebuilt.update).toBe("rebuild"); - expect(requests(rebuilt)).toBe(2); + expect(rebuilt).toMatchObject({ update: "rebuild", usageIncomplete: false }); + expect(requests(rebuilt)).toBe(1); expect(scanStarts).toHaveLength(3); expect(scanStarts[0]).toBe(0); expect(scanStarts[1]).toBeGreaterThan(0); From 57a013155897f4cfd317c8417eaac58f1705590f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:57:42 +0900 Subject: [PATCH 014/224] fix(codex): enforce and explain automatic plan exclusions --- .../_plan/260912_accounts/020_eligibility.md | 21 ++++++++++++++++++ .../021_eligibility_delivery.md | 9 ++++++++ .../docs/fr/guides/codex-integration.md | 3 +-- .../content/docs/guides/codex-integration.md | 3 +-- .../docs/ja/guides/codex-integration.md | 3 +-- .../docs/ko/guides/codex-integration.md | 3 +-- .../docs/ru/guides/codex-integration.md | 3 +-- .../docs/tr/guides/codex-integration.md | 3 +-- .../docs/zh-cn/guides/codex-integration.md | 3 +-- .../docs/zh-tw/guides/codex-integration.md | 3 +-- .../components/codex-account-pool-cards.tsx | 10 +++++++-- gui/src/hooks/useCodexAccountPool.ts | 2 ++ 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 ++ .../codex-account-pool-pinned-badge.test.tsx | 19 ++++++++++++++++ src/cli/account-api.ts | 8 +++++++ src/cli/account.ts | 3 +++ src/codex/auth-api.ts | 11 ++++++++++ src/codex/routing.ts | 4 +++- structure/catalog.md | 2 ++ structure/clients/claude-desktop.md | 2 ++ structure/codex-home.md | 2 ++ structure/config.md | 2 ++ structure/design-methodology.md | 2 ++ structure/gui-and-management-api.md | 2 ++ structure/ops/docs-and-release.md | 2 ++ structure/overview.md | 2 ++ structure/providers/openai-tiers.md | 6 +++++ structure/runtime.md | 2 ++ structure/subagents.md | 2 ++ tests/cli/cli-account.test.ts | 12 ++++++++++ .../codex-integration/codex-auth-api.test.ts | 16 ++++++++++++++ .../codex-auth-context.test.ts | 22 +++++++++++++++++++ .../codex-pool-plan-exclusion.test.ts | 18 +++++++++++---- 41 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 devlog/_plan/260912_accounts/020_eligibility.md create mode 100644 devlog/_plan/260912_accounts/021_eligibility_delivery.md diff --git a/devlog/_plan/260912_accounts/020_eligibility.md b/devlog/_plan/260912_accounts/020_eligibility.md new file mode 100644 index 0000000000..734bc42d89 --- /dev/null +++ b/devlog/_plan/260912_accounts/020_eligibility.md @@ -0,0 +1,21 @@ +# Finish automatic plan policy and visible exclusion reasons + +Cycle eligibility; C3 selection policy. Depends only on roadmap, independent dev PR. #4238 already added excludedPlans; do not reimplement its selector. Source: routing.ts:1044-1090 and 1326; explicit fixedAccountId path auth-context.ts:826/915. + +MODIFY `src/codex/routing.ts`: export the existing normalized policy predicate (or move the pure plan calculation into `src/codex/plan.ts` and reuse it). Add the predicate to BOTH configured-account fallback guards at preview :2152 and detailed resolve :2391. Before, an all-excluded pool returns its excluded active row; after, ordinary selection returns null/none. Explicit fixed routes retain existing auth, pause, entitlement checks. Native __main__ remains exempt, avoiding physical auth reads on selection-only paths. + +```diff +- && !isCodexAccountPaused(config, active) ++ && !isCodexAccountPaused(config, active) ++ && !isCodexAccountPlanExcluded(config, active) +``` + +MODIFY `src/codex/auth-api.ts`: poolAccountDto adds optional `selectionExcludedReason: "plan_excluded"`, derived from the SAME predicate and config, never from credential health; include current plan already in DTO. MODIFY `src/cli/account-api.ts` AccountRow/CodexAccountDto mapping and `src/cli/account.ts` statusText to show `not-auto-selected(plan=)`. MODIFY `gui/src/components/codex-account-pool-types.ts`, pool-card badge in `codex-account-pool-cards.tsx`, and all locale catalogs: separate localized reason; do not mutate paused/needsReauth and do not disable explicit routing. Unknown plan and empty policy remain eligible; reauth renewal clears the reason dynamically. + +Field chain: existing excludedPlans config create/save/load → same normalized predicate → account DTO JSON → CLI/GUI optional union → status and badge. No new config field or minimumPlan ordering. Enforcing tier: runtime automatic selection only; explicit fixed account intentionally bypasses this selection rule, not auth; residual unknown-plan and native-main exemptions documented, no hard account-block claim. + +MODIFY existing `tests/codex-integration/codex-pool-plan-exclusion.test.ts`: replace last-account soft fallback test with none/preview none; test normalized plan update and explicit fixed route. Extend account API/CLI and card tests for reason and renewal clearing. Sync ownership docs and providers configuration pages that describe the old soft exception. Retain source attribution of #4238; no recarry of already-landed commits. Local tests/build/typecheck NOT RUN. Hosted CI plus rendered artifact from final tip supplies execution proof. + +Exclusion reason derives from the routing config plan, not a display-only freshly observed plan if persistence failed. This preserves truth between selection and explanation. + +P revalidation on dev d6fb87197a: keep exported existing predicate in routing.ts; pass runtimeConfig into both poolAccountDto calls. Alongside closed selectionExcludedReason include selectionExcludedPlan from the same routing config when excluded, so a display-only fresh WHAM tier cannot mislabel the reason. CLI/card render this policy plan. Exact GUI type owner is hooks/useCodexAccountPool.ts; component type file re-exports it. Docs source is guides/codex-integration.md in every locale; revise all-excluded fallback paragraphs there. Callback D delivered PR4352 and left hosted acceptance open; this cycle is independent from current dev. diff --git a/devlog/_plan/260912_accounts/021_eligibility_delivery.md b/devlog/_plan/260912_accounts/021_eligibility_delivery.md new file mode 100644 index 0000000000..ed4405ac17 --- /dev/null +++ b/devlog/_plan/260912_accounts/021_eligibility_delivery.md @@ -0,0 +1,9 @@ +# Plan exclusion completion + +Built on already-landed #4238, independently from current dev d6fb87197a. Existing normalized predicate is shared with the account DTO; both preview and real automatic fallback reject excluded plans when no eligible account remains. Explicit account-qualified routes retain normal auth, pause and entitlement checks. Native main remains exempt. + +CLI and dashboard display the policy's routing-plan reason separately from credential health and a possibly newer display-only plan. The automatic Set-as-next action is suppressed for excluded rows because pinning does not bypass this policy; explicit account-qualified routes remain available. All nine UI locale catalogs and eight affected integration guides are synchronized. Source ownership docs link the canonical plan-exclusion contract. + +Regression sources cover all-excluded preview/resolve, renewal, explicit route with pause/reauth, API reasons, CLI normalization and card display/renewal. No new test file or dependency. Local suites/build/typecheck/install: NOT RUN. Hosted CI and rendered preview remain pending. Source searches: isCodexAccountPlanExcluded, getPoolAccountPlan, poolAccountDto, CodexAccountEntry, selection guards and excludedPlans docs; reused the existing predicate rather than a parallel policy. + +Prior callback cycle delivered PR4352 and remains pending hosted verification. This is an independent dev PR, with no callback code and no manual chain dependency. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index fa2457395d..44c98858bd 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -399,8 +399,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' C'est une politique de sélection, pas un blocage. Un compte écarté conserve ses identifiants, son historique de quota et son affinité de thread, reste visible dans la liste des comptes et demeure joignable par sélection explicite comme `work/gpt-5.5`. Seule la rotation automatique cesse de le choisir, y compris lorsqu'il est déjà le compte actif ou déjà lié à un thread — l'état exact que laisse un abonnement expiré. -Deux limites volontaires. Le compte Codex principal n'est jamais écarté par forfait, car le routage en mode sélection seule ne lit pas son forfait dans les identifiants natifs protégés ; une règle le couvrant se contredirait. Et lorsqu'il ne reste aucun compte non écarté, le compte écarté répond quand même au lieu d'échouer : mettre tous les comptes en pause reste le moyen d'arrêter complètement le service. Il n'existe pas de `minimumPlan`, car classer les forfaits ChatGPT entre eux exige un ordre total qui n'existe pas ici. - +Le compte Codex principal reste exempt de l’exclusion par forfait : le routage en mode sélection seule ne lit pas ses identifiants natifs protégés. Si tous les comptes éligibles du pool sont exclus, la sélection automatique ne renvoie aucun compte. Les routes désignant explicitement un compte restent disponibles, avec les contrôles de pause, d’authentification et de droits du modèle. La carte et le CLI affichent le forfait exclu séparément de l’état des identifiants. Il n’existe pas de réglage `minimumPlan`, faute d’ordre total des forfaits. ## Restauration de Codex natif `ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index ffc383ac8c..6a52f5b584 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -730,8 +730,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' This is a selection policy, not a block. An excluded account keeps its credential, quota history, and thread affinity, stays visible on the account surface, and is still reachable by explicit account selection such as `work/gpt-5.5`. What changes is that automatic rotation stops choosing it, including when it is already the active account or already bound to a thread — which is the state a lapsed subscription leaves behind. -Two deliberate limits. The main Codex account is never excluded by plan, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would disagree with itself. And when no unexcluded account remains, the excluded one still answers rather than failing closed; pausing every account is still the way to stop serving entirely. There is no `minimumPlan` counterpart, because ranking ChatGPT plans against each other needs a total ordering that does not exist here. - +The main Codex account remains exempt from plan exclusion; selection-only routing does not read its fenced native credential. If every eligible pool account is excluded, automatic selection returns no account. Explicit account-qualified routes remain available and still enforce pause, authentication and model entitlement. The account card and CLI show the excluded routing plan separately from credential health. There is no `minimumPlan` setting because the plan names do not define a total order. ## Restoring native Codex `ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index b1a499dcbc..31a90c96ff 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -265,8 +265,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.5` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。 -意図的な制限が2つあります。メインの Codex アカウントはプランによって除外されません。選択のみのルーティングは保護されたネイティブ資格情報を読まずにプランを伏せるため、メインを対象にすると挙動が食い違うからです。また、除外されていないアカウントが1つも残らない場合は、失敗させずに除外済みのアカウントが応答します。完全に停止したい場合は従来どおり全アカウントを一時停止してください。`minimumPlan` に相当する設定はありません。ChatGPT のプランを順位付けするには、ここに存在しない全順序が必要になるためです。 - +メイン Codex アカウントはプラン除外の対象外です。選択のみのルーティングは保護されたネイティブ資格情報を読みません。利用可能なプールアカウントがすべて除外されると、自動選択はアカウントを返しません。アカウントを明示したルートは引き続き利用でき、一時停止・認証・モデル権限の検査は維持されます。カードと CLI は資格情報の状態とは別に、除外されたルーティングプランを表示します。プランに全順序がないため `minimumPlan` 設定はありません。 ## ネイティブ Codexの復元 `ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ Codex の復元を試みます。OpenCodex は所有を確認できるルーティング設定を削除し、設定ファイルを安全に復元できない場合は未完了として報告します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 0dc6021bd6..604a6c1276 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -276,8 +276,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.5` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다. -의도한 제한이 두 가지 있습니다. 메인 Codex 계정은 플랜으로 제외하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않고 플랜을 감추기 때문에, 메인까지 적용하면 상황에 따라 판정이 어긋납니다. 그리고 제외되지 않은 계정이 하나도 남지 않으면 실패시키지 않고 제외된 계정이 그대로 응답합니다. 완전히 멈추려면 지금처럼 모든 계정을 일시 중지하면 됩니다. `minimumPlan`에 해당하는 설정은 없습니다. ChatGPT 플랜에 순위를 매기려면 여기 존재하지 않는 전순서가 필요합니다. - +메인 Codex 계정에는 플랜 제외 정책을 적용하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않습니다. 풀의 모든 사용 가능한 계정이 제외되면 자동으로 계정을 선택하지 않습니다. 계정을 직접 지정한 경로는 계속 사용할 수 있으며 일시 중지·인증·모델 사용 권한 검사는 그대로 적용됩니다. 계정 카드와 CLI에는 자격 증명 상태와 별도로 제외된 플랜이 표시됩니다. 플랜 사이에 정해진 순위가 없으므로 `minimumPlan` 설정은 없습니다. ## 네이티브 Codex 복원 `ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 Codex 복원을 시도합니다. OpenCodex 소유로 확인된 라우팅 항목을 제거하며, 설정 파일을 안전하게 복구할 수 없으면 미완료로 보고합니다. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 4e5acb9ee6..8bed86f84c 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -392,8 +392,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.5`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки. -Два намеренных ограничения. Основной аккаунт Codex никогда не исключается по тарифу: маршрутизация в режиме «только выбор» скрывает его тариф, чтобы не читать защищённые нативные учётные данные, и правило для него противоречило бы само себе. А если не осталось ни одного неисключённого аккаунта, исключённый всё равно отвечает вместо отказа; чтобы остановить обслуживание полностью, по-прежнему нужно поставить на паузу все аккаунты. Аналога `minimumPlan` нет: чтобы ранжировать тарифы ChatGPT, нужен полный порядок, которого здесь не существует. - +Основной аккаунт Codex не исключается по тарифу: маршрутизация только для выбора не читает защищённые нативные учётные данные. Если все доступные аккаунты пула исключены, автоматический выбор не возвращает аккаунт. Явные маршруты к аккаунту доступны, но проверки паузы, аутентификации и прав на модель сохраняются. Карточка и CLI показывают исключённый тариф отдельно от состояния учётных данных. Настройки `minimumPlan` нет, поскольку тарифы не имеют полного порядка. ## Восстановление нативного Codex `ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 09493150a4..e307dd002c 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -449,8 +449,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' Bu bir engelleme değil, seçim politikasıdır. Dışarıda bırakılan hesap kimlik bilgisini, kota geçmişini ve iş parçacığı bağını korur, hesap listesinde görünmeye devam eder ve `work/gpt-5.5` gibi açık bir seçimle hâlâ erişilebilir. Değişen tek şey, otomatik rotasyonun onu artık seçmemesidir; hesap zaten etkin olsa ya da bir iş parçacığına bağlı olsa bile. Süresi dolan bir abonelik tam olarak bu durumu bırakır. -İki kasıtlı sınır var. Ana Codex hesabı plana göre hiçbir zaman dışarıda bırakılmaz: yalnızca-seçim yönlendirmesi korunan yerel kimlik bilgisini okumamak için planını saklar, dolayısıyla ana hesabı kapsayan bir kural kendisiyle çelişirdi. Ayrıca dışarıda bırakılmamış hiçbir hesap kalmadığında, dışarıda bırakılan hesap başarısız olmak yerine yine yanıt verir; hizmeti tamamen durdurmak için hâlâ tüm hesapları duraklatmak gerekir. `minimumPlan` karşılığı yoktur, çünkü ChatGPT planlarını sıralamak burada bulunmayan bir tam sıralama gerektirir. - +Ana Codex hesabı plan hariç tutma politikasından muaftır; yalnızca seçim yapan yönlendirme korunan yerel kimlik bilgilerini okumaz. Kullanılabilir tüm havuz hesapları hariç tutulursa otomatik seçim hesap döndürmez. Açıkça hesap belirten yollar kullanılabilir; duraklatma, kimlik doğrulama ve model yetkisi denetimleri korunur. Hesap kartı ve CLI, hariç tutulan yönlendirme planını kimlik bilgisi durumundan ayrı gösterir. Planların tam sıralaması olmadığından `minimumPlan` ayarı yoktur. ## Yerel Codex'i geri yükleme `ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 1e4cea1d43..db90fd9de5 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -336,8 +336,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.5` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。 -有两处刻意的限制。主 Codex 账号不会因套餐被排除:仅选择模式的路由不读取受保护的原生凭据而隐去其套餐,覆盖主账号的规则会自相矛盾。另外,当没有未被排除的账号时,被排除的账号仍会应答而不是失败;要彻底停止服务,仍然是暂停全部账号。没有对应的 `minimumPlan`,因为给 ChatGPT 套餐排序需要一个这里并不存在的全序。 - +主 Codex 账号不受套餐排除策略影响;仅选择模式不会读取受保护的原生凭据。如果所有可用的池账号都被排除,自动选择不返回账号。明确指定账号的路由仍可使用,并继续检查暂停、认证和模型权限。账号卡片与 CLI 将被排除的路由套餐与凭据健康状态分开显示。套餐没有全序关系,因此不提供 `minimumPlan` 设置。 ## 恢复原生 Codex `ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 Codex。OpenCodex 只移除能够确认归属的路由配置;如果无法安全恢复配置文件,会报告恢复未完成。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index a17997b848..2582af8b10 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -343,8 +343,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.5` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。 -有兩處刻意的限制。主 Codex 帳號不會因方案被排除:僅選擇模式的路由不讀取受保護的原生憑證而隱去其方案,涵蓋主帳號的規則會自相矛盾。此外,當沒有未被排除的帳號時,被排除的帳號仍會回應而不是失敗;要完全停止服務,仍然是暫停所有帳號。沒有對應的 `minimumPlan`,因為為 ChatGPT 方案排序需要一個這裡並不存在的全序。 - +主 Codex 帳號不受方案排除策略影響;僅選擇模式不會讀取受保護的原生憑證。如果所有可用的池帳號都被排除,自動選取不會回傳帳號。明確指定帳號的路由仍可使用,並繼續檢查暫停、認證及模型權限。帳號卡片與 CLI 將被排除的路由方案與憑證健康狀態分開顯示。方案沒有全序關係,因此不提供 `minimumPlan` 設定。 ## 恢復原生 Codex `ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 619ec2ecf2..72da4b774c 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -78,6 +78,7 @@ export function CodexAccountPoolCards({ <> {pool.map(a => { const healthStatus = a.health?.status; + const planExcluded = a.selectionExcludedReason === "plan_excluded"; const showReauth = Boolean(a.needsReauth) || oauthHealthShowsReauth(healthStatus); const inCooldown = oauthHealthIsCooldown(healthStatus); const validationPending = a.health?.reason === "validation_pending"; @@ -90,6 +91,11 @@ export function CodexAccountPoolCards({ {a.alias ?? a.email} {a.plan && {a.plan}} + {planExcluded && ( + + {t("codexAuth.planExcluded")} + + )} {a.paused && ( {t("codexAuth.paused")} @@ -102,13 +108,13 @@ export function CodexAccountPoolCards({ {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a) && !showReauth && !inCooldown && !validationPending && ( + {isNext(a) && !planExcluded && !showReauth && !inCooldown && !validationPending && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!a.paused && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( + {!a.paused && !planExcluded && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 2d909b29bd..87aca93743 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -54,6 +54,8 @@ export interface CodexAccountEntry { }; mainAccountHardLock?: MainAccountHardLockStatus; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; healthLabel?: string; healthSummary?: string; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fdb18eb6ed..f48259c80a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1329,6 +1329,8 @@ export const de: Record = { "codexAuth.pause": "Pausieren", "codexAuth.resume": "Fortsetzen", "codexAuth.paused": "PAUSIERT", + "codexAuth.planExcluded": "Nicht automatisch gewählt", + "codexAuth.planExcludedHint": "Tarif {plan} ist von der automatischen Auswahl ausgeschlossen. Explizite Kontorouten bleiben verfügbar.", "codexAuth.pauseSucceeded": "{email} ist pausiert", "codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar", "codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1847a7af7e..88a47c7c66 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1905,6 +1905,8 @@ export const en = { "codexAuth.pause": "Pause", "codexAuth.resume": "Resume", "codexAuth.paused": "PAUSED", + "codexAuth.planExcluded": "Not auto-selected", + "codexAuth.planExcludedHint": "Plan {plan} is excluded from automatic selection. Explicit account routes remain available.", "codexAuth.pauseSucceeded": "{email} is paused", "codexAuth.resumeSucceeded": "{email} is available to the pool again", "codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e465adbb10..71d02a56db 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1837,6 +1837,8 @@ export const fr: Record = { "codexAuth.pause": "Suspendre", "codexAuth.resume": "Reprendre", "codexAuth.paused": "SUSPENDU", + "codexAuth.planExcluded": "Exclu du choix automatique", + "codexAuth.planExcludedHint": "Le forfait {plan} est exclu de la sélection automatique. Les routes explicites vers ce compte restent disponibles.", "codexAuth.pauseSucceeded": "{email} est suspendu", "codexAuth.resumeSucceeded": "{email} est de nouveau disponible dans le groupe", "codexAuth.pauseFailed": "Impossible de suspendre {email}. Aucune modification apportée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9a3d9f58b..022888c079 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1762,6 +1762,8 @@ export const ja: Record = { "codexAuth.pause": "一時停止", "codexAuth.resume": "再開", "codexAuth.paused": "一時停止中", + "codexAuth.planExcluded": "自動選択の対象外", + "codexAuth.planExcludedHint": "プラン {plan} は自動選択の対象外です。アカウントを明示的に指定すると利用できます。", "codexAuth.pauseSucceeded": "{email} を一時停止しました", "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67ee251970..41fbeca7ee 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1365,6 +1365,8 @@ export const ko: Record = { "codexAuth.pause": "일시 중지", "codexAuth.resume": "재개", "codexAuth.paused": "일시 중지됨", + "codexAuth.planExcluded": "자동 선택 제외", + "codexAuth.planExcludedHint": "{plan} 플랜은 자동 선택에서 제외됩니다. 계정을 직접 지정하면 사용할 수 있습니다.", "codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다", "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56d43fc301..5ca60bb485 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1832,6 +1832,8 @@ export const ru: Record = { "codexAuth.pause": "Приостановить", "codexAuth.resume": "Возобновить", "codexAuth.paused": "ПРИОСТАНОВЛЕН", + "codexAuth.planExcluded": "Не выбирается автоматически", + "codexAuth.planExcludedHint": "Тариф {plan} исключён из автоматического выбора. Явная маршрутизация на аккаунт доступна.", "codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен", "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b627813bb9..98e50d5556 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1862,6 +1862,8 @@ export const tr: Record = { "codexAuth.pause": "Duraklat", "codexAuth.resume": "Devam Ettir", "codexAuth.paused": "DURAKLATILDI", + "codexAuth.planExcluded": "Otomatik seçilmez", + "codexAuth.planExcludedHint": "{plan} planı otomatik seçimden hariç tutulur. Açık hesap yönlendirmeleri kullanılabilir.", "codexAuth.pauseSucceeded": "{email} duraklatıldı", "codexAuth.resumeSucceeded": "{email} tekrar havuza alındı", "codexAuth.pauseFailed": "{email} duraklatılamadı.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ce06556fd4..a1b9ce40b4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1453,6 +1453,8 @@ export const zhTW: Record = { "codexAuth.pause": "暫停", "codexAuth.resume": "恢復", "codexAuth.paused": "已暫停", + "codexAuth.planExcluded": "不自動選取", + "codexAuth.planExcludedHint": "方案 {plan} 已排除自動選取。仍可明確指定此帳號。", "codexAuth.pauseSucceeded": "已暫停 {email}", "codexAuth.resumeSucceeded": "{email} 已重新加入帳號池", "codexAuth.pauseFailed": "無法暫停 {email},未做任何變更。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ae2fdfec92..bb222d01ea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1346,6 +1346,8 @@ export const zh: Record = { "codexAuth.pause": "暂停", "codexAuth.resume": "恢复", "codexAuth.paused": "已暂停", + "codexAuth.planExcluded": "不自动选择", + "codexAuth.planExcludedHint": "套餐 {plan} 已从自动选择中排除。仍可明确指定此账号。", "codexAuth.pauseSucceeded": "已暂停 {email}", "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index 9702d7aff9..bb3e616491 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -280,3 +280,22 @@ test("healthy account cards omit log-label and 30-day usage copy", async () => { expect(main.textContent).not.toContain("Log label: main"); expect(hasPinnedHint(main)).toBe(false); }); + + +test("plan exclusion is visible without presenting the account as the next automatic selection", async () => { + await mountPool(makeController({ + accounts: [mainAccount, { ...account, plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }], + activeId: account.id, + })); + const card = cardFor(account.email); + const excluded = [...card.querySelectorAll(".badge")].find(el => el.textContent === en["codexAuth.planExcluded"]); + expect(excluded).toBeTruthy(); + expect(excluded!.getAttribute("title")).toContain("free"); + expect([...card.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(false); + expect(card.textContent).not.toContain(en["codexAuth.paused"]); + expect(switchAction(card)).toBeNull(); + await act(async () => { + root!.render(); + }); + expect(cardFor(account.email).textContent).not.toContain(en["codexAuth.planExcluded"]); +}); diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index e0e573e251..c734f7eb85 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -26,6 +26,8 @@ export interface AccountRow { masked?: string; active: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; /** Registered credential that is still excluded from routing until validation completes. */ validationPending?: boolean; /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ @@ -243,6 +245,8 @@ interface CodexAccountDto { plan?: string; isMain?: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { reason?: string }; priority?: number; quota?: CodexQuotaDto | null; @@ -309,6 +313,10 @@ export async function fetchCodexRows( plan: a.plan, active: a.id === activeId, needsReauth: a.needsReauth, + ...(a.selectionExcludedReason === "plan_excluded" ? { + selectionExcludedReason: "plan_excluded" as const, + ...(typeof a.selectionExcludedPlan === "string" ? { selectionExcludedPlan: a.selectionExcludedPlan } : {}), + } : {}), ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}), priority: typeof a.priority === "number" ? a.priority : 0, paused: a.paused === true, diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..04e7f52be2 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -101,6 +101,9 @@ function statusText(row: AccountRow): string { if (row.active) parts.push(row.type === "codex" ? "selected" : "active"); if (row.needsReauth) parts.push("needs-reauth"); if (row.validationPending) parts.push("validation-pending"); + if (row.selectionExcludedReason === "plan_excluded") { + parts.push(`not-auto-selected(plan=${row.selectionExcludedPlan ?? row.plan ?? "unknown"})`); + } return parts.join(" "); } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 09becf51ea..3b5fc83d1e 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -49,6 +49,7 @@ import { clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, + isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion, resetCodexRoutingForManualSelection, settleCodexQuotaRecoveryProbe, @@ -379,6 +380,7 @@ export type CodexAccountReauthReason = | "forbidden"; function poolAccountDto( + config: OcxConfig, account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, @@ -413,6 +415,10 @@ function poolAccountDto( quota: quota ? { ...quota } : null, needsReauth: needsReauth || health.status === "reauth_required", ...(reauthReason !== undefined ? { reauthReason } : {}), + ...(isCodexAccountPlanExcluded(config, account.id) ? { + selectionExcludedReason: "plan_excluded" as const, + selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan), + } : {}), hasCredential, ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}), ...oauthAccountHealthFields("codex", account.id, health), @@ -1192,6 +1198,9 @@ export interface CodexAuthAccountDto { * needs the operator; `/api/oauth/accounts` already carries the same field name. */ reauthReason?: CodexAccountReauthReason; + /** Automatic selection policy only; explicit routes retain their usual auth checks. */ + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; hasCredential: boolean; health: OAuthAccountHealth; healthLabel: OAuthHealthLabel; @@ -2006,6 +2015,7 @@ export async function listCodexAuthAccountsSnapshot( const currentCredential = getCodexAccountCredential(accountId); if (!currentCredential) { return [poolAccountDto( + runtimeConfig, currentAccount, { quota: null, needsReauth: true }, false, @@ -2026,6 +2036,7 @@ export async function listCodexAuthAccountsSnapshot( ? { ...currentAccount, plan: quotaResult.freshPlan } : currentAccount; return [poolAccountDto( + runtimeConfig, dtoAccount, effectiveQuotaResult, true, diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 04c5b8b1ab..83ade05b4b 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1066,7 +1066,7 @@ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | und * selection-only drain so routing never reads the fenced native credential for it, so a rule that * covered main would disagree with itself between drain and ordinary routing. */ -function isCodexAccountPlanExcluded( +export function isCodexAccountPlanExcluded( config: OcxConfig, accountId: string, precomputed?: ReadonlySet, @@ -2155,6 +2155,7 @@ export function previewCodexAccountForRequest( else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) return active; else return null; } @@ -2391,6 +2392,7 @@ export function resolveCodexAccountForThreadDetailed( } else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) { return { status: "selected", accountId: active }; } else { diff --git a/structure/catalog.md b/structure/catalog.md index 91d7734848..3d430fa176 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -268,3 +268,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Account-qualified catalog routes bypass automatic plan exclusions while retaining credential and entitlement checks; see [automatic pool plan exclusions](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 4c86504a08..02a44005da 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -79,3 +79,5 @@ testable on any host: stubbing `process.platform` does not propagate to `os.plat Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Desktop requests routed to the Codex pool use the shared [automatic plan exclusion contract](../providers/openai-tiers.md#automatic-pool-plan-exclusions); explicit account-qualified targets retain their selection semantics. diff --git a/structure/codex-home.md b/structure/codex-home.md index 7c523dd20a..ed52e53a0c 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -226,3 +226,5 @@ a deliberate user choice: Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Plan-based automatic exclusions leave native credential files untouched and preserve the native-main exemption in the [selection policy](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..3980d030c9 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +`codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/design-methodology.md b/structure/design-methodology.md index bcaf940d72..a8e9a822f3 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -36,3 +36,5 @@ surfaces, run through all 3 stages in order. - Design methodology: Product-Personality-Selection (dev-uiux-design §1) - 6 design dials: mood, lightness, density, shape, typography, motion - 7 axes total: design → domain → feature/data/security/ops/cost (derived) + +The Codex account card separates automatic plan-policy exclusion from credential health and suppresses an unavailable next-session action; see the [account selection contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a860a78fc2..ec5a815667 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -515,3 +515,5 @@ survives availability drift, while complete/native custom orders await explicit Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Codex account DTOs and cards expose the routing-plan exclusion separately from credential health; the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions) also governs CLI projection. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..0b0366a2ab 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The account CLI and translated Codex integration guides follow the [automatic plan exclusion contract](../providers/openai-tiers.md#automatic-pool-plan-exclusions), including all-excluded pools and explicit routes. diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..11ce44ed33 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +Codex plan exclusions constrain automatic pool selection without deleting credentials; [account-policy reasons](providers/openai-tiers.md#automatic-pool-plan-exclusions) remain distinct from health and pause. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index b8d1d56675..7c9dce0813 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -398,3 +398,9 @@ model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. + +## Automatic pool plan exclusions + +`src/codex/routing.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. + +`src/codex/auth-api.ts` projects `selectionExcludedReason: "plan_excluded"` and `selectionExcludedPlan` from the routing config, even when a newer display-only WHAM plan could not be persisted. The dashboard and account CLI show the policy reason separately from credential health; renewal clears the derived fields. The automatic next-session action and badge are omitted for excluded rows. diff --git a/structure/runtime.md b/structure/runtime.md index 495745051e..c9718b89df 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -192,3 +192,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Automatic Codex pool selection and account status share the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/subagents.md b/structure/subagents.md index 3f00e96302..44d922bdcc 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -202,3 +202,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Subagent automatic pool preview returns no candidate when all pool plans are excluded; explicit account-qualified models retain the [selection-policy distinction](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 214270e4be..fdfa2b6151 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,18 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("plan exclusions survive the API projection and use the policy plan", async () => { + codexAccounts = [{ id: "policy", plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free", paused: false }]; + const human = await run(["list", "openai"]); + expect(human.code).toBe(0); + expect(human.stdout).toContain("not-auto-selected(plan=free)"); + const machine = await run(["list", "openai", "--json"]); + expect(JSON.parse(machine.stdout).accounts[0]).toMatchObject({ selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }); + codexAccounts = [{ id: "policy", plan: "plus", selectionExcludedReason: "unrecognized", selectionExcludedPlan: "free" }]; + expect((await run(["list", "openai"])).stdout).not.toContain("not-auto-selected"); + expect(JSON.parse((await run(["list", "openai", "--json"])).stdout).accounts[0]).not.toHaveProperty("selectionExcludedReason"); + }); + test.each([100, 12])("pending validation stays visible at %s percent usage without exposing raw health details", async weeklyPercent => { codexAccounts = [{ id: "pending", email: "p***@example.test", quota: { weeklyPercent }, health: { status: "warning", reason: "validation_pending", message: RAW_SENTINEL } }]; diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 89813bb058..4ef25c381c 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1051,6 +1051,22 @@ describe("codex-auth API", () => { } }); + test("account DTO exposes the routing plan exclusion and clears it on renewal", async () => { + const cfg = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + seedPoolAccount(cfg, { id: "plan-row", email: "plan@example.test", plan: "free" }); + const read = async () => { + const request = new Request("http://localhost/api/codex-auth/accounts"); + const response = await handleCodexAuthAPI(request, new URL(request.url), cfg); + const body = await response!.json() as { accounts: CodexAuthAccountDto[] }; + return body.accounts.find(account => account.id === "plan-row")!; + }; + expect(await read()).toMatchObject({ selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free", paused: false }); + cfg.codexAccounts![0].plan = "plus"; + const renewed = await read(); + expect(renewed).not.toHaveProperty("selectionExcludedReason"); + expect(renewed).not.toHaveProperty("selectionExcludedPlan"); + }); + test("GET /api/codex-auth/accounts returns array with main", async () => { const req = new Request("http://localhost/api/codex-auth/accounts", { method: "GET" }); const url = new URL(req.url); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 580f6d15bd..5c50d021e0 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1523,6 +1523,28 @@ describe("Codex auth context", () => { .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); + test("explicit account routing bypasses plan policy while retaining pause and reauth checks", async () => { + const cfg = config(); + cfg.codexAccounts!.find(account => account.id === "pool-a")!.plan = "free"; + cfg.codexPool = { excludedPlans: ["free"] }; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, chatgptAccountId: "pool_a_acc", + }); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + cfg.pausedCodexAccountIds = ["pool-a"]; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).rejects.toThrow("Selected Codex account is unavailable"); + cfg.pausedCodexAccountIds = []; + markAccountNeedsReauth("pool-a"); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).rejects.toThrow("Selected Codex account needs reauthentication"); + }); + test("exact selection reports reauthentication without falling back to the active Pool account", async () => { const cfg = config(); cfg.activeCodexAccountId = "pool-b"; diff --git a/tests/codex-integration/codex-pool-plan-exclusion.test.ts b/tests/codex-integration/codex-pool-plan-exclusion.test.ts index 52a76fb57a..36fe8aed2a 100644 --- a/tests/codex-integration/codex-pool-plan-exclusion.test.ts +++ b/tests/codex-integration/codex-pool-plan-exclusion.test.ts @@ -6,6 +6,7 @@ import { clearCodexUpstreamHealth, clearThreadAccountMap, pickLowestUsageCodexAccount, + isCodexAccountPlanExcluded, previewCodexAccountForRequest, resolveCodexAccountForThread, } from "../../src/codex/routing"; @@ -164,15 +165,24 @@ describe("codex pool plan exclusion", () => { expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); }); - test("the last remaining account still serves rather than stranding the operator", () => { - // Deliberately unlike pause. #4211 asks for a selection policy, not a hard block, so with no - // unexcluded candidate left the excluded account keeps answering instead of failing closed. + test("automatic routing refuses the last excluded account", () => { const config = makeConfig({ codexAccounts: [{ id: "downgraded", email: "downgraded@test", isMain: false, plan: "free" }], codexPool: { excludedPlans: ["free"] }, } as Partial); recordUsage("downgraded", 10); expect(pickLowestUsageCodexAccount(config)).toBeNull(); - expect(resolveCodexAccountForThread("last-account", config)).toBe("downgraded"); + expect(resolveCodexAccountForThread("last-account", config)).toBeNull(); + expect(previewCodexAccountForRequest("last-account", config)).toBeNull(); }); + test("renewal clears the policy reason without pausing or deleting the account", () => { + const config = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + expect(isCodexAccountPlanExcluded(config, "downgraded")).toBe(true); + config.codexAccounts![0].plan = "plus"; + expect(isCodexAccountPlanExcluded(config, "downgraded")).toBe(false); + expect(isCodexAccountPlanExcluded(config, "__main__")).toBe(false); + expect(config.codexAccounts).toHaveLength(2); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + }); From 3bf0ae126a3d1bbb7d182d72dcaacb39614fc5b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:59:31 +0900 Subject: [PATCH 015/224] feat(remote): carry bounded executor and hub runtime adapters Carry #3458 runtime foundations with explicit session grants, private state stores and fail-closed Windows command support. Keep server and dashboard activation for the dependent integration layer. Co-authored-by: Ingwannu --- .gitignore | 3 + .npmignore | 1 + .../020_executor_runtime.md | 22 + native/remote-workspace-helper/Cargo.lock | 130 +++ native/remote-workspace-helper/Cargo.toml | 24 + native/remote-workspace-helper/src/main.rs | 49 ++ .../remote-workspace-helper/src/protocol.rs | 246 ++++++ .../src/sandbox/macos.rs | 19 + .../src/sandbox/mod.rs | 77 ++ .../src/sandbox/windows.rs | 15 + .../tests/live_confinement.rs | 75 ++ package.json | 5 + scripts/test-layout/layout.json | 16 + src/cli/remote-workspace.ts | 154 ++++ src/lib/windows-atomic-replace.ts | 1 + src/remote-control/index.ts | 233 +++++- .../workspace-agent-connection.ts | 366 +++++++++ .../workspace-claude-runtime.ts | 243 ++++++ src/remote-control/workspace-codex-runtime.ts | 531 +++++++++++++ src/remote-control/workspace-codex-sandbox.ts | 115 +++ .../workspace-command-runner.ts | 749 ++++++++++++++++++ src/remote-control/workspace-coordinator.ts | 230 ++++++ src/remote-control/workspace-device.ts | 585 ++++++++++++++ src/remote-control/workspace-executable.ts | 43 + src/remote-control/workspace-executor.ts | 396 +++++++++ src/remote-control/workspace-hub.ts | 519 ++++++++++++ src/remote-control/workspace-pi-runtime.ts | 382 +++++++++ src/remote-control/workspace-process.ts | 129 +++ src/remote-control/workspace-rpc.ts | 304 +++++++ src/remote-control/workspace-runtime.ts | 60 ++ src/remote-control/workspace-secret-store.ts | 39 + src/remote-control/workspace-sessions.ts | 730 +++++++++++++++++ src/remote-control/workspace-tool-bridge.ts | 192 +++++ structure/clients/claude-desktop.md | 2 + structure/clients/integrations.md | 2 + structure/config.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/overview.md | 2 + structure/remote-workspace.md | 18 +- structure/runtime.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + .../remote-workspace-agent-wire.test.ts | 324 ++++++++ ...e-workspace-app-server.integration.test.ts | 426 ++++++++++ ...emote-workspace-claude.integration.test.ts | 166 ++++ .../remote-workspace-cli-runtimes.test.ts | 67 ++ tests/clients/remote-workspace-cli.test.ts | 105 +++ .../remote-workspace-codex-runtime.test.ts | 120 +++ .../remote-workspace-command-runner.test.ts | 328 ++++++++ tests/clients/remote-workspace-device.test.ts | 158 ++++ tests/clients/remote-workspace-hub.test.ts | 211 +++++ ...remote-workspace-linux-confinement.test.ts | 114 +++ .../clients/remote-workspace-platform.test.ts | 182 +++++ .../remote-workspace-secret-store.test.ts | 105 +++ .../remote-workspace-session-binding.test.ts | 75 ++ .../clients/remote-workspace-sessions.test.ts | 352 ++++++++ .../remote-workspace-tool-bridge.test.ts | 87 ++ tests/clients/remote-workspace.test.ts | 464 +++++++++++ tests/fake-codex-server.ts | 4 + tests/fixtures/fake-claude-stream.ts | 8 + tests/fixtures/test-layout-expected.json | 16 + 62 files changed, 9984 insertions(+), 47 deletions(-) create mode 100644 native/remote-workspace-helper/Cargo.lock create mode 100644 native/remote-workspace-helper/Cargo.toml create mode 100644 native/remote-workspace-helper/src/main.rs create mode 100644 native/remote-workspace-helper/src/protocol.rs create mode 100644 native/remote-workspace-helper/src/sandbox/macos.rs create mode 100644 native/remote-workspace-helper/src/sandbox/mod.rs create mode 100644 native/remote-workspace-helper/src/sandbox/windows.rs create mode 100644 native/remote-workspace-helper/tests/live_confinement.rs create mode 100644 src/cli/remote-workspace.ts create mode 100644 src/remote-control/workspace-agent-connection.ts create mode 100644 src/remote-control/workspace-claude-runtime.ts create mode 100644 src/remote-control/workspace-codex-runtime.ts create mode 100644 src/remote-control/workspace-codex-sandbox.ts create mode 100644 src/remote-control/workspace-command-runner.ts create mode 100644 src/remote-control/workspace-coordinator.ts create mode 100644 src/remote-control/workspace-device.ts create mode 100644 src/remote-control/workspace-executable.ts create mode 100644 src/remote-control/workspace-executor.ts create mode 100644 src/remote-control/workspace-hub.ts create mode 100644 src/remote-control/workspace-pi-runtime.ts create mode 100644 src/remote-control/workspace-process.ts create mode 100644 src/remote-control/workspace-rpc.ts create mode 100644 src/remote-control/workspace-runtime.ts create mode 100644 src/remote-control/workspace-secret-store.ts create mode 100644 src/remote-control/workspace-sessions.ts create mode 100644 src/remote-control/workspace-tool-bridge.ts create mode 100644 tests/clients/remote-workspace-agent-wire.test.ts create mode 100644 tests/clients/remote-workspace-app-server.integration.test.ts create mode 100644 tests/clients/remote-workspace-claude.integration.test.ts create mode 100644 tests/clients/remote-workspace-cli-runtimes.test.ts create mode 100644 tests/clients/remote-workspace-cli.test.ts create mode 100644 tests/clients/remote-workspace-codex-runtime.test.ts create mode 100644 tests/clients/remote-workspace-command-runner.test.ts create mode 100644 tests/clients/remote-workspace-device.test.ts create mode 100644 tests/clients/remote-workspace-hub.test.ts create mode 100644 tests/clients/remote-workspace-linux-confinement.test.ts create mode 100644 tests/clients/remote-workspace-platform.test.ts create mode 100644 tests/clients/remote-workspace-secret-store.test.ts create mode 100644 tests/clients/remote-workspace-session-binding.test.ts create mode 100644 tests/clients/remote-workspace-sessions.test.ts create mode 100644 tests/clients/remote-workspace-tool-bridge.test.ts create mode 100644 tests/clients/remote-workspace.test.ts create mode 100644 tests/fixtures/fake-claude-stream.ts diff --git a/.gitignore b/.gitignore index ce10233dcc..f32218aafd 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ tests/**/.tmp-* # `git add` three separate times and reached `dev` once — see # tests/ci-workflows/repo-hygiene.test.ts, which fails if any path here becomes tracked again. go/ + +# Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. +native/**/target/ diff --git a/.npmignore b/.npmignore index acf3a0c4d0..cfbe1d3750 100644 --- a/.npmignore +++ b/.npmignore @@ -19,6 +19,7 @@ gui/eslint.config.* gui/bun.lock # misc +native/remote-workspace-helper/target/ *.test.ts *.map .DS_Store diff --git a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md index 550b2a5bb5..e20200c7c3 100644 --- a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md +++ b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md @@ -82,3 +82,25 @@ REMOTE-ARCH-003: Separate persisted enrollment capabilities from current connect REMOTE-ARCH-006: Use existing required private-file/Windows ACL primitives for new identity and bearer stores. Check permission setup failures and refuse loading/saving secrets when enforcement fails. Do not change global config-store behavior. Record exact selected existing helper in phase-2 P after reading the owner; no best-effort function is accepted as proof. REMOTE-ARCH-007: Codex real App Server tests depend on OCX_CODEX_BIN; Claude real integration on OCX_CLAUDE_BIN; Pi on OCX_PI_BIN. The Linux confinement case can return without execution unless OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT=1 or bwrap is available. Current generic CI alone does not prove those paths. Mock tests prove lifecycle and tool-routing contracts only; native Hub isolation and executor confinement stay explicit final acceptance gaps when not activated. For each adapter separately record denied local tools, inherited plugins/hooks/config, offline refusal and teardown; inspect source plus hosted mocks, no claims of live CLI confinement from flags alone. + +## Phase-2 revalidation and exact owner choices + +Previous D: wp1 inactive foundation source cycle complete at 726ddc7fc0; final hosted proof remains wp4. Continue in child branch codex/260912-60plus-remote-runtime. Existing public exports and added host-negative coverage are retained. + +REMOTE-ARCH-004: storage modules import atomicWriteFile directly from src/config/atomic-write.ts and getConfigDir from src/config/paths.ts, avoiding the broad config.ts barrel. Device CLI orchestration retains explicit runner construction because it computes actual availability after root approval; no import-time probe exists. This is intentional sequential coupling. Server seams in phase 3 use narrow structural connection/session interfaces rather than pulling concrete remote classes into shared request types. No remote module imports server surfaces. + +REMOTE-ARCH-006 exact helpers: NEW src/remote-control/workspace-secret-store.ts owns prepareWorkspaceSecretDirectory(directory) and hardenWorkspaceSecretFile(path). On POSIX use chmodSync with propagated failure and lstat directory/file identity/type checks. On Windows call existing src/lib/windows-secret-acl.ts hardenSecretDir/hardenSecretPath with required:true. Reject symlink state targets. All three stores use this before reads and before atomicWriteFile. Existing atomic-write.ts already creates an empty private descriptor, hardens before writing bytes, and scrubs failures; retain it. Tests: NEW tests/clients/remote-workspace-secret-store.test.ts covers owner-only POSIX file mode, unexpected path types/symlinks and failed reads; hosted Windows ACL owner tests remain applicable. No global config behavior changes. + +src/lib/windows-atomic-replace.ts change is the new ReplacePublisher literal remote-workspace (the function is already exported). Use existing counter serialization/consumers unchanged: creation at executor write, diagnostic key serialization, dynamic record readers; no closed switch to extend. + +NEW tests/clients/remote-workspace-session-binding.test.ts covers session/device/root/capability mismatches with zero execution and a valid positive control, using encrypted messages and independent fixtures. MODIFY agent-wire, hub, sessions and device tests to assert subset negotiation and presence intersection. Platform runner source retains existing fail-closed native paths; remove stale comment claiming supported macOS commands. + +### Audit amendment: store-level failure propagation + +Hub/Device/Session file-store constructors accept an optional narrow permissions dependency containing prepareDirectory and hardenFile, defaulting to the required production helper. Load returns null for absent files; existing files require directory and file checks before secret reads. Save prepares directory, hardens an existing target, then invokes the existing private atomic writer. For each store, injected directory/file hardening throws must propagate, preserve existing bytes and prevent secret IO. New-state first-run controls return null then save/load valid fixtures. Add all three store cases to remote-workspace-secret-store.test.ts; this injection observes caller ordering rather than relying on ACL-owner tests alone. + +### Native containment amendment + +Independent source review requires a protected Linux bubblewrap executable outside writable roots, with identity revalidation before use. Custom executable files and their parent chain must not be writable by group/other; canonical system symlinks are resolved before checking. Workspace roots cannot contain the executable; every invocation rechecks. Add source/runner regression fixtures without claiming a local run. + +Windows command availability remains disabled in this carry: nativeRemoteWorkspaceCommandRunnerAvailable returns false before invoking the helper, and the official Windows helper rejects public probe/run without allocating OS resources. The candidate Windows implementation remains in original PR history; do not retain callable unverified entrypoints. This matches the fail-closed macOS policy and preserves independently authorized file tools. Update native denial tests and docs; Windows working-command acceptance stays OPEN. A future lifecycle owner and hosted cancellation/cleanup evidence are required before re-enablement. This is a safety limitation, not completion of Windows commands. diff --git a/native/remote-workspace-helper/Cargo.lock b/native/remote-workspace-helper/Cargo.lock new file mode 100644 index 0000000000..8dba097e9d --- /dev/null +++ b/native/remote-workspace-helper/Cargo.lock @@ -0,0 +1,130 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "opencodex-remote-workspace-helper" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "windows-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/remote-workspace-helper/Cargo.toml b/native/remote-workspace-helper/Cargo.toml new file mode 100644 index 0000000000..65bd1d0ba7 --- /dev/null +++ b/native/remote-workspace-helper/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "opencodex-remote-workspace-helper" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[dependencies] +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_Storage_FileSystem", + "Win32_System_JobObjects", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_Threading", +] } diff --git a/native/remote-workspace-helper/src/main.rs b/native/remote-workspace-helper/src/main.rs new file mode 100644 index 0000000000..8312186b8d --- /dev/null +++ b/native/remote-workspace-helper/src/main.rs @@ -0,0 +1,49 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod protocol; +mod sandbox; + +use std::io::{self, Read, Write}; + +use protocol::{HelperRequest, HelperResponse, MAX_REQUEST_BYTES, PROTOCOL_VERSION}; + +fn main() { + if std::env::args().nth(1).as_deref() == Some("__probe-child") { + std::process::exit(sandbox::run_probe_child()); + } + + let response = match read_request().and_then(handle_request) { + Ok(response) => response, + Err(error) => HelperResponse::error(error), + }; + let mut stdout = io::stdout().lock(); + if serde_json::to_writer(&mut stdout, &response).is_err() || stdout.write_all(b"\n").is_err() { + std::process::exit(2); + } +} + +fn read_request() -> Result { + let mut body = Vec::new(); + io::stdin() + .take((MAX_REQUEST_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| "could not read helper request".to_owned())?; + if body.len() > MAX_REQUEST_BYTES { + return Err("helper request exceeds its size limit".to_owned()); + } + let request: HelperRequest = + serde_json::from_slice(&body).map_err(|_| "helper request is invalid".to_owned())?; + request.validate()?; + Ok(request) +} + +fn handle_request(request: HelperRequest) -> Result { + if request.version != PROTOCOL_VERSION { + return Err("unsupported helper protocol version".to_owned()); + } + match request.operation.as_str() { + "probe" => sandbox::probe().map(|()| HelperResponse::probe_success()), + "run" => sandbox::run(&request).map(HelperResponse::command_success), + _ => Err("unsupported helper operation".to_owned()), + } +} diff --git a/native/remote-workspace-helper/src/protocol.rs b/native/remote-workspace-helper/src/protocol.rs new file mode 100644 index 0000000000..900f630e5c --- /dev/null +++ b/native/remote-workspace-helper/src/protocol.rs @@ -0,0 +1,246 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +#[cfg(target_os = "windows")] +use std::path::PathBuf; + +pub const PROTOCOL_VERSION: u8 = 1; +pub const MAX_REQUEST_BYTES: usize = 64 * 1024; +pub const MAX_OUTPUT_BYTES: usize = 256 * 1024; +const MAX_PATH_BYTES: usize = 4096; +const MAX_COMMAND_ARGUMENTS: usize = 64; +const MAX_COMMAND_ARGUMENT_BYTES: usize = 4096; +const MAX_COMMAND_BYTES: usize = 16 * 1024; +const MAX_TOOLCHAIN_ROOTS: usize = 16; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct HelperRequest { + pub version: u8, + pub operation: String, + #[serde(default)] + pub root: String, + #[serde(default)] + pub cwd: String, + #[serde(default)] + pub command: Vec, + #[serde(default)] + pub toolchain_roots: Vec, + #[serde(default)] + pub timeout_ms: u64, + #[serde(default)] + pub max_output_bytes: usize, + #[serde(default)] + pub network_access: bool, +} + +impl HelperRequest { + pub fn validate(&self) -> Result<(), String> { + if self.operation == "probe" { + if !self.root.is_empty() + || !self.cwd.is_empty() + || !self.command.is_empty() + || !self.toolchain_roots.is_empty() + || self.timeout_ms != 0 + || self.max_output_bytes != 0 + || self.network_access + { + return Err("probe request must not carry command authority".to_owned()); + } + return Ok(()); + } + if self.operation != "run" { + return Ok(()); + } + validate_path(&self.root, "workspace root")?; + validate_path(&self.cwd, "command cwd")?; + if !Path::new(&self.root).is_absolute() || !Path::new(&self.cwd).is_absolute() { + return Err("workspace root and cwd must be absolute".to_owned()); + } + if self.command.is_empty() || self.command.len() > MAX_COMMAND_ARGUMENTS { + return Err("invalid command vector".to_owned()); + } + let mut command_bytes = 0usize; + for value in &self.command { + if value.is_empty() || value.len() > MAX_COMMAND_ARGUMENT_BYTES || value.contains('\0') + { + return Err("invalid command vector".to_owned()); + } + command_bytes = command_bytes + .checked_add(value.len()) + .ok_or_else(|| "command vector is too large".to_owned())?; + } + if command_bytes > MAX_COMMAND_BYTES { + return Err("command vector is too large".to_owned()); + } + if self.toolchain_roots.len() > MAX_TOOLCHAIN_ROOTS { + return Err("too many toolchain roots".to_owned()); + } + for path in &self.toolchain_roots { + validate_path(path, "toolchain root")?; + if !Path::new(path).is_absolute() { + return Err("toolchain roots must be absolute".to_owned()); + } + } + if !(1..=60_000).contains(&self.timeout_ms) { + return Err("command timeout is outside its limit".to_owned()); + } + if !(1024..=MAX_OUTPUT_BYTES).contains(&self.max_output_bytes) { + return Err("command output limit is outside its limit".to_owned()); + } + Ok(()) + } + + #[cfg(target_os = "windows")] + pub fn canonical_paths(&self) -> Result { + let root = canonical_directory(&self.root, "workspace root")?; + let cwd = canonical_directory(&self.cwd, "command cwd")?; + if !cwd.starts_with(&root) { + return Err("command cwd escaped its workspace root".to_owned()); + } + let mut toolchain_roots = Vec::with_capacity(self.toolchain_roots.len()); + for value in &self.toolchain_roots { + let canonical = canonical_directory(value, "toolchain root")?; + if !toolchain_roots.contains(&canonical) { + toolchain_roots.push(canonical); + } + } + Ok(CanonicalPaths { + root, + cwd, + toolchain_roots, + }) + } +} + +fn validate_path(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() || value.len() > MAX_PATH_BYTES || value.contains('\0') { + return Err(format!("invalid {label}")); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn canonical_directory(value: &str, label: &str) -> Result { + let original = Path::new(value); + let metadata = + std::fs::symlink_metadata(original).map_err(|_| format!("{label} is unavailable"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} must remain a real directory")); + } + original + .canonicalize() + .map_err(|_| format!("{label} is unavailable")) +} + +#[cfg(target_os = "windows")] +#[derive(Debug)] +pub struct CanonicalPaths { + pub root: PathBuf, + pub cwd: PathBuf, + pub toolchain_roots: Vec, +} + +#[derive(Debug)] +pub struct CommandOutcome { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelperResponse { + version: u8, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + probe: Option, + #[serde(skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stdout_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stderr_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl HelperResponse { + pub fn error(error: String) -> Self { + Self { + version: PROTOCOL_VERSION, + ok: false, + probe: None, + exit_code: None, + stdout_base64: None, + stderr_base64: None, + error: Some(limit_error(error)), + } + } + + pub fn probe_success() -> Self { + Self { + version: PROTOCOL_VERSION, + ok: true, + probe: Some(true), + exit_code: None, + stdout_base64: None, + stderr_base64: None, + error: None, + } + } + + pub fn command_success(outcome: CommandOutcome) -> Self { + Self { + version: PROTOCOL_VERSION, + ok: true, + probe: None, + exit_code: Some(outcome.exit_code), + stdout_base64: Some(STANDARD.encode(outcome.stdout)), + stderr_base64: Some(STANDARD.encode(outcome.stderr)), + error: None, + } + } +} + +fn limit_error(mut value: String) -> String { + const MAX_ERROR_CHARS: usize = 512; + if value.chars().count() <= MAX_ERROR_CHARS { + return value; + } + value = value.chars().take(MAX_ERROR_CHARS).collect(); + value.push('…'); + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_authority_smuggled_into_probe() { + let request: HelperRequest = + serde_json::from_str(r#"{"version":1,"operation":"probe","command":["whoami"]}"#) + .expect("valid JSON fixture"); + assert!(request.validate().is_err()); + } + + #[test] + fn rejects_unknown_wire_fields() { + assert!( + serde_json::from_str::( + r#"{"version":1,"operation":"probe","surprise":true}"#, + ) + .is_err() + ); + } + + #[test] + fn bounds_command_shape_before_platform_code() { + let request: HelperRequest = serde_json::from_str( + r#"{"version":1,"operation":"run","root":"/tmp/a","cwd":"/tmp/a","command":["x"],"timeoutMs":0,"maxOutputBytes":262144}"#, + ) + .expect("valid JSON fixture"); + assert!(request.validate().is_err()); + } +} diff --git a/native/remote-workspace-helper/src/sandbox/macos.rs b/native/remote-workspace-helper/src/sandbox/macos.rs new file mode 100644 index 0000000000..2052f82707 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/macos.rs @@ -0,0 +1,19 @@ +use crate::protocol::{CommandOutcome, HelperRequest}; + +const MACOS_CONFINEMENT_UNAVAILABLE: &str = + "macOS Remote Workspace command confinement is unavailable; file tools remain enabled"; + +/// macOS has no unprivileged Job Object or cgroup equivalent that can revoke every descendant's +/// workspace access. A Seatbelt profile can constrain a process, but allowing subprocesses lets a +/// descendant call `setsid()` and outlive cancellation. Importing broad system profiles merely to +/// make a single-process probe start would also widen unrelated host-service authority. Until a +/// native containment owner closes both boundaries, command execution must stay unavailable. +pub fn probe() -> Result<(), String> { + Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned()) +} + +/// Keep the helper itself fail-closed even if a caller bypasses OCX capability negotiation and +/// submits a `run` request directly. +pub fn run(_request: &HelperRequest) -> Result { + Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned()) +} diff --git a/native/remote-workspace-helper/src/sandbox/mod.rs b/native/remote-workspace-helper/src/sandbox/mod.rs new file mode 100644 index 0000000000..4b9bf551d4 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/mod.rs @@ -0,0 +1,77 @@ +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +use crate::protocol::{CommandOutcome, HelperRequest}; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +#[cfg(target_os = "macos")] +pub use macos::{probe, run}; +#[cfg(target_os = "windows")] +pub use windows::{probe, run}; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn probe() -> Result<(), String> { + Err("native helper is supported only on macOS and Windows".to_owned()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn run(_request: &HelperRequest) -> Result { + Err("native helper is supported only on macOS and Windows".to_owned()) +} + +pub fn run_probe_child() -> i32 { + let mut args = std::env::args().skip(2); + let Some(workspace) = args.next() else { + return 20; + }; + let Some(outside_file) = args.next() else { + return 21; + }; + let Some(outside_write) = args.next() else { + return 22; + }; + let Some(listener_address) = args.next() else { + return 23; + }; + let Some(existing_workspace_file) = args.next() else { + return 24; + }; + if args.next().is_some() { + return 24; + } + + let marker = std::path::Path::new(&workspace).join("probe-marker"); + if fs::write(&marker, b"sandboxed").is_err() { + return 25; + } + if !matches!(fs::read(&existing_workspace_file), Ok(value) if value == b"existing") + || fs::write(&existing_workspace_file, b"updated").is_err() + { + return 29; + } + let mut outside = Vec::new(); + if OpenOptions::new() + .read(true) + .open(&outside_file) + .and_then(|mut file| file.read_to_end(&mut outside)) + .is_ok() + { + return 26; + } + if fs::write(&outside_write, b"escaped").is_ok() { + return 27; + } + let Ok(listener_address) = listener_address.parse::() else { + return 23; + }; + if TcpStream::connect_timeout(&listener_address, Duration::from_millis(500)).is_ok() { + return 28; + } + 0 +} diff --git a/native/remote-workspace-helper/src/sandbox/windows.rs b/native/remote-workspace-helper/src/sandbox/windows.rs new file mode 100644 index 0000000000..2ecefef055 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/windows.rs @@ -0,0 +1,15 @@ +use crate::protocol::{CommandOutcome, HelperRequest}; + +const WINDOWS_CONFINEMENT_UNAVAILABLE: &str = + "Windows Remote Workspace command confinement is unavailable; command execution is disabled"; + +// A command-capable implementation must retain cleanup ownership through helper cancellation +// and establish Job membership atomically. Until that owner is implemented and verified, +// direct helper requests and capability probes refuse before allocating OS resources. +pub fn probe() -> Result<(), String> { + Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned()) +} + +pub fn run(_request: &HelperRequest) -> Result { + Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned()) +} diff --git a/native/remote-workspace-helper/tests/live_confinement.rs b/native/remote-workspace-helper/tests/live_confinement.rs new file mode 100644 index 0000000000..e735029ac8 --- /dev/null +++ b/native/remote-workspace-helper/tests/live_confinement.rs @@ -0,0 +1,75 @@ +#![cfg(any(target_os = "macos", target_os = "windows"))] + +use serde_json::Value; +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run_helper(request: &Value) -> Value { + let binary = env!("CARGO_BIN_EXE_opencodex-remote-workspace-helper"); + let mut child = Command::new(binary) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("native helper starts"); + child + .stdin + .take() + .expect("native helper stdin") + .write_all(&serde_json::to_vec(request).expect("helper request serializes")) + .expect("helper request is written"); + let output = child.wait_with_output().expect("native helper exits"); + assert!( + output.status.success(), + "helper stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("helper response is JSON") +} + +fn run_probe() -> Value { + run_helper(&serde_json::json!({ "version": 1, "operation": "probe" })) +} + +#[cfg(target_os = "windows")] +#[test] +fn native_helper_keeps_windows_command_execution_fail_closed() { + let unavailable = serde_json::json!({ + "version": 1, + "ok": false, + "error": "Windows Remote Workspace command confinement is unavailable; command execution is disabled" + }); + assert_eq!(run_probe(), unavailable); + let root = std::env::current_dir().expect("test cwd"); + assert_eq!(run_helper(&serde_json::json!({ + "version": 1, "operation": "run", "root": root, "cwd": root, + "command": ["cmd.exe", "/c", "exit"], "timeoutMs": 1000, "maxOutputBytes": 4096 + })), unavailable); +} + +#[cfg(target_os = "macos")] +#[test] +fn native_helper_keeps_macos_command_execution_fail_closed() { + let unavailable = serde_json::json!({ + "version": 1, + "ok": false, + "error": "macOS Remote Workspace command confinement is unavailable; file tools remain enabled" + }); + assert_eq!(run_probe(), unavailable); + + let root = std::env::current_dir().expect("test cwd"); + assert_eq!( + run_helper(&serde_json::json!({ + "version": 1, + "operation": "run", + "root": root, + "cwd": root, + "command": ["/usr/bin/true"], + "toolchainRoots": [], + "timeoutMs": 5_000, + "maxOutputBytes": 16 * 1024, + "networkAccess": false + })), + unavailable + ); +} diff --git a/package.json b/package.json index 6fae3e4d49..593ae79698 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "README.md", "SPONSORS.md", "AGENTS_INSTALL.md", + "native/remote-workspace-helper/Cargo.toml", + "native/remote-workspace-helper/Cargo.lock", + "native/remote-workspace-helper/src", "LICENSE" ], "engines": { @@ -52,6 +55,8 @@ "structure:check": "bun scripts/structure-ssot.ts", "generate:model-metadata": "bun scripts/generate-model-metadata.ts", "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", + "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fa89d735b8..55b8a9bf44 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1051,6 +1051,22 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", "remote-control-prototype.test.ts": "clients", "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", diff --git a/src/cli/remote-workspace.ts b/src/cli/remote-workspace.ts new file mode 100644 index 0000000000..5eb8a59c44 --- /dev/null +++ b/src/cli/remote-workspace.ts @@ -0,0 +1,154 @@ +import type { RemoteWorkspaceDeviceState } from "../remote-control/workspace-device"; +import { + RemoteWorkspaceDeviceFileStore, + pairRemoteWorkspaceDevice, + remoteWorkspaceCapabilitiesForCommandRunner, + runRemoteWorkspaceAgent, + type PairRemoteWorkspaceDeviceOptions, + type RemoteWorkspaceAgentRunStatus, + type RemoteWorkspaceDeviceStateStore, +} from "../remote-control/workspace-device"; +import { createPlatformRemoteWorkspaceCommandRunner } from "../remote-control/workspace-command-runner"; +import { + CliUsageError, + readSecretLine, + rejectArgs, + takeFlag, + takeJsonFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +export const REMOTE_WORKSPACE_USAGE = `Usage: + ocx remote-workspace pair --pairing-code-stdin --root [--root ...] [--toolchain-root ...] [--executor-helper ] [--name ] [--json] + ocx remote-workspace agent + ocx remote-workspace status [--json]`; + +export interface RemoteWorkspaceCliDeps extends RuntimeApiDeps { + store?: RemoteWorkspaceDeviceStateStore; + pair?: (options: PairRemoteWorkspaceDeviceOptions) => Promise; + runAgent?: typeof runRemoteWorkspaceAgent; + signal?: AbortSignal; + onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void; +} + +function takeRepeatedPathFlag(args: string[], flag: "--root" | "--toolchain-root"): string[] { + const roots: string[] = []; + for (;;) { + const index = args.indexOf(flag); + if (index < 0) break; + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new CliUsageError(`${flag} requires an absolute path`, REMOTE_WORKSPACE_USAGE); + roots.push(value); + args.splice(index, 2); + } + return roots; +} + +function publicStatus(state: RemoteWorkspaceDeviceState | null): Record { + if (!state) return { paired: false }; + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner( + createPlatformRemoteWorkspaceCommandRunner({ + linux: { + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + }, + ...(state.nativeHelper ? { native: { + helper: state.nativeHelper, + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + } } : {}), + }), + state.capabilities, + ); + return { + paired: true, + hubUrl: state.hubUrl, + deviceId: state.deviceId, + deviceName: state.deviceName, + devicePlatform: state.devicePlatform, + capabilities, + roots: state.roots.map(root => ({ id: root.id, label: root.label, path: root.path })), + toolchainRoots: state.toolchainRoots, + }; +} + +export async function runRemoteWorkspaceCommand(rawArgs: string[], deps: RemoteWorkspaceCliDeps = {}): Promise { + const args = [...rawArgs]; + const command = args.shift(); + const store = deps.store ?? new RemoteWorkspaceDeviceFileStore(); + if (command === "status") { + const wantsJson = takeJsonFlag(args); + rejectArgs(args, REMOTE_WORKSPACE_USAGE); + const status = publicStatus(store.load()); + if (wantsJson) console.log(JSON.stringify(status, null, 2)); + else if (!status.paired) console.log("Remote Workspace executor is not paired."); + else { + console.log(`Remote Workspace executor: ${status.deviceName}`); + console.log(`Hub: ${status.hubUrl}`); + console.log(`Capabilities: ${(status.capabilities as string[]).join(", ")}`); + console.log(`Workspace roots: ${(status.roots as unknown[]).length}`); + } + return 0; + } + if (command === "pair") { + const wantsJson = takeJsonFlag(args); + const readCode = takeFlag(args, "--pairing-code-stdin"); + const name = takeOption(args, "--name"); + const nativeHelperPath = takeOption(args, "--executor-helper"); + const roots = takeRepeatedPathFlag(args, "--root"); + const toolchainRoots = takeRepeatedPathFlag(args, "--toolchain-root"); + const hubUrl = args.shift(); + if (!hubUrl || !readCode || roots.length === 0) throw new CliUsageError( + "pair requires , --pairing-code-stdin, and at least one --root", + REMOTE_WORKSPACE_USAGE, + ); + rejectArgs(args, REMOTE_WORKSPACE_USAGE, { redactValues: true }); + const pairingCode = await readSecretLine(deps, "Remote Workspace pairing code"); + const state = await (deps.pair ?? pairRemoteWorkspaceDevice)({ + hubUrl, + pairingCode, + ...(name ? { name } : {}), + roots: roots.map(path => ({ path })), + toolchainRoots, + ...(nativeHelperPath ? { nativeHelperPath } : {}), + store, + }); + const status = publicStatus(state); + if (wantsJson) console.log(JSON.stringify(status, null, 2)); + else { + console.log(`Paired ${state.deviceName} with ${state.hubUrl}.`); + console.log("Run `ocx remote-workspace agent` to keep this executor online."); + } + return 0; + } + if (command === "agent") { + rejectArgs(args, REMOTE_WORKSPACE_USAGE); + const state = store.load(); + if (!state) throw new CliUsageError("Remote Workspace executor is not paired. Run the pair command first.", REMOTE_WORKSPACE_USAGE); + const controller = deps.signal ? null : new AbortController(); + const signal = deps.signal ?? controller!.signal; + const stop = () => controller?.abort(); + if (controller) { + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + } + try { + await (deps.runAgent ?? runRemoteWorkspaceAgent)({ + state, + signal, + onStatus: deps.onStatus ?? (status => { + if (status.state === "online") console.log(`Remote Workspace executor online: ${state.deviceName}`); + if (status.state === "reconnecting" && status.message) console.error(`Remote Workspace reconnecting: ${status.message}`); + }), + }); + } finally { + if (controller) { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + } + } + return 0; + } + throw new CliUsageError("choose pair, agent, or status", REMOTE_WORKSPACE_USAGE); +} diff --git a/src/lib/windows-atomic-replace.ts b/src/lib/windows-atomic-replace.ts index 0f3ba94552..a876c98bca 100644 --- a/src/lib/windows-atomic-replace.ts +++ b/src/lib/windows-atomic-replace.ts @@ -33,6 +33,7 @@ export type ReplacePublisher = | "claude-agents" | "lab-automation" | "lab-ledger" + | "remote-workspace" | "storage-cleanup" | "tray"; diff --git a/src/remote-control/index.ts b/src/remote-control/index.ts index 256832a324..352ff68043 100644 --- a/src/remote-control/index.ts +++ b/src/remote-control/index.ts @@ -1,3 +1,25 @@ +export { + parseRemoteControlClientHello, + parseRemoteControlHostHello, + serializeRemoteControlHello, + generateRemoteControlIdentityKeyPair, + RemoteControlCipher, + RemoteControlClientHandshake, + acceptRemoteControlClientHello, +} from "./crypto"; +export type { + RemoteControlIdentityKeyPair, + CreateRemoteControlClientHandshakeOptions, + AcceptRemoteControlClientHelloOptions, +} from "./crypto"; +export { + RemoteControlHost, +} from "./host"; +export type { + RemoteControlTerminal, + RemoteControlTerminalFactory, + RemoteControlHostOptions, +} from "./host"; export { REMOTE_CONTROL_PROTOCOL_VERSION, REMOTE_CONTROL_RELAY_HEADER_BYTES, @@ -24,28 +46,6 @@ export type { RemoteControlRelayFrame, RemoteControlApplicationFrame, } from "./protocol"; -export { - parseRemoteControlClientHello, - parseRemoteControlHostHello, - serializeRemoteControlHello, - generateRemoteControlIdentityKeyPair, - RemoteControlCipher, - RemoteControlClientHandshake, - acceptRemoteControlClientHello, -} from "./crypto"; -export type { - RemoteControlIdentityKeyPair, - CreateRemoteControlClientHandshakeOptions, - AcceptRemoteControlClientHelloOptions, -} from "./crypto"; -export { - RemoteControlHost, -} from "./host"; -export type { - RemoteControlTerminal, - RemoteControlTerminalFactory, - RemoteControlHostOptions, -} from "./host"; export { OpaqueRemoteControlRelay, } from "./relay"; @@ -53,6 +53,176 @@ export type { RemoteControlRelayPeer, OpaqueRemoteControlRelayOptions, } from "./relay"; +export { + RemoteWorkspaceHubAgentConnection, + RemoteWorkspaceExecutorAgentConnection, +} from "./workspace-agent-connection"; +export type { + RemoteWorkspaceControlSocket, +} from "./workspace-agent-connection"; +export { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES, + isRemoteWorkspaceAgentProfile, + serializeRemoteWorkspaceHubMessage, + serializeRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + parseRemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +export type { + RemoteWorkspaceAgentProfile, + RemoteWorkspaceHubMessage, + RemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +export { + ClaudeRemoteWorkspaceRuntimeFactory, +} from "./workspace-claude-runtime"; +export type { + ClaudeRemoteWorkspaceRuntimeOptions, +} from "./workspace-claude-runtime"; +export { + CodexRemoteWorkspaceRuntimeFactory, +} from "./workspace-codex-runtime"; +export type { + CodexRemoteWorkspaceRuntimeOptions, +} from "./workspace-codex-runtime"; +export { + resolveCodexLinuxSandboxBinary, + codexRemotePermissionProfileCompatibility, +} from "./workspace-codex-sandbox"; +export { + pinRemoteWorkspaceNativeHelper, + discoverRemoteWorkspaceNativeHelper, + parseRemoteWorkspaceNativeHelperDescriptor, + linuxRemoteWorkspaceCommandArgv, + createLinuxRemoteWorkspaceCommandRunner, + createNativeRemoteWorkspaceCommandRunner, + nativeRemoteWorkspaceCommandRunnerAvailable, + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandRunnerAvailable, +} from "./workspace-command-runner"; +export type { + LinuxRemoteWorkspaceCommandRunnerOptions, + RemoteWorkspaceNativeHelperDescriptor, + NativeRemoteWorkspaceCommandRunnerOptions, +} from "./workspace-command-runner"; +export { + remoteWorkspaceThreadStartParams, + RemoteWorkspaceCoordinator, +} from "./workspace-coordinator"; +export type { + RemoteWorkspaceSessionBinding, + RemoteWorkspaceTransport, + AppServerDynamicToolRequest, + AppServerDynamicToolResponse, +} from "./workspace-coordinator"; +export { + REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + normalizeRemoteWorkspaceHubUrl, + parseRemoteWorkspaceDeviceState, + RemoteWorkspaceDeviceFileStore, + pairRemoteWorkspaceDevice, + remoteWorkspaceCapabilitiesForCommandRunner, + connectRemoteWorkspaceAgent, + runRemoteWorkspaceAgent, +} from "./workspace-device"; +export type { + RemoteWorkspaceDeviceRoot, + RemoteWorkspaceDeviceState, + RemoteWorkspaceDeviceStateStore, + PairRemoteWorkspaceDeviceOptions, + RemoteWorkspaceWebSocketLike, + RemoteWorkspaceWebSocketFactory, + RemoteWorkspaceAgentHandle, + RemoteWorkspaceAgentRunStatus, +} from "./workspace-device"; +export { + findExecutableOnPath, +} from "./workspace-executable"; +export { + validateRemoteWorkspaceRelativePath, + RemoteWorkspaceExecutor, +} from "./workspace-executor"; +export type { + RemoteWorkspaceRoot, + RemoteWorkspaceExecutionRequest, + RemoteWorkspaceExecutorOptions, + RemoteWorkspaceCommandRequest, + RemoteWorkspaceCommandResult, + RemoteWorkspaceCommandRunner, +} from "./workspace-executor"; +export { + REMOTE_WORKSPACE_HUB_STATE_VERSION, + REMOTE_WORKSPACE_MAX_DEVICES, + REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE, + RemoteWorkspacePairingRateLimitError, + parseRemoteWorkspaceHubState, + RemoteWorkspaceHubFileStore, + RemoteWorkspaceHub, +} from "./workspace-hub"; +export type { + RemoteWorkspaceRootAdvertisement, + RemoteWorkspaceStoredDevice, + RemoteWorkspaceHubState, + RemoteWorkspaceHubStateStore, + RemoteWorkspacePublicDevice, + RemoteWorkspacePairingGrant, + RemoteWorkspacePairDeviceInput, + RemoteWorkspacePairDeviceResult, +} from "./workspace-hub"; +export { + PiRemoteWorkspaceRuntimeFactory, +} from "./workspace-pi-runtime"; +export type { + PiRemoteWorkspaceRuntimeOptions, +} from "./workspace-pi-runtime"; +export { + remoteWorkspaceProcessInvocation, + waitForRemoteWorkspaceProcessExit, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + removeRemoteWorkspaceIsolation, +} from "./workspace-process"; +export type { + RemoteWorkspaceProcessInvocationOptions, + RemoteWorkspaceOwnedProcess, + StopRemoteWorkspaceProcessOptions, +} from "./workspace-process"; +export { + REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, + frameRemoteWorkspaceRpcMessage, + RemoteWorkspaceRpcReassembler, +} from "./workspace-rpc-framing"; +export { + EncryptedRemoteWorkspaceTransport, + EncryptedRemoteWorkspaceExecutorEndpoint, +} from "./workspace-rpc"; +export type { + EncryptedRemoteWorkspaceTransportOptions, + EncryptedRemoteWorkspaceExecutorEndpointOptions, +} from "./workspace-rpc"; +export { + REMOTE_WORKSPACE_SESSION_STATE_VERSION, + parseRemoteWorkspaceSessionState, + RemoteWorkspaceSessionFileStore, + RemoteWorkspaceSessionService, +} from "./workspace-sessions"; +export type { + RemoteWorkspaceSessionStatus, + RemoteWorkspaceAccessMode, + RemoteWorkspaceSessionEvent, + RemoteWorkspaceSessionSummary, + RemoteWorkspaceRuntimeHandle, + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceSessionState, + RemoteWorkspaceSessionStateStore, +} from "./workspace-sessions"; +export { + startRemoteWorkspaceToolBridge, +} from "./workspace-tool-bridge"; +export type { + RemoteWorkspaceToolBridge, +} from "./workspace-tool-bridge"; export { REMOTE_WORKSPACE_TOOL_NAMESPACE, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, @@ -76,25 +246,6 @@ export type { RemoteWorkspaceToolCallParams, RemoteWorkspaceToolResult, } from "./workspace-tools"; -export { - REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, - REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES, - isRemoteWorkspaceAgentProfile, - serializeRemoteWorkspaceHubMessage, - serializeRemoteWorkspaceAgentMessage, - parseRemoteWorkspaceHubMessage, - parseRemoteWorkspaceAgentMessage, -} from "./workspace-agent-protocol"; -export type { - RemoteWorkspaceAgentProfile, - RemoteWorkspaceHubMessage, - RemoteWorkspaceAgentMessage, -} from "./workspace-agent-protocol"; -export { - REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, - frameRemoteWorkspaceRpcMessage, - RemoteWorkspaceRpcReassembler, -} from "./workspace-rpc-framing"; export { truncateRemoteWorkspaceUtf8, } from "./workspace-utf8"; diff --git a/src/remote-control/workspace-agent-connection.ts b/src/remote-control/workspace-agent-connection.ts new file mode 100644 index 0000000000..095fcad6ca --- /dev/null +++ b/src/remote-control/workspace-agent-connection.ts @@ -0,0 +1,366 @@ +import type { RemoteControlIdentityKeyPair } from "./crypto"; +import { + RemoteControlClientHandshake, + acceptRemoteControlClientHello, +} from "./crypto"; +import type { RemoteWorkspaceExecutor } from "./workspace-executor"; +import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol"; +import { + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, +} from "./workspace-rpc"; +import { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + serializeRemoteWorkspaceAgentMessage, + serializeRemoteWorkspaceHubMessage, + type RemoteWorkspaceAgentProfile, +} from "./workspace-agent-protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; + +const SESSION_OPEN_TIMEOUT_MS = 10_000; + +export interface RemoteWorkspaceControlSocket { + send(value: string): void | Promise; + close(code: number, reason: string): void; +} + +interface PendingHubSession { + handshake: RemoteControlClientHandshake; + resolve(transport: EncryptedRemoteWorkspaceTransport): void; + reject(error: Error): void; + timer: ReturnType; +} + +function safeReason(value: string): string { + const cleaned = value.replace(/[\x00-\x1f\x7f]/g, " ").trim(); + const selected = cleaned || "remote workspace session closed"; + return truncateRemoteWorkspaceUtf8(selected, 120); +} + +/** Hub-side representation of one authenticated, online OCX-only executor. */ +export class RemoteWorkspaceHubAgentConnection { + private readonly pending = new Map(); + private readonly active = new Map(); + private readonly cancelledSessionIds = new Set(); + private closed = false; + private presenceAccepted = false; + private presencePending = false; + private currentCapabilities: RemoteWorkspaceCapability[]; + + constructor(private readonly options: { + deviceId: string; + devicePublicKey: string; + hubIdentity: RemoteControlIdentityKeyPair; + socket: RemoteWorkspaceControlSocket; + capabilities?: readonly RemoteWorkspaceCapability[]; + onCapabilities?: (capabilities: readonly RemoteWorkspaceCapability[]) => void; + sessionOpenTimeoutMs?: number; + }) { + this.currentCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + } + + isOnline(): boolean { + return !this.closed && this.presenceAccepted; + } + + capabilities(): RemoteWorkspaceCapability[] { + return [...this.currentCapabilities]; + } + + async openSession(options: { + sessionId: string; + rootId: string; + profile: RemoteWorkspaceAgentProfile; + capabilities: readonly RemoteWorkspaceCapability[]; + }): Promise { + if (!this.isOnline()) throw new Error("remote workspace executor is offline"); + if (this.pending.has(options.sessionId) || this.active.has(options.sessionId)) { + throw new Error("remote workspace session already exists"); + } + if (this.pending.size + this.active.size >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + if (!Array.isArray(options.capabilities)) throw new Error("remote workspace session requires explicit capabilities"); + const requestedCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + if (requestedCapabilities.some(capability => !this.currentCapabilities.includes(capability))) { + throw new Error("remote workspace session requests an unavailable capability"); + } + const handshake = RemoteControlClientHandshake.create({ + sessionId: options.sessionId, + deviceId: this.options.deviceId, + commandProfile: options.profile, + capabilities: requestedCapabilities, + accountPrivateKey: this.options.hubIdentity.privateKey, + }); + const timeoutMs = this.options.sessionOpenTimeoutMs ?? SESSION_OPEN_TIMEOUT_MS; + const opened = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(options.sessionId); + this.rememberCancelledSession(options.sessionId); + reject(new Error("remote workspace session handshake timed out")); + }, timeoutMs); + this.pending.set(options.sessionId, { handshake, resolve, reject, timer }); + }); + try { + await this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: options.rootId, + clientHello: handshake.hello, + })); + } catch (error) { + const pending = this.pending.get(options.sessionId); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(options.sessionId); + pending.reject(error instanceof Error ? error : new Error("remote workspace session send failed")); + } + } + return await opened; + } + + receive(raw: string | Uint8Array): void { + if (this.closed) throw new Error("remote workspace executor is offline"); + const message = parseRemoteWorkspaceAgentMessage(raw); + if (message.type === "presence") { + if (this.presenceAccepted || this.presencePending) { + throw new Error("remote workspace executor sent duplicate presence"); + } + const approved = parseRemoteWorkspaceCapabilities(this.options.capabilities); + const capabilities = parseRemoteWorkspaceCapabilities(message.capabilities.filter(capability => approved.includes(capability))); + this.presencePending = true; + const accept = () => { + if (this.closed) return; + this.options.onCapabilities?.(capabilities); + this.currentCapabilities = capabilities; + this.presenceAccepted = true; + this.presencePending = false; + }; + let sent: void | Promise; + try { + sent = this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence_ack", + capabilities, + })); + } catch (error) { + this.presencePending = false; + throw error; + } + if (sent && typeof sent.then === "function") { + void sent.then(accept).catch(() => this.close("remote workspace presence acknowledgement failed")); + } else { + accept(); + } + return; + } + if (!this.presenceAccepted) { + throw new Error("remote workspace executor presence is required before session traffic"); + } + if (message.type === "heartbeat") return; + if (message.type === "session_accept") { + const pending = this.pending.get(message.sessionId); + if (!pending) { + if (!this.cancelledSessionIds.delete(message.sessionId)) { + throw new Error("remote workspace accepted an unknown session"); + } + void Promise.resolve(this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_close", + sessionId: message.sessionId, + reason: "remote workspace session was already cancelled", + }))).catch(() => this.close("remote workspace cancelled-session cleanup failed")); + return; + } + const cipher = pending.handshake.complete(message.hostHello, this.options.devicePublicKey); + const transport = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: this.options.deviceId, + cipher, + sendCiphertext: value => this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "ciphertext", + sessionId: message.sessionId, + payload: value, + })), + }); + clearTimeout(pending.timer); + this.pending.delete(message.sessionId); + this.active.set(message.sessionId, transport); + pending.resolve(transport); + return; + } + if (message.type === "session_reject") { + const pending = this.pending.get(message.sessionId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.sessionId); + pending.reject(new Error(safeReason(message.reason))); + return; + } + const transport = this.active.get(message.sessionId); + if (!transport) throw new Error("remote workspace ciphertext targeted an unknown session"); + transport.receiveCiphertext(message.payload); + } + + async closeSession(sessionId: string, reason = "remote workspace session closed"): Promise { + const pending = this.pending.get(sessionId); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(sessionId); + this.rememberCancelledSession(sessionId); + pending.reject(new Error(safeReason(reason))); + } + const transport = this.active.get(sessionId); + if (transport) { + this.active.delete(sessionId); + transport.close(safeReason(reason)); + } + if (this.closed) return; + await this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_close", + sessionId, + reason: safeReason(reason), + })); + } + + close(reason = "remote workspace executor disconnected"): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(safeReason(reason))); + } + this.pending.clear(); + for (const transport of this.active.values()) transport.close(safeReason(reason)); + this.active.clear(); + this.cancelledSessionIds.clear(); + try { this.options.socket.close(1008, safeReason(reason)); } catch { /* socket is already gone */ } + } + + private rememberCancelledSession(sessionId: string): void { + this.cancelledSessionIds.add(sessionId); + while (this.cancelledSessionIds.size > 16) { + const oldest = this.cancelledSessionIds.values().next(); + if (oldest.done) break; + this.cancelledSessionIds.delete(oldest.value); + } + } +} + +/** Executor-side connection. It owns no Codex, Claude Code, Pi, provider key, or model session. */ +export class RemoteWorkspaceExecutorAgentConnection { + private readonly sessions = new Map(); + private closed = false; + + constructor(private readonly options: { + deviceId: string; + deviceIdentity: RemoteControlIdentityKeyPair; + hubPublicKey: string; + executor: RemoteWorkspaceExecutor; + capabilities?: readonly RemoteWorkspaceCapability[]; + onPresenceAccepted?: () => void; + socket: RemoteWorkspaceControlSocket; + }) { + this.currentCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + } + + private currentCapabilities: RemoteWorkspaceCapability[]; + + async receive(raw: string | Uint8Array): Promise { + if (this.closed) throw new Error("remote workspace agent connection is closed"); + const message = parseRemoteWorkspaceHubMessage(raw); + if (message.type === "presence_ack") { + if (message.capabilities.some(capability => !this.currentCapabilities.includes(capability))) { + throw new Error("remote workspace Hub acknowledged different executor capabilities"); + } + this.currentCapabilities = [...message.capabilities]; + this.options.onPresenceAccepted?.(); + return; + } + if (message.type === "session_open") { + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint | null = null; + try { + if (this.sessions.has(message.clientHello.sessionId)) { + throw new Error("remote workspace executor session already exists"); + } + if (this.sessions.size >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + if (message.clientHello.deviceId !== this.options.deviceId) { + throw new Error("remote workspace session targeted another executor"); + } + if (!this.options.executor.hasApprovedRoot(message.rootId)) { + throw new Error("remote workspace root is not approved"); + } + const accepted = acceptRemoteControlClientHello(message.clientHello, { + expectedSessionId: message.clientHello.sessionId, + expectedDeviceId: this.options.deviceId, + accountPublicKey: this.options.hubPublicKey, + devicePrivateKey: this.options.deviceIdentity.privateKey, + allowedCapabilities: this.currentCapabilities, + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: this.options.deviceId, + sessionId: message.clientHello.sessionId, + rootId: message.rootId, + capabilities: parseRemoteWorkspaceCapabilities(accepted.hello.capabilities), + cipher: accepted.cipher, + executor: this.options.executor, + sendCiphertext: value => this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "ciphertext", + sessionId: message.clientHello.sessionId, + payload: value, + })), + }); + this.sessions.set(message.clientHello.sessionId, endpoint); + await this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_accept", + sessionId: message.clientHello.sessionId, + hostHello: accepted.hello, + })); + } catch (error) { + if (endpoint) { + this.sessions.delete(message.clientHello.sessionId); + endpoint.close(); + } + await this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_reject", + sessionId: message.clientHello.sessionId, + reason: safeReason(error instanceof Error ? error.message : "remote workspace session refused"), + })); + } + return; + } + if (message.type === "session_close") { + this.sessions.get(message.sessionId)?.close(); + this.sessions.delete(message.sessionId); + return; + } + const endpoint = this.sessions.get(message.sessionId); + if (!endpoint) throw new Error("remote workspace ciphertext targeted an unknown executor session"); + // Decryption and counter validation happen synchronously before this returns. The execution + // promise is intentionally detached so an unencrypted session_close control frame can abort a + // long-running command instead of waiting behind that command on the socket's ordered queue. + void endpoint.receiveCiphertext(message.payload).catch(() => { + this.close(); + this.options.socket.close(1008, "remote workspace protocol error"); + }); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const endpoint of this.sessions.values()) endpoint.close(); + this.sessions.clear(); + } +} diff --git a/src/remote-control/workspace-claude-runtime.ts b/src/remote-control/workspace-claude-runtime.ts new file mode 100644 index 0000000000..243ee5e1da --- /dev/null +++ b/src/remote-control/workspace-claude-runtime.ts @@ -0,0 +1,243 @@ +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { remoteWorkspaceDeveloperInstructions } from "./workspace-tools"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, +} from "./workspace-process"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, +} from "./workspace-sessions"; + +const MAX_OUTPUT_LINE_BYTES = 2 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; + +function safeError(value: unknown, fallback: string): string { + return (value instanceof Error ? value.message : typeof value === "string" ? value : fallback) + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function assistantText(value: unknown): string | null { + const message = record(value); + if (!message || !Array.isArray(message.content)) return null; + const text = message.content.flatMap(raw => { + const part = record(raw); + return part?.type === "text" && typeof part.text === "string" ? [part.text] : []; + }).join(""); + return text || null; +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let retained = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + if (retained >= MAX_STDERR_BYTES) continue; + const chunk = next.value.subarray(0, MAX_STDERR_BYTES - retained); + chunks.push(chunk); + retained += chunk.byteLength; + } + } finally { + reader.releaseLock(); + } + const merged = new Uint8Array(retained); + let offset = 0; + for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder().decode(merged); +} + +export interface ClaudeRemoteWorkspaceRuntimeOptions { + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class ClaudeRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "claude" as const; + + constructor(private readonly options: ClaudeRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + const command = this.options.command && this.options.command.length > 0 + ? this.options.command[0] + : findExecutableOnPath("claude"); + return command + ? { available: true, ...(this.options.version ? { version: this.options.version } : {}) } + : { available: false, reason: "Claude Code is not installed on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const configuredCommand = this.options.command && this.options.command.length > 0 + ? [...this.options.command] + : null; + const executable = configuredCommand?.[0] ?? findExecutableOnPath("claude"); + if (!executable) throw new Error("Claude Code is not installed on this Hub"); + const commandPrefix = configuredCommand ?? [executable]; + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-claude-")); + try { + chmodSync(isolation, 0o700); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const threadId = options.resumeThreadId ?? randomUUID(); + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const mcpPath = join(isolation, "mcp.json"); + try { + writeFileSync(mcpPath, `${JSON.stringify({ + mcpServers: { + ocx_remote_workspace: { + type: "http", + url: `${bridge.url}/mcp`, + headers: { Authorization: `Bearer ${bridge.token}` }, + }, + }, + })}\n`, { mode: 0o600 }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + let firstTurn = options.resumeThreadId === undefined; + let active: Bun.Subprocess<"pipe", "pipe", "pipe"> | null = null; + let stopped = false; + let stopOperation: Promise | null = null; + + const runPrompt = async (text: string): Promise => { + if (stopped) throw new Error("Claude Remote Workspace session is stopped"); + if (active) throw new Error("Claude Remote Workspace turn is already active"); + const args = [ + ...commandPrefix, + "-p", + "--input-format", "text", + "--output-format", "stream-json", + "--verbose", + "--strict-mcp-config", + "--mcp-config", mcpPath, + "--setting-sources", "", + "--tools", "", + "--allowedTools", "mcp__ocx_remote_workspace__*", + "--permission-mode", "dontAsk", + "--disable-slash-commands", + "--no-chrome", + "--system-prompt", remoteWorkspaceDeveloperInstructions(options.deviceName, options.tools), + firstTurn ? "--session-id" : "--resume", + threadId, + ]; + const childEnv = { ...process.env, ...this.options.env }; + const invocation = remoteWorkspaceProcessInvocation(args, { env: childEnv }); + const child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + active = child; + try { + child.stdin.write(text); + child.stdin.end(); + } catch (error) { + await stopRemoteWorkspaceProcess(child); + if (active === child) active = null; + throw error; + } + const stderrPromise = drain(child.stderr); + const reader = child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + let emittedAssistant = false; + let resultError: string | null = null; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_OUTPUT_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Claude Code output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_OUTPUT_LINE_BYTES) throw new Error("Claude Code output line is too large"); + if (line) { + const event = record(JSON.parse(line)); + if (event?.type === "assistant") { + const answer = assistantText(event.message); + if (answer) { options.emit("assistant", answer); emittedAssistant = true; } + } + if (event?.type === "result") { + if (event.is_error === true) resultError = safeError(event.result, "Claude Code turn failed"); + else if (!emittedAssistant && typeof event.result === "string" && event.result) { + options.emit("assistant", event.result); + emittedAssistant = true; + } + } + } + newline = buffer.indexOf("\n"); + } + } + const exitCode = await child.exited; + const stderr = await stderrPromise; + if (resultError) throw new Error(resultError); + if (exitCode !== 0) throw new Error(safeError(stderr, `Claude Code exited with code ${exitCode}`)); + firstTurn = false; + } catch (error) { + await stopRemoteWorkspaceProcess(child); + await stderrPromise.catch(() => ""); + throw error; + } finally { + reader.releaseLock(); + if (active === child) active = null; + } + }; + + return { + threadId, + canResume: () => !firstTurn, + prompt: runPrompt, + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + const child = active; + stopOperation = runRemoteWorkspaceCleanupSteps([ + async () => { if (child) await stopRemoteWorkspaceProcess(child); }, + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } +} diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts new file mode 100644 index 0000000000..b064e3c9b8 --- /dev/null +++ b/src/remote-control/workspace-codex-runtime.ts @@ -0,0 +1,531 @@ +import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { resolveCodexRuntime } from "../codex/runtime"; +import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; +import { REMOTE_WORKSPACE_TOOL_NAMESPACE } from "./workspace-tools"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + waitForRemoteWorkspaceProcessExit, +} from "./workspace-process"; +import { + codexRemotePermissionProfileCompatibility, + resolveCodexLinuxSandboxBinary, +} from "./workspace-codex-sandbox"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, + RemoteWorkspaceSessionEvent, +} from "./workspace-sessions"; + +const MAX_JSON_LINE_BYTES = 2 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; +const MAX_BUFFERED_ASSISTANT_ITEMS = 32; +const MAX_BUFFERED_ASSISTANT_BYTES = 64 * 1024; +const MAX_EARLY_TURN_COMPLETIONS = 16; +const START_TIMEOUT_MS = 15_000; +const REQUEST_TIMEOUT_MS = 60_000; + +interface JsonRpcMessage { + id?: string | number; + method?: string; + params?: Record; + result?: Record; + error?: { message?: unknown }; +} + +interface PendingRpc { + resolve(message: JsonRpcMessage): void; + reject(error: Error): void; + timer: ReturnType; +} + +function parseJsonRpcMessage(value: unknown): JsonRpcMessage { + const raw = object(value); + if (!raw) throw new Error("invalid Codex App Server message"); + if (raw.id !== undefined && typeof raw.id !== "string" && typeof raw.id !== "number") { + throw new Error("invalid Codex App Server message ID"); + } + if (raw.method !== undefined && typeof raw.method !== "string") { + throw new Error("invalid Codex App Server method"); + } + const params = raw.params === undefined ? undefined : object(raw.params); + const result = raw.result === undefined ? undefined : object(raw.result); + const error = raw.error === undefined ? undefined : object(raw.error); + if ((raw.params !== undefined && !params) + || (raw.result !== undefined && !result) + || (raw.error !== undefined && !error)) { + throw new Error("invalid Codex App Server message fields"); + } + return { + ...(raw.id !== undefined ? { id: raw.id } : {}), + ...(typeof raw.method === "string" ? { method: raw.method } : {}), + ...(params ? { params } : {}), + ...(result ? { result } : {}), + ...(error ? { error: { message: error.message } } : {}), + }; +} + +function errorMessage(value: unknown, fallback: string): string { + const raw = value instanceof Error ? value.message : typeof value === "string" ? value : fallback; + return raw.replace(/[^\x20-\x7e\n\t]/g, " ").slice(0, 4_096) || fallback; +} + +function object(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function nestedString(value: unknown, keys: readonly string[]): string | null { + let current: unknown = value; + for (const key of keys) current = object(current)?.[key]; + return typeof current === "string" && current.length > 0 ? current : null; +} + +function itemText(value: unknown): string | null { + const item = object(value); + if (!item) return null; + if (typeof item.text === "string" && item.text.length > 0) return item.text; + if (!Array.isArray(item.content)) return null; + const parts: string[] = []; + for (const raw of item.content) { + const part = object(raw); + const text = part && typeof part.text === "string" ? part.text : null; + if (text) parts.push(text); + } + return parts.length > 0 ? parts.join("") : null; +} + +function appendBoundedUtf8(current: string, delta: string, maximum: number): string { + const marker = "\n[truncated]"; + if (current.endsWith(marker)) return current; + const combined = `${current}${delta}`; + if (Buffer.byteLength(combined, "utf8") <= maximum) return combined; + const bodyLimit = maximum - Buffer.byteLength(marker, "utf8"); + return `${truncateRemoteWorkspaceUtf8(combined, bodyLimit)}${marker}`; +} + +function setBounded(map: Map, key: K, value: V, maximum: number): void { + if (!map.has(key) && map.size >= maximum) { + const oldest = map.keys().next(); + if (!oldest.done) map.delete(oldest.value); + } + map.set(key, value); +} + +class JsonLineRpcProcess { + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + private closeError: Error | null = null; + + onRequest: ((message: JsonRpcMessage) => Promise) | null = null; + onNotification: ((message: JsonRpcMessage) => void) | null = null; + onClose: ((error: Error) => void) | null = null; + + constructor(private readonly child: Bun.Subprocess<"pipe", "pipe", "pipe">) { + void this.readStdout(); + void this.drainStderr(); + void child.exited.then(code => this.fail(new Error(`Codex App Server exited with code ${code}`))); + } + + request(method: string, params: Record, timeoutMs = REQUEST_TIMEOUT_MS): Promise { + if (this.closed) return Promise.reject(this.closeError ?? new Error("Codex App Server is closed")); + const id = ++this.nextId; + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Codex App Server ${method} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + }); + try { + this.send({ jsonrpc: "2.0", id, method, params }); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error instanceof Error ? error : new Error("Codex App Server write failed")); + } + } + return result; + } + + notify(method: string, params: Record): void { + this.send({ jsonrpc: "2.0", method, params }); + } + + async close(): Promise { + try { + if (!this.closed) { + try { this.child.stdin.end(); } catch { /* child already closed */ } + } + const graceful = await waitForRemoteWorkspaceProcessExit(this.child, 1_500); + if (!graceful) { + await stopRemoteWorkspaceProcess(this.child); + } + } finally { + // Pending callers must settle even if the OS refuses to reap the child. + this.fail(new Error("Codex App Server session closed")); + } + } + + private send(message: Record): void { + if (this.closed) throw this.closeError ?? new Error("Codex App Server is closed"); + const line = `${JSON.stringify(message)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Codex App Server message is too large"); + this.child.stdin.write(line); + this.child.stdin.flush(); + } + + private async readStdout(): Promise { + const reader = this.child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_JSON_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Codex App Server output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Codex App Server output line is too large"); + if (line) this.receive(parseJsonRpcMessage(JSON.parse(line))); + newline = buffer.indexOf("\n"); + } + } + } catch (error) { + void stopRemoteWorkspaceProcess(this.child).catch(() => {}); + this.fail(new Error(errorMessage(error, "Codex App Server output failed"))); + } finally { + reader.releaseLock(); + } + } + + private async drainStderr(): Promise { + const reader = this.child.stderr.getReader(); + let retained = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + retained = Math.min(MAX_STDERR_BYTES, retained + next.value.byteLength); + } + } catch { + // stdout and the exit code own the user-visible process failure. + } finally { + reader.releaseLock(); + void retained; + } + } + + private receive(message: JsonRpcMessage): void { + if (!message || typeof message !== "object") throw new Error("invalid Codex App Server message"); + if (message.id !== undefined && typeof message.method !== "string") { + const pending = this.pending.get(message.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(errorMessage(message.error.message, "Codex App Server request failed"))); + else pending.resolve(message); + return; + } + if (typeof message.method !== "string") return; + if (message.id === undefined) { + this.onNotification?.(message); + return; + } + const id = message.id; + const request = this.onRequest; + if (!request) { + this.send({ jsonrpc: "2.0", id, error: { code: -32_601, message: "client request handler is unavailable" } }); + return; + } + void request(message).then( + response => this.send({ jsonrpc: "2.0", ...response }), + error => this.send({ + jsonrpc: "2.0", + id, + error: { code: -32_000, message: errorMessage(error, "Remote Workspace tool failed") }, + }), + ); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + this.closeError = error; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + this.onClose?.(error); + } +} + +interface ActiveTurn { + id: string; + resolve(): void; + reject(error: Error): void; +} + +export interface CodexRemoteWorkspaceRuntimeOptions { + /** Test seam. Production resolves the configured, trusted Codex runtime. */ + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "codex" as const; + + constructor(private readonly options: CodexRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + if (this.options.command && this.options.command.length > 0) { + return { available: true, version: this.options.version ?? "test" }; + } + const resolved = resolveCodexRuntime(); + const compatibility = codexRemotePermissionProfileCompatibility(); + if (!compatibility.compatible) return { available: false, reason: compatibility.reason }; + return resolved.runtime.version + ? { available: true, version: resolved.runtime.version } + : { available: false, reason: "Codex CLI is not installed or runnable on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const command = this.options.command + ? [...this.options.command] + : [resolveCodexRuntime().runtime.command]; + if (command.length < 1) throw new Error("Codex CLI is unavailable on this Hub"); + const executablePath = isAbsolute(command[0]!) ? command[0]! : findExecutableOnPath(command[0]!); + if (!executablePath) throw new Error("Codex CLI executable could not be resolved on this Hub"); + command[0] = executablePath; + const runtimeDirectory = dirname(realpathSync(executablePath)); + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-codex-")); + let processPath = process.env.PATH ?? "/usr/bin:/bin"; + const runtimeReadPaths = [runtimeDirectory]; + try { + chmodSync(isolation, 0o700); + if (process.platform === "linux") { + const native = resolveCodexLinuxSandboxBinary(executablePath); + if (!native) { + throw new Error("Codex Remote Workspace could not locate the native Linux permission-profile helper"); + } + const helperDir = join(isolation, "sandbox-bin"); + mkdirSync(helperDir, { mode: 0o700 }); + const helper = join(helperDir, "codex-linux-sandbox"); + try { linkSync(native, helper); } + catch { symlinkSync(native, helper); } + processPath = `${helperDir}:${processPath}`; + runtimeReadPaths.push(dirname(native), helperDir); + } + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const thread = { id: "" }; + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId: () => thread.id, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const tokenEnvVar = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; + const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; + const childEnv = { ...process.env, ...this.options.env, PATH: processPath, [tokenEnvVar]: bridge.token }; + const invocation = remoteWorkspaceProcessInvocation([ + ...command, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(tokenEnvVar)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=${JSON.stringify(options.tools)}`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { env: childEnv }); + let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const peer = new JsonLineRpcProcess(child); + let activeTurn: ActiveTurn | null = null; + let stopped = false; + const completedBeforeWait = new Map(); + const assistantDeltas = new Map(); + let stopOperation: Promise | null = null; + + const finishTurn = (turnId: string, status: string, detail: string | null): void => { + if (!activeTurn || activeTurn.id !== turnId) { + setBounded(completedBeforeWait, turnId, { status, error: detail }, MAX_EARLY_TURN_COMPLETIONS); + return; + } + const current = activeTurn; + activeTurn = null; + assistantDeltas.clear(); + if (status === "completed") current.resolve(); + else current.reject(new Error(detail ?? `Codex turn ${status}`)); + }; + + peer.onRequest = async message => { + if (message.method !== "item/tool/call" || message.id === undefined) { + throw new Error("unsupported Codex App Server client request"); + } + const tool = nestedString(message.params, ["tool"]) ?? "remote tool"; + options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`); + return options.coordinator.handle({ + method: "item/tool/call", + id: message.id, + params: message.params, + }); + }; + peer.onNotification = message => { + const params = message.params ?? {}; + if (message.method === "item/agentMessage/delta") { + const itemId = nestedString(params, ["itemId"]) ?? nestedString(params, ["item", "id"]); + const delta = nestedString(params, ["delta"]); + if (itemId && delta) { + setBounded( + assistantDeltas, + itemId, + appendBoundedUtf8(assistantDeltas.get(itemId) ?? "", delta, MAX_BUFFERED_ASSISTANT_BYTES), + MAX_BUFFERED_ASSISTANT_ITEMS, + ); + } + return; + } + if (message.method === "item/completed") { + const item = object(params.item); + const itemId = item && typeof item.id === "string" ? item.id : null; + const text = itemText(item) ?? (itemId ? assistantDeltas.get(itemId) ?? null : null); + if (itemId) assistantDeltas.delete(itemId); + if (text) options.emit("assistant", text); + return; + } + if (message.method === "turn/completed") { + const turn = object(params.turn); + const turnId = turn && typeof turn.id === "string" ? turn.id : null; + if (!turnId) return; + const status = typeof turn?.status === "string" ? turn.status : "failed"; + const detail = nestedString(turn, ["error", "message"]); + finishTurn(turnId, status, detail); + } + }; + peer.onClose = error => { + const current = activeTurn; + activeTurn = null; + completedBeforeWait.clear(); + assistantDeltas.clear(); + current?.reject(error); + }; + + try { + await peer.request("initialize", { + clientInfo: { name: "opencodex_remote_workspace", title: "OpenCodex Remote Workspace", version: "1" }, + capabilities: { experimentalApi: true }, + }, START_TIMEOUT_MS); + peer.notify("initialized", {}); + const effective = await peer.request("config/read", { cwd: isolation, includeLayers: false }, START_TIMEOUT_MS); + const effectiveConfig = object(effective.result?.config) ?? {}; + if (typeof effectiveConfig.sandbox_mode === "string" || effectiveConfig.sandbox_workspace_write) { + throw new Error("Codex Remote Workspace requires permission profiles; remove legacy sandbox_mode settings from the selected Codex profile first"); + } + const disabledServerNames = Object.keys(object(effectiveConfig.mcp_servers) ?? {}); + const disabledHookNames = Object.keys(object(effectiveConfig.hooks) ?? {}); + const threadParams = remoteWorkspaceThreadStartParams({ + executorName: options.deviceName, + coordinatorIsolationPath: isolation, + tools: options.tools, + mcp: { + url: `${bridge.url}/mcp`, + bearerTokenEnvVar: tokenEnvVar, + disabledServerNames, + disabledHookNames, + hubRuntimeReadPaths: runtimeReadPaths, + }, + }); + const { ephemeral: _startOnlyEphemeral, ...resumeParams } = threadParams; + const started = options.resumeThreadId + ? await peer.request("thread/resume", { ...resumeParams, threadId: options.resumeThreadId }, START_TIMEOUT_MS) + : await peer.request("thread/start", threadParams, START_TIMEOUT_MS); + const threadId = nestedString(started.result, ["thread", "id"]); + if (!threadId) throw new Error("Codex App Server returned no thread ID"); + if (options.resumeThreadId && threadId !== options.resumeThreadId) { + throw new Error("Codex App Server resumed a different Remote Workspace thread"); + } + thread.id = threadId; + + return { + threadId, + async prompt(text: string): Promise { + if (stopped) throw new Error("Codex Remote Workspace session is stopped"); + if (activeTurn) throw new Error("Codex Remote Workspace turn is already active"); + const startedTurn = await peer.request("turn/start", { + threadId, + input: [{ type: "text", text }], + approvalPolicy: "never", + }); + const turnId = nestedString(startedTurn.result, ["turn", "id"]); + if (!turnId) throw new Error("Codex App Server returned no turn ID"); + const early = completedBeforeWait.get(turnId); + if (early) { + completedBeforeWait.delete(turnId); + if (early.status === "completed") return; + throw new Error(early.error ?? `Codex turn ${early.status}`); + } + await new Promise((resolve, reject) => { activeTurn = { id: turnId, resolve, reject }; }); + }, + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + const turn = activeTurn; + stopOperation = runRemoteWorkspaceCleanupSteps([ + async () => { + if (turn) await peer.request("turn/interrupt", { threadId, turnId: turn.id }, 3_000).catch(() => {}); + }, + () => peer.close(), + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } catch (error) { + await peer.close().catch(() => {}); + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + } +} diff --git a/src/remote-control/workspace-codex-sandbox.ts b/src/remote-control/workspace-codex-sandbox.ts new file mode 100644 index 0000000000..21630db764 --- /dev/null +++ b/src/remote-control/workspace-codex-sandbox.ts @@ -0,0 +1,115 @@ +import { accessSync, constants, existsSync, openSync, closeSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"; +import { arch } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { inspectCodexShimBackingForCommand } from "../codex/shim"; +import { findExecutableOnPath } from "./workspace-executable"; +import { resolveCodexHomeDir } from "../codex/home"; + +function isNativeExecutable(path: string): boolean { + let descriptor: number | null = null; + try { + descriptor = openSync(path, "r"); + const header = Buffer.alloc(4); + if (readSync(descriptor, header, 0, header.length, 0) !== header.length) return false; + return header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + } catch { + return false; + } finally { + if (descriptor !== null) closeSync(descriptor); + } +} + +function packageRootForEntrypoint(path: string): string | null { + let current = dirname(path); + for (let depth = 0; depth < 10; depth += 1) { + const manifest = join(current, "package.json"); + if (existsSync(manifest)) { + try { + const parsed = JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }; + if (parsed.name === "@openai/codex") return current; + } catch { /* keep walking */ } + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +function checkedNative(path: string): string | null { + try { + const canonical = realpathSync(path); + if (!statSync(canonical).isFile() || !isNativeExecutable(canonical)) return null; + accessSync(canonical, constants.X_OK); + return canonical; + } catch { + return null; + } +} + +function generatedShimBacking(path: string): string | null { + try { + const source = readFileSync(path, "utf8"); + if (Buffer.byteLength(source, "utf8") > 128 * 1024 + || !source.includes("# opencodex codex autostart shim")) return null; + const match = /^exec '([^'\r\n]+)' "\$@"\s*$/m.exec(source); + return match?.[1] && isAbsolute(match[1]) ? match[1] : null; + } catch { + return null; + } +} + +/** + * Permission profiles invoke the same native Codex binary under argv[0] + * `codex-linux-sandbox`. npm and OpenCodex shims expose a JS/shell launcher instead, + * so resolve the package-owned native binary without executing or modifying the install. + */ +export function resolveCodexLinuxSandboxBinary(command: string): string | null { + if (process.platform !== "linux") return null; + const selected = isAbsolute(command) ? command : findExecutableOnPath(command); + if (!selected) return null; + const shim = inspectCodexShimBackingForCommand(selected); + const entrypoint = shim.status === "matched" + ? shim.backingPath + : generatedShimBacking(selected) ?? selected; + const direct = checkedNative(entrypoint); + if (direct) return direct; + let canonical: string; + try { canonical = realpathSync(entrypoint); } catch { return null; } + const root = packageRootForEntrypoint(canonical); + if (!root) return null; + const target = arch() === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"; + const packageName = arch() === "arm64" ? "codex-linux-arm64" : "codex-linux-x64"; + const candidates = [ + join(root, "node_modules", "@openai", packageName, "vendor", target, "bin", "codex"), + join(root, "vendor", target, "bin", "codex"), + ]; + for (const candidate of candidates) { + const native = checkedNative(candidate); + if (native) return native; + } + return null; +} + +export function codexRemotePermissionProfileCompatibility( + codexHome = resolveCodexHomeDir(), +): { compatible: boolean; reason?: string } { + const configPath = join(codexHome, "config.toml"); + if (!existsSync(configPath)) return { compatible: true }; + try { + const metadata = statSync(configPath); + if (!metadata.isFile() || metadata.size > 4 * 1024 * 1024) { + return { compatible: false, reason: "Codex config cannot be safely inspected for Remote Workspace permissions." }; + } + const config = Bun.TOML.parse(readFileSync(configPath, "utf8")) as Record; + if (typeof config.sandbox_mode === "string" || config.sandbox_workspace_write !== undefined) { + return { + compatible: false, + reason: "Codex Remote Workspace needs permission profiles, but this Codex config still selects legacy sandbox_mode.", + }; + } + return { compatible: true }; + } catch { + return { compatible: false, reason: "Codex config could not be parsed for Remote Workspace permissions." }; + } +} diff --git a/src/remote-control/workspace-command-runner.ts b/src/remote-control/workspace-command-runner.ts new file mode 100644 index 0000000000..f9a3625caa --- /dev/null +++ b/src/remote-control/workspace-command-runner.ts @@ -0,0 +1,749 @@ +import { createHash } from "node:crypto"; +import { + accessSync, + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + opendirSync, + openSync, + readSync, + realpathSync, + statSync, +} from "node:fs"; +import { arch } from "node:os"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import type { + RemoteWorkspaceCommandRequest, + RemoteWorkspaceCommandResult, + RemoteWorkspaceCommandRunner, +} from "./workspace-executor"; + +const DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const NATIVE_HELPER_PROTOCOL_VERSION = 1; +const MAX_NATIVE_HELPER_BYTES = 64 * 1024 * 1024; +const MAX_NATIVE_HELPER_ERROR_CHARS = 512; +const MAX_NATIVE_HELPER_STDERR_BYTES = 16 * 1024; +const MAX_WORKSPACE_PREFLIGHT_ENTRIES = 250_000; +const SANDBOX_BUN_PATH = "/ocx-runtime/bin/bun"; +const READABLE_SYSTEM_PATHS = [ + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib64", +] as const; +const READABLE_ETC_PATHS = [ + "/etc/alternatives", + "/etc/ca-certificates", + "/etc/ssl", + "/etc/hosts", + "/etc/nsswitch.conf", + "/etc/passwd", + "/etc/group", + "/etc/localtime", + "/etc/resolv.conf", +] as const; + +export interface LinuxRemoteWorkspaceCommandRunnerOptions { + bubblewrapPath?: string; + networkAccess?: boolean; + /** Additional read-only toolchain trees explicitly approved by the device owner. */ + toolchainRoots?: readonly string[]; + /** Exact Bun executable used by OCX; mounted as one file rather than exposing its host directory. */ + runtimeExecutablePath?: string; + /** Writable roots inspected before command capability is advertised. */ + writableRoots?: readonly string[]; + spawn?: typeof Bun.spawn; + /** Cross-platform test seam for the real namespace capability probe. */ + probe?: (argv: readonly string[]) => boolean; +} + +export interface RemoteWorkspaceNativeHelperDescriptor { + path: string; + sha256: string; +} + +interface NativeHelperRequest { + version: typeof NATIVE_HELPER_PROTOCOL_VERSION; + operation: "probe" | "run"; + root?: string; + cwd?: string; + command?: string[]; + toolchainRoots?: string[]; + timeoutMs?: number; + maxOutputBytes?: number; + networkAccess?: boolean; +} + +interface NativeHelperProbeResponse { + version: typeof NATIVE_HELPER_PROTOCOL_VERSION; + ok: true; + probe: true; +} + +export interface NativeRemoteWorkspaceCommandRunnerOptions { + helper: RemoteWorkspaceNativeHelperDescriptor; + toolchainRoots?: readonly string[]; + /** Writable workspace roots that must never contain the executable enforcing their sandbox. */ + writableRoots: readonly string[]; + networkAccess?: boolean; + platform?: NodeJS.Platform; + spawn?: typeof Bun.spawn; + spawnSync?: typeof Bun.spawnSync; + /** Pure test seam. Production always executes the digest-pinned helper's real probe. */ + probe?: (request: NativeHelperRequest) => unknown; +} + +const availabilityCache = new Map(); + +function exactObject(value: unknown, keys: readonly string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("remote workspace native helper returned an invalid response"); + } + const record = value as Record; + const allowed = new Set(keys); + if (Object.keys(record).some(key => !allowed.has(key))) { + throw new Error("remote workspace native helper returned an invalid response"); + } + return record; +} + +function parseNativeHelperProbeResponse(value: unknown): NativeHelperProbeResponse { + const raw = exactObject(value, ["version", "ok", "probe"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== true || raw.probe !== true) { + throw new Error("remote workspace native helper failed its confinement probe"); + } + return { version: NATIVE_HELPER_PROTOCOL_VERSION, ok: true, probe: true }; +} + +function boundedBase64(value: unknown, label: string, maximum: number): Buffer { + if (typeof value !== "string" || value.length > Math.ceil(maximum / 3) * 4 + 4 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error(`remote workspace native helper returned invalid ${label}`); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.byteLength > maximum || decoded.toString("base64") !== value) { + throw new Error(`remote workspace native helper returned invalid ${label}`); + } + return decoded; +} + +function parseNativeHelperCommandResponse(value: unknown, maximum: number): RemoteWorkspaceCommandResult { + const raw = exactObject(value, ["version", "ok", "exitCode", "stdoutBase64", "stderrBase64"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== true + || typeof raw.exitCode !== "number" || !Number.isSafeInteger(raw.exitCode) + || raw.exitCode < -2_147_483_648 || raw.exitCode > 4_294_967_295) { + throw new Error("remote workspace native helper returned an invalid command result"); + } + const stdout = boundedBase64(raw.stdoutBase64, "stdout", maximum); + const stderr = boundedBase64(raw.stderrBase64, "stderr", maximum); + if (stdout.byteLength + stderr.byteLength > maximum) { + throw new Error("remote workspace native helper exceeded its output contract"); + } + const decoder = new TextDecoder("utf-8", { fatal: false }); + return { + exitCode: raw.exitCode, + stdout: decoder.decode(stdout), + stderr: decoder.decode(stderr), + }; +} + +function parseNativeHelperJson(value: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error("remote workspace native helper returned malformed JSON"); + } +} + +function sha256File(path: string): string { + const descriptor = openSync(path, constants.O_RDONLY); + try { + const metadata = fstatSync(descriptor); + if (!metadata.isFile() || metadata.size < 1 || metadata.size > MAX_NATIVE_HELPER_BYTES) { + throw new Error("remote workspace native helper has an invalid size"); + } + const hash = createHash("sha256"); + const chunk = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < metadata.size) { + const count = readSync(descriptor, chunk, 0, Math.min(chunk.byteLength, metadata.size - offset), offset); + if (count === 0) throw new Error("remote workspace native helper changed while hashing"); + hash.update(chunk.subarray(0, count)); + offset += count; + } + const after = fstatSync(descriptor); + if (after.size !== metadata.size || after.mtimeMs !== metadata.mtimeMs + || after.dev !== metadata.dev || after.ino !== metadata.ino) { + throw new Error("remote workspace native helper changed while hashing"); + } + return hash.digest("hex"); + } finally { + closeSync(descriptor); + } +} + +export function pinRemoteWorkspaceNativeHelper(path: string): RemoteWorkspaceNativeHelperDescriptor { + if (!isAbsolute(path) || path.includes("\0")) { + throw new Error("remote workspace native helper must be an absolute path"); + } + const linked = lstatSync(path); + if (!linked.isFile() || linked.isSymbolicLink()) { + throw new Error("remote workspace native helper must remain a real file"); + } + const canonical = realpathSync(path); + accessSync(canonical, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (process.platform !== "win32" && (statSync(canonical).mode & 0o022) !== 0) { + throw new Error("remote workspace native helper must not be group or world writable"); + } + return { path: canonical, sha256: sha256File(canonical) }; +} + +export function discoverRemoteWorkspaceNativeHelper(options: { + platform?: NodeJS.Platform; + architecture?: string; +} = {}): RemoteWorkspaceNativeHelperDescriptor | undefined { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "win32") return undefined; + const architecture = options.architecture ?? arch(); + const executable = platform === "win32" + ? "opencodex-remote-workspace-helper.exe" + : "opencodex-remote-workspace-helper"; + const candidates = [ + // Signed release bundles place the helper here. + `${import.meta.dir}/../../native-bin/${platform}-${architecture}/${executable}`, + // Source/private-dogfood builds produced by `bun run build:remote-workspace-helper`. + `${import.meta.dir}/../../native/remote-workspace-helper/target/release/${executable}`, + ]; + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + try { + return pinRemoteWorkspaceNativeHelper(candidate); + } catch { + return undefined; + } + } + return undefined; +} + +export function parseRemoteWorkspaceNativeHelperDescriptor(value: unknown): RemoteWorkspaceNativeHelperDescriptor { + const raw = exactObject(value, ["path", "sha256"]); + if (typeof raw.path !== "string" || !isAbsolute(raw.path) || raw.path.includes("\0") || raw.path.length > 4096 + || typeof raw.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(raw.sha256)) { + throw new Error("invalid remote workspace native helper descriptor"); + } + return { path: raw.path, sha256: raw.sha256 }; +} + +function assertNativeHelperIntegrity(value: RemoteWorkspaceNativeHelperDescriptor): RemoteWorkspaceNativeHelperDescriptor { + const helper = parseRemoteWorkspaceNativeHelperDescriptor(value); + const linked = lstatSync(helper.path); + if (!linked.isFile() || linked.isSymbolicLink() || realpathSync(helper.path) !== helper.path) { + throw new Error("remote workspace native helper identity changed; pair it again"); + } + accessSync(helper.path, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (process.platform !== "win32" && (linked.mode & 0o022) !== 0) { + throw new Error("remote workspace native helper permissions are unsafe"); + } + if (sha256File(helper.path) !== helper.sha256) { + throw new Error("remote workspace native helper digest changed; pair it again"); + } + return helper; +} + +function assertNativeHelperOutsideWritableRoots( + helper: RemoteWorkspaceNativeHelperDescriptor, + roots: readonly string[], +): string[] { + if (roots.length < 1 || roots.length > 32) { + throw new Error("remote workspace native runner needs one to 32 writable roots"); + } + const canonicalRoots: string[] = []; + for (const root of roots) { + if (!isAbsolute(root) || root.includes("\0")) { + throw new Error("remote workspace writable root must be an absolute path"); + } + const canonicalRoot = realpathSync(root); + if (canonicalRoots.includes(canonicalRoot)) { + throw new Error("remote workspace native runner received a duplicate writable root"); + } + if (inside(canonicalRoot, helper.path)) { + // A sandboxed command can write anywhere below its approved root. Executing the sandbox + // helper from that same tree would turn the hash-then-spawn pathname into a writable trust + // anchor that a workspace command can replace before a later invocation. + throw new Error("remote workspace native helper must be outside every writable workspace root"); + } + canonicalRoots.push(canonicalRoot); + } + return canonicalRoots; +} + +function nativeHelperEnvironment(platform: NodeJS.Platform): Record { + const result: Record = {}; + const names = platform === "win32" + ? ["SystemRoot", "WINDIR", "TEMP", "TMP"] + : ["TMPDIR"]; + for (const name of names) { + const value = process.env[name]; + if (value) result[name] = value; + } + return result; +} + +function inside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function assertWorkspaceHasNoExternalHardlinkAliases(root: string): void { + const canonicalRoot = realpathSync(root); + const pending = [canonicalRoot]; + let entries = 0; + while (pending.length > 0) { + const current = pending.pop()!; + const directory = opendirSync(current); + try { + for (;;) { + const entry = directory.readSync(); + if (!entry) break; + entries += 1; + if (entries > MAX_WORKSPACE_PREFLIGHT_ENTRIES) { + throw new Error("remote workspace is too large for safe command preflight"); + } + const target = join(current, entry.name); + const metadata = lstatSync(target); + if (metadata.isDirectory() && !metadata.isSymbolicLink()) { + pending.push(target); + } else if (!metadata.isDirectory() && metadata.nlink > 1) { + // A bind mount or Seatbelt path rule cannot distinguish two names for one inode. Reject + // rather than let a workspace alias read or mutate a file whose other name is outside. + throw new Error("remote workspace command root contains a hard-linked file"); + } + } + } finally { + directory.closeSync(); + } + } +} + +function assertCommandRootsSafe(roots: readonly string[]): void { + for (const root of roots) assertWorkspaceHasNoExternalHardlinkAliases(root); +} + +function sandboxPath(root: string, cwd: string): string { + if (!inside(root, cwd)) throw new Error("remote workspace command cwd escaped its root"); + const rel = relative(root, cwd); + return rel ? `/workspace/${rel.split(sep).join("/")}` : "/workspace"; +} + +function bindArgs(flag: "--ro-bind" | "--ro-bind-try", paths: readonly string[]): string[] { + const result: string[] = []; + for (const path of paths) { + if (flag === "--ro-bind-try" || existsSync(path)) result.push(flag, path, path); + } + return result; +} + +function approvedToolchainRoots(values: readonly string[]): string[] { + const result: string[] = []; + for (const value of values) { + if (!isAbsolute(value) || !existsSync(value) || value.includes("\0")) { + throw new Error("remote workspace toolchain root must be an existing absolute path"); + } + const metadata = lstatSync(value); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace toolchain root must remain a real directory"); + } + result.push(realpathSync(value)); + } + return [...new Set(result)]; +} + +function approvedRuntimeExecutable(value: string | undefined): string | null { + if (value === undefined) return null; + if (!isAbsolute(value) || value.includes("\0")) { + throw new Error("remote workspace runtime executable must be an absolute path"); + } + const canonical = realpathSync(value); + if (!statSync(canonical).isFile()) throw new Error("remote workspace runtime executable must be a file"); + accessSync(canonical, constants.X_OK); + return canonical; +} + +function trustedBubblewrap(path: string, roots: readonly string[]): string { + if (!isAbsolute(path)) throw new Error("bubblewrap must be an absolute executable path"); + const canonical = realpathSync(path); + const file = lstatSync(canonical); + if (!file.isFile() || file.nlink !== 1) throw new Error("bubblewrap must be a private executable file"); + for (const root of roots) { + if (inside(realpathSync(root), canonical)) { + throw new Error("bubblewrap must be outside every writable workspace root"); + } + } + accessSync(canonical, constants.X_OK); + let current = canonical; + for (;;) { + const metadata = lstatSync(current); + if (process.platform !== "win32" && (metadata.mode & 0o022) !== 0) { + throw new Error("bubblewrap executable and parent directories must not be group or world writable"); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return canonical; +} + +export function linuxRemoteWorkspaceCommandArgv( + request: RemoteWorkspaceCommandRequest, + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): string[] { + const bubblewrap = trustedBubblewrap(options.bubblewrapPath ?? "/usr/bin/bwrap", [...(options.writableRoots ?? []), request.root]); + const toolchains = approvedToolchainRoots(options.toolchainRoots ?? []); + const runtimeExecutable = approvedRuntimeExecutable(options.runtimeExecutablePath); + const commandPath = [...(runtimeExecutable ? ["/ocx-runtime/bin"] : []), ...toolchains, DEFAULT_PATH].join(":"); + return [ + bubblewrap, + "--die-with-parent", + "--new-session", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ...(options.networkAccess === true ? [] : ["--unshare-net"]), + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + ...(runtimeExecutable ? [ + "--dir", "/ocx-runtime", + "--dir", "/ocx-runtime/bin", + "--ro-bind", runtimeExecutable, SANDBOX_BUN_PATH, + ] : []), + ...bindArgs("--ro-bind", READABLE_SYSTEM_PATHS), + ...bindArgs("--ro-bind-try", READABLE_ETC_PATHS), + ...toolchains.flatMap(path => ["--ro-bind", path, path]), + "--bind", request.root, "/workspace", + "--chdir", sandboxPath(request.root, request.cwd), + "--clearenv", + "--setenv", "HOME", "/workspace", + "--setenv", "PATH", commandPath, + "--setenv", "LANG", "C.UTF-8", + "--setenv", "LC_ALL", "C.UTF-8", + "--", + ...request.command, + ]; +} + +async function collectBoundedOutput( + stream: ReadableStream, + reserve: (bytes: number) => boolean, + onOverflow: () => void, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + if (!reserve(next.value.byteLength)) { + onOverflow(); + throw new Error("remote workspace command output limit exceeded"); + } + chunks.push(next.value); + total += next.value.byteLength; + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder("utf-8", { fatal: false }).decode(body); +} + +export function createLinuxRemoteWorkspaceCommandRunner( + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): RemoteWorkspaceCommandRunner { + const spawn = options.spawn ?? Bun.spawn; + return { + async run(request): Promise { + assertWorkspaceHasNoExternalHardlinkAliases(request.root); + const argv = linuxRemoteWorkspaceCommandArgv(request, options); + const child = spawn(argv, { + cwd: request.root, + env: { PATH: DEFAULT_PATH, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let retained = 0; + let timedOut = false; + let overflowed = false; + let cancelled = false; + const stop = () => { + try { child.kill(); } catch { /* process already exited */ } + }; + const cancel = () => { + cancelled = true; + stop(); + }; + request.signal?.addEventListener("abort", cancel, { once: true }); + if (request.signal?.aborted) cancel(); + const reserve = (bytes: number): boolean => { + if (retained + bytes > request.maxOutputBytes) { + overflowed = true; + return false; + } + retained += bytes; + return true; + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, request.timeoutMs); + try { + const [stdoutResult, stderrResult, exitCode] = await Promise.allSettled([ + collectBoundedOutput(child.stdout, reserve, stop), + collectBoundedOutput(child.stderr, reserve, stop), + child.exited, + ]); + if (cancelled) throw new Error("remote workspace command was cancelled"); + if (timedOut) throw new Error("remote workspace command timed out"); + if (overflowed) throw new Error("remote workspace command output limit exceeded"); + if (stdoutResult.status === "rejected") throw stdoutResult.reason; + if (stderrResult.status === "rejected") throw stderrResult.reason; + if (exitCode.status === "rejected") throw exitCode.reason; + return { exitCode: exitCode.value, stdout: stdoutResult.value, stderr: stderrResult.value }; + } finally { + clearTimeout(timer); + request.signal?.removeEventListener("abort", cancel); + } + }, + }; +} + +function nativeHelperFailure(value: unknown): Error { + const raw = exactObject(value, ["version", "ok", "error"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== false + || typeof raw.error !== "string" || raw.error.length < 1 + || [...raw.error].length > MAX_NATIVE_HELPER_ERROR_CHARS || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(raw.error)) { + return new Error("remote workspace native helper returned an invalid failure"); + } + return new Error(raw.error); +} + +function nativeHelperRequest(options: NativeRemoteWorkspaceCommandRunnerOptions, request: RemoteWorkspaceCommandRequest): NativeHelperRequest { + return { + version: NATIVE_HELPER_PROTOCOL_VERSION, + operation: "run", + root: request.root, + cwd: request.cwd, + command: [...request.command], + toolchainRoots: approvedToolchainRoots(options.toolchainRoots ?? []), + timeoutMs: request.timeoutMs, + maxOutputBytes: request.maxOutputBytes, + networkAccess: options.networkAccess === true, + }; +} + +export function createNativeRemoteWorkspaceCommandRunner( + options: NativeRemoteWorkspaceCommandRunnerOptions, +): RemoteWorkspaceCommandRunner { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "win32") { + throw new Error("remote workspace native command helper is supported only on macOS and Windows"); + } + if (!nativeRemoteWorkspaceCommandRunnerAvailable(options)) { + throw new Error("remote workspace native command helper failed its confinement probe"); + } + const spawn = options.spawn ?? Bun.spawn; + return { + async run(request): Promise { + const helper = assertNativeHelperIntegrity(options.helper); + const writableRoots = assertNativeHelperOutsideWritableRoots(helper, options.writableRoots); + const requestRoot = realpathSync(request.root); + if (!writableRoots.includes(requestRoot)) { + throw new Error("remote workspace command root is outside the native runner grant"); + } + assertWorkspaceHasNoExternalHardlinkAliases(requestRoot); + const body = JSON.stringify(nativeHelperRequest(options, request)); + if (Buffer.byteLength(body, "utf8") > 64 * 1024) { + throw new Error("remote workspace native helper request is too large"); + } + const child = spawn([helper.path], { + cwd: request.root, + env: nativeHelperEnvironment(platform), + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }); + let retained = 0; + let overflowed = false; + let cancelled = false; + let timedOut = false; + const stop = () => { + try { child.kill(); } catch { /* helper already exited */ } + }; + const cancel = () => { + cancelled = true; + stop(); + }; + request.signal?.addEventListener("abort", cancel, { once: true }); + if (request.signal?.aborted) cancel(); + const maximumResponseBytes = Math.ceil(request.maxOutputBytes / 3) * 4 + 4_096; + const reserve = (bytes: number): boolean => { + if (retained + bytes > maximumResponseBytes + MAX_NATIVE_HELPER_STDERR_BYTES) { + overflowed = true; + return false; + } + retained += bytes; + return true; + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, request.timeoutMs + 2_000); + try { + if (!cancelled) { + child.stdin.write(body); + child.stdin.end(); + } + const [stdoutResult, stderrResult, exitResult] = await Promise.allSettled([ + collectBoundedOutput(child.stdout, reserve, stop), + collectBoundedOutput(child.stderr, reserve, stop), + child.exited, + ]); + if (cancelled) throw new Error("remote workspace command was cancelled"); + if (timedOut) throw new Error("remote workspace native helper timed out"); + if (overflowed) throw new Error("remote workspace native helper output limit exceeded"); + if (stdoutResult.status === "rejected" || stderrResult.status === "rejected" || exitResult.status === "rejected") { + throw new Error("remote workspace native helper failed"); + } + if (Buffer.byteLength(stdoutResult.value, "utf8") > maximumResponseBytes + || Buffer.byteLength(stderrResult.value, "utf8") > MAX_NATIVE_HELPER_STDERR_BYTES + || exitResult.value !== 0) { + throw new Error("remote workspace native helper failed"); + } + const response = parseNativeHelperJson(Buffer.from(stdoutResult.value, "utf8")); + if (response && typeof response === "object" && !Array.isArray(response) + && (response as Record).ok === false) { + throw nativeHelperFailure(response); + } + return parseNativeHelperCommandResponse(response, request.maxOutputBytes); + } finally { + clearTimeout(timer); + request.signal?.removeEventListener("abort", cancel); + try { child.stdin.end(); } catch { /* helper already closed stdin */ } + } + }, + }; +} + +export function nativeRemoteWorkspaceCommandRunnerAvailable( + options: NativeRemoteWorkspaceCommandRunnerOptions, +): boolean { + const platform = options.platform ?? process.platform; + if (platform !== "darwin") return false; // Windows awaits a surviving native cleanup owner. + try { + const helper = assertNativeHelperIntegrity(options.helper); + const writableRoots = assertNativeHelperOutsideWritableRoots(helper, options.writableRoots); + assertCommandRootsSafe(writableRoots); + const request: NativeHelperRequest = { version: NATIVE_HELPER_PROTOCOL_VERSION, operation: "probe" }; + const raw = options.probe + ? options.probe(request) + : (() => { + const result = (options.spawnSync ?? Bun.spawnSync)([helper.path], { + cwd: dirname(helper.path), + env: nativeHelperEnvironment(platform), + stdin: Buffer.from(JSON.stringify(request), "utf8"), + stdout: "pipe", + stderr: "ignore", + timeout: 8_000, + windowsHide: true, + }); + if (!result.success || result.stdout.byteLength > 4_096) { + throw new Error("remote workspace native helper probe failed"); + } + return parseNativeHelperJson(result.stdout); + })(); + parseNativeHelperProbeResponse(raw); + return true; + } catch { + return false; + } +} + +export function createPlatformRemoteWorkspaceCommandRunner(options: { + platform?: NodeJS.Platform; + linux?: LinuxRemoteWorkspaceCommandRunnerOptions; + native?: Omit; +} = {}): RemoteWorkspaceCommandRunner | undefined { + const platform = options.platform ?? process.platform; + if (platform === "linux" && linuxRemoteWorkspaceCommandRunnerAvailable(options.linux)) { + const linux = { + ...options.linux, + runtimeExecutablePath: options.linux?.runtimeExecutablePath ?? process.execPath, + }; + return createLinuxRemoteWorkspaceCommandRunner(linux); + } + if ((platform === "darwin" || platform === "win32") && options.native) { + const native = { ...options.native, platform }; + try { + return createNativeRemoteWorkspaceCommandRunner(native); + } catch { + return undefined; + } + } + return undefined; +} + +export function linuxRemoteWorkspaceCommandRunnerAvailable( + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): boolean { + let path: string; + try { + path = trustedBubblewrap(options.bubblewrapPath ?? "/usr/bin/bwrap", options.writableRoots ?? []); + if (options.writableRoots) assertCommandRootsSafe(options.writableRoots); + } catch { + return false; + } + const argv = [ + path, + "--die-with-parent", + "--new-session", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ...(options.networkAccess === true ? [] : ["--unshare-net"]), + "--proc", "/proc", + "--dev", "/dev", + ...bindArgs("--ro-bind", READABLE_SYSTEM_PATHS), + "--", + "/bin/true", + ]; + if (options.probe) return options.probe(argv); + const cacheKey = `${path}\0${options.networkAccess === true ? "network" : "isolated"}`; + const cached = availabilityCache.get(cacheKey); + if (cached !== undefined) return cached; + let available = false; + try { + available = Bun.spawnSync(argv, { + env: { PATH: DEFAULT_PATH, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + timeout: 2_000, + }).success; + } catch { + available = false; + } + availabilityCache.set(cacheKey, available); + return available; +} + diff --git a/src/remote-control/workspace-coordinator.ts b/src/remote-control/workspace-coordinator.ts new file mode 100644 index 0000000000..684746622a --- /dev/null +++ b/src/remote-control/workspace-coordinator.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation"; +import { + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + parseRemoteWorkspaceToolCall, + remoteWorkspaceCodexDeveloperInstructions, + remoteWorkspaceCapabilityForTool, + remoteWorkspaceDeveloperInstructions, + remoteWorkspaceToolsForCapabilities, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolCallParams, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; +import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor"; + +export interface RemoteWorkspaceSessionBinding { + sessionId: string; + threadId: string; + executorDeviceId: string; + executorName: string; + rootId: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; +} + +export interface RemoteWorkspaceTransport { + isOnline(deviceId: string): boolean; + invoke(request: RemoteWorkspaceExecutionRequest): Promise; +} + +export interface AppServerDynamicToolRequest { + method: "item/tool/call"; + id: string | number; + params: unknown; +} + +export interface AppServerDynamicToolResponse { + id: string | number; + result: { + contentItems: Array<{ type: "inputText"; text: string }>; + success: boolean; + }; +} + +function identifier(value: string, label: string): string { + if (value.length < 1 || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function resultText(result: RemoteWorkspaceToolResult): string { + const encoded = JSON.stringify(result); + if (Buffer.byteLength(encoded, "utf8") > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) { + return JSON.stringify({ ok: false, error: "remote workspace tool result exceeded the coordinator limit" }); + } + return encoded; +} + +export function remoteWorkspaceThreadStartParams(options: { + executorName: string; + coordinatorIsolationPath: string; + tools: readonly RemoteWorkspaceToolName[]; + platform?: NodeJS.Platform; + windowsSystemDirectory?: string; + mcp?: { + url: string; + bearerTokenEnvVar: string; + disabledServerNames?: readonly string[]; + disabledHookNames?: readonly string[]; + hubRuntimeReadPaths?: readonly string[]; + }; +}): Record { + if (!isAbsolute(options.coordinatorIsolationPath) || options.coordinatorIsolationPath.includes("\0")) { + throw new Error("remote workspace coordinator isolation path must be absolute"); + } + const platform = options.platform ?? process.platform; + const shellEnvironment = platform === "win32" + ? { + HOME: options.coordinatorIsolationPath, + USERPROFILE: options.coordinatorIsolationPath, + TEMP: options.coordinatorIsolationPath, + TMP: options.coordinatorIsolationPath, + PATH: options.windowsSystemDirectory ?? resolveTrustedWindowsSystemDirectory(), + } + : { + HOME: options.coordinatorIsolationPath, + PATH: platform === "darwin" ? "/usr/bin:/bin" : "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + }; + const config = options.mcp ? { + // A Remote Workspace thread may authenticate/model-call from the Hub, but every + // model-visible action must either be the one OCX MCP server or fail closed. + default_permissions: "ocx-remote-deny-local", + permissions: { + "ocx-remote-deny-local": { + description: "Deny Hub-local command filesystem and network access for Remote Workspace.", + filesystem: { + ":minimal": "read", + ":workspace_roots": { ".": "read" }, + ...Object.fromEntries((options.mcp.hubRuntimeReadPaths ?? []).map(path => [path, "read"])), + }, + network: { enabled: false }, + }, + }, + approval_policy: "never", + allow_login_shell: false, + shell_environment_policy: { + inherit: "none", + ignore_default_excludes: false, + set: shellEnvironment, + }, + web_search: "disabled", + tools: { view_image: false, web_search: false }, + agents: { enabled: false }, + apps: { _default: { enabled: false } }, + features: { + apps: false, + browser_use: false, + computer_use: false, + in_app_browser: false, + memories: false, + multi_agent: false, + plugins: false, + remote_plugin: false, + }, + memories: { use_memories: false, generate_memories: false }, + hooks: Object.fromEntries((options.mcp.disabledHookNames ?? []).map(name => [name, []])), + mcp_servers: { + ...Object.fromEntries((options.mcp.disabledServerNames ?? []) + .filter(name => name !== REMOTE_WORKSPACE_TOOL_NAMESPACE) + .map(name => [name, { enabled: false }])), + [REMOTE_WORKSPACE_TOOL_NAMESPACE]: { + enabled: true, + required: true, + url: options.mcp.url, + bearer_token_env_var: options.mcp.bearerTokenEnvVar, + enabled_tools: [...options.tools], + default_tools_approval_mode: "approve", + startup_timeout_sec: 5, + tool_timeout_sec: 65, + }, + }, + } : undefined; + return { + cwd: options.coordinatorIsolationPath, + runtimeWorkspaceRoots: [options.coordinatorIsolationPath], + approvalPolicy: "never", + ephemeral: false, + serviceName: "opencodex_remote_workspace", + developerInstructions: options.mcp + ? remoteWorkspaceCodexDeveloperInstructions(options.executorName, options.tools) + : remoteWorkspaceDeveloperInstructions(options.executorName, options.tools), + ...(config ? { config } : {}), + }; +} + +export class RemoteWorkspaceCoordinator { + private readonly sessions = new Map(); + + constructor(private readonly transport: RemoteWorkspaceTransport) {} + + register(binding: RemoteWorkspaceSessionBinding): () => void { + const capabilities = [...binding.capabilities]; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + if (tools.length < 1) throw new Error("remote workspace binding has no usable tools"); + const normalized: RemoteWorkspaceSessionBinding = { + sessionId: identifier(binding.sessionId, "session ID"), + threadId: identifier(binding.threadId, "thread ID"), + executorDeviceId: identifier(binding.executorDeviceId, "executor device ID"), + executorName: identifier(binding.executorName, "executor name"), + rootId: identifier(binding.rootId, "root ID"), + capabilities, + tools, + }; + if (this.sessions.has(normalized.threadId)) throw new Error("remote workspace thread is already bound"); + this.sessions.set(normalized.threadId, normalized); + return () => { + if (this.sessions.get(normalized.threadId)?.sessionId === normalized.sessionId) { + this.sessions.delete(normalized.threadId); + } + }; + } + + async handle(request: AppServerDynamicToolRequest): Promise { + if (request.method !== "item/tool/call") throw new Error("unsupported App Server request"); + let call: RemoteWorkspaceToolCallParams; + try { + call = parseRemoteWorkspaceToolCall(request.params); + } catch (error) { + return this.response(request.id, { ok: false, error: error instanceof Error ? error.message : "invalid remote tool call" }); + } + const binding = this.sessions.get(call.threadId); + if (!binding) return this.response(request.id, { ok: false, error: "remote workspace thread is not bound" }); + if (!binding.tools.includes(call.tool) + || !binding.capabilities.includes(remoteWorkspaceCapabilityForTool(call.tool))) { + return this.response(request.id, { ok: false, error: "remote workspace tool is not supported by this executor" }); + } + if (!this.transport.isOnline(binding.executorDeviceId)) { + return this.response(request.id, { ok: false, error: "remote executor is offline; local fallback is disabled" }); + } + let result: RemoteWorkspaceToolResult; + try { + result = await this.transport.invoke({ + requestId: randomUUID(), + sessionId: binding.sessionId, + executorDeviceId: binding.executorDeviceId, + rootId: binding.rootId, + tool: call.tool, + arguments: call.arguments, + }); + } catch { + result = { ok: false, error: "remote executor transport failed; local fallback is disabled" }; + } + return this.response(request.id, result); + } + + private response(id: string | number, result: RemoteWorkspaceToolResult): AppServerDynamicToolResponse { + return { + id, + result: { + contentItems: [{ type: "inputText", text: resultText(result) }], + success: result.ok, + }, + }; + } +} diff --git a/src/remote-control/workspace-device.ts b/src/remote-control/workspace-device.ts new file mode 100644 index 0000000000..40e42d817b --- /dev/null +++ b/src/remote-control/workspace-device.ts @@ -0,0 +1,585 @@ +import { createPrivateKey, createPublicKey, randomUUID, sign, verify } from "node:crypto"; +import { arch, hostname, platform } from "node:os"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteControlIdentityKeyPair, +} from "./crypto"; +import { RemoteWorkspaceExecutor } from "./workspace-executor"; +import { RemoteWorkspaceExecutorAgentConnection } from "./workspace-agent-connection"; +import { + createPlatformRemoteWorkspaceCommandRunner, + discoverRemoteWorkspaceNativeHelper, + parseRemoteWorkspaceNativeHelperDescriptor, + pinRemoteWorkspaceNativeHelper, + type RemoteWorkspaceNativeHelperDescriptor, +} from "./workspace-command-runner"; +import type { RemoteWorkspaceCommandRunner } from "./workspace-executor"; +import { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + serializeRemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_DEVICE_STATE_VERSION = 1 as const; +const DEVICE_TOKEN_PATTERN = /^ocxrw_[A-Za-z0-9_-]{43}$/; +const MAX_PAIR_RESPONSE_BYTES = 64 * 1024; +const MAX_DEVICE_STATE_BYTES = 1024 * 1024; +const PAIR_TIMEOUT_MS = 15_000; + +export interface RemoteWorkspaceDeviceRoot { + id: string; + label: string; + path: string; +} + +export interface RemoteWorkspaceDeviceState { + version: typeof REMOTE_WORKSPACE_DEVICE_STATE_VERSION; + hubUrl: string; + agentUrl: string; + deviceId: string; + deviceName: string; + devicePlatform: string; + capabilities: RemoteWorkspaceCapability[]; + deviceToken: string; + deviceIdentity: RemoteControlIdentityKeyPair; + hubPublicKey: string; + roots: RemoteWorkspaceDeviceRoot[]; + toolchainRoots: string[]; + nativeHelper?: RemoteWorkspaceNativeHelperDescriptor; +} + +export interface RemoteWorkspaceDeviceStateStore { + load(): RemoteWorkspaceDeviceState | null; + save(state: RemoteWorkspaceDeviceState): void; +} + +export interface PairRemoteWorkspaceDeviceOptions { + hubUrl: string; + pairingCode: string; + name?: string; + roots: Array<{ path: string; label?: string }>; + fetchImpl?: typeof fetch; + store?: RemoteWorkspaceDeviceStateStore; + devicePlatform?: string; + capabilities?: RemoteWorkspaceCapability[]; + toolchainRoots?: string[]; + nativeHelperPath?: string; +} + +export interface RemoteWorkspaceWebSocketLike { + readyState: number; + send(value: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: "open" | "close" | "error" | "message", listener: (event: Event | MessageEvent) => void): void; +} + +export type RemoteWorkspaceWebSocketFactory = ( + url: string, + headers: Record, +) => RemoteWorkspaceWebSocketLike; + +function boundedText(value: unknown, label: string, max: number): string { + if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function uuid(value: unknown, label: string): string { + const text = boundedText(value, label, 64); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) { + throw new Error(`invalid remote workspace ${label}`); + } + return text; +} + +function publicKey(value: unknown, label: string): string { + const encoded = boundedText(value, label, 1024); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error(`invalid remote workspace ${label}`); + const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error(`remote workspace ${label} must use Ed25519`); + return encoded; +} + +function identity(value: unknown): RemoteControlIdentityKeyPair { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device identity"); + const raw = value as Record; + const pub = publicKey(raw.publicKey, "device public key"); + const priv = boundedText(raw.privateKey, "device private key", 2048); + const privateKey = createPrivateKey({ key: Buffer.from(priv, "base64url"), type: "pkcs8", format: "der" }); + if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519"); + const challenge = Buffer.from("opencodex remote workspace device identity v1", "utf8"); + if (!verify( + null, + challenge, + createPublicKey({ key: Buffer.from(pub, "base64url"), type: "spki", format: "der" }), + sign(null, challenge, privateKey), + )) throw new Error("remote workspace device identity key pair does not match"); + return { publicKey: pub, privateKey: priv }; +} + +export function normalizeRemoteWorkspaceHubUrl(value: string): string { + const url = new URL(value); + const local = (url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.protocol === "http:"; + if (url.protocol !== "https:" && !local) throw new Error("remote workspace hub must use HTTPS"); + if (url.username || url.password || url.search || url.hash) throw new Error("remote workspace hub URL must not contain credentials or fragments"); + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + return url.toString().replace(/\/$/, ""); +} + +function agentUrlForHub(hubUrl: string): string { + const url = new URL("/remote-workspace/agent", `${hubUrl}/`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function validateRootInputs(values: Array<{ path: string; label?: string }>): RemoteWorkspaceDeviceRoot[] { + if (values.length < 1 || values.length > 32) throw new Error("remote workspace device needs one to 32 roots"); + const paths = new Set(); + const labels = new Set(); + return values.map(value => { + if (!isAbsolute(value.path) || value.path.includes("\0")) throw new Error("remote workspace root must be an absolute path"); + const metadata = lstatSync(value.path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); + const path = realpathSync(value.path); + const label = boundedText(value.label ?? basename(path), "root label", 80); + const folded = label.toLocaleLowerCase("en-US"); + if (paths.has(path) || labels.has(folded)) throw new Error("duplicate remote workspace root"); + paths.add(path); + labels.add(folded); + return { id: randomUUID(), label, path }; + }); +} + +function parseRoots(value: unknown): RemoteWorkspaceDeviceRoot[] { + if (!Array.isArray(value) || value.length < 1 || value.length > 32) throw new Error("invalid remote workspace device roots"); + const paths = new Set(); + const ids = new Set(); + return value.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device root"); + const raw = item as Record; + const id = uuid(raw.id, "root ID"); + const label = boundedText(raw.label, "root label", 80); + const path = boundedText(raw.path, "root path", 4096); + if (!isAbsolute(path) || ids.has(id) || paths.has(path)) throw new Error("invalid remote workspace device root"); + ids.add(id); + paths.add(path); + return { id, label, path }; + }); +} + +function validateToolchainRoots(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 16) throw new Error("invalid remote workspace toolchain roots"); + const paths = new Set(); + for (const candidate of value) { + if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) { + throw new Error("remote workspace toolchain root must be an absolute directory"); + } + const metadata = lstatSync(candidate); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace toolchain root must be a real directory"); + } + paths.add(realpathSync(candidate)); + } + return [...paths]; +} + +export function parseRemoteWorkspaceDeviceState(value: unknown): RemoteWorkspaceDeviceState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_DEVICE_STATE_VERSION) throw new Error("unsupported remote workspace device state"); + const hubUrl = normalizeRemoteWorkspaceHubUrl(boundedText(raw.hubUrl, "hub URL", 2048)); + const agentUrl = boundedText(raw.agentUrl, "agent URL", 2048); + if (agentUrl !== agentUrlForHub(hubUrl)) throw new Error("remote workspace agent URL does not match its hub"); + const deviceToken = boundedText(raw.deviceToken, "device token", 128); + if (!DEVICE_TOKEN_PATTERN.test(deviceToken)) throw new Error("invalid remote workspace device token"); + return { + version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + hubUrl, + agentUrl, + deviceId: uuid(raw.deviceId, "device ID"), + deviceName: boundedText(raw.deviceName, "device name", 80), + devicePlatform: boundedText(raw.devicePlatform, "device platform", 80), + capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities), + deviceToken, + deviceIdentity: identity(raw.deviceIdentity), + hubPublicKey: publicKey(raw.hubPublicKey, "hub public key"), + roots: parseRoots(raw.roots), + toolchainRoots: validateToolchainRoots(raw.toolchainRoots), + ...(raw.nativeHelper === undefined + ? {} + : { nativeHelper: parseRemoteWorkspaceNativeHelperDescriptor(raw.nativeHelper) }), + }; +} + +export class RemoteWorkspaceDeviceFileStore implements RemoteWorkspaceDeviceStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-device.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceDeviceState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_DEVICE_STATE_BYTES) { + throw new Error("remote workspace device state is too large"); + } + return parseRemoteWorkspaceDeviceState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceDeviceState): void { + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceDeviceState(state), null, 2)}\n`); + } +} + +async function boundedJson(response: Response): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > MAX_PAIR_RESPONSE_BYTES) throw new Error("remote workspace hub response is too large"); + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + if (reader) { + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_PAIR_RESPONSE_BYTES) { + await reader.cancel("remote workspace hub response is too large").catch(() => {}); + throw new Error("remote workspace hub response is too large"); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(body); + try { return text ? JSON.parse(text) : {}; } + catch { throw new Error(`remote workspace hub returned HTTP ${response.status}`); } +} + +export async function pairRemoteWorkspaceDevice(options: PairRemoteWorkspaceDeviceOptions): Promise { + const hubUrl = normalizeRemoteWorkspaceHubUrl(options.hubUrl); + const roots = validateRootInputs(options.roots); + const deviceName = boundedText(options.name ?? hostname(), "device name", 80); + const devicePlatform = boundedText(options.devicePlatform ?? `${platform()}-${arch()}`, "device platform", 80); + const toolchainRoots = validateToolchainRoots(options.toolchainRoots); + const nativeHelper = options.nativeHelperPath + ? pinRemoteWorkspaceNativeHelper(options.nativeHelperPath) + : discoverRemoteWorkspaceNativeHelper(); + const commandRunner = createPlatformRemoteWorkspaceCommandRunner({ + linux: { toolchainRoots, writableRoots: roots.map(root => root.path) }, + ...(nativeHelper ? { native: { + helper: nativeHelper, + toolchainRoots, + writableRoots: roots.map(root => root.path), + } } : {}), + }); + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, options.capabilities); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const response = await (options.fetchImpl ?? fetch)(new URL("/remote-workspace/pair", `${hubUrl}/`), { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(PAIR_TIMEOUT_MS), + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + code: options.pairingCode, + name: deviceName, + platform: devicePlatform, + publicKey: deviceIdentity.publicKey, + capabilities, + roots: roots.map(root => ({ id: root.id, label: root.label })), + }), + }); + const body = await boundedJson(response); + if (!response.ok || !body || typeof body !== "object" || Array.isArray(body)) { + const error = body && typeof body === "object" && "error" in body && typeof body.error === "string" + ? body.error + : `remote workspace pairing failed (${response.status})`; + throw new Error(error); + } + const raw = body as Record; + const device = raw.device && typeof raw.device === "object" && !Array.isArray(raw.device) + ? raw.device as Record + : null; + if (!device) throw new Error("remote workspace hub returned an invalid device"); + const state = parseRemoteWorkspaceDeviceState({ + version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + hubUrl, + agentUrl: agentUrlForHub(hubUrl), + deviceId: device.id, + deviceName, + devicePlatform, + capabilities, + deviceToken: raw.deviceToken, + deviceIdentity, + hubPublicKey: raw.hubPublicKey, + roots, + toolchainRoots, + ...(nativeHelper ? { nativeHelper } : {}), + }); + (options.store ?? new RemoteWorkspaceDeviceFileStore()).save(state); + return state; +} + +function defaultWebSocketFactory(url: string, headers: Record): RemoteWorkspaceWebSocketLike { + return new WebSocket(url, { headers } as unknown as string[]) as unknown as RemoteWorkspaceWebSocketLike; +} + +async function messageBytes(event: MessageEvent): Promise { + if (typeof event.data === "string") return event.data; + if (event.data instanceof ArrayBuffer) return new Uint8Array(event.data); + if (ArrayBuffer.isView(event.data)) return new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + if (event.data instanceof Blob) return new Uint8Array(await event.data.arrayBuffer()); + throw new Error("remote workspace agent received an unsupported frame"); +} + +export interface RemoteWorkspaceAgentHandle { + connected: Promise; + closed: Promise; + stop(): void; +} + +export interface RemoteWorkspaceAgentRunStatus { + state: "connecting" | "online" | "reconnecting" | "stopped"; + attempt: number; + message?: string; +} + +/** Never advertise more authority than both local support and the pairing-time grant allow. */ +export function remoteWorkspaceCapabilitiesForCommandRunner( + commandRunner: RemoteWorkspaceCommandRunner | undefined, + approved?: readonly RemoteWorkspaceCapability[], +): RemoteWorkspaceCapability[] { + const available = parseRemoteWorkspaceCapabilities([ + "workspace.read", + "workspace.write", + ...(commandRunner ? ["workspace.exec" as const] : []), + ]); + const requested = parseRemoteWorkspaceCapabilities(approved ?? available); + const allowed = new Set(available); + return parseRemoteWorkspaceCapabilities(requested.filter(capability => allowed.has(capability))); +} + +export function connectRemoteWorkspaceAgent(options: { + state: RemoteWorkspaceDeviceState; + webSocketFactory?: RemoteWorkspaceWebSocketFactory; + commandRunner?: RemoteWorkspaceCommandRunner | null; +}): RemoteWorkspaceAgentHandle { + const state = parseRemoteWorkspaceDeviceState(options.state); + const commandRunner = options.commandRunner === undefined + ? createPlatformRemoteWorkspaceCommandRunner({ + linux: { + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + }, + ...(state.nativeHelper ? { native: { + helper: state.nativeHelper, + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + } } : {}), + }) + : options.commandRunner ?? undefined; + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, state.capabilities); + const executor = new RemoteWorkspaceExecutor({ + deviceId: state.deviceId, + roots: state.roots.map(root => ({ id: root.id, path: root.path })), + commandRunner, + }); + const socket = (options.webSocketFactory ?? defaultWebSocketFactory)(state.agentUrl, { + authorization: `Bearer ${state.deviceToken}`, + }); + let agent: RemoteWorkspaceExecutorAgentConnection | null = null; + let opened = false; + let presenceAccepted = false; + let stopped = false; + let settleConnected!: () => void; + let rejectConnected!: (error: Error) => void; + let settleClosed!: () => void; + const connected = new Promise((resolve, reject) => { + settleConnected = resolve; + rejectConnected = reject; + }); + const closed = new Promise(resolve => { settleClosed = resolve; }); + let queue = Promise.resolve(); + let heartbeat: ReturnType | null = null; + let presenceTimer: ReturnType | null = null; + const acceptPresence = () => { + if (stopped) return; + if (presenceAccepted) return; + presenceAccepted = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + settleConnected(); + }; + + socket.addEventListener("open", () => { + if (stopped) { + try { socket.close(1000, "remote workspace agent stopped"); } catch { /* already closed */ } + return; + } + opened = true; + presenceTimer = setTimeout(() => { + rejectConnected(new Error("remote workspace Hub did not acknowledge executor capabilities")); + socket.close(1008, "remote workspace presence timed out"); + }, 10_000); + agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId: state.deviceId, + deviceIdentity: state.deviceIdentity, + hubPublicKey: state.hubPublicKey, + executor, + capabilities, + onPresenceAccepted: acceptPresence, + socket: { + send: value => socket.send(value), + close: (code, reason) => socket.close(code, reason), + }, + }); + socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities, + })); + heartbeat = setInterval(() => { + if (socket.readyState !== 1) return; + socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "heartbeat", + nonce: randomUUID(), + })); + }, 20_000); + }); + socket.addEventListener("message", event => { + if (stopped || !(event instanceof MessageEvent) || !agent) return; + queue = queue.then(async () => agent?.receive(await messageBytes(event))).catch(() => { + socket.close(1008, "remote workspace protocol error"); + }); + }); + socket.addEventListener("error", () => { + if (!stopped && !presenceAccepted) rejectConnected(new Error("remote workspace agent connection failed")); + }); + socket.addEventListener("close", () => { + stopped = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + const currentAgent = agent; + agent = null; + currentAgent?.close(); + if (!presenceAccepted) rejectConnected(new Error( + opened + ? "remote workspace agent connection closed before presence acknowledgement" + : "remote workspace agent connection closed before opening", + )); + settleClosed(); + }); + return { + connected, + closed, + stop() { + if (stopped) return; + stopped = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + const currentAgent = agent; + agent = null; + currentAgent?.close(); + if (!presenceAccepted) rejectConnected(new Error("remote workspace agent stopped")); + settleClosed(); + try { socket.close(1000, "remote workspace agent stopped"); } catch { /* CONNECTING sockets differ by runtime */ } + }, + }; +} + +function waitForReconnect(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise(resolve => { + const timer = setTimeout(finish, delayMs); + function finish() { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + +export async function runRemoteWorkspaceAgent(options: { + state: RemoteWorkspaceDeviceState; + signal: AbortSignal; + webSocketFactory?: RemoteWorkspaceWebSocketFactory; + commandRunner?: RemoteWorkspaceCommandRunner | null; + onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void; + minReconnectMs?: number; + maxReconnectMs?: number; + random?: () => number; +}): Promise { + const state = parseRemoteWorkspaceDeviceState(options.state); + const minimum = options.minReconnectMs ?? 500; + const maximum = options.maxReconnectMs ?? 15_000; + if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum < 10 || maximum < minimum) { + throw new Error("invalid remote workspace reconnect policy"); + } + let attempt = 0; + let delayMs = minimum; + while (!options.signal.aborted) { + attempt += 1; + options.onStatus?.({ state: "connecting", attempt }); + const handle = connectRemoteWorkspaceAgent({ + state, + ...(options.webSocketFactory ? { webSocketFactory: options.webSocketFactory } : {}), + ...(options.commandRunner !== undefined ? { commandRunner: options.commandRunner } : {}), + }); + const stop = () => handle.stop(); + options.signal.addEventListener("abort", stop, { once: true }); + try { + await handle.connected; + delayMs = minimum; + options.onStatus?.({ state: "online", attempt }); + await handle.closed; + } catch (error) { + handle.stop(); + if (!options.signal.aborted) { + options.onStatus?.({ + state: "reconnecting", + attempt, + message: error instanceof Error ? error.message : "remote workspace connection failed", + }); + } + } finally { + options.signal.removeEventListener("abort", stop); + } + if (options.signal.aborted) break; + options.onStatus?.({ state: "reconnecting", attempt }); + const random = Math.min(1, Math.max(0, (options.random ?? Math.random)())); + const jitteredDelay = Math.max(10, Math.round(delayMs * (0.8 + random * 0.4))); + await waitForReconnect(jitteredDelay, options.signal); + delayMs = Math.min(maximum, delayMs * 2); + } + options.onStatus?.({ state: "stopped", attempt }); +} diff --git a/src/remote-control/workspace-executable.ts b/src/remote-control/workspace-executable.ts new file mode 100644 index 0000000000..db6153303c --- /dev/null +++ b/src/remote-control/workspace-executable.ts @@ -0,0 +1,43 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; + +function executableCandidate(path: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Resolve only durable PATH entries; an empty/current-directory entry is never trusted. */ +export function findExecutableOnPath(name: string, options: { + path?: string; + pathExt?: string; + platform?: NodeJS.Platform; + /** Pure cross-platform test seam; production checks the real filesystem. */ + probe?: (candidate: string) => boolean; +} = {}): string | null { + const path = options.path ?? process.env.PATH; + const platform = options.platform ?? process.platform; + if (!path) return null; + const paths = platform === "win32" ? win32 : posix; + const spawnableWindowsExtensions = new Set([".com", ".exe", ".bat", ".cmd"]); + const suffixes = platform === "win32" + ? (options.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .map(value => value.trim()) + .filter(value => spawnableWindowsExtensions.has(value.toLowerCase())) + : [""]; + if (platform === "win32" && win32.extname(name)) suffixes.unshift(""); + const probe = options.probe ?? (candidate => executableCandidate(candidate, platform)); + for (const directory of path.split(paths.delimiter)) { + if (!directory) continue; + for (const suffix of suffixes) { + const candidate = paths.join(directory, `${name}${suffix.toLowerCase()}`); + if (probe(candidate)) return candidate; + } + } + return null; +} diff --git a/src/remote-control/workspace-executor.ts b/src/remote-control/workspace-executor.ts new file mode 100644 index 0000000000..3312e8348f --- /dev/null +++ b/src/remote-control/workspace-executor.ts @@ -0,0 +1,396 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + opendirSync, + readSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from "node:path"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; + +export interface RemoteWorkspaceRoot { + id: string; + path: string; +} + +export interface RemoteWorkspaceExecutionRequest { + requestId: string; + sessionId: string; + executorDeviceId: string; + rootId: string; + tool: RemoteWorkspaceToolName; + arguments: unknown; +} + +export interface RemoteWorkspaceExecutorOptions { + deviceId: string; + roots: readonly RemoteWorkspaceRoot[]; + maxOutputBytes?: number; + platform?: NodeJS.Platform; + /** Production must provide an OS-sandboxed runner. Omission disables command execution. */ + commandRunner?: RemoteWorkspaceCommandRunner; +} + +export interface RemoteWorkspaceCommandRequest { + command: string[]; + root: string; + cwd: string; + timeoutMs: number; + maxOutputBytes: number; + signal?: AbortSignal; +} + +export interface RemoteWorkspaceCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface RemoteWorkspaceCommandRunner { + run(request: RemoteWorkspaceCommandRequest): Promise; +} + +interface ApprovedRoot { + id: string; + path: string; + dev: number; + ino: number; + birthtimeMs: number; +} + +function objectArguments(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("remote workspace arguments must be an object"); + } + return value as Record; +} + +function noExtraKeys(value: Record, allowed: readonly string[]): void { + const set = new Set(allowed); + if (Object.keys(value).some(key => !set.has(key))) throw new Error("unknown remote workspace argument"); +} + +const WINDOWS_RESERVED_BASENAME = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i; + +export function validateRemoteWorkspaceRelativePath( + value: unknown, + fallback?: string, + platform: NodeJS.Platform = process.platform, +): string { + const path = value === undefined ? fallback : value; + if (typeof path !== "string" || path.length < 1 || path.length > 4096 || path.includes("\0")) { + throw new Error("invalid remote workspace path"); + } + const paths = platform === "win32" ? win32 : posix; + if (paths.isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\")) { + throw new Error("remote workspace path must be relative"); + } + if (platform === "win32") { + for (const segment of path.split(/[\\/]/)) { + if (!segment || segment === "." || segment === "..") continue; + if (/[\x01-\x1f<>:"|?*]/.test(segment) || /[ .]$/.test(segment) || WINDOWS_RESERVED_BASENAME.test(segment)) { + throw new Error("remote workspace path is not a safe Windows file path"); + } + } + } + return path; +} + +function inside(root: string, candidate: string): boolean { + const fromRoot = relative(root, candidate); + return fromRoot === "" || (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot)); +} + +function errorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object" || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function assertNoSymlinkComponents(root: string, candidate: string, includeLeaf: boolean): void { + const rel = relative(root, candidate); + const parts = rel === "" ? [] : rel.split(sep); + const limit = includeLeaf ? parts.length : Math.max(0, parts.length - 1); + let current = root; + for (let index = 0; index < limit; index += 1) { + current = resolve(current, parts[index]!); + if (lstatSync(current).isSymbolicLink()) throw new Error("remote workspace symlink traversal is not allowed"); + } +} + +function resolveExisting(root: string, value: unknown, platform = process.platform): string { + const candidate = resolve(root, validateRemoteWorkspaceRelativePath(value, ".", platform)); + if (!inside(root, candidate)) throw new Error("remote workspace path escapes the approved root"); + assertNoSymlinkComponents(root, candidate, true); + const canonical = realpathSync(candidate); + if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); + return canonical; +} + +function resolveWritable(root: string, value: unknown, platform = process.platform): string { + const candidate = resolve(root, validateRemoteWorkspaceRelativePath(value, undefined, platform)); + if (!inside(root, candidate) || candidate === root) throw new Error("remote workspace path escapes the approved root"); + const parent = dirname(candidate); + assertNoSymlinkComponents(root, parent, true); + const canonicalParent = realpathSync(parent); + if (!inside(root, canonicalParent)) throw new Error("remote workspace parent escapes the approved root"); + try { + assertNoSymlinkComponents(root, candidate, true); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + return resolve(canonicalParent, basename(candidate)); +} + +function sha256(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number { + const selected = value === undefined ? fallback : value; + if (typeof selected !== "number" || !Number.isSafeInteger(selected) || selected < minimum || selected > maximum) { + throw new Error("invalid remote workspace numeric argument"); + } + return selected; +} + +function decodeUtf8(value: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: false }).decode(value); +} + +function assertOpenedRegularFile(root: string, target: string, descriptor: number, maximum: number) { + const opened = fstatSync(descriptor); + const linked = lstatSync(target); + if (opened.isFile() && linked.isFile() && (opened.nlink !== 1 || linked.nlink !== 1)) { + throw new Error("remote workspace hard-linked files are not allowed"); + } + if (!opened.isFile() || !linked.isFile() || linked.isSymbolicLink() + || opened.dev !== linked.dev || opened.ino !== linked.ino + || opened.birthtimeMs !== linked.birthtimeMs) { + throw new Error("remote workspace file identity changed during access"); + } + const canonical = realpathSync(target); + if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); + if (opened.size > maximum) throw new Error("remote workspace file exceeds the read limit"); + return opened; +} + +function readBoundedRegularFile(root: string, target: string, maximum: number): { body: Buffer; mode: number } { + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const descriptor = openSync(target, constants.O_RDONLY | noFollow); + try { + const metadata = assertOpenedRegularFile(root, target, descriptor, maximum); + const body = Buffer.alloc(metadata.size); + let offset = 0; + while (offset < body.byteLength) { + const read = readSync(descriptor, body, offset, body.byteLength - offset, null); + if (read === 0) break; + offset += read; + } + assertOpenedRegularFile(root, target, descriptor, maximum); + return { body: offset === body.byteLength ? body : body.subarray(0, offset), mode: metadata.mode & 0o777 }; + } finally { + closeSync(descriptor); + } +} + +function assertStableWritableParent(root: string, target: string): void { + const parent = dirname(target); + assertNoSymlinkComponents(root, parent, true); + const canonical = realpathSync(parent); + if (!inside(root, canonical) || relative(parent, canonical) !== "") { + throw new Error("remote workspace write parent changed during access"); + } +} + +function assertApprovedRootIdentity(root: ApprovedRoot): void { + const linked = lstatSync(root.path); + const canonical = realpathSync(root.path); + if (!linked.isDirectory() || linked.isSymbolicLink() + || linked.dev !== root.dev || linked.ino !== root.ino + || linked.birthtimeMs !== root.birthtimeMs + || relative(root.path, canonical) !== "") { + throw new Error("remote workspace approved root identity changed; pair the folder again"); + } +} + +function assertWritePrecondition(root: string, target: string, expectedSha256: string | null): number { + try { + const current = readBoundedRegularFile(root, target, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES); + if (expectedSha256 === null || sha256(current.body) !== expectedSha256) { + throw new Error("remote workspace file changed before write"); + } + return current.mode; + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + if (expectedSha256 !== null) throw new Error("remote workspace file is missing"); + return 0o600; + } +} + +export class RemoteWorkspaceExecutor { + private readonly roots = new Map(); + private readonly maxOutputBytes: number; + private operationTail: Promise = Promise.resolve(); + + constructor(private readonly options: RemoteWorkspaceExecutorOptions) { + if (!options.deviceId || options.deviceId.length > 256) throw new Error("invalid remote workspace executor device ID"); + this.maxOutputBytes = options.maxOutputBytes ?? REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES; + if (!Number.isSafeInteger(this.maxOutputBytes) || this.maxOutputBytes < 1024) { + throw new Error("invalid remote workspace output limit"); + } + for (const root of options.roots) { + if (!root.id || root.id.length > 128 || this.roots.has(root.id)) throw new Error("invalid remote workspace root ID"); + const metadata = lstatSync(root.path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); + const canonical = realpathSync(root.path); + const identity = lstatSync(canonical); + this.roots.set(root.id, { + id: root.id, + path: canonical, + dev: identity.dev, + ino: identity.ino, + birthtimeMs: identity.birthtimeMs, + }); + } + if (this.roots.size === 0) throw new Error("remote workspace executor needs one approved root"); + } + + hasApprovedRoot(rootId: string): boolean { + return this.roots.has(rootId); + } + + async invoke(request: RemoteWorkspaceExecutionRequest, signal?: AbortSignal): Promise { + if (request.executorDeviceId !== this.options.deviceId) { + return { ok: false, error: "remote workspace executor identity mismatch" }; + } + const root = this.roots.get(request.rootId); + if (!root) return { ok: false, error: "remote workspace root is not approved" }; + if (!request.requestId || !request.sessionId) return { ok: false, error: "invalid remote workspace request identity" }; + const previous = this.operationTail; + let release!: () => void; + this.operationTail = new Promise(resolvePromise => { release = resolvePromise; }); + await previous; + try { + if (signal?.aborted) throw new Error("remote workspace operation was cancelled"); + assertApprovedRootIdentity(root); + switch (request.tool) { + case "list_directory": return { ok: true, value: this.listDirectory(root, request.arguments) }; + case "read_file": return { ok: true, value: this.readFile(root, request.arguments) }; + case "write_file": return { ok: true, value: this.writeFile(root, request.arguments) }; + case "exec": return { ok: true, value: await this.exec(root, request.arguments, signal) }; + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "remote workspace operation failed" }; + } finally { + release(); + } + } + + private listDirectory(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path"]); + const target = resolveExisting(root.path, args.path ?? ".", this.options.platform); + if (!statSync(target).isDirectory()) throw new Error("remote workspace list target is not a directory"); + const directory = opendirSync(target); + const entries: Array<{ name: string; type: "directory" | "file" | "symlink" | "other" }> = []; + try { + while (true) { + const entry = directory.readSync(); + if (!entry) break; + if (entries.length >= 4096) throw new Error("remote workspace directory has too many entries"); + entries.push({ + name: entry.name, + type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other", + }); + } + } finally { + directory.closeSync(); + } + return { + path: relative(root.path, target) || ".", + entries, + }; + } + + private readFile(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path", "maxBytes"]); + const target = resolveExisting(root.path, args.path, this.options.platform); + const maxBytes = boundedInteger(args.maxBytes, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, 1, this.maxOutputBytes); + const { body } = readBoundedRegularFile(root.path, target, maxBytes); + return { path: relative(root.path, target), content: decodeUtf8(body), sha256: sha256(body), bytes: body.byteLength }; + } + + private writeFile(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path", "content", "expectedSha256"]); + if (typeof args.content !== "string") throw new Error("remote workspace file content must be text"); + const body = Buffer.from(args.content, "utf8"); + if (body.byteLength > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) throw new Error("remote workspace file exceeds the write limit"); + const expectedSha256 = args.expectedSha256; + if (expectedSha256 !== null && (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256))) { + throw new Error("invalid remote workspace expected file hash"); + } + const target = resolveWritable(root.path, args.path, this.options.platform); + const mode = assertWritePrecondition(root.path, target, expectedSha256); + const temporary = resolve(dirname(target), `.${randomUUID()}.ocx-remote-write`); + try { + writeFileSync(temporary, body, { flag: "wx", mode }); + assertStableWritableParent(root.path, target); + assertWritePrecondition(root.path, target, expectedSha256); + renameAtomicFile(temporary, target, undefined, "remote-workspace"); + } finally { + try { unlinkSync(temporary); } catch { /* committed or already absent */ } + } + return { path: relative(root.path, target), sha256: sha256(body), bytes: body.byteLength }; + } + + private async exec(root: ApprovedRoot, input: unknown, signal?: AbortSignal): Promise { + if (!this.options.commandRunner) { + throw new Error("remote workspace command runner is disabled until an OS sandbox is configured"); + } + const args = objectArguments(input); + noExtraKeys(args, ["command", "cwd", "timeoutMs"]); + if (!Array.isArray(args.command) || args.command.length < 1 || args.command.length > 64) { + throw new Error("invalid remote workspace command vector"); + } + const command: string[] = []; + for (const value of args.command) { + if (typeof value !== "string" || value.length < 1 || value.length > 4096 || value.includes("\0")) { + throw new Error("invalid remote workspace command vector"); + } + command.push(value); + } + if (command.reduce((total, value) => total + value.length, 0) > 16 * 1024) { + throw new Error("remote workspace command vector is too large"); + } + const cwd = resolveExisting(root.path, args.cwd ?? ".", this.options.platform); + if (!statSync(cwd).isDirectory()) throw new Error("remote workspace command cwd is not a directory"); + const timeoutMs = boundedInteger(args.timeoutMs, 30_000, 1, 60_000); + const result = await this.options.commandRunner.run({ + command, + root: root.path, + cwd, + timeoutMs, + maxOutputBytes: this.maxOutputBytes, + signal, + }); + const outputBytes = Buffer.byteLength(result.stdout, "utf8") + Buffer.byteLength(result.stderr, "utf8"); + if (outputBytes > this.maxOutputBytes) { + throw new Error("remote workspace command runner exceeded its output contract"); + } + return { cwd: relative(root.path, cwd) || ".", ...result }; + } +} diff --git a/src/remote-control/workspace-hub.ts b/src/remote-control/workspace-hub.ts new file mode 100644 index 0000000000..b1c08e4c3b --- /dev/null +++ b/src/remote-control/workspace-hub.ts @@ -0,0 +1,519 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + randomBytes, + randomUUID, + sign, + timingSafeEqual, + verify, +} from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteControlIdentityKeyPair, +} from "./crypto"; +import type { RemoteWorkspaceHubAgentConnection } from "./workspace-agent-connection"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_HUB_STATE_VERSION = 1 as const; +export const REMOTE_WORKSPACE_MAX_DEVICES = 32; +export const REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE = 32; +const PAIRING_LIFETIME_MS = 10 * 60_000; +const MAX_PAIRING_GRANTS = 16; +const MAX_HUB_STATE_BYTES = 1024 * 1024; +const TOKEN_PREFIX = "ocxrw_"; +const PAIRING_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; +const PAIRING_SOURCE_WINDOW_MS = 10 * 60_000; +const PAIRING_SOURCE_FAILURE_LIMIT = 10; +const PAIRING_SOURCE_LIMIT = 1_024; + +export interface RemoteWorkspaceRootAdvertisement { + id: string; + label: string; +} + +export interface RemoteWorkspaceStoredDevice { + id: string; + name: string; + platform: string; + publicKey: string; + tokenHash: string; + capabilities: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; + createdAt: string; + lastSeenAt: string | null; +} + +export interface RemoteWorkspaceHubState { + version: typeof REMOTE_WORKSPACE_HUB_STATE_VERSION; + identity: RemoteControlIdentityKeyPair; + devices: RemoteWorkspaceStoredDevice[]; +} + +export interface RemoteWorkspaceHubStateStore { + load(): RemoteWorkspaceHubState | null; + save(state: RemoteWorkspaceHubState): void; +} + +export interface RemoteWorkspacePublicDevice { + id: string; + name: string; + platform: string; + capabilities: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; + online: boolean; + createdAt: string; + lastSeenAt: string | null; +} + +export interface RemoteWorkspacePairingGrant { + code: string; + expiresAt: string; +} + +export interface RemoteWorkspacePairDeviceInput { + code: string; + name: string; + platform: string; + publicKey: string; + capabilities?: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; +} + +export interface RemoteWorkspacePairDeviceResult { + device: RemoteWorkspacePublicDevice; + deviceToken: string; + hubPublicKey: string; +} + +interface PendingPairingGrant { + hash: Buffer; + expiresAt: number; +} + +interface PairingSourceFailureRecord { + failures: number; + windowStartedAt: number; +} + +export class RemoteWorkspacePairingRateLimitError extends Error { + constructor( + readonly retryAfterSeconds: number, + readonly reason: "source" | "capacity", + ) { + super("remote workspace pairing rate limit exceeded"); + this.name = "RemoteWorkspacePairingRateLimitError"; + } +} + +function sha256(value: string): Buffer { + return createHash("sha256").update(value, "utf8").digest(); +} + +function encodeHash(value: Buffer): string { + return value.toString("base64url"); +} + +function parseHash(value: unknown): Buffer { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) throw new Error("invalid remote workspace token hash"); + const decoded = Buffer.from(value, "base64url"); + if (decoded.byteLength !== 32) throw new Error("invalid remote workspace token hash"); + return decoded; +} + +function normalizeCode(value: string): string { + return value.replace(/[\s-]/g, "").toUpperCase(); +} + +function newPairingCode(): string { + const bytes = randomBytes(12); + let code = ""; + for (let index = 0; index < bytes.length; index += 1) { + code += PAIRING_ALPHABET[bytes[index]! % PAIRING_ALPHABET.length]; + } + return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`; +} + +function boundedText(value: unknown, label: string, max: number): string { + if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function objectRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid remote workspace device metadata"); + } + return value as Record; +} + +function exactPairingFields(value: Record): void { + const required = ["code", "name", "platform", "publicKey", "roots"] as const; + const allowed = new Set([...required, "capabilities"]); + if (required.some(key => !Object.hasOwn(value, key)) + || Object.keys(value).some(key => !allowed.has(key))) { + throw new Error("invalid remote workspace device metadata"); + } +} + +function validUuid(value: unknown, label: string): string { + const normalized = boundedText(value, label, 64); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function validatePublicKey(value: unknown): string { + const encoded = boundedText(value, "device public key", 1024); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error("invalid remote workspace device public key"); + const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519"); + return encoded; +} + +function validateIdentity(value: unknown): RemoteControlIdentityKeyPair { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub identity"); + const raw = value as Record; + const publicKey = validatePublicKey(raw.publicKey); + const privateKey = boundedText(raw.privateKey, "hub private key", 2048); + const privateDer = Buffer.from(privateKey, "base64url"); + const parsed = createPrivateKey({ key: privateDer, type: "pkcs8", format: "der" }); + if (parsed.asymmetricKeyType !== "ed25519") throw new Error("remote workspace hub key must use Ed25519"); + const challenge = Buffer.from("opencodex remote workspace hub identity v1", "utf8"); + const signature = sign(null, challenge, parsed); + const verifier = createPublicKey({ key: Buffer.from(publicKey, "base64url"), type: "spki", format: "der" }); + if (!verify(null, challenge, verifier, signature)) { + throw new Error("remote workspace hub identity key pair does not match"); + } + return { publicKey, privateKey }; +} + +function validateRoots(value: unknown): RemoteWorkspaceRootAdvertisement[] { + if (!Array.isArray(value) || value.length < 1 || value.length > REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE) { + throw new Error("remote workspace device needs one to 32 roots"); + } + const ids = new Set(); + const labels = new Set(); + return value.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace root"); + const raw = item as Record; + const id = validUuid(raw.id, "root ID"); + const label = boundedText(raw.label, "root label", 80); + const folded = label.toLocaleLowerCase("en-US"); + if (ids.has(id) || labels.has(folded)) throw new Error("duplicate remote workspace root"); + ids.add(id); + labels.add(folded); + return { id, label }; + }); +} + +function validateDate(value: unknown, nullable = false): string | null { + if (nullable && value === null) return null; + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error("invalid remote workspace timestamp"); + return value; +} + +export function parseRemoteWorkspaceHubState(value: unknown): RemoteWorkspaceHubState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_HUB_STATE_VERSION || !Array.isArray(raw.devices)) { + throw new Error("unsupported remote workspace hub state"); + } + if (raw.devices.length > REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit exceeded"); + const ids = new Set(); + const names = new Set(); + const devices = raw.devices.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device state"); + const device = item as Record; + const id = validUuid(device.id, "device ID"); + const name = boundedText(device.name, "device name", 80); + const folded = name.toLocaleLowerCase("en-US"); + if (ids.has(id) || names.has(folded)) throw new Error("duplicate remote workspace device identity"); + ids.add(id); + names.add(folded); + if (typeof device.tokenHash !== "string") throw new Error("invalid remote workspace token hash"); + parseHash(device.tokenHash); + const tokenHash = device.tokenHash; + return { + id, + name, + platform: boundedText(device.platform, "device platform", 80), + publicKey: validatePublicKey(device.publicKey), + tokenHash, + capabilities: parseRemoteWorkspaceCapabilities(device.capabilities), + roots: validateRoots(device.roots), + createdAt: validateDate(device.createdAt)!, + lastSeenAt: validateDate(device.lastSeenAt, true), + }; + }); + return { + version: REMOTE_WORKSPACE_HUB_STATE_VERSION, + identity: validateIdentity(raw.identity), + devices, + }; +} + +export class RemoteWorkspaceHubFileStore implements RemoteWorkspaceHubStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-hub.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceHubState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_HUB_STATE_BYTES) { + throw new Error("remote workspace hub state is too large"); + } + return parseRemoteWorkspaceHubState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceHubState): void { + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceHubState(state), null, 2)}\n`); + } +} + +export class RemoteWorkspaceHub { + private state: RemoteWorkspaceHubState; + private readonly grants = new Map(); + private readonly pairingSourceFailures = new Map(); + private readonly connections = new Map(); + + constructor( + private readonly store: RemoteWorkspaceHubStateStore, + private readonly now: () => number = Date.now, + ) { + const loaded = store.load(); + this.state = loaded ?? { + version: REMOTE_WORKSPACE_HUB_STATE_VERSION, + identity: generateRemoteControlIdentityKeyPair(), + devices: [], + }; + if (loaded === null) this.store.save(this.state); + } + + identity(): RemoteControlIdentityKeyPair { + return { ...this.state.identity }; + } + + createPairingGrant(): RemoteWorkspacePairingGrant { + this.pruneGrants(); + if (this.grants.size >= MAX_PAIRING_GRANTS) throw new Error("remote workspace pairing capacity reached"); + let code: string; + let digest: Buffer; + do { + code = newPairingCode(); + digest = sha256(normalizeCode(code)); + } while (this.grants.has(encodeHash(digest))); + const expiresAt = this.now() + PAIRING_LIFETIME_MS; + this.grants.set(encodeHash(digest), { hash: digest, expiresAt }); + return { code, expiresAt: new Date(expiresAt).toISOString() }; + } + + private pairingSourceKey(source: string): string { + return encodeHash(sha256(`remote-workspace-pairing-source\0${source}`)); + } + + private prunePairingSourceFailures(now: number): void { + // Records never extend their original fixed window, so insertion order is expiry order. Stop + // at the first live entry instead of making every unauthenticated request scan the full cap. + for (const [key, record] of this.pairingSourceFailures) { + if (record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS > now) break; + this.pairingSourceFailures.delete(key); + } + } + + private pairingSourceRecord(source: string, now: number): [string, PairingSourceFailureRecord | undefined] { + this.prunePairingSourceFailures(now); + const key = this.pairingSourceKey(source); + return [key, this.pairingSourceFailures.get(key)]; + } + + private admitPairingSource(source: string, now: number): string { + const [key, record] = this.pairingSourceRecord(source, now); + if (record && record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) { + const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now); + throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source"); + } + return key; + } + + assertPairingSourceAllowed(source = "anonymous"): void { + this.admitPairingSource(source, this.now()); + } + + private recordPairingSourceFailure(key: string, now: number): void { + let record = this.pairingSourceFailures.get(key); + if (!record) { + if (this.pairingSourceFailures.size >= PAIRING_SOURCE_LIMIT) { + throw new RemoteWorkspacePairingRateLimitError(1, "capacity"); + } + record = { failures: 0, windowStartedAt: now }; + this.pairingSourceFailures.set(key, record); + } + record.failures += 1; + if (record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) { + const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now); + throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source"); + } + } + + pairDevice(input: unknown, source = "anonymous"): RemoteWorkspacePairDeviceResult { + this.pruneGrants(); + const nowMs = this.now(); + const sourceKey = this.admitPairingSource(source, nowMs); + const raw = objectRecord(input); + const normalizedCode = normalizeCode(typeof raw.code === "string" ? raw.code : ""); + if (normalizedCode.length !== 12 || ![...normalizedCode].every(character => PAIRING_ALPHABET.includes(character))) { + this.recordPairingSourceFailure(sourceKey, nowMs); + throw new Error("invalid or expired remote workspace pairing code"); + } + const digest = sha256(normalizedCode); + const key = encodeHash(digest); + const grant = this.grants.get(key); + if (!grant || grant.expiresAt <= nowMs || !timingSafeEqual(grant.hash, digest)) { + this.recordPairingSourceFailure(sourceKey, nowMs); + throw new Error("invalid or expired remote workspace pairing code"); + } + this.pairingSourceFailures.delete(sourceKey); + // A valid grant is one-shot even when the submitted device metadata is rejected. Keeping it + // alive after a conflict would let the same copied secret authorize repeated enrollment tries. + this.grants.delete(key); + exactPairingFields(raw); + if (this.state.devices.length >= REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit reached"); + const name = boundedText(raw.name, "device name", 80); + const folded = name.toLocaleLowerCase("en-US"); + if (this.state.devices.some(device => device.name.toLocaleLowerCase("en-US") === folded)) { + throw new Error("remote workspace device name is already in use"); + } + const now = new Date(nowMs).toISOString(); + const token = `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`; + const device: RemoteWorkspaceStoredDevice = { + id: randomUUID(), + name, + platform: boundedText(raw.platform, "device platform", 80), + publicKey: validatePublicKey(raw.publicKey), + tokenHash: encodeHash(sha256(token)), + capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities), + roots: validateRoots(raw.roots), + createdAt: now, + lastSeenAt: null, + }; + this.state = { ...this.state, devices: [...this.state.devices, device] }; + this.store.save(this.state); + return { + device: this.publicDevice(device), + deviceToken: token, + hubPublicKey: this.state.identity.publicKey, + }; + } + + authenticateDeviceToken(token: string): RemoteWorkspaceStoredDevice | null { + if (!token.startsWith(TOKEN_PREFIX) || token.length !== TOKEN_PREFIX.length + 43) return null; + const digest = sha256(token); + for (const device of this.state.devices) { + const stored = parseHash(device.tokenHash); + if (timingSafeEqual(stored, digest)) { + return { ...device, capabilities: [...device.capabilities], roots: device.roots.map(root => ({ ...root })) }; + } + } + return null; + } + + attachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void { + const index = this.state.devices.findIndex(device => device.id === deviceId); + if (index < 0) throw new Error("unknown remote workspace device"); + const previous = this.connections.get(deviceId); + if (previous && previous !== connection) previous.close("remote workspace executor reconnected"); + this.connections.set(deviceId, connection); + const seen = new Date(this.now()).toISOString(); + this.state = { + ...this.state, + devices: this.state.devices.map((device, deviceIndex) => ( + deviceIndex === index ? { ...device, lastSeenAt: seen } : device + )), + }; + this.store.save(this.state); + } + + updateDeviceCapabilities(deviceId: string, capabilities: readonly RemoteWorkspaceCapability[]): void { + const device = this.state.devices.find(candidate => candidate.id === deviceId); + if (!device) throw new Error("unknown remote workspace device"); + const normalized = parseRemoteWorkspaceCapabilities(capabilities); + if (normalized.some(capability => !device.capabilities.includes(capability))) { + throw new Error("remote workspace presence exceeds enrollment grant"); + } + // Connection availability is transient; the persisted enrollment grant is unchanged. + } + + detachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void { + if (this.connections.get(deviceId) !== connection) return; + this.connections.delete(deviceId); + connection.close(); + } + + connection(deviceId: string): RemoteWorkspaceHubAgentConnection | null { + const connection = this.connections.get(deviceId); + return connection?.isOnline() ? connection : null; + } + + listDevices(): RemoteWorkspacePublicDevice[] { + return this.state.devices.map(device => this.publicDevice(device)); + } + + revokeDevice(deviceId: string): boolean { + const before = this.state.devices.length; + this.state = { ...this.state, devices: this.state.devices.filter(device => device.id !== deviceId) }; + if (this.state.devices.length === before) return false; + const connection = this.connections.get(deviceId); + this.connections.delete(deviceId); + connection?.close("remote workspace device was revoked"); + this.store.save(this.state); + return true; + } + + closeAllConnections(reason = "remote workspace hub stopped"): void { + const connections = [...this.connections.values()]; + this.connections.clear(); + for (const connection of connections) connection.close(reason); + } + + private publicDevice(device: RemoteWorkspaceStoredDevice): RemoteWorkspacePublicDevice { + return { + id: device.id, + name: device.name, + platform: device.platform, + capabilities: device.capabilities.filter(capability => { + const connection = this.connections.get(device.id); + return !connection || connection.capabilities().includes(capability); + }), + roots: device.roots.map(root => ({ ...root })), + online: this.connections.get(device.id)?.isOnline() ?? false, + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + }; + } + + private pruneGrants(): void { + const now = this.now(); + for (const [key, grant] of this.grants) { + if (grant.expiresAt <= now) this.grants.delete(key); + } + } +} diff --git a/src/remote-control/workspace-pi-runtime.ts b/src/remote-control/workspace-pi-runtime.ts new file mode 100644 index 0000000000..a5987a5fe1 --- /dev/null +++ b/src/remote-control/workspace-pi-runtime.ts @@ -0,0 +1,382 @@ +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + waitForRemoteWorkspaceProcessExit, +} from "./workspace-process"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import { REMOTE_WORKSPACE_DYNAMIC_TOOLS } from "./workspace-tools"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, +} from "./workspace-sessions"; + +const MAX_JSON_LINE_BYTES = 2 * 1024 * 1024; + +interface PendingResponse { + resolve(value: Record): void; + reject(error: Error): void; + timer: ReturnType; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function safeError(value: unknown, fallback: string): string { + return (value instanceof Error ? value.message : typeof value === "string" ? value : fallback) + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function messageText(value: unknown): string | null { + const message = record(value); + if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return null; + const text = message.content.flatMap(raw => { + const part = record(raw); + return part?.type === "text" && typeof part.text === "string" ? [part.text] : []; + }).join(""); + return text || null; +} + +function remotePiInstructions(deviceName: string, tools: readonly string[]): string { + const name = deviceName.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120) || "remote executor"; + const remoteTools = tools.map(tool => `remote_${tool}`).join(", "); + return [ + `You operate only on the OpenCodex remote executor named ${JSON.stringify(name)}.`, + `Use only these tools for filesystem and command work: ${remoteTools}.`, + "The Hub working directory is an empty isolation boundary, not the user's project.", + "If a remote tool fails or the executor is offline, stop and report it. Never substitute local operations.", + ].join(" "); +} + +function extensionSource(tools: readonly string[]): string { + const allowed = new Set(tools); + const definitions = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => allowed.has(tool.name)).map(tool => ({ + remoteName: `remote_${tool.name}`, + tool: tool.name, + description: tool.description, + parameters: tool.inputSchema, + })); + return `const definitions = ${JSON.stringify(definitions)}; +const endpoint = process.env.OCX_REMOTE_WORKSPACE_BRIDGE_URL; +const token = process.env.OCX_REMOTE_WORKSPACE_BRIDGE_TOKEN; + +export default function registerRemoteWorkspace(pi) { + if (!endpoint || !token) throw new Error("Remote Workspace bridge is unavailable"); + for (const definition of definitions) { + pi.registerTool({ + name: definition.remoteName, + label: definition.remoteName, + description: definition.description, + parameters: definition.parameters, + async execute(_toolCallId, parameters, signal) { + const response = await fetch(endpoint + "/invoke", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer " + token }, + body: JSON.stringify({ tool: definition.tool, arguments: parameters }), + signal, + }); + const result = await response.json(); + if (!response.ok || !result || result.success !== true) { + throw new Error(result && typeof result.text === "string" ? result.text : "Remote Workspace tool failed"); + } + return { content: [{ type: "text", text: result.text }], details: { remote: true } }; + }, + }); + } +} +`; +} + +class PiRpcProcess { + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + private activeSettle: { resolve(): void; reject(error: Error): void } | null = null; + + onEvent: ((event: Record) => void) | null = null; + + constructor(private readonly child: Bun.Subprocess<"pipe", "pipe", "pipe">) { + void this.read(); + void this.drainStderr(); + void child.exited.then(code => this.fail(new Error(`Pi RPC exited with code ${code}`))); + } + + async command(type: string, fields: Record = {}, timeoutMs = 15_000): Promise> { + if (this.closed) throw new Error("Pi RPC is closed"); + const id = `ocx-${++this.nextId}`; + const result = new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${type} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + }); + try { + this.send({ id, type, ...fields }); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error instanceof Error ? error : new Error("Pi RPC write failed")); + } + } + return result; + } + + async prompt(message: string): Promise { + if (this.activeSettle) throw new Error("Pi Remote Workspace turn is already active"); + const settled = new Promise((resolve, reject) => { this.activeSettle = { resolve, reject }; }); + try { + const accepted = await this.command("prompt", { message }); + if (accepted.success !== true) throw new Error(safeError(accepted.error, "Pi rejected the prompt")); + await settled; + } catch (error) { + this.activeSettle = null; + throw error; + } + } + + async abort(): Promise { + if (!this.activeSettle) return; + await this.command("abort", {}, 3_000).catch(() => {}); + } + + async close(): Promise { + try { + if (!this.closed) { + try { this.child.stdin.end(); } catch { /* already closed */ } + } + const graceful = await waitForRemoteWorkspaceProcessExit(this.child, 1_500); + if (!graceful) { + await stopRemoteWorkspaceProcess(this.child); + } + } finally { + // Active and pending RPC waiters cannot survive a failed process teardown. + this.fail(new Error("Pi Remote Workspace session closed")); + } + } + + private send(value: Record): void { + const line = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Pi RPC message is too large"); + this.child.stdin.write(line); + this.child.stdin.flush(); + } + + private async read(): Promise { + const reader = this.child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_JSON_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Pi RPC output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Pi RPC output line is too large"); + if (line) { + const event = record(JSON.parse(line)); + if (!event) throw new Error("invalid Pi RPC event"); + this.receive(event); + } + newline = buffer.indexOf("\n"); + } + } + } catch (error) { + void stopRemoteWorkspaceProcess(this.child).catch(() => {}); + this.fail(new Error(safeError(error, "Pi RPC output failed"))); + } finally { + reader.releaseLock(); + } + } + + private async drainStderr(): Promise { + const reader = this.child.stderr.getReader(); + try { while (!(await reader.read()).done) { /* drain without retaining secrets */ } } + catch { /* stdout/exit code owns the failure */ } + finally { reader.releaseLock(); } + } + + private receive(event: Record): void { + if (event.type === "response" && typeof event.id === "string") { + const pending = this.pending.get(event.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(event.id); + pending.resolve(event); + return; + } + if (event.type === "agent_settled") { + const active = this.activeSettle; + this.activeSettle = null; + active?.resolve(); + } + if (event.type === "extension_error") { + const active = this.activeSettle; + this.activeSettle = null; + active?.reject(new Error(safeError(event.error, "Pi Remote Workspace extension failed"))); + } + this.onEvent?.(event); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + const active = this.activeSettle; + this.activeSettle = null; + active?.reject(error); + } +} + +export interface PiRemoteWorkspaceRuntimeOptions { + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class PiRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "pi" as const; + + constructor(private readonly options: PiRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + const command = this.options.command && this.options.command.length > 0 + ? this.options.command[0] + : findExecutableOnPath("pi"); + return command + ? { available: true, ...(this.options.version ? { version: this.options.version } : {}) } + : { available: false, reason: "Pi is not installed on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const configuredCommand = this.options.command && this.options.command.length > 0 + ? [...this.options.command] + : null; + const executable = configuredCommand?.[0] ?? findExecutableOnPath("pi"); + if (!executable) throw new Error("Pi is not installed on this Hub"); + const commandPrefix = configuredCommand ?? [executable]; + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-pi-")); + try { + chmodSync(isolation, 0o700); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const extensionPath = join(isolation, "remote-workspace-extension.js"); + try { + writeFileSync(extensionPath, extensionSource(options.tools), { mode: 0o600 }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const threadId = options.resumeThreadId ?? randomUUID(); + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const childEnv = { + ...process.env, + ...this.options.env, + OCX_REMOTE_WORKSPACE_BRIDGE_URL: bridge.url, + OCX_REMOTE_WORKSPACE_BRIDGE_TOKEN: bridge.token, + }; + const invocation = remoteWorkspaceProcessInvocation([ + ...commandPrefix, + "--mode", "rpc", + "--session-id", threadId, + "--name", `OCX Remote: ${options.deviceName}`, + "--no-builtin-tools", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-themes", + "--no-context-files", + "--no-approve", + "--extension", extensionPath, + "--tools", options.tools.map(tool => `remote_${tool}`).join(","), + "--system-prompt", remotePiInstructions(options.deviceName, options.tools), + ], { env: childEnv }); + let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const rpc = new PiRpcProcess(child); + rpc.onEvent = event => { + if (event.type === "message_end") { + const text = messageText(event.message); + if (text) options.emit("assistant", text); + } + if (event.type === "tool_execution_start" && typeof event.toolName === "string") { + options.emit("tool", `Pi requested ${event.toolName}`); + } + }; + try { + const state = await rpc.command("get_state"); + if (state.success !== true) throw new Error(safeError(state.error, "Pi RPC failed to initialize")); + } catch (error) { + await rpc.close().catch(() => {}); + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + let stopped = false; + let stopOperation: Promise | null = null; + return { + threadId, + prompt: text => rpc.prompt(text), + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + stopOperation = runRemoteWorkspaceCleanupSteps([ + () => rpc.abort(), + () => rpc.close(), + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } +} diff --git a/src/remote-control/workspace-process.ts b/src/remote-control/workspace-process.ts new file mode 100644 index 0000000000..cc177e5ebc --- /dev/null +++ b/src/remote-control/workspace-process.ts @@ -0,0 +1,129 @@ +import { execFileSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { commandInvocation, type SpawnInvocation } from "../lib/win-exec"; +import { resolveTrustedWindowsTaskkillExe } from "../lib/windows-elevation"; + +export interface RemoteWorkspaceProcessInvocationOptions { + platform?: NodeJS.Platform; + env?: Record; +} + +/** + * Preserve argv boundaries on Unix and route Windows npm `.cmd`/`.bat` shims through the + * repository's audited ComSpec escaping. `shell: true` is deliberately never used. + */ +export function remoteWorkspaceProcessInvocation( + command: readonly string[], + options: RemoteWorkspaceProcessInvocationOptions = {}, +): SpawnInvocation { + if (command.length < 1 || !command[0]) throw new Error("remote workspace process command is empty"); + return commandInvocation( + command[0], + command.slice(1), + options.platform ?? process.platform, + { env: options.env ?? process.env }, + ); +} + +export interface RemoteWorkspaceOwnedProcess { + pid: number; + exitCode: number | null; + exited: Promise; + kill(signal?: number | NodeJS.Signals): void; +} + +export interface StopRemoteWorkspaceProcessOptions { + platform?: NodeJS.Platform; + taskkillPath?: string; + execFile?: (file: string, args: readonly string[]) => void; + waitMs?: number; +} + +export async function waitForRemoteWorkspaceProcessExit( + child: RemoteWorkspaceOwnedProcess, + waitMs: number, +): Promise { + if (!Number.isSafeInteger(waitMs) || waitMs < 1) throw new Error("invalid remote workspace process wait"); + let timer: ReturnType | null = null; + try { + return await Promise.race([ + child.exited.then(() => true, () => true), + new Promise(resolve => { timer = setTimeout(() => resolve(false), waitMs); }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** Run every owned-resource cleanup step and report the first failure only after all were attempted. */ +export async function runRemoteWorkspaceCleanupSteps( + steps: readonly (() => void | Promise)[], +): Promise { + let failed = false; + let firstFailure: unknown; + for (const step of steps) { + try { + await step(); + } catch (error) { + if (!failed) firstFailure = error; + failed = true; + } + } + if (failed) { + throw firstFailure instanceof Error + ? firstFailure + : new Error("remote workspace cleanup failed"); + } +} + +/** Stop only the process OCX spawned; Windows must include its `.cmd` descendant tree. */ +export async function stopRemoteWorkspaceProcess( + child: RemoteWorkspaceOwnedProcess, + options: StopRemoteWorkspaceProcessOptions = {}, +): Promise { + if (child.exitCode !== null) return; + const platform = options.platform ?? process.platform; + if (platform === "win32") { + const exec = options.execFile ?? ((file: string, args: readonly string[]) => { + execFileSync(file, [...args], { stdio: "ignore", timeout: 5_000, windowsHide: true }); + }); + try { + exec(options.taskkillPath ?? resolveTrustedWindowsTaskkillExe(), ["/PID", String(child.pid), "/T", "/F"]); + } catch { + try { child.kill(); } catch { /* child already exited */ } + } + if (!await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500)) { + throw new Error("remote workspace Windows process tree did not exit"); + } + } else { + try { child.kill("SIGTERM"); } catch { /* child already exited */ } + const exited = await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500); + if (!exited) { + try { child.kill("SIGKILL"); } catch { /* child already exited */ } + if (!await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500)) { + throw new Error("remote workspace process did not exit after SIGKILL"); + } + } + } +} + +/** Windows AV/indexers can retain just-exited CLI files briefly; use Node's bounded retry. */ +export function removeRemoteWorkspaceIsolation(path: string): void { + rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); +} + +/** + * [Decision Log] + * - 목적과 의도: Make Hub-owned Codex, Claude Code, and Pi processes start and stop identically + * across Linux, macOS, and Windows without leaving npm-shim descendants behind. + * - 기존 구현 및 제약 조건: Unix can spawn executable scripts directly. Windows npm exposes + * `.cmd` files that Bun cannot safely launch shell-less, and killing cmd.exe alone can orphan Node. + * - 검토한 주요 대안: `shell: true`, three runtime-specific wrappers, direct `.cmd` spawn, or the + * repository's existing escaped ComSpec invocation plus trusted System32 taskkill. + * - 선택한 방식: Share one launcher and one owned-process stop helper across all three runtimes. + * - 다른 대안 대신 이 방식을 선택한 이유: It preserves exact argv boundaries, avoids a PATH- + * resolved shell/taskkill hijack, and matches already-tested OpenCodex Windows behavior. + * - 장점, 단점 및 영향: Windows npm installs work and stop cleanly. Windows stop is necessarily + * forceful because its normal process kill is already forceful; Unix gets a graceful SIGTERM + * window and then a bounded SIGKILL fallback so an ignoring child cannot outlive the session. + */ diff --git a/src/remote-control/workspace-rpc.ts b/src/remote-control/workspace-rpc.ts new file mode 100644 index 0000000000..15f8c7a381 --- /dev/null +++ b/src/remote-control/workspace-rpc.ts @@ -0,0 +1,304 @@ +import type { RemoteControlCipher } from "./crypto"; +import type { + RemoteWorkspaceExecutionRequest, + RemoteWorkspaceExecutor, +} from "./workspace-executor"; +import { + isRemoteWorkspaceToolName, + remoteWorkspaceCapabilityForTool, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; +import type { RemoteWorkspaceTransport } from "./workspace-coordinator"; +import { + REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, + RemoteWorkspaceRpcReassembler, + frameRemoteWorkspaceRpcMessage, +} from "./workspace-rpc-framing"; + +const REMOTE_WORKSPACE_RPC_VERSION = 1 as const; +const REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS = 30_000; +const REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS = 8; +interface RemoteWorkspaceRpcRequest { + version: typeof REMOTE_WORKSPACE_RPC_VERSION; + kind: "request"; + request: RemoteWorkspaceExecutionRequest; +} + +interface RemoteWorkspaceRpcResponse { + version: typeof REMOTE_WORKSPACE_RPC_VERSION; + kind: "response"; + requestId: string; + result: RemoteWorkspaceToolResult; +} + +type RemoteWorkspaceRpcMessage = RemoteWorkspaceRpcRequest | RemoteWorkspaceRpcResponse; + +interface PendingRequest { + resolve(value: RemoteWorkspaceToolResult): void; + reject(error: Error): void; + timer: ReturnType; +} + +function boundedIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value); +} + +function encodeMessage(value: RemoteWorkspaceRpcMessage): Uint8Array { + const encoded = new TextEncoder().encode(JSON.stringify(value)); + if (encoded.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("remote workspace RPC message exceeds the bounded message limit"); + } + return encoded; +} + +function parseResult(value: unknown): RemoteWorkspaceToolResult { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace RPC result"); + const raw = value as Record; + if (Object.keys(raw).some(key => key !== "ok" && key !== "value" && key !== "error")) { + throw new Error("invalid remote workspace RPC result fields"); + } + if (raw.ok === true && raw.error === undefined) { + return raw.value === undefined ? { ok: true } : { ok: true, value: raw.value }; + } + if (raw.ok === false && raw.value === undefined + && typeof raw.error === "string" && raw.error.length >= 1 && raw.error.length <= 4096) { + return { ok: false, error: raw.error }; + } + throw new Error("invalid remote workspace RPC result status"); +} + +function parseRequest(value: unknown): RemoteWorkspaceExecutionRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace RPC request"); + const raw = value as Record; + if ( + !boundedIdentifier(raw.requestId) + || !boundedIdentifier(raw.sessionId) + || !boundedIdentifier(raw.executorDeviceId) + || !boundedIdentifier(raw.rootId) + || !isRemoteWorkspaceToolName(raw.tool) + ) throw new Error("invalid remote workspace RPC request identity"); + return { + requestId: raw.requestId, + sessionId: raw.sessionId, + executorDeviceId: raw.executorDeviceId, + rootId: raw.rootId, + tool: raw.tool, + arguments: raw.arguments, + }; +} + +function parseMessage(value: Uint8Array): RemoteWorkspaceRpcMessage { + if (!(value instanceof Uint8Array) || value.byteLength < 1 || value.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("invalid remote workspace RPC message length"); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error("invalid remote workspace RPC JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid remote workspace RPC message"); + const raw = parsed as Record; + if (raw.version !== REMOTE_WORKSPACE_RPC_VERSION) throw new Error("unsupported remote workspace RPC version"); + if (raw.kind === "request") { + return { version: REMOTE_WORKSPACE_RPC_VERSION, kind: "request", request: parseRequest(raw.request) }; + } + if (raw.kind === "response" && boundedIdentifier(raw.requestId)) { + return { + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: raw.requestId, + result: parseResult(raw.result), + }; + } + throw new Error("invalid remote workspace RPC message kind"); +} + +export interface EncryptedRemoteWorkspaceTransportOptions { + executorDeviceId: string; + cipher: RemoteControlCipher; + sendCiphertext(value: Uint8Array): void | Promise; + timeoutMs?: number; +} + +/** Coordinator-side transport. The WebSocket/relay adapter only has to carry ciphertext. */ +export class EncryptedRemoteWorkspaceTransport implements RemoteWorkspaceTransport { + private readonly pending = new Map(); + private readonly reassembler = new RemoteWorkspaceRpcReassembler(); + private readonly timeoutMs: number; + private sendTail: Promise = Promise.resolve(); + private online = true; + + constructor(private readonly options: EncryptedRemoteWorkspaceTransportOptions) { + this.timeoutMs = options.timeoutMs ?? REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS; + if (!boundedIdentifier(options.executorDeviceId) || !Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) { + throw new Error("invalid encrypted remote workspace transport options"); + } + } + + isOnline(deviceId: string): boolean { + return this.online && deviceId === this.options.executorDeviceId; + } + + async invoke(request: RemoteWorkspaceExecutionRequest): Promise { + if (!this.isOnline(request.executorDeviceId)) throw new Error("remote workspace executor is offline"); + if (this.pending.has(request.requestId)) throw new Error("duplicate remote workspace request ID"); + if (this.pending.size >= REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS) { + throw new Error("remote workspace request limit reached"); + } + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(request.requestId); + reject(new Error("remote workspace request timed out")); + }, this.timeoutMs); + this.pending.set(request.requestId, { resolve, reject, timer }); + }); + try { + await this.sendMessage(encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "request", + request, + })); + } catch { + // A failed encrypted write consumes a directional counter. Continuing would make every + // later frame undecryptable, so fail every pending operation instead of waiting for timeout. + this.close("remote workspace send failed"); + } + return await response; + } + + receiveCiphertext(value: Uint8Array): void { + if (!this.online) throw new Error("remote workspace transport is closed"); + const responsePlaintext = this.reassembler.accept(this.options.cipher.decrypt(value)); + if (!responsePlaintext) return; + const message = parseMessage(responsePlaintext); + if (message.kind !== "response") throw new Error("coordinator received a remote workspace request"); + const pending = this.pending.get(message.requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.requestId); + pending.resolve(message.result); + } + + close(reason = "remote workspace transport closed"): void { + if (!this.online) return; + this.online = false; + this.reassembler.clear(); + this.options.cipher.destroy(); + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + this.pending.clear(); + } + + private sendMessage(message: Uint8Array): Promise { + const operation = this.sendTail.then(async () => { + if (!this.online) throw new Error("remote workspace transport is closed"); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await this.options.sendCiphertext(this.options.cipher.encrypt(frame)); + } + }); + this.sendTail = operation.catch(() => {}); + return operation; + } +} + +export interface EncryptedRemoteWorkspaceExecutorEndpointOptions { + executorDeviceId: string; + sessionId: string; + rootId: string; + capabilities: readonly RemoteWorkspaceCapability[]; + cipher: RemoteControlCipher; + executor: Pick; + sendCiphertext(value: Uint8Array): void | Promise; +} + +/** Executor-side endpoint. It accepts only authenticated, ordered E2EE session frames. */ +export class EncryptedRemoteWorkspaceExecutorEndpoint { + private closed = false; + private readonly active = new Map(); + private readonly reassembler = new RemoteWorkspaceRpcReassembler(); + private sendTail: Promise = Promise.resolve(); + + private readonly grantedCapabilities: ReadonlySet; + private readonly sessionId: string; + private readonly rootId: string; + + constructor(private readonly options: EncryptedRemoteWorkspaceExecutorEndpointOptions) { + if (!boundedIdentifier(options.executorDeviceId) || !boundedIdentifier(options.sessionId) + || !boundedIdentifier(options.rootId)) throw new Error("invalid remote workspace executor endpoint"); + this.options = { ...options }; + this.sessionId = options.sessionId; + this.rootId = options.rootId; + this.grantedCapabilities = new Set(options.capabilities); + } + + async receiveCiphertext(value: Uint8Array): Promise { + if (this.closed) throw new Error("remote workspace executor endpoint is closed"); + const requestPlaintext = this.reassembler.accept(this.options.cipher.decrypt(value)); + if (!requestPlaintext) return; + const message = parseMessage(requestPlaintext); + if (message.kind !== "request") throw new Error("executor received a remote workspace response"); + if (message.request.executorDeviceId !== this.options.executorDeviceId) { + throw new Error("remote workspace encrypted request targeted another executor"); + } + if (message.request.sessionId !== this.sessionId || message.request.rootId !== this.rootId) { + throw new Error("remote workspace encrypted request does not match its session binding"); + } + if (!this.grantedCapabilities.has(remoteWorkspaceCapabilityForTool(message.request.tool))) { + throw new Error("remote workspace tool capability was not granted to this session"); + } + if (this.active.has(message.request.requestId)) throw new Error("duplicate remote workspace executor request ID"); + if (this.active.size >= REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS) { + throw new Error("remote workspace executor request limit reached"); + } + const controller = new AbortController(); + this.active.set(message.request.requestId, controller); + let result: RemoteWorkspaceToolResult; + try { + result = await this.options.executor.invoke(message.request, controller.signal); + } finally { + this.active.delete(message.request.requestId); + } + if (this.closed) return; + let responsePlaintext: Uint8Array; + try { + responsePlaintext = encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: message.request.requestId, + result, + }); + } catch { + responsePlaintext = encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: message.request.requestId, + result: { ok: false, error: "remote workspace result exceeded the encrypted frame limit" }, + }); + } + await this.sendMessage(responsePlaintext); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.reassembler.clear(); + for (const controller of this.active.values()) controller.abort(); + this.active.clear(); + this.options.cipher.destroy(); + } + + private sendMessage(message: Uint8Array): Promise { + const operation = this.sendTail.then(async () => { + if (this.closed) throw new Error("remote workspace executor endpoint is closed"); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await this.options.sendCiphertext(this.options.cipher.encrypt(frame)); + } + }); + this.sendTail = operation.catch(() => {}); + return operation; + } +} diff --git a/src/remote-control/workspace-runtime.ts b/src/remote-control/workspace-runtime.ts new file mode 100644 index 0000000000..01596753c2 --- /dev/null +++ b/src/remote-control/workspace-runtime.ts @@ -0,0 +1,60 @@ +import type { OcxConfig } from "../types"; +import { + RemoteWorkspaceHub, + RemoteWorkspaceHubFileStore, + type RemoteWorkspaceHubStateStore, +} from "./workspace-hub"; +import { CodexRemoteWorkspaceRuntimeFactory } from "./workspace-codex-runtime"; +import { ClaudeRemoteWorkspaceRuntimeFactory } from "./workspace-claude-runtime"; +import { PiRemoteWorkspaceRuntimeFactory } from "./workspace-pi-runtime"; +import { + RemoteWorkspaceSessionFileStore, + RemoteWorkspaceSessionService, +} from "./workspace-sessions"; + +const hubs = new WeakMap(); +const sessionServices = new WeakMap(); + +export function remoteWorkspaceHubForConfig( + config: Readonly, + store?: RemoteWorkspaceHubStateStore, +): RemoteWorkspaceHub { + if (config.runtimeRole !== "hub") throw new Error("remote workspace requires runtimeRole=hub"); + const existing = hubs.get(config); + if (existing) return existing; + const hub = new RemoteWorkspaceHub(store ?? new RemoteWorkspaceHubFileStore()); + hubs.set(config, hub); + return hub; +} + +export function remoteWorkspaceSessionsForConfig( + config: Readonly, +): RemoteWorkspaceSessionService { + if (config.runtimeRole !== "hub") throw new Error("remote workspace requires runtimeRole=hub"); + const existing = sessionServices.get(config); + if (existing) return existing; + const service = new RemoteWorkspaceSessionService( + remoteWorkspaceHubForConfig(config), + [ + new CodexRemoteWorkspaceRuntimeFactory(), + new ClaudeRemoteWorkspaceRuntimeFactory(), + new PiRemoteWorkspaceRuntimeFactory(), + ], + Date.now, + new RemoteWorkspaceSessionFileStore(), + ); + sessionServices.set(config, service); + return service; +} + +export function initializedRemoteWorkspaceHubForConfig( + config: Readonly, +): RemoteWorkspaceHub | null { + return hubs.get(config) ?? null; +} + +export function initializedRemoteWorkspaceSessionsForConfig( + config: Readonly, +): RemoteWorkspaceSessionService | null { + return sessionServices.get(config) ?? null; +} diff --git a/src/remote-control/workspace-secret-store.ts b/src/remote-control/workspace-secret-store.ts new file mode 100644 index 0000000000..9ff1c29249 --- /dev/null +++ b/src/remote-control/workspace-secret-store.ts @@ -0,0 +1,39 @@ +import { chmodSync, lstatSync, mkdirSync } from "node:fs"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; + +export interface WorkspaceSecretPermissions { + prepareDirectory(path: string): void; + hardenFile(path: string): void; +} + +/** Only ENOENT means first-run absence; permission failures must not reset identity. */ +export function workspaceSecretFileExists(path: string): boolean { + try { lstatSync(path); return true; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export const workspaceSecretPermissions: WorkspaceSecretPermissions = { + prepareDirectory(path) { + assertNotRealHomeUnderTest(path); + mkdirSync(path, { recursive: true, mode: 0o700 }); + const metadata = lstatSync(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace secret directory must be a real directory"); + } + if (process.platform === "win32") hardenSecretDir(path, { required: true }); + else chmodSync(path, 0o700); + }, + hardenFile(path) { + assertNotRealHomeUnderTest(path); + const metadata = lstatSync(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) { + throw new Error("remote workspace secret must be a private regular file"); + } + if (process.platform === "win32") hardenSecretPath(path, { required: true }); + else chmodSync(path, 0o600); + }, +}; diff --git a/src/remote-control/workspace-sessions.ts b/src/remote-control/workspace-sessions.ts new file mode 100644 index 0000000000..775756200e --- /dev/null +++ b/src/remote-control/workspace-sessions.ts @@ -0,0 +1,730 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { RemoteWorkspaceCoordinator, type RemoteWorkspaceTransport } from "./workspace-coordinator"; +import type { RemoteWorkspaceHub } from "./workspace-hub"; +import { isRemoteWorkspaceAgentProfile, type RemoteWorkspaceAgentProfile } from "./workspace-agent-protocol"; +import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor"; +import { runRemoteWorkspaceCleanupSteps } from "./workspace-process"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; +import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol"; +import { + parseRemoteWorkspaceCapabilities, + remoteWorkspaceToolsForCapabilities, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_SESSION_STATE_VERSION = 1 as const; + +export type RemoteWorkspaceSessionStatus = + | "starting" + | "ready" + | "running" + | "waiting_for_executor" + | "failed" + | "stopped"; + +export type RemoteWorkspaceAccessMode = "read-only" | "workspace"; + +export interface RemoteWorkspaceSessionEvent { + sequence: number; + at: string; + type: "status" | "assistant" | "tool" | "error"; + text: string; +} + +export interface RemoteWorkspaceSessionSummary { + id: string; + profile: RemoteWorkspaceAgentProfile; + accessMode: RemoteWorkspaceAccessMode; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; + threadId: string | null; + /** True only after the runtime has created durable history that can be resumed. */ + resumable: boolean; + status: RemoteWorkspaceSessionStatus; + createdAt: string; + updatedAt: string; + events: RemoteWorkspaceSessionEvent[]; +} + +export interface RemoteWorkspaceRuntimeHandle { + threadId: string; + canResume?(): boolean; + prompt(text: string): Promise; + stop(): Promise; +} + +export interface RemoteWorkspaceRuntimeFactory { + profile: RemoteWorkspaceAgentProfile; + available(): Promise<{ available: boolean; version?: string; reason?: string }>; + start(options: { + sessionId: string; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; + resumeThreadId?: string; + coordinator: RemoteWorkspaceCoordinator; + emit(type: RemoteWorkspaceSessionEvent["type"], text: string): void; + }): Promise; +} + +export interface RemoteWorkspaceSessionState { + version: typeof REMOTE_WORKSPACE_SESSION_STATE_VERSION; + sessions: RemoteWorkspaceSessionSummary[]; +} + +export interface RemoteWorkspaceSessionStateStore { + load(): RemoteWorkspaceSessionState | null; + save(state: RemoteWorkspaceSessionState): void; +} + +interface LiveSession extends RemoteWorkspaceSessionSummary { + handle: RemoteWorkspaceRuntimeHandle | null; + unregister: (() => void) | null; + closeTransport: (() => Promise) | null; + operation: Promise; + stopOperation: Promise | null; + remoteTransport: SwitchableRemoteWorkspaceTransport | null; + turnActive: boolean; +} + +const MAX_EVENTS_PER_SESSION = 100; +const MAX_EVENT_TEXT_BYTES = 8 * 1024; +const MAX_PROMPT_BYTES = 256 * 1024; +const MAX_LIVE_SESSIONS = 8; +const MAX_RETAINED_SESSIONS = 64; +const MAX_LIST_EVENTS_PER_SESSION = 20; +const MAX_PERSISTED_EVENTS_PER_SESSION = 40; +const MAX_PERSISTED_EVENT_TEXT_BYTES = 4 * 1024; +const MAX_SESSION_STATE_BYTES = 16 * 1024 * 1024; +const AVAILABILITY_CACHE_MS = 30_000; +type RuntimeAvailability = Record; + +class SwitchableRemoteWorkspaceTransport implements RemoteWorkspaceTransport { + constructor(private current: RemoteWorkspaceTransport) {} + + replace(next: RemoteWorkspaceTransport): void { + this.current = next; + } + + isOnline(deviceId: string): boolean { + return this.current.isOnline(deviceId); + } + + invoke(request: RemoteWorkspaceExecutionRequest): Promise { + return this.current.invoke(request); + } +} + +function boundedPrompt(value: unknown): string { + if (typeof value !== "string" || value.trim().length < 1 || Buffer.byteLength(value, "utf8") > MAX_PROMPT_BYTES) { + throw new Error("remote workspace prompt must contain 1 to 262144 UTF-8 bytes"); + } + return value; +} + +function boundedEventText(value: string): string { + if (Buffer.byteLength(value, "utf8") <= MAX_EVENT_TEXT_BYTES) return value; + const marker = "\n[truncated]"; + return `${truncateRemoteWorkspaceUtf8(value, MAX_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"))}${marker}`; +} + +function boundedPersistedEventText(value: string): string { + if (Buffer.byteLength(value, "utf8") <= MAX_PERSISTED_EVENT_TEXT_BYTES) return value; + const marker = "\n[truncated for restart snapshot]"; + const maximum = MAX_PERSISTED_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"); + return `${truncateRemoteWorkspaceUtf8(value, maximum)}${marker}`; +} + +function boundedString(value: unknown, label: string, maximum = 256): string { + if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function timestamp(value: unknown): string { + const result = boundedString(value, "timestamp", 64); + if (!Number.isFinite(Date.parse(result))) throw new Error("invalid remote workspace timestamp"); + return result; +} + +function parseStatus(value: unknown): RemoteWorkspaceSessionStatus { + if (value === "starting" || value === "ready" || value === "running" + || value === "waiting_for_executor" || value === "failed" || value === "stopped") return value; + throw new Error("invalid remote workspace session status"); +} + +function parseAccessMode(value: unknown): RemoteWorkspaceAccessMode { + if (value === undefined || value === "workspace") return "workspace"; + if (value === "read-only") return value; + throw new Error("invalid remote workspace access mode"); +} + +function parseEvent(value: unknown): RemoteWorkspaceSessionEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session event"); + const raw = value as Record; + if (typeof raw.sequence !== "number" || !Number.isSafeInteger(raw.sequence) || raw.sequence < 1) { + throw new Error("invalid remote workspace event sequence"); + } + if (raw.type !== "status" && raw.type !== "assistant" && raw.type !== "tool" && raw.type !== "error") { + throw new Error("invalid remote workspace event type"); + } + return { + sequence: raw.sequence, + at: timestamp(raw.at), + type: raw.type, + text: boundedString(raw.text, "event text", MAX_EVENT_TEXT_BYTES), + }; +} + +function parseSession(value: unknown): RemoteWorkspaceSessionSummary { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session"); + const raw = value as Record; + if (!isRemoteWorkspaceAgentProfile(raw.profile)) throw new Error("invalid remote workspace session profile"); + const accessMode = parseAccessMode(raw.accessMode); + const capabilities = parseRemoteWorkspaceCapabilities(raw.capabilities); + if (accessMode === "read-only" + && (capabilities.length !== 1 || capabilities[0] !== "workspace.read")) { + throw new Error("read-only remote workspace state contains write capabilities"); + } + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + if (!Array.isArray(raw.events) || raw.events.length > MAX_EVENTS_PER_SESSION) { + throw new Error("invalid remote workspace session events"); + } + if (raw.threadId !== null && typeof raw.threadId !== "string") throw new Error("invalid remote workspace thread ID"); + const resumable = raw.resumable === undefined + ? raw.threadId !== null + : raw.resumable === true; + if (raw.resumable !== undefined && typeof raw.resumable !== "boolean") { + throw new Error("invalid remote workspace resumable state"); + } + if (resumable && raw.threadId === null) throw new Error("resumable remote workspace session has no thread ID"); + return { + id: boundedString(raw.id, "session ID"), + profile: raw.profile, + accessMode, + deviceId: boundedString(raw.deviceId, "device ID"), + deviceName: boundedString(raw.deviceName, "device name", 80), + rootId: boundedString(raw.rootId, "root ID"), + rootLabel: boundedString(raw.rootLabel, "root label", 80), + capabilities, + tools, + threadId: raw.threadId === null ? null : boundedString(raw.threadId, "thread ID"), + resumable, + status: parseStatus(raw.status), + createdAt: timestamp(raw.createdAt), + updatedAt: timestamp(raw.updatedAt), + events: raw.events.map(parseEvent), + }; +} + +export function parseRemoteWorkspaceSessionState(value: unknown): RemoteWorkspaceSessionState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_SESSION_STATE_VERSION || !Array.isArray(raw.sessions)) { + throw new Error("unsupported remote workspace session state"); + } + if (raw.sessions.length > MAX_RETAINED_SESSIONS) throw new Error("remote workspace retained session limit exceeded"); + const ids = new Set(); + const sessions = raw.sessions.map(item => { + const session = parseSession(item); + if (ids.has(session.id)) throw new Error("duplicate remote workspace session ID"); + ids.add(session.id); + return session; + }); + return { version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions }; +} + +export class RemoteWorkspaceSessionFileStore implements RemoteWorkspaceSessionStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-sessions.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceSessionState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_SESSION_STATE_BYTES) { + throw new Error("remote workspace session state is too large"); + } + return parseRemoteWorkspaceSessionState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceSessionState): void { + const parsed = parseRemoteWorkspaceSessionState(state); + const body = `${JSON.stringify(parsed, null, 2)}\n`; + if (Buffer.byteLength(body, "utf8") > MAX_SESSION_STATE_BYTES) { + throw new Error("remote workspace session state is too large"); + } + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, body); + } +} + +export class RemoteWorkspaceSessionService { + private readonly sessions = new Map(); + private readonly runtimes = new Map(); + private sequence = 0; + private availabilityCache: { at: number; value: RuntimeAvailability } | null = null; + private availabilityFlight: Promise | null = null; + + constructor( + private readonly hub: RemoteWorkspaceHub, + factories: readonly RemoteWorkspaceRuntimeFactory[], + private readonly now: () => number = Date.now, + private readonly store?: RemoteWorkspaceSessionStateStore, + ) { + for (const factory of factories) { + if (this.runtimes.has(factory.profile)) throw new Error("duplicate remote workspace runtime profile"); + this.runtimes.set(factory.profile, factory); + } + for (const summary of this.store?.load()?.sessions ?? []) { + const restoredStatus = summary.status === "stopped" + ? "stopped" + : summary.threadId && summary.resumable + ? "waiting_for_executor" + : "failed"; + this.sessions.set(summary.id, { + ...summary, + status: restoredStatus, + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }); + for (const event of summary.events) this.sequence = Math.max(this.sequence, event.sequence); + } + } + + async availability(): Promise { + if (this.availabilityCache && this.now() - this.availabilityCache.at < AVAILABILITY_CACHE_MS) { + return structuredClone(this.availabilityCache.value); + } + if (this.availabilityFlight) return structuredClone(await this.availabilityFlight); + this.availabilityFlight = (async () => { + const probe = async (profile: RemoteWorkspaceAgentProfile) => { + const factory = this.runtimes.get(profile); + if (!factory) return { available: false, reason: "runtime adapter is not installed" }; + try { return await factory.available(); } + catch { return { available: false, reason: "runtime availability probe failed" }; } + }; + const [codex, claude, pi] = await Promise.all([ + probe("codex"), + probe("claude"), + probe("pi"), + ]); + const value: RuntimeAvailability = { codex, claude, pi }; + this.availabilityCache = { at: this.now(), value }; + return value; + })(); + try { return structuredClone(await this.availabilityFlight); } + finally { this.availabilityFlight = null; } + } + + list(): RemoteWorkspaceSessionSummary[] { + this.refreshOfflineStates(); + return [...this.sessions.values()].map(session => this.publicSession(session, MAX_LIST_EVENTS_PER_SESSION)); + } + + get(sessionId: string): RemoteWorkspaceSessionSummary | null { + this.refreshOfflineStates(); + const session = this.sessions.get(sessionId); + return session ? this.publicSession(session) : null; + } + + async create(input: { + profile: RemoteWorkspaceAgentProfile; + deviceId: string; + rootId: string; + accessMode?: RemoteWorkspaceAccessMode; + }): Promise { + this.pruneRetainedSessions(); + const liveCount = [...this.sessions.values()].filter(session => session.handle !== null).length; + if (liveCount >= MAX_LIVE_SESSIONS) throw new Error("remote workspace active session limit reached"); + const deviceLiveCount = [...this.sessions.values()].filter(session => ( + session.deviceId === input.deviceId && session.handle !== null + )).length; + if (deviceLiveCount >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + const factory = this.runtimes.get(input.profile); + if (!factory) throw new Error(`remote workspace ${input.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${input.profile} runtime is unavailable`); + const device = this.hub.listDevices().find(candidate => candidate.id === input.deviceId); + if (!device) throw new Error("remote workspace device not found"); + const root = device.roots.find(candidate => candidate.id === input.rootId); + if (!root) throw new Error("remote workspace root not found on the selected device"); + const connection = this.hub.connection(device.id); + if (!connection) throw new Error("remote workspace executor is offline"); + const id = randomUUID(); + const accessMode = parseAccessMode(input.accessMode ?? "read-only"); + const deviceCapabilities = parseRemoteWorkspaceCapabilities(device.capabilities); + const capabilities = accessMode === "read-only" + ? parseRemoteWorkspaceCapabilities(["workspace.read"]) + : deviceCapabilities; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + const connectionCapabilities = connection.capabilities(); + if (capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capability advertisement is stale; refresh and try again"); + } + const timestamp = new Date(this.now()).toISOString(); + const session: LiveSession = { + id, + profile: input.profile, + accessMode, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + threadId: null, + resumable: false, + status: "starting", + createdAt: timestamp, + updatedAt: timestamp, + events: [], + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }; + this.sessions.set(id, session); + this.emit(session, "status", `Starting ${input.profile} on ${device.name}/${root.label}`); + try { + this.persist(); + } catch (error) { + this.sessions.delete(id); + throw error; + } + try { + session.closeTransport = () => connection.closeSession(id); + const transport = await connection.openSession({ sessionId: id, rootId: root.id, profile: input.profile, capabilities }); + if (session.stopOperation) { + await session.closeTransport().catch(() => {}); + session.closeTransport = null; + throw new Error("remote workspace session was stopped while starting"); + } + const remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.remoteTransport = remoteTransport; + const coordinator = new RemoteWorkspaceCoordinator(remoteTransport); + const handle = await factory.start({ + sessionId: id, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + if (session.stopOperation) { + await handle.stop().catch(() => {}); + throw new Error("remote workspace session was stopped while starting"); + } + session.threadId = handle.threadId; + session.resumable = handle.canResume?.() ?? true; + session.handle = handle; + session.unregister = coordinator.register({ + sessionId: id, + threadId: handle.threadId, + executorDeviceId: device.id, + executorName: device.name, + rootId: root.id, + capabilities, + tools, + }); + this.status(session, "ready", `${input.profile} is ready on ${device.name}/${root.label}`); + return this.publicSession(session); + } catch (error) { + let reported = error; + if (session.status !== "stopped") { + try { + this.status(session, "failed", error instanceof Error ? error.message : "remote workspace session failed to start"); + } catch (persistenceError) { + reported = persistenceError; + } + } + session.unregister?.(); + session.unregister = null; + await session.handle?.stop().catch(() => {}); + session.handle = null; + await session.closeTransport?.().catch(() => {}); + session.closeTransport = null; + session.remoteTransport = null; + throw reported; + } + } + + async prompt(sessionId: string, value: unknown): Promise { + const prompt = boundedPrompt(value); + const session = this.sessions.get(sessionId); + if (!session || session.status === "stopped") throw new Error("remote workspace session is not ready"); + if (!session.handle && (!session.threadId || !session.resumable)) { + throw new Error("remote workspace session cannot be resumed"); + } + if (session.turnActive) throw new Error("remote workspace session already has an active turn"); + if (session.stopOperation) throw new Error("remote workspace session is stopping"); + session.turnActive = true; + const run = async () => { + try { + await this.ensureRemoteTransport(session); + await this.ensureRuntime(session); + if (session.stopOperation) throw new Error("remote workspace session is stopping"); + this.status(session, "running", "Turn started"); + await session.handle!.prompt(prompt); + session.resumable = session.handle!.canResume?.() ?? true; + if (!this.hub.connection(session.deviceId) + || !session.remoteTransport?.isOnline(session.deviceId)) { + this.status(session, "waiting_for_executor", "Turn completed; reconnect the remote executor before continuing."); + } else { + this.status(session, "ready", "Turn completed"); + } + } catch (error) { + const message = error instanceof Error ? error.message : "remote workspace turn failed"; + this.status(session, this.hub.connection(session.deviceId) ? "failed" : "waiting_for_executor", message); + throw error; + } finally { + session.turnActive = false; + } + }; + session.operation = run(); + await session.operation; + return this.publicSession(session); + } + + async stop(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + if (session.stopOperation) return session.stopOperation; + session.stopOperation = (async () => { + const handle = session.handle; + const activeOperation = session.operation; + // Cancellation has to run before waiting for the active turn. Waiting first makes + // Stop unable to interrupt a model request or remote command that never completes. + try { + await runRemoteWorkspaceCleanupSteps([ + async () => { if (handle) await handle.stop(); }, + () => activeOperation.catch(() => {}), + () => { session.unregister?.(); session.unregister = null; }, + async () => { if (session.closeTransport) await session.closeTransport(); }, + () => { + session.closeTransport = null; + session.handle = null; + session.remoteTransport = null; + }, + ]); + } catch (error) { + this.status(session, "failed", "Session cleanup failed; one or more owned resources did not close."); + throw error; + } + this.status(session, "stopped", "Session stopped"); + return true; + })(); + return session.stopOperation; + } + + async stopAll(): Promise { + const active = [...this.sessions.values()].filter(session => session.status !== "stopped"); + await Promise.all(active.map(session => this.stop(session.id).then(() => undefined))); + this.persist(); + } + + async shutdown(): Promise { + const active = [...this.sessions.values()].filter(session => session.status !== "stopped"); + await Promise.all(active.map(async session => { + if (session.stopOperation) { + await session.stopOperation; + return; + } + session.stopOperation = (async () => { + const handle = session.handle; + const activeOperation = session.operation; + try { + await runRemoteWorkspaceCleanupSteps([ + async () => { if (handle) await handle.stop(); }, + () => activeOperation.catch(() => {}), + () => { session.unregister?.(); session.unregister = null; }, + async () => { if (session.closeTransport) await session.closeTransport(); }, + () => { + session.closeTransport = null; + session.handle = null; + session.remoteTransport = null; + }, + ]); + } catch (error) { + this.status(session, "failed", "Hub shutdown could not close every Remote Workspace resource."); + throw error; + } + this.status( + session, + session.threadId && session.resumable ? "waiting_for_executor" : "failed", + session.threadId && session.resumable + ? "Hub stopped; reconnect the executor to resume this session." + : "Hub stopped before the model session was created.", + ); + return true; + })(); + await session.stopOperation; + })); + this.persist(); + } + + private status(session: LiveSession, status: RemoteWorkspaceSessionStatus, text: string): void { + session.status = status; + this.emit(session, status === "failed" ? "error" : "status", text); + this.persist(); + } + + private emit(session: LiveSession, type: RemoteWorkspaceSessionEvent["type"], text: string): void { + const at = new Date(this.now()).toISOString(); + session.updatedAt = at; + session.events.push({ sequence: ++this.sequence, at, type, text: boundedEventText(text) }); + if (session.events.length > MAX_EVENTS_PER_SESSION) { + session.events.splice(0, session.events.length - MAX_EVENTS_PER_SESSION); + } + } + + private publicSession(session: LiveSession, eventLimit = MAX_EVENTS_PER_SESSION): RemoteWorkspaceSessionSummary { + const { + handle: _handle, + unregister: _unregister, + closeTransport: _close, + operation: _operation, + stopOperation: _stopOperation, + remoteTransport: _remoteTransport, + turnActive: _turnActive, + ...publicState + } = session; + return structuredClone({ ...publicState, events: publicState.events.slice(-eventLimit) }); + } + + private async ensureRemoteTransport(session: LiveSession): Promise { + if (session.remoteTransport?.isOnline(session.deviceId)) return; + const connection = this.hub.connection(session.deviceId); + if (!connection) { + this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled."); + throw new Error("remote workspace executor is offline"); + } + const connectionCapabilities = connection.capabilities(); + if (session.capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capabilities changed; start a new session for this computer"); + } + this.status(session, "starting", `Reconnecting ${session.deviceName}/${session.rootLabel}`); + const transport = await connection.openSession({ + sessionId: session.id, + rootId: session.rootId, + profile: session.profile, + capabilities: session.capabilities, + }); + if (session.stopOperation) { + await connection.closeSession(session.id).catch(() => {}); + throw new Error("remote workspace session is stopping"); + } + await session.closeTransport?.().catch(() => {}); + if (session.remoteTransport) session.remoteTransport.replace(transport); + else session.remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.closeTransport = () => connection.closeSession(session.id); + this.status(session, "ready", `${session.profile} reconnected to ${session.deviceName}/${session.rootLabel}`); + } + + private async ensureRuntime(session: LiveSession): Promise { + if (session.handle) return; + if (!session.threadId || !session.resumable || !session.remoteTransport) { + throw new Error("remote workspace session cannot be resumed"); + } + const factory = this.runtimes.get(session.profile); + if (!factory) throw new Error(`remote workspace ${session.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${session.profile} runtime is unavailable`); + const coordinator = new RemoteWorkspaceCoordinator(session.remoteTransport); + const handle = await factory.start({ + sessionId: session.id, + deviceId: session.deviceId, + deviceName: session.deviceName, + rootId: session.rootId, + rootLabel: session.rootLabel, + capabilities: [...session.capabilities], + tools: [...session.tools], + resumeThreadId: session.threadId, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + try { + session.unregister = coordinator.register({ + sessionId: session.id, + threadId: handle.threadId, + executorDeviceId: session.deviceId, + executorName: session.deviceName, + rootId: session.rootId, + capabilities: [...session.capabilities], + tools: [...session.tools], + }); + } catch (error) { + await handle.stop().catch(() => {}); + throw error; + } + session.threadId = handle.threadId; + session.handle = handle; + this.status(session, "ready", `${session.profile} resumed on ${session.deviceName}/${session.rootLabel}`); + } + + private refreshOfflineStates(): void { + for (const session of this.sessions.values()) { + if (session.status !== "ready" || this.hub.connection(session.deviceId)) continue; + this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled."); + } + } + + private pruneRetainedSessions(): void { + if (this.sessions.size < MAX_RETAINED_SESSIONS) return; + for (const [id, session] of this.sessions) { + if (session.status !== "stopped" && !(session.status === "failed" && session.handle === null)) continue; + this.sessions.delete(id); + if (this.sessions.size < MAX_RETAINED_SESSIONS) return; + } + if (this.sessions.size >= MAX_RETAINED_SESSIONS) { + throw new Error("remote workspace retained session limit reached; stop an active session first"); + } + } + + private persist(): void { + if (!this.store) return; + const sessions = [...this.sessions.values()].map(session => { + const summary = this.publicSession(session); + return { + ...summary, + events: summary.events.slice(-MAX_PERSISTED_EVENTS_PER_SESSION).map(event => ({ + ...event, + text: boundedPersistedEventText(event.text), + })), + }; + }); + this.store.save({ version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions }); + } +} diff --git a/src/remote-control/workspace-tool-bridge.ts b/src/remote-control/workspace-tool-bridge.ts new file mode 100644 index 0000000000..7a3b6306a3 --- /dev/null +++ b/src/remote-control/workspace-tool-bridge.ts @@ -0,0 +1,192 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import type { RemoteWorkspaceCoordinator } from "./workspace-coordinator"; +import { + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + isRemoteWorkspaceToolName, + type RemoteWorkspaceToolName, +} from "./workspace-tools"; + +const MAX_BRIDGE_BODY_BYTES = 512 * 1024; +const MAX_BRIDGE_ACTIVE_REQUESTS = 8; +function json(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "cache-control": "no-store" } }); +} + +function errorText(value: unknown): string { + return (value instanceof Error ? value.message : "Remote Workspace tool failed") + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +async function readBoundedJson(req: Request): Promise { + if (!req.body) throw new Error("invalid JSON"); + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_BRIDGE_BODY_BYTES) { + await reader.cancel("request too large").catch(() => {}); + throw new Error("request too large"); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); +} + +export interface RemoteWorkspaceToolBridge { + url: string; + token: string; + stop(): Promise; +} + +/** + * Loopback-only bridge used by Hub-owned CLIs whose extension boundary is HTTP. + * The random bearer is passed only to the child process. The model sees tool schemas, + * never this endpoint or token, and every invocation still goes through the E2EE coordinator. + */ +export function startRemoteWorkspaceToolBridge(options: { + coordinator: RemoteWorkspaceCoordinator; + threadId: string | (() => string); + tools: readonly RemoteWorkspaceToolName[]; + onTool?: (tool: RemoteWorkspaceToolName) => void; +}): RemoteWorkspaceToolBridge { + const token = randomBytes(32).toString("base64url"); + const toolNames = new Set(options.tools); + const definitions = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => toolNames.has(tool.name)); + if (definitions.length < 1) throw new Error("Remote Workspace bridge needs at least one tool"); + const invoke = async (tool: unknown, args: unknown): Promise<{ success: boolean; text: string }> => { + if (!isRemoteWorkspaceToolName(tool) || !toolNames.has(tool)) { + return { success: false, text: JSON.stringify({ ok: false, error: "unknown Remote Workspace tool" }) }; + } + options.onTool?.(tool); + const threadId = typeof options.threadId === "function" ? options.threadId() : options.threadId; + if (!threadId) return { success: false, text: JSON.stringify({ ok: false, error: "remote workspace thread is not ready" }) }; + const result = await options.coordinator.handle({ + method: "item/tool/call", + id: randomUUID(), + params: { + threadId, + turnId: randomUUID(), + callId: randomUUID(), + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool, + arguments: args, + }, + }); + return { success: result.result.success, text: result.result.contentItems[0]!.text }; + }; + let activeRequests = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (req.headers.get("origin")) return json({ error: "browser origins are not allowed" }, 403); + if (req.headers.get("authorization") !== `Bearer ${token}`) return json({ error: "unauthorized" }, 401); + if (req.method !== "POST" || (url.pathname !== "/invoke" && url.pathname !== "/mcp")) { + return json({ error: "not found" }, 404); + } + if (activeRequests >= MAX_BRIDGE_ACTIVE_REQUESTS) return json({ error: "Remote Workspace bridge is busy" }, 429); + activeRequests += 1; + try { + const length = Number(req.headers.get("content-length") ?? "0"); + if (!Number.isFinite(length) || length > MAX_BRIDGE_BODY_BYTES) return json({ error: "request too large" }, 413); + let parsed: unknown; + try { parsed = await readBoundedJson(req); } + catch (error) { + return json({ error: error instanceof Error && error.message === "request too large" ? error.message : "invalid JSON" }, + error instanceof Error && error.message === "request too large" ? 413 : 400); + } + const body = record(parsed); + if (!body) return json({ error: "invalid request" }, 400); + + if (url.pathname === "/invoke") { + try { + return json(await invoke(body.tool, body.arguments)); + } catch (error) { + return json({ success: false, text: JSON.stringify({ ok: false, error: errorText(error) }) }, 502); + } + } + + const id = body.id; + const method = body.method; + const params = record(body.params) ?? {}; + if (typeof method !== "string") return json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32_600, message: "invalid MCP request" } }); + if (method === "notifications/initialized") return new Response(null, { status: 202 }); + if (method === "initialize") { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { + protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2025-06-18", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "opencodex-remote-workspace", version: "1" }, + }, + }); + } + if (method === "ping") return json({ jsonrpc: "2.0", id: id ?? null, result: {} }); + if (method === "tools/list") { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { + tools: definitions.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }, + }); + } + if (method === "tools/call") { + try { + const called = await invoke(params.name, params.arguments); + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { content: [{ type: "text", text: called.text }], isError: !called.success }, + }); + } catch (error) { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { content: [{ type: "text", text: errorText(error) }], isError: true }, + }); + } + } + return json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32_601, message: "MCP method not found" } }); + } finally { + activeRequests -= 1; + } + }, + }); + let stopping: Promise | null = null; + return { + url: new URL("/", server.url).toString().replace(/\/$/, ""), + token, + stop() { + stopping ??= server.stop(true); + return stopping; + }, + }; +} diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..643eb77ee6 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,5 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +The unregistered executor CLI module stores Remote Workspace state separately from client configuration; see [Remote Workspace](../remote-workspace.md). diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index ae71389ad7..9c9f2bd786 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -168,3 +168,5 @@ pin one legacy root owner before changing it. Sibling stores remain independent. precede coordinated writes under one scoped flight, and actual file state/refusals remain separate. Restore reconciles target intent from validated snapshot ownership without changing sibling policy. Profile journal views retain source-store provenance for older legacy entries. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..dfcb13f15e 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The unregistered executor CLI module stores Remote Workspace state separately from client configuration; see [Remote Workspace](remote-workspace.md). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..fb638ee220 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -511,3 +511,5 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..b408487d18 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..be1af80293 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 534c649471..cb18d70589 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -1,11 +1,17 @@ -# Remote Workspace protocol +# Remote Workspace -`src/remote-control/` is an inactive protocol library. Importing it registers no HTTP route, opens no connection and starts no process or timer. Existing Remote Hub provider routing remains in `src/remote/` and is a separate capability. +`src/remote-control/` owns Remote Workspace contracts, explicit executor construction and Hub session adapters. No module is registered with server startup in this layer. Existing Remote Hub provider routing remains in `src/remote/` and is a separate capability. -`src/remote-control/protocol.ts` owns versioned frame, identity and capability contracts. `src/remote-control/crypto.ts` uses Ed25519 signatures, P-256 ephemeral agreement and directional AES-GCM counters. `src/remote-control/workspace-agent-protocol.ts` bounds and parses control envelopes. `src/remote-control/workspace-tools.ts` describes the remote tool namespace and capability mapping. +`src/remote-control/protocol.ts` owns frame and identity contracts. `src/remote-control/crypto.ts` implements signed handshakes and directional encryption. `src/remote-control/workspace-agent-protocol.ts` parses bounded control messages; `src/remote-control/workspace-rpc-framing.ts` bounds reassembly allocation, count and expiry. Importing these modules starts no process or timer; incomplete reassembly owns expiry timers after an explicit call. -`src/remote-control/workspace-rpc-framing.ts` fragments logical messages and bounds reassembly size, count and expiry. Expiry timers exist only after explicit incomplete-fragment acceptance. `src/remote-control/workspace-utf8.ts` bounds text without splitting surrogate pairs. +`src/remote-control/workspace-agent-connection.ts` intersects presence with enrollment authority and negotiates explicit session grants. `src/remote-control/workspace-rpc.ts` snapshots session/device/root/capabilities and rejects mismatches before invoking the executor. The paired Hub is trusted to select an approved root over authenticated WSS; workspace control traffic is not an untrusted opaque relay protocol. -`src/remote-control/host.ts` accepts an explicitly supplied terminal factory. Authenticated application traffic can invoke that factory; no production factory is supplied here. `src/remote-control/relay.ts` forwards opaque envelopes after its caller authorizes the peer. Neither adapter is wired into server startup. +`src/remote-control/workspace-executor.ts` checks approved root identity, relative paths, file size and write preconditions. Its optional command runner lives in `src/remote-control/workspace-command-runner.ts`. Linux uses bubblewrap outside writable workspace roots and checks executable/parent permissions before invocation. The official Windows and macOS native helpers refuse commands; file tools remain independent of command availability. -The public exports in `src/remote-control/index.ts` expose only this foundation. Device enrollment, executor operations and UI activation are not part of this layer. Tests in `tests/clients/remote-control-prototype.test.ts`, `tests/clients/remote-workspace-rpc-framing.test.ts` and `tests/clients/remote-workspace-protocol.test.ts` cover the protocol contracts; they do not prove platform command confinement. +`src/remote-control/workspace-hub.ts`, `src/remote-control/workspace-device.ts` and `src/remote-control/workspace-sessions.ts` own separate persisted state. `src/remote-control/workspace-secret-store.ts` requires private permissions and rejects access failures rather than treating them as first-run absence. Publication reuses `src/config/atomic-write.ts`; workspace file publication uses the remote-workspace publisher in `src/lib/windows-atomic-replace.ts`. + +`src/remote-control/workspace-runtime.ts` is the lazy composition owner for Hub services. Codex, Claude and Pi adapters keep model processes on the Hub and expose selected remote tools. Their source configuration is not evidence of live CLI confinement. `src/cli/remote-workspace.ts` contains explicit executor pair/agent/status handling; it is not yet registered by this layer. + +The optional terminal prototype in `src/remote-control/host.ts` invokes only a caller-supplied factory after authenticated traffic. `src/remote-control/relay.ts` routes opaque prototype envelopes after caller authorization. Neither is a production terminal service. + +Regression coverage lives in `tests/clients/remote-workspace-session-binding.test.ts`, `tests/clients/remote-workspace-secret-store.test.ts` and the adjacent protocol, agent-wire, device, hub, sessions and command-runner tests. Real CLI and native confinement tests require their explicit environments; generic suite success does not certify those paths. Windows command support remains unavailable pending a verified lifecycle owner. diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..e71dad34f0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -188,3 +188,5 @@ not an authentication or entitlement decision. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..839cd4a82a 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,5 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..850b276cda 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,5 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/tests/clients/remote-workspace-agent-wire.test.ts b/tests/clients/remote-workspace-agent-wire.test.ts new file mode 100644 index 0000000000..0b4f2c9f95 --- /dev/null +++ b/tests/clients/remote-workspace-agent-wire.test.ts @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RemoteWorkspaceExecutor, + RemoteWorkspaceExecutorAgentConnection, + RemoteWorkspaceHubAgentConnection, + RemoteControlClientHandshake, + generateRemoteControlIdentityKeyPair, + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + serializeRemoteWorkspaceAgentMessage, + serializeRemoteWorkspaceHubMessage, + type RemoteWorkspaceControlSocket, + type RemoteWorkspaceCommandRunner, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture(commandRunner?: RemoteWorkspaceCommandRunner) { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-agent-wire-")); + roots.push(root); + const workspace = join(root, "computer-2"); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(workspace, "marker.txt"), "computer-2-only"); + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "workspace", path: workspace }], + commandRunner, + }); + let hub: RemoteWorkspaceHubAgentConnection; + let agent: RemoteWorkspaceExecutorAgentConnection; + const hubSocket: RemoteWorkspaceControlSocket = { + send(value) { void agent.receive(value); }, + close: () => agent.close(), + }; + const agentSocket: RemoteWorkspaceControlSocket = { + send: value => hub.receive(value), + close: () => hub.close(), + }; + hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + socket: hubSocket, + sessionOpenTimeoutMs: 1_000, + }); + agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId, + deviceIdentity, + hubPublicKey: hubIdentity.publicKey, + executor, + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + socket: agentSocket, + }); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + })); + return { hub, agent, workspace, deviceId }; +} + +describe("remote workspace agent wire", () => { + test("does not become online or accept session traffic before capability presence", async () => { + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + socket: { send: () => {}, close: () => {} }, + }); + expect(hub.isOnline()).toBe(false); + await expect(hub.openSession({ sessionId: randomUUID(), rootId: "workspace", profile: "codex", capabilities: hub.capabilities() })) + .rejects.toThrow("offline"); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + expect(hub.isOnline()).toBe(true); + expect(() => hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + }))).toThrow("duplicate presence"); + hub.close(); + }); + + test("cancels a session handshake immediately instead of waiting for its timeout", async () => { + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const sent: string[] = []; + const hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + socket: { send: value => { sent.push(value); }, close: () => {} }, + sessionOpenTimeoutMs: 30_000, + }); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + const sessionId = randomUUID(); + const opening = hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: hub.capabilities() }); + await hub.closeSession(sessionId, "cancelled by user"); + await expect(opening).rejects.toThrow("cancelled by user"); + expect(sent.map(message => parseRemoteWorkspaceHubMessage(message).type)) + .toEqual(["presence_ack", "session_open", "session_close"]); + hub.close(); + }); + + test("opens an authenticated encrypted session and executes on the OCX-only device", async () => { + const state = fixture(); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ + sessionId, + rootId: "workspace", + profile: "codex", capabilities: state.hub.capabilities() }); + const result = await transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "read_file", + arguments: { path: "marker.txt" }, + }); + expect(result).toMatchObject({ ok: true, value: { content: "computer-2-only" } }); + await state.hub.closeSession(sessionId); + expect(transport.isOnline(state.deviceId)).toBe(false); + }); + + test("discards an endpoint when sending session acceptance fails", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-agent-accept-failure-")); + roots.push(root); + const deviceId = randomUUID(); + const sessionId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "workspace", path: root }], + }); + const handshake = RemoteControlClientHandshake.create({ + sessionId, + deviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write"], + accountPrivateKey: hubIdentity.privateKey, + }); + const sent: string[] = []; + let failAcceptance = true; + const agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId, + deviceIdentity, + hubPublicKey: hubIdentity.publicKey, + executor, + capabilities: ["workspace.read", "workspace.write"], + socket: { + send(value) { + const message = parseRemoteWorkspaceAgentMessage(value); + if (message.type === "session_accept" && failAcceptance) { + failAcceptance = false; + throw new Error("socket send failed"); + } + sent.push(value); + }, + close() {}, + }, + }); + const open = serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: "workspace", + clientHello: handshake.hello, + }); + await agent.receive(open); + await agent.receive(open); + expect(sent.map(value => parseRemoteWorkspaceAgentMessage(value).type)) + .toEqual(["session_reject", "session_accept"]); + agent.close(); + }); + + test("fails pending and active work closed when the executor disconnects", async () => { + const state = fixture(); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "pi", capabilities: state.hub.capabilities() }); + state.hub.close("executor disconnected"); + expect(transport.isOnline(state.deviceId)).toBe(false); + await expect(transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "read_file", + arguments: { path: "marker.txt" }, + })).rejects.toThrow("offline"); + }); + + test("session close aborts an active command on the executor", async () => { + let started!: () => void; + const active = new Promise(resolve => { started = resolve; }); + let cancelled = false; + const state = fixture({ + async run(request) { + started(); + return await new Promise((_resolve, reject) => { + const abort = () => { + cancelled = true; + reject(new Error("cancelled")); + }; + request.signal?.addEventListener("abort", abort, { once: true }); + if (request.signal?.aborted) abort(); + }); + }, + }); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: state.hub.capabilities() }); + const invocation = transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["sleep", "60"] }, + }); + await active; + await state.hub.closeSession(sessionId); + await expect(invocation).rejects.toThrow("closed"); + await Bun.sleep(5); + expect(cancelled).toBe(true); + }); + + test("bounds concurrent Hub requests while serializing operations on one executor", async () => { + let started = 0; + const state = fixture({ + async run(request) { + started += 1; + return await new Promise((_resolve, reject) => { + const abort = () => reject(new Error("cancelled")); + request.signal?.addEventListener("abort", abort, { once: true }); + if (request.signal?.aborted) abort(); + }); + }, + }); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: state.hub.capabilities() }); + const pending = Array.from({ length: 8 }, () => transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["wait"] }, + }).catch(error => error)); + for (let count = 0; count < 100 && started < 1; count += 1) await Bun.sleep(1); + expect(started).toBe(1); + await expect(transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["overflow"] }, + })).rejects.toThrow("request limit"); + await state.hub.closeSession(sessionId); + await Promise.all(pending); + }); + + test("rejects malformed, oversized, and non-workspace control messages", () => { + expect(() => parseRemoteWorkspaceHubMessage("{}")) + .toThrow("unsupported remote workspace agent protocol"); + expect(() => parseRemoteWorkspaceAgentMessage(JSON.stringify({ + version: 1, + type: "heartbeat", + nonce: "ok", + extra: true, + }))).toThrow("fields"); + expect(() => parseRemoteWorkspaceAgentMessage("x".repeat(100 * 1024))) + .toThrow("length"); + }); +}); + +test("a read-only negotiated session rejects writes before touching its approved root", async () => { + const state = fixture(); + const sessionId = randomUUID(); + try { + const transport = await state.hub.openSession({ + sessionId, rootId: "workspace", profile: "codex", capabilities: ["workspace.read"], + }); + const result = await transport.invoke({ + requestId: randomUUID(), sessionId, executorDeviceId: state.deviceId, + rootId: "workspace", tool: "read_file", arguments: { path: "marker.txt" }, + }); + expect(result.ok).toBe(true); + await expect(transport.invoke({ + requestId: randomUUID(), sessionId, executorDeviceId: state.deviceId, + rootId: "workspace", tool: "write_file", arguments: { path: "new.txt", content: "denied", expectedSha256: null }, + })).rejects.toThrow(); + } finally { state.hub.close(); state.agent.close(); } +}); diff --git a/tests/clients/remote-workspace-app-server.integration.test.ts b/tests/clients/remote-workspace-app-server.integration.test.ts new file mode 100644 index 0000000000..54f7115452 --- /dev/null +++ b/tests/clients/remote-workspace-app-server.integration.test.ts @@ -0,0 +1,426 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + REMOTE_WORKSPACE_TOOL_NAMESPACE, + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, + RemoteControlClientHandshake, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, + acceptRemoteControlClientHello, + generateRemoteControlIdentityKeyPair, + remoteWorkspaceThreadStartParams, + startRemoteWorkspaceToolBridge, + type RemoteWorkspaceTransport, + type RemoteWorkspaceCommandRunner, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +interface JsonMessage { + id?: string | number; + method?: string; + params?: Record; + result?: Record; + error?: Record; +} + +interface CapturedResponsesRequest { + input?: Array>; + tools?: Array>; +} + +function sse(events: unknown[]): string { + return events.map(event => { + const type = (event as { type: string }).type; + return `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`; + }).join(""); +} + +function completed(id: string): unknown { + return { + type: "response.completed", + response: { + id, + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }; +} + +function responseCreated(id: string): unknown { + return { type: "response.created", response: { id } }; +} + +class JsonLinePeer { + private readonly reader: ReadableStreamDefaultReader; + private buffer = ""; + + constructor( + stdout: ReadableStream, + private readonly stdin: FileSink, + ) { + this.reader = stdout.getReader(); + } + + send(message: unknown): void { + this.stdin.write(`${JSON.stringify(message)}\n`); + this.stdin.flush(); + } + + async next(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + const newline = this.buffer.indexOf("\n"); + if (newline >= 0) { + const line = this.buffer.slice(0, newline).replace(/\r$/, ""); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + return JSON.parse(line) as JsonMessage; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("timed out waiting for Codex App Server JSON-RPC"); + const next = await Promise.race([ + this.reader.read(), + new Promise((_, reject) => setTimeout( + () => reject(new Error("timed out waiting for Codex App Server output")), + remaining, + )), + ]); + if (next.done) throw new Error("Codex App Server closed its output"); + this.buffer += new TextDecoder().decode(next.value, { stream: true }); + } + } + + async waitFor(predicate: (message: JsonMessage) => boolean): Promise { + for (let count = 0; count < 200; count += 1) { + const message = await this.next(); + if (predicate(message)) return message; + } + throw new Error("Codex App Server did not emit the expected message"); + } +} + +const codexBin = process.env.OCX_CODEX_BIN; +const appServerTest = codexBin ? test : test.skip; + +const localIntegrationCommandRunner: RemoteWorkspaceCommandRunner = { + async run(request) { + const child = Bun.spawn(request.command, { + cwd: request.cwd, + env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: request.cwd }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, request.timeoutMs); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (timedOut) throw new Error("local integration command timed out"); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > request.maxOutputBytes) { + throw new Error("local integration command output limit exceeded"); + } + return { stdout, stderr, exitCode }; + } finally { + clearTimeout(timer); + } + }, +}; + +appServerTest("real Codex App Server delegates a dynamic workspace tool to Computer 2", async () => { + if (!codexBin || !existsSync(codexBin)) throw new Error("OCX_CODEX_BIN must identify a real Codex executable"); + const root = mkdtempSync(join(tmpdir(), "ocx-remote-app-server-")); + const mainHome = join(root, "main-home"); + const mainCodexHome = join(root, "main-codex"); + const mainOcxHome = join(root, "main-ocx"); + const sandboxBin = join(root, "sandbox-bin"); + const coordinatorIsolation = join(root, "coordinator-isolation"); + const executorRoot = join(root, "computer-2-workspace"); + const hubSecret = join(root, "hub-secret.txt"); + for (const path of [mainHome, mainCodexHome, mainOcxHome, coordinatorIsolation, executorRoot, sandboxBin]) { + mkdirSync(path, { recursive: true }); + } + linkSync(codexBin, join(sandboxBin, "codex-linux-sandbox")); + writeFileSync(join(coordinatorIsolation, "integration-marker.txt"), "main-unchanged"); + writeFileSync(hubSecret, "HUB-SECRET-MUST-NOT-LEAK"); + + const requestBodies: unknown[] = []; + let responseIndex = 0; + const modelServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (request.method !== "POST" || !url.pathname.endsWith("/responses")) { + return Response.json({ error: "not_found" }, { status: 404 }); + } + const requestBody = await request.json() as CapturedResponsesRequest; + requestBodies.push(requestBody); + responseIndex += 1; + if (responseIndex === 1) { + const hasCodeMode = JSON.stringify(requestBody.input).includes('"name":"functions"') + && JSON.stringify(requestBody.input).includes('"name":"exec"'); + if (!hasCodeMode) { + return new Response(sse([ + responseCreated("resp-remote-no-tool"), + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-no-remote-tool", + content: [{ type: "output_text", text: "Remote tool unavailable" }], + }, + }, + completed("resp-remote-no-tool"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + return new Response(sse([ + responseCreated("resp-remote-1"), + { + type: "response.output_item.done", + item: { + type: "custom_tool_call", + call_id: "remote-exec-call", + namespace: "functions", + name: "exec", + input: [ + "const result = await tools.mcp__ocx_remote_workspace__exec({", + " command: ['/bin/sh', '-lc', \"printf 'computer-2' > integration-marker.txt; printf 'executor-cwd:'; pwd\"],", + " cwd: '.',", + " timeoutMs: 5000,", + "});", + "let localProbe;", + `try { localProbe = await tools.exec_command({ cmd: ${JSON.stringify(`cat -- ${JSON.stringify(hubSecret)}`)} }); }`, + "catch (error) { localProbe = String(error); }", + "text(JSON.stringify({ result, localProbe }));", + ].join("\n"), + }, + }, + completed("resp-remote-1"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + if (responseIndex === 2) { + return new Response(sse([ + responseCreated("resp-remote-2"), + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-remote-done", + content: [{ type: "output_text", text: "Remote workspace complete" }], + }, + }, + completed("resp-remote-2"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ error: "unexpected_request" }, { status: 500 }); + }, + }); + + const config = [ + 'model = "gpt-5.6-sol"', + 'model_provider = "ocx_remote_spike"', + 'approval_policy = "never"', + '', + '[model_providers.ocx_remote_spike]', + 'name = "OCX Remote Spike"', + `base_url = "${new URL("/v1", modelServer.url).toString().replace(/\/$/, "")}"`, + 'env_key = "OCX_REMOTE_SPIKE_API_KEY"', + 'wire_api = "responses"', + 'supports_websockets = false', + '', + ].join("\n"); + writeFileSync(join(mainCodexHome, "config.toml"), config, { mode: 0o600 }); + + const deviceId = randomUUID(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "selected-folder", path: executorRoot }], + commandRunner: localIntegrationCommandRunner, + }); + const accountIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const transportSessionId = randomUUID(); + const handshake = RemoteControlClientHandshake.create({ + sessionId: transportSessionId, + deviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + accountPrivateKey: accountIdentity.privateKey, + }); + const accepted = acceptRemoteControlClientHello(handshake.hello, { + expectedSessionId: transportSessionId, + expectedDeviceId: deviceId, + accountPublicKey: accountIdentity.publicKey, + devicePrivateKey: deviceIdentity.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write", "workspace.exec"], + }); + let encryptedTransport: EncryptedRemoteWorkspaceTransport; + let executorEndpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + encryptedTransport = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: deviceId, + cipher: handshake.complete(accepted.hello, deviceIdentity.publicKey), + sendCiphertext: value => executorEndpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + executorEndpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: deviceId, + sessionId: transportSessionId, + rootId: "selected-folder", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + cipher: accepted.cipher, + executor, + sendCiphertext: value => encryptedTransport.receiveCiphertext(value), + }); + const transport: RemoteWorkspaceTransport = encryptedTransport; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const threadRef = { id: "" }; + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: () => threadRef.id, + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const mcpTokenEnv = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; + const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; + + const appServer = Bun.spawn([ + codexBin, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(mcpTokenEnv)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=["list_directory","read_file","write_file","exec"]`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { + cwd: coordinatorIsolation, + env: { + PATH: `${sandboxBin}:${process.env.PATH ?? "/usr/bin:/bin"}`, + HOME: mainHome, + CODEX_HOME: mainCodexHome, + OPENCODEX_HOME: mainOcxHome, + OCX_REMOTE_SPIKE_API_KEY: "test-only-not-a-real-key", + [mcpTokenEnv]: bridge.token, + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const stderrPromise = new Response(appServer.stderr).text(); + const peer = new JsonLinePeer(appServer.stdout, appServer.stdin); + + try { + peer.send({ + method: "initialize", + id: 0, + params: { + clientInfo: { name: "ocx_remote_workspace_test", title: "OCX Remote Workspace Test", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }, + }); + const initialized = await peer.waitFor(message => message.id === 0); + expect(initialized.error).toBeUndefined(); + peer.send({ method: "initialized", params: {} }); + + peer.send({ + method: "thread/start", + id: 1, + params: { + ...remoteWorkspaceThreadStartParams({ + executorName: "Computer 2", + coordinatorIsolationPath: coordinatorIsolation, + tools: ["list_directory", "read_file", "write_file", "exec"], + mcp: { + url: `${bridge.url}/mcp`, + bearerTokenEnvVar: mcpTokenEnv, + hubRuntimeReadPaths: [dirname(realpathSync(codexBin)), sandboxBin], + }, + }), + model: "gpt-5.6-sol", + modelProvider: "ocx_remote_spike", + ephemeral: true, + }, + }); + const threadResponse = await peer.waitFor(message => message.id === 1); + expect(threadResponse.error).toBeUndefined(); + const thread = threadResponse.result?.thread as { id?: string } | undefined; + if (!thread?.id) throw new Error("Codex App Server did not return a thread ID"); + threadRef.id = thread.id; + coordinator.register({ + sessionId: transportSessionId, + threadId: thread.id, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "selected-folder", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + + peer.send({ + method: "turn/start", + id: 2, + params: { + threadId: thread.id, + input: [{ type: "text", text: "Create the marker in the selected remote workspace." }], + approvalPolicy: "never", + }, + }); + + let turnCompleted = false; + for (let count = 0; count < 200 && !turnCompleted; count += 1) { + const message = await peer.next(); + if (message.method === "item/tool/call" && message.id !== undefined) { + const response = await coordinator.handle({ + method: "item/tool/call", + id: message.id, + params: message.params, + }); + peer.send(response); + } + if (message.method === "turn/completed") turnCompleted = true; + if (message.id === 2 && message.error) throw new Error(`turn/start failed: ${JSON.stringify(message.error)}`); + } + + expect(turnCompleted).toBe(true); + expect(existsSync(join(executorRoot, "integration-marker.txt"))).toBe(true); + expect(readFileSync(join(executorRoot, "integration-marker.txt"), "utf8")).toBe("computer-2"); + expect(readFileSync(join(coordinatorIsolation, "integration-marker.txt"), "utf8")).toBe("main-unchanged"); + expect(requestBodies).toHaveLength(2); + expect(JSON.stringify(requestBodies[0])).toContain(REMOTE_WORKSPACE_TOOL_NAMESPACE); + // Current Codex consolidates MCP into the sandboxed functions.exec code-mode tool. + // Executing the nested remote helper above proves the registered MCP server is callable. + expect(JSON.stringify((requestBodies[0] as CapturedResponsesRequest).input)).toContain('"name":"functions"'); + const followUp = requestBodies[1] as CapturedResponsesRequest; + const toolOutput = followUp.input?.find(item => item.type === "custom_tool_call_output"); + expect(toolOutput).toBeDefined(); + const serializedToolOutput = JSON.stringify(toolOutput); + expect(serializedToolOutput).toContain("executor-cwd:"); + expect(serializedToolOutput).toContain(executorRoot); + expect(serializedToolOutput).not.toContain(coordinatorIsolation); + expect(serializedToolOutput).not.toContain("HUB-SECRET-MUST-NOT-LEAK"); + } finally { + encryptedTransport.close(); + appServer.kill(); + await appServer.exited; + await stderrPromise; + await modelServer.stop(true); + await bridge.stop(); + removeTreeWithRetry(root); + } +}, 30_000); diff --git a/tests/clients/remote-workspace-claude.integration.test.ts b/tests/clients/remote-workspace-claude.integration.test.ts new file mode 100644 index 0000000000..c29cda1e20 --- /dev/null +++ b/tests/clients/remote-workspace-claude.integration.test.ts @@ -0,0 +1,166 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClaudeRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function sse(events: Array<{ event: string; data: unknown }>): Response { + return new Response(events.map(item => `event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream", "cache-control": "no-store" }, + }); +} + +function messageStart(id: string): { event: string; data: unknown } { + return { + event: "message_start", + data: { + type: "message_start", + message: { + id, + type: "message", + role: "assistant", + model: "claude-test", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 8, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 1 }, + }, + }, + }; +} + +const claudePath = process.env.OCX_CLAUDE_BIN; +const claudeTest = claudePath ? test : test.skip; + +claudeTest("real Claude Code uses only the selected remote executor MCP tools", async () => { + if (!claudePath) return; + const root = mkdtempSync(join(tmpdir(), "ocx-remote-claude-real-")); + roots.push(root); + const workspace = join(root, "executor"); + const home = join(root, "home"); + mkdirSync(workspace); + mkdirSync(home); + writeFileSync(join(workspace, "marker.txt"), "only-on-computer-2"); + const requestBodies: Array> = []; + const model = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/count_tokens")) return Response.json({ input_tokens: 8 }); + if (!url.pathname.endsWith("/messages")) return Response.json({ error: { message: "not found" } }, { status: 404 }); + const body = await req.json() as Record; + requestBodies.push(body); + if (requestBodies.length === 1) { + return sse([ + messageStart("msg_remote_tool"), + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_remote_read", name: "mcp__ocx_remote_workspace__read_file", input: {} } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{\"path\":\"marker.txt\"}" } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 8 } } }, + { event: "message_stop", data: { type: "message_stop" } }, + ]); + } + return sse([ + messageStart("msg_remote_answer"), + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Read only-on-computer-2 from the executor." } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 12 } } }, + { event: "message_stop", data: { type: "message_stop" } }, + ]); + }, + }); + const deviceId = crypto.randomUUID(); + const executor = new RemoteWorkspaceExecutor({ deviceId, roots: [{ id: "root", path: workspace }] }); + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: candidate => candidate === deviceId, + invoke: request => executor.invoke(request), + }); + const events: string[] = []; + const factory = new ClaudeRemoteWorkspaceRuntimeFactory({ + command: [claudePath], + version: "real-smoke", + env: { + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), + ANTHROPIC_BASE_URL: model.url.toString().replace(/\/$/, ""), + ANTHROPIC_AUTH_TOKEN: "test-only-token", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }, + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId, + deviceName: "Computer 2", + rootId: "root", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + coordinator, + emit: (type, text) => events.push(`${type}:${text}`), + }); + const unregister = coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "root", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + }); + try { + await handle.prompt("Read marker.txt from the remote workspace."); + expect(events.some(event => event.includes("Read only-on-computer-2 from the executor."))).toBe(true); + expect(JSON.stringify(requestBodies.at(-1))).toContain("only-on-computer-2"); + expect(JSON.stringify(requestBodies)).not.toContain("remote_exec"); + const persistedThreadId = handle.threadId; + unregister(); + await handle.stop(); + const resumed = await factory.start({ + sessionId: "session-1", + deviceId, + deviceName: "Computer 2", + rootId: "root", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + resumeThreadId: persistedThreadId, + coordinator, + emit: (type, text) => events.push(`${type}:${text}`), + }); + const unregisterResumed = coordinator.register({ + sessionId: "session-1", + threadId: resumed.threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "root", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + }); + try { + await resumed.prompt("Continue the same remote session."); + expect(resumed.threadId).toBe(persistedThreadId); + expect(JSON.stringify(requestBodies.at(-1))).toContain("Continue the same remote session."); + } finally { + unregisterResumed(); + await resumed.stop(); + } + } finally { + unregister(); + await handle.stop(); + await model.stop(true); + } +}, 30_000); diff --git a/tests/clients/remote-workspace-cli-runtimes.test.ts b/tests/clients/remote-workspace-cli-runtimes.test.ts new file mode 100644 index 0000000000..33785b64e7 --- /dev/null +++ b/tests/clients/remote-workspace-cli-runtimes.test.ts @@ -0,0 +1,67 @@ +import { fixturePath } from "../helpers/repo-root"; +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + ClaudeRemoteWorkspaceRuntimeFactory, + PiRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + type RemoteWorkspaceSessionEvent, +} from "../../src/remote-control"; + +function coordinator(): RemoteWorkspaceCoordinator { + return new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true, value: null }; }, + }); +} + +test("Claude runtime keeps the CLI on the Hub and emits its answer", async () => { + const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; + const factory = new ClaudeRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, fixturePath("fake-claude-stream.ts")], + version: "test", + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator: coordinator(), + emit: (type, text) => events.push({ type, text }), + }); + try { + await handle.prompt("hello remote"); + expect(events).toEqual([{ type: "assistant", text: "Hub answer: hello remote" }]); + } finally { + await handle.stop(); + } +}); + +const piPath = process.env.OCX_PI_BIN; +const piTest = piPath ? test : test.skip; + +piTest("real Pi RPC starts with only the explicit Remote Workspace extension", async () => { + if (!piPath) return; + const factory = new PiRemoteWorkspaceRuntimeFactory({ command: [piPath], version: "test" }); + const startOptions = { + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator: coordinator(), + emit: () => {}, + } as const; + const handle = await factory.start(startOptions); + expect(handle.threadId).toMatch(/^[0-9a-f-]{36}$/); + const threadId = handle.threadId; + await handle.stop(); + const resumed = await factory.start({ ...startOptions, resumeThreadId: threadId }); + expect(resumed.threadId).toBe(threadId); + await resumed.stop(); +}); diff --git a/tests/clients/remote-workspace-cli.test.ts b/tests/clients/remote-workspace-cli.test.ts new file mode 100644 index 0000000000..2358d59133 --- /dev/null +++ b/tests/clients/remote-workspace-cli.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { Readable } from "node:stream"; +import { runRemoteWorkspaceCommand } from "../../src/cli/remote-workspace"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceStateStore, +} from "../../src/remote-control"; + +class MemoryStore implements RemoteWorkspaceDeviceStateStore { + constructor(public state: RemoteWorkspaceDeviceState | null = null) {} + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceDeviceState) { this.state = structuredClone(state); } +} + +function state(): RemoteWorkspaceDeviceState { + return { + version: 1, + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), + deviceName: "Computer 2", + devicePlatform: "linux-x64", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: generateRemoteControlIdentityKeyPair(), + hubPublicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project", path: "/work/project" }], + toolchainRoots: [], + }; +} + +describe("ocx remote-workspace", () => { + test("reads the one-time pairing code from stdin and never requires it in argv", async () => { + const store = new MemoryStore(); + const expected = state(); + let received: Record | null = null; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const code = await runRemoteWorkspaceCommand([ + "pair", + "https://hub.example.test", + "--root", "/work/project", + "--root", "/work/other", + "--executor-helper", "/opt/opencodex/remote-workspace-helper", + "--name", "Computer 2", + "--pairing-code-stdin", + "--json", + ], { + store, + stdinImpl: Readable.from(["ABCD-EFGH-JKLM\n"]), + pair: async options => { + received = options as unknown as Record; + return expected; + }, + }); + expect(code).toBe(0); + expect(received).toMatchObject({ + hubUrl: "https://hub.example.test", + pairingCode: "ABCD-EFGH-JKLM", + name: "Computer 2", + roots: [{ path: "/work/project" }, { path: "/work/other" }], + nativeHelperPath: "/opt/opencodex/remote-workspace-helper", + }); + expect(JSON.stringify(log.mock.calls)).not.toContain(expected.deviceToken); + expect(JSON.stringify(log.mock.calls)).not.toContain(expected.deviceIdentity.privateKey); + } finally { + log.mockRestore(); + } + }); + + test("status reports local executor identity without secret material", async () => { + const saved = state(); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runRemoteWorkspaceCommand(["status", "--json"], { store: new MemoryStore(saved) })).toBe(0); + const output = JSON.stringify(log.mock.calls); + expect(output).toContain("Computer 2"); + expect(output).toContain("/work/project"); + expect(output).not.toContain(saved.deviceToken); + expect(output).not.toContain(saved.deviceIdentity.privateKey); + } finally { + log.mockRestore(); + } + }); + + test("agent hands the paired state to the reconnecting runner", async () => { + const saved = state(); + const controller = new AbortController(); + let received: RemoteWorkspaceDeviceState | null = null; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const code = await runRemoteWorkspaceCommand(["agent"], { + store: new MemoryStore(saved), + signal: controller.signal, + runAgent: async options => { received = options.state; }, + }); + expect(code).toBe(0); + expect(received?.deviceId).toBe(saved.deviceId); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/tests/clients/remote-workspace-codex-runtime.test.ts b/tests/clients/remote-workspace-codex-runtime.test.ts new file mode 100644 index 0000000000..b9d259c2eb --- /dev/null +++ b/tests/clients/remote-workspace-codex-runtime.test.ts @@ -0,0 +1,120 @@ +import { repoPath } from "../helpers/repo-root"; +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + CodexRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + type RemoteWorkspaceSessionEvent, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; + +test("Codex Remote Workspace runtime owns the model process on the Hub", async () => { + const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; + const transport: RemoteWorkspaceTransport = { + isOnline: () => true, + async invoke() { return { ok: true, value: null }; }, + }; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + version: "0.146.0-test", + env: { + FAKE_CODEX_SCRIPT: JSON.stringify({ + turns: [{ + notifications: [{ + method: "item/completed", + params: { item: { id: "answer-1", type: "agentMessage", text: "Done from Computer 1" } }, + }], + }], + }), + }, + }); + + expect(await factory.available()).toEqual({ available: true, version: "0.146.0-test" }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator, + emit: (type, text) => events.push({ type, text }), + }); + const unregister = coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + try { + await handle.prompt("Inspect the remote project"); + expect(events).toContainEqual({ type: "assistant", text: "Done from Computer 1" }); + } finally { + unregister(); + await handle.stop(); + } +}); + +test("Codex Remote Workspace stop interrupts a held turn", async () => { + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { FAKE_CODEX_SCRIPT: JSON.stringify({ turns: [{ heldUntilInterrupt: true }] }) }, + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator, + emit: () => {}, + }); + coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const turn = handle.prompt("Hold this turn").then(() => "resolved", () => "rejected"); + await new Promise(resolvePromise => setTimeout(resolvePromise, 30)); + await handle.stop(); + expect(await turn).toBe("rejected"); +}); + +test("Codex Remote Workspace resumes the persisted App Server thread ID", async () => { + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + }); + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }); + const handle = await factory.start({ + sessionId: "session-resume", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + resumeThreadId: "thread-persisted", + coordinator, + emit: () => {}, + }); + expect(handle.threadId).toBe("thread-persisted"); + await handle.stop(); +}); diff --git a/tests/clients/remote-workspace-command-runner.test.ts b/tests/clients/remote-workspace-command-runner.test.ts new file mode 100644 index 0000000000..9595b2b4f3 --- /dev/null +++ b/tests/clients/remote-workspace-command-runner.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + RemoteWorkspaceExecutor, + createLinuxRemoteWorkspaceCommandRunner, + createNativeRemoteWorkspaceCommandRunner, + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandArgv, + linuxRemoteWorkspaceCommandRunnerAvailable, + nativeRemoteWorkspaceCommandRunnerAvailable, + pinRemoteWorkspaceNativeHelper, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-bwrap-")); + roots.push(root); + const workspace = join(root, "workspace"); + const outside = join(root, "outside-secret.txt"); + mkdirSync(join(workspace, "project"), { recursive: true }); + writeFileSync(outside, "must-not-be-visible"); + return { root, workspace, outside }; +} + +function fakeNativeHelper(root: string, response: Record, requestPath?: string) { + const path = join(root, "ocx-remote-helper-test"); + const encodedResponse = JSON.stringify(response).replaceAll("'", "'\\''"); + const requestCapture = requestPath + ? `input=$(cat); printf '%s' "$input" > '${requestPath.replaceAll("'", "'\\''")}'` + : "cat >/dev/null"; + writeFileSync(path, `#!/bin/sh\nset -eu\n${requestCapture}\nprintf '%s\\n' '${encodedResponse}'\n`, { mode: 0o700 }); + chmodSync(path, 0o700); + return pinRemoteWorkspaceNativeHelper(path); +} + +describe("remote workspace Linux command sandbox", () => { + test("rejects a sandbox executable inside a writable workspace before probing", () => { + const state = fixture(); + const path = join(state.workspace, "bwrap"); + writeFileSync(path, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + let probes = 0; + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: path, writableRoots: [state.workspace], + probe() { probes += 1; return true; }, + })).toBe(false); + expect(probes).toBe(0); + expect(() => linuxRemoteWorkspaceCommandArgv({ + command: ["true"], root: state.workspace, cwd: state.workspace, + timeoutMs: 1_000, maxOutputBytes: 4096, + }, { bubblewrapPath: path })).toThrow("outside every writable"); + }); + + test("Windows command capability stays unavailable even with a positive probe seam", () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { version: 1, ok: true, probe: true }); + let probes = 0; + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + platform: "win32", helper, + writableRoots: [state.workspace], probe() { probes += 1; return { version: 1, ok: true, probe: true }; }, + })).toBe(false); + expect(probes).toBe(0); + }); + + test("builds a minimal bubblewrap argv with one writable workspace", () => { + const state = fixture(); + const argv = linuxRemoteWorkspaceCommandArgv({ + command: ["/bin/sh", "-lc", "pwd"], + root: state.workspace, + cwd: join(state.workspace, "project"), + timeoutMs: 1_000, + maxOutputBytes: 4_096, + }, { bubblewrapPath: process.execPath }); + expect(argv[0]).toBe(process.execPath); + expect(argv).toContain("--unshare-net"); + expect(argv).toContain("--clearenv"); + expect(argv).toContain("--bind"); + expect(argv).toContain(state.workspace); + expect(argv).toContain("/workspace/project"); + expect(argv).not.toContain(state.outside); + }); + + test("runs inside the selected root and cannot see an adjacent host file", async () => { + if (!linuxRemoteWorkspaceCommandRunnerAvailable()) return; + const state = fixture(); + const deviceId = randomUUID(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "root", path: state.workspace }], + commandRunner: createLinuxRemoteWorkspaceCommandRunner(), + }); + const result = await executor.invoke({ + requestId: randomUUID(), + sessionId: randomUUID(), + executorDeviceId: deviceId, + rootId: "root", + tool: "exec", + arguments: { + command: [ + "/bin/sh", + "-lc", + `test ! -e ${JSON.stringify(state.outside)} && printf sandboxed > marker.txt && pwd`, + ], + cwd: "project", + timeoutMs: 5_000, + }, + }); + expect(result.ok).toBe(true); + expect(result.value).toMatchObject({ exitCode: 0, cwd: "project" }); + expect(JSON.stringify(result.value)).toContain("/workspace/project"); + expect(readFileSync(join(state.workspace, "project", "marker.txt"), "utf8")).toBe("sandboxed"); + }); + + test("keeps exec disabled where an equivalent platform sandbox is unavailable", () => { + expect(createPlatformRemoteWorkspaceCommandRunner({ platform: "win32" })).toBeUndefined(); + expect(createPlatformRemoteWorkspaceCommandRunner({ platform: "darwin" })).toBeUndefined(); + }); + + test("advertises native exec only after a digest-pinned confinement probe", () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { version: 1, ok: true, probe: true }); + let probeRequest: unknown; + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe(request) { + probeRequest = request; + return { version: 1, ok: true, probe: true }; + }, + })).toBe(true); + expect(probeRequest).toEqual({ version: 1, operation: "probe" }); + expect(createPlatformRemoteWorkspaceCommandRunner({ + platform: "win32", + native: { + helper, + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: false, error: "not confined" }), + }, + })).toBeUndefined(); + writeFileSync(helper.path, "replaced", { mode: 0o700 }); + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + })).toBe(false); + }); + + test("sends native command authority over bounded stdin and decodes one strict result", async () => { + const state = fixture(); + const requestPath = join(state.root, "request.json"); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 7, + stdoutBase64: Buffer.from("native stdout").toString("base64"), + stderrBase64: Buffer.from("native stderr").toString("base64"), + }, requestPath); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + const result = await runner.run({ + command: ["/usr/bin/printf", "hello world"], + root: state.workspace, + cwd: join(state.workspace, "project"), + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }); + expect(result).toEqual({ exitCode: 7, stdout: "native stdout", stderr: "native stderr" }); + const request = JSON.parse(readFileSync(requestPath, "utf8")); + expect(request).toEqual({ + version: 1, + operation: "run", + root: state.workspace, + cwd: join(state.workspace, "project"), + command: ["/usr/bin/printf", "hello world"], + toolchainRoots: [], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + networkAccess: false, + }); + expect(JSON.stringify(request)).not.toContain(process.env.OPENAI_API_KEY ?? "__no_api_key__"); + }); + + test("rejects widened or malformed native helper responses", async () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "@@not-base64@@", + stderrBase64: "", + }); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + await expect(runner.run({ + command: ["cmd.exe"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("invalid stdout"); + }); + + test("never advertises or invokes a native helper from inside a writable workspace", async () => { + const state = fixture(); + const helper = fakeNativeHelper(state.workspace, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "", + stderrBase64: "", + }); + expect(createPlatformRemoteWorkspaceCommandRunner({ + platform: "darwin", + native: { + helper, + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }, + })).toBeUndefined(); + + }); + + test("binds every native command to the runner's construction-time writable roots", async () => { + const state = fixture(); + const other = join(state.root, "other-workspace"); + mkdirSync(other); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "", + stderrBase64: "", + }); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + await expect(runner.run({ + command: ["/usr/bin/true"], + root: other, + cwd: other, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("outside the native runner grant"); + }); + + test("revalidates approved toolchain roots and rejects a later symlink substitution", () => { + const state = fixture(); + const realToolchain = join(state.root, "real-toolchain"); + const substituted = join(state.root, "toolchain"); + mkdirSync(realToolchain); + symlinkSync(realToolchain, substituted, process.platform === "win32" ? "junction" : "dir"); + expect(() => linuxRemoteWorkspaceCommandArgv({ + command: ["true"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 1_000, + maxOutputBytes: 4_096, + }, { + bubblewrapPath: process.execPath, + toolchainRoots: [substituted], + })).toThrow("remain a real directory"); + }); + + test("rejects a pre-existing hardlink before starting a workspace command", async () => { + const state = fixture(); + linkSync(state.outside, join(state.workspace, "outside-alias")); + const runner = createLinuxRemoteWorkspaceCommandRunner({ + bubblewrapPath: process.execPath, + spawn: (() => { throw new Error("sandbox spawn must not be reached"); }) as typeof Bun.spawn, + }); + await expect(runner.run({ + command: ["/bin/true"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 1_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("hard-linked file"); + expect(readFileSync(state.outside, "utf8")).toBe("must-not-be-visible"); + }); + + test("the production Linux runner exposes only the current OCX Bun file, not its host directory", async () => { + if (process.platform !== "linux" || !linuxRemoteWorkspaceCommandRunnerAvailable()) return; + const state = fixture(); + const argv = linuxRemoteWorkspaceCommandArgv({ + command: ["bun", "--version"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }, { runtimeExecutablePath: process.execPath }); + expect(argv).toContain("/ocx-runtime/bin/bun"); + expect(argv).toContain(realpathSync(process.execPath)); + expect(argv).not.toContain(dirname(realpathSync(process.execPath))); + expect(argv).not.toContain(process.env.HOME ?? "__missing_home__"); + const runner = createPlatformRemoteWorkspaceCommandRunner(); + if (!runner) throw new Error("Linux Remote Workspace runner was not detected"); + const result = await runner.run({ + command: ["bun", "--version"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(Bun.version); + }); +}); diff --git a/tests/clients/remote-workspace-device.test.ts b/tests/clients/remote-workspace-device.test.ts new file mode 100644 index 0000000000..386f2ddfbd --- /dev/null +++ b/tests/clients/remote-workspace-device.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RemoteWorkspaceHub, + connectRemoteWorkspaceAgent, + generateRemoteControlIdentityKeyPair, + pairRemoteWorkspaceDevice, + parseRemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceStateStore, + type RemoteWorkspaceHubState, + type RemoteWorkspaceHubStateStore, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +class HubStore implements RemoteWorkspaceHubStateStore { + state: RemoteWorkspaceHubState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceHubState) { this.state = structuredClone(state); } +} + +class DeviceStore implements RemoteWorkspaceDeviceStateStore { + state: RemoteWorkspaceDeviceState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceDeviceState) { this.state = structuredClone(state); } +} + +describe("remote workspace device enrollment", () => { + test("pairs through one HTTPS request while keeping the real root path on Computer 2", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-")); + roots.push(root); + const workspace = join(root, "private-project"); + const toolchain = join(root, "private-toolchain"); + const nativeHelper = join(root, "private-native-helper"); + mkdirSync(workspace); + mkdirSync(toolchain); + writeFileSync(nativeHelper, "test helper", { mode: 0o700 }); + chmodSync(nativeHelper, 0o700); + const hubStore = new HubStore(); + const hub = new RemoteWorkspaceHub(hubStore); + const grant = hub.createPairingGrant(); + const deviceStore = new DeviceStore(); + let requestBody = ""; + const state = await pairRemoteWorkspaceDevice({ + hubUrl: "https://hub.example.test", + pairingCode: grant.code, + name: "Computer 2", + devicePlatform: "linux-x64", + roots: [{ path: workspace, label: "Main project" }], + toolchainRoots: [toolchain], + nativeHelperPath: nativeHelper, + store: deviceStore, + fetchImpl: async (input, init) => { + expect(String(input)).toBe("https://hub.example.test/remote-workspace/pair"); + requestBody = String(init?.body); + const paired = hub.pairDevice(JSON.parse(requestBody)); + return Response.json(paired, { status: 201 }); + }, + }); + expect(requestBody).not.toContain(workspace); + expect(requestBody).not.toContain(toolchain); + expect(requestBody).not.toContain(nativeHelper); + expect(requestBody).not.toContain(state.deviceIdentity.privateKey); + expect(state).toMatchObject({ + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceName: "Computer 2", + devicePlatform: "linux-x64", + roots: [{ label: "Main project", path: realpathSync(workspace) }], + toolchainRoots: [realpathSync(toolchain)], + }); + expect(state.nativeHelper?.path).toBe(realpathSync(nativeHelper)); + expect(state.nativeHelper?.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(deviceStore.state).toEqual(state); + expect(hub.authenticateDeviceToken(state.deviceToken)?.id).toBe(state.deviceId); + expect(JSON.stringify(hubStore.state)).not.toContain(state.deviceToken); + expect(JSON.stringify(hubStore.state)).not.toContain(workspace); + }); + + test("requires HTTPS except for explicit loopback development", async () => { + expect(() => parseRemoteWorkspaceDeviceState({ version: 1, hubUrl: "http://example.test" })) + .toThrow("must use HTTPS"); + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-local-")); + roots.push(root); + const store = new DeviceStore(); + await expect(pairRemoteWorkspaceDevice({ + hubUrl: "http://127.0.0.1:7075", + pairingCode: "AAAA-BBBB-CCCC", + roots: [{ path: root }], + store, + fetchImpl: async () => Response.json({ error: "invalid or expired" }, { status: 401 }), + })).rejects.toThrow("invalid or expired"); + expect(store.state).toBeNull(); + }); + + test("cancels a chunked Hub response before it can grow beyond the pairing limit", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-bounded-response-")); + roots.push(root); + const store = new DeviceStore(); + let cancelled = false; + await expect(pairRemoteWorkspaceDevice({ + hubUrl: "https://hub.example.test", + pairingCode: "ABCD-EFGH-JKLM", + roots: [{ path: root }], + store, + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024)); + controller.enqueue(new Uint8Array([1])); + }, + cancel() { cancelled = true; }, + }), { status: 200 }), + })).rejects.toThrow("response is too large"); + expect(cancelled).toBe(true); + expect(store.state).toBeNull(); + }); + + test("stops cleanly even when the platform WebSocket rejects close while connecting", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-stop-")); + roots.push(root); + const state: RemoteWorkspaceDeviceState = { + version: 1, + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), + deviceName: "Computer 2", + devicePlatform: "darwin-arm64", + capabilities: ["workspace.read", "workspace.write"], + deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: generateRemoteControlIdentityKeyPair(), + hubPublicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project", path: root }], + toolchainRoots: [], + }; + const handle = connectRemoteWorkspaceAgent({ + state, + commandRunner: null, + webSocketFactory: () => ({ + readyState: 0, + send() {}, + close() { throw new Error("CONNECTING close is not supported"); }, + addEventListener() {}, + }), + }); + handle.stop(); + await expect(handle.connected).rejects.toThrow("stopped"); + await handle.closed; + }); +}); diff --git a/tests/clients/remote-workspace-hub.test.ts b/tests/clients/remote-workspace-hub.test.ts new file mode 100644 index 0000000000..b6ce600238 --- /dev/null +++ b/tests/clients/remote-workspace-hub.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + RemoteWorkspaceHub, + RemoteWorkspaceHubAgentConnection, + RemoteWorkspacePairingRateLimitError, + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + generateRemoteControlIdentityKeyPair, + parseRemoteWorkspaceHubState, + serializeRemoteWorkspaceAgentMessage, + type RemoteWorkspaceHubState, + type RemoteWorkspaceHubStateStore, +} from "../../src/remote-control"; + +class MemoryStore implements RemoteWorkspaceHubStateStore { + state: RemoteWorkspaceHubState | null = null; + writes = 0; + + load(): RemoteWorkspaceHubState | null { + return this.state ? structuredClone(this.state) : null; + } + + save(state: RemoteWorkspaceHubState): void { + this.state = structuredClone(state); + this.writes += 1; + } +} + +function pairedHub(now = Date.parse("2026-09-03T12:00:00.000Z")) { + const store = new MemoryStore(); + const hub = new RemoteWorkspaceHub(store, () => now); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const grant = hub.createPairingGrant(); + const paired = hub.pairDevice({ + code: grant.code.replaceAll("-", " ").toLowerCase(), + name: "Computer 2", + platform: "linux-x64", + publicKey: deviceIdentity.publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + }); + return { hub, store, deviceIdentity, paired, now }; +} + +describe("remote workspace hub registry", () => { + test("pairs one named OCX-only device without persisting its bearer token", () => { + const state = pairedHub(); + expect(state.paired.device).toMatchObject({ + name: "Computer 2", + platform: "linux-x64", + online: false, + roots: [{ label: "Project" }], + }); + expect(state.paired.deviceToken).toStartWith("ocxrw_"); + expect(state.paired.hubPublicKey).toBe(state.hub.identity().publicKey); + expect(state.hub.authenticateDeviceToken(state.paired.deviceToken)?.id).toBe(state.paired.device.id); + expect(JSON.stringify(state.store.state)).not.toContain(state.paired.deviceToken); + expect(JSON.stringify(state.hub.listDevices())).not.toContain("publicKey"); + expect(JSON.stringify(state.hub.listDevices())).not.toContain("tokenHash"); + }); + + test("consumes pairing codes once and enforces unique device names", () => { + const state = pairedHub(); + expect(() => state.hub.pairDevice({ + code: "not-a-code", + name: "Computer 3", + platform: "linux-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + })).toThrow("invalid or expired"); + + const grant = state.hub.createPairingGrant(); + expect(() => state.hub.pairDevice({ + code: grant.code, + name: "computer 2", + platform: "windows-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + })).toThrow("already in use"); + expect(() => state.hub.pairDevice({ + code: grant.code, + name: "Computer 3", + platform: "windows-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + })).toThrow("invalid or expired"); + }); + + test("bounds invalid pairing attempts by hashed source, expiry, and map capacity", () => { + let now = Date.parse("2026-09-03T12:00:00.000Z"); + const hub = new RemoteWorkspaceHub(new MemoryStore(), () => now); + const invalid = (source: string) => hub.pairDevice({ code: "AAAA-BBBB-CCCC" }, source); + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(() => invalid("peer:192.0.2.10")).toThrow("invalid or expired"); + } + let limited: unknown; + try { invalid("peer:192.0.2.10"); } catch (error) { limited = error; } + expect(limited).toBeInstanceOf(RemoteWorkspacePairingRateLimitError); + expect(limited).toMatchObject({ reason: "source", retryAfterSeconds: 600 }); + + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(() => invalid("peer:192.0.2.11")).toThrow("invalid or expired"); + } + const identity = generateRemoteControlIdentityKeyPair(); + const grant = hub.createPairingGrant(); + expect(hub.pairDevice({ + code: grant.code, + name: "Computer 2", + platform: "linux-x64", + publicKey: identity.publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + }, "peer:192.0.2.11").device.name).toBe("Computer 2"); + expect(() => invalid("peer:192.0.2.11")).toThrow("invalid or expired"); + + now += 10 * 60_000 + 1; + const afterExpiry = hub.createPairingGrant(); + expect(hub.pairDevice({ + code: afterExpiry.code, + name: "Computer 3", + platform: "linux-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + }, "peer:192.0.2.10").device.name).toBe("Computer 3"); + + const capped = new RemoteWorkspaceHub(new MemoryStore(), () => now); + for (let source = 0; source < 1_024; source += 1) { + expect(() => capped.pairDevice({ code: "AAAA-BBBB-CCCC" }, `peer:${source}`)) + .toThrow("invalid or expired"); + } + let capacity: unknown; + try { capped.pairDevice({ code: "AAAA-BBBB-CCCC" }, "peer:overflow"); } + catch (error) { capacity = error; } + expect(capacity).toBeInstanceOf(RemoteWorkspacePairingRateLimitError); + expect(capacity).toMatchObject({ reason: "capacity", retryAfterSeconds: 1 }); + }); + + test("tracks online presence, replaces reconnects, and revokes the device", () => { + const state = pairedHub(); + const closes: string[] = []; + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + socket: { + send: () => {}, + close: (_code, reason) => closes.push(reason), + }, + }); + state.hub.attachConnection(state.paired.device.id, connection); + expect(state.hub.listDevices()[0]).toMatchObject({ online: false }); + connection.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + expect(state.hub.listDevices()[0]).toMatchObject({ online: true, lastSeenAt: "2026-09-03T12:00:00.000Z" }); + expect(state.hub.connection(state.paired.device.id)).toBe(connection); + expect(state.hub.revokeDevice(state.paired.device.id)).toBe(true); + expect(state.hub.listDevices()).toEqual([]); + expect(connection.isOnline()).toBe(false); + expect(state.store.state?.devices).toEqual([]); + expect(state.hub.authenticateDeviceToken(state.paired.deviceToken)).toBeNull(); + expect(closes).toEqual(["remote workspace device was revoked"]); + }); + + test("refuses mismatched persisted hub identity keys", () => { + const first = generateRemoteControlIdentityKeyPair(); + const second = generateRemoteControlIdentityKeyPair(); + expect(() => parseRemoteWorkspaceHubState({ + version: 1, + identity: { publicKey: first.publicKey, privateKey: second.privateKey }, + devices: [], + })).toThrow("does not match"); + }); +}); + +test("presence reduces availability without changing the durable enrollment grant", () => { + const state = pairedHub(); + const advertised: unknown[] = []; + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + capabilities: ["workspace.read", "workspace.write"], + onCapabilities: capabilities => state.hub.updateDeviceCapabilities(state.paired.device.id, capabilities), + socket: { send: value => { advertised.push(JSON.parse(value)); }, close() {} }, + }); + state.hub.attachConnection(state.paired.device.id, connection); + connection.receive(serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read"], + })); + expect(state.hub.listDevices()[0]?.capabilities).toEqual(["workspace.read"]); + expect(state.store.state?.devices[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + expect(advertised[0]).toMatchObject({ capabilities: ["workspace.read"] }); + state.hub.detachConnection(state.paired.device.id, connection); + + const reconnect = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + capabilities: ["workspace.read", "workspace.write"], + socket: { send() {}, close() {} }, + }); + state.hub.attachConnection(state.paired.device.id, reconnect); + reconnect.receive(serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + })); + expect(reconnect.capabilities()).toEqual(["workspace.read", "workspace.write"]); + expect(state.hub.listDevices()[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + expect(state.store.state?.devices[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + state.hub.closeAllConnections(); +}); diff --git a/tests/clients/remote-workspace-linux-confinement.test.ts b/tests/clients/remote-workspace-linux-confinement.test.ts new file mode 100644 index 0000000000..4d88d29096 --- /dev/null +++ b/tests/clients/remote-workspace-linux-confinement.test.ts @@ -0,0 +1,114 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandRunnerAvailable, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +test("hosted Linux proves workspace write and denies adjacent access, loopback, and detached survival", async () => { + const required = process.env.OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT === "1"; + const available = process.platform === "linux" + && existsSync("/usr/bin/bwrap") + && linuxRemoteWorkspaceCommandRunnerAvailable(); + if (!required && !available) return; + expect(process.platform).toBe("linux"); + expect(existsSync("/usr/bin/bwrap")).toBe(true); + expect(available).toBe(true); + + const parent = mkdtempSync(join(tmpdir(), "ocx-remote-linux-confinement-")); + roots.push(parent); + const workspace = join(parent, "workspace"); + const marker = join(workspace, "probe-marker"); + const outsideRead = join(parent, "outside-secret"); + const outsideWrite = join(parent, "outside-write"); + mkdirSync(workspace); + writeFileSync(join(workspace, ".keep"), "workspace"); + writeFileSync(outsideRead, "must-not-be-visible"); + + let acceptedConnections = 0; + const listener = createServer(socket => { + acceptedConnections += 1; + socket.destroy(); + }); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", resolve); + }); + const address = listener.address(); + if (!address || typeof address === "string") throw new Error("loopback probe did not bind TCP"); + const runner = createPlatformRemoteWorkspaceCommandRunner({ + linux: { writableRoots: [workspace] }, + }); + if (!runner) throw new Error("production Linux Remote Workspace runner was not created"); + + try { + const result = await runner.run({ + root: workspace, + cwd: workspace, + command: [ + "bun", + "-e", + [ + 'import { readFileSync, writeFileSync } from "node:fs";', + 'const [outsideRead, outsideWrite, port] = process.argv.slice(1);', + 'if (!outsideRead || !outsideWrite || !port) process.exit(31);', + 'if (process.execPath !== "/ocx-runtime/bin/bun") process.exit(29);', + 'writeFileSync("probe-marker", "sandboxed");', + 'try { readFileSync(outsideRead); process.exit(26); } catch (error) { void error; }', + 'try { writeFileSync(outsideWrite, "escaped"); process.exit(27); } catch (error) { void error; }', + 'try { await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) }); process.exit(28); } catch (error) { void error; }', + ].join("\n"), + "--", + outsideRead, + outsideWrite, + String(address.port), + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }); + expect(result.exitCode).toBe(0); + expect(readFileSync(marker, "utf8")).toBe("sandboxed"); + expect(existsSync(outsideWrite)).toBe(false); + expect(acceptedConnections).toBe(0); + } finally { + await new Promise(resolve => listener.close(() => resolve())); + } + + const lateMarker = join(workspace, "late-marker"); + const controller = new AbortController(); + const pending = runner.run({ + root: workspace, + cwd: workspace, + command: [ + "/bin/bash", + "-c", + "setsid /bin/bash -c 'sleep 0.5; printf escaped > late-marker' >/dev/null 2>&1 & sleep 30", + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + signal: controller.signal, + }); + await Bun.sleep(100); + controller.abort(); + await expect(pending).rejects.toThrow("cancelled"); + await Bun.sleep(750); + expect(existsSync(lateMarker)).toBe(false); + + const unsafeWorkspace = join(parent, "unsafe-workspace"); + mkdirSync(unsafeWorkspace); + linkSync(outsideRead, join(unsafeWorkspace, "outside-alias")); + expect(createPlatformRemoteWorkspaceCommandRunner({ + linux: { writableRoots: [unsafeWorkspace] }, + })).toBeUndefined(); + expect(readFileSync(outsideRead, "utf8")).toBe("must-not-be-visible"); +}); diff --git a/tests/clients/remote-workspace-platform.test.ts b/tests/clients/remote-workspace-platform.test.ts new file mode 100644 index 0000000000..ec48362055 --- /dev/null +++ b/tests/clients/remote-workspace-platform.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { + findExecutableOnPath, +} from "../../src/remote-control/workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + remoteWorkspaceThreadStartParams, + linuxRemoteWorkspaceCommandRunnerAvailable, + remoteWorkspaceCapabilitiesForCommandRunner, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + truncateRemoteWorkspaceUtf8, + validateRemoteWorkspaceRelativePath, +} from "../../src/remote-control"; + +describe("Remote Workspace cross-platform boundaries", () => { + test("resolves Windows PATH and PATHEXT with Windows grammar on every test host", () => { + const visited: string[] = []; + const resolved = findExecutableOnPath("claude", { + platform: "win32", + path: "C:\\first;D:\\npm", + pathExt: ".PS1;.EXE;.CMD", + probe(candidate) { + visited.push(candidate); + return candidate.toLowerCase() === "d:\\npm\\claude.cmd"; + }, + }); + expect(resolved).toBe("D:\\npm\\claude.cmd"); + expect(visited).toEqual([ + "C:\\first\\claude.exe", + "C:\\first\\claude.cmd", + "D:\\npm\\claude.exe", + "D:\\npm\\claude.cmd", + ]); + }); + + test("launches Windows npm shims through escaped ComSpec and leaves Unix argv direct", () => { + const windows = remoteWorkspaceProcessInvocation( + ["C:\\Users\\u\\AppData\\Roaming\\npm\\claude.cmd", "--system-prompt", "a&b"], + { platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" } }, + ); + expect(windows.file).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(windows.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(windows.args[3]).toContain("a^&b"); + expect(windows.options.windowsVerbatimArguments).toBe(true); + + expect(remoteWorkspaceProcessInvocation(["/usr/bin/claude", "--version"], { platform: "linux" })) + .toEqual({ file: "/usr/bin/claude", args: ["--version"], options: {} }); + expect(remoteWorkspaceProcessInvocation(["/opt/homebrew/bin/pi", "--version"], { platform: "darwin" })) + .toEqual({ file: "/opt/homebrew/bin/pi", args: ["--version"], options: {} }); + }); + + test("stops the exact Windows wrapper tree through trusted taskkill semantics", async () => { + let settle!: (code: number) => void; + const exited = new Promise(resolve => { settle = resolve; }); + const calls: Array<{ file: string; args: readonly string[] }> = []; + let fallbackKills = 0; + await stopRemoteWorkspaceProcess({ + pid: 4242, + exitCode: null, + exited, + kill() { fallbackKills += 1; settle(0); }, + }, { + platform: "win32", + taskkillPath: "C:\\Windows\\System32\\taskkill.exe", + execFile(file, args) { calls.push({ file, args }); settle(0); }, + waitMs: 10, + }); + expect(calls).toEqual([{ + file: "C:\\Windows\\System32\\taskkill.exe", + args: ["/PID", "4242", "/T", "/F"], + }]); + expect(fallbackKills).toBe(0); + }); + + test("escalates a Unix child that ignores SIGTERM without killing unrelated processes", async () => { + let settle!: (code: number) => void; + const exited = new Promise(resolve => { settle = resolve; }); + const signals: Array = []; + await stopRemoteWorkspaceProcess({ + pid: 4243, + exitCode: null, + exited, + kill(signal) { + signals.push(signal); + if (signal === "SIGKILL") settle(137); + }, + }, { platform: "darwin", waitMs: 1 }); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + }); + + test("runs every cleanup owner even when an earlier resource fails", async () => { + const completed: string[] = []; + await expect(runRemoteWorkspaceCleanupSteps([ + () => { completed.push("process"); throw new Error("process cleanup failed"); }, + async () => { completed.push("bridge"); }, + () => { completed.push("isolation"); }, + ])).rejects.toThrow("process cleanup failed"); + expect(completed).toEqual(["process", "bridge", "isolation"]); + }); + + test("reports an owned child that remains alive after forced termination", async () => { + const exited = new Promise(() => {}); + await expect(stopRemoteWorkspaceProcess({ + pid: 4244, + exitCode: null, + exited, + kill() {}, + }, { platform: "linux", waitMs: 1 })).rejects.toThrow("did not exit after SIGKILL"); + }); + + test("reconnection cannot widen the capability grant recorded at pairing", () => { + const runner = { async run() { return { exitCode: 0, stdout: "", stderr: "" }; } }; + expect(remoteWorkspaceCapabilitiesForCommandRunner(runner, ["workspace.read"])) + .toEqual(["workspace.read"]); + expect(remoteWorkspaceCapabilitiesForCommandRunner(undefined, [ + "workspace.read", "workspace.write", "workspace.exec", + ])).toEqual(["workspace.read", "workspace.write"]); + }); + + test("bounds large UTF-8 text without quadratic trimming or split surrogate pairs", () => { + const value = `${"가".repeat(100_000)}😀tail`; + const truncated = truncateRemoteWorkspaceUtf8(value, 8_192); + expect(Buffer.byteLength(truncated, "utf8")).toBeLessThanOrEqual(8_192); + expect(truncated.endsWith("\ud83d")).toBe(false); + expect(truncated.includes("tail")).toBe(false); + }); + + test("uses platform-native deny-local shell environments", () => { + const windows = remoteWorkspaceThreadStartParams({ + executorName: "Windows executor", + coordinatorIsolationPath: "/test/coordinator", + tools: ["read_file"], + platform: "win32", + windowsSystemDirectory: "C:\\Windows\\System32", + mcp: { url: "http://127.0.0.1:1/mcp", bearerTokenEnvVar: "TOKEN" }, + }) as { config: { shell_environment_policy: { set: Record } } }; + expect(windows.config.shell_environment_policy.set).toMatchObject({ + USERPROFILE: "/test/coordinator", + TEMP: "/test/coordinator", + PATH: "C:\\Windows\\System32", + }); + expect(windows.config.shell_environment_policy.set.PATH).not.toContain("/usr/"); + + const mac = remoteWorkspaceThreadStartParams({ + executorName: "Mac executor", + coordinatorIsolationPath: "/test/coordinator", + tools: ["read_file"], + platform: "darwin", + mcp: { url: "http://127.0.0.1:1/mcp", bearerTokenEnvVar: "TOKEN" }, + }) as { config: { shell_environment_policy: { set: Record } } }; + expect(mac.config.shell_environment_policy.set.PATH).toBe("/usr/bin:/bin"); + }); + + test("advertises Linux exec only after the namespace probe succeeds", () => { + if (!existsSync("/usr/bin/bwrap")) return; + let sawNetworkIsolation = false; + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: "/usr/bin/bwrap", + probe(argv) { + sawNetworkIsolation = argv.includes("--unshare-net"); + return false; + }, + })).toBe(false); + expect(sawNetworkIsolation).toBe(true); + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: "/usr/bin/bwrap", + probe: () => true, + })).toBe(true); + }); + + test("rejects Windows device names, ADS, and normalized aliases without blocking POSIX names", () => { + for (const path of ["NUL", "con.txt", "CONIN$", "CLOCK$.txt", "logs\\COM1.json", "file.txt:token", "name.", "name ", "bad\u0001name"]) { + expect(() => validateRemoteWorkspaceRelativePath(path, undefined, "win32")).toThrow("safe Windows"); + } + expect(validateRemoteWorkspaceRelativePath("normal\\file.txt", undefined, "win32")) + .toBe("normal\\file.txt"); + expect(validateRemoteWorkspaceRelativePath("NUL:valid-on-posix", undefined, "linux")) + .toBe("NUL:valid-on-posix"); + }); +}); diff --git a/tests/clients/remote-workspace-secret-store.test.ts b/tests/clients/remote-workspace-secret-store.test.ts new file mode 100644 index 0000000000..6913e11bf6 --- /dev/null +++ b/tests/clients/remote-workspace-secret-store.test.ts @@ -0,0 +1,105 @@ +import { afterEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateRemoteControlIdentityKeyPair } from "../../src/remote-control/crypto"; +import { RemoteWorkspaceHubFileStore } from "../../src/remote-control/workspace-hub"; +import { RemoteWorkspaceDeviceFileStore } from "../../src/remote-control/workspace-device"; +import { RemoteWorkspaceSessionFileStore } from "../../src/remote-control/workspace-sessions"; +import { workspaceSecretPermissions, type WorkspaceSecretPermissions } from "../../src/remote-control/workspace-secret-store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousHome = process.env.OPENCODEX_HOME; +const roots: string[] = []; +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixtures() { + const root = mkdtempSync(join(tmpdir(), "ocx-workspace-secret-")); + roots.push(root); + process.env.OPENCODEX_HOME = root; + const identity = generateRemoteControlIdentityKeyPair(); + const hubState = { version: 1 as const, identity, devices: [] }; + const sessionState = { version: 1 as const, sessions: [] }; + const deviceState = { + version: 1 as const, hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), deviceName: "Executor", devicePlatform: "test", + capabilities: ["workspace.read" as const], deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: identity, hubPublicKey: identity.publicKey, + roots: [{ id: randomUUID(), label: "Project", path: root }], toolchainRoots: [], + }; + return [ + { path: join(root, "hub.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceHubFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(hubState) }; + } }, + { path: join(root, "device.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceDeviceFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(deviceState) }; + } }, + { path: join(root, "sessions.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceSessionFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(sessionState) }; + } }, + ]; +} + +test("all workspace stores distinguish absent state from permission failure", () => { + for (const fixture of fixtures()) { + const store = fixture.create(fixture.path); + expect(store.load()).toBeNull(); + store.save(); + expect(store.load()).not.toBeNull(); + if (process.platform !== "win32") expect(statSync(fixture.path).mode & 0o777).toBe(0o600); + } +}); + +test("all stores propagate hardening failures before decoding or publishing secret bytes", () => { + for (const fixture of fixtures()) { + for (const failedStep of ["prepareDirectory", "hardenFile"] as const) { + // Invalid JSON would fail if read reached decoding instead of the permission boundary. + writeFileSync(fixture.path, "private-sentinel-not-json", { mode: 0o600 }); + const calls: string[] = []; + const permissions: WorkspaceSecretPermissions = { + prepareDirectory() { calls.push("directory"); if (failedStep === "prepareDirectory") throw new Error("denied hardening"); }, + hardenFile() { calls.push("file"); throw new Error("denied hardening"); }, + }; + const store = fixture.create(fixture.path, permissions); + expect(() => store.load()).toThrow("denied hardening"); + expect(() => store.save()).toThrow("denied hardening"); + expect(readFileSync(fixture.path, "utf8")).toBe("private-sentinel-not-json"); + expect(calls).toEqual(failedStep === "prepareDirectory" + ? ["directory", "directory"] : ["directory", "file", "directory", "file"]); + } + } +}); + +test("secret files refuse symbolic-link targets", () => { + if (process.platform === "win32") return; // Windows link creation requires separate privileges. + const fixture = fixtures()[0]!; + const target = `${fixture.path}.target`; + writeFileSync(target, "private", { mode: 0o600 }); + symlinkSync(target, fixture.path); + expect(() => workspaceSecretPermissions.hardenFile(fixture.path)).toThrow("regular file"); + expect(readFileSync(target, "utf8")).toBe("private"); +}); + + +test("an inaccessible existing store is never reported as first-run absence", () => { + if (process.platform === "win32" || process.getuid?.() === 0) return; + for (const fixture of fixtures()) { + const store = fixture.create(fixture.path); + store.save(); + const before = readFileSync(fixture.path, "utf8"); + const directory = fixture.path.slice(0, fixture.path.lastIndexOf("/")); + chmodSync(directory, 0); + try { expect(() => store.load()).toThrow(); } + finally { chmodSync(directory, 0o700); } + expect(readFileSync(fixture.path, "utf8")).toBe(before); + } +}); diff --git a/tests/clients/remote-workspace-session-binding.test.ts b/tests/clients/remote-workspace-session-binding.test.ts new file mode 100644 index 0000000000..ac1ab7c7c3 --- /dev/null +++ b/tests/clients/remote-workspace-session-binding.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + EncryptedRemoteWorkspaceExecutorEndpoint, + RemoteControlClientHandshake, + acceptRemoteControlClientHello, + frameRemoteWorkspaceRpcMessage, + generateRemoteControlIdentityKeyPair, + type RemoteWorkspaceExecutionRequest, +} from "../../src/remote-control"; + +function fixture() { + const hub = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const sessionId = randomUUID(); + const deviceId = randomUUID(); + const handshake = RemoteControlClientHandshake.create({ + sessionId, deviceId, commandProfile: "codex", capabilities: ["workspace.read"], + accountPrivateKey: hub.privateKey, + }); + const accepted = acceptRemoteControlClientHello(handshake.hello, { + expectedSessionId: sessionId, expectedDeviceId: deviceId, + accountPublicKey: hub.publicKey, devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write"], + }); + const client = handshake.complete(accepted.hello, device.publicKey); + const invocations: RemoteWorkspaceExecutionRequest[] = []; + const endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: deviceId, sessionId, rootId: "first-approved-root", + capabilities: ["workspace.read"], cipher: accepted.cipher, + executor: { async invoke(request) { invocations.push(request); return { ok: true }; } }, + sendCiphertext() {}, + }); + const request: RemoteWorkspaceExecutionRequest = { + requestId: randomUUID(), sessionId, executorDeviceId: deviceId, + rootId: "first-approved-root", tool: "read_file", arguments: { path: "marker" }, + }; + return { + invocations, + async send(overrides: Partial = {}) { + const message = new TextEncoder().encode(JSON.stringify({ + version: 1, kind: "request", request: { ...request, ...overrides }, + })); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await endpoint.receiveCiphertext(client.encrypt(frame)); + } + }, + close() { endpoint.close(); client.destroy(); }, + }; +} + +test("encrypted requests cannot leave their session grant before executor invocation", async () => { + const mismatches: Partial[] = [ + { sessionId: randomUUID() }, + { executorDeviceId: randomUUID() }, + { rootId: "second-approved-root" }, + { tool: "write_file", arguments: { path: "marker", content: "changed", expectedSha256: null } }, + ]; + for (const mismatch of mismatches) { + const state = fixture(); + try { + await expect(state.send(mismatch)).rejects.toThrow(); + expect(state.invocations).toEqual([]); + } finally { state.close(); } + } +}); + +test("a matching encrypted read reaches the selected executor once", async () => { + const state = fixture(); + try { + await state.send(); + expect(state.invocations).toHaveLength(1); + expect(state.invocations[0]).toMatchObject({ rootId: "first-approved-root", tool: "read_file" }); + } finally { state.close(); } +}); diff --git a/tests/clients/remote-workspace-sessions.test.ts b/tests/clients/remote-workspace-sessions.test.ts new file mode 100644 index 0000000000..dc0be8ed16 --- /dev/null +++ b/tests/clients/remote-workspace-sessions.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from "bun:test"; +import type { RemoteWorkspaceHub } from "../../src/remote-control/workspace-hub"; +import { + RemoteWorkspaceSessionService, + type RemoteWorkspaceRuntimeFactory, + type RemoteWorkspaceRuntimeHandle, + type RemoteWorkspaceSessionEvent, + type RemoteWorkspaceSessionState, + type RemoteWorkspaceSessionStateStore, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; + +const DEVICE_ID = "11111111-1111-4111-8111-111111111111"; +const ROOT_ID = "22222222-2222-4222-8222-222222222222"; + +interface Harness { + service: RemoteWorkspaceSessionService; + setOnline(value: boolean): void; + invocations: Array<{ tool: string; rootId: string }>; + closedSessions: string[]; + stopCalls(): number; + sessionOpens(): number; + sessionGrants: string[][]; + runtimeStarts(): Array; +} + +class MemorySessionStore implements RemoteWorkspaceSessionStateStore { + state: RemoteWorkspaceSessionState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceSessionState) { this.state = structuredClone(state); } +} + +function deferred(): { + promise: Promise; + resolve(): void; + reject(error: Error): void; +} { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createHarness(options: { + promptGate?: ReturnType; + startGate?: ReturnType; + onStart?: () => void; + lazyResumable?: boolean; + eventsAtStart?: number; + sessionStore?: RemoteWorkspaceSessionStateStore; + stopError?: Error; + closeError?: Error; +} = {}): Harness { + let online = true; + let stops = 0; + let opens = 0; + let promptStarted = false; + let runtimeResumable = options.lazyResumable !== true; + const invocations: Array<{ tool: string; rootId: string }> = []; + const closedSessions: string[] = []; + const sessionGrants: string[][] = []; + const transportStates: Array<{ online: boolean }> = []; + const runtimeStarts: Array = []; + const newTransport = (): RemoteWorkspaceTransport => { + const state = { online: true }; + transportStates.push(state); + return { + isOnline: deviceId => state.online && deviceId === DEVICE_ID, + async invoke(request) { + if (!state.online) throw new Error("transport offline"); + invocations.push({ tool: request.tool, rootId: request.rootId }); + return { ok: true, value: { entries: ["src"] } }; + }, + }; + }; + const connection = { + capabilities: () => ["workspace.read", "workspace.write", "workspace.exec"], + async openSession(input: { capabilities: string[] }) { sessionGrants.push([...input.capabilities]); opens += 1; return newTransport(); }, + async closeSession(sessionId: string) { + closedSessions.push(sessionId); + if (options.closeError) throw options.closeError; + }, + }; + const hub = { + listDevices: () => [{ + id: DEVICE_ID, + name: "Build box", + platform: "linux", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + roots: [{ id: ROOT_ID, label: "Project" }], + online, + createdAt: "2026-01-01T00:00:00.000Z", + lastSeenAt: null, + }], + connection: (deviceId: string) => online && deviceId === DEVICE_ID ? connection : null, + } as unknown as RemoteWorkspaceHub; + + const factory: RemoteWorkspaceRuntimeFactory = { + profile: "codex", + async available() { return { available: true, version: "test" }; }, + async start({ coordinator, emit, resumeThreadId }) { + options.onStart?.(); + if (options.startGate) await options.startGate.promise; + runtimeStarts.push(resumeThreadId); + for (let index = 0; index < (options.eventsAtStart ?? 0); index += 1) { + emit("assistant", `event-${index}`); + } + const handle: RemoteWorkspaceRuntimeHandle = { + threadId: resumeThreadId ?? "thread-remote-1", + canResume: () => runtimeResumable, + async prompt() { + promptStarted = true; + if (options.promptGate) await options.promptGate.promise; + else { + const response = await coordinator.handle({ + method: "item/tool/call", + id: "tool-1", + params: { + threadId: "thread-remote-1", + turnId: "turn-1", + callId: "call-1", + namespace: "ocx_remote_workspace", + tool: "list_directory", + arguments: { path: "." }, + }, + }); + emit("tool", response.result.contentItems[0]!.text); + } + runtimeResumable = true; + }, + async stop() { + stops += 1; + if (promptStarted) options.promptGate?.reject(new Error("turn cancelled")); + if (options.stopError) throw options.stopError; + }, + }; + return handle; + }, + }; + return { + service: new RemoteWorkspaceSessionService(hub, [factory], Date.now, options.sessionStore), + setOnline(value) { + online = value; + if (!value) for (const state of transportStates) state.online = false; + }, + invocations, + closedSessions, + stopCalls: () => stops, + sessionOpens: () => opens, + sessionGrants, + runtimeStarts: () => [...runtimeStarts], + }; +} + +describe("Remote Workspace session service", () => { + test("binds one model session to the selected executor root", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.status).toBe("ready"); + expect(created.deviceName).toBe("Build box"); + expect(created.rootLabel).toBe("Project"); + expect(created).toMatchObject({ + accessMode: "read-only", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + + const completed = await harness.service.prompt(created.id, "Inspect this project"); + expect(completed.status).toBe("ready"); + expect(harness.invocations).toEqual([{ tool: "list_directory", rootId: ROOT_ID }]); + expect(completed.events.some(event => event.type === "tool" && event.text.includes("src"))).toBe(true); + }); + + test("exposes write and exec tools only after an explicit workspace access grant", async () => { + const harness = createHarness(); + const created = await harness.service.create({ + profile: "codex", + deviceId: DEVICE_ID, + rootId: ROOT_ID, + accessMode: "workspace", + }); + expect(created).toMatchObject({ + accessMode: "workspace", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + }); + + test("fails closed when the selected executor disconnects", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + harness.setOnline(false); + await expect(harness.service.prompt(created.id, "Do not run locally")).rejects.toThrow("executor is offline"); + expect(harness.invocations).toHaveLength(0); + expect(harness.service.get(created.id)?.status).toBe("waiting_for_executor"); + }); + + test("reopens only the encrypted executor channel after the device reconnects", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(harness.sessionOpens()).toBe(1); + harness.setOnline(false); + expect(harness.service.get(created.id)?.status).toBe("waiting_for_executor"); + harness.setOnline(true); + const completed = await harness.service.prompt(created.id, "Continue remotely"); + expect(completed.status).toBe("ready"); + expect(harness.sessionOpens()).toBe(2); + expect(harness.invocations).toEqual([{ tool: "list_directory", rootId: ROOT_ID }]); + }); + + test("rejects a second prompt while a turn is active", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const first = harness.service.prompt(created.id, "First"); + await Promise.resolve(); + await expect(harness.service.prompt(created.id, "Second")).rejects.toThrow("active turn"); + gate.resolve(); + await first; + }); + + test("a turn that finishes after disconnect stays waiting instead of reporting ready", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const running = harness.service.prompt(created.id, "Keep the target binding"); + await Promise.resolve(); + harness.setOnline(false); + gate.resolve(); + const completed = await running; + expect(completed.status).toBe("waiting_for_executor"); + }); + + test("stop cancels an active turn before waiting for it", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const promptOutcome = harness.service.prompt(created.id, "Long turn").then( + () => "resolved", + () => "rejected", + ); + await Promise.resolve(); + + expect(await harness.service.stop(created.id)).toBe(true); + expect(await promptOutcome).toBe("rejected"); + expect(harness.stopCalls()).toBe(1); + expect(harness.closedSessions).toEqual([created.id]); + expect(harness.service.get(created.id)?.status).toBe("stopped"); + }); + + test("stop cannot be overwritten by a session that finishes starting late", async () => { + const startGate = deferred(); + const startEntered = deferred(); + const harness = createHarness({ startGate, onStart: startEntered.resolve }); + const creating = harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await startEntered.promise; + const starting = harness.service.list()[0]; + if (!starting) throw new Error("starting session was not visible"); + + expect(await harness.service.stop(starting.id)).toBe(true); + startGate.resolve(); + await expect(creating).rejects.toThrow("stopped while starting"); + expect(harness.service.get(starting.id)?.status).toBe("stopped"); + expect(harness.stopCalls()).toBe(1); + }); + + test("attempts every session cleanup owner and reports incomplete teardown", async () => { + const harness = createHarness({ + stopError: new Error("runtime refused to stop"), + closeError: new Error("transport refused to close"), + }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await expect(harness.service.stop(created.id)).rejects.toThrow("runtime refused to stop"); + expect(harness.stopCalls()).toBe(1); + expect(harness.closedSessions).toEqual([created.id]); + expect(harness.service.get(created.id)?.status).toBe("failed"); + }); + + test("keeps only a bounded event history", async () => { + const harness = createHarness({ eventsAtStart: 510 }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.events).toHaveLength(100); + expect(created.events[0]!.sequence).toBeGreaterThan(1); + const types: RemoteWorkspaceSessionEvent["type"][] = created.events.map(event => event.type); + expect(types.at(-1)).toBe("status"); + }); + + test("restores a persisted Hub session and resumes its original model thread", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(store.state?.sessions[0]?.threadId).toBe("thread-remote-1"); + + const restarted = createHarness({ sessionStore: store }); + expect(restarted.service.get(created.id)?.status).toBe("waiting_for_executor"); + const completed = await restarted.service.prompt(created.id, "Continue after Hub restart"); + expect(completed.status).toBe("ready"); + expect(restarted.runtimeStarts()).toEqual(["thread-remote-1"]); + }); + + test("persists a lazy runtime as resumable only after its first completed turn", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store, lazyResumable: true }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.resumable).toBe(false); + expect(store.state?.sessions[0]?.resumable).toBe(false); + + const completed = await first.service.prompt(created.id, "Create durable history"); + expect(completed.resumable).toBe(true); + const restarted = createHarness({ sessionStore: store, lazyResumable: true }); + expect(restarted.service.get(created.id)?.status).toBe("waiting_for_executor"); + }); + + test("graceful Hub shutdown cleans runtimes without marking resumable sessions stopped", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await first.service.shutdown(); + expect(first.stopCalls()).toBe(1); + expect(store.state?.sessions[0]?.status).toBe("waiting_for_executor"); + + const restarted = createHarness({ sessionStore: store }); + const completed = await restarted.service.prompt(created.id, "Resume after graceful restart"); + expect(completed.status).toBe("ready"); + expect(restarted.runtimeStarts()).toEqual(["thread-remote-1"]); + }); + + test("stops every retained runtime during Hub shutdown", async () => { + const harness = createHarness(); + await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await harness.service.stopAll(); + expect(harness.stopCalls()).toBe(2); + expect(harness.service.list().every(session => session.status === "stopped")).toBe(true); + }); +}); + + +test("read-only capability grant is forwarded on initial open and reconnect", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID, accessMode: "read-only" }); + expect(harness.sessionGrants).toEqual([["workspace.read"]]); + harness.setOnline(false); + harness.service.list(); + harness.setOnline(true); + await harness.service.prompt(created.id, "Read after reconnect"); + expect(harness.sessionGrants).toEqual([["workspace.read"], ["workspace.read"]]); + await harness.service.stop(created.id); +}); diff --git a/tests/clients/remote-workspace-tool-bridge.test.ts b/tests/clients/remote-workspace-tool-bridge.test.ts new file mode 100644 index 0000000000..48c24634b7 --- /dev/null +++ b/tests/clients/remote-workspace-tool-bridge.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { RemoteWorkspaceCoordinator, startRemoteWorkspaceToolBridge } from "../../src/remote-control"; + +test("loopback CLI bridge accepts only its bearer and delegates to the E2EE coordinator", async () => { + const invocations: string[] = []; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke(request) { + invocations.push(request.tool); + return { ok: true, value: { entries: ["src"] } }; + }, + }); + coordinator.register({ + sessionId: "session-1", + threadId: "thread-1", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: "thread-1", + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + try { + const denied = await fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + expect(denied.status).toBe(401); + const allowed = await fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + expect(allowed.status).toBe(200); + const body = await allowed.json() as { success: boolean; text: string }; + expect(body.success).toBe(true); + expect(body.text).toContain("src"); + expect(invocations).toEqual(["list_directory"]); + } finally { + await bridge.stop(); + } +}); + +test("loopback CLI bridge rejects excess work before buffering another request", async () => { + const releases: Array<() => void> = []; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + invoke: async () => await new Promise<{ ok: true; value: null }>(resolve => { + releases.push(() => resolve({ ok: true, value: null })); + }), + }); + coordinator.register({ + sessionId: "session-1", + threadId: "thread-1", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: "thread-1", + tools: ["list_directory"], + }); + const request = () => fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + try { + const active = Array.from({ length: 8 }, request); + for (let count = 0; count < 100 && releases.length < 8; count += 1) await Bun.sleep(1); + expect(releases).toHaveLength(8); + expect((await request()).status).toBe(429); + for (const release of releases) release(); + expect((await Promise.all(active)).every(response => response.status === 200)).toBe(true); + } finally { + for (const release of releases) release(); + await bridge.stop(); + } +}); diff --git a/tests/clients/remote-workspace.test.ts b/tests/clients/remote-workspace.test.ts new file mode 100644 index 0000000000..6754d5b8ff --- /dev/null +++ b/tests/clients/remote-workspace.test.ts @@ -0,0 +1,464 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash, randomUUID } from "node:crypto"; +import { + mkdirSync, + linkSync, + mkdtempSync, + readFileSync, + renameSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, + RemoteControlClientHandshake, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, + acceptRemoteControlClientHello, + generateRemoteControlIdentityKeyPair, + remoteWorkspaceThreadStartParams, + type AppServerDynamicToolRequest, + type RemoteWorkspaceCommandRunner, + type RemoteWorkspaceExecutionRequest, + type RemoteWorkspaceToolResult, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +const localTestCommandRunner: RemoteWorkspaceCommandRunner = { + async run(request) { + const child = Bun.spawn(request.command, { + cwd: request.cwd, + env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: request.cwd }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, request.timeoutMs); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (timedOut) throw new Error("local test command timed out"); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > request.maxOutputBytes) { + throw new Error("local test command output limit exceeded"); + } + return { stdout, stderr, exitCode }; + } finally { + clearTimeout(timer); + } + }, +}; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-workspace-")); + roots.push(root); + const main = join(root, "main"); + const executorRoot = join(root, "executor"); + mkdirSync(join(main, "project"), { recursive: true }); + mkdirSync(join(executorRoot, "project"), { recursive: true }); + writeFileSync(join(main, "project", "marker.txt"), "main-only"); + writeFileSync(join(executorRoot, "project", "marker.txt"), "executor-before"); + const deviceId = `device-${randomUUID()}`; + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "project-root", path: executorRoot }], + commandRunner: localTestCommandRunner, + }); + let online = true; + let invokeCount = 0; + const transport: RemoteWorkspaceTransport = { + isOnline: candidate => online && candidate === deviceId, + async invoke(request: RemoteWorkspaceExecutionRequest): Promise { + invokeCount += 1; + return await executor.invoke(request); + }, + }; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const threadId = `thread-${randomUUID()}`; + coordinator.register({ + sessionId: `session-${randomUUID()}`, + threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const request = (tool: string, args: unknown, id: number = 1): AppServerDynamicToolRequest => ({ + method: "item/tool/call", + id, + params: { + threadId, + turnId: `turn-${randomUUID()}`, + callId: `call-${randomUUID()}`, + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool, + arguments: args, + }, + }); + return { + root, + main, + executorRoot, + executor, + coordinator, + request, + setOnline(value: boolean) { online = value; }, + invokeCount: () => invokeCount, + }; +} + +function responseValue(response: Awaited>): RemoteWorkspaceToolResult { + return JSON.parse(response.result.contentItems[0]!.text) as RemoteWorkspaceToolResult; +} + +describe("remote workspace coordinator and executor", () => { + test("publishes only the namespaced client-executed tools and isolates the coordinator cwd", () => { + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS).toHaveLength(1); + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].name).toBe(REMOTE_WORKSPACE_TOOL_NAMESPACE); + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.map(tool => tool.name)).toEqual([ + "list_directory", "read_file", "write_file", "exec", + ]); + const coordinatorIsolation = resolve("isolated-coordinator-session"); + const params = remoteWorkspaceThreadStartParams({ + executorName: "Computer 2", + coordinatorIsolationPath: coordinatorIsolation, + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + expect(params).toMatchObject({ + cwd: coordinatorIsolation, + runtimeWorkspaceRoots: [coordinatorIsolation], + approvalPolicy: "never", + serviceName: "opencodex_remote_workspace", + }); + expect(String(params.developerInstructions)).toContain("never fall back locally"); + }); + + test("rejects write and exec calls that are outside the session access grant", async () => { + let invoked = false; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { invoked = true; return { ok: true }; }, + }); + coordinator.register({ + sessionId: "session-read-only", + threadId: "thread-read-only", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + const result = await coordinator.handle({ + method: "item/tool/call", + id: "request-1", + params: { + threadId: "thread-read-only", + turnId: "turn-1", + callId: "call-1", + namespace: "ocx_remote_workspace", + tool: "exec", + arguments: { command: ["true"] }, + }, + }); + expect(responseValue(result).error).toContain("not supported"); + expect(invoked).toBe(false); + }); + + test("writes and executes only inside Computer 2 while the same Computer 1 path stays unchanged", async () => { + const state = fixture(); + const write = await state.coordinator.handle(state.request("write_file", { + path: "project/marker.txt", + content: "executor-after", + expectedSha256: sha256("executor-before"), + })); + expect(write.result.success).toBe(true); + expect(responseValue(write).ok).toBe(true); + expect(readFileSync(join(state.executorRoot, "project", "marker.txt"), "utf8")).toBe("executor-after"); + expect(readFileSync(join(state.main, "project", "marker.txt"), "utf8")).toBe("main-only"); + + const command = process.platform === "win32" + ? ["powershell.exe", "-NoProfile", "-Command", "Write-Output -NoNewline 'executor-process:'; (Get-Location).Path"] + : ["/bin/sh", "-lc", "printf 'executor-process:'; pwd"]; + const exec = await state.coordinator.handle(state.request("exec", { + command, + cwd: "project", + timeoutMs: 5_000, + }, 2)); + const result = responseValue(exec); + expect(exec.result.success).toBe(true); + expect(result.ok).toBe(true); + expect(JSON.stringify(result.value)).toContain("executor-process:"); + expect(JSON.stringify(result.value)).toContain(join(state.executorRoot, "project")); + expect(JSON.stringify(result.value)).not.toContain(state.main); + }); + + test("lists and reads bounded workspace data through the selected root", async () => { + const state = fixture(); + const list = responseValue(await state.coordinator.handle(state.request("list_directory", { path: "project" }))); + expect(list).toMatchObject({ ok: true, value: { path: "project" } }); + expect(JSON.stringify(list.value)).toContain("marker.txt"); + + const read = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/marker.txt", + maxBytes: 1024, + }))); + expect(read).toMatchObject({ ok: true, value: { content: "executor-before", bytes: 15 } }); + expect((read.value as { sha256: string }).sha256).toBe(sha256("executor-before")); + }); + + test("does not read an unbounded existing file while checking a write precondition", async () => { + const state = fixture(); + writeFileSync( + join(state.executorRoot, "project", "oversized.txt"), + Buffer.alloc(REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES + 1), + ); + const write = responseValue(await state.coordinator.handle(state.request("write_file", { + path: "project/oversized.txt", + content: "replacement", + expectedSha256: "0".repeat(64), + }))); + expect(write.ok).toBe(false); + expect(write.error).toContain("read limit"); + }); + + test("rejects traversal and symlink escapes on the executor", async () => { + const state = fixture(); + const traversal = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "../main/project/marker.txt", + }))); + expect(traversal.ok).toBe(false); + expect(traversal.error).toContain("escapes"); + + symlinkSync( + join(state.main, "project"), + join(state.executorRoot, "outside-link"), + process.platform === "win32" ? "junction" : "dir", + ); + const symlink = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "outside-link/marker.txt", + }))); + expect(symlink.ok).toBe(false); + expect(symlink.error).toContain("symlink"); + expect(readFileSync(join(state.main, "project", "marker.txt"), "utf8")).toBe("main-only"); + }); + + test("rejects hardlink aliases for both file reads and writes", async () => { + const state = fixture(); + const outside = join(state.main, "project", "marker.txt"); + linkSync(outside, join(state.executorRoot, "project", "outside-alias.txt")); + const read = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/outside-alias.txt", + }))); + expect(read.ok).toBe(false); + expect(read.error).toContain("hard-linked"); + + const write = responseValue(await state.coordinator.handle(state.request("write_file", { + path: "project/outside-alias.txt", + content: "escaped", + expectedSha256: sha256("main-only"), + }))); + expect(write.ok).toBe(false); + expect(write.error).toContain("hard-linked"); + expect(readFileSync(outside, "utf8")).toBe("main-only"); + }); + + test("rejects a workspace root replaced after local approval", async () => { + const state = fixture(); + renameSync(state.executorRoot, `${state.executorRoot}-approved`); + mkdirSync(join(state.executorRoot, "project"), { recursive: true }); + writeFileSync(join(state.executorRoot, "project", "marker.txt"), "replacement-root"); + const result = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/marker.txt", + }))); + expect(result.ok).toBe(false); + expect(result.error).toContain("root identity changed"); + }); + + test("fails closed while the selected executor is offline and never invokes another path", async () => { + const state = fixture(); + state.setOnline(false); + const response = await state.coordinator.handle(state.request("exec", { + command: ["/bin/true"], + })); + expect(response.result.success).toBe(false); + expect(responseValue(response).error).toContain("local fallback is disabled"); + expect(state.invokeCount()).toBe(0); + }); + + test("keeps command execution disabled by default until an OS sandbox is supplied", async () => { + const state = fixture(); + const locked = new RemoteWorkspaceExecutor({ + deviceId: "locked-device", + roots: [{ id: "project-root", path: state.executorRoot }], + }); + const result = await locked.invoke({ + requestId: randomUUID(), + sessionId: randomUUID(), + executorDeviceId: "locked-device", + rootId: "project-root", + tool: "exec", + arguments: { command: ["/bin/true"] }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("OS sandbox"); + }); + + test("rejects unbound threads and non-remote namespaces before transport", async () => { + const state = fixture(); + const unbound = state.request("read_file", { path: "project/marker.txt" }); + (unbound.params as Record).threadId = `other-${randomUUID()}`; + expect(responseValue(await state.coordinator.handle(unbound)).error).toContain("not bound"); + + const wrongNamespace = state.request("read_file", { path: "project/marker.txt" }); + (wrongNamespace.params as Record).namespace = "local_workspace"; + expect(responseValue(await state.coordinator.handle(wrongNamespace)).error).toContain("identity"); + expect(state.invokeCount()).toBe(0); + }); + + test("carries coordinator requests and executor results over the authenticated E2EE channel", async () => { + const state = fixture(); + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const cryptoDeviceId = randomUUID(); + const cryptoSessionId = randomUUID(); + const clientHandshake = RemoteControlClientHandshake.create({ + sessionId: cryptoSessionId, + deviceId: cryptoDeviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + accountPrivateKey: account.privateKey, + }); + const accepted = acceptRemoteControlClientHello(clientHandshake.hello, { + expectedSessionId: cryptoSessionId, + expectedDeviceId: cryptoDeviceId, + accountPublicKey: account.publicKey, + devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write", "workspace.exec"], + }); + const clientCipher = clientHandshake.complete(accepted.hello, device.publicKey); + + let client: EncryptedRemoteWorkspaceTransport; + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + client = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: `device-${cryptoDeviceId}`, + cipher: clientCipher, + sendCiphertext: value => endpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + const encryptedExecutor = new RemoteWorkspaceExecutor({ + deviceId: `device-${cryptoDeviceId}`, + roots: [{ id: "project-root", path: state.executorRoot }], + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: `device-${cryptoDeviceId}`, + sessionId: cryptoSessionId, + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write"], + cipher: accepted.cipher, + executor: encryptedExecutor, + sendCiphertext: value => client.receiveCiphertext(value), + }); + + const result = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "read_file", + arguments: { path: "project/marker.txt" }, + }); + expect(result).toMatchObject({ ok: true, value: { content: "executor-before" } }); + expect(JSON.stringify(result)).not.toContain(state.main); + client.close(); + }); + + test("fragments large writes and reads without raising the relay frame memory limit", async () => { + const state = fixture(); + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const cryptoDeviceId = randomUUID(); + const cryptoSessionId = randomUUID(); + const clientHandshake = RemoteControlClientHandshake.create({ + sessionId: cryptoSessionId, + deviceId: cryptoDeviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write"], + accountPrivateKey: account.privateKey, + }); + const accepted = acceptRemoteControlClientHello(clientHandshake.hello, { + expectedSessionId: cryptoSessionId, + expectedDeviceId: cryptoDeviceId, + accountPublicKey: account.publicKey, + devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write"], + }); + const clientCipher = clientHandshake.complete(accepted.hello, device.publicKey); + const content = "remote-fragment\n".repeat(10_000); + + let client: EncryptedRemoteWorkspaceTransport; + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + client = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: `device-${cryptoDeviceId}`, + cipher: clientCipher, + sendCiphertext: value => endpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: `device-${cryptoDeviceId}`, + sessionId: cryptoSessionId, + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write"], + cipher: accepted.cipher, + executor: new RemoteWorkspaceExecutor({ + deviceId: `device-${cryptoDeviceId}`, + roots: [{ id: "project-root", path: state.executorRoot }], + }), + sendCiphertext: value => client.receiveCiphertext(value), + }); + + const write = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "write_file", + arguments: { path: "project/large.txt", content, expectedSha256: null }, + }); + expect(write).toMatchObject({ ok: true, value: { bytes: Buffer.byteLength(content) } }); + const read = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "read_file", + arguments: { path: "project/large.txt", maxBytes: REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES }, + }); + expect(read).toMatchObject({ ok: true, value: { content } }); + client.close(); + endpoint.close(); + }); +}); diff --git a/tests/fake-codex-server.ts b/tests/fake-codex-server.ts index dc71863a1b..e77a763d45 100644 --- a/tests/fake-codex-server.ts +++ b/tests/fake-codex-server.ts @@ -177,6 +177,10 @@ async function handleMessage(msg: Record): Promise { return; } switch (method) { + case "config/read": { + respond(id, { config: {}, origins: {}, layers: null }); + return; + } case "thread/start": { if (script.rejectThreadStart) { respondError(id, script.rejectThreadStart); diff --git a/tests/fixtures/fake-claude-stream.ts b/tests/fixtures/fake-claude-stream.ts new file mode 100644 index 0000000000..3f1c7bb9f3 --- /dev/null +++ b/tests/fixtures/fake-claude-stream.ts @@ -0,0 +1,8 @@ +let input = ""; +for await (const chunk of Bun.stdin.stream()) input += new TextDecoder().decode(chunk); +const text = input.trim() ? `Hub answer: ${input.trim()}` : "Hub answer"; +process.stdout.write(`${JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text }] }, +})}\n`); +process.stdout.write(`${JSON.stringify({ type: "result", is_error: false, result: text })}\n`); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6e98e236f0..efaa7923a8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -886,6 +886,22 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", "remote-control-prototype.test.ts": "clients", "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", From 59ec04b907b56a324971f23fd5350795f3039021 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:00:49 +0900 Subject: [PATCH 016/224] fix(cursor): preserve first overflow and bound stable-thread remints Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 8 + scripts/test-layout/layout.json | 1 + src/adapters/cursor.ts | 176 ++++++---- src/adapters/cursor/cursor-errors.ts | 12 + src/adapters/cursor/thread-continuity.ts | 87 +++++ structure/providers/cursor.md | 4 + tests/fixtures/test-layout-expected.json | 1 + tests/providers/cursor/cursor-adapter.test.ts | 327 ++++++++++++++++++ .../cursor-continuity-retention.test.ts | 27 ++ 9 files changed, 570 insertions(+), 73 deletions(-) create mode 100644 tests/providers/cursor/cursor-continuity-retention.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..865cb63834 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,14 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Cursor context overflow + +Cursor's first bare context overflow is surfaced to the client. Later eligible requests +with a stable client thread may recover with up to three conversation remints per retained +scope. The in-memory allowance expires after one idle hour, eviction, or restart. Requests +without a stable thread, tool-result resumes, partial output, compaction and quota errors do +not use this recovery. This does not infer whether a task is making progress. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7241f26266..8a40a91e13 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -538,6 +538,7 @@ "crash-guard.test.ts": "service", "credential-redirect-guard.test.ts": "lib", "cursor-adapter.test.ts": "providers/cursor", + "cursor-continuity-retention.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 7382c25d8c..a240ae1982 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,7 +3,7 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; @@ -31,7 +31,14 @@ import { debugProviderDiagnostic } from "../lib/debug"; import { isDebugEnabled } from "../lib/debug-settings"; import { createAdapterTierMetadata } from "../providers/fastwire"; import { estimateTokens } from "../lib/token-estimate"; -import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; +import { + cursorOverflowRemintScopeKey, + markCursorOverflowSurfaced, + recordCursorOverflowRemint, + rememberCursorThreadConversation, + shouldSkipCursorOverflowRemint, + shouldSurfaceCursorOverflowFirst, +} from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { cursorRequestHasShellAlias, cursorRequestUsesCodeMode } from "./cursor/tool-definitions"; import { @@ -399,84 +406,107 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ); }; - try { - await runOnce(request); - } catch (err) { - const outputGuardRetryText = - err instanceof CursorToolResultEchoError - ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT - : err instanceof CursorRoutingCommentaryError - ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT - : undefined; - // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11). - // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation - // retry is safe. A second rejection propagates as an error rather than looping. - if ( - outputGuardRetryText - && !emittedOutput - && !replayUnsafe - && !incoming.abortSignal?.aborted - ) { - debugProviderDiagnostic( - "cursor", - err instanceof CursorToolResultEchoError - ? "envelope-echo-retry" - : "routing-commentary-retry", - { - wireModel: request.modelId, - conversationHash: request.conversationId.slice(0, 16), - }, + const remintConversationId = (failedConversationId: string) => { + lastTransport = undefined; + _parsed._cursorConversationId = undefined; + const next = createCursorRequest(_parsed, { forceFreshConversation: true }); + rekeyContextUsage(failedConversationId, next.conversationId); + _parsed._cursorConversationId = next.conversationId; + // Persist recovery for store:false clients that send any stable Cursor thread owner, so + // the next turn does not recompute the stale deterministic thread hash. Isolated helper / + // compaction turns must not park their throwaway id under the parent or Desktop owner. + const threadOwner = cursorClientThreadOwner(_parsed); + if (threadOwner && _parsed._cursorIsolateConversation !== true) { + rememberCursorThreadConversation( + threadOwner, + next.conversationId, + _parsed._cursorIdentityScope, ); - const echoedConversationId = request.conversationId; - lastTransport = undefined; - _parsed._cursorConversationId = undefined; - request = { - ...createCursorRequest(_parsed, { forceFreshConversation: true }), - echoRetryContinuationText: outputGuardRetryText, - }; - rekeyContextUsage(echoedConversationId, request.conversationId); - _parsed._cursorConversationId = request.conversationId; - const echoThreadOwner = cursorClientThreadOwner(_parsed); - if (echoThreadOwner && _parsed._cursorIsolateConversation !== true) { - rememberCursorThreadConversation( - echoThreadOwner, - request.conversationId, - _parsed._cursorIdentityScope, - ); - } + } + return next; + }; + + for (;;) { + try { await runOnce(request); - } else { - // One-shot fallback for external-model Connect invalid_argument before any - // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result - // resumes, local exec/MCP side effects, and already-emitted output fail closed. + break; + } catch (err) { + const outputGuardRetryText = + err instanceof CursorToolResultEchoError + ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT + : err instanceof CursorRoutingCommentaryError + ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT + : undefined; + // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11). + // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation + // retry is safe. A second rejection propagates as an error rather than looping. if ( - !isCursorInvalidArgumentError(err) - || !isCursorExternalWireModel(request.modelId) - || lastRawIsToolResult - || emittedOutput - || replayUnsafe - || incoming.abortSignal?.aborted + outputGuardRetryText + && !emittedOutput + && !replayUnsafe + && !incoming.abortSignal?.aborted ) { - throw err; - } - const failedConversationId = request.conversationId; - lastTransport = undefined; - _parsed._cursorConversationId = undefined; - request = createCursorRequest(_parsed, { forceFreshConversation: true }); - rekeyContextUsage(failedConversationId, request.conversationId); - _parsed._cursorConversationId = request.conversationId; - // Persist recovery for store:false clients that send any stable Cursor thread owner, so - // the next turn does not recompute the stale deterministic thread hash. Isolated helper / - // compaction turns must not park their throwaway id under the parent or Desktop owner. - const threadOwner = cursorClientThreadOwner(_parsed); - if (threadOwner && _parsed._cursorIsolateConversation !== true) { - rememberCursorThreadConversation( - threadOwner, - request.conversationId, + debugProviderDiagnostic( + "cursor", + err instanceof CursorToolResultEchoError + ? "envelope-echo-retry" + : "routing-commentary-retry", + { + wireModel: request.modelId, + conversationHash: request.conversationId.slice(0, 16), + }, + ); + const echoedConversationId = request.conversationId; + request = { + ...remintConversationId(echoedConversationId), + echoRetryContinuationText: outputGuardRetryText, + }; + await runOnce(request); + break; + } else { + const overflowRemintSafe = + !lastRawIsToolResult + && !emittedOutput + && !replayUnsafe + && request.contextUsageStoreCheckpoints !== false + && !incoming.abortSignal?.aborted; + const overflowScopeKey = cursorOverflowRemintScopeKey( + cursorClientThreadOwner(_parsed), _parsed._cursorIdentityScope, ); + if ( + overflowScopeKey + && overflowRemintSafe + && isCursorOverflowRemintCandidate(err, requestSizeContext) + ) { + if (shouldSkipCursorOverflowRemint(overflowScopeKey)) throw err; + if (shouldSurfaceCursorOverflowFirst(overflowScopeKey)) { + markCursorOverflowSurfaced(overflowScopeKey); + throw err; + } + if (!recordCursorOverflowRemint(overflowScopeKey)) throw err; + if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef); + request = remintConversationId(request.conversationId); + continue; + } + + // One-shot fallback for external-model Connect invalid_argument before any + // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result + // resumes, local exec/MCP side effects, and already-emitted output fail closed. + if ( + !isCursorInvalidArgumentError(err) + || !isCursorExternalWireModel(request.modelId) + || lastRawIsToolResult + || emittedOutput + || replayUnsafe + || incoming.abortSignal?.aborted + ) { + throw err; + } + request = remintConversationId(request.conversationId); + await runOnce(request); + break; } - await runOnce(request); } } if ( diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index b7005db3c2..fba44b2f99 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -191,6 +191,18 @@ function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean { return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow; } +/** + * True when a transport error is the bare 0-token resource_exhausted overflow shape + * (not quota/rate) that should surface for Codex compact or remint on later hits. + */ +export function isCursorOverflowRemintCandidate(err: unknown, sizeContext?: CursorSizeContext): boolean { + const message = errorMessage(err); + if (!message) return false; + const lower = message.toLowerCase(); + if (!isCursorZeroTokenResourceExhausted(lower)) return false; + return classifyCursorError(message, sizeContext) === "Cursor context limit exceeded"; +} + export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean { if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false; // Any explicit quota/rate cue wins: this is a real 429. diff --git a/src/adapters/cursor/thread-continuity.ts b/src/adapters/cursor/thread-continuity.ts index 6cb0e2cf35..aa3c3dac32 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -65,3 +65,90 @@ export function lookupCursorThreadConversation( export function clearCursorThreadContinuityForTests(): void { overrides.clear(); } + +/** Max conversation-id remints after the first surfaced overflow per retained scope. */ +export const CURSOR_OVERFLOW_REMINT_MAX = 3; +export const CURSOR_OVERFLOW_REMINT_TTL_MS = 60 * 60 * 1000; +export const CURSOR_OVERFLOW_REMINT_MAX_ENTRIES = 2_048; + +type OverflowRemintState = { + surfaced: boolean; + remintCount: number; + skip: boolean; + updatedAt: number; +}; + +const overflowRemintByScope = new Map(); + +function pruneOverflowRemints(at: number): void { + for (const [scopeKey, entry] of overflowRemintByScope) { + if (at - entry.updatedAt > CURSOR_OVERFLOW_REMINT_TTL_MS) overflowRemintByScope.delete(scopeKey); + } + while (overflowRemintByScope.size > CURSOR_OVERFLOW_REMINT_MAX_ENTRIES) { + const oldest = overflowRemintByScope.keys().next().value; + if (oldest === undefined) break; + overflowRemintByScope.delete(oldest); + } +} + +function overflowRemintEntry(scopeKey: string): OverflowRemintState { + const at = now(); + pruneOverflowRemints(at); + const existing = overflowRemintByScope.get(scopeKey); + if (existing) { + existing.updatedAt = at; + overflowRemintByScope.delete(scopeKey); + overflowRemintByScope.set(scopeKey, existing); + return existing; + } + const fresh: OverflowRemintState = { surfaced: false, remintCount: 0, skip: false, updatedAt: at }; + overflowRemintByScope.set(scopeKey, fresh); + pruneOverflowRemints(at); + return fresh; +} + +/** Stable client-thread ownership survives conversation remints; wire ids alone do not. */ +export function cursorOverflowRemintScopeKey( + threadOwner: string | undefined, + identityScope?: string, +): string | null { + if (!threadOwner) return null; + return `overflow\0${cursorThreadScopeKey(threadOwner, identityScope)}`; +} + +/** True until the first overflow for this scope has been surfaced for Codex compact. */ +export function shouldSurfaceCursorOverflowFirst(scopeKey: string): boolean { + pruneOverflowRemints(now()); + return overflowRemintByScope.get(scopeKey)?.surfaced !== true; +} + +export function markCursorOverflowSurfaced(scopeKey: string): void { + const entry = overflowRemintEntry(scopeKey); + entry.surfaced = true; +} + +export function shouldSkipCursorOverflowRemint(scopeKey: string): boolean { + pruneOverflowRemints(now()); + const entry = overflowRemintByScope.get(scopeKey); + return entry?.skip === true || (entry?.remintCount ?? 0) >= CURSOR_OVERFLOW_REMINT_MAX; +} + +/** Record one overflow remint; returns false when the cap is exhausted. */ +export function recordCursorOverflowRemint(scopeKey: string): boolean { + const entry = overflowRemintEntry(scopeKey); + if (entry.skip || entry.remintCount >= CURSOR_OVERFLOW_REMINT_MAX) { + entry.skip = true; + return false; + } + entry.remintCount += 1; + return true; +} + +export function clearCursorOverflowRemintForTests(): void { + overflowRemintByScope.clear(); +} + +export function cursorOverflowRemintCountForTests(): number { + pruneOverflowRemints(now()); + return overflowRemintByScope.size; +} diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index be42793e0b..3d734a33ed 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -82,3 +82,7 @@ constraints cannot widen the canonical shape. Bare shell bridge names are reject on the freeform path. Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in `tests/providers/cursor/cursor-tool-definitions.test.ts`. + +## Overflow remint boundary + +`src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects and compaction remain fail-closed. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 62724ffed2..cf071d04fc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -373,6 +373,7 @@ "crash-guard.test.ts": "service", "credential-redirect-guard.test.ts": "lib", "cursor-adapter.test.ts": "providers/cursor", + "cursor-continuity-retention.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index a86df7c243..82049b83f4 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -4,6 +4,7 @@ import { cursorExecDeniedMessage, } from "../../../src/adapters/cursor"; import { + clearCursorOverflowRemintForTests, clearCursorThreadContinuityForTests, lookupCursorThreadConversation, } from "../../../src/adapters/cursor/thread-continuity"; @@ -817,3 +818,329 @@ describe("Cursor adapter live transport", () => { clearCursorCheckpointsForTests(); }); }); +const LARGE_OVERFLOW_CONTENT = "word ".repeat(100_000); + +function bareOverflowError(): Error { + return Object.assign( + new Error("Cursor context limit exceeded: Cursor Connect error resource_exhausted: Error"), + { code: "resource_exhausted" }, + ); +} + +function overflowTurnBody(threadId?: string): OcxParsedRequest { + return { + modelId: "cursor/auto", + context: { messages: [{ role: "user", content: LARGE_OVERFLOW_CONTENT, timestamp: 1 }] }, + stream: false, + options: {}, + _cursorIdentityScope: "acct-overflow-remint", + ...(threadId ? { _clientThreadId: threadId } : { _cursorConversationId: "cursor_overflow_base" }), + }; +} + +describe("Cursor overflow conversation remint", () => { + test("first bare overflow surfaces without reminting the conversation id", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-surface-first"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(seen).toHaveLength(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor context limit exceeded"), + }); + }); + + test("second overflow remints and persists thread override", async () => { + clearCursorOverflowRemintForTests(); + clearCursorThreadContinuityForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + if (attempts === 1) { + throw bareOverflowError(); + } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + + const threadId = "overflow-remint-thread"; + const body = overflowTurnBody(threadId); + + const surfaceEvents: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => surfaceEvents.push(event)); + expect(attempts).toBe(1); + expect(surfaceEvents.some(event => event.type === "error")).toBe(true); + + seen.length = 0; + attempts = 0; + const remintEvents: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => remintEvents.push(event)); + + expect(attempts).toBe(2); + expect(seen).toHaveLength(2); + expect(seen[1]).not.toBe(seen[0]); + expect(remintEvents.some(event => event.type === "done")).toBe(true); + expect(lookupCursorThreadConversation(threadId, "acct-overflow-remint")).toBe(seen[1]); + expect(body._cursorConversationId).toBe(seen[1]); + }); + + test("fourth overflow skips remint after surface-first and three remints", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-cap-skip"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + + attempts = 0; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(4); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor context limit exceeded"), + }); + }); + + test("quota-cue resource_exhausted does not remint and surfaces as rate limit", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw Object.assign( + new Error("Cursor rate limit exceeded: resource_exhausted: too many requests"), + { code: "resource_exhausted" }, + ); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-quota-cue"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor rate limit exceeded"), + }); + }); + + test("does not overflow-remint on tool-result resumes", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body: OcxParsedRequest = { + modelId: "cursor/auto", + context: { + messages: [ + { role: "user", content: LARGE_OVERFLOW_CONTENT, timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "read_file", namespace: "mcp__fs", arguments: { path: "a.txt" } }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read_file", + toolNamespace: "mcp__fs", + content: "FILE CONTENTS HERE", + isError: false, + timestamp: 3, + }, + ], + }, + stream: false, + options: {}, + _cursorConversationId: "cursor_overflow_tool", + _cursorIdentityScope: "acct-overflow-remint", + }; + + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + }); + + test("does not overflow-remint compaction turns", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-compaction"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + attempts = 0; + seen.length = 0; + body._compactionRequest = true; + body._cursorIsolateConversation = true; + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + + expect(attempts).toBe(1); + expect(seen).toHaveLength(1); + }); + + test("does not overflow-remint after non-heartbeat output was emitted", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + yield { type: "text", text: "partial" } satisfies CursorServerMessage; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-after-output"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(events.some(event => event.type === "text_delta")).toBe(true); + expect(events.some(event => event.type === "error")).toBe(true); + }); +}); + + +describe("Cursor overflow accounting across requests", () => { + for (const ownerField of ["_clientThreadId", "_cursorClientThreadId"] as const) { + test(`${ownerField} retains the cap across successful remints`, async () => { + clearCursorOverflowRemintForTests(); + clearCursorThreadContinuityForTests(); + let attempts = 0; + let failNext = true; + const seen: string[] = []; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run(request) { + attempts++; + seen.push(request.conversationId); + if (failNext) { failNext = false; throw bareOverflowError(); } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + const body = () => { + const parsed = overflowTurnBody(); + parsed._cursorConversationId = undefined; + parsed[ownerField] = `cross-request-${ownerField}`; + return parsed; + }; + await adapter.runTurn?.(body(), { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + for (let remint = 0; remint < 3; remint++) { + failNext = true; + const before = attempts; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); + expect(attempts - before).toBe(2); + expect(seen[seen.length - 1]).not.toBe(seen[seen.length - 2]); + expect(events.some(event => event.type === "done")).toBe(true); + } + failNext = true; + const before = attempts; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); + expect(attempts - before).toBe(1); + expect(events.some(event => event.type === "error")).toBe(true); + }); + } + test("conversation-only clients never gain an automatic remint allowance", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run() { attempts++; throw bareOverflowError(); }, + writeClient() {}, + }), + }); + for (let turn = 0; turn < 3; turn++) { + const events: AdapterEvent[] = []; + await adapter.runTurn?.(overflowTurnBody(), { headers: new Headers() }, event => events.push(event)); + expect(attempts).toBe(turn + 1); + expect(events.some(event => event.type === "error")).toBe(true); + } + }); +}); diff --git a/tests/providers/cursor/cursor-continuity-retention.test.ts b/tests/providers/cursor/cursor-continuity-retention.test.ts new file mode 100644 index 0000000000..2d3f833e8f --- /dev/null +++ b/tests/providers/cursor/cursor-continuity-retention.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + clearCursorOverflowRemintForTests, + CURSOR_OVERFLOW_REMINT_MAX_ENTRIES, + cursorOverflowRemintCountForTests, + markCursorOverflowSurfaced, + shouldSkipCursorOverflowRemint, + shouldSurfaceCursorOverflowFirst, +} from "../../../src/adapters/cursor/thread-continuity"; + +describe("Cursor overflow remint retention", () => { + test("bounds per-scope state", () => { + clearCursorOverflowRemintForTests(); + for (let index = 0; index < CURSOR_OVERFLOW_REMINT_MAX_ENTRIES + 20; index++) { + markCursorOverflowSurfaced(`scope-${index}`); + } + expect(cursorOverflowRemintCountForTests()).toBe(CURSOR_OVERFLOW_REMINT_MAX_ENTRIES); + clearCursorOverflowRemintForTests(); + }); + + test("read-only checks do not allocate retention entries", () => { + clearCursorOverflowRemintForTests(); + expect(shouldSurfaceCursorOverflowFirst("missing")).toBe(true); + expect(shouldSkipCursorOverflowRemint("missing")).toBe(false); + expect(cursorOverflowRemintCountForTests()).toBe(0); + }); +}); From a3182185f0e089504d72e5729e4674cf0dc07ea1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:09 +0900 Subject: [PATCH 017/224] style(remote): remove trailing blank line in runner --- src/remote-control/workspace-command-runner.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/remote-control/workspace-command-runner.ts b/src/remote-control/workspace-command-runner.ts index f9a3625caa..1b2fe74628 100644 --- a/src/remote-control/workspace-command-runner.ts +++ b/src/remote-control/workspace-command-runner.ts @@ -746,4 +746,3 @@ export function linuxRemoteWorkspaceCommandRunnerAvailable( availabilityCache.set(cacheKey, available); return available; } - From b2d239ea172f92d65bab206d3301931eb3b9bfd9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:25 +0900 Subject: [PATCH 018/224] fix: bound multipart encrypted agent task recovery Preserve ordered whole-token parts and input identity through one admitted recovery request. Detect split-token structure without authorizing reconstruction. Local tests NOT RUN by maintainer instruction; hosted CI follows. --- devlog/_plan/260912_v2_contracts/000_plan.md | 26 ++++ .../260912_v2_contracts/010_plaintext.md | 38 ++++++ .../_plan/260912_v2_contracts/020_recovery.md | 50 ++++++++ .../260912_v2_contracts/030_verification.md | 9 ++ .../docs/reference/configuration/agents.md | 13 ++ src/server/responses/agent-task-recovery.ts | 57 ++++----- src/server/responses/encrypted-payload.ts | 47 +++++++- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 14 +++ structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + .../agent-task-recovery-security.test.ts | 4 +- tests/server/agent-task-recovery.test.ts | 114 ++++++++++++++++++ 23 files changed, 362 insertions(+), 36 deletions(-) create mode 100644 devlog/_plan/260912_v2_contracts/000_plan.md create mode 100644 devlog/_plan/260912_v2_contracts/010_plaintext.md create mode 100644 devlog/_plan/260912_v2_contracts/020_recovery.md create mode 100644 devlog/_plan/260912_v2_contracts/030_verification.md diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_plan/260912_v2_contracts/000_plan.md new file mode 100644 index 0000000000..5ba4c2022d --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/000_plan.md @@ -0,0 +1,26 @@ +# V2 delegation contracts + +This unit reconciles plaintext prevention (#2495) separately from encrypted task recovery (#3661). Eligible native parents may opt into plaintext V2 calls; recovery continues to use its existing authenticated, bounded path. The replacement candidates #4242/#4243 are compared against the exact issue contract before any adoption. + +Loop: satisfy-spec, triggered by the authorized v2 lane. Goal: scoped carry PRs and final cumulative hosted CI evidence. Non-goals: merges, issue closure, releases, installed service/config changes, native GitHub stacks, local product tests/build/typecheck/install. Local tests are NOT RUN by explicit instruction. Verification: source/diff checks during each cycle; Cross-platform CI on the final published head, with run IDs and conclusions retained. Stop: implementation, audit and CI evidence handed to the integration owner; no claim of integration. Outcomes: DONE with evidence, or an explicit unresolved acceptance/gate. Artifacts: this unit plus ignored `.tmp/v2/` evidence. Escalation: real tool denials and unresolved security/contract blockers are recorded; no new access/settings. Resource bounds: available account/tool permissions, this worktree only, no user token/time/agent-count cap. + +| Cycle | Outcome | Design | +|---|---|---| +| wp0 | Docs-only roadmap locked by independent design reflection and A review | this document | +| wp1 | Exact plaintext request/response contract and regression coverage | [010](010_plaintext.md) | +| wp2 | Bounded encrypted envelope handling and residual disposition | [020](020_recovery.md) | +| wp3 | Final cumulative hosted verification and durable handoff | [030](030_verification.md) | + +wp1 and wp2 are distinct capabilities; execution order does not itself create a PR dependency. Use independent dev-based PRs if neither consumes the other's changes. A shared final cumulative verification branch may be needed to prove composition; do not silently call intermediate CI final-tip evidence. + +Existing owners: `src/adapters/openai-responses.ts`, `src/server/responses/core.ts`, `src/server/responses/agent-task-recovery.ts`; tests remain under domain directories. Source-of-truth pages are mapped by `structure/INDEX.md`. Reuse these owners, not a second server/recovery subsystem. Do-nothing/config-only alternatives cannot provide the missing wire behavior. + +Generic supported inherited-model subagents provide independent design consultation and separate review. Native architect selection is unavailable and is not claimed. Original contributor attribution follows the adopted source, including Sigurd-git for #2496 and SB Yoon if any #4242 code is carried. Source PRs/issues remain open or closed in their current state until the integration owner decides. + +## Cycle record + +wp0: P entered with own session binding; roadmap in progress. Product validation NOT RUN. + +wp0 A: Gauss GO-WITH-FIXES (blockers=0); WP1-A01 cache ordering and WP2-A01 fragment owner folded into decade docs. Pasteur reflection ALIGNED; generic inherited-model consultation, native architect not selected. + +wp0 check correction: initial D was refused because the roadmap task had not yet been marked done. The subsequent P command re-entered planning; no completed cycle is claimed for that attempt. Re-audit retains the unchanged independent verdict, and a fresh docs-only B/C/D closes the actual cycle after recording its task outcome. diff --git a/devlog/_plan/260912_v2_contracts/010_plaintext.md b/devlog/_plan/260912_v2_contracts/010_plaintext.md new file mode 100644 index 0000000000..4551da65cb --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/010_plaintext.md @@ -0,0 +1,38 @@ +# Plaintext V2 prevention + +Class C4 public wire/retention boundary; consumes wp0. Source proposal: #2496 at 1a4cb4aab14200ec2efa71aea00d2a55fc90aca7. Exact public patch is the starting implementation specification, ported to current owners below. #4242 and #4243 are alternatives, not automatically dependencies. + +| Action | Path | Before → after | +|---|---|---| +| NEW | `src/responses/plaintext-v2-agent-messages.ts` | no explicit canonical exception → #2496 request compiler and bounded restoration helper, corrected by D2–D4 below | +| MODIFY | `src/types/config.ts`, `src/config.ts` | absent flag → optional `plaintextV2AgentMessages?: boolean`, unset default; malformed reads drop only field, candidate writes reject | +| MODIFY | `src/types/request.ts` | absent route marker → optional request-local `_plaintextV2AgentMessages` | +| MODIFY | `src/adapters/base.ts`, `src/adapters/openai-responses.ts` | no alias capabilities → adapter-produced request-owned tool-name sets after canonical opt-in rewrite | +| MODIFY | `src/server/responses/core.ts` | direct native passthrough → final-route opt-in preparation, alias metadata refreshed after each build, restoration before client/cache on every JSON/SSE/WS path | +| MODIFY | `src/server/index.ts` | recovery-only warning → separate opt-in plaintext retention warning | +| NEW | `tests/responses/plaintext-v2-agent-messages.test.ts`, `tests/server/plaintext-v2-agent-messages-server.test.ts` | absent → port #2496 tests and add refusal/collision cases | +| MODIFY | `tests/server/config.test.ts`, `tests/server/agent-task-recovery.test.ts`, `tests/responses/ws-upstream.test.ts` | existing adjacent contracts → port applicable #2496 regression deltas | +| MODIFY | `scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json` | no new tests → register both new domain paths | +| MODIFY | English and zh-cn `guides/sub-agent-surface.md`, `reference/configuration/agents.md` under `docs-site/src/content/docs/` | recovery/V1 alternatives → config-only experimental plaintext contract and retention warning | +| MODIFY | applicable `structure/` owners from INDEX | current ownership descriptions → point to canonical plaintext contract without duplicating unrelated subsystem behavior | + +D1: preserve the explicit issue option; no management toggle or routed mirror catalog. +D2: `shouldPreparePlaintextV2AgentMessages`: true only for Responses wire, final canonical ChatGPT forward destination and default top-level collaboration catalog; additional_tools-only catalogs do not activate it. +D3: `preparePlaintextV2AgentMessages`: copy-on-write namespace + three tool aliases. Only `message.encrypted === true` is removed. Scan declaration/reference identity positions, including nested catalogs and qualified alias names, before any rewrite. Refuse all on any collision; foreign namespaces stay untouched. +D4: `restorePlaintextV2AgentMessageCalls*`: restore only request-generated identity capabilities. Preserve marker `encrypted_function_args: []`. Treat malformed JSON, unknown private identities, binding conflict and >10,000 identities as refusal. Bounded JSON returns 502; streams emit response.failed; refusal has no retry and no continuation write. Refresh state per turn/build; no connection/global alias state. + +Field chain: config type → config schema/save → config load/candidate validation → final route marker → adapter body serialization and AdapterRequest metadata → response restoration. Metadata is in-process only, never serialized as response fields or persisted with previous_response_id. Startup consumes config for warning. No new public state enum. + +Activation matrix: disabled/malformed flag, noncanonical/key/Anthropic/routed parent/V1/custom namespace unchanged; true canonical declaration rewritten without input mutation; each collision location leaves whole request unchanged; known aliases restored for JSON/SSE/WS and snapshots; same aliases under foreign namespace unchanged; malformed/overlimit/conflict terminal refused and not cached; next turn disabled and concurrent requests do not inherit prior metadata. Use hosted tests only; no live-account canary is claimed. + +Guard strength: runtime explicit option + compiler/restorer are code-path controls; operator can disable the option, which selects ordinary encrypted behavior. No credential authorization is added. Residual undocumented upstream behavior and plaintext retention are documented, not described as encryption guarantees. + +Port mapping verified against current tree: old `tests/config.test.ts` is now `tests/server/config.test.ts`; old `tests/ws-upstream.test.ts` is now `tests/responses/ws-upstream.test.ts`. A `git apply --check` of supporting #2496 hunks fails at current adapter/config/startup context; manual semantic port is required, not blind cherry-pick. Pure helper and new tests can use their full public source bodies with adjusted imports. Existing test helper `repo-root.ts` supplies repository paths instead of legacy relative directory inference. + +Exact integration replacements: `refreshRoutedNamespaceToolAliases` at core line 4702 becomes `refreshRequestToolAliases`, assigning both alias sets from each AdapterRequest or fresh empty sets. All seven current callers are renamed. At core `rememberPassthroughResponseChecked`, change `const restoredResponse = normalized...` to an intermediate normalized value; run plaintext restoration and return immediately on refusal before `rememberPassthroughResponse`. In blockRewrites, insert plaintext restoration immediately after `createResponsesSnapshotBlockRewrite`, before field backfill and guard. In bounded JSON, apply restoration after `normalizeFunctionCompletionJson` and before model rewrite; a refusal short-circuits before `rememberPassthroughResponseChecked`. At unsupported passthrough fallback, cancel response body and return safe 502 whenever request alias sets are nonempty. + +A synthesis WP1-A01: early raw inspection cannot authorize continuation for plaintext turns. Disable its cache callbacks when aliases are active. Publish only from a post-restoration/post-guard client block observer, and only after shared request-local stream validation accepts the terminal. Bounded JSON caches only its final restored value. Stream malformed/conflicting/overlimit rejection permanently prevents publication. + +WP1 implementation-P revalidation: prior D locked the roadmap. Reuse `createSseInspector` for restored client blocks: append a final block observer after the alias restorer and undeclared-tool guard, feed `${block}\n\n`, and dispose with the composed rewrite. Raw inspector callbacks are suppressed only for active plaintext aliases. The existing collector reconstructs output from accepted events. This gives one validated publication path rather than parallel raw/client cache decisions. Keep the final marker-preserving restorer before this collector. Unknown private identities throw before collection; terminal-only valid complete snapshots are accepted, so there is no invented requirement for prior added frames. + +Exact source-of-truth canonical owner is `structure/subagents.md`; add concise links from the mapped affected owners `runtime.md`, `config.md`, `overview.md`, `catalog.md`, `transports/responses.md`, `transports/streaming-health.md`, `transports/inventory.md`, `data-planes/images.md`, `data-planes/inbound-compat.md`, `providers/openai-tiers.md`, `providers/cursor.md`, `providers/chat-compat.md`, `providers/kiro.md`, `providers/xai-grok.md`, `adapters/registry.md`, `gui-and-management-api.md`, `clients/claude-desktop.md`, `ops/service-and-sidecars.md`, and `ops/docs-and-release.md` where the source-area map requires same-change synchronization. Links distinguish unchanged surfaces from the canonical new contract. diff --git a/devlog/_plan/260912_v2_contracts/020_recovery.md b/devlog/_plan/260912_v2_contracts/020_recovery.md new file mode 100644 index 0000000000..7dbcb075db --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/020_recovery.md @@ -0,0 +1,50 @@ +# Encrypted envelope recovery + +Class C4 authenticated plaintext boundary; consumes roadmap and independent envelope design. #3794 diagnostics and MESSAGE support are already present. This phase preserves them and never adds automatic outage retries. + +| Action | Path | Before → after | +|---|---|---| +| MODIFY | `src/server/responses/agent-task-recovery.ts` | single encryptedIndex/ciphertext → ordered bounded part descriptors and exact envelope snapshot; single backend recovery request; atomic input revalidation before replacement | +| MODIFY | `tests/server/agent-task-recovery.test.ts` | single-part coverage → ordered multipart, invalid/ambiguous fragments, size/count cap, input mutation and cache isolation cases | +| MODIFY | `docs-site/src/content/docs/reference/configuration/agents.md` | narrow recovery description → exact supported multipart shape, no blind retries and residual fragment limitations | +| MODIFY | relevant `structure/` owners | current single-part invariant → canonical bounded envelope contract | + +D5 proposal for design audit: accept a contiguous run of complete structurally valid Fernet strings, at most 32 parts and 2 MiB combined. Keep routing header singular and author/recipient equal to sender/task. Forward original complete token parts in their order to the same fixed backend endpoint once. Partial token strings remain unsupported unless source evidence establishes an unambiguous join contract; do not infer authentication from a plausible Fernet shape. + +`AgentEnvelope` replaces encryptedIndex with an ordered part list. The cache key hashes a length-delimited serialized token array (not ambiguous string concatenation). `recoveryPayload` maps these parts into its one input message. `injectAssignment` reruns envelope parsing and compares the full admitted snapshot (header, identities, positions, all ciphertext parts) before one content splice, then removes agent routing identity fields exactly as today. Existing admission is still before every cache read. Input mutation causes input_changed and cache discard. + +Creation → serialization → consumption: parser builds ordered part descriptors; recoveryPayload emits each validated whole part; cache key binds their order and boundaries; injection validates the original current input and writes one assignment. No new stored config or failure enum is needed; unsupported_envelope remains not attempted and existing typed request failures remain attempted/capacity outcomes. + +Activation matrix: one complete part unchanged; two complete ordered parts reach exactly one mocked backend call and one plaintext replacement; swapped tokens have distinct cache identity; wrong sender/recipient/header rejected without fetch; interleaved plaintext/noncontiguous encrypted parts rejected; empty, malformed, excessive count or total bytes rejected; delayed input mutation refuses assignment; HTTP 5xx yields the existing typed reason after one call; no retry budget increase. Reuse existing helper fixtures; no test execution locally. + +Fragment disposition: this unit does not concatenate split tokens. #3661 contains no fragment association or representation evidence. The runtime's existing plaintext-in-encrypted-slot compatibility must remain. Add end-to-end regression coverage for a consecutive encrypted run whose exact concatenation is structurally one Fernet token: classify that narrowly as unreadable and refuse without recovery, while ordinary plaintext slots still normalize. If no sound discriminator is found, retain the issue residual explicitly; never claim full #3661 closure from whole-token support. + + +Concrete replacement contract: + +```ts +// AgentEnvelope +// - encryptedIndex: number; ciphertext: string; +// + encryptedStartIndex: number; ciphertexts: readonly string[]; +// + inputSnapshot: string; +// Parser: collect {index, token} only when token list has exactly one member +// and that member === raw encrypted_content. Reject missing header, +// >32 entries, >2 MiB aggregate, and nonconsecutive indexes. Capture +// JSON.stringify(item) at admission after all identity checks. +// Cache replaces .update(envelope.ciphertext) with +.update(JSON.stringify(envelope.ciphertexts)) +// Fixed recovery endpoint content replaces its single encrypted part with +...envelope.ciphertexts.map(encrypted_content => ({ + type: "encrypted_content", encrypted_content, +})) +// Injection verifies original item bytes before touching content: +if (JSON.stringify(item) !== envelope.inputSnapshot) return false; +content.splice(envelope.encryptedStartIndex, envelope.ciphertexts.length, + { type: "input_text", text: assignment }); +``` + +The snapshot is request-local and not logged/persisted. JSON request parsing is the input boundary, so getters/cycles are not supported client states. Tests use the existing Request/recovery public entrypoints, not exported parser internals. + +Reflection amendment: also MODIFY `src/server/responses/encrypted-payload.ts` only for the narrow multi-slot discriminator and MODIFY `tests/server/agent-task-recovery.test.ts` with `post()` integration assertions that recovery is not attempted and routed fetch is absent. Whole-token recovery tests remain at the recovery API. The discriminator runs before sanitization; for otherwise unreadable envelopes, matched fragments do not reach the routed provider. General malformed payload detection remains outside this claim. + +wp2 reflection synthesis: preserve only identified fragment objects during sanitization, not an entire content array. Independent plaintext slots still normalize. Fragment refusal applies only when no independent readable task text remains, retaining current mixed-content policy; mixed input is explicitly outside the refusal claim. All encrypted slots in a recovery envelope must be valid consecutive whole tokens, including malformed non-string slots (which refuse). MODIFY `tests/server/agent-task-recovery-security.test.ts`: replace formerly unsupported duplicate-whole-token fixture with a genuinely noncontiguous encrypted run; keep fragment and admission-negative coverage, add positive multipart regression separately. diff --git a/devlog/_plan/260912_v2_contracts/030_verification.md b/devlog/_plan/260912_v2_contracts/030_verification.md new file mode 100644 index 0000000000..cf5ec964fd --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/030_verification.md @@ -0,0 +1,9 @@ +# Final hosted verification and handoff + +Consumes published implementation heads. No product change is planned unless exact hosted failure or independent audit identifies a defect; then amend this design with the concrete source delta before repair. + +MODIFY this unit's cycle records with actual outcomes. MODIFY ignored `.tmp/v2/handoff.md` and NEW ignored `.tmp/v2/final-ci.json` with own branch/worktree/session, original dispositions, credit, carry PR URLs, exact heads, chain order if any, remaining issue acceptance, unresolved review/security judgments and local NOT RUN. + +Commands: `git diff --check` observes whitespace only. `gh pr view` observes live head/base/reviews. `gh run list --commit ` finds hosted runs; `gh run view --json headSha,status,conclusion,jobs,url` provides final evidence. Inspect `.github/workflows/ci.yml` or actual workflow source for full lane dispatch. Do not claim skipped/cancelled jobs passed. CI failure repairs are additional PABCD cycles when they form a separate work-phase. + +Before publish, inspect exact diff and original contributor commits; push only owned branches using `git push --no-verify`. Populate Summary/Verification/Checklist template honestly with NOT RUN local tests. No closure or merge. Capture remote PR head equality with local final SHA and final Cross-platform CI result. A source scan or receipt wrapper is not product test evidence. Independent review has a source SHA and limitations. Any live upstream canary absent remains explicit. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 24dd9dec16..c65933f0bb 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -204,6 +204,19 @@ Admission and retention are deliberately narrow: fail-closed error; client cancellation returns 499. Neither path forwards ciphertext to the routed provider. +Recovery accepts one consecutive run of up to 32 complete Fernet-shaped encrypted parts, with +at most 2 MiB of combined ciphertext. Parts retain their order and boundaries in one authenticated +request. Cache identity includes the sequence; the original input is revalidated before assignment +replacement. HTTP failures retain the existing bounded diagnostic reason and do not trigger an +internal retry. + +Split tokens are not reconstructed for recovery. A bounded run whose exact concatenation has +Fernet structure stays classified as ciphertext through plaintext-slot normalization. If the task +has no independent readable text, it fails closed without a recovery or routed-provider request. +Independent readable text retains the existing mixed-content policy. Other fragment representations +remain unsupported; this does not establish general token-split recovery or upstream multipart +fidelity. + ### Threat model This path assumes the local native Codex caller already holds a valid ChatGPT credential and that diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index a15a2563ca..8f44661d22 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -3,7 +3,7 @@ import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; import type { OcxConfig } from "../../types"; import { boundedBodyDecodeFailure, readBoundedResponseBody } from "../../lib/bounded-body"; import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors"; -import { structurallyValidFernetTokens } from "./encrypted-payload"; +import { MAX_AGENT_TASK_CIPHERTEXT_BYTES, MAX_AGENT_TASK_ENCRYPTED_PARTS, structurallyValidFernetTokens } from "./encrypted-payload"; import { cachedAgentTaskRecovery, discardCachedAgentTaskRecovery, @@ -31,7 +31,6 @@ const CODEX_ORIGINATORS = new Set([ const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const OPENAI_TOKEN_ISSUERS = new Set(["https://auth.openai.com", "https://auth.openai.com/"]); const OPENAI_TOKEN_AUDIENCE = "https://api.openai.com/v1"; -const MAX_CIPHERTEXT_BYTES = 2 * 1024 * 1024; const MAX_ASSIGNMENT_BYTES = 2 * 1024 * 1024; const MAX_RECOVERY_RESPONSE_BYTES = 4 * 1024 * 1024; const CACHE_SCOPE_KEY = randomBytes(32); @@ -73,12 +72,13 @@ export function agentTaskRecoveryConfig(config: OcxConfig): AgentTaskRecoveryOpt interface AgentEnvelope { itemIndex: number; - encryptedIndex: number; + encryptedStartIndex: number; + inputSnapshot: string; headerText: string; messageType: "NEW_TASK" | "MESSAGE"; taskName: string; sender: string; - ciphertext: string; + ciphertexts: readonly string[]; author: string; recipient: string; } @@ -107,10 +107,9 @@ function findEnvelope(input: unknown): AgentEnvelope | null { let messageType: "NEW_TASK" | "MESSAGE" | null = null; let taskName: string | null = null; let sender: string | null = null; - let encryptedIndex = -1; - let ciphertext = ""; - let encryptedPartCount = 0; - let ciphertextCount = 0; + let encryptedStartIndex = -1; + const ciphertexts: string[] = []; + let ciphertextBytes = 0; for (let index = 0; index < content.length; index += 1) { const part = content[index] as { type?: unknown; text?: unknown; encrypted_content?: unknown } | null; @@ -132,13 +131,15 @@ function findEnvelope(input: unknown): AgentEnvelope | null { sender = match[3]!; } } - if (part.type !== "encrypted_content" || typeof part.encrypted_content !== "string") continue; - encryptedPartCount += 1; - for (const token of structurallyValidFernetTokens(part.encrypted_content)) { - ciphertextCount += 1; - encryptedIndex = index; - ciphertext = token; - } + if (part.type !== "encrypted_content") continue; + if (typeof part.encrypted_content !== "string") return null; + ciphertextBytes += Buffer.byteLength(part.encrypted_content); + if (ciphertexts.length >= MAX_AGENT_TASK_ENCRYPTED_PARTS || ciphertextBytes > MAX_AGENT_TASK_CIPHERTEXT_BYTES) return null; + const tokens = structurallyValidFernetTokens(part.encrypted_content); + if (tokens.length !== 1 || tokens[0] !== part.encrypted_content) return null; + if (encryptedStartIndex < 0) encryptedStartIndex = index; + if (index !== encryptedStartIndex + ciphertexts.length) return null; + ciphertexts.push(part.encrypted_content); } if ( @@ -146,11 +147,8 @@ function findEnvelope(input: unknown): AgentEnvelope | null { || !messageType || !taskName || !sender - || encryptedIndex < 0 - || encryptedPartCount !== 1 - || ciphertextCount !== 1 - || (content[encryptedIndex] as { encrypted_content?: unknown }).encrypted_content !== ciphertext - || Buffer.byteLength(ciphertext) > MAX_CIPHERTEXT_BYTES + || encryptedStartIndex < 0 + || ciphertexts.length === 0 ) return null; const itemRecord = item as { author?: unknown; recipient?: unknown }; @@ -159,12 +157,13 @@ function findEnvelope(input: unknown): AgentEnvelope | null { return { itemIndex, - encryptedIndex, + encryptedStartIndex, + inputSnapshot: JSON.stringify(item), headerText, messageType, taskName, sender, - ciphertext, + ciphertexts, author: itemRecord.author, recipient: itemRecord.recipient, }; @@ -197,14 +196,8 @@ function injectAssignment(input: unknown, envelope: AgentEnvelope, assignment: s if (!item || typeof item !== "object") return false; const content = (item as { content?: unknown }).content; if (!Array.isArray(content)) return false; - const part = content[envelope.encryptedIndex] as { type?: unknown; encrypted_content?: unknown } | undefined; - if ( - !part - || part.type !== "encrypted_content" - || part.encrypted_content !== envelope.ciphertext - ) return false; - - content[envelope.encryptedIndex] = { type: "input_text", text: assignment }; + if (JSON.stringify(item) !== envelope.inputSnapshot) return false; + content.splice(envelope.encryptedStartIndex, envelope.ciphertexts.length, { type: "input_text", text: assignment }); const message = item as Record; message.type = "message"; message.role = "user"; @@ -313,7 +306,7 @@ function admittedRecovery( .update("\0") .update(envelope.sender) .update("\0") - .update(envelope.ciphertext) + .update(JSON.stringify(envelope.ciphertexts)) .digest("hex"); return { admitted: true, recovery: { envelope, admission, cacheKey } }; } @@ -343,7 +336,7 @@ function recoveryPayload(envelope: AgentEnvelope, model: string): string { recipient: envelope.recipient, content: [ { type: "input_text", text: envelope.headerText }, - { type: "encrypted_content", encrypted_content: envelope.ciphertext }, + ...envelope.ciphertexts.map(encrypted_content => ({ type: "encrypted_content", encrypted_content })), ], }], }); diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 3df72f769a..82d4b0514e 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -200,6 +200,45 @@ export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW // envelope stripper below. export const AGENT_MESSAGE_CONTROL_PREAMBLE = /(?:^|\n)\[CXC-[A-Z0-9-]+\][^\n]*(?:\n(?!\n|Message Type\s*:)[^\n]*)*(?=\n{2,}|\nMessage Type\s*:|$)/gi; +export const MAX_AGENT_TASK_ENCRYPTED_PARTS = 32; +export const MAX_AGENT_TASK_CIPHERTEXT_BYTES = 2 * 1024 * 1024; + +/** Detection only: joining fragments never authorizes recovery or proves authenticity. */ +function splitFernetParts(content: unknown[]): Set { + const protectedParts = new Set(); + let run: Array<{ part: object; text: string }> = []; + let bytes = 0; + let overLimit = false; + const finish = (): void => { + if (!overLimit && run.length > 1 + && run.every(({ text }) => !isStructurallyValidFernetToken(text)) + && isStructurallyValidFernetToken(run.map(({ text }) => text).join(""))) { + for (const { part } of run) protectedParts.add(part); + } + run = []; + bytes = 0; + overLimit = false; + }; + for (const part of content) { + if (!part || typeof part !== "object" || (part as { type?: unknown }).type !== "encrypted_content" + || typeof (part as { encrypted_content?: unknown }).encrypted_content !== "string") { + finish(); + continue; + } + if (overLimit) continue; + const text = (part as { encrypted_content: string }).encrypted_content; + bytes += Buffer.byteLength(text); + if (run.length >= MAX_AGENT_TASK_ENCRYPTED_PARTS || bytes > MAX_AGENT_TASK_CIPHERTEXT_BYTES) { + overLimit = true; + run = []; + continue; + } + run.push({ part, text }); + } + finish(); + return protectedParts; +} + export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { if (!Array.isArray(input)) return false; @@ -222,7 +261,8 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { const content = (item as { content?: unknown }).content; if (!Array.isArray(content)) return false; - let hasFernetTask = false; + const fragmentParts = splitFernetParts(content); + let hasFernetTask = fragmentParts.size > 0; const readableParts: string[] = []; for (const part of content) { if (!part || typeof part !== "object") continue; @@ -238,6 +278,7 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { continue; } + if (fragmentParts.has(part)) continue; const runs = fernetTokenRuns(record.encrypted_content); if (runs.length > 0) hasFernetTask = true; readableParts.push(textWithoutFernetRuns(record.encrypted_content, runs)); @@ -282,6 +323,7 @@ export function hasEncryptedContentPart(content: unknown): boolean { export function sanitizeEncryptedContentInPlace(input: unknown): number { if (!Array.isArray(input)) return 0; let rewritten = 0; + const protectedFragments = new WeakSet(); type VisitFrame = | { kind: "visit"; node: unknown } | { kind: "array"; node: unknown[]; index: number } @@ -293,6 +335,7 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number { const frame = stack.pop()!; if (frame.kind === "visit") { if (Array.isArray(frame.node)) { + for (const part of splitFernetParts(frame.node)) protectedFragments.add(part); stack.push({ kind: "array", node: frame.node, index: 0 }); } else if (frame.node && typeof frame.node === "object") { stack.push({ kind: "object", values: Object.values(frame.node), index: 0 }); @@ -309,7 +352,7 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number { && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string" ) { const payload = (child as { encrypted_content: string }).encrypted_content; - if (!looksLikeBackendCiphertext(payload)) { + if (!protectedFragments.has(child) && !looksLikeBackendCiphertext(payload)) { const parts = encryptedSlotParts(payload); frame.node.splice(frame.index, 1, ...parts); rewritten += 1; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 075037c14d..82a5afec2e 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -60,3 +60,5 @@ so the schema is not something a user can fix from configuration (issue #2673). Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/catalog.md b/structure/catalog.md index 91d7734848..ab355b1ba7 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -268,3 +268,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 4c86504a08..4c0b045a10 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -79,3 +79,5 @@ testable on any host: stubbing `process.platform` does not propagate to `os.plat Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 952f6ab06f..9ad5e11a0a 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -73,3 +73,5 @@ conflicts with `modelSupportsReasoningSummaries: false` for the same model. Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 5fbc419557..338e845b33 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -93,3 +93,5 @@ falling back to OpenCodex guesses, and the integration does not write the remove Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a860a78fc2..df97370b21 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -515,3 +515,5 @@ survives availability drift, while complete/native custom orders await explicit Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..93ebef2741 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e4d2b41958..84c8bd08a7 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -136,3 +136,5 @@ client responsibilities. Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 22105b20c8..7e378669d0 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -53,3 +53,5 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index 5f67121e3e..a2314b4d34 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -194,3 +194,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/subagents.md b/structure/subagents.md index 3f00e96302..a884df4240 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -202,3 +202,17 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +## Multipart encrypted task recovery + +`src/server/responses/agent-task-recovery.ts` admits at most 32 consecutive, individually complete +Fernet-shaped parts with a combined 2 MiB ciphertext limit. Every encrypted slot must belong to +that run. The existing credential admission precedes cache access; the cache key includes an +unambiguous ordered sequence. One fixed-endpoint request forwards separate parts, and assignment +replacement compares the complete original item snapshot before splicing the run. Recovery output +is model-transcribed plaintext, not cryptographic fidelity proof, and no internal outage retry is added. + +`src/server/responses/encrypted-payload.ts` uses bounded concatenation only to recognize otherwise +unreadable split-token shapes. The sanitizer preserves just those fragment objects and continues +normalizing independent plaintext slots. Detection never authorizes reconstruction or recovery; +other fragment layouts and mixed readable content retain their documented residual boundaries. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 66f80525ad..4982d5b09d 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -61,3 +61,5 @@ executor contract. Main-request migration must not treat that branch as fixed-tr Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 9903b7c18b..708606f5b2 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -517,3 +517,5 @@ not retried. Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index f90334d5b4..eb24afa52e 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -193,3 +193,5 @@ frame rather than always emitting `response.completed`. If the response status i Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/tests/server/agent-task-recovery-security.test.ts b/tests/server/agent-task-recovery-security.test.ts index d44c155dfb..c7a5ede55d 100644 --- a/tests/server/agent-task-recovery-security.test.ts +++ b/tests/server/agent-task-recovery-security.test.ts @@ -38,7 +38,7 @@ describe("agent task recovery security", () => { const header = { type: "input_text", text: ROUTING_ENVELOPE }; const encrypted = { type: "encrypted_content", encrypted_content: FERNET_TASK }; const inputs = [ - agentMessage([header, encrypted, encrypted]), + agentMessage([header, encrypted, { type: "input_text", text: "" }, encrypted]), agentMessage([header, { ...encrypted, encrypted_content: FERNET_TASK.slice(0, 50) }, { ...encrypted, encrypted_content: FERNET_TASK.slice(50) }]), agentMessage([{ ...header, text: ROUTING_ENVELOPE.replace("NEW_TASK", "new_task") }, encrypted]), @@ -339,7 +339,7 @@ describe("agent task recovery security", () => { throw new Error("recovery must stay unreachable"); }) as typeof fetch; const ambiguous = encryptedInput() as Array<{ content: Array> }>; - ambiguous[0]!.content.push({ type: "encrypted_content", encrypted_content: FERNET_TASK }); + ambiguous[0]!.content.push({ type: "input_text", text: "" }, { type: "encrypted_content", encrypted_content: FERNET_TASK }); const response = await post( routedConfig(), diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 939fe189eb..3be8b90432 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1,3 +1,4 @@ +import { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "../../src/server/responses/encrypted-payload"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { warnAgentTaskRecoveryStartup } from "../../src/server"; @@ -980,3 +981,116 @@ describe("mid-thread encrypted agent task recovery (#4089)", () => { expect(raw).not.toContain(FERNET_TASK); }); }); + + +describe("bounded multipart encrypted task recovery", () => { + beforeEach(() => resetAgentTaskRecoveryState()); + afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + const multipart = (tokens: string[] = [FERNET_TASK, SECOND_FERNET_TASK]) => agentMessage([ + { type: "input_text", text: ROUTING_ENVELOPE }, + ...tokens.map(encrypted_content => ({ type: "encrypted_content", encrypted_content })), + ]); + + test.each(["NEW_TASK", "MESSAGE"] as const)("recovers ordered %s parts in one request and isolates sequence caches", async messageType => { + let sends = 0; + const sent: Array<{ input: Array<{ content: Array<{ encrypted_content?: string }> }> }> = []; + globalThis.fetch = (async (_url, init) => { + sends++; + sent.push(JSON.parse(String(init?.body))); + return new Response(recoverySse("Complete multipart assignment.")); + }) as typeof fetch; + const input = () => { + const value = multipart(); + const item = value[0] as { content: Array> }; + item.content[0]!.text = ROUTING_ENVELOPE.replace("NEW_TASK", messageType); + return value; + }; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const current = input(); + expect(await recoverEncryptedAgentTaskWithResult(req, current, {}, routedConfig())).toEqual({ recovered: true }); + expect(sent[0]!.input[0]!.content.slice(1).map(part => part.encrypted_content)).toEqual([FERNET_TASK, SECOND_FERNET_TASK]); + expect(current).toEqual([{ type: "message", role: "user", content: [ + { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", messageType) }, + { type: "input_text", text: "Complete multipart assignment." }, + ] }]); + expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(1); + const reversed = input(); + (reversed[0] as { content: unknown[] }).content.splice(1, 2, + { type: "encrypted_content", encrypted_content: SECOND_FERNET_TASK }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }); + expect(restoreCachedEncryptedAgentTasks(req, reversed, routedConfig())).toBe(0); + expect(await recoverEncryptedAgentTaskWithResult(req, reversed, {}, routedConfig())).toEqual({ recovered: true }); + expect(sends).toBe(2); + discardEncryptedAgentTaskRecovery(req, input(), routedConfig()); + expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(0); + }); + + test("refuses malformed slots, nonconsecutive runs, and count/byte overflow without a fetch", async () => { + let sends = 0; + globalThis.fetch = (async () => { sends++; return new Response(recoverySse("must not run")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const tooLargeRaw = Buffer.alloc(57 + 16 * 131072, 0x5a); + tooLargeRaw[0] = 0x80; + const token = tooLargeRaw.toString("base64").replaceAll("+", "-").replaceAll("/", "_"); + const cases = [multipart(Array.from({ length: 33 }, () => FERNET_TASK)), multipart([token]), + agentMessage([{ type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, { type: "encrypted_content", encrypted_content: 123 }]), + agentMessage([{ type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, { type: "input_text", text: "" }, { type: "encrypted_content", encrypted_content: SECOND_FERNET_TASK }]), + ]; + for (const input of cases) { + const before = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: false, reason: "unsupported_envelope" }); + expect(input).toEqual(before); + } + expect(sends).toBe(0); + }); + + test("revalidates full input identity after asynchronous recovery", async () => { + let release!: (response: Response) => void; + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + globalThis.fetch = (() => { started(); return new Promise(resolve => { release = resolve; }); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const input = multipart(); + const pending = recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig()); + await ready; + (input[0] as { author: string }).author = "changed-author"; + release(new Response(recoverySse("Must not replace changed task."))); + expect(await pending).toEqual({ recovered: false, reason: "input_changed" }); + expect((input[0] as { type: string }).type).toBe("agent_message"); + expect(restoreCachedEncryptedAgentTasks(req, multipart(), routedConfig())).toBe(0); + }); + + test("split tokens remain unreadable through sanitization and never trigger recovery", async () => { + const input = multipart([FERNET_TASK.slice(0, 50), FERNET_TASK.slice(50)]); + const before = structuredClone(input); + expect(hasUnreadableEncryptedAgentTask(input)).toBe(true); + expect(sanitizeEncryptedContentInPlace(input)).toBe(0); + expect(input).toEqual(before); + expect(hasUnreadableEncryptedAgentTask(input)).toBe(true); + let sends = 0; + globalThis.fetch = (async () => { sends++; return providerResponse(); }) as typeof fetch; + const response = await post(routedConfig(), "xai/grok-4.5", input, codexHeaders()); + expect(response.status).toBe(400); + expect(await response.text()).toContain("unreadable_encrypted_agent_task"); + expect(sends).toBe(0); + }); + + test("identified fragments do not prevent independent plaintext-slot normalization", () => { + const input = multipart([FERNET_TASK.slice(0, 50), FERNET_TASK.slice(50)]); + const content = (input[0] as { content: Array> }).content; + content.push({ type: "input_text", text: "Readable task." }, { type: "encrypted_content", encrypted_content: "Independent plaintext." }); + const fragments = structuredClone(content.slice(1, 3)); + expect(hasUnreadableEncryptedAgentTask(input)).toBe(false); + expect(sanitizeEncryptedContentInPlace(input)).toBe(1); + expect(content.slice(1, 3)).toEqual(fragments); + expect(content.at(-1)).toEqual({ type: "input_text", text: "Independent plaintext." }); + }); + + test("multipart backend 503 is still one attempt with bounded diagnostics", async () => { + let sends = 0; + globalThis.fetch = (async () => { sends++; return new Response("private failure", { status: 503 }); }) as typeof fetch; + const result = await recoverEncryptedAgentTaskWithResult(new Request("http://localhost/v1/responses", { headers: codexHeaders() }), multipart(), {}, routedConfig()); + expect(result).toEqual({ recovered: false, reason: "recovery_http_rejected" }); + expect(sends).toBe(1); + }); +}); From 36625c78be4ca0ff5a94e145cdffe52a5ba9092d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:36 +0900 Subject: [PATCH 019/224] test(cursor): activate remint guards after first overflow --- tests/providers/cursor/cursor-adapter.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index 82049b83f4..1f42b697dc 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -1017,9 +1017,12 @@ describe("Cursor overflow conversation remint", () => { stream: false, options: {}, _cursorConversationId: "cursor_overflow_tool", + _clientThreadId: "overflow-tool-result", _cursorIdentityScope: "acct-overflow-remint", }; + await adapter.runTurn?.(overflowTurnBody("overflow-tool-result"), { headers: new Headers() }, () => {}); + attempts = 0; await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); expect(attempts).toBe(1); }); @@ -1057,6 +1060,7 @@ describe("Cursor overflow conversation remint", () => { test("does not overflow-remint after non-heartbeat output was emitted", async () => { clearCursorOverflowRemintForTests(); let attempts = 0; + let emitPartial = false; const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token", @@ -1064,7 +1068,7 @@ describe("Cursor overflow conversation remint", () => { createTransport: () => ({ async *run() { attempts += 1; - yield { type: "text", text: "partial" } satisfies CursorServerMessage; + if (emitPartial) yield { type: "text", text: "partial" } satisfies CursorServerMessage; throw bareOverflowError(); }, writeClient() {}, @@ -1072,6 +1076,9 @@ describe("Cursor overflow conversation remint", () => { }); const body = overflowTurnBody("overflow-after-output"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + attempts = 0; + emitPartial = true; const events: AdapterEvent[] = []; await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); @@ -1113,9 +1120,11 @@ describe("Cursor overflow accounting across requests", () => { for (let remint = 0; remint < 3; remint++) { failNext = true; const before = attempts; + const priorConversation = seen[seen.length - 1]; const events: AdapterEvent[] = []; await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); expect(attempts - before).toBe(2); + expect(seen[seen.length - 2]).toBe(priorConversation); expect(seen[seen.length - 1]).not.toBe(seen[seen.length - 2]); expect(events.some(event => event.type === "done")).toBe(true); } From e6e3b5b1d8b2f59534b16102e3a1aa012dfe6911 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:36:58 +0900 Subject: [PATCH 020/224] docs: plan thinking presentation and transport carries --- .../260912_thinking_contract/000_plan.md | 31 ++ .../260912_thinking_contract/001_sources.md | 7 + .../010_presentation.md | 316 +++++++++++++++++ .../020_transport_hint.md | 319 ++++++++++++++++++ .../260912_thinking_contract/030_spark.md | 66 ++++ .../260912_thinking_contract/040_delivery.md | 7 + 6 files changed, 746 insertions(+) create mode 100644 devlog/_plan/260912_thinking_contract/000_plan.md create mode 100644 devlog/_plan/260912_thinking_contract/001_sources.md create mode 100644 devlog/_plan/260912_thinking_contract/010_presentation.md create mode 100644 devlog/_plan/260912_thinking_contract/020_transport_hint.md create mode 100644 devlog/_plan/260912_thinking_contract/030_spark.md create mode 100644 devlog/_plan/260912_thinking_contract/040_delivery.md diff --git a/devlog/_plan/260912_thinking_contract/000_plan.md b/devlog/_plan/260912_thinking_contract/000_plan.md new file mode 100644 index 0000000000..86a87f9d36 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/000_plan.md @@ -0,0 +1,31 @@ +# Preserve reasoning provenance and transport intent + +Readers: maintainers choosing whether to integrate the thinking lane. Raw reasoning must remain content, while provider-authored summaries can be displayed under an explicit provider default. The plan reconciles #4301 and #4287, separately reviews #3652 hint suppression, and carries #4130 Spark compatibility without retirement. + +Loop: satisfy-spec HOTL, triggered by authorized thinking-lane delivery. Goal: reviewable carry PRs and final-head hosted CI. Non-goals: merges, closure of source PRs, retirement #4334, releases, user service/config changes, other worktrees. All local product suites/build/typecheck/install are NOT RUN by instruction. Only available existing credentials/tools are used; no user token/time/agent ceiling was set. Stop: every source PR has a justified disposition and every delivered branch has exact-head hosted CI evidence. Outcomes: DONE on evidence, HOLD/NEEDS_HUMAN on explicit unresolved acceptance, never fake green. Escalation: real tool denial or requirement beyond scope; main reclaims after two distinct reviewer failures. Native architect selector is unavailable; inherited independent design review and reflection follow the user instruction, with a separate A audit. + +## Dependency map + +| Cycle | Artifact | Result | +| --- | --- | --- | +| roadmap | this file and all decade docs | docs-only plan lock | +| presentation | 010_presentation.md | raw/summary contract and provider opt-in | +| hint | 020_transport_hint.md | independent transport-hint disposition/carry | +| spark | 030_spark.md | independent Spark Lite carry | +| delivery | 040_delivery.md | final heads, review closure and hosted CI | + +Presentation combines two conflicting source proposals into one contract. Hint and Spark are independent and receive ordinary dev-based PRs, not artificial stack dependencies. Final review consumes all branches. No GitHub native stacks are requested. + +## Evidence and owner map + +Baseline origin/dev: 69e3dcda755a52feb1327edad6c8ea6cefd6e871. Source PR heads: #4301 5d6d1862a11da6e4d0c04eb7f35f9f48ae1285fd; #4287 fe13bdb7bf8403a2a2cdb10f258a68b649177953; #3652 13fb263778e9036e66ae86d41e29f9f47bbbed92; #4130 5d56f5461ea3d18668b85f6bb0d8a523920f2536. All open when inspected. Original authors: Robin Bially, yxr1995-maker, itismyfield, luvs01; exact Git trailers will be read from original commits before carrying. + +Current owners: src/bridge.ts:663 raw-reasoning finalization; src/adapters/google.ts:571 shared part classifier; src/server/responses/core.ts:2490 final-route normalization; src/responses/parser.ts:543 summary omission policy; src/types/request.ts:310 AdapterEvent. Reuse these boundaries; no new event enum or generic service layer. Structure INDEX maps shared areas to topical documents; main contracts are providers/chat-compat.md, providers/google.md, transports/responses.md and config.md with references from affected area owners. + +Verification: git diff --check was run at baseline and exited 0, checking diff whitespace only. GitHub ci.yml workflow_dispatch lane=all reads checkout source, typechecks, runs product suites and cross-platform jobs; NOT RUN locally. Every conditional scenario is named in decade docs and must be asserted in committed regression tests. Source inspection is not runtime proof. + +## Cycle records + +Roadmap P: requirements/source inspection and independent design review in progress. No product patch applied. + +Roadmap B: locked amended contract after independent A PASS and both design reflections ALIGNED. Product implementation starts in the next cycle. diff --git a/devlog/_plan/260912_thinking_contract/001_sources.md b/devlog/_plan/260912_thinking_contract/001_sources.md new file mode 100644 index 0000000000..f05813eae8 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/001_sources.md @@ -0,0 +1,7 @@ +# Source decisions + +Public PR diffs and latest comments are the source proposal evidence. #4130's September 11 corrections pin Lite on for nonempty additional_tools bodies and off otherwise; adopting the earlier unconditional false version loses tools. #4334 is an explicit retirement HOLD and is not carried. + +#4301 removes automatic content-to-summary conversion. #4287 tests raw DeepSeek content as a visible summary; that expectation conflicts with provenance and will be replaced, not adopted. Google thought-summary API documentation distinguishes summaries from opaque thought signatures: https://ai.google.dev/gemini-api/docs/generate-content/thinking (opened 2026-09-12). CCA generationConfig/includeThoughts behavior is contributor probe evidence, not a newly performed live-service probe. + +Searches used: reasoning_raw_delta, thinking_delta, hideThinkingSummary, googlePartTextEvent, preserveReasoningContent, and the four PR numbers. Existing bridge event types can distinguish raw content and summary without a new enum. No-code/config-only options cannot repair the existing mislabeled content; raw rewrite deletion plus existing boundaries is the smallest change. diff --git a/devlog/_plan/260912_thinking_contract/010_presentation.md b/devlog/_plan/260912_thinking_contract/010_presentation.md new file mode 100644 index 0000000000..e39e9c0bfe --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/010_presentation.md @@ -0,0 +1,316 @@ +# Presentation contract + +Class C4 public protocol contract. Depends on roadmap lock. MODIFY src/bridge.ts: closeCurrentRawReasoning and reasoning_raw_delta emit response.reasoning_text.delta/done with content_index:0; final items use summary:[] and content:[{type:reasoning_text,text}]. buildResponseJSONWithBudget mirrors this. Keep hidden txt-only replay envelopes intact. DELETE src/server/responses-reasoning-summary-rewrite.ts and its obsolete unit test; MODIFY core.ts to remove imports and SSE/JSON content-to-summary rewrites. MODIFY both layout manifests to remove that test. Adopt the exact #4301 hunks below except reporter video and historical verification record. + +MODIFY provider.ts, registry.ts, derive.ts, router.ts and auth-cors.ts to carry showThinkingSummary boolean (preserve explicit false). Seed only google-antigravity true. Creation: provider config/registry; serialization: providerConfigSeed and deriveKeyLoginMap; deserialization: config provider passthrough and management field policy; consumers: routedProviderConfig, final-route normalization, Google request builder. No new enum. + +MODIFY core.ts final-route normalization: apply provider default only when original reasoning.summary is omitted, never explicit none; recompute on each final route so fallback cannot inherit another provider default. Provider opt-in authorizes summary display, not raw-to-summary conversion. + +MODIFY google.ts shared part classifier to use existing thinking_delta only for Gemini thought summaries under verified Gemini model provenance; CCA Claude/gpt-oss thought text remains reasoning_raw_delta. Persist request-local Gemini identity using existing adapter state, used by both stream and buffered classifier calls. includeThoughts stays provider-opted, Gemini-only, non-image and explicit-hide aware. MODIFY google-wire-compiler.ts to retain only boolean true includeThoughts, independently of thinkingLevel. Do not claim raw text is an actual summary. + +MODIFY the #4287 end-to-end fixture: raw DeepSeek content remains content with empty summary even under provider opt-in; actual CCA Gemini thought parts use summary; omitted vs none vs auto, explicit provider false, saved-row enrichment, fallback reset, streaming/buffered paths. Extend existing Google tests and bridge raw tests; both layout manifests register responses-show-thinking-summary.test.ts. Update English providers docs and structure owners, keeping locale statements consistent. Source tests are authored but run only by hosted CI. + +Acceptance: raw event fixture => content delta and no summary delta; actual Gemini summary fixture => summary only when requested/provider-opted; explicit none => no synthesized summary and no includeThoughts request; false/unknown provider => no opt-in; fallback to unopted route => hidden behavior reset; replay envelope decodes same raw text and tool continuation remains valid; native Responses mixed content/summary remains byte-semantically native. No model prose synthesizer is introduced. + +## Source patch blueprint + +```diff +diff --git a/src/bridge.ts b/src/bridge.ts +index 20e7c3fe09..bc90f35b94 100644 +--- a/src/bridge.ts ++++ b/src/bridge.ts +@@ -663,16 +663,13 @@ export function bridgeToResponsesSSE( + const closeCurrentRawReasoning = () => { + if (!currentRawReasoning) return; + rawReasoningForNextToolCall = currentRawReasoning.text; +- emit("response.reasoning_summary_text.done", { +- item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, text: currentRawReasoning.text, +- }); +- emit("response.reasoning_summary_part.done", { +- item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, +- part: { type: "summary_text", text: currentRawReasoning.text }, ++ emit("response.reasoning_text.done", { ++ item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, content_index: 0, text: currentRawReasoning.text, + }); + const item = { + type: "reasoning", id: currentRawReasoning.itemId, +- summary: [{ type: "summary_text", text: currentRawReasoning.text }], ++ summary: [] as never[], ++ content: [{ type: "reasoning_text", text: currentRawReasoning.text }], + }; + emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item }); + retainFinishedItem(item as OutputItem, currentRawReasoning.textBytes, "reasoning"); +@@ -1111,10 +1108,6 @@ export function bridgeToResponsesSSE( + const itemId = `rs_${uuid()}`; + const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] }; + emit("response.output_item.added", { output_index: outputIndex, item }); +- emit("response.reasoning_summary_part.added", { +- item_id: itemId, output_index: outputIndex, summary_index: 0, +- part: { type: "summary_text", text: "" }, +- }); + currentRawReasoning = { itemId, outputIndex, text: "", textBytes: 0 }; + } + ({ value: currentRawReasoning.text, bytes: currentRawReasoning.textBytes } = appendString( +@@ -1123,9 +1116,13 @@ export function bridgeToResponsesSSE( + event.text, + "reasoning", + )); +- emit("response.reasoning_summary_text.delta", { ++ // Raw reasoning (openai-chat reasoning_content, kiro tags) rides the CONTENT ++ // channel, matching native gpt-oss passthrough: Codex applies its own display ++ // policy, so the desktop band shows the "Thinking…" placeholder instead of the ++ // raw CoT (the #45 summary-channel display intent is intentionally reverted). ++ emit("response.reasoning_text.delta", { + item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, +- summary_index: 0, delta: event.text, ++ content_index: 0, delta: event.text, + }); + break; + } +@@ -1780,7 +1777,8 @@ function buildResponseJSONWithBudget( + } + pushOutput({ + type: "reasoning", id: `rs_${uuid()}`, +- summary: [{ type: "summary_text", text: currentRawReasoning }], ++ summary: [], ++ content: [{ type: "reasoning_text", text: currentRawReasoning }], + }, currentRawReasoningBytes, "reasoning"); + currentRawReasoning = ""; + currentRawReasoningBytes = 0; + +``` + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts +index 88c482ba7d..aa835e50b4 100644 +--- a/src/adapters/google-wire-compiler.ts ++++ b/src/adapters/google-wire-compiler.ts +@@ -130,12 +130,20 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { + ))].slice(0, 5); + if (stopSequences.length > 0) out.stopSequences = stopSequences; + } +- if (isObject(value.thinkingConfig) && typeof value.thinkingConfig.thinkingLevel === "string") { +- const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); +- const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) +- ? raw +- : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); +- if (thinkingLevel) out.thinkingConfig = { thinkingLevel }; ++ if (isObject(value.thinkingConfig)) { ++ const thinking: JsonObject = {}; ++ if (typeof value.thinkingConfig.thinkingLevel === "string") { ++ const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); ++ const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) ++ ? raw ++ : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); ++ if (thinkingLevel) thinking.thinkingLevel = thinkingLevel; ++ } ++ // The one key that makes Google return `thought: true` text. Cloud Code Assist serves ++ // thinking either way (thoughtsTokenCount stays non-zero) but withholds the text unless the ++ // request opts in, so dropping it here silently reinstates the missing-thinking behavior. ++ if (value.thinkingConfig.includeThoughts === true) thinking.includeThoughts = true; ++ if (Object.keys(thinking).length > 0) out.thinkingConfig = thinking; + } + if (Array.isArray(value.responseModalities)) { + const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); +diff --git a/src/adapters/google.ts b/src/adapters/google.ts +index 7fcc88ba59..5a6675f6e4 100644 +--- a/src/adapters/google.ts ++++ b/src/adapters/google.ts +@@ -866,11 +866,27 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte + ); + antigravityModel = wireModelId; + antigravitySession = sessionId; ++ // Gemini returns no chain-of-thought TEXT unless the request opts in. Probed against CCA ++ // 2026-09-12: `gemini-3.8-flash-high` answered with thoughtsTokenCount=321 and zero ++ // `thought` parts, then 358-652 chars of genuine reasoning once includeThoughts was set. ++ // Scoped to Gemini wire ids — Claude-on-CCA accepts the flag but never returns thought ++ // parts, and gpt-oss rejects it outright (400 INVALID_ARGUMENT, which would break every ++ // gpt-oss turn). Gated on the provider's visible-thinking opt-in so a user who wants ++ // thinking hidden does not pay conversation-history tokens for text nobody renders; ++ // `hideThinkingSummary !== true` is the same per-request gate the response path uses, so ++ // a client that explicitly asked for hidden thinking is not billed for the text either. ++ const includeThoughts = provider.showThinkingSummary === true ++ && parsed.options.hideThinkingSummary !== true ++ && /^gemini-/.test(wireModelId) ++ && !isImageCapableModel(parsed.modelId); + // Effort → thinkingConfig for CCA (CLIProxyAPI proven: request.generationConfig.thinkingConfig). + // Suffix/compat IDs return thinkingLevel=undefined — the suffix IS the effort, no contradiction. +- if (thinkingLevel) { ++ if (thinkingLevel || includeThoughts) { + const gc = (body.generationConfig ?? {}) as Record; +- gc.thinkingConfig = { thinkingLevel }; ++ gc.thinkingConfig = { ++ ...(thinkingLevel ? { thinkingLevel } : {}), ++ ...(includeThoughts ? { includeThoughts: true } : {}), ++ }; + body.generationConfig = gc; + } + // Reasoning continuity: Gemini models re-inject cached thoughtSignatures; Claude-on-Antigravity +diff --git a/src/providers/derive.ts b/src/providers/derive.ts +index 67e6c0522e..7edf28787b 100644 +--- a/src/providers/derive.ts ++++ b/src/providers/derive.ts +@@ -43,6 +43,7 @@ export interface DerivedKeyLoginProvider { + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; ++ showThinkingSummary?: boolean; + reasoningSplitModels?: string[]; + reasoningDetailsModels?: string[]; + thinkingToggleModels?: string[]; +@@ -267,6 +268,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon + ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), + ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), + ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ++ ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), + ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), + ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), + ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), +@@ -315,6 +317,7 @@ export function deriveKeyLoginMap(): Record { + ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), + ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), + ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ++ ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), + ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), + ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), + ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), +@@ -567,6 +570,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig + if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels]; + if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels]; + if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames; ++ if (prov.showThinkingSummary === undefined && seed.showThinkingSummary !== undefined) prov.showThinkingSummary = seed.showThinkingSummary; + if (prov.keyOptional === undefined && seed.keyOptional !== undefined) prov.keyOptional = seed.keyOptional; + if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier; + if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip; +diff --git a/src/providers/registry.ts b/src/providers/registry.ts +index f72bb7650b..e483e9db23 100644 +--- a/src/providers/registry.ts ++++ b/src/providers/registry.ts +@@ -343,6 +343,10 @@ export interface ProviderRegistryEntry { + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; ++ /** ++ * Opt this provider into visible thinking summaries (see OcxProviderConfig.showThinkingSummary). ++ */ ++ showThinkingSummary?: boolean; + reasoningSplitModels?: string[]; + reasoningDetailsModels?: string[]; + thinkingToggleModels?: string[]; +@@ -367,7 +371,7 @@ export type ProviderConfigSeed = Pick< + | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" + | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" + | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" +- | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" ++ | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" + | "googleMode" | "project" | "location" | "headers" + >; + +@@ -2045,7 +2049,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ + // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would + // retarget a user's custom base back to Google. A leading `./` is required because a bare + // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. +- { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, ++ { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", showThinkingSummary: true, jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, + { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, + { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, + { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, +diff --git a/src/router.ts b/src/router.ts +index 55a0326fce..bf2b9b4b98 100644 +--- a/src/router.ts ++++ b/src/router.ts +@@ -410,6 +410,13 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider + ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined + ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } + : {}), ++ // The request path resolves through routedProviderConfig() and never calls ++ // enrichProviderFromRegistry(), so a saved provider row written before the ++ // registry learned this flag must be backfilled here or route.provider never ++ // carries it and the showThinkingSummary opt-in stays dead. ++ ...(provider.showThinkingSummary === undefined && registryEntry.showThinkingSummary !== undefined ++ ? { showThinkingSummary: registryEntry.showThinkingSummary } ++ : {}), + // Registry-only client-facing repair policy (#938): fill only when the + // saved provider has no explicit policy; clone so runtime never aliases + // the registry constant. +diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts +index 3a93246cd0..93377b5573 100644 +--- a/src/server/auth-cors.ts ++++ b/src/server/auth-cors.ts +@@ -885,6 +885,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { + autoToolChoiceOnlyModels: "editor", + preserveReasoningContentModels: "editor", + requiresReasoningPlaceholderModels: "editor", ++ showThinkingSummary: "editor", + retryOn429: "editor", + transientRetryOn5xx: "editor", + reasoningSplitModels: "editor", +diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts +index cccd942026..852cd9f8b0 100644 +--- a/src/server/responses/core.ts ++++ b/src/server/responses/core.ts +@@ -2467,6 +2467,20 @@ async function resolveSubagentFallbackModelEligibility(args: { + }; + } + ++/** ++ * Whether the client explicitly asked for hidden thinking (`reasoning.summary: "none"`). ++ * ++ * Pinned: parseRequest collapses "omitted" and "none" into one hideThinkingSummary ++ * flag, so the raw request body is the ONLY place that still distinguishes them. ++ * Provider opt-ins like showThinkingSummary must consult this — never the flag ++ * alone — or a future caller that copies only the flag would silently unlock an ++ * explicit opt-out. ++ */ ++function clientExplicitlyHidThinking(parsed: OcxParsedRequest): boolean { ++ const rawReasoning = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning; ++ return typeof rawReasoning === "object" && rawReasoning !== null ++ && (rawReasoning as { summary?: unknown }).summary === "none"; ++} + /** + * Apply every route-dependent request mutation against the final selected route. + * Must run only after subagent fallback has settled the model/provider. +@@ -2508,6 +2522,15 @@ async function applyFinalRouteRequestNormalization(args: { + // this request will actually use (#404). + route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); + route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); ++ // Provider-opted visible thinking (e.g. google-antigravity): parseRequest hides thinking ++ // whenever the client omits reasoning.summary, which is the Codex default. A provider that ++ // serves genuine user-facing reasoning opts back into the summary channel here, so thought ++ // parts (Gemini thought, content-channel reasoning_text) reach the client instead of only ++ // the hidden replay envelopes. An explicit client reasoning.summary "none" still wins. ++ if (route.provider.showThinkingSummary === true && parsed.options.hideThinkingSummary === true ++ && !clientExplicitlyHidThinking(parsed)) { ++ parsed.options.hideThinkingSummary = false; ++ } + if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; + logCtx.model = route.modelId; + logCtx.provider = route.providerName; +diff --git a/src/types/provider.ts b/src/types/provider.ts +index e65130a4fa..b6374a991a 100644 +--- a/src/types/provider.ts ++++ b/src/types/provider.ts +@@ -746,6 +746,15 @@ export interface OcxProviderConfig { + * out explicitly (e.g. MiniMax, where low effort disables thinking). + */ + requiresReasoningPlaceholderModels?: string[]; ++ /** ++ * Opt-in: surface upstream thinking as visible reasoning summaries even when the ++ * client did not send `reasoning.summary`. parseRequest hides thinking by default ++ * (Codex omits the field), which strands genuine reasoning — e.g. Gemini `thought` ++ * parts on the google-antigravity (Cloud Code Assist) wire — in hidden replay ++ * envelopes. An explicit client `reasoning.summary: "none"` still wins. Set `false` ++ * to opt a seeded preset back out. ++ */ ++ showThinkingSummary?: boolean; + /** + * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only, + * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays + +``` + +## Reflection corrections accepted + +Explicit wire reasoning.summary:"none" wins. A client that serializes configured none as omission cannot be distinguished from unspecified preference. No client config rewrite or global catalog summary default changes. Summary classification is limited to built CCA Gemini requests; unknown/uninitialized, direct Google/Vertex and CCA Claude/gpt-oss remain raw. Streaming and buffered summary-to-tool continuations assert exact Google signature on correct call; never emit Google signatures as Anthropic thinking_signature. Hidden unsigned summaries may disappear but required tool replay state survives. Exercise final assistant text and terminal order, fallback in both directions, and remove replay-comparison rewrite alongside SSE/JSON rewrite. Desktop appearance remains client-controlled; source patch comments claiming an unconditional placeholder are replaced during adoption. diff --git a/devlog/_plan/260912_thinking_contract/020_transport_hint.md b/devlog/_plan/260912_thinking_contract/020_transport_hint.md new file mode 100644 index 0000000000..335e339a00 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/020_transport_hint.md @@ -0,0 +1,319 @@ +# Optional hint suppression + +Class C4 review because client metadata policy changes. Independent of presentation; depends only on roadmap. Adopt #3652 only after independent security/transport review. Public proposal removes exactly two x-codex-safety-buffering headers, metadata.type=safety_buffering events and top-level safety_buffering fields at the client relay boundary. Default false; malformed config must remain off and candidate validation rejects nonbooleans. This suppresses optional transport hints; provider safety decisions/refusals and upstream checks are unchanged. Compact and independent WS/other-provider pathways retain existing policy unless a directly exercised shared boundary already applies. + +MODIFY src/config.ts and src/types/config.ts for validated boolean/default; src/server/relay.ts for allowlisted header removal and SSE terminal-boundary transformation; relay-eager.ts for option forwarding; core.ts to compute option only for canonical OpenAI forward destination and pass it to all relevant headers/client output boundaries; index.ts exports if needed by existing test style. Do not apply to custom gateway/key providers. Preserve errors, response.failed/incomplete and terminal sentinel handling. + +MODIFY tests/responses/passthrough-headers.test.ts, openai-responses-passthrough.test.ts and tests/server/config.test.ts. Scenarios: absent/false/true/malformed config; uppercase headers; unrelated headers; split metadata frames; actual failure carrying hint must still fail; noncanonical provider has identical fields and retains them; eager/non-eager client paths. Add missing canonical route coverage if independent review identifies it. MODIFY English/ja/ko/ru/zh-cn server configuration docs and structure owners. Avoid unsupported claims about models being weaker or provider safety bypass. + +Before/after anchor: createSseTerminalOutputBoundary() -> createSseTerminalOutputBoundary(options?: CodexSafetyBufferingFilterOptions); sanitizePassthroughHeaders(upstream) -> sanitizePassthroughHeaders(upstream, options?); canonical true => filter option, every other provider => undefined. Full public source diff is pinned by #3652 head in 000_plan.md and inspected locally; any needed correction is recorded here before B. + +## Independent design corrections + +H1 accepted: policy rewrite and hint stripping compose. Build policyFailurePayload first, then remove top-level safety_buffering from the effective emitted payload, preserving response.failed/error data and retryable:false. H2 accepted: extend current relaySseWithFailedTail fourth options object with terminalBoundary; never replace upstreamError. Core passes both existing upstreamError and new terminalBoundary; update the existing source-contract assertion to preserve its original guarantee. H3 accepted: native WebSocket codex.response.metadata.headers and /responses/compact are explicitly excluded; their hints remain unfiltered. No new WS metadata filter. Docs must not claim the old WS allowlist excludes these headers. Regression fixtures cover CRLF/split/malformed input, policy error plus hint, EOF upstreamError, canonical true and noncanonical preservation. + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/config.ts b/src/config.ts +index fdcda9547c..cd0641feb2 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -1125,6 +1125,8 @@ const configSchema = z.object({ + configRebaseProvenance: z.unknown().optional(), + // A retry can be billable, so absence and malformed hand edits both stay off. + emptyCompletionRetry: z.boolean().optional().catch(false), ++ // Header suppression changes what Codex sees, so absence and malformed edits stay off. ++ dropCodexSafetyBuffering: z.boolean().optional().catch(false), + // A malformed hand edit must not silently stop opening the browser: fall back + // to undefined, which resolves to the historical auto-open behavior. + oauthOpenBrowser: z.boolean().optional().catch(undefined), +@@ -2613,6 +2615,14 @@ function emptyCompletionRetryError(value: unknown): string | null { + return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted"; + } + ++function dropCodexSafetyBufferingError(value: unknown): string | null { ++ const raw = rawConfigRecord(value); ++ if (!raw || !Object.hasOwn(raw, "dropCodexSafetyBuffering")) return null; ++ const enabled = raw.dropCodexSafetyBuffering; ++ if (enabled === undefined || typeof enabled === "boolean") return null; ++ return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted"; ++} ++ + function oauthOpenBrowserError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null; +@@ -2718,6 +2728,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx + ?? codexQuotaAutoRefreshError(value) + ?? codexAccountPickerEnabledError(value) + ?? emptyCompletionRetryError(value) ++ ?? dropCodexSafetyBufferingError(value) + ?? oauthOpenBrowserError(value) + ?? runtimeRoleError(value) + ?? remoteGuiConfigError(value) +@@ -3684,6 +3695,7 @@ export function getDefaultConfig(): OcxConfig { + return { + port: 10100, + emptyCompletionRetry: false, ++ dropCodexSafetyBuffering: false, + managementUsageMaxReadBytes: 64 * 1024 * 1024, + appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), + // Fresh/re-initialized configs are already written in the current three-tier +diff --git a/src/server/index.ts b/src/server/index.ts +index aedd6bf236..c6ce73b2f1 100644 +--- a/src/server/index.ts ++++ b/src/server/index.ts +@@ -142,6 +142,7 @@ import { + } from "./relay"; + export { + consumeForInspection, ++ codexSafetyBufferingFilterOptions, + relaySseWithFailedTail, + relaySseWithHeartbeat, + relayWithAbort, +diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts +index 655997b813..a6e60d3d02 100644 +--- a/src/server/relay-eager.ts ++++ b/src/server/relay-eager.ts +@@ -26,6 +26,7 @@ + + import { + adapterEofIncompleteFrame, ++ type CodexSafetyBufferingFilterOptions, + createSseTerminalOutputBoundary, + doneFrame, + failedTailFrame, +@@ -83,6 +84,8 @@ export type EagerRelayOptions = { + postCancelDrainBytes?: number; + /** Injectable clock for tests. */ + now?: () => number; ++ /** Client output boundary filters (Codex safety-buffering hints). */ ++ terminalBoundary?: CodexSafetyBufferingFilterOptions; + }; + + const DEFAULT_MAX_QUEUE_BYTES = 8 * 1024 * 1024; +@@ -111,7 +114,7 @@ export function relaySseEagerBounded( + const terminalEncoder = new TextEncoder(); + const adapterEofFrame = adapterEofIncompleteFrame(terminalEncoder); + const terminalSentinel = doneFrame(terminalEncoder); +- const terminalBoundary = createSseTerminalOutputBoundary(); ++ const terminalBoundary = createSseTerminalOutputBoundary(opts?.terminalBoundary); + const activeRewrite: SseBlockRewrite | undefined = hooks.rewriteBlocks + ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined); + const encodeFailedTail = (error: unknown): Uint8Array | null => { +diff --git a/src/server/relay.ts b/src/server/relay.ts +index 60b57ea025..d840b2e59c 100644 +--- a/src/server/relay.ts ++++ b/src/server/relay.ts +@@ -162,7 +162,10 @@ export type SseTerminalOutputBoundary = { + * terminal, and drops every later block/byte. A premature [DONE] is held until + * a terminal arrives so clean EOF can synthesize one terminal and one sentinel. + */ +-export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { ++export function createSseTerminalOutputBoundary( ++ options?: CodexSafetyBufferingFilterOptions, ++): SseTerminalOutputBoundary { ++ const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES); +@@ -181,6 +184,10 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { + const payload = sseDataPayload(decoder.decode(frame.block)); + const isDone = payload === "[DONE]"; + const parsed = payload === null ? undefined : parseSsePayload(payload); ++ const safetyBuffering = dropSafetyBuffering && parsed !== undefined ++ ? codexSafetyBufferingBlockAction(parsed) ++ : "keep"; ++ if (safetyBuffering === "drop") continue; + const policyError = parsed !== undefined && isPolicyRewriteType(parsed) + ? cyberPolicyTerminalError(parsed) + : undefined; +@@ -189,7 +196,9 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { + decoder.decode(frame.block), + policyFailurePayload(policyError, parsed), + )) +- : frame.block; ++ : safetyBuffering === "strip" ++ ? encoder.encode(stripCodexSafetyBufferingField(decoder.decode(frame.block), parsed)) ++ : frame.block; + if (isDone) { + done = true; + if (responsesTerminal) { +@@ -260,10 +269,11 @@ export function relaySseWithFailedTail( + body: ReadableStream, + upstream: AbortController, + onClientGone?: (reason?: unknown) => void, ++ boundaryOptions?: CodexSafetyBufferingFilterOptions, + ): ReadableStream { + const reader = body.getReader(); + const encoder = new TextEncoder(); +- const terminalBoundary = createSseTerminalOutputBoundary(); ++ const terminalBoundary = createSseTerminalOutputBoundary(boundaryOptions); + let closed = false; + const relayChunk = ( + controller: ReadableStreamDefaultController, +@@ -438,6 +448,29 @@ function isPolicyRewriteType(parsed: unknown): boolean { + return type === "response.failed" || type === "response.incomplete" || type === "error"; + } + ++/** ++ * Codex emits its safety-buffering hint in the SSE body as well as in headers: ++ * a `response.metadata` event whose `metadata.type` is `safety_buffering`, or a ++ * `safety_buffering` field on another event. The metadata event is dropped whole; ++ * the field is stripped so the carrying event is otherwise relayed unchanged. ++ */ ++function codexSafetyBufferingBlockAction(parsed: unknown): "keep" | "drop" | "strip" { ++ const root = asJsonRecord(parsed); ++ if (!root) return "keep"; ++ if (root.type === "response.metadata") { ++ const metadata = asJsonRecord(root.metadata); ++ if (metadata?.type === "safety_buffering") return "drop"; ++ } ++ return Object.hasOwn(root, "safety_buffering") ? "strip" : "keep"; ++} ++ ++function stripCodexSafetyBufferingField(block: string, parsed: unknown): string { ++ const root = asJsonRecord(parsed); ++ if (!root) return block; ++ const { safety_buffering: _safetyBuffering, ...rest } = root; ++ return replaceSseDataPayload(block, JSON.stringify(rest)); ++} ++ + function rewritePolicyTerminalBlock(block: string, payload: string): string { + const newline = block.includes("\r\n") ? "\r\n" : "\n"; + const rewritten = replaceSseDataPayload(block, payload); +@@ -1422,7 +1455,31 @@ export function consumeForResponseLogMetadata( + * body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough. + * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim. + */ +-export function sanitizePassthroughHeaders(upstream: Headers): Headers { ++export const CODEX_SAFETY_BUFFERING_HEADERS = [ ++ "x-codex-safety-buffering-enabled", ++ "x-codex-safety-buffering-faster-model", ++] as const; ++ ++const CODEX_SAFETY_BUFFERING_HEADER_SET: ReadonlySet = new Set(CODEX_SAFETY_BUFFERING_HEADERS); ++ ++export interface CodexSafetyBufferingFilterOptions { ++ /** ++ * Drop Codex safety-buffering hints: the `x-codex-safety-buffering-*` response ++ * headers and the `safety_buffering` SSE metadata event / field. Absent and ++ * `false` relay everything unchanged. ++ */ ++ dropCodexSafetyBuffering?: boolean; ++} ++ ++/** Resolve the passthrough header policy from the loaded config (absent means "forward everything"). */ ++export function codexSafetyBufferingFilterOptions( ++ config: { dropCodexSafetyBuffering?: boolean }, ++): CodexSafetyBufferingFilterOptions { ++ return { dropCodexSafetyBuffering: config.dropCodexSafetyBuffering === true }; ++} ++ ++export function sanitizePassthroughHeaders(upstream: Headers, options?: CodexSafetyBufferingFilterOptions): Headers { ++ const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; + const DROP = new Set([ + "content-encoding", + "content-length", +@@ -1439,7 +1496,10 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers { + ]); + const out = new Headers(); + upstream.forEach((value, key) => { +- if (!DROP.has(key.toLowerCase())) out.set(key, value); ++ const lower = key.toLowerCase(); ++ if (DROP.has(lower)) return; ++ if (dropSafetyBuffering && CODEX_SAFETY_BUFFERING_HEADER_SET.has(lower)) return; ++ out.set(key, value); + }); + return out; + } +diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts +index 9d0eea0d76..e199917968 100644 +--- a/src/server/responses/core.ts ++++ b/src/server/responses/core.ts +@@ -304,6 +304,7 @@ import { + markEagerRelaySseResponse, + markNativePassthroughSseResponse, + relaySseWithFailedTail, ++ codexSafetyBufferingFilterOptions, + relayWithAbort, + sanitizePassthroughHeaders, + } from "../relay"; +@@ -3850,6 +3851,9 @@ async function handleResponsesInner( + let hostAdmissionLease = pendingHostAdmissionLease; + pendingHostAdmissionLease = null; + try { ++ const codexSafetyBufferingOptions = isCanonicalOpenAiForwardProvider(route.provider) ++ ? codexSafetyBufferingFilterOptions(config) ++ : undefined; + const imageGenCallAliases = route.provider.authMode === "forward" + ? new Map() + : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); +@@ -4732,7 +4736,7 @@ async function handleResponsesInner( + } + break; + } +- const headers = sanitizePassthroughHeaders(upstreamResponse.headers); ++ const headers = sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions); + const resolvedModel = headers.get("openai-model")?.trim(); + if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; + if (isUsageDebugEnabled()) { +@@ -4824,7 +4828,7 @@ async function handleResponsesInner( + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, +- headers: sanitizePassthroughHeaders(upstreamResponse.headers), ++ headers: sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions), + }); + } + if (!upstreamResponse.ok) { +@@ -5027,6 +5031,7 @@ async function handleResponsesInner( + onDone: () => unregisterTurn(turnAc), + }, { + clientGoneSignal: options.abortSignal, ++ terminalBoundary: codexSafetyBufferingOptions, + ...(inlineEagerRewrite ? { rewriteBudget: translatorBudget } : {}), + }); + // When selected, this relay closes response.completed even if upstream +@@ -5110,7 +5115,8 @@ async function handleResponsesInner( + const rewrittenBody = clientBlockRewrite !== undefined + ? relaySseWithBlockRewrite(nativeBody, clientBlockRewrite, translatorBudget) + : nativeBody; +- const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason)); ++ const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason), ++ codexSafetyBufferingOptions); + return markNativePassthroughSseResponse(new Response(clientBody, { + status: upstreamResponse.status, + headers, +@@ -5238,7 +5244,7 @@ async function handleResponsesInner( + } + throw error; + } +- const sseHeaders = sanitizePassthroughHeaders(headers); ++ const sseHeaders = sanitizePassthroughHeaders(headers, codexSafetyBufferingOptions); + sseHeaders.set("content-type", "text/event-stream"); + sseHeaders.set("cache-control", "no-store"); + return new Response(stream, { +diff --git a/src/types/config.ts b/src/types/config.ts +index 8cf1246979..4d2c63fdf1 100644 +--- a/src/types/config.ts ++++ b/src/types/config.ts +@@ -335,6 +335,16 @@ export interface OcxConfig { + client?: OcxClientConnectionConfig; + /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ + emptyCompletionRetry?: boolean; ++ /** ++ * Drop the Codex safety-buffering hints from a Codex Responses passthrough: the ++ * `x-codex-safety-buffering-*` response headers, `response.metadata` SSE events of ++ * type `safety_buffering`, and the `safety_buffering` field on other SSE events. ++ * The Codex TUI turns those hints into a "retry with a faster model" prompt whose ++ * default action switches the session to a weaker model, so an unattended session ++ * can lose its model to a stray keystroke. Absent and `false` relay everything ++ * unchanged. ++ */ ++ dropCodexSafetyBuffering?: boolean; + /** + * Whether a login may open a browser on the machine running the proxy. + * + +``` diff --git a/devlog/_plan/260912_thinking_contract/030_spark.md b/devlog/_plan/260912_thinking_contract/030_spark.md new file mode 100644 index 0000000000..3997aaa6d7 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/030_spark.md @@ -0,0 +1,66 @@ +# Spark Lite metadata follows body shape + +Class C3 bounded compatibility. Independent of presentation/hint; depends on roadmap. MODIFY src/adapters/openai-responses.ts only inside canonical OpenAI forwarding and final wire model gpt-5.3-codex-spark. Add bodyCarriesLiteToolShape next to existing tool-shape helpers: Array.isArray(body.input) && body.input.some(item => isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) && item.tools.length > 0). After final Spark body construction, delete all case variants of CODEX_RESPONSES_LITE_HEADER then set it to liteShaped ? "true" : "false". Existing prepareCodexWsRequest projects it onto native frame metadata. + +Before: Spark deletes the header, allowing stale native metadata to survive. After: tool-less/top-level-tool Spark frames advertise false; nonempty Lite catalog frames advertise true despite conflicting inherited header. No retirement, no changes to model availability, no user service changes. + +MODIFY tests/codex-integration/codex-metadata-integrity.test.ts: alias resolved final model, inherited true/false/mixed-case/absent header, Lite tool body true, empty Lite group false, malformed metadata keeps HTTP fallback/body, noncanonical remains unchanged. MODIFY tests/responses/ws-upstream-reuse.test.ts: legacy true socket retires when adapter produces false, replacement same identity reused, raw request immutable. MODIFY all eight existing architecture locale pages and structure/transports/responses.md, referencing body-shape rule from shared area owners. Adopt latest #4130 source diff, preserving author; do not import historical earlier heads. + +Verification: source diff review and final-branch hosted ci.yml lane=all. Tests NOT RUN locally. Success proves framing and connection identity, not a live backend EOF fix or all tool-bearing EOF cases. Remaining acceptance: broader tool-format conversion stays out of scope. + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts +index c4aa523ee6..8fbe43816d 100644 +--- a/src/adapters/openai-responses.ts ++++ b/src/adapters/openai-responses.ts +@@ -864,6 +864,21 @@ function promoteClientLoadedTools(body: unknown): unknown { + } + + const MAX_RESPONSES_CALL_ID_LENGTH = 64; ++ ++/** ++ * Whether the outgoing body still delivers tools through the responses-lite shape. ++ * ++ * Lite carries the client catalog as an `additional_tools` input item; the non-Lite wire shape ++ * expects top-level `tools`. Anything that flips the Lite advertisement has to agree with the ++ * shape actually being sent, or the destination silently loses the tool surface. ++ */ ++function bodyCarriesLiteToolShape(body: Record): boolean { ++ if (!Array.isArray(body.input)) return false; ++ return body.input.some(item => ++ isPlainObject(item) && item.type === "additional_tools" ++ && Array.isArray(item.tools) && item.tools.length > 0 ++ ); ++} + const REPAIRED_CALL_ID_PREFIX = "call_ocx_"; + const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length; + +@@ -2515,12 +2530,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): + parsed.modelId, + ); + if (isCanonicalOpenAiForwardProvider(provider)) { +- // Spark closes Responses Lite streams before a terminal completion. Select compatibility +- // from the final wire model so aliases cannot leave the caller or a static header enabled. ++ // Select Spark's Lite compatibility from the final wire model, including aliases, and ++ // let the BODY decide it. The header also overrides native WS metadata downstream, so a ++ // forwarded or statically configured value must never contradict the shape being sent. ++ // ++ // The synchronized catalog keeps `use_responses_lite: true` for Spark precisely because ++ // it selects tool delivery (`input[].additional_tools` instead of top-level `tools`), and ++ // stripSparkCompatibility filters that group in place rather than promoting it. So a ++ // Lite-shaped body is pinned back ON — otherwise an inherited `false` advertises non-Lite ++ // while the tools exist only in the Lite shape, and Spark loses the tool surface. Only a ++ // body with no Lite tool group is downgraded, which is what the stream fix needs. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { ++ const liteShaped = bodyCarriesLiteToolShape(finalBody); + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; + } ++ headers[CODEX_RESPONSES_LITE_HEADER] = liteShaped ? "true" : "false"; + } + const routingHeaders = new Headers(headers); + applyCodexRoutingHint(routingHeaders, finalBody); + +``` diff --git a/devlog/_plan/260912_thinking_contract/040_delivery.md b/devlog/_plan/260912_thinking_contract/040_delivery.md new file mode 100644 index 0000000000..a70e62bfb6 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/040_delivery.md @@ -0,0 +1,7 @@ +# Final heads and handoff + +Class C3 delivery evidence. Depends on all dispositions. MODIFY branch-owned numbered completion docs and ignored .tmp/thinking/handoff.md. Read existing .github/PULL_REQUEST_TEMPLATE.md; write every section, credits and precise NOT RUN limitation. Publish only own codex/260912-60plus-thinking* branches with git push --no-verify; PR bases dev for independent units, ordinary parent branch only for actual dependencies. No merge/auto-merge/closures. + +NEW .tmp/thinking/*-ci.json captures gh run view JSON for final SHA plus all jobs. NEW .tmp/thinking/*-review.md captures independent implementation findings with accepted/rebutted disposition. Refresh head/base, native stack membership (unknown if API unsupported), current reviews and CI before handoff. Inspect .github/workflows/ci.yml and dispatch lane=all at each final branch where needed. Existing automatic runs stay untouched. If final-head CI fails, inspect failing logs, repair scoped source or fixtures, commit/push --no-verify and validate new final tip. Do not label skipped/cancelled/old-head runs passing. + +Final handoff fields: own worktree, branch per PR, source PR disposition, exact head, PR URL, dependency order, original author trailers, remaining acceptance, unresolved reviews, CI run id/url/head/result/job conclusions, own cycle records and local tests NOT RUN. Parent performs any subsequent integration. No evidence claims from peer commentary alone. From a7b41aad4e3a6d632803664ffb94d6d3443b7d1c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:03:15 +0900 Subject: [PATCH 021/224] test: cover multipart boundary and input mutation cases --- tests/server/agent-task-recovery.test.ts | 27 +++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 3be8b90432..faf117f1a7 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1025,6 +1025,21 @@ describe("bounded multipart encrypted task recovery", () => { expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(0); }); + test("accepts exactly 32 whole parts without deduplicating ciphertext", async () => { + let sends = 0; + let forwarded: string[] = []; + globalThis.fetch = (async (_url, init) => { + sends++; + const body = JSON.parse(String(init?.body)); + forwarded = body.input[0].content.slice(1).map((part: { encrypted_content: string }) => part.encrypted_content); + return new Response(recoverySse("All repeated parts retained.")); + }) as typeof fetch; + const tokens = Array.from({ length: 32 }, () => FERNET_TASK); + expect(await recoverEncryptedAgentTaskWithResult(new Request("http://localhost/v1/responses", { headers: codexHeaders() }), multipart(tokens), {}, routedConfig())).toEqual({ recovered: true }); + expect(forwarded).toEqual(tokens); + expect(sends).toBe(1); + }); + test("refuses malformed slots, nonconsecutive runs, and count/byte overflow without a fetch", async () => { let sends = 0; globalThis.fetch = (async () => { sends++; return new Response(recoverySse("must not run")); }) as typeof fetch; @@ -1032,7 +1047,10 @@ describe("bounded multipart encrypted task recovery", () => { const tooLargeRaw = Buffer.alloc(57 + 16 * 131072, 0x5a); tooLargeRaw[0] = 0x80; const token = tooLargeRaw.toString("base64").replaceAll("+", "-").replaceAll("/", "_"); - const cases = [multipart(Array.from({ length: 33 }, () => FERNET_TASK)), multipart([token]), + const boundedRaw = Buffer.alloc(57 + 16 * 50000, 0x5a); + boundedRaw[0] = 0x80; + const boundedToken = boundedRaw.toString("base64").replaceAll("+", "-").replaceAll("/", "_"); + const cases = [multipart(Array.from({ length: 33 }, () => FERNET_TASK)), multipart([token]), multipart([boundedToken, boundedToken]), agentMessage([{ type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, { type: "encrypted_content", encrypted_content: 123 }]), agentMessage([{ type: "input_text", text: ROUTING_ENVELOPE }, { type: "encrypted_content", encrypted_content: FERNET_TASK }, { type: "input_text", text: "" }, { type: "encrypted_content", encrypted_content: SECOND_FERNET_TASK }]), ]; @@ -1044,7 +1062,7 @@ describe("bounded multipart encrypted task recovery", () => { expect(sends).toBe(0); }); - test("revalidates full input identity after asynchronous recovery", async () => { + test.each(["author", "header", "later-token"] as const)("revalidates %s after asynchronous recovery", async mutation => { let release!: (response: Response) => void; let started!: () => void; const ready = new Promise(resolve => { started = resolve; }); @@ -1053,7 +1071,10 @@ describe("bounded multipart encrypted task recovery", () => { const input = multipart(); const pending = recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig()); await ready; - (input[0] as { author: string }).author = "changed-author"; + const item = input[0] as { author: string; content: Array> }; + if (mutation === "author") item.author = "changed-author"; + else if (mutation === "header") item.content[0]!.text = ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"); + else item.content[2]!.encrypted_content = FERNET_TASK; release(new Response(recoverySse("Must not replace changed task."))); expect(await pending).toEqual({ recovered: false, reason: "input_changed" }); expect((input[0] as { type: string }).type).toBe("agent_message"); From d11f1e65b0e10cb67f494b6d04d8ee01ed66de73 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:51:39 +0900 Subject: [PATCH 022/224] feat(responses): opt in to client transport hint suppression Co-authored-by: Wonchang Oh Co-authored-by: Claude Fable 5.1 Co-authored-by: Codex --- .../020_transport_hint.md | 4 + .../docs/ja/reference/configuration/server.md | 3 + .../docs/ko/reference/configuration/server.md | 1 + .../docs/reference/configuration/server.md | 1 + .../docs/ru/reference/configuration/server.md | 3 + .../zh-cn/reference/configuration/server.md | 3 + src/config.ts | 12 +++ src/server/index.ts | 1 + src/server/relay-eager.ts | 5 +- src/server/relay.ts | 80 ++++++++++++++++--- src/server/responses/core.ts | 13 ++- src/types/config.ts | 2 + structure/adapters/registry.md | 3 + structure/catalog.md | 3 + structure/clients/claude-desktop.md | 3 + structure/config.md | 4 + structure/data-planes/images.md | 3 + structure/data-planes/inbound-compat.md | 3 + structure/gui-and-management-api.md | 3 + structure/ops/docs-and-release.md | 3 + structure/ops/service-and-sidecars.md | 3 + structure/overview.md | 3 + structure/providers/openai-tiers.md | 3 + structure/providers/xai-grok.md | 3 + structure/runtime.md | 3 + structure/subagents.md | 3 + structure/transports/inventory.md | 3 + structure/transports/responses.md | 9 +++ structure/transports/streaming-health.md | 3 + .../openai-responses-passthrough.test.ts | 77 ++++++++++++++++++ tests/responses/passthrough-abort.test.ts | 2 +- tests/responses/passthrough-headers.test.ts | 75 ++++++++++++++++- tests/responses/sse-failed-tail.test.ts | 40 ++++++++++ tests/server/config.test.ts | 13 +++ 34 files changed, 376 insertions(+), 17 deletions(-) diff --git a/devlog/_plan/260912_thinking_contract/020_transport_hint.md b/devlog/_plan/260912_thinking_contract/020_transport_hint.md index 335e339a00..abfff918c7 100644 --- a/devlog/_plan/260912_thinking_contract/020_transport_hint.md +++ b/devlog/_plan/260912_thinking_contract/020_transport_hint.md @@ -317,3 +317,7 @@ index 8cf1246979..4d2c63fdf1 100644 * ``` + +## Hint P revalidation + +Previous D: presentation source complete; final hosted CI remains in delivery. This branch starts from the common docs checkpoint bd34120180 and baseline product 69e3dcda. Original #3652 does not apply cleanly because relay upstreamError handling changed. Carry nonconflicting hunks and manually adapt relay/core/config hunks, preserving cancellation and error capture. Independent H1-H3 plan reflection ALIGNED remains applicable. diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 0dd9cf59e4..e86387f80e 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -13,6 +13,7 @@ description: リスナー、リモート アクセス、アドミッション | `hostname?` | `string` | `"127.0.0.1"` |バインドアドレス。非ループバック バインドには `OPENCODEX_API_AUTH_TOKEN` が必要です。 | | `proxy?` | `string` | — |送信 HTTP(S) プロキシ URL または `${ENV_VAR}`。これらの変数が設定されていない場合にのみ、`HTTP_PROXY` / `HTTPS_PROXY` に適用されます。ループバックは `NO_PROXY` に残ります。 | | `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses ターンを、ターミナルイベント前にストリームが終了した場合も含め、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | Codex Responses パススルーから Codex の safety-buffering ヒントを除去します。対象は `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 応答ヘッダー、`safety_buffering` 型の `response.metadata` SSE イベント、およびその他の SSE イベントにある `safety_buffering` フィールドです。Codex TUI はこれらを、既定の操作でセッションをより弱いモデルに切り替える「より高速なモデルで再試行」プロンプトとして表示します。その他の `x-codex-*` ヘッダーと SSE イベントの内容は、そのフィールドの除去を除いて変更せずに転送されます。既定ではオフです。 | | `stallTimeoutSec?` | `number` | `300` | `response.incomplete` より前にアップストリーム データがない秒数。最小 1。 | `connectTimeoutMs?` | `number` | `200000` |試行ごとの DNS/TCP/TLS/最終ヘッダーの期限。本体が生成される前に終了します。 | | `shutdownTimeoutMs?` | `number` | `5000` |アクティブなターンが中止される前の正常な排出期限。 | @@ -171,3 +172,5 @@ Anthropic OAuth サイドカーは、opencodex の既存のクロード コー ## Codex クォータのネットワーク診断 メイン Codex アカウント行の `quotaRefresh` はクォータ取得の診断情報であり、残量やモデルへのアクセス権を示すものではありません。キャッシュ利用時や取得を行わない場合は省略されることがあります。取得には操作中のシェルではなく、実行中のプロキシサービスの環境が使われます。`proxy` 未設定では既存の環境を維持し、`"auto"` は起動時に Windows の静的プロキシ設定だけを読みます。PAC/WPAD、SOCKS のみの設定、実行中の変更は自動反映されません。TUN での成功だけでは HTTP プロキシ経路の正常性は確認できません。[コマンドと状態の説明(英語)](/reference/configuration/server/#codex-quota-network-diagnostics)を参照してください。 + +`dropCodexSafetyBuffering`: プロバイダーの安全性の適用と拒否応答は変更しません。native `codex.response.metadata.headers` WebSocket メタデータと `/responses/compact` は対象外です。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 577379fed9..5f60fc7c6c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -13,6 +13,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 데이터 admission 토큰이 필요하며, `OPENCODEX_API_AUTH_TOKEN` → `OCX_API_TOKEN_FILE` → 설치된 owner-only `service-api-token` 순서로 결정됩니다. 손으로 내보낼 값은 없습니다. [Remote access](#remote-access)를 보세요. | | `proxy?` | `string` | — | 송신용 HTTP(S) 프록시 URL 또는 `${ENV_VAR}`입니다. 해당 변수가 비어 있을 때만 `HTTP_PROXY` / `HTTPS_PROXY`에 적용되며, 루프백은 `NO_PROXY`에 그대로 남습니다. | | `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출이 없는 Responses 턴을, 터미널 이벤트 전에 스트림이 종료된 경우를 포함해 동일한 요청으로 한 번 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | Canonical Codex Responses 응답의 선택적 safety-buffering 헤더 두 개와 SSE 힌트를 제거합니다. 공급자의 안전 정책이나 거절 응답은 바뀌지 않습니다. Native WS 메타데이터와 compact는 제외됩니다. | | `stallTimeoutSec?` | `number` | `300` | 업스트림 데이터가 없을 때 `response.incomplete`가 되기까지의 초 수입니다. 최소 1입니다. | | `connectTimeoutMs?` | `number` | `200000` | 시도별 DNS/TCP/TLS/최종 헤더 기한입니다. 본문 생성 전에 끝납니다. | | `shutdownTimeoutMs?` | `number` | `5000` | 진행 중인 turn을 중단하기 전에 허용하는 정상 종료 드레인 기한입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 108d2e0925..3781d5846e 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -15,6 +15,7 @@ runs helper features around provider requests. | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL, `${ENV_VAR}`, or `"auto"`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`, `https=` then `http=` entry) once at process start and logs the host it chose. On other platforms, or when the system proxy is off, SOCKS-only, or unreadable, it uses direct egress and says so. PAC/WPAD and live proxy changes are not followed; restart the service after changing the system proxy. | | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | Remove optional client-facing hints from canonical Codex Responses passthrough: the two `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` events whose metadata type is `safety_buffering`, and top-level `safety_buffering` fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native `codex.response.metadata.headers` WebSocket metadata and `/responses/compact` are outside this filter. | | `stallTimeoutSec?` | `number` | `300` | Seconds without upstream data before `response.incomplete`. Minimum 1. | | `oauthOpenBrowser?` | `boolean` | `true` | Whether a login may open a browser on the machine running the proxy. Absent and `true` both open, so an existing install is unchanged; only an explicit `false` declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy's machine — the login still starts and the URL is still returned and displayed. `POST /api/oauth/login` and `POST /api/codex-auth/login` accept a per-request `openBrowser` boolean that overrides this, and the dashboard exposes the same choice beside the login button. Device-code flows never open a browser either way. | | `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 84b3caf183..000a194b5c 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -14,6 +14,7 @@ description: Listener, удалённый доступ, admission key, тайм | `hostname?` | `string` | `"127.0.0.1"` | Адрес bind'а. Не-loopback bind требует `OPENCODEX_API_AUTH_TOKEN`. | | `proxy?` | `string` | — | URL исходящего HTTP(S)-прокси или `${ENV_VAR}`. Применяется к `HTTP_PROXY` / `HTTPS_PROXY` только когда эти переменные не заданы; loopback всегда остаётся в `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Явно включает один идентичный повтор Responses, если в turn нет ни текста, ни tool call, включая случай, когда stream завершается до terminal event. Повтор может тарифицироваться. `OCX_EMPTY_COMPLETION_RETRY=0` отключает его без изменения config; combo и routed-compaction turn исключены. | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | Удаляет подсказки Codex safety-buffering из passthrough-ответов Codex Responses: заголовки `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model`, SSE-события `response.metadata` типа `safety_buffering` и поле `safety_buffering` в других SSE-событиях. Codex TUI отображает их как предложение повторить запрос с более быстрой моделью, действие по умолчанию в котором переключает сессию на более слабую модель. Остальные заголовки `x-codex-*` и содержимое других SSE-событий передаются без изменений, кроме удаления этого поля. По умолчанию выключено. | | `stallTimeoutSec?` | `number` | `300` | Секунды без upstream-данных до `response.incomplete`. Минимум 1. | | `connectTimeoutMs?` | `number` | `200000` | Дедлайн одной попытки DNS/TCP/TLS/final-header; он завершается до генерации тела ответа. | | `shutdownTimeoutMs?` | `number` | `5000` | Дедлайн graceful-drain до принудительного прерывания активных turn'ов. | @@ -219,3 +220,5 @@ opencodex. Перед использованием прогоните soak-test ## Сетевая диагностика квоты Codex Поле `quotaRefresh` в строке основного аккаунта Codex описывает получение квоты, а не её остаток или право доступа к модели. Оно может отсутствовать при чтении кэша или если запрос не выполнялся. Используется окружение работающего прокси-сервиса, а не текущего терминала. Если `proxy` не задан, существующее окружение сохраняется; `"auto"` читает только статические настройки прокси Windows при запуске. PAC/WPAD, настройки только SOCKS и изменения во время работы автоматически не учитываются. Успех через TUN сам по себе не подтверждает исправность пути HTTP-прокси. См. [команды и состояния на английском](/reference/configuration/server/#codex-quota-network-diagnostics). + +`dropCodexSafetyBuffering`: не меняет проверки безопасности провайдера или отказы. Native WebSocket `codex.response.metadata.headers` и `/responses/compact` не входят в область фильтра. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 4031f9ff19..8e8050de64 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -14,6 +14,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `hostname?` | `string` | `"127.0.0.1"` | 绑定地址。非回环绑定需要 `OPENCODEX_API_AUTH_TOKEN`。 | | `proxy?` | `string` | — | 出站 HTTP(S) 代理 URL,或 `${ENV_VAR}`。仅当 `HTTP_PROXY` / `HTTPS_PROXY` 未设置时才会应用;回环地址始终保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 显式启用:当 Responses turn 既无文本也无工具调用时,使用相同请求重试一次,包括流在终止事件之前结束的情况。重试可能产生费用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不修改配置的情况下禁用;combo 与 routed-compaction turn 不参与。 | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | 从 Codex Responses 透传响应中移除 Codex safety-buffering 提示:`x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 响应头、类型为 `safety_buffering` 的 `response.metadata` SSE 事件,以及其他 SSE 事件中的 `safety_buffering` 字段。Codex TUI 会将这些提示显示为“使用更快模型重试”的提示框,其默认操作会把会话切换到较弱的模型。其他 `x-codex-*` 响应头和其他所有 SSE 事件内容均保持不变,但会移除该字段。默认关闭。 | | `stallTimeoutSec?` | `number` | `300` | 在上游没有数据之前可等待的秒数,超过后返回 `response.incomplete`。最小值为 1。 | | `connectTimeoutMs?` | `number` | `200000` | 每次尝试的 DNS/TCP/TLS/最终响应头截止时间;它在正文生成之前结束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 优雅停机截止时间,超过后会中止仍在进行中的请求。 | @@ -185,3 +186,5 @@ Anthropic OAuth 侧车会复用 opencodex 现有的 Claude Code OAuth 指纹。 ## Codex 额度网络诊断 主 Codex 账户行中的 `quotaRefresh` 描述额度查询结果,并不代表剩余额度或模型访问权限。读取缓存或未执行查询时,该字段可能省略。查询使用正在运行的代理服务的环境,而不是当前终端的环境。未设置 `proxy` 时保留现有环境;`"auto"` 只在启动时读取 Windows 静态代理设置,不自动处理 PAC/WPAD、仅 SOCKS 的设置或运行中的更改。TUN 测试成功并不能单独证明 HTTP 代理路径正常。命令和状态说明见[英文网络诊断章节](/reference/configuration/server/#codex-quota-network-diagnostics)。 + +`dropCodexSafetyBuffering`: 不会改变供应商安全策略或拒绝响应。原生 WebSocket `codex.response.metadata.headers` 和 `/responses/compact` 不在过滤范围内。 diff --git a/src/config.ts b/src/config.ts index 5e81a7e5f1..a4fa6d7c02 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1253,6 +1253,8 @@ const configSchema = z.object({ configRebaseProvenance: z.unknown().optional(), // A retry can be billable, so absence and malformed hand edits both stay off. emptyCompletionRetry: z.boolean().optional().catch(false), + // Header suppression changes what Codex sees, so absence and malformed edits stay off. + dropCodexSafetyBuffering: z.boolean().optional().catch(false), // A malformed hand edit must not silently stop opening the browser: fall back // to undefined, which resolves to the historical auto-open behavior. oauthOpenBrowser: z.boolean().optional().catch(undefined), @@ -2858,6 +2860,14 @@ function emptyCompletionRetryError(value: unknown): string | null { return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted"; } +function dropCodexSafetyBufferingError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "dropCodexSafetyBuffering")) return null; + const enabled = raw.dropCodexSafetyBuffering; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted"; +} + function oauthOpenBrowserError(value: unknown): string | null { const raw = rawConfigRecord(value); if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null; @@ -3001,6 +3011,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? codexQuotaAutoRefreshError(value) ?? codexAccountPickerEnabledError(value) ?? emptyCompletionRetryError(value) + ?? dropCodexSafetyBufferingError(value) ?? oauthOpenBrowserError(value) ?? runtimeRoleError(value) ?? remoteGuiConfigError(value) @@ -4022,6 +4033,7 @@ export function getDefaultConfig(): OcxConfig { return { port: 10100, emptyCompletionRetry: false, + dropCodexSafetyBuffering: false, fastRows: true, managementUsageMaxReadBytes: 64 * 1024 * 1024, appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..3e3b89b8d9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -150,6 +150,7 @@ import { } from "./relay"; export { consumeForInspection, + codexSafetyBufferingFilterOptions, relaySseWithFailedTail, relaySseWithHeartbeat, relayWithAbort, diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts index 7844c65886..8439aea85d 100644 --- a/src/server/relay-eager.ts +++ b/src/server/relay-eager.ts @@ -26,6 +26,7 @@ import { adapterEofIncompleteFrame, + type CodexSafetyBufferingFilterOptions, createSseTerminalOutputBoundary, doneFrame, failedTailFrame, @@ -84,6 +85,8 @@ export type EagerRelayOptions = { postCancelDrainBytes?: number; /** Last known upstream failure to preserve when EOF would otherwise become adapter_eof. */ upstreamError?: string; + /** Optional client-facing hint policy; inspection retains original frames. */ + terminalBoundary?: CodexSafetyBufferingFilterOptions; /** Injectable clock for tests. */ now?: () => number; }; @@ -114,7 +117,7 @@ export function relaySseEagerBounded( const terminalEncoder = new TextEncoder(); const adapterEofFrame = adapterEofIncompleteFrame(terminalEncoder); const terminalSentinel = doneFrame(terminalEncoder); - const terminalBoundary = createSseTerminalOutputBoundary(); + const terminalBoundary = createSseTerminalOutputBoundary(opts?.terminalBoundary); const activeRewrite: SseBlockRewrite | undefined = hooks.rewriteBlocks ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined); const encodeFailedTail = (error: unknown): Uint8Array | null => { diff --git a/src/server/relay.ts b/src/server/relay.ts index a483d88f20..f480ace68a 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -183,7 +183,10 @@ export type SseTerminalOutputBoundary = { * terminal, and drops every later block/byte. A premature [DONE] is held until * a terminal arrives so clean EOF can synthesize one terminal and one sentinel. */ -export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { +export function createSseTerminalOutputBoundary( + options?: CodexSafetyBufferingFilterOptions, +): SseTerminalOutputBoundary { + const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; const decoder = new TextDecoder(); const encoder = new TextEncoder(); const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES); @@ -207,15 +210,22 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { // behind EOF, so its log context cannot determine the outgoing terminal. const message = boundedBareUpstreamErrorMessage(parsed); if (message !== undefined) upstreamError = message; + const safetyBuffering = dropSafetyBuffering && parsed !== undefined + ? codexSafetyBufferingBlockAction(parsed) : "keep"; + if (safetyBuffering === "drop") continue; const policyError = parsed !== undefined && isPolicyRewriteType(parsed) ? cyberPolicyTerminalError(parsed) : undefined; - const outboundBlock = policyError - ? encoder.encode(rewritePolicyTerminalBlock( - decoder.decode(frame.block), - policyFailurePayload(policyError, parsed), - )) + const policyPayload = policyError ? policyFailurePayload(policyError, parsed) : undefined; + let outboundBlock = policyPayload !== undefined + ? encoder.encode(rewritePolicyTerminalBlock(decoder.decode(frame.block), policyPayload)) : frame.block; + if (safetyBuffering === "strip") { + outboundBlock = encoder.encode(stripCodexSafetyBufferingField( + decoder.decode(outboundBlock), + policyPayload !== undefined ? parseSsePayload(policyPayload) : parsed, + )); + } if (isDone) { done = true; if (responsesTerminal) { @@ -287,11 +297,11 @@ export function relaySseWithFailedTail( body: ReadableStream, upstream: AbortController, onClientGone?: (reason?: unknown) => void, - opts?: { upstreamError?: string }, + opts?: { upstreamError?: string; terminalBoundary?: CodexSafetyBufferingFilterOptions }, ): ReadableStream { const reader = body.getReader(); const encoder = new TextEncoder(); - const terminalBoundary = createSseTerminalOutputBoundary(); + const terminalBoundary = createSseTerminalOutputBoundary(opts?.terminalBoundary); let closed = false; const relayChunk = ( controller: ReadableStreamDefaultController, @@ -468,6 +478,29 @@ function isPolicyRewriteType(parsed: unknown): boolean { return type === "response.failed" || type === "response.incomplete" || type === "error"; } +/** + * Codex emits its safety-buffering hint in the SSE body as well as in headers: + * a `response.metadata` event whose `metadata.type` is `safety_buffering`, or a + * `safety_buffering` field on another event. The metadata event is dropped whole; + * the field is stripped so the carrying event is otherwise relayed unchanged. + */ +function codexSafetyBufferingBlockAction(parsed: unknown): "keep" | "drop" | "strip" { + const root = asJsonRecord(parsed); + if (!root) return "keep"; + if (root.type === "response.metadata") { + const metadata = asJsonRecord(root.metadata); + if (metadata?.type === "safety_buffering") return "drop"; + } + return Object.hasOwn(root, "safety_buffering") ? "strip" : "keep"; +} + +function stripCodexSafetyBufferingField(block: string, parsed: unknown): string { + const root = asJsonRecord(parsed); + if (!root) return block; + const { safety_buffering: _safetyBuffering, ...rest } = root; + return replaceSseDataPayload(block, JSON.stringify(rest)); +} + function rewritePolicyTerminalBlock(block: string, payload: string): string { const newline = block.includes("\r\n") ? "\r\n" : "\n"; const rewritten = replaceSseDataPayload(block, payload); @@ -1461,7 +1494,31 @@ export function consumeForResponseLogMetadata( * body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough. * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim. */ -export function sanitizePassthroughHeaders(upstream: Headers): Headers { +export const CODEX_SAFETY_BUFFERING_HEADERS = [ + "x-codex-safety-buffering-enabled", + "x-codex-safety-buffering-faster-model", +] as const; + +const CODEX_SAFETY_BUFFERING_HEADER_SET: ReadonlySet = new Set(CODEX_SAFETY_BUFFERING_HEADERS); + +export interface CodexSafetyBufferingFilterOptions { + /** + * Drop Codex safety-buffering hints: the `x-codex-safety-buffering-*` response + * headers and the `safety_buffering` SSE metadata event / field. Absent and + * `false` relay everything unchanged. + */ + dropCodexSafetyBuffering?: boolean; +} + +/** Resolve the passthrough header policy from the loaded config (absent means "forward everything"). */ +export function codexSafetyBufferingFilterOptions( + config: { dropCodexSafetyBuffering?: boolean }, +): CodexSafetyBufferingFilterOptions { + return { dropCodexSafetyBuffering: config.dropCodexSafetyBuffering === true }; +} + +export function sanitizePassthroughHeaders(upstream: Headers, options?: CodexSafetyBufferingFilterOptions): Headers { + const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; const DROP = new Set([ "content-encoding", "content-length", @@ -1478,7 +1535,10 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers { ]); const out = new Headers(); upstream.forEach((value, key) => { - if (!DROP.has(key.toLowerCase())) out.set(key, value); + const lower = key.toLowerCase(); + if (DROP.has(lower)) return; + if (dropSafetyBuffering && CODEX_SAFETY_BUFFERING_HEADER_SET.has(lower)) return; + out.set(key, value); }); return out; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a10d97d282..cd1c93abda 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -342,6 +342,7 @@ import { markEagerRelaySseResponse, markNativePassthroughSseResponse, relaySseWithFailedTail, + codexSafetyBufferingFilterOptions, relayWithAbort, sanitizePassthroughHeaders, } from "../relay"; @@ -4745,6 +4746,9 @@ async function handleResponsesInner( let hostAdmissionLease = pendingHostAdmissionLease; pendingHostAdmissionLease = null; try { + const codexSafetyBufferingOptions = isCanonicalOpenAiForwardProvider(route.provider) + ? codexSafetyBufferingFilterOptions(config) + : undefined; const imageGenCallAliases = route.provider.authMode === "forward" ? new Map() : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); @@ -5818,7 +5822,7 @@ async function handleResponsesInner( } break; } - const headers = sanitizePassthroughHeaders(upstreamResponse.headers); + const headers = sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions); const resolvedModel = headers.get("openai-model")?.trim(); if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; if (isUsageDebugEnabled()) { @@ -5907,7 +5911,7 @@ async function handleResponsesInner( return new Response(upstreamResponse.body, { status: upstreamResponse.status, statusText: upstreamResponse.statusText, - headers: sanitizePassthroughHeaders(upstreamResponse.headers), + headers: sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions), }); } if (!upstreamResponse.ok) { @@ -6173,6 +6177,7 @@ async function handleResponsesInner( onDone: () => unregisterTurn(turnAc), }, { clientGoneSignal: options.abortSignal, + terminalBoundary: codexSafetyBufferingOptions, ...(inlineEagerRewrite ? { rewriteBudget: translatorBudget } : {}), ...(logCtx.upstreamError === undefined ? {} : { upstreamError: logCtx.upstreamError }), }); @@ -6264,7 +6269,7 @@ async function handleResponsesInner( responseCompletionCancelled = true; clientGone.abort(reason); }, - { upstreamError: logCtx.upstreamError }, + { upstreamError: logCtx.upstreamError, terminalBoundary: codexSafetyBufferingOptions }, ); return markNativePassthroughSseResponse(new Response(clientBody, { status: upstreamResponse.status, @@ -6395,7 +6400,7 @@ async function handleResponsesInner( } throw error; } - const sseHeaders = sanitizePassthroughHeaders(headers); + const sseHeaders = sanitizePassthroughHeaders(headers, codexSafetyBufferingOptions); sseHeaders.set("content-type", "text/event-stream"); sseHeaders.set("cache-control", "no-store"); return new Response(stream, { diff --git a/src/types/config.ts b/src/types/config.ts index acfa35f868..6248cccd2c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -374,6 +374,8 @@ export interface OcxConfig { privacy?: OcxPrivacyConfig; /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ emptyCompletionRetry?: boolean; + /** Suppress allowlisted client-facing Codex transport hints; provider enforcement is unchanged. */ + dropCodexSafetyBuffering?: boolean; /** * Whether a login may open a browser on the machine running the proxy. * diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index b0fab66633..6487e42d9d 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -63,3 +63,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/structure/catalog.md b/structure/catalog.md index bf7ef00d38..78a3275a28 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -271,3 +271,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](transports/responses.md). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 36511b6f00..fe9edd9dd7 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -82,3 +82,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..6ae3821141 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,7 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +`dropCodexSafetyBuffering` is an optional boolean, default false. Invalid API candidates reject; +malformed persisted values stay disabled. It controls only the allowlisted client-output hints +described in [Responses transport](transports/responses.md), not upstream policy or model selection. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 01d0cd4b0f..74f02d0f07 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -76,3 +76,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 45fe1c11e7..40aadf8b70 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -96,3 +96,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 952e9bd082..fa672aba9e 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -530,3 +530,6 @@ Missing, invalid or expired evidence is unknown. `gui/src/pages/Combos.tsx` wake expiry, including a deadline crossed before effects run, rechecks activation and visibility, and refreshes quota with Combo data while preserving drafts. Each successful quota snapshot also advances the observation clock, so a retained older row cannot defer evaluation of a fresh row. + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](transports/responses.md). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index b06c1ce393..cadbab667c 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -308,3 +308,6 @@ The Combo guides describe the distinction between display quota and single-crede The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 21bafe5b3b..8977ebb700 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -139,3 +139,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/structure/overview.md b/structure/overview.md index da3f2dc473..c81406dd7a 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -106,3 +106,6 @@ would pass while the rule was violated. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](transports/responses.md). diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index b8d1d56675..c5136fc3c9 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -398,3 +398,6 @@ model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. + +Canonical forwarding alone can apply the optional client-output safety-buffering hint filter; +API-key and custom forward destinations preserve their metadata. See [Responses transport](../transports/responses.md). diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5c497d4084..1162967a65 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -62,3 +62,6 @@ Account-scoped OAuth quota remains display evidence for provider-level Combo sel The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/structure/runtime.md b/structure/runtime.md index 6d733bf8b5..4f78775ea5 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -212,3 +212,6 @@ cooldowns and response-driven retry remain authoritative. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](transports/responses.md). diff --git a/structure/subagents.md b/structure/subagents.md index 662bc857d3..3ba557bc10 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -207,3 +207,6 @@ Provider-level Combo eligibility uses explicit inference evidence for the curren The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](transports/responses.md). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0348c1cdd..a514981afd 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -67,3 +67,6 @@ Quota publication distinguishes display reports from explicitly supplied inferen The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 5624a2e04e..5b56e3b57f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -520,3 +520,12 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +## Optional client transport hints + +`dropCodexSafetyBuffering` defaults to false. Canonical OpenAI forward Responses can remove only +the two safety-buffering response headers, matching response.metadata events and top-level +safety_buffering fields. Pull/eager client output boundaries compose this with policy failure +normalization; refusal/error semantics, retryability, cancellation and captured EOF errors remain +intact. Internal inspection observes original upstream frames. Native codex.response.metadata.headers +WebSocket metadata and compact are excluded. This does not disable upstream safety enforcement. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index ae20c7c7ea..a765b5e7a2 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -196,3 +196,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Optional Codex transport-hint suppression is scoped to canonical Responses client output; +its defaults and exclusions are owned by [Responses transport](../transports/responses.md). diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index d5a49fd39e..f6b411ec4e 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -335,6 +335,55 @@ test("canonical forward providers normalize trailing slashes and let the pool ov expect(request.headers["chatgpt-account-id"]).toBe("runtime-account"); }); +test("noncanonical Responses preserves provider-owned safety-buffering hints", async () => { + const upstream = [ + 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_custom"},"safety_buffering":{"provider_owned":true}}\n\n', + 'event: response.metadata\ndata: {"type":"response.metadata","metadata":{"type":"safety_buffering","provider_owned":true}}\n\n', + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_custom","status":"completed","output":[]}}\n\n', + "data: [DONE]\n\n", + ].join(""); + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(upstream, { headers: { + "content-type": "text/event-stream", + "x-codex-safety-buffering-enabled": "provider-owned", + "x-codex-safety-buffering-faster-model": "provider-model", + } })) as typeof fetch; + try { + for (const providerConfig of [ + { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key" as const, + apiKey: "fixture-key", + }, + { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "forward" as const, + headers: { authorization: "Bearer provider-static" }, + }, + ]) { + const config = { + port: 0, + defaultProvider: "fixture", + dropCodexSafetyBuffering: true, + providers: { fixture: providerConfig }, + } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", stream: true, input: "ping" }), + }), config, { model: "", provider: "" }); + + expect(response.headers.get("x-codex-safety-buffering-enabled")).toBe("provider-owned"); + expect(response.headers.get("x-codex-safety-buffering-faster-model")).toBe("provider-model"); + expect(await response.text()).toBe(upstream); + } + } finally { + globalThis.fetch = savedFetch; + } +}); + test("noncanonical pool-required providers use only their configured static credentials", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", @@ -4533,3 +4582,31 @@ describe("raw usage passthrough on the forward path (#41980 parity, #37138 adjac } }); }); + + +test("canonical Responses hint suppression is opt-in at the request boundary", async () => { + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response([ + 'data: {"type":"response.created","response":{"id":"resp_hint"},"safety_buffering":true}\n\n', + 'data: {"type":"response.metadata","metadata":{"type":"safety_buffering"}}\n\n', + 'data: {"type":"response.completed","response":{"id":"resp_hint","status":"completed","output":[]}}\n\n', + ].join(""), { headers: { "content-type": "text/event-stream", + "x-codex-safety-buffering-enabled": "true", "x-codex-safety-buffering-faster-model": "fixture-model", "x-codex-turn-id": "fixture-turn" } })) as typeof fetch; + try { + for (const dropCodexSafetyBuffering of [undefined, false, true]) { + const config = { port: 0, dropCodexSafetyBuffering, providers: { openai: { + ...provider, codexAccountMode: "direct", upstreamWebsocket: false, + } } } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", authorization: "Bearer fixture-forward-token" }, + body: JSON.stringify({ model: "openai/gpt-5.6-sol", input: "ping", stream: true }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(response.headers.has("x-codex-safety-buffering-enabled")).toBe(dropCodexSafetyBuffering !== true); + expect(response.headers.get("x-codex-turn-id")).toBe("fixture-turn"); + const text = await response.text(); + expect(text.includes("safety_buffering")).toBe(dropCodexSafetyBuffering !== true); + expect(text).toContain("response.completed"); + } + } finally { globalThis.fetch = savedFetch; } +}); diff --git a/tests/responses/passthrough-abort.test.ts b/tests/responses/passthrough-abort.test.ts index 46100c6902..913da3eadd 100644 --- a/tests/responses/passthrough-abort.test.ts +++ b/tests/responses/passthrough-abort.test.ts @@ -79,7 +79,7 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { expect(sseBranch).toContain("rewriteBlocks: clientBlockRewrite"); // Elsewhere the failed-tail relay converts mid-stream resets into a clean response.failed. expect(sseBranch).toMatch( - /relaySseWithFailedTail\(\s*rewrittenBody,\s*upstream,\s*reason\s*=>\s*\{\s*responseCompletionCancelled\s*=\s*true;\s*clientGone\.abort\(reason\);\s*\},\s*\{\s*upstreamError:\s*logCtx\.upstreamError\s*\},\s*\)/, + /relaySseWithFailedTail\(\s*rewrittenBody,\s*upstream,\s*reason\s*=>\s*\{\s*responseCompletionCancelled\s*=\s*true;\s*clientGone\.abort\(reason\);\s*\},\s*\{\s*upstreamError:\s*logCtx\.upstreamError,\s*terminalBoundary:\s*codexSafetyBufferingOptions\s*\},\s*\)/, ); expect(sseBranch).toContain("new Response(clientBody"); expect(sseBranch).toContain("markNativePassthroughSseResponse"); diff --git a/tests/responses/passthrough-headers.test.ts b/tests/responses/passthrough-headers.test.ts index 8018e77906..2cd5261609 100644 --- a/tests/responses/passthrough-headers.test.ts +++ b/tests/responses/passthrough-headers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { sanitizePassthroughHeaders } from "../../src/server"; +import { codexSafetyBufferingFilterOptions, sanitizePassthroughHeaders } from "../../src/server"; +import { createSseTerminalOutputBoundary } from "../../src/server/relay"; describe("passthrough header sanitization (RC5 / F4)", () => { test("content-type: text/event-stream survives sanitization", () => { @@ -43,3 +44,75 @@ describe("passthrough header sanitization (RC5 / F4)", () => { expect(sanitized.get("content-type")).toBe("text/event-stream"); }); }); + +describe("codex safety-buffering hint headers", () => { + const upstream = () => new Headers({ + "content-type": "text/event-stream", + "x-codex-safety-buffering-enabled": "true", + "X-Codex-Safety-Buffering-Faster-Model": "gpt-5.6-luna", + "x-codex-primary-used-percent": "12", + "openai-model": "gpt-6-astra", + }); + + test("forwarded verbatim by default and when the option is off", () => { + for (const options of [undefined, {}, { dropCodexSafetyBuffering: false }]) { + const sanitized = sanitizePassthroughHeaders(upstream(), options); + expect(sanitized.get("x-codex-safety-buffering-enabled")).toBe("true"); + expect(sanitized.get("x-codex-safety-buffering-faster-model")).toBe("gpt-5.6-luna"); + } + }); + + test("dropped case-insensitively when opted in, other x-codex headers survive", () => { + const sanitized = sanitizePassthroughHeaders(upstream(), { dropCodexSafetyBuffering: true }); + expect(sanitized.has("x-codex-safety-buffering-enabled")).toBe(false); + expect(sanitized.has("x-codex-safety-buffering-faster-model")).toBe(false); + expect(sanitized.get("x-codex-primary-used-percent")).toBe("12"); + expect(sanitized.get("openai-model")).toBe("gpt-6-astra"); + expect(sanitized.get("content-type")).toBe("text/event-stream"); + }); + + test("codexSafetyBufferingFilterOptions only enables the drop on an explicit true", () => { + expect(codexSafetyBufferingFilterOptions({})).toEqual({ dropCodexSafetyBuffering: false }); + expect(codexSafetyBufferingFilterOptions({ dropCodexSafetyBuffering: false })) + .toEqual({ dropCodexSafetyBuffering: false }); + expect(codexSafetyBufferingFilterOptions({ dropCodexSafetyBuffering: true })) + .toEqual({ dropCodexSafetyBuffering: true }); + }); +}); + +describe("Codex safety-buffering SSE hints at the client output boundary", () => { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const frames = [ + 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_1"},"safety_buffering":{"retry_model":"gpt-5.6-luna"}}\n\n', + 'event: response.metadata\ndata: {"type":"response.metadata","metadata":{"type":"safety_buffering","retry_model":"gpt-5.6-luna"}}\n\n', + 'event: response.metadata\ndata: {"type":"response.metadata","metadata":{"type":"other","turn":1}}\n\n', + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"hi"}\n\n', + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}\n\n', + ]; + const relay = (options?: { dropCodexSafetyBuffering?: boolean }): string => { + const boundary = createSseTerminalOutputBoundary(options); + let out = ""; + for (const frame of frames) out += decoder.decode(boundary.feed(encoder.encode(frame))); + out += decoder.decode(boundary.finish()); + boundary.dispose(); + return out; + }; + + test("relayed verbatim by default and when the option is off", () => { + for (const options of [undefined, {}, { dropCodexSafetyBuffering: false }]) { + expect(relay(options)).toBe(frames.join("")); + } + }); + + test("metadata event dropped and field stripped when opted in, other events untouched", () => { + const out = relay({ dropCodexSafetyBuffering: true }); + expect(out).not.toContain("safety_buffering"); + expect(out).not.toContain("gpt-5.6-luna"); + expect(out).toContain('data: {"type":"response.created","response":{"id":"resp_1"}}'); + expect(out).toContain(frames[2]); + expect(out).toContain(frames[3]); + expect(out).toContain(frames[4]); + expect(out.match(/^event: /gm)).toHaveLength(4); + }); +}); diff --git a/tests/responses/sse-failed-tail.test.ts b/tests/responses/sse-failed-tail.test.ts index f21301ffb7..0a38424f42 100644 --- a/tests/responses/sse-failed-tail.test.ts +++ b/tests/responses/sse-failed-tail.test.ts @@ -422,3 +422,43 @@ describe("relaySseWithFailedTail", () => { expect(out).not.toContain("event: response.failed"); }); }); + + +describe("optional Codex hint filtering preserves relay semantics", () => { + for (const eager of [false, true]) { + const relay = (chunks: string[], upstreamError?: string) => eager + ? relaySseEagerBounded(sourceStream(chunks), new AbortController(), parityHooks, + { upstreamError, terminalBoundary: { dropCodexSafetyBuffering: true } }) + : relaySseWithFailedTail(sourceStream(chunks), new AbortController(), undefined, + { upstreamError, terminalBoundary: { dropCodexSafetyBuffering: true } }); + test(`policy failure plus hint is composed, eager=${eager}`, async () => { + const frame = `event: error\r\ndata: ${JSON.stringify({ type: "error", safety_buffering: { enabled: true }, + error: { code: "cyber_policy", message: "blocked by upstream policy", type: "invalid_request_error" } })}\r\n\r\n`; + const text = await drain(relay([frame.slice(0, 19), frame.slice(19)])); + expect(text).not.toContain("safety_buffering"); + expect(text).toContain('"type":"response.failed"'); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).toContain('"retryable":false'); + expect(text.match(/data: \[DONE\]/g)).toHaveLength(1); + }); + test(`metadata removal retains other frames and one terminal, eager=${eager}`, async () => { + const metadata = 'data: {"type":"response.metadata","metadata":{"type":"safety_buffering"}}\n\n'; + const other = 'data: {"type":"codex.response.metadata","headers":{"x-codex-safety-buffering-enabled":"true"}}\n\n'; + const malformed = 'data: {malformed}\n\n'; + const terminal = 'data: {"type":"response.completed","response":{"status":"completed"},"safety_buffering":true}\n\ndata: [DONE]\n\n'; + const text = await drain(relay([metadata.slice(0, 7), metadata.slice(7), other, malformed, terminal])); + expect(text).not.toContain('"type":"safety_buffering"'); + expect(text).not.toContain('"safety_buffering":true'); + expect(text).toContain(other); + expect(text).toContain(malformed); + expect(text).toContain('"type":"response.completed"'); + expect(text.match(/data: \[DONE\]/g)).toHaveLength(1); + }); + test(`hint-only EOF preserves captured error fallback, eager=${eager}`, async () => { + const text = await drain(relay(['data: {"type":"response.metadata","metadata":{"type":"safety_buffering"}}\n\n'], "provider unavailable")); + expect(text).toContain("provider unavailable"); + expect(text).not.toContain("adapter_eof"); + expect(text).not.toContain("safety_buffering"); + }); + } +}); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index b096b5857c..13cc92f708 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -777,6 +777,19 @@ describe("opencodex config defaults", () => { }); }); + test("codex safety-buffering header drop is an explicit top-level opt-in", () => { + const defaults = getDefaultConfig(); + expect(defaults.dropCodexSafetyBuffering).toBe(false); + expect(validateConfigCandidate({ ...defaults, dropCodexSafetyBuffering: true })).toMatchObject({ + ok: true, + config: { dropCodexSafetyBuffering: true }, + }); + expect(validateConfigCandidate({ ...defaults, dropCodexSafetyBuffering: "yes" })).toMatchObject({ + ok: false, + error: expect.stringContaining("dropCodexSafetyBuffering"), + }); + }); + test("usage and MCP config overrides change the effective bound while defaults remain compatible", () => { const defaults = getDefaultConfig(); expect(defaults.managementUsageMaxReadBytes).toBe(64 * 1024 * 1024); From e966c9b31af5ccf40b0c9bff2c36624a57a1b365 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:36:58 +0900 Subject: [PATCH 023/224] docs: plan thinking presentation and transport carries --- .../260912_thinking_contract/000_plan.md | 31 ++ .../260912_thinking_contract/001_sources.md | 7 + .../010_presentation.md | 316 +++++++++++++++++ .../020_transport_hint.md | 319 ++++++++++++++++++ .../260912_thinking_contract/030_spark.md | 66 ++++ .../260912_thinking_contract/040_delivery.md | 7 + 6 files changed, 746 insertions(+) create mode 100644 devlog/_plan/260912_thinking_contract/000_plan.md create mode 100644 devlog/_plan/260912_thinking_contract/001_sources.md create mode 100644 devlog/_plan/260912_thinking_contract/010_presentation.md create mode 100644 devlog/_plan/260912_thinking_contract/020_transport_hint.md create mode 100644 devlog/_plan/260912_thinking_contract/030_spark.md create mode 100644 devlog/_plan/260912_thinking_contract/040_delivery.md diff --git a/devlog/_plan/260912_thinking_contract/000_plan.md b/devlog/_plan/260912_thinking_contract/000_plan.md new file mode 100644 index 0000000000..86a87f9d36 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/000_plan.md @@ -0,0 +1,31 @@ +# Preserve reasoning provenance and transport intent + +Readers: maintainers choosing whether to integrate the thinking lane. Raw reasoning must remain content, while provider-authored summaries can be displayed under an explicit provider default. The plan reconciles #4301 and #4287, separately reviews #3652 hint suppression, and carries #4130 Spark compatibility without retirement. + +Loop: satisfy-spec HOTL, triggered by authorized thinking-lane delivery. Goal: reviewable carry PRs and final-head hosted CI. Non-goals: merges, closure of source PRs, retirement #4334, releases, user service/config changes, other worktrees. All local product suites/build/typecheck/install are NOT RUN by instruction. Only available existing credentials/tools are used; no user token/time/agent ceiling was set. Stop: every source PR has a justified disposition and every delivered branch has exact-head hosted CI evidence. Outcomes: DONE on evidence, HOLD/NEEDS_HUMAN on explicit unresolved acceptance, never fake green. Escalation: real tool denial or requirement beyond scope; main reclaims after two distinct reviewer failures. Native architect selector is unavailable; inherited independent design review and reflection follow the user instruction, with a separate A audit. + +## Dependency map + +| Cycle | Artifact | Result | +| --- | --- | --- | +| roadmap | this file and all decade docs | docs-only plan lock | +| presentation | 010_presentation.md | raw/summary contract and provider opt-in | +| hint | 020_transport_hint.md | independent transport-hint disposition/carry | +| spark | 030_spark.md | independent Spark Lite carry | +| delivery | 040_delivery.md | final heads, review closure and hosted CI | + +Presentation combines two conflicting source proposals into one contract. Hint and Spark are independent and receive ordinary dev-based PRs, not artificial stack dependencies. Final review consumes all branches. No GitHub native stacks are requested. + +## Evidence and owner map + +Baseline origin/dev: 69e3dcda755a52feb1327edad6c8ea6cefd6e871. Source PR heads: #4301 5d6d1862a11da6e4d0c04eb7f35f9f48ae1285fd; #4287 fe13bdb7bf8403a2a2cdb10f258a68b649177953; #3652 13fb263778e9036e66ae86d41e29f9f47bbbed92; #4130 5d56f5461ea3d18668b85f6bb0d8a523920f2536. All open when inspected. Original authors: Robin Bially, yxr1995-maker, itismyfield, luvs01; exact Git trailers will be read from original commits before carrying. + +Current owners: src/bridge.ts:663 raw-reasoning finalization; src/adapters/google.ts:571 shared part classifier; src/server/responses/core.ts:2490 final-route normalization; src/responses/parser.ts:543 summary omission policy; src/types/request.ts:310 AdapterEvent. Reuse these boundaries; no new event enum or generic service layer. Structure INDEX maps shared areas to topical documents; main contracts are providers/chat-compat.md, providers/google.md, transports/responses.md and config.md with references from affected area owners. + +Verification: git diff --check was run at baseline and exited 0, checking diff whitespace only. GitHub ci.yml workflow_dispatch lane=all reads checkout source, typechecks, runs product suites and cross-platform jobs; NOT RUN locally. Every conditional scenario is named in decade docs and must be asserted in committed regression tests. Source inspection is not runtime proof. + +## Cycle records + +Roadmap P: requirements/source inspection and independent design review in progress. No product patch applied. + +Roadmap B: locked amended contract after independent A PASS and both design reflections ALIGNED. Product implementation starts in the next cycle. diff --git a/devlog/_plan/260912_thinking_contract/001_sources.md b/devlog/_plan/260912_thinking_contract/001_sources.md new file mode 100644 index 0000000000..f05813eae8 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/001_sources.md @@ -0,0 +1,7 @@ +# Source decisions + +Public PR diffs and latest comments are the source proposal evidence. #4130's September 11 corrections pin Lite on for nonempty additional_tools bodies and off otherwise; adopting the earlier unconditional false version loses tools. #4334 is an explicit retirement HOLD and is not carried. + +#4301 removes automatic content-to-summary conversion. #4287 tests raw DeepSeek content as a visible summary; that expectation conflicts with provenance and will be replaced, not adopted. Google thought-summary API documentation distinguishes summaries from opaque thought signatures: https://ai.google.dev/gemini-api/docs/generate-content/thinking (opened 2026-09-12). CCA generationConfig/includeThoughts behavior is contributor probe evidence, not a newly performed live-service probe. + +Searches used: reasoning_raw_delta, thinking_delta, hideThinkingSummary, googlePartTextEvent, preserveReasoningContent, and the four PR numbers. Existing bridge event types can distinguish raw content and summary without a new enum. No-code/config-only options cannot repair the existing mislabeled content; raw rewrite deletion plus existing boundaries is the smallest change. diff --git a/devlog/_plan/260912_thinking_contract/010_presentation.md b/devlog/_plan/260912_thinking_contract/010_presentation.md new file mode 100644 index 0000000000..e39e9c0bfe --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/010_presentation.md @@ -0,0 +1,316 @@ +# Presentation contract + +Class C4 public protocol contract. Depends on roadmap lock. MODIFY src/bridge.ts: closeCurrentRawReasoning and reasoning_raw_delta emit response.reasoning_text.delta/done with content_index:0; final items use summary:[] and content:[{type:reasoning_text,text}]. buildResponseJSONWithBudget mirrors this. Keep hidden txt-only replay envelopes intact. DELETE src/server/responses-reasoning-summary-rewrite.ts and its obsolete unit test; MODIFY core.ts to remove imports and SSE/JSON content-to-summary rewrites. MODIFY both layout manifests to remove that test. Adopt the exact #4301 hunks below except reporter video and historical verification record. + +MODIFY provider.ts, registry.ts, derive.ts, router.ts and auth-cors.ts to carry showThinkingSummary boolean (preserve explicit false). Seed only google-antigravity true. Creation: provider config/registry; serialization: providerConfigSeed and deriveKeyLoginMap; deserialization: config provider passthrough and management field policy; consumers: routedProviderConfig, final-route normalization, Google request builder. No new enum. + +MODIFY core.ts final-route normalization: apply provider default only when original reasoning.summary is omitted, never explicit none; recompute on each final route so fallback cannot inherit another provider default. Provider opt-in authorizes summary display, not raw-to-summary conversion. + +MODIFY google.ts shared part classifier to use existing thinking_delta only for Gemini thought summaries under verified Gemini model provenance; CCA Claude/gpt-oss thought text remains reasoning_raw_delta. Persist request-local Gemini identity using existing adapter state, used by both stream and buffered classifier calls. includeThoughts stays provider-opted, Gemini-only, non-image and explicit-hide aware. MODIFY google-wire-compiler.ts to retain only boolean true includeThoughts, independently of thinkingLevel. Do not claim raw text is an actual summary. + +MODIFY the #4287 end-to-end fixture: raw DeepSeek content remains content with empty summary even under provider opt-in; actual CCA Gemini thought parts use summary; omitted vs none vs auto, explicit provider false, saved-row enrichment, fallback reset, streaming/buffered paths. Extend existing Google tests and bridge raw tests; both layout manifests register responses-show-thinking-summary.test.ts. Update English providers docs and structure owners, keeping locale statements consistent. Source tests are authored but run only by hosted CI. + +Acceptance: raw event fixture => content delta and no summary delta; actual Gemini summary fixture => summary only when requested/provider-opted; explicit none => no synthesized summary and no includeThoughts request; false/unknown provider => no opt-in; fallback to unopted route => hidden behavior reset; replay envelope decodes same raw text and tool continuation remains valid; native Responses mixed content/summary remains byte-semantically native. No model prose synthesizer is introduced. + +## Source patch blueprint + +```diff +diff --git a/src/bridge.ts b/src/bridge.ts +index 20e7c3fe09..bc90f35b94 100644 +--- a/src/bridge.ts ++++ b/src/bridge.ts +@@ -663,16 +663,13 @@ export function bridgeToResponsesSSE( + const closeCurrentRawReasoning = () => { + if (!currentRawReasoning) return; + rawReasoningForNextToolCall = currentRawReasoning.text; +- emit("response.reasoning_summary_text.done", { +- item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, text: currentRawReasoning.text, +- }); +- emit("response.reasoning_summary_part.done", { +- item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, +- part: { type: "summary_text", text: currentRawReasoning.text }, ++ emit("response.reasoning_text.done", { ++ item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, content_index: 0, text: currentRawReasoning.text, + }); + const item = { + type: "reasoning", id: currentRawReasoning.itemId, +- summary: [{ type: "summary_text", text: currentRawReasoning.text }], ++ summary: [] as never[], ++ content: [{ type: "reasoning_text", text: currentRawReasoning.text }], + }; + emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item }); + retainFinishedItem(item as OutputItem, currentRawReasoning.textBytes, "reasoning"); +@@ -1111,10 +1108,6 @@ export function bridgeToResponsesSSE( + const itemId = `rs_${uuid()}`; + const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] }; + emit("response.output_item.added", { output_index: outputIndex, item }); +- emit("response.reasoning_summary_part.added", { +- item_id: itemId, output_index: outputIndex, summary_index: 0, +- part: { type: "summary_text", text: "" }, +- }); + currentRawReasoning = { itemId, outputIndex, text: "", textBytes: 0 }; + } + ({ value: currentRawReasoning.text, bytes: currentRawReasoning.textBytes } = appendString( +@@ -1123,9 +1116,13 @@ export function bridgeToResponsesSSE( + event.text, + "reasoning", + )); +- emit("response.reasoning_summary_text.delta", { ++ // Raw reasoning (openai-chat reasoning_content, kiro tags) rides the CONTENT ++ // channel, matching native gpt-oss passthrough: Codex applies its own display ++ // policy, so the desktop band shows the "Thinking…" placeholder instead of the ++ // raw CoT (the #45 summary-channel display intent is intentionally reverted). ++ emit("response.reasoning_text.delta", { + item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, +- summary_index: 0, delta: event.text, ++ content_index: 0, delta: event.text, + }); + break; + } +@@ -1780,7 +1777,8 @@ function buildResponseJSONWithBudget( + } + pushOutput({ + type: "reasoning", id: `rs_${uuid()}`, +- summary: [{ type: "summary_text", text: currentRawReasoning }], ++ summary: [], ++ content: [{ type: "reasoning_text", text: currentRawReasoning }], + }, currentRawReasoningBytes, "reasoning"); + currentRawReasoning = ""; + currentRawReasoningBytes = 0; + +``` + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts +index 88c482ba7d..aa835e50b4 100644 +--- a/src/adapters/google-wire-compiler.ts ++++ b/src/adapters/google-wire-compiler.ts +@@ -130,12 +130,20 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { + ))].slice(0, 5); + if (stopSequences.length > 0) out.stopSequences = stopSequences; + } +- if (isObject(value.thinkingConfig) && typeof value.thinkingConfig.thinkingLevel === "string") { +- const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); +- const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) +- ? raw +- : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); +- if (thinkingLevel) out.thinkingConfig = { thinkingLevel }; ++ if (isObject(value.thinkingConfig)) { ++ const thinking: JsonObject = {}; ++ if (typeof value.thinkingConfig.thinkingLevel === "string") { ++ const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); ++ const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) ++ ? raw ++ : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); ++ if (thinkingLevel) thinking.thinkingLevel = thinkingLevel; ++ } ++ // The one key that makes Google return `thought: true` text. Cloud Code Assist serves ++ // thinking either way (thoughtsTokenCount stays non-zero) but withholds the text unless the ++ // request opts in, so dropping it here silently reinstates the missing-thinking behavior. ++ if (value.thinkingConfig.includeThoughts === true) thinking.includeThoughts = true; ++ if (Object.keys(thinking).length > 0) out.thinkingConfig = thinking; + } + if (Array.isArray(value.responseModalities)) { + const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); +diff --git a/src/adapters/google.ts b/src/adapters/google.ts +index 7fcc88ba59..5a6675f6e4 100644 +--- a/src/adapters/google.ts ++++ b/src/adapters/google.ts +@@ -866,11 +866,27 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte + ); + antigravityModel = wireModelId; + antigravitySession = sessionId; ++ // Gemini returns no chain-of-thought TEXT unless the request opts in. Probed against CCA ++ // 2026-09-12: `gemini-3.8-flash-high` answered with thoughtsTokenCount=321 and zero ++ // `thought` parts, then 358-652 chars of genuine reasoning once includeThoughts was set. ++ // Scoped to Gemini wire ids — Claude-on-CCA accepts the flag but never returns thought ++ // parts, and gpt-oss rejects it outright (400 INVALID_ARGUMENT, which would break every ++ // gpt-oss turn). Gated on the provider's visible-thinking opt-in so a user who wants ++ // thinking hidden does not pay conversation-history tokens for text nobody renders; ++ // `hideThinkingSummary !== true` is the same per-request gate the response path uses, so ++ // a client that explicitly asked for hidden thinking is not billed for the text either. ++ const includeThoughts = provider.showThinkingSummary === true ++ && parsed.options.hideThinkingSummary !== true ++ && /^gemini-/.test(wireModelId) ++ && !isImageCapableModel(parsed.modelId); + // Effort → thinkingConfig for CCA (CLIProxyAPI proven: request.generationConfig.thinkingConfig). + // Suffix/compat IDs return thinkingLevel=undefined — the suffix IS the effort, no contradiction. +- if (thinkingLevel) { ++ if (thinkingLevel || includeThoughts) { + const gc = (body.generationConfig ?? {}) as Record; +- gc.thinkingConfig = { thinkingLevel }; ++ gc.thinkingConfig = { ++ ...(thinkingLevel ? { thinkingLevel } : {}), ++ ...(includeThoughts ? { includeThoughts: true } : {}), ++ }; + body.generationConfig = gc; + } + // Reasoning continuity: Gemini models re-inject cached thoughtSignatures; Claude-on-Antigravity +diff --git a/src/providers/derive.ts b/src/providers/derive.ts +index 67e6c0522e..7edf28787b 100644 +--- a/src/providers/derive.ts ++++ b/src/providers/derive.ts +@@ -43,6 +43,7 @@ export interface DerivedKeyLoginProvider { + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; ++ showThinkingSummary?: boolean; + reasoningSplitModels?: string[]; + reasoningDetailsModels?: string[]; + thinkingToggleModels?: string[]; +@@ -267,6 +268,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon + ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), + ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), + ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ++ ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), + ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), + ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), + ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), +@@ -315,6 +317,7 @@ export function deriveKeyLoginMap(): Record { + ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), + ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), + ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ++ ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), + ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), + ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), + ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), +@@ -567,6 +570,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig + if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels]; + if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels]; + if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames; ++ if (prov.showThinkingSummary === undefined && seed.showThinkingSummary !== undefined) prov.showThinkingSummary = seed.showThinkingSummary; + if (prov.keyOptional === undefined && seed.keyOptional !== undefined) prov.keyOptional = seed.keyOptional; + if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier; + if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip; +diff --git a/src/providers/registry.ts b/src/providers/registry.ts +index f72bb7650b..e483e9db23 100644 +--- a/src/providers/registry.ts ++++ b/src/providers/registry.ts +@@ -343,6 +343,10 @@ export interface ProviderRegistryEntry { + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; ++ /** ++ * Opt this provider into visible thinking summaries (see OcxProviderConfig.showThinkingSummary). ++ */ ++ showThinkingSummary?: boolean; + reasoningSplitModels?: string[]; + reasoningDetailsModels?: string[]; + thinkingToggleModels?: string[]; +@@ -367,7 +371,7 @@ export type ProviderConfigSeed = Pick< + | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" + | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" + | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" +- | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" ++ | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" + | "googleMode" | "project" | "location" | "headers" + >; + +@@ -2045,7 +2049,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ + // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would + // retarget a user's custom base back to Google. A leading `./` is required because a bare + // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. +- { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, ++ { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", showThinkingSummary: true, jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, + { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, + { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, + { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, +diff --git a/src/router.ts b/src/router.ts +index 55a0326fce..bf2b9b4b98 100644 +--- a/src/router.ts ++++ b/src/router.ts +@@ -410,6 +410,13 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider + ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined + ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } + : {}), ++ // The request path resolves through routedProviderConfig() and never calls ++ // enrichProviderFromRegistry(), so a saved provider row written before the ++ // registry learned this flag must be backfilled here or route.provider never ++ // carries it and the showThinkingSummary opt-in stays dead. ++ ...(provider.showThinkingSummary === undefined && registryEntry.showThinkingSummary !== undefined ++ ? { showThinkingSummary: registryEntry.showThinkingSummary } ++ : {}), + // Registry-only client-facing repair policy (#938): fill only when the + // saved provider has no explicit policy; clone so runtime never aliases + // the registry constant. +diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts +index 3a93246cd0..93377b5573 100644 +--- a/src/server/auth-cors.ts ++++ b/src/server/auth-cors.ts +@@ -885,6 +885,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { + autoToolChoiceOnlyModels: "editor", + preserveReasoningContentModels: "editor", + requiresReasoningPlaceholderModels: "editor", ++ showThinkingSummary: "editor", + retryOn429: "editor", + transientRetryOn5xx: "editor", + reasoningSplitModels: "editor", +diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts +index cccd942026..852cd9f8b0 100644 +--- a/src/server/responses/core.ts ++++ b/src/server/responses/core.ts +@@ -2467,6 +2467,20 @@ async function resolveSubagentFallbackModelEligibility(args: { + }; + } + ++/** ++ * Whether the client explicitly asked for hidden thinking (`reasoning.summary: "none"`). ++ * ++ * Pinned: parseRequest collapses "omitted" and "none" into one hideThinkingSummary ++ * flag, so the raw request body is the ONLY place that still distinguishes them. ++ * Provider opt-ins like showThinkingSummary must consult this — never the flag ++ * alone — or a future caller that copies only the flag would silently unlock an ++ * explicit opt-out. ++ */ ++function clientExplicitlyHidThinking(parsed: OcxParsedRequest): boolean { ++ const rawReasoning = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning; ++ return typeof rawReasoning === "object" && rawReasoning !== null ++ && (rawReasoning as { summary?: unknown }).summary === "none"; ++} + /** + * Apply every route-dependent request mutation against the final selected route. + * Must run only after subagent fallback has settled the model/provider. +@@ -2508,6 +2522,15 @@ async function applyFinalRouteRequestNormalization(args: { + // this request will actually use (#404). + route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); + route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); ++ // Provider-opted visible thinking (e.g. google-antigravity): parseRequest hides thinking ++ // whenever the client omits reasoning.summary, which is the Codex default. A provider that ++ // serves genuine user-facing reasoning opts back into the summary channel here, so thought ++ // parts (Gemini thought, content-channel reasoning_text) reach the client instead of only ++ // the hidden replay envelopes. An explicit client reasoning.summary "none" still wins. ++ if (route.provider.showThinkingSummary === true && parsed.options.hideThinkingSummary === true ++ && !clientExplicitlyHidThinking(parsed)) { ++ parsed.options.hideThinkingSummary = false; ++ } + if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; + logCtx.model = route.modelId; + logCtx.provider = route.providerName; +diff --git a/src/types/provider.ts b/src/types/provider.ts +index e65130a4fa..b6374a991a 100644 +--- a/src/types/provider.ts ++++ b/src/types/provider.ts +@@ -746,6 +746,15 @@ export interface OcxProviderConfig { + * out explicitly (e.g. MiniMax, where low effort disables thinking). + */ + requiresReasoningPlaceholderModels?: string[]; ++ /** ++ * Opt-in: surface upstream thinking as visible reasoning summaries even when the ++ * client did not send `reasoning.summary`. parseRequest hides thinking by default ++ * (Codex omits the field), which strands genuine reasoning — e.g. Gemini `thought` ++ * parts on the google-antigravity (Cloud Code Assist) wire — in hidden replay ++ * envelopes. An explicit client `reasoning.summary: "none"` still wins. Set `false` ++ * to opt a seeded preset back out. ++ */ ++ showThinkingSummary?: boolean; + /** + * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only, + * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays + +``` + +## Reflection corrections accepted + +Explicit wire reasoning.summary:"none" wins. A client that serializes configured none as omission cannot be distinguished from unspecified preference. No client config rewrite or global catalog summary default changes. Summary classification is limited to built CCA Gemini requests; unknown/uninitialized, direct Google/Vertex and CCA Claude/gpt-oss remain raw. Streaming and buffered summary-to-tool continuations assert exact Google signature on correct call; never emit Google signatures as Anthropic thinking_signature. Hidden unsigned summaries may disappear but required tool replay state survives. Exercise final assistant text and terminal order, fallback in both directions, and remove replay-comparison rewrite alongside SSE/JSON rewrite. Desktop appearance remains client-controlled; source patch comments claiming an unconditional placeholder are replaced during adoption. diff --git a/devlog/_plan/260912_thinking_contract/020_transport_hint.md b/devlog/_plan/260912_thinking_contract/020_transport_hint.md new file mode 100644 index 0000000000..335e339a00 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/020_transport_hint.md @@ -0,0 +1,319 @@ +# Optional hint suppression + +Class C4 review because client metadata policy changes. Independent of presentation; depends only on roadmap. Adopt #3652 only after independent security/transport review. Public proposal removes exactly two x-codex-safety-buffering headers, metadata.type=safety_buffering events and top-level safety_buffering fields at the client relay boundary. Default false; malformed config must remain off and candidate validation rejects nonbooleans. This suppresses optional transport hints; provider safety decisions/refusals and upstream checks are unchanged. Compact and independent WS/other-provider pathways retain existing policy unless a directly exercised shared boundary already applies. + +MODIFY src/config.ts and src/types/config.ts for validated boolean/default; src/server/relay.ts for allowlisted header removal and SSE terminal-boundary transformation; relay-eager.ts for option forwarding; core.ts to compute option only for canonical OpenAI forward destination and pass it to all relevant headers/client output boundaries; index.ts exports if needed by existing test style. Do not apply to custom gateway/key providers. Preserve errors, response.failed/incomplete and terminal sentinel handling. + +MODIFY tests/responses/passthrough-headers.test.ts, openai-responses-passthrough.test.ts and tests/server/config.test.ts. Scenarios: absent/false/true/malformed config; uppercase headers; unrelated headers; split metadata frames; actual failure carrying hint must still fail; noncanonical provider has identical fields and retains them; eager/non-eager client paths. Add missing canonical route coverage if independent review identifies it. MODIFY English/ja/ko/ru/zh-cn server configuration docs and structure owners. Avoid unsupported claims about models being weaker or provider safety bypass. + +Before/after anchor: createSseTerminalOutputBoundary() -> createSseTerminalOutputBoundary(options?: CodexSafetyBufferingFilterOptions); sanitizePassthroughHeaders(upstream) -> sanitizePassthroughHeaders(upstream, options?); canonical true => filter option, every other provider => undefined. Full public source diff is pinned by #3652 head in 000_plan.md and inspected locally; any needed correction is recorded here before B. + +## Independent design corrections + +H1 accepted: policy rewrite and hint stripping compose. Build policyFailurePayload first, then remove top-level safety_buffering from the effective emitted payload, preserving response.failed/error data and retryable:false. H2 accepted: extend current relaySseWithFailedTail fourth options object with terminalBoundary; never replace upstreamError. Core passes both existing upstreamError and new terminalBoundary; update the existing source-contract assertion to preserve its original guarantee. H3 accepted: native WebSocket codex.response.metadata.headers and /responses/compact are explicitly excluded; their hints remain unfiltered. No new WS metadata filter. Docs must not claim the old WS allowlist excludes these headers. Regression fixtures cover CRLF/split/malformed input, policy error plus hint, EOF upstreamError, canonical true and noncanonical preservation. + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/config.ts b/src/config.ts +index fdcda9547c..cd0641feb2 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -1125,6 +1125,8 @@ const configSchema = z.object({ + configRebaseProvenance: z.unknown().optional(), + // A retry can be billable, so absence and malformed hand edits both stay off. + emptyCompletionRetry: z.boolean().optional().catch(false), ++ // Header suppression changes what Codex sees, so absence and malformed edits stay off. ++ dropCodexSafetyBuffering: z.boolean().optional().catch(false), + // A malformed hand edit must not silently stop opening the browser: fall back + // to undefined, which resolves to the historical auto-open behavior. + oauthOpenBrowser: z.boolean().optional().catch(undefined), +@@ -2613,6 +2615,14 @@ function emptyCompletionRetryError(value: unknown): string | null { + return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted"; + } + ++function dropCodexSafetyBufferingError(value: unknown): string | null { ++ const raw = rawConfigRecord(value); ++ if (!raw || !Object.hasOwn(raw, "dropCodexSafetyBuffering")) return null; ++ const enabled = raw.dropCodexSafetyBuffering; ++ if (enabled === undefined || typeof enabled === "boolean") return null; ++ return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted"; ++} ++ + function oauthOpenBrowserError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null; +@@ -2718,6 +2728,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx + ?? codexQuotaAutoRefreshError(value) + ?? codexAccountPickerEnabledError(value) + ?? emptyCompletionRetryError(value) ++ ?? dropCodexSafetyBufferingError(value) + ?? oauthOpenBrowserError(value) + ?? runtimeRoleError(value) + ?? remoteGuiConfigError(value) +@@ -3684,6 +3695,7 @@ export function getDefaultConfig(): OcxConfig { + return { + port: 10100, + emptyCompletionRetry: false, ++ dropCodexSafetyBuffering: false, + managementUsageMaxReadBytes: 64 * 1024 * 1024, + appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), + // Fresh/re-initialized configs are already written in the current three-tier +diff --git a/src/server/index.ts b/src/server/index.ts +index aedd6bf236..c6ce73b2f1 100644 +--- a/src/server/index.ts ++++ b/src/server/index.ts +@@ -142,6 +142,7 @@ import { + } from "./relay"; + export { + consumeForInspection, ++ codexSafetyBufferingFilterOptions, + relaySseWithFailedTail, + relaySseWithHeartbeat, + relayWithAbort, +diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts +index 655997b813..a6e60d3d02 100644 +--- a/src/server/relay-eager.ts ++++ b/src/server/relay-eager.ts +@@ -26,6 +26,7 @@ + + import { + adapterEofIncompleteFrame, ++ type CodexSafetyBufferingFilterOptions, + createSseTerminalOutputBoundary, + doneFrame, + failedTailFrame, +@@ -83,6 +84,8 @@ export type EagerRelayOptions = { + postCancelDrainBytes?: number; + /** Injectable clock for tests. */ + now?: () => number; ++ /** Client output boundary filters (Codex safety-buffering hints). */ ++ terminalBoundary?: CodexSafetyBufferingFilterOptions; + }; + + const DEFAULT_MAX_QUEUE_BYTES = 8 * 1024 * 1024; +@@ -111,7 +114,7 @@ export function relaySseEagerBounded( + const terminalEncoder = new TextEncoder(); + const adapterEofFrame = adapterEofIncompleteFrame(terminalEncoder); + const terminalSentinel = doneFrame(terminalEncoder); +- const terminalBoundary = createSseTerminalOutputBoundary(); ++ const terminalBoundary = createSseTerminalOutputBoundary(opts?.terminalBoundary); + const activeRewrite: SseBlockRewrite | undefined = hooks.rewriteBlocks + ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined); + const encodeFailedTail = (error: unknown): Uint8Array | null => { +diff --git a/src/server/relay.ts b/src/server/relay.ts +index 60b57ea025..d840b2e59c 100644 +--- a/src/server/relay.ts ++++ b/src/server/relay.ts +@@ -162,7 +162,10 @@ export type SseTerminalOutputBoundary = { + * terminal, and drops every later block/byte. A premature [DONE] is held until + * a terminal arrives so clean EOF can synthesize one terminal and one sentinel. + */ +-export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { ++export function createSseTerminalOutputBoundary( ++ options?: CodexSafetyBufferingFilterOptions, ++): SseTerminalOutputBoundary { ++ const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES); +@@ -181,6 +184,10 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { + const payload = sseDataPayload(decoder.decode(frame.block)); + const isDone = payload === "[DONE]"; + const parsed = payload === null ? undefined : parseSsePayload(payload); ++ const safetyBuffering = dropSafetyBuffering && parsed !== undefined ++ ? codexSafetyBufferingBlockAction(parsed) ++ : "keep"; ++ if (safetyBuffering === "drop") continue; + const policyError = parsed !== undefined && isPolicyRewriteType(parsed) + ? cyberPolicyTerminalError(parsed) + : undefined; +@@ -189,7 +196,9 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { + decoder.decode(frame.block), + policyFailurePayload(policyError, parsed), + )) +- : frame.block; ++ : safetyBuffering === "strip" ++ ? encoder.encode(stripCodexSafetyBufferingField(decoder.decode(frame.block), parsed)) ++ : frame.block; + if (isDone) { + done = true; + if (responsesTerminal) { +@@ -260,10 +269,11 @@ export function relaySseWithFailedTail( + body: ReadableStream, + upstream: AbortController, + onClientGone?: (reason?: unknown) => void, ++ boundaryOptions?: CodexSafetyBufferingFilterOptions, + ): ReadableStream { + const reader = body.getReader(); + const encoder = new TextEncoder(); +- const terminalBoundary = createSseTerminalOutputBoundary(); ++ const terminalBoundary = createSseTerminalOutputBoundary(boundaryOptions); + let closed = false; + const relayChunk = ( + controller: ReadableStreamDefaultController, +@@ -438,6 +448,29 @@ function isPolicyRewriteType(parsed: unknown): boolean { + return type === "response.failed" || type === "response.incomplete" || type === "error"; + } + ++/** ++ * Codex emits its safety-buffering hint in the SSE body as well as in headers: ++ * a `response.metadata` event whose `metadata.type` is `safety_buffering`, or a ++ * `safety_buffering` field on another event. The metadata event is dropped whole; ++ * the field is stripped so the carrying event is otherwise relayed unchanged. ++ */ ++function codexSafetyBufferingBlockAction(parsed: unknown): "keep" | "drop" | "strip" { ++ const root = asJsonRecord(parsed); ++ if (!root) return "keep"; ++ if (root.type === "response.metadata") { ++ const metadata = asJsonRecord(root.metadata); ++ if (metadata?.type === "safety_buffering") return "drop"; ++ } ++ return Object.hasOwn(root, "safety_buffering") ? "strip" : "keep"; ++} ++ ++function stripCodexSafetyBufferingField(block: string, parsed: unknown): string { ++ const root = asJsonRecord(parsed); ++ if (!root) return block; ++ const { safety_buffering: _safetyBuffering, ...rest } = root; ++ return replaceSseDataPayload(block, JSON.stringify(rest)); ++} ++ + function rewritePolicyTerminalBlock(block: string, payload: string): string { + const newline = block.includes("\r\n") ? "\r\n" : "\n"; + const rewritten = replaceSseDataPayload(block, payload); +@@ -1422,7 +1455,31 @@ export function consumeForResponseLogMetadata( + * body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough. + * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim. + */ +-export function sanitizePassthroughHeaders(upstream: Headers): Headers { ++export const CODEX_SAFETY_BUFFERING_HEADERS = [ ++ "x-codex-safety-buffering-enabled", ++ "x-codex-safety-buffering-faster-model", ++] as const; ++ ++const CODEX_SAFETY_BUFFERING_HEADER_SET: ReadonlySet = new Set(CODEX_SAFETY_BUFFERING_HEADERS); ++ ++export interface CodexSafetyBufferingFilterOptions { ++ /** ++ * Drop Codex safety-buffering hints: the `x-codex-safety-buffering-*` response ++ * headers and the `safety_buffering` SSE metadata event / field. Absent and ++ * `false` relay everything unchanged. ++ */ ++ dropCodexSafetyBuffering?: boolean; ++} ++ ++/** Resolve the passthrough header policy from the loaded config (absent means "forward everything"). */ ++export function codexSafetyBufferingFilterOptions( ++ config: { dropCodexSafetyBuffering?: boolean }, ++): CodexSafetyBufferingFilterOptions { ++ return { dropCodexSafetyBuffering: config.dropCodexSafetyBuffering === true }; ++} ++ ++export function sanitizePassthroughHeaders(upstream: Headers, options?: CodexSafetyBufferingFilterOptions): Headers { ++ const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; + const DROP = new Set([ + "content-encoding", + "content-length", +@@ -1439,7 +1496,10 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers { + ]); + const out = new Headers(); + upstream.forEach((value, key) => { +- if (!DROP.has(key.toLowerCase())) out.set(key, value); ++ const lower = key.toLowerCase(); ++ if (DROP.has(lower)) return; ++ if (dropSafetyBuffering && CODEX_SAFETY_BUFFERING_HEADER_SET.has(lower)) return; ++ out.set(key, value); + }); + return out; + } +diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts +index 9d0eea0d76..e199917968 100644 +--- a/src/server/responses/core.ts ++++ b/src/server/responses/core.ts +@@ -304,6 +304,7 @@ import { + markEagerRelaySseResponse, + markNativePassthroughSseResponse, + relaySseWithFailedTail, ++ codexSafetyBufferingFilterOptions, + relayWithAbort, + sanitizePassthroughHeaders, + } from "../relay"; +@@ -3850,6 +3851,9 @@ async function handleResponsesInner( + let hostAdmissionLease = pendingHostAdmissionLease; + pendingHostAdmissionLease = null; + try { ++ const codexSafetyBufferingOptions = isCanonicalOpenAiForwardProvider(route.provider) ++ ? codexSafetyBufferingFilterOptions(config) ++ : undefined; + const imageGenCallAliases = route.provider.authMode === "forward" + ? new Map() + : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); +@@ -4732,7 +4736,7 @@ async function handleResponsesInner( + } + break; + } +- const headers = sanitizePassthroughHeaders(upstreamResponse.headers); ++ const headers = sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions); + const resolvedModel = headers.get("openai-model")?.trim(); + if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; + if (isUsageDebugEnabled()) { +@@ -4824,7 +4828,7 @@ async function handleResponsesInner( + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, +- headers: sanitizePassthroughHeaders(upstreamResponse.headers), ++ headers: sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions), + }); + } + if (!upstreamResponse.ok) { +@@ -5027,6 +5031,7 @@ async function handleResponsesInner( + onDone: () => unregisterTurn(turnAc), + }, { + clientGoneSignal: options.abortSignal, ++ terminalBoundary: codexSafetyBufferingOptions, + ...(inlineEagerRewrite ? { rewriteBudget: translatorBudget } : {}), + }); + // When selected, this relay closes response.completed even if upstream +@@ -5110,7 +5115,8 @@ async function handleResponsesInner( + const rewrittenBody = clientBlockRewrite !== undefined + ? relaySseWithBlockRewrite(nativeBody, clientBlockRewrite, translatorBudget) + : nativeBody; +- const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason)); ++ const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason), ++ codexSafetyBufferingOptions); + return markNativePassthroughSseResponse(new Response(clientBody, { + status: upstreamResponse.status, + headers, +@@ -5238,7 +5244,7 @@ async function handleResponsesInner( + } + throw error; + } +- const sseHeaders = sanitizePassthroughHeaders(headers); ++ const sseHeaders = sanitizePassthroughHeaders(headers, codexSafetyBufferingOptions); + sseHeaders.set("content-type", "text/event-stream"); + sseHeaders.set("cache-control", "no-store"); + return new Response(stream, { +diff --git a/src/types/config.ts b/src/types/config.ts +index 8cf1246979..4d2c63fdf1 100644 +--- a/src/types/config.ts ++++ b/src/types/config.ts +@@ -335,6 +335,16 @@ export interface OcxConfig { + client?: OcxClientConnectionConfig; + /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ + emptyCompletionRetry?: boolean; ++ /** ++ * Drop the Codex safety-buffering hints from a Codex Responses passthrough: the ++ * `x-codex-safety-buffering-*` response headers, `response.metadata` SSE events of ++ * type `safety_buffering`, and the `safety_buffering` field on other SSE events. ++ * The Codex TUI turns those hints into a "retry with a faster model" prompt whose ++ * default action switches the session to a weaker model, so an unattended session ++ * can lose its model to a stray keystroke. Absent and `false` relay everything ++ * unchanged. ++ */ ++ dropCodexSafetyBuffering?: boolean; + /** + * Whether a login may open a browser on the machine running the proxy. + * + +``` diff --git a/devlog/_plan/260912_thinking_contract/030_spark.md b/devlog/_plan/260912_thinking_contract/030_spark.md new file mode 100644 index 0000000000..3997aaa6d7 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/030_spark.md @@ -0,0 +1,66 @@ +# Spark Lite metadata follows body shape + +Class C3 bounded compatibility. Independent of presentation/hint; depends on roadmap. MODIFY src/adapters/openai-responses.ts only inside canonical OpenAI forwarding and final wire model gpt-5.3-codex-spark. Add bodyCarriesLiteToolShape next to existing tool-shape helpers: Array.isArray(body.input) && body.input.some(item => isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) && item.tools.length > 0). After final Spark body construction, delete all case variants of CODEX_RESPONSES_LITE_HEADER then set it to liteShaped ? "true" : "false". Existing prepareCodexWsRequest projects it onto native frame metadata. + +Before: Spark deletes the header, allowing stale native metadata to survive. After: tool-less/top-level-tool Spark frames advertise false; nonempty Lite catalog frames advertise true despite conflicting inherited header. No retirement, no changes to model availability, no user service changes. + +MODIFY tests/codex-integration/codex-metadata-integrity.test.ts: alias resolved final model, inherited true/false/mixed-case/absent header, Lite tool body true, empty Lite group false, malformed metadata keeps HTTP fallback/body, noncanonical remains unchanged. MODIFY tests/responses/ws-upstream-reuse.test.ts: legacy true socket retires when adapter produces false, replacement same identity reused, raw request immutable. MODIFY all eight existing architecture locale pages and structure/transports/responses.md, referencing body-shape rule from shared area owners. Adopt latest #4130 source diff, preserving author; do not import historical earlier heads. + +Verification: source diff review and final-branch hosted ci.yml lane=all. Tests NOT RUN locally. Success proves framing and connection identity, not a live backend EOF fix or all tool-bearing EOF cases. Remaining acceptance: broader tool-format conversion stays out of scope. + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts +index c4aa523ee6..8fbe43816d 100644 +--- a/src/adapters/openai-responses.ts ++++ b/src/adapters/openai-responses.ts +@@ -864,6 +864,21 @@ function promoteClientLoadedTools(body: unknown): unknown { + } + + const MAX_RESPONSES_CALL_ID_LENGTH = 64; ++ ++/** ++ * Whether the outgoing body still delivers tools through the responses-lite shape. ++ * ++ * Lite carries the client catalog as an `additional_tools` input item; the non-Lite wire shape ++ * expects top-level `tools`. Anything that flips the Lite advertisement has to agree with the ++ * shape actually being sent, or the destination silently loses the tool surface. ++ */ ++function bodyCarriesLiteToolShape(body: Record): boolean { ++ if (!Array.isArray(body.input)) return false; ++ return body.input.some(item => ++ isPlainObject(item) && item.type === "additional_tools" ++ && Array.isArray(item.tools) && item.tools.length > 0 ++ ); ++} + const REPAIRED_CALL_ID_PREFIX = "call_ocx_"; + const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length; + +@@ -2515,12 +2530,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): + parsed.modelId, + ); + if (isCanonicalOpenAiForwardProvider(provider)) { +- // Spark closes Responses Lite streams before a terminal completion. Select compatibility +- // from the final wire model so aliases cannot leave the caller or a static header enabled. ++ // Select Spark's Lite compatibility from the final wire model, including aliases, and ++ // let the BODY decide it. The header also overrides native WS metadata downstream, so a ++ // forwarded or statically configured value must never contradict the shape being sent. ++ // ++ // The synchronized catalog keeps `use_responses_lite: true` for Spark precisely because ++ // it selects tool delivery (`input[].additional_tools` instead of top-level `tools`), and ++ // stripSparkCompatibility filters that group in place rather than promoting it. So a ++ // Lite-shaped body is pinned back ON — otherwise an inherited `false` advertises non-Lite ++ // while the tools exist only in the Lite shape, and Spark loses the tool surface. Only a ++ // body with no Lite tool group is downgraded, which is what the stream fix needs. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { ++ const liteShaped = bodyCarriesLiteToolShape(finalBody); + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; + } ++ headers[CODEX_RESPONSES_LITE_HEADER] = liteShaped ? "true" : "false"; + } + const routingHeaders = new Headers(headers); + applyCodexRoutingHint(routingHeaders, finalBody); + +``` diff --git a/devlog/_plan/260912_thinking_contract/040_delivery.md b/devlog/_plan/260912_thinking_contract/040_delivery.md new file mode 100644 index 0000000000..a70e62bfb6 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/040_delivery.md @@ -0,0 +1,7 @@ +# Final heads and handoff + +Class C3 delivery evidence. Depends on all dispositions. MODIFY branch-owned numbered completion docs and ignored .tmp/thinking/handoff.md. Read existing .github/PULL_REQUEST_TEMPLATE.md; write every section, credits and precise NOT RUN limitation. Publish only own codex/260912-60plus-thinking* branches with git push --no-verify; PR bases dev for independent units, ordinary parent branch only for actual dependencies. No merge/auto-merge/closures. + +NEW .tmp/thinking/*-ci.json captures gh run view JSON for final SHA plus all jobs. NEW .tmp/thinking/*-review.md captures independent implementation findings with accepted/rebutted disposition. Refresh head/base, native stack membership (unknown if API unsupported), current reviews and CI before handoff. Inspect .github/workflows/ci.yml and dispatch lane=all at each final branch where needed. Existing automatic runs stay untouched. If final-head CI fails, inspect failing logs, repair scoped source or fixtures, commit/push --no-verify and validate new final tip. Do not label skipped/cancelled/old-head runs passing. + +Final handoff fields: own worktree, branch per PR, source PR disposition, exact head, PR URL, dependency order, original author trailers, remaining acceptance, unresolved reviews, CI run id/url/head/result/job conclusions, own cycle records and local tests NOT RUN. Parent performs any subsequent integration. No evidence claims from peer commentary alone. From 533d5782863d7ffb0de86243e2136d51582de77b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:44:33 +0900 Subject: [PATCH 024/224] fix(responses): separate raw reasoning from provider summaries Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com> Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com> Co-authored-by: Eran --- .../010_presentation.md | 4 + .../docs/reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 2 +- src/adapters/google-wire-compiler.ts | 20 +- src/adapters/google.ts | 26 +- src/bridge.ts | 25 +- src/providers/derive.ts | 4 + src/providers/registry.ts | 8 +- src/router.ts | 7 + src/server/auth-cors.ts | 1 + .../responses-reasoning-summary-rewrite.ts | 178 ----------- src/server/responses/core.ts | 30 +- src/types/provider.ts | 6 + structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/chat-compat.md | 19 +- structure/providers/cursor.md | 2 + structure/providers/google.md | 14 +- structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 13 +- structure/transports/streaming-health.md | 2 + .../bridge-raw-reasoning-hidden.test.ts | 14 +- tests/adapters/bridge.test.ts | 23 +- tests/adapters/google/google-adapter.test.ts | 126 +++++++- .../google/google-wire-compiler.test.ts | 24 ++ tests/fixtures/test-layout-expected.json | 2 +- tests/providers/opencode-go-luna-wire.test.ts | 15 +- ...nses-reasoning-summary-passthrough.test.ts | 23 +- ...esponses-reasoning-summary-rewrite.test.ts | 286 ------------------ .../responses-show-thinking-summary.test.ts | 172 +++++++++++ .../server/server-combo-failover-e2e.test.ts | 32 ++ ...erver-xai-chat-reasoning-streaming.test.ts | 4 +- 42 files changed, 528 insertions(+), 581 deletions(-) delete mode 100644 src/server/responses-reasoning-summary-rewrite.ts delete mode 100644 tests/responses/responses-reasoning-summary-rewrite.test.ts create mode 100644 tests/responses/responses-show-thinking-summary.test.ts diff --git a/devlog/_plan/260912_thinking_contract/010_presentation.md b/devlog/_plan/260912_thinking_contract/010_presentation.md index e39e9c0bfe..8c626aa5bd 100644 --- a/devlog/_plan/260912_thinking_contract/010_presentation.md +++ b/devlog/_plan/260912_thinking_contract/010_presentation.md @@ -314,3 +314,7 @@ index e65130a4fa..b6374a991a 100644 ## Reflection corrections accepted Explicit wire reasoning.summary:"none" wins. A client that serializes configured none as omission cannot be distinguished from unspecified preference. No client config rewrite or global catalog summary default changes. Summary classification is limited to built CCA Gemini requests; unknown/uninitialized, direct Google/Vertex and CCA Claude/gpt-oss remain raw. Streaming and buffered summary-to-tool continuations assert exact Google signature on correct call; never emit Google signatures as Anthropic thinking_signature. Hidden unsigned summaries may disappear but required tool replay state survives. Exercise final assistant text and terminal order, fallback in both directions, and remove replay-comparison rewrite alongside SSE/JSON rewrite. Desktop appearance remains client-controlled; source patch comments claiming an unconditional placeholder are replaced during adoption. + +## Presentation P revalidation + +Prior D: roadmap locked; next presentation implementation. Both source patches apply to baseline; combined application requires keeping the newer no-rewrite expectation. Shared classifier signatures remain current. Implement CCA-only provider default by recomputing parsed.options.hideThinkingSummary from raw summary each final route for inboundWire responses; other inbound types preserve their existing flag. Missing raw request leaves original hide flag authoritative. CCA Gemini classification records boolean in existing per-request adapter closure on each build; default false. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 23b7e5e92d..863b264042 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -208,6 +208,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | | `reasoningDetailsModels?` | `string[]` | Models whose endpoint returns thinking as a structured `reasoning_details` array (MiniMax M-series with `reasoning_split`); stream deltas are cumulative snapshots that are prefix-diffed, and preserved reasoning replays as a `reasoning_details` array instead of a `reasoning_content` string. | | `requiresReasoningPlaceholderModels?` | `string[]` | Models whose upstream rejects a tool_call continuation missing `reasoning_content` (DeepSeek thinking mode); a minimal placeholder is injected when the replay cache misses. Defaults to `preserveReasoningContentModels`; set `[]` to opt out. | +| `showThinkingSummary?` | `boolean` | Display provider-authored summaries when a Responses client omits `reasoning.summary`. Explicit wire `"none"` wins; a client that serializes its preference as omission cannot be distinguished. Raw reasoning remains content and is never relabeled as a summary. The `google-antigravity` preset defaults to `true`; explicit `false` disables that default. CCA Gemini requests also opt into `generationConfig.thinkingConfig.includeThoughts` when display is enabled; image, Claude and gpt-oss requests do not. This does not change client configuration or global catalog summary defaults. | | `thinkingToggleModels?` | `string[]` | Chat models using `thinking.enabled` rather than an effort ladder. | | `thinkingBudgetModels?` | `string[]` | Chat models using integer `thinking_budget`; effort maps to a budget fraction. | | `noVisionModels?` | `string[]` | Text-only models sent through the vision sidecar; matching tolerates an Ollama `:size` tag. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7241f26266..1162f987ad 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1097,10 +1097,10 @@ "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", - "responses-reasoning-summary-rewrite.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", + "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index 88c482ba7d..aa835e50b4 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -130,12 +130,20 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { ))].slice(0, 5); if (stopSequences.length > 0) out.stopSequences = stopSequences; } - if (isObject(value.thinkingConfig) && typeof value.thinkingConfig.thinkingLevel === "string") { - const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); - const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) - ? raw - : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); - if (thinkingLevel) out.thinkingConfig = { thinkingLevel }; + if (isObject(value.thinkingConfig)) { + const thinking: JsonObject = {}; + if (typeof value.thinkingConfig.thinkingLevel === "string") { + const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); + const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) + ? raw + : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); + if (thinkingLevel) thinking.thinkingLevel = thinkingLevel; + } + // The one key that makes Google return `thought: true` text. Cloud Code Assist serves + // thinking either way (thoughtsTokenCount stays non-zero) but withholds the text unless the + // request opts in, so dropping it here silently reinstates the missing-thinking behavior. + if (value.thinkingConfig.includeThoughts === true) thinking.includeThoughts = true; + if (Object.keys(thinking).length > 0) out.thinkingConfig = thinking; } if (Array.isArray(value.responseModalities)) { const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 7fcc88ba59..518aca3903 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -568,13 +568,15 @@ function googleToolCallMetadataFromPart( * Keep that provider visibility bit authoritative here so the streaming and buffered parsers * cannot accidentally expose the same hidden reasoning through different event types. */ -function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined { +function googlePartTextEvent(part: GoogleResponsePart, thoughtSummary = false): AdapterEvent | undefined { // A malformed scalar/object is not text and must not cross the AdapterEvent boundary. Dropping // only this optional field preserves the rest of the part without inventing assistant output by // coercion; an empty string keeps its existing no-event behavior. if (typeof part.text !== "string" || part.text.length === 0) return undefined; return part.thought === true - ? { type: "reasoning_raw_delta", text: part.text } + ? thoughtSummary + ? { type: "thinking_delta", thinking: part.text } + : { type: "reasoning_raw_delta", text: part.text } : { type: "text_delta", text: part.text }; } @@ -721,6 +723,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // 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. let antigravityModel: string | undefined; + let returnsThoughtSummaries = false; let antigravitySession: string | undefined; // Vertex returns the same opaque Gemini thought signatures as CCA, but its replay namespace // must stay transport-scoped: a signature minted by one Google backend must never be sent to @@ -795,6 +798,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte : provider.googleMode === "vertex" ? parsed.modelId : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false); + returnsThoughtSummaries = provider.googleMode === "cloud-code-assist" + && /^gemini-/.test(routedModelId) && !isImageCapableModel(parsed.modelId); // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation. const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId; const stripRejectedClaudeSdkParagraph = provider.googleMode === "cloud-code-assist" @@ -866,11 +871,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte ); antigravityModel = wireModelId; antigravitySession = sessionId; + // CCA Gemini exposes provider-authored thought summaries with includeThoughts. + // Other CCA model families do not share this request contract. + const includeThoughts = provider.showThinkingSummary === true + && parsed.options.hideThinkingSummary !== true + && /^gemini-/.test(wireModelId) + && !isImageCapableModel(parsed.modelId); // Effort → thinkingConfig for CCA (CLIProxyAPI proven: request.generationConfig.thinkingConfig). // Suffix/compat IDs return thinkingLevel=undefined — the suffix IS the effort, no contradiction. - if (thinkingLevel) { + if (thinkingLevel || includeThoughts) { const gc = (body.generationConfig ?? {}) as Record; - gc.thinkingConfig = { thinkingLevel }; + gc.thinkingConfig = { + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(includeThoughts ? { includeThoughts: true } : {}), + }; body.generationConfig = gc; } // Reasoning continuity: Gemini models re-inject cached thoughtSignatures; Claude-on-Antigravity @@ -1139,7 +1153,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingStreamThoughtSig = sig; } - const textEvent = googlePartTextEvent(part); + const textEvent = googlePartTextEvent(part, returnsThoughtSummaries); if (textEvent) { emittedContentEvent = true; yield textEvent; @@ -1415,7 +1429,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingThoughtSig = sig; } - const textEvent = googlePartTextEvent(part); + const textEvent = googlePartTextEvent(part, returnsThoughtSummaries); if (textEvent) events.push(textEvent); const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; if (inline && typeof inline.data === "string") { diff --git a/src/bridge.ts b/src/bridge.ts index 20e7c3fe09..a9fbb42988 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -663,16 +663,13 @@ export function bridgeToResponsesSSE( const closeCurrentRawReasoning = () => { if (!currentRawReasoning) return; rawReasoningForNextToolCall = currentRawReasoning.text; - emit("response.reasoning_summary_text.done", { - item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, text: currentRawReasoning.text, - }); - emit("response.reasoning_summary_part.done", { - item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, - part: { type: "summary_text", text: currentRawReasoning.text }, + emit("response.reasoning_text.done", { + item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, content_index: 0, text: currentRawReasoning.text, }); const item = { type: "reasoning", id: currentRawReasoning.itemId, - summary: [{ type: "summary_text", text: currentRawReasoning.text }], + summary: [] as never[], + content: [{ type: "reasoning_text", text: currentRawReasoning.text }], }; emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item }); retainFinishedItem(item as OutputItem, currentRawReasoning.textBytes, "reasoning"); @@ -1111,10 +1108,6 @@ export function bridgeToResponsesSSE( const itemId = `rs_${uuid()}`; const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] }; emit("response.output_item.added", { output_index: outputIndex, item }); - emit("response.reasoning_summary_part.added", { - item_id: itemId, output_index: outputIndex, summary_index: 0, - part: { type: "summary_text", text: "" }, - }); currentRawReasoning = { itemId, outputIndex, text: "", textBytes: 0 }; } ({ value: currentRawReasoning.text, bytes: currentRawReasoning.textBytes } = appendString( @@ -1123,9 +1116,12 @@ export function bridgeToResponsesSSE( event.text, "reasoning", )); - emit("response.reasoning_summary_text.delta", { + // Raw reasoning (openai-chat reasoning_content, kiro tags) rides the CONTENT + // channel. Clients control raw-reasoning display; this text is not a + // provider-authored summary. + emit("response.reasoning_text.delta", { item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, - summary_index: 0, delta: event.text, + content_index: 0, delta: event.text, }); break; } @@ -1780,7 +1776,8 @@ function buildResponseJSONWithBudget( } pushOutput({ type: "reasoning", id: `rs_${uuid()}`, - summary: [{ type: "summary_text", text: currentRawReasoning }], + summary: [], + content: [{ type: "reasoning_text", text: currentRawReasoning }], }, currentRawReasoningBytes, "reasoning"); currentRawReasoning = ""; currentRawReasoningBytes = 0; diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 72a662aee4..3ab1d01500 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -44,6 +44,7 @@ export interface DerivedKeyLoginProvider { autoToolChoiceOnlyModels?: string[]; preserveReasoningContentModels?: string[]; requiresReasoningPlaceholderModels?: string[]; + showThinkingSummary?: boolean; reasoningSplitModels?: string[]; reasoningDetailsModels?: string[]; thinkingToggleModels?: string[]; @@ -271,6 +272,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), + ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), @@ -320,6 +322,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), + ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), @@ -574,6 +577,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels]; if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels]; if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames; + if (prov.showThinkingSummary === undefined && seed.showThinkingSummary !== undefined) prov.showThinkingSummary = seed.showThinkingSummary; if (prov.keyOptional === undefined && seed.keyOptional !== undefined) prov.keyOptional = seed.keyOptional; if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier; if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e2189254fc..d2bee8888c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -356,6 +356,10 @@ export interface ProviderRegistryEntry { autoToolChoiceOnlyModels?: string[]; preserveReasoningContentModels?: string[]; requiresReasoningPlaceholderModels?: string[]; + /** + * Opt this provider into visible thinking summaries (see OcxProviderConfig.showThinkingSummary). + */ + showThinkingSummary?: boolean; reasoningSplitModels?: string[]; reasoningDetailsModels?: string[]; thinkingToggleModels?: string[]; @@ -380,7 +384,7 @@ export type ProviderConfigSeed = Pick< | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" - | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" + | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" | "googleMode" | "project" | "location" | "headers" >; @@ -2112,7 +2116,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would // retarget a user's custom base back to Google. A leading `./` is required because a bare // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. - { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, + { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", showThinkingSummary: true, jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/src/router.ts b/src/router.ts index bd8e9dc690..70e427b74b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -413,6 +413,13 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), + // The request path resolves through routedProviderConfig() and never calls + // enrichProviderFromRegistry(), so a saved provider row written before the + // registry learned this flag must be backfilled here or route.provider never + // carries it and the showThinkingSummary opt-in stays dead. + ...(provider.showThinkingSummary === undefined && registryEntry.showThinkingSummary !== undefined + ? { showThinkingSummary: registryEntry.showThinkingSummary } + : {}), // Registry-only client-facing repair policy (#938): fill only when the // saved provider has no explicit policy; clone so runtime never aliases // the registry constant. diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0910698a0c..e59029a3e2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -887,6 +887,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { autoToolChoiceOnlyModels: "editor", preserveReasoningContentModels: "editor", requiresReasoningPlaceholderModels: "editor", + showThinkingSummary: "editor", retryOn429: "editor", transientRetryOn5xx: "editor", reasoningSplitModels: "editor", diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts deleted file mode 100644 index 55c6d8ae7b..0000000000 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { SsePayloadRewrite } from "./sse-payload-rewrite"; - -/** - * Route content-channel reasoning from native-Responses upstreams through the - * expandable summary channel (issue #45). - * - * Codex renders the expandable reasoning trace from the Responses reasoning - * item's `summary[]` channel. DeepSeek's native `/responses` endpoint emits - * raw thinking on the content channel instead (`response.reasoning_text.delta` - * plus items with `content: [{type: "reasoning_text", text}]` and an empty - * `summary`), so routed DeepSeek turns showed the "Worked for Xs" timer with - * nothing to expand. Native OpenAI upstreams already emit summary-channel - * events; this rewrite is a no-op for them (no reasoning_text events to - * rewrite) and only engages when the upstream produces content-channel - * reasoning. - * - * Replay compatibility: Codex echoes the reasoning item it received back into - * the next request's input. DeepSeek's Responses API accepts summary-shaped - * reasoning input items (verified live), so the rewrite round-trips. - */ - -function isPlainObject(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function reasoningTextOf(item: Record): string { - if (!Array.isArray(item.content)) return ""; - return item.content - .filter((part): part is Record => isPlainObject(part) && part.type === "reasoning_text") - .map(part => (typeof part.text === "string" ? part.text : "")) - .join(""); -} - -/** Move a reasoning item's content channel into the summary channel. */ -function reasoningItemToSummaryShape(item: Record): Record { - if (item.type !== "reasoning") return item; - // `encrypted_content` is opaque, state-bearing provider data, so the entire item must retain its - // upstream shape unless that backend has an explicit replay contract permitting a rewrite. This - // defensively protects content-channel backends that do issue blobs when the client replays the - // stored item. The delta rewrite can still provide the expandable trace for the live turn. - // DeepSeek — the provider this rewrite was verified against — is `statelessResponses` and issues - // no blob, so it is unaffected. - if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) return item; - const text = reasoningTextOf(item); - // Items that already use the summary channel (or carry no content text at - // all) are left untouched: rewriting them could clear a valid summary. - if (text.length === 0) return item; - const next: Record = { ...item }; - delete next.content; - next.summary = [{ type: "summary_text", text }]; - return next; -} - -/** - * Rewrite one parsed SSE payload in place of the content channel, or return - * `null` when nothing changed (caller keeps the original payload). - */ -function rewritePayload(payload: Record): Record | null { - switch (payload.type) { - case "response.reasoning_text.delta": { - const next: Record = { - type: "response.reasoning_summary_text.delta", - item_id: payload.item_id, - output_index: payload.output_index, - summary_index: 0, - delta: payload.delta, - }; - if (payload.sequence_number !== undefined) next.sequence_number = payload.sequence_number; - return next; - } - case "response.reasoning_text.done": { - const next: Record = { - type: "response.reasoning_summary_text.done", - item_id: payload.item_id, - output_index: payload.output_index, - summary_index: 0, - text: payload.text, - }; - if (payload.sequence_number !== undefined) next.sequence_number = payload.sequence_number; - return next; - } - default: { - let changed = false; - const next: Record = { ...payload }; - if (isPlainObject(next.item) && next.item.type === "reasoning") { - const rewritten = reasoningItemToSummaryShape(next.item); - if (rewritten !== next.item) { - next.item = rewritten; - changed = true; - } - } - // SSE event shape: {type: "response.completed", response: {output}}. - const response = isPlainObject(next.response) ? { ...next.response } : null; - if (response && Array.isArray(response.output)) { - const output = response.output.map(item => { - if (!isPlainObject(item) || item.type !== "reasoning") return item; - const rewritten = reasoningItemToSummaryShape(item); - if (rewritten !== item) changed = true; - return rewritten; - }); - if (changed) { - response.output = output; - next.response = response; - } - } - // Bare response document shape (non-streaming passthrough): - // {object: "response", output: [...]}. - if (Array.isArray(next.output)) { - const output = next.output.map(item => { - if (!isPlainObject(item) || item.type !== "reasoning") return item; - const rewritten = reasoningItemToSummaryShape(item); - if (rewritten !== item) changed = true; - return rewritten; - }); - if (changed) next.output = output; - } - return changed ? next : null; - } - } -} - -/** Payload rewrite for passthrough relays whose upstream emits content-channel reasoning. */ -export function createReasoningSummaryChannelPayloadRewrite(): SsePayloadRewrite { - return (payload: string): string => { - let parsed: unknown; - try { - parsed = JSON.parse(payload); - } catch { - return payload; - } - if (!isPlainObject(parsed)) return payload; - const rewritten = rewritePayload(parsed); - return rewritten !== null ? JSON.stringify(rewritten) : payload; - }; -} - -/** - * Object-level variant for the non-streaming passthrough: the bounded-JSON - * relay bypasses the SSE payload rewrite, so reasoning items inside a full - * Responses JSON document need the same normalization before plain JSON - * serialization or forced JSON-to-SSE reframing. Returns the same reference - * when nothing changed. - */ -export function rewriteReasoningSummaryInJson(value: unknown): unknown { - if (!isPlainObject(value)) return value; - const rewritten = rewritePayload(value); - return rewritten !== null ? rewritten : value; -} - -/** String-level variant of {@link rewriteReasoningSummaryInJson}. */ -export function rewriteReasoningSummaryInJsonString(json: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return json; - } - const rewritten = rewriteReasoningSummaryInJson(parsed); - return rewritten === parsed ? json : JSON.stringify(rewritten); -} - -/** - * True when a routed native-Responses provider emits content-channel reasoning - * (raw `reasoning_text`) instead of the summary channel. DeepSeek's - * `/responses` endpoint is the current example: it ships raw thinking with an - * empty `summary` and keeps `preserveReasoningContentModels` so multi-turn - * replays round-trip. - */ -export function routeUsesContentChannelReasoning( - provider: { statelessResponses?: boolean; preserveReasoningContentModels?: string[] }, - modelId: string, -): boolean { - if (provider.statelessResponses === true) return true; - const preserved = provider.preserveReasoningContentModels; - const normalizedModelId = modelId.toLowerCase(); - return Array.isArray(preserved) - && preserved.some(id => id.toLowerCase() === normalizedModelId); -} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a10d97d282..7e71e5d37d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -365,12 +365,6 @@ import { hasResponsesItemIdRepair, repairResponsesJsonItemIds, } from "../responses-item-id-repair"; -import { - createReasoningSummaryChannelPayloadRewrite, - rewriteReasoningSummaryInJson, - rewriteReasoningSummaryInJsonString, - routeUsesContentChannelReasoning, -} from "../responses-reasoning-summary-rewrite"; import { createImageGenCallRestoreRewrite, imageGenToolCallAliases, @@ -2514,6 +2508,13 @@ async function applyFinalRouteRequestNormalization(args: { // this request will actually use (#404). route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); + // Recompute from the original wire preference on every route, including fallback. + // A provider default never converts raw reasoning into a summary. + if (inboundWire === "responses" && parsed._rawBody) { + const summary = (parsed._rawBody as { reasoning?: { summary?: unknown } }).reasoning?.summary; + parsed.options.hideThinkingSummary = summary === "none" + || (!summary && route.provider.showThinkingSummary !== true); + } if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; logCtx.provider = route.providerName; @@ -5042,10 +5043,7 @@ async function handleResponsesInner( ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) : restored) as { id?: unknown; output?: unknown; status?: unknown }; // Replay overlap compares the items the client echoes, including visible reasoning shape. - const replayResponse = parsed.options.hideThinkingSummary !== true - && routeUsesContentChannelReasoning(route.provider, route.modelId) - ? rewriteReasoningSummaryInJson(restoredResponse) as typeof restoredResponse - : restoredResponse; + const replayResponse = restoredResponse; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( @@ -6039,10 +6037,6 @@ async function handleResponsesInner( ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, responseModelRewrite, - parsed.options.hideThinkingSummary !== true - && routeUsesContentChannelReasoning(route.provider, route.modelId) - ? createReasoningSummaryChannelPayloadRewrite() - : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); // #893: sparse-snapshot gateways get field backfills AND lifecycle event // injection at the block level, after payload rewrites. Defaults come @@ -6313,13 +6307,7 @@ async function handleResponsesInner( const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(repaired, parsed._responseModelId) : repaired; - // The bounded-JSON answer bypasses the SSE payload rewrite, so content- - // channel reasoning needs the same normalization here for the plain - // JSON answer and every reframed-SSE variant built from clientJson. - return parsed.options.hideThinkingSummary !== true - && routeUsesContentChannelReasoning(route.provider, route.modelId) - ? rewriteReasoningSummaryInJsonString(modelRewritten) - : modelRewritten; + return modelRewritten; })(); // #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and // the reframed-SSE branch below are built from this body, so one check covers them. This diff --git a/src/types/provider.ts b/src/types/provider.ts index d559ffef8f..bf37ac708a 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -773,6 +773,12 @@ export interface OcxProviderConfig { * out explicitly (e.g. MiniMax, where low effort disables thinking). */ requiresReasoningPlaceholderModels?: string[]; + /** + * Default to displaying provider-authored summaries when Responses summary is omitted. + * Explicit wire summary:"none" wins; false disables a seeded provider default. + * Raw reasoning is never relabeled as a summary. + */ + showThinkingSummary?: boolean; /** * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only, * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index b0fab66633..514b7fede6 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -63,3 +63,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Adapter events distinguish raw reasoning content from summary-channel thinking; CCA Gemini classification is request-local. See [Google provenance](../providers/google.md). diff --git a/structure/catalog.md b/structure/catalog.md index bf7ef00d38..dffd61b353 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -271,3 +271,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Provider `showThinkingSummary` is a Responses request default; it does not rewrite catalog summary defaults or client configuration. See [Google summaries](providers/google.md). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 36511b6f00..3a7f229f98 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -82,3 +82,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Provider summary defaults are Responses-specific and do not rewrite connected Claude Desktop profiles. See [inbound compatibility](../data-planes/inbound-compat.md). diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 01d0cd4b0f..0260413b7b 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -76,3 +76,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +CCA image-capable requests do not acquire the text-summary includeThoughts opt-in. See [Google summary boundary](../providers/google.md). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 45fe1c11e7..a7f077a5d7 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -96,3 +96,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +The provider summary default applies at Responses ingress; native Chat and Anthropic inbound preferences keep their existing handling. Raw content is never renamed to a summary. See [bridge contract](../providers/chat-compat.md). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 952e9bd082..d3df91a6ef 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -530,3 +530,5 @@ Missing, invalid or expired evidence is unknown. `gui/src/pages/Combos.tsx` wake expiry, including a deadline crossed before effects run, rechecks activation and visibility, and refreshes quota with Combo data while preserving drafts. Each successful quota snapshot also advances the observation clock, so a retained older row cannot defer evaluation of a fresh row. + +The provider editor field policy exposes `showThinkingSummary` as a boolean provider option; it controls Responses summary defaults without a dashboard rendering change. See [Google provider](providers/google.md). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index b06c1ce393..634c71a8b7 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -308,3 +308,5 @@ The Combo guides describe the distinction between display quota and single-crede The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Provider configuration documents distinguish actual summaries from raw reasoning content. The test layout registers the summary-default contract cases and removes the obsolete content-rewrite test with its implementation. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 21bafe5b3b..337218e478 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -139,3 +139,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Provider summary defaults are evaluated per routed Responses request without changing service lifecycle or sidecar activation. See [runtime](../runtime.md). diff --git a/structure/overview.md b/structure/overview.md index da3f2dc473..eec481ff18 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -106,3 +106,5 @@ would pass while the rule was violated. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Raw reasoning content and provider-authored summaries remain distinct on the Responses wire. See [reasoning presentation](providers/chat-compat.md). diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e0b87add2d..7a52bf477f 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -215,18 +215,13 @@ honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_r item (`summary: []`, txt-only `ocxr1:` `encrypted_content`, no text deltas) — invisible in the Codex app, so tool cells group like native models — while the text still round-trips for `preserveReasoningContentModels` replay. Visible mode (summary "auto") keeps the raw -`content[reasoning_text]` shape. Diagnosis and codex-rs grouping evidence: -`devlog/_fin/260709_native_response_pattern/`. - -The content-to-summary channel rewrite skips any reasoning item that carries a native -`encrypted_content` blob. The blob is opaque, state-bearing provider data, so the item must -round-trip unchanged unless that backend has an explicit replay contract permitting a rewrite. -This defensively protects providers that issue blobs and later join the route through -`preserveReasoningContentModels`. The rewrite's round trip was verified against DeepSeek, which is -`statelessResponses` and issues no blob. Grok is unaffected in practice because it natively emits -summary-channel reasoning and no `reasoning_text` events, so this content-to-summary item rewrite -does not engage on its route. Only the stored item is exempt — `reasoning_text` delta events carry -no blob and still route to the summary channel, so the live expandable trace is unchanged. +`content[reasoning_text]` shape: raw deltas stream as `response.reasoning_text.delta` and the final +item carries `content: [{type: "reasoning_text", text}]`, so Codex applies its own display policy — +the desktop thinking band shows the "Thinking…" placeholder, and raw text appears only when +`show_raw_agent_reasoning` is enabled. Routing raw CoT through the summary channel instead (the +#45 display intent, intentionally reverted 260911) put unsummarized thinking in the desktop band, +which only fits native OpenAI providers that author real summaries. Diagnosis and codex-rs +grouping evidence: `devlog/_fin/260709_native_response_pattern/`. The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index be42793e0b..dd1ea597d9 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -82,3 +82,5 @@ constraints cannot widen the canonical shape. Bare shell bridge names are reject on the freeform path. Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in `tests/providers/cursor/cursor-tool-definitions.test.ts`. + +Shared raw-reasoning events retain content-channel presentation; provider-authored thinking keeps its existing summary path. See [bridge contract](chat-compat.md). diff --git a/structure/providers/google.md b/structure/providers/google.md index 26187aeebf..1c1d5648f8 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -2,12 +2,14 @@ ## Google thought-text visibility boundary -Google-family responses may represent model-internal reasoning as a text-bearing part with -`thought: true`. The Google adapter maps that text to the internal `reasoning_raw_delta` event; -only text without the marker becomes visible `text_delta`. Streaming SSE and buffered JSON share -one classifier so transport selection cannot change whether provider-declared reasoning is shown -as assistant output. Thought-signature observation still runs on the original parts before text -classification, preserving the opaque continuation state independently of display semantics. +Google-family parts with `thought: true` stay separate from assistant output. After a CCA +Gemini request is built, the shared streaming/buffered classifier emits `thinking_delta` for +these provider-authored summaries. Other Google wires, non-Gemini CCA models and uninitialized +adapters retain `reasoning_raw_delta`. Model provenance is refreshed on every build. +`showThinkingSummary` defaults on only for the Antigravity preset; explicit provider false and +explicit wire summary none win. Eligible CCA Gemini requests use `includeThoughts: true` only +when provider opt-in and per-request display both allow it. Thought signatures remain attached +to their tool calls independently; they never become Anthropic thinking signatures. > Decision record: [ADR-0055](../decisions/ADR-0055-google-thought-text-visibility-boundary.md) diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5c497d4084..f852534dda 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -62,3 +62,5 @@ Account-scoped OAuth quota remains display evidence for provider-level Combo sel The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Grok chat raw reasoning uses content-channel output with an empty summary; hidden replay envelopes retain continuation text. Native Responses content is not promoted to summaries. See [chat compatibility](chat-compat.md). diff --git a/structure/runtime.md b/structure/runtime.md index 6d733bf8b5..ed719df105 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -212,3 +212,5 @@ cooldowns and response-driven retry remain authoritative. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Responses route normalization resolves provider summary defaults from the original wire preference on every final route. See [reasoning presentation](providers/chat-compat.md) and [CCA summary provenance](providers/google.md). diff --git a/structure/subagents.md b/structure/subagents.md index 662bc857d3..907d4d0d8e 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -207,3 +207,5 @@ Provider-level Combo eligibility uses explicit inference evidence for the curren The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Final-route summary visibility is recomputed after fallback from the original Responses preference; an earlier provider opt-in does not carry into a later provider. See [reasoning presentation](providers/chat-compat.md). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0348c1cdd..dcd0256588 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -67,3 +67,5 @@ Quota publication distinguishes display reports from explicitly supplied inferen The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +CCA Gemini summary provenance and request opt-in are specified in [Google provider](../providers/google.md); raw Responses content retains its wire channel. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 5624a2e04e..4a3a8eb321 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -309,14 +309,11 @@ custom result has no local call, because its original wire type cannot be establ would send an unmatched result upstream. The check resolves the selected wire protocol and the request's own tool declarations after final route selection, so stateful destinations keep their upstream-owned native function and native-only custom continuations. Explicit input still receives -orphan repair; this path asks the client to replay rather than reconstructing history. This flag also enables the existing -visible content-to-summary rewrite for SSE and JSON; summary-channel items and opaque reasoning -blobs keep their existing response handling. The shared recording callback applies the same -reasoning rewrite under the exact client-visible predicate before caching output, after tool -restoration and function normalization. This keeps full-content replay fingerprints comparable -for both full-history-plus-ID and delta continuations without weakening identity checks. Hidden -summaries and opaque blobs keep their existing cache representation. It does not change streaming selection or Chat -model routes. Go fixtures cover Luna, Grok and Muse against both response formats. +orphan repair; this path asks the client to replay rather than reconstructing history. Content-channel reasoning stays content in SSE, JSON and stored replay output; native +summary items and opaque blobs retain their upstream representation. Full-content replay +fingerprints compare the same client-visible items without content-to-summary conversion. +It does not change streaming selection or Chat model routes. Go fixtures cover Luna, Grok +and Muse against both response formats. The canonical OpenCode Go transport also derives `x-opencode-session` from the existing hashed session lane before per-model wire selection. One conversation keeps one opaque affinity value diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index ae20c7c7ea..76681dd7bb 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -196,3 +196,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Raw reasoning and provider-authored summary deltas both remain real upstream activity; visibility does not change heartbeat or terminal ownership. See [reasoning presentation](../providers/chat-compat.md). diff --git a/tests/adapters/bridge-raw-reasoning-hidden.test.ts b/tests/adapters/bridge-raw-reasoning-hidden.test.ts index 4acc66ce62..162d98b206 100644 --- a/tests/adapters/bridge-raw-reasoning-hidden.test.ts +++ b/tests/adapters/bridge-raw-reasoning-hidden.test.ts @@ -77,18 +77,19 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del expect(fc).toMatchObject({ call_id: "call_1", name: "read_file" }); }); - test("streamed visible (flag off): raw reasoning rides the expandable summary channel (#2007)", async () => { + test("streamed visible (flag off): raw reasoning rides the content channel (#2007)", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "reasoning_raw_delta", text: "visible raw" }, { type: "done" }, ]), "routed/model")); - expect(frames.some(f => f.event === "response.reasoning_summary_text.delta")).toBe(true); - expect(frames.some(f => f.event === "response.reasoning_text.delta")).toBe(false); + expect(frames.some(f => f.event === "response.reasoning_text.delta")).toBe(true); + expect(frames.some(f => f.event === "response.reasoning_summary_text.delta")).toBe(false); const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; const output = completed.output as Record[]; expect(output[0]).toMatchObject({ type: "reasoning", - summary: [{ type: "summary_text", text: "visible raw" }], + summary: [], + content: [{ type: "reasoning_text", text: "visible raw" }], }); }); @@ -118,14 +119,15 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)?.txt).toBe("quiet"); }); - test("non-streaming visible: raw reasoning lands in the summary channel (#2007)", () => { + test("non-streaming visible: raw reasoning lands on the content channel (#2007)", () => { const json = buildResponseJSON([ { type: "reasoning_raw_delta", text: "loud" }, { type: "done" }, ], "routed/model", {}); const output = (json as { output: Record[] }).output; expect(output.find(o => o.type === "reasoning")).toMatchObject({ - summary: [{ type: "summary_text", text: "loud" }], + summary: [], + content: [{ type: "reasoning_text", text: "loud" }], }); }); diff --git a/tests/adapters/bridge.test.ts b/tests/adapters/bridge.test.ts index e9c2b050b6..f283b77015 100644 --- a/tests/adapters/bridge.test.ts +++ b/tests/adapters/bridge.test.ts @@ -87,27 +87,26 @@ describe("Responses bridge reasoning and usage parity", () => { expect(firstOutputs).toBe(1); }); - test("streaming raw reasoning is routed through the expandable summary channel", async () => { + test("streaming raw reasoning rides the content channel like native gpt-oss", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "reasoning_raw_delta", text: "raw detail" }, { type: "done", usage: { inputTokens: 10, outputTokens: 5, cachedInputTokens: 3, reasoningOutputTokens: 2 } }, ]), "routed/model")); - // Chat-completions providers (DeepSeek-style) deliver thinking as raw - // reasoning_content. Codex renders the expandable reasoning trace from the - // Responses summary channel only, so raw reasoning is routed through the - // summary channel (issue #45) instead of the content channel. - expect(frames.find(f => f.event === "response.reasoning_summary_text.delta")?.data) - .toMatchObject({ summary_index: 0, delta: "raw detail" }); - expect(frames.some(f => f.event === "response.reasoning_text.delta")).toBe(false); + // Raw reasoning_content rides the content channel so Codex applies its own display + // policy: the desktop band shows the "Thinking…" placeholder, and raw text appears + // only when show_raw_agent_reasoning is enabled — never as a fake summary. + expect(frames.find(f => f.event === "response.reasoning_text.delta")?.data) + .toMatchObject({ content_index: 0, delta: "raw detail" }); + expect(frames.some(f => f.event === "response.reasoning_summary_text.delta")).toBe(false); const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; const output = completed.output as Record[]; expect(output[0]).toMatchObject({ type: "reasoning", - summary: [{ type: "summary_text", text: "raw detail" }], + summary: [], + content: [{ type: "reasoning_text", text: "raw detail" }], }); - expect((output[0] as { content?: unknown }).content).toBeUndefined(); expect(completed.usage).toMatchObject({ input_tokens: 10, input_tokens_details: { cached_tokens: 3 }, @@ -502,9 +501,9 @@ describe("Responses bridge reasoning and usage parity", () => { const output = json.output as Record[]; expect(output.map(item => item.type)).toEqual(["reasoning", "message"]); expect(output[0]).toMatchObject({ - summary: [{ type: "summary_text", text: "raw json" }], + summary: [], + content: [{ type: "reasoning_text", text: "raw json" }], }); - expect((output[0] as { content?: unknown }).content).toBeUndefined(); expect(json.usage).toMatchObject({ input_tokens: 6, input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, diff --git a/tests/adapters/google/google-adapter.test.ts b/tests/adapters/google/google-adapter.test.ts index 61e1d15d4b..36052d90f2 100644 --- a/tests/adapters/google/google-adapter.test.ts +++ b/tests/adapters/google/google-adapter.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter } from "../../../src/adapters/google"; import { chatCompletionsToResponsesBody } from "../../../src/chat/inbound"; +import { buildResponseJSON } from "../../../src/bridge"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; import { parseRequest } from "../../../src/responses/parser"; -import type { OcxParsedRequest } from "../../../src/types"; +import type { AdapterEvent, OcxParsedRequest } from "../../../src/types"; const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }; @@ -583,3 +585,125 @@ describe("google adapter — direct -tiered wire renames", () => { } }); }); + +describe("google adapter — Antigravity thought-text opt-in", () => { + // CCA keeps generating thinking either way (thoughtsTokenCount stays non-zero) but returns + // NO `thought` text unless the request sets generationConfig.thinkingConfig.includeThoughts. + // Probed 2026-09-12: gemini-3.8-flash-high answered with 0 thought parts and 321 thoughts + // tokens, then 358-652 chars of reasoning once the key was present. + const ccaProvider = { + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "proj-123", + } as const; + const optedIn = { ...ccaProvider, showThinkingSummary: true } as const; + + function thoughtParsed(modelId: string, effort?: string, hideThinkingSummary?: boolean): OcxParsedRequest { + return { + modelId, + stream: false, + options: { ...(effort ? { reasoning: effort } : {}), ...(hideThinkingSummary ? { hideThinkingSummary } : {}) }, + context: { messages: [{ role: "user", content: "hi" }], tools: [] }, + } as unknown as OcxParsedRequest; + } + + async function thinkingConfig( + providerConfig: Record, + modelId: string, + effort?: string, + hideThinkingSummary?: boolean, + ): Promise | undefined> { + const { body } = await createGoogleAdapter(providerConfig as never) + .buildRequest(thoughtParsed(modelId, effort, hideThinkingSummary)); + const envelope = JSON.parse(body) as { + request: { generationConfig?: { thinkingConfig?: Record } }; + }; + return envelope.request.generationConfig?.thinkingConfig; + } + + test("asks CCA for thought text on the Gemini wire families", async () => { + // Suffix tier ids deliberately state no level — the suffix IS the effort — so the opt-in + // has to stand on its own for those. + expect(await thinkingConfig(optedIn, "gemini-3.8-flash", "high")).toEqual({ includeThoughts: true }); + expect(await thinkingConfig(optedIn, "gemini-3.8-flash-medium")).toEqual({ includeThoughts: true }); + expect(await thinkingConfig(optedIn, "gemini-3.7-flash", "high")) + .toEqual({ thinkingLevel: "high", includeThoughts: true }); + expect(await thinkingConfig(optedIn, "gemini-3.1-pro", "high")) + .toEqual({ thinkingLevel: "high", includeThoughts: true }); + }); + + test("never sends the flag to models that reject or ignore it", async () => { + // gpt-oss answers 400 INVALID_ARGUMENT with the key present, so it would break the turn. + expect(await thinkingConfig(optedIn, "gpt-oss-120b-medium")).toBeUndefined(); + // Claude-on-CCA accepts the key but returns no thought parts, so it stays off that wire. + expect(await thinkingConfig(optedIn, "claude-sonnet-4-6", "high")).toEqual({ thinkingLevel: "high" }); + }); + + test("a provider without the opt-in keeps the CCA wire unchanged", async () => { + expect(await thinkingConfig(ccaProvider, "gemini-3.8-flash", "high")).toBeUndefined(); + expect(await thinkingConfig(ccaProvider, "gemini-3.7-flash", "high")).toEqual({ thinkingLevel: "high" }); + }); + + test("an explicit client opt-out stops the thought text at the source", async () => { + // Same per-request gate the response path uses: hideThinkingSummary is set for an explicit + // reasoning.summary "none", and paying upstream for text the client refused is waste. + expect(await thinkingConfig(optedIn, "gemini-3.8-flash", "high", true)).toBeUndefined(); + expect(await thinkingConfig(optedIn, "gemini-3.7-flash", "high", true)).toEqual({ thinkingLevel: "high" }); + }); +}); + + +describe("CCA thought summary provenance and replay", () => { + const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "fixture-key", project: "fixture-project", showThinkingSummary: true } as const; + const signature = "CiQAx-summary-tool-signature-0123456789abcdef"; + for (const stream of [false, true]) test(`Gemini summary retains tool signature, stream=${stream}`, async () => { + const adapter = withTestTranslatorBudget(createGoogleAdapter(cca)); + const parsed = parsedWith([{ role: "user", content: "lookup" }], [ + { name: "lookup", description: "look up", parameters: { type: "object", properties: {} } }, + ]); + parsed.modelId = "gemini-3.8-flash"; + await adapter.buildRequest(parsed); + const payload = { response: { candidates: [{ content: { parts: [ + { thought: true, text: "Provider summary", thoughtSignature: signature }, + { functionCall: { name: "lookup", args: {} } }, + { text: "Final answer" }, + ] }, finishReason: "STOP" }], usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 } } }; + const events: AdapterEvent[] = []; + if (stream) { + for await (const event of adapter.parseStream(new Response(`data: ${JSON.stringify(payload)}\n\n`, + { headers: { "content-type": "text/event-stream" } }))) events.push(event); + } else events.push(...await adapter.parseResponse!(Response.json(payload))); + expect(events[0]).toEqual({ type: "thinking_delta", thinking: "Provider summary" }); + expect(events.some(event => event.type === "thinking_signature")).toBe(false); + const call = events.find(event => event.type === "tool_call_start"); + expect(call?.type === "tool_call_start" && call.providerMetadata?.google?.thoughtSignature).toBe(signature); + expect(events.at(-1)?.type).toBe("done"); + const output = buildResponseJSON(events, parsed.modelId); + expect(JSON.stringify(output)).toContain('"summary_text","text":"Provider summary"'); + expect(JSON.stringify(output)).toContain("Final answer"); + const continuation = parseRequest({ model: parsed.modelId, input: [ + ...(output.output as unknown[]), { type: "function_call_output", call_id: call && "id" in call ? call.id : "", output: "result" }, + ] }); + const next = JSON.parse((await withTestTranslatorBudget(createGoogleAdapter(cca)).buildRequest(continuation)).body); + const parts = next.request.contents.flatMap((turn: { parts: unknown[] }) => turn.parts); + expect(parts).toContainEqual(expect.objectContaining({ functionCall: expect.objectContaining({ name: "lookup" }), thoughtSignature: signature })); + }); + + test("reused adapter resets Gemini summary provenance for a CCA non-Gemini model", async () => { + const adapter = withTestTranslatorBudget(createGoogleAdapter(cca)); + for (const modelId of ["gemini-3.8-flash", "gpt-oss-120b-medium"]) { + const request = parsedWith([{ role: "user", content: "hi" }]); + request.modelId = modelId; + await adapter.buildRequest(request); + const events = await adapter.parseResponse!(Response.json({ response: { candidates: [{ + content: { parts: [{ thought: true, text: "thinking" }] }, finishReason: "STOP", + }] } })); + expect(events[0]).toEqual(modelId.startsWith("gemini-") + ? { type: "thinking_delta", thinking: "thinking" } + : { type: "reasoning_raw_delta", text: "thinking" }); + } + }); +}); diff --git a/tests/adapters/google/google-wire-compiler.test.ts b/tests/adapters/google/google-wire-compiler.test.ts index 1523d11d85..482aa809e6 100644 --- a/tests/adapters/google/google-wire-compiler.test.ts +++ b/tests/adapters/google/google-wire-compiler.test.ts @@ -132,4 +132,28 @@ describe("Google wire compiler", () => { const repaired = JSON.parse(repairGoogleInvalidRequestBody(body, error)!); expect(repaired.request.generationConfig).toEqual({ maxOutputTokens: 4096 }); }); + + test("keeps the includeThoughts opt-in while still dropping unknown thinking keys", () => { + const withOptIn = compileGoogleWireBody({ + generationConfig: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "max", futureThinkingField: true }, + }, + }); + expect(withOptIn.body.generationConfig).toEqual({ + thinkingConfig: { thinkingLevel: "high", includeThoughts: true }, + }); + + // The flag has to survive on its own too: suffix tier ids deliberately carry no + // thinkingLevel, so an includeThoughts-only config is the whole request. + const optInOnly = compileGoogleWireBody({ + generationConfig: { thinkingConfig: { includeThoughts: true } }, + }); + expect(optInOnly.body.generationConfig).toEqual({ thinkingConfig: { includeThoughts: true } }); + + // Non-boolean / absent values must not invent the key. + const notRequested = compileGoogleWireBody({ + generationConfig: { thinkingConfig: { includeThoughts: "yes", thinkingLevel: "high" } }, + }); + expect(notRequested.body.generationConfig).toEqual({ thinkingConfig: { thinkingLevel: "high" } }); + }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 62724ffed2..7fd3c60621 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -932,10 +932,10 @@ "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", - "responses-reasoning-summary-rewrite.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", + "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", diff --git a/tests/providers/opencode-go-luna-wire.test.ts b/tests/providers/opencode-go-luna-wire.test.ts index c0afb15553..7866ec9415 100644 --- a/tests/providers/opencode-go-luna-wire.test.ts +++ b/tests/providers/opencode-go-luna-wire.test.ts @@ -163,19 +163,17 @@ describe("OpenCode Go stateless reasoning and continuation routes", () => { const initial = { type: "message", role: "user", content: [{ type: "input_text", text: "Run probe" }] }; const first = await drive({ input: [initial] }); expect(first.document.output[0]).toEqual(reasoning[0]); - expect(first.document.output[1]).toEqual(continuation.summary === "auto" ? { - type: "reasoning", id: `rs_${prefix}_content`, status: "completed", summary: [{ type: "summary_text", text: "Visible thinking" }], - } : reasoning[1]); + // The passthrough keeps native content-channel reasoning in both display modes. + expect(first.document.output[1]).toEqual(reasoning[1]); expect(first.document.output[2]).toEqual(reasoning[2]); expect(first.document.output[3]).toMatchObject(call); expect(first.document.output[4]).toEqual(priorMessage); if (streaming) { - const channel = continuation.summary === "auto" ? "reasoning_summary_text" : "reasoning_text"; - expect(first.text).toContain(`"type":"response.${channel}.delta"`); + expect(first.text).toContain('"type":"response.reasoning_text.delta"'); } const result = { type: "function_call_output", call_id: call.call_id, output: "probe succeeded" }; // Echo exactly the client-visible history through handleResponses. An upstream-shape - // cache would prepend it again after the content-to-summary rewrite (F1). + // cache would prepend it again (F1). const nextBody = { input: continuation.fullHistory ? [initial, ...first.document.output, result] : [result], previous_response_id: first.document.id, store: true, @@ -207,9 +205,8 @@ describe("OpenCode Go stateless reasoning and continuation routes", () => { expect(replay.filter(item => item.type === "reasoning")).toHaveLength(3); expect(replay).toContainEqual(expect.objectContaining({ type: "reasoning", encrypted_content: blob })); expect(JSON.stringify(replay)).toContain("Already summarized"); - if (continuation.summary === "auto") expect(replay).toContainEqual(expect.objectContaining({ - type: "reasoning", summary: [{ type: "summary_text", text: "Visible thinking" }], - })); + // Replay sanitation strips reasoning content in both display modes (F1), so the + // visible "Visible thinking" trace does not re-enter the upstream history. expect(JSON.stringify(replay)).not.toContain("no tool result was recorded"); }); } diff --git a/tests/responses/responses-reasoning-summary-passthrough.test.ts b/tests/responses/responses-reasoning-summary-passthrough.test.ts index 5912221354..ab6e099bcc 100644 --- a/tests/responses/responses-reasoning-summary-passthrough.test.ts +++ b/tests/responses/responses-reasoning-summary-passthrough.test.ts @@ -6,10 +6,12 @@ import type { OcxConfig } from "../../src/types"; /** * The passthrough relay for DeepSeek's native /responses endpoint emits - * content-channel reasoning (reasoning_text.delta + content items). The - * summary-channel rewrite must engage only when the client did NOT ask for - * hidden thinking (hideThinkingSummary) - otherwise a client that asked to - * hide reasoning would get it surfaced as visible summary output. + * content-channel reasoning (reasoning_text.delta + content items) in BOTH + * display modes: Codex applies its own raw-reasoning display policy, so a + * requested summary must not rewrite the native passthrough shape either. + * Hidden thinking (hideThinkingSummary) and visible summary get the same + * content-channel passthrough; the hidden variant additionally arrives as an + * envelope-only item upstream when the adapter layer handles suppression. */ function deepseekSeed() { @@ -86,15 +88,16 @@ describe("passthrough reasoning summary rewrite honors hideThinkingSummary", () expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); }); - test("SSE: requested summary routes raw reasoning through the summary channel", async () => { + test("SSE: requested summary keeps the native content-channel passthrough", async () => { const response = await runHandleResponses( { model: "deepseek-v4-flash", input: "ping", stream: true, reasoning: { effort: "max", summary: "detailed" } }, SSE_UPSTREAM_FRAMES.join(""), "text/event-stream", ); const text = await response.text(); - expect(text).toContain("response.reasoning_summary_text.delta"); - expect(text).toContain('"summary":[{"type":"summary_text","text":"think"}]'); + expect(text).toContain("response.reasoning_text.delta"); + expect(text).not.toContain("response.reasoning_summary_text.delta"); + expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); }); test("bounded JSON: hidden thinking keeps the content shape", async () => { @@ -108,14 +111,14 @@ describe("passthrough reasoning summary rewrite honors hideThinkingSummary", () expect(text).not.toContain('"summary":[{"type":"summary_text"'); }); - test("bounded JSON: requested summary moves item content into summary", async () => { + test("bounded JSON: requested summary keeps the content shape", async () => { const response = await runHandleResponses( { model: "deepseek-v4-flash", input: "ping", stream: false, reasoning: { effort: "max", summary: "detailed" } }, JSON_UPSTREAM, "application/json", ); const text = await response.text(); - expect(text).toContain('"summary":[{"type":"summary_text","text":"think"}]'); - expect(text).not.toContain('"content":[{"type":"reasoning_text","text":"think"}]'); + expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); + expect(text).not.toContain('"summary":[{"type":"summary_text"'); }); }); diff --git a/tests/responses/responses-reasoning-summary-rewrite.test.ts b/tests/responses/responses-reasoning-summary-rewrite.test.ts deleted file mode 100644 index 32940a24e7..0000000000 --- a/tests/responses/responses-reasoning-summary-rewrite.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - createReasoningSummaryChannelPayloadRewrite, - routeUsesContentChannelReasoning, - rewriteReasoningSummaryInJson, - rewriteReasoningSummaryInJsonString, -} from "../../src/server/responses-reasoning-summary-rewrite"; - -const rewrite = createReasoningSummaryChannelPayloadRewrite(); - -function apply(payload: unknown): unknown { - return JSON.parse(rewrite(JSON.stringify(payload))); -} - -describe("responses reasoning summary channel rewrite", () => { - test("routes reasoning_text.delta through the summary channel", () => { - expect(apply({ - type: "response.reasoning_text.delta", - content_index: 0, - delta: "think", - item_id: "rs_1", - output_index: 0, - sequence_number: 4, - })).toEqual({ - type: "response.reasoning_summary_text.delta", - summary_index: 0, - delta: "think", - item_id: "rs_1", - output_index: 0, - sequence_number: 4, - }); - }); - - test("routes reasoning_text.done through the summary channel", () => { - expect(apply({ - type: "response.reasoning_text.done", - content_index: 0, - text: "full thinking", - item_id: "rs_1", - output_index: 0, - })).toEqual({ - type: "response.reasoning_summary_text.done", - summary_index: 0, - text: "full thinking", - item_id: "rs_1", - output_index: 0, - }); - }); - - test("moves reasoning item content into summary on output_item.done", () => { - expect(apply({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }, - })).toEqual({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_1", - status: "completed", - summary: [{ type: "summary_text", text: "thinking" }], - }, - }); - }); - - test("moves reasoning item content into summary inside response.completed", () => { - const payload = { - type: "response.completed", - response: { - id: "resp_1", - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }, - { type: "message", id: "msg_1", status: "completed", content: [{ type: "output_text", text: "OK" }] }, - ], - }, - }; - const result = apply(payload) as { response: { output: Record[] } }; - expect(result.response.output[0]).toEqual({ - type: "reasoning", - id: "rs_1", - status: "completed", - summary: [{ type: "summary_text", text: "thinking" }], - }); - expect(result.response.output[1]).toEqual(payload.response.output[1]); - }); - - test("leaves summary-channel and message events untouched", () => { - const untouched = [ - { type: "response.reasoning_summary_text.delta", summary_index: 0, delta: "s", item_id: "rs_1", output_index: 0 }, - { type: "response.output_text.delta", content_index: 0, delta: "OK", item_id: "msg_1", output_index: 1 }, - { type: "response.output_item.added", output_index: 1, item: { type: "message", id: "msg_1", status: "in_progress", content: [] } }, - ]; - for (const payload of untouched) { - expect(apply(payload)).toEqual(payload); - } - }); - - test("leaves a reasoning item without content text untouched", () => { - expect(apply({ - type: "response.output_item.done", - output_index: 0, - item: { type: "reasoning", id: "rs_1", status: "completed", content: [], summary: [] }, - })).toEqual({ - type: "response.output_item.done", - output_index: 0, - item: { type: "reasoning", id: "rs_1", status: "completed", content: [], summary: [] }, - }); - }); - - test("preserves a summary-channel reasoning item as-is", () => { - expect(apply({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [], - summary: [{ type: "summary_text", text: "already summarized" }], - }, - })).toEqual({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [], - summary: [{ type: "summary_text", text: "already summarized" }], - }, - }); - }); - - test("rewrites reasoning items inside a bare completed response document", () => { - const doc = { - id: "resp_1", - object: "response", - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }, - { type: "message", id: "msg_1", status: "completed", content: [{ type: "output_text", text: "OK" }] }, - ], - }; - const result = rewriteReasoningSummaryInJson(doc) as { output: Record[] }; - expect(result.output[0]).toEqual({ - type: "reasoning", - id: "rs_1", - status: "completed", - summary: [{ type: "summary_text", text: "thinking" }], - }); - expect(result.output[1]).toEqual(doc.output[1]); - }); - - test("rewrites reasoning items inside an SSE completed event document", () => { - const doc = { - type: "response.completed", - response: { - id: "resp_1", - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }, - ], - }, - }; - const result = rewriteReasoningSummaryInJson(doc) as { response: { output: Record[] } }; - expect(result.response.output[0]).toEqual({ - type: "reasoning", - id: "rs_1", - status: "completed", - summary: [{ type: "summary_text", text: "thinking" }], - }); - }); - - test("string-level rewrite leaves summary-channel documents untouched", () => { - const doc = JSON.stringify({ - id: "resp_1", - output: [{ type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "already summarized" }] }], - }); - expect(rewriteReasoningSummaryInJsonString(doc)).toBe(doc); - }); - - test("malformed payloads pass through unchanged", () => { - expect(rewrite("not json")).toBe("not json"); - expect(rewrite("[1,2]")).toBe("[1,2]"); - }); - - // `encrypted_content` is opaque, state-bearing provider data, so preserve the complete item - // shape defensively when the client replays it. This rewrite's round-trip was verified against - // DeepSeek, which is stateless and issues no blob; providers that do issue one joined later - // through `preserveReasoningContentModels`. - describe("items carrying encrypted_content", () => { - const blobItem = { - type: "reasoning", - id: "rs_1", - status: "completed", - encrypted_content: "gAAAAAB-upstream-issued-blob", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }; - - test("are returned byte-for-byte on output_item.done", () => { - const payload = { type: "response.output_item.done", output_index: 0, item: blobItem }; - expect(apply(payload)).toEqual(payload); - }); - - test("are returned byte-for-byte inside response.completed output", () => { - const payload = { - type: "response.completed", - response: { id: "resp_1", output: [blobItem] }, - }; - expect(apply(payload)).toEqual(payload); - }); - - test("are returned byte-for-byte through the non-streaming document rewrite", () => { - const doc = { id: "resp_1", object: "response", output: [blobItem] }; - expect(rewriteReasoningSummaryInJson(doc)).toBe(doc); - const json = JSON.stringify(doc); - expect(rewriteReasoningSummaryInJsonString(json)).toBe(json); - }); - - // Only the stored item is protected: the live trace Codex renders comes from the delta events, - // which carry no blob and are still routed to the summary channel. - test("do not disable the delta rewrite that renders the live trace", () => { - expect(apply({ - type: "response.reasoning_text.delta", - delta: "think", - item_id: "rs_1", - output_index: 0, - })).toMatchObject({ type: "response.reasoning_summary_text.delta", delta: "think" }); - }); - }); -}); - -describe("routeUsesContentChannelReasoning", () => { - test("statelessResponses providers use the content channel", () => { - expect(routeUsesContentChannelReasoning({ statelessResponses: true }, "deepseek-v4-flash")).toBe(true); - }); - - test("preserveReasoningContentModels lists qualify", () => { - expect(routeUsesContentChannelReasoning( - { preserveReasoningContentModels: ["deepseek-v4-flash"] }, - "deepseek-v4-flash", - )).toBe(true); - }); - - test("model matching is case-insensitive on both sides", () => { - expect(routeUsesContentChannelReasoning( - { preserveReasoningContentModels: ["DeepSeek-V4-Flash"] }, - "deepseek-v4-flash", - )).toBe(true); - expect(routeUsesContentChannelReasoning( - { preserveReasoningContentModels: ["deepseek-v4-flash"] }, - "DeepSeek-V4-Flash", - )).toBe(true); - }); - - test("other providers do not", () => { - expect(routeUsesContentChannelReasoning({}, "gpt-5.5")).toBe(false); - }); -}); diff --git a/tests/responses/responses-show-thinking-summary.test.ts b/tests/responses/responses-show-thinking-summary.test.ts new file mode 100644 index 0000000000..a12ad8dc83 --- /dev/null +++ b/tests/responses/responses-show-thinking-summary.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +// Provider-opted visible thinking (showThinkingSummary): a provider that serves +// genuine user-facing reasoning surfaces it on the summary channel even when the +// client omits reasoning.summary (the Codex default, which otherwise hides all +// thinking in replay-only envelopes). An explicit client summary of "none" still +// wins and keeps thinking hidden. + +function shownSeed() { + const seed = providerConfigSeed(getProviderRegistryEntry("deepseek")!); + return { ...seed, apiKey: "sk-test", showThinkingSummary: true } as OcxProviderConfig; +} + +function sseFrame(payload: unknown): string { + return "data: " + JSON.stringify(payload) + "\n\n"; +} + +const SSE_UPSTREAM = [ + sseFrame({ type: "response.created", response: { id: "resp_1", status: "in_progress", output: [] } }), + sseFrame({ type: "response.output_item.added", output_index: 0, item: { type: "reasoning", id: "rs_1", status: "in_progress", content: [], summary: [] } }), + sseFrame({ type: "response.reasoning_text.delta", content_index: 0, delta: "think", item_id: "rs_1", output_index: 0 }), + sseFrame({ type: "response.reasoning_text.done", content_index: 0, text: "think", item_id: "rs_1", output_index: 0 }), + sseFrame({ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "rs_1", status: "completed", content: [{ type: "reasoning_text", text: "think" }], summary: [] } }), + sseFrame({ type: "response.completed", response: { id: "resp_1", status: "completed", output: [{ type: "reasoning", id: "rs_1", status: "completed", content: [{ type: "reasoning_text", text: "think" }], summary: [] }] } }), +].join(""); + +async function runHandleResponses(body: Record, seed: OcxProviderConfig) { + const encoder = new TextEncoder(); + globalThis.fetch = (async () => new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(SSE_UPSTREAM)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + )) as typeof fetch; + const config = { providers: { deepseek: seed } } as unknown as OcxConfig; + return handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + config, + { model: "", provider: "" }, + { abortSignal: AbortSignal.timeout(5_000) }, + ); +} + +describe("showThinkingSummary provider option", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + test("provider opt-in never relabels raw content as a summary", async () => { + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: true }, + shownSeed(), + ); + const text = await response.text(); + expect(text).not.toContain("response.reasoning_summary_text.delta"); + expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); + }); + + test("explicit client summary none keeps thinking hidden", async () => { + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: true, reasoning: { summary: "none" } }, + shownSeed(), + ); + const text = await response.text(); + expect(text).not.toContain("response.reasoning_summary_text.delta"); + expect(text).toContain("response.reasoning_text.delta"); + }); + + test("without the provider option, omitted summary stays hidden", async () => { + const seed = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" } as OcxProviderConfig; + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: true }, + seed, + ); + const text = await response.text(); + expect(text).not.toContain("response.reasoning_summary_text.delta"); + expect(text).toContain("response.reasoning_text.delta"); + }); + + test("google-antigravity preset opts in", () => { + expect(providerConfigSeed(getProviderRegistryEntry("google-antigravity")!).showThinkingSummary).toBe(true); + expect(providerConfigSeed(getProviderRegistryEntry("deepseek")!).showThinkingSummary).toBeUndefined(); + }); + + for (const stream of [false, true]) for (const [summary, providerFlag, visible] of [ + [undefined, undefined, true], ["none", true, false], [undefined, false, false], ["auto", false, true], + ] as const) test(`CCA summary=${summary} provider=${providerFlag} stream=${stream}`, async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-show-thinking-")); + const prevHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3_600_000, + projectId: "project-id", + }, + }], + }, + })); + const seen: string[] = []; + const requests: Array<{ request: { generationConfig?: { thinkingConfig?: { includeThoughts?: boolean } } } }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push(String(input)); + requests.push(JSON.parse(String(init?.body))); + const payload = { + response: { + candidates: [{ + content: { parts: [{ thought: true, text: "cca-think" }, { text: "OK" }] }, + finishReason: "STOP", + }], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15, thoughtsTokenCount: 3 }, + }, + }; + return stream + ? new Response(sseFrame(payload), { headers: { "content-type": "text/event-stream" } }) + : Response.json(payload); + }) as typeof fetch; + try { + const seed = { + ...providerConfigSeed(getProviderRegistryEntry("google-antigravity")!), + liveModels: false, + models: ["gemini-3.8-flash"], + } as OcxProviderConfig; + // Simulate a saved provider row written before the registry learned the flag: + // the request path must backfill it from the registry entry (routedProviderConfig), + // enrichProviderFromRegistry never runs there. + delete seed.showThinkingSummary; + if (providerFlag !== undefined) seed.showThinkingSummary = providerFlag; + const config = { providers: { "google-antigravity": seed } } as unknown as OcxConfig; + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "google-antigravity/gemini-3.8-flash", input: "ping", stream, reasoning: { effort: "low", ...(summary ? { summary } : {}) } }), + }), + config, + { model: "", provider: "" }, + { abortSignal: AbortSignal.timeout(10_000) }, + ); + const text = await response.text(); + expect(seen).toHaveLength(1); + expect(seen[0]).toContain(stream ? "v1internal:streamGenerateContent" : "v1internal:generateContent"); + expect(text.includes('"summary":[{"type":"summary_text","text":"cca-think"}]')).toBe(visible); + expect(requests[0].request.generationConfig?.thinkingConfig?.includeThoughts === true) + .toBe(visible && providerFlag !== false); + if (stream) expect(text).toContain("response.completed"); + expect(text).toContain("OK"); + } finally { + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 6f649c52d3..1428b8137f 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -3937,3 +3937,35 @@ describe("combo compact failover", () => { expect(await response.text()).toContain("empty summary"); }); }); + + +describe("thinking-summary defaults follow the serving combo route", () => { + for (const firstVisible of [true, false]) for (const summary of [undefined, "none", "auto"]) { + test(`fallback from ${firstVisible} with summary=${summary}`, async () => { + const observed: Array<[string, boolean | undefined]> = []; + customRunTurn = async (parsed, _incoming, emit) => { + observed.push([parsed.modelId, parsed.options.hideThinkingSummary]); + if (parsed.modelId === "m1") { + emit({ type: "error", message: "provider unavailable", status: 503, retryable: true }); + return; + } + emit({ type: "thinking_delta", thinking: "Actual provider summary" }); + emit({ type: "text_delta", text: "Final fallback answer" }); + emit({ type: "done" }); + }; + const config = comboConfig({ + a: provider("test-run-turn", "https://a.test/v1", "key-a", { showThinkingSummary: firstVisible }), + b: provider("test-run-turn", "https://b.test/v1", "key-b", { showThinkingSummary: !firstVisible }), + }); + const response = await post(config, { ...(summary ? { reasoning: { summary } } : {}) }); + const output = JSON.stringify(await response.json()); + expect(response.status).toBe(200); + expect(observed).toEqual([ + ["m1", summary === "none" || (!summary && !firstVisible)], + ["m2", summary === "none" || (!summary && firstVisible)], + ]); + expect(output.includes("Actual provider summary")).toBe(summary === "auto" || (!summary && !firstVisible)); + expect(output).toContain("Final fallback answer"); + }); + } +}); diff --git a/tests/server/server-xai-chat-reasoning-streaming.test.ts b/tests/server/server-xai-chat-reasoning-streaming.test.ts index 5019147189..badc15fa9f 100644 --- a/tests/server/server-xai-chat-reasoning-streaming.test.ts +++ b/tests/server/server-xai-chat-reasoning-streaming.test.ts @@ -151,7 +151,7 @@ describe("xAI OAuth Chat reasoning streaming", () => { let received = ""; await Promise.race([ (async () => { - while (!received.includes("response.reasoning_summary_text.delta")) { + while (!received.includes("response.reasoning_text.delta")) { const chunk = await reader!.read(); if (chunk.done) throw new Error("stream ended before the first xAI reasoning delta"); received += decoder.decode(chunk.value, { stream: true }); @@ -184,7 +184,7 @@ describe("xAI OAuth Chat reasoning streaming", () => { if (chunk.done) break; received += decoder.decode(chunk.value, { stream: true }); } - const reasoningIndex = received.indexOf("response.reasoning_summary_text.delta"); + const reasoningIndex = received.indexOf("response.reasoning_text.delta"); const contentIndex = received.indexOf("response.output_text.delta"); const completedIndex = received.indexOf("response.completed"); expect(reasoningIndex).toBeGreaterThanOrEqual(0); From 6fe87d4ca88da30f35107195ac15592ecf35540c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:45:31 +0900 Subject: [PATCH 025/224] test(responses): exercise summary generation and hidden signature replay --- .../010_presentation.md | 2 ++ tests/adapters/google/google-adapter.test.ts | 22 +++++++++++++------ .../responses-show-thinking-summary.test.ts | 5 +++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/devlog/_plan/260912_thinking_contract/010_presentation.md b/devlog/_plan/260912_thinking_contract/010_presentation.md index 8c626aa5bd..b1503a5f34 100644 --- a/devlog/_plan/260912_thinking_contract/010_presentation.md +++ b/devlog/_plan/260912_thinking_contract/010_presentation.md @@ -318,3 +318,5 @@ Explicit wire reasoning.summary:"none" wins. A client that serializes configured ## Presentation P revalidation Prior D: roadmap locked; next presentation implementation. Both source patches apply to baseline; combined application requires keeping the newer no-rewrite expectation. Shared classifier signatures remain current. Implement CCA-only provider default by recomputing parsed.options.hideThinkingSummary from raw summary each final route for inboundWire responses; other inbound types preserve their existing flag. Missing raw request leaves original hide flag authoritative. CCA Gemini classification records boolean in existing per-request adapter closure on each build; default false. + +C review corrections: fixture summary emission now depends on includeThoughts; provider false + client auto does not request upstream summaries. Signature regression exercises actual SSE and JSON serializers, visible/hidden modes, a tool-ending first turn, exact signature and real matching functionResponse. Final-answer behavior is covered separately by bridge and combo tests. diff --git a/tests/adapters/google/google-adapter.test.ts b/tests/adapters/google/google-adapter.test.ts index 36052d90f2..f4b53960a5 100644 --- a/tests/adapters/google/google-adapter.test.ts +++ b/tests/adapters/google/google-adapter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter } from "../../../src/adapters/google"; import { chatCompletionsToResponsesBody } from "../../../src/chat/inbound"; -import { buildResponseJSON } from "../../../src/bridge"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../../src/bridge"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; import { parseRequest } from "../../../src/responses/parser"; import type { AdapterEvent, OcxParsedRequest } from "../../../src/types"; @@ -659,7 +659,7 @@ describe("CCA thought summary provenance and replay", () => { const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://daily-cloudcode-pa.googleapis.com", apiKey: "fixture-key", project: "fixture-project", showThinkingSummary: true } as const; const signature = "CiQAx-summary-tool-signature-0123456789abcdef"; - for (const stream of [false, true]) test(`Gemini summary retains tool signature, stream=${stream}`, async () => { + for (const stream of [false, true]) for (const hideThinkingSummary of [false, true]) test(`Gemini signature stream=${stream} hidden=${hideThinkingSummary}`, async () => { const adapter = withTestTranslatorBudget(createGoogleAdapter(cca)); const parsed = parsedWith([{ role: "user", content: "lookup" }], [ { name: "lookup", description: "look up", parameters: { type: "object", properties: {} } }, @@ -669,7 +669,6 @@ describe("CCA thought summary provenance and replay", () => { const payload = { response: { candidates: [{ content: { parts: [ { thought: true, text: "Provider summary", thoughtSignature: signature }, { functionCall: { name: "lookup", args: {} } }, - { text: "Final answer" }, ] }, finishReason: "STOP" }], usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 } } }; const events: AdapterEvent[] = []; if (stream) { @@ -681,15 +680,24 @@ describe("CCA thought summary provenance and replay", () => { const call = events.find(event => event.type === "tool_call_start"); expect(call?.type === "tool_call_start" && call.providerMetadata?.google?.thoughtSignature).toBe(signature); expect(events.at(-1)?.type).toBe("done"); - const output = buildResponseJSON(events, parsed.modelId); - expect(JSON.stringify(output)).toContain('"summary_text","text":"Provider summary"'); - expect(JSON.stringify(output)).toContain("Final answer"); + let output: Record; + if (stream) { + async function* replay() { yield* events; } + const text = await new Response(bridgeToResponsesSSE(replay(), parsed.modelId, { hideThinkingSummary })).text(); + const payloads = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + output = payloads.find(frame => frame.type === "response.completed").response; + expect(text.includes("response.reasoning_summary_text.delta")).toBe(!hideThinkingSummary); + } else output = buildResponseJSON(events, parsed.modelId, { hideThinkingSummary }); + expect(JSON.stringify(output).includes("Provider summary")).toBe(!hideThinkingSummary); + if (!Array.isArray(output.output)) throw new Error("missing Responses output"); const continuation = parseRequest({ model: parsed.modelId, input: [ - ...(output.output as unknown[]), { type: "function_call_output", call_id: call && "id" in call ? call.id : "", output: "result" }, + ...output.output, { type: "function_call_output", call_id: call && "id" in call ? call.id : "", output: "result" }, ] }); const next = JSON.parse((await withTestTranslatorBudget(createGoogleAdapter(cca)).buildRequest(continuation)).body); const parts = next.request.contents.flatMap((turn: { parts: unknown[] }) => turn.parts); expect(parts).toContainEqual(expect.objectContaining({ functionCall: expect.objectContaining({ name: "lookup" }), thoughtSignature: signature })); + expect(parts).toContainEqual(expect.objectContaining({ functionResponse: expect.objectContaining({ name: "lookup", response: { result: "result" } }) })); + expect(JSON.stringify(next)).not.toContain("no tool result"); }); test("reused adapter resets Gemini summary provenance for a CCA non-Gemini model", async () => { diff --git a/tests/responses/responses-show-thinking-summary.test.ts b/tests/responses/responses-show-thinking-summary.test.ts index a12ad8dc83..a749518b63 100644 --- a/tests/responses/responses-show-thinking-summary.test.ts +++ b/tests/responses/responses-show-thinking-summary.test.ts @@ -96,7 +96,7 @@ describe("showThinkingSummary provider option", () => { }); for (const stream of [false, true]) for (const [summary, providerFlag, visible] of [ - [undefined, undefined, true], ["none", true, false], [undefined, false, false], ["auto", false, true], + [undefined, undefined, true], ["none", true, false], [undefined, false, false], ["auto", false, false], ["auto", true, true], ] as const) test(`CCA summary=${summary} provider=${providerFlag} stream=${stream}`, async () => { const home = mkdtempSync(join(tmpdir(), "ocx-show-thinking-")); const prevHome = process.env.OPENCODEX_HOME; @@ -120,10 +120,11 @@ describe("showThinkingSummary provider option", () => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { seen.push(String(input)); requests.push(JSON.parse(String(init?.body))); + const requestedThoughts = requests.at(-1)?.request.generationConfig?.thinkingConfig?.includeThoughts === true; const payload = { response: { candidates: [{ - content: { parts: [{ thought: true, text: "cca-think" }, { text: "OK" }] }, + content: { parts: [...(requestedThoughts ? [{ thought: true, text: "cca-think" }] : []), { text: "OK" }] }, finishReason: "STOP", }], usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15, thoughtsTokenCount: 3 }, From 97a7d9c0f8b9ae3dae9ce87c439e4e84a5564216 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:46:14 +0900 Subject: [PATCH 026/224] test(google): pass summary visibility to SSE bridge options --- tests/adapters/google/google-adapter.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/adapters/google/google-adapter.test.ts b/tests/adapters/google/google-adapter.test.ts index f4b53960a5..1dd5a7c97a 100644 --- a/tests/adapters/google/google-adapter.test.ts +++ b/tests/adapters/google/google-adapter.test.ts @@ -683,7 +683,8 @@ describe("CCA thought summary provenance and replay", () => { let output: Record; if (stream) { async function* replay() { yield* events; } - const text = await new Response(bridgeToResponsesSSE(replay(), parsed.modelId, { hideThinkingSummary })).text(); + const text = await new Response(bridgeToResponsesSSE(replay(), parsed.modelId, + undefined, undefined, undefined, undefined, undefined, { hideThinkingSummary })).text(); const payloads = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); output = payloads.find(frame => frame.type === "response.completed").response; expect(text.includes("response.reasoning_summary_text.delta")).toBe(!hideThinkingSummary); From 53021fb04b8088e4ce273ae4b175b7385266597f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:36:58 +0900 Subject: [PATCH 027/224] docs: plan thinking presentation and transport carries --- .../260912_thinking_contract/000_plan.md | 31 ++ .../260912_thinking_contract/001_sources.md | 7 + .../010_presentation.md | 316 +++++++++++++++++ .../020_transport_hint.md | 319 ++++++++++++++++++ .../260912_thinking_contract/030_spark.md | 66 ++++ .../260912_thinking_contract/040_delivery.md | 7 + 6 files changed, 746 insertions(+) create mode 100644 devlog/_plan/260912_thinking_contract/000_plan.md create mode 100644 devlog/_plan/260912_thinking_contract/001_sources.md create mode 100644 devlog/_plan/260912_thinking_contract/010_presentation.md create mode 100644 devlog/_plan/260912_thinking_contract/020_transport_hint.md create mode 100644 devlog/_plan/260912_thinking_contract/030_spark.md create mode 100644 devlog/_plan/260912_thinking_contract/040_delivery.md diff --git a/devlog/_plan/260912_thinking_contract/000_plan.md b/devlog/_plan/260912_thinking_contract/000_plan.md new file mode 100644 index 0000000000..86a87f9d36 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/000_plan.md @@ -0,0 +1,31 @@ +# Preserve reasoning provenance and transport intent + +Readers: maintainers choosing whether to integrate the thinking lane. Raw reasoning must remain content, while provider-authored summaries can be displayed under an explicit provider default. The plan reconciles #4301 and #4287, separately reviews #3652 hint suppression, and carries #4130 Spark compatibility without retirement. + +Loop: satisfy-spec HOTL, triggered by authorized thinking-lane delivery. Goal: reviewable carry PRs and final-head hosted CI. Non-goals: merges, closure of source PRs, retirement #4334, releases, user service/config changes, other worktrees. All local product suites/build/typecheck/install are NOT RUN by instruction. Only available existing credentials/tools are used; no user token/time/agent ceiling was set. Stop: every source PR has a justified disposition and every delivered branch has exact-head hosted CI evidence. Outcomes: DONE on evidence, HOLD/NEEDS_HUMAN on explicit unresolved acceptance, never fake green. Escalation: real tool denial or requirement beyond scope; main reclaims after two distinct reviewer failures. Native architect selector is unavailable; inherited independent design review and reflection follow the user instruction, with a separate A audit. + +## Dependency map + +| Cycle | Artifact | Result | +| --- | --- | --- | +| roadmap | this file and all decade docs | docs-only plan lock | +| presentation | 010_presentation.md | raw/summary contract and provider opt-in | +| hint | 020_transport_hint.md | independent transport-hint disposition/carry | +| spark | 030_spark.md | independent Spark Lite carry | +| delivery | 040_delivery.md | final heads, review closure and hosted CI | + +Presentation combines two conflicting source proposals into one contract. Hint and Spark are independent and receive ordinary dev-based PRs, not artificial stack dependencies. Final review consumes all branches. No GitHub native stacks are requested. + +## Evidence and owner map + +Baseline origin/dev: 69e3dcda755a52feb1327edad6c8ea6cefd6e871. Source PR heads: #4301 5d6d1862a11da6e4d0c04eb7f35f9f48ae1285fd; #4287 fe13bdb7bf8403a2a2cdb10f258a68b649177953; #3652 13fb263778e9036e66ae86d41e29f9f47bbbed92; #4130 5d56f5461ea3d18668b85f6bb0d8a523920f2536. All open when inspected. Original authors: Robin Bially, yxr1995-maker, itismyfield, luvs01; exact Git trailers will be read from original commits before carrying. + +Current owners: src/bridge.ts:663 raw-reasoning finalization; src/adapters/google.ts:571 shared part classifier; src/server/responses/core.ts:2490 final-route normalization; src/responses/parser.ts:543 summary omission policy; src/types/request.ts:310 AdapterEvent. Reuse these boundaries; no new event enum or generic service layer. Structure INDEX maps shared areas to topical documents; main contracts are providers/chat-compat.md, providers/google.md, transports/responses.md and config.md with references from affected area owners. + +Verification: git diff --check was run at baseline and exited 0, checking diff whitespace only. GitHub ci.yml workflow_dispatch lane=all reads checkout source, typechecks, runs product suites and cross-platform jobs; NOT RUN locally. Every conditional scenario is named in decade docs and must be asserted in committed regression tests. Source inspection is not runtime proof. + +## Cycle records + +Roadmap P: requirements/source inspection and independent design review in progress. No product patch applied. + +Roadmap B: locked amended contract after independent A PASS and both design reflections ALIGNED. Product implementation starts in the next cycle. diff --git a/devlog/_plan/260912_thinking_contract/001_sources.md b/devlog/_plan/260912_thinking_contract/001_sources.md new file mode 100644 index 0000000000..f05813eae8 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/001_sources.md @@ -0,0 +1,7 @@ +# Source decisions + +Public PR diffs and latest comments are the source proposal evidence. #4130's September 11 corrections pin Lite on for nonempty additional_tools bodies and off otherwise; adopting the earlier unconditional false version loses tools. #4334 is an explicit retirement HOLD and is not carried. + +#4301 removes automatic content-to-summary conversion. #4287 tests raw DeepSeek content as a visible summary; that expectation conflicts with provenance and will be replaced, not adopted. Google thought-summary API documentation distinguishes summaries from opaque thought signatures: https://ai.google.dev/gemini-api/docs/generate-content/thinking (opened 2026-09-12). CCA generationConfig/includeThoughts behavior is contributor probe evidence, not a newly performed live-service probe. + +Searches used: reasoning_raw_delta, thinking_delta, hideThinkingSummary, googlePartTextEvent, preserveReasoningContent, and the four PR numbers. Existing bridge event types can distinguish raw content and summary without a new enum. No-code/config-only options cannot repair the existing mislabeled content; raw rewrite deletion plus existing boundaries is the smallest change. diff --git a/devlog/_plan/260912_thinking_contract/010_presentation.md b/devlog/_plan/260912_thinking_contract/010_presentation.md new file mode 100644 index 0000000000..e39e9c0bfe --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/010_presentation.md @@ -0,0 +1,316 @@ +# Presentation contract + +Class C4 public protocol contract. Depends on roadmap lock. MODIFY src/bridge.ts: closeCurrentRawReasoning and reasoning_raw_delta emit response.reasoning_text.delta/done with content_index:0; final items use summary:[] and content:[{type:reasoning_text,text}]. buildResponseJSONWithBudget mirrors this. Keep hidden txt-only replay envelopes intact. DELETE src/server/responses-reasoning-summary-rewrite.ts and its obsolete unit test; MODIFY core.ts to remove imports and SSE/JSON content-to-summary rewrites. MODIFY both layout manifests to remove that test. Adopt the exact #4301 hunks below except reporter video and historical verification record. + +MODIFY provider.ts, registry.ts, derive.ts, router.ts and auth-cors.ts to carry showThinkingSummary boolean (preserve explicit false). Seed only google-antigravity true. Creation: provider config/registry; serialization: providerConfigSeed and deriveKeyLoginMap; deserialization: config provider passthrough and management field policy; consumers: routedProviderConfig, final-route normalization, Google request builder. No new enum. + +MODIFY core.ts final-route normalization: apply provider default only when original reasoning.summary is omitted, never explicit none; recompute on each final route so fallback cannot inherit another provider default. Provider opt-in authorizes summary display, not raw-to-summary conversion. + +MODIFY google.ts shared part classifier to use existing thinking_delta only for Gemini thought summaries under verified Gemini model provenance; CCA Claude/gpt-oss thought text remains reasoning_raw_delta. Persist request-local Gemini identity using existing adapter state, used by both stream and buffered classifier calls. includeThoughts stays provider-opted, Gemini-only, non-image and explicit-hide aware. MODIFY google-wire-compiler.ts to retain only boolean true includeThoughts, independently of thinkingLevel. Do not claim raw text is an actual summary. + +MODIFY the #4287 end-to-end fixture: raw DeepSeek content remains content with empty summary even under provider opt-in; actual CCA Gemini thought parts use summary; omitted vs none vs auto, explicit provider false, saved-row enrichment, fallback reset, streaming/buffered paths. Extend existing Google tests and bridge raw tests; both layout manifests register responses-show-thinking-summary.test.ts. Update English providers docs and structure owners, keeping locale statements consistent. Source tests are authored but run only by hosted CI. + +Acceptance: raw event fixture => content delta and no summary delta; actual Gemini summary fixture => summary only when requested/provider-opted; explicit none => no synthesized summary and no includeThoughts request; false/unknown provider => no opt-in; fallback to unopted route => hidden behavior reset; replay envelope decodes same raw text and tool continuation remains valid; native Responses mixed content/summary remains byte-semantically native. No model prose synthesizer is introduced. + +## Source patch blueprint + +```diff +diff --git a/src/bridge.ts b/src/bridge.ts +index 20e7c3fe09..bc90f35b94 100644 +--- a/src/bridge.ts ++++ b/src/bridge.ts +@@ -663,16 +663,13 @@ export function bridgeToResponsesSSE( + const closeCurrentRawReasoning = () => { + if (!currentRawReasoning) return; + rawReasoningForNextToolCall = currentRawReasoning.text; +- emit("response.reasoning_summary_text.done", { +- item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, text: currentRawReasoning.text, +- }); +- emit("response.reasoning_summary_part.done", { +- item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, +- part: { type: "summary_text", text: currentRawReasoning.text }, ++ emit("response.reasoning_text.done", { ++ item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, content_index: 0, text: currentRawReasoning.text, + }); + const item = { + type: "reasoning", id: currentRawReasoning.itemId, +- summary: [{ type: "summary_text", text: currentRawReasoning.text }], ++ summary: [] as never[], ++ content: [{ type: "reasoning_text", text: currentRawReasoning.text }], + }; + emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item }); + retainFinishedItem(item as OutputItem, currentRawReasoning.textBytes, "reasoning"); +@@ -1111,10 +1108,6 @@ export function bridgeToResponsesSSE( + const itemId = `rs_${uuid()}`; + const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] }; + emit("response.output_item.added", { output_index: outputIndex, item }); +- emit("response.reasoning_summary_part.added", { +- item_id: itemId, output_index: outputIndex, summary_index: 0, +- part: { type: "summary_text", text: "" }, +- }); + currentRawReasoning = { itemId, outputIndex, text: "", textBytes: 0 }; + } + ({ value: currentRawReasoning.text, bytes: currentRawReasoning.textBytes } = appendString( +@@ -1123,9 +1116,13 @@ export function bridgeToResponsesSSE( + event.text, + "reasoning", + )); +- emit("response.reasoning_summary_text.delta", { ++ // Raw reasoning (openai-chat reasoning_content, kiro tags) rides the CONTENT ++ // channel, matching native gpt-oss passthrough: Codex applies its own display ++ // policy, so the desktop band shows the "Thinking…" placeholder instead of the ++ // raw CoT (the #45 summary-channel display intent is intentionally reverted). ++ emit("response.reasoning_text.delta", { + item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, +- summary_index: 0, delta: event.text, ++ content_index: 0, delta: event.text, + }); + break; + } +@@ -1780,7 +1777,8 @@ function buildResponseJSONWithBudget( + } + pushOutput({ + type: "reasoning", id: `rs_${uuid()}`, +- summary: [{ type: "summary_text", text: currentRawReasoning }], ++ summary: [], ++ content: [{ type: "reasoning_text", text: currentRawReasoning }], + }, currentRawReasoningBytes, "reasoning"); + currentRawReasoning = ""; + currentRawReasoningBytes = 0; + +``` + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts +index 88c482ba7d..aa835e50b4 100644 +--- a/src/adapters/google-wire-compiler.ts ++++ b/src/adapters/google-wire-compiler.ts +@@ -130,12 +130,20 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { + ))].slice(0, 5); + if (stopSequences.length > 0) out.stopSequences = stopSequences; + } +- if (isObject(value.thinkingConfig) && typeof value.thinkingConfig.thinkingLevel === "string") { +- const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); +- const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) +- ? raw +- : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); +- if (thinkingLevel) out.thinkingConfig = { thinkingLevel }; ++ if (isObject(value.thinkingConfig)) { ++ const thinking: JsonObject = {}; ++ if (typeof value.thinkingConfig.thinkingLevel === "string") { ++ const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); ++ const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) ++ ? raw ++ : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); ++ if (thinkingLevel) thinking.thinkingLevel = thinkingLevel; ++ } ++ // The one key that makes Google return `thought: true` text. Cloud Code Assist serves ++ // thinking either way (thoughtsTokenCount stays non-zero) but withholds the text unless the ++ // request opts in, so dropping it here silently reinstates the missing-thinking behavior. ++ if (value.thinkingConfig.includeThoughts === true) thinking.includeThoughts = true; ++ if (Object.keys(thinking).length > 0) out.thinkingConfig = thinking; + } + if (Array.isArray(value.responseModalities)) { + const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); +diff --git a/src/adapters/google.ts b/src/adapters/google.ts +index 7fcc88ba59..5a6675f6e4 100644 +--- a/src/adapters/google.ts ++++ b/src/adapters/google.ts +@@ -866,11 +866,27 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte + ); + antigravityModel = wireModelId; + antigravitySession = sessionId; ++ // Gemini returns no chain-of-thought TEXT unless the request opts in. Probed against CCA ++ // 2026-09-12: `gemini-3.8-flash-high` answered with thoughtsTokenCount=321 and zero ++ // `thought` parts, then 358-652 chars of genuine reasoning once includeThoughts was set. ++ // Scoped to Gemini wire ids — Claude-on-CCA accepts the flag but never returns thought ++ // parts, and gpt-oss rejects it outright (400 INVALID_ARGUMENT, which would break every ++ // gpt-oss turn). Gated on the provider's visible-thinking opt-in so a user who wants ++ // thinking hidden does not pay conversation-history tokens for text nobody renders; ++ // `hideThinkingSummary !== true` is the same per-request gate the response path uses, so ++ // a client that explicitly asked for hidden thinking is not billed for the text either. ++ const includeThoughts = provider.showThinkingSummary === true ++ && parsed.options.hideThinkingSummary !== true ++ && /^gemini-/.test(wireModelId) ++ && !isImageCapableModel(parsed.modelId); + // Effort → thinkingConfig for CCA (CLIProxyAPI proven: request.generationConfig.thinkingConfig). + // Suffix/compat IDs return thinkingLevel=undefined — the suffix IS the effort, no contradiction. +- if (thinkingLevel) { ++ if (thinkingLevel || includeThoughts) { + const gc = (body.generationConfig ?? {}) as Record; +- gc.thinkingConfig = { thinkingLevel }; ++ gc.thinkingConfig = { ++ ...(thinkingLevel ? { thinkingLevel } : {}), ++ ...(includeThoughts ? { includeThoughts: true } : {}), ++ }; + body.generationConfig = gc; + } + // Reasoning continuity: Gemini models re-inject cached thoughtSignatures; Claude-on-Antigravity +diff --git a/src/providers/derive.ts b/src/providers/derive.ts +index 67e6c0522e..7edf28787b 100644 +--- a/src/providers/derive.ts ++++ b/src/providers/derive.ts +@@ -43,6 +43,7 @@ export interface DerivedKeyLoginProvider { + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; ++ showThinkingSummary?: boolean; + reasoningSplitModels?: string[]; + reasoningDetailsModels?: string[]; + thinkingToggleModels?: string[]; +@@ -267,6 +268,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon + ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), + ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), + ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ++ ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), + ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), + ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), + ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), +@@ -315,6 +317,7 @@ export function deriveKeyLoginMap(): Record { + ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), + ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), + ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ++ ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), + ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), + ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), + ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), +@@ -567,6 +570,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig + if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels]; + if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels]; + if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames; ++ if (prov.showThinkingSummary === undefined && seed.showThinkingSummary !== undefined) prov.showThinkingSummary = seed.showThinkingSummary; + if (prov.keyOptional === undefined && seed.keyOptional !== undefined) prov.keyOptional = seed.keyOptional; + if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier; + if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip; +diff --git a/src/providers/registry.ts b/src/providers/registry.ts +index f72bb7650b..e483e9db23 100644 +--- a/src/providers/registry.ts ++++ b/src/providers/registry.ts +@@ -343,6 +343,10 @@ export interface ProviderRegistryEntry { + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; ++ /** ++ * Opt this provider into visible thinking summaries (see OcxProviderConfig.showThinkingSummary). ++ */ ++ showThinkingSummary?: boolean; + reasoningSplitModels?: string[]; + reasoningDetailsModels?: string[]; + thinkingToggleModels?: string[]; +@@ -367,7 +371,7 @@ export type ProviderConfigSeed = Pick< + | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" + | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" + | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" +- | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" ++ | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" + | "googleMode" | "project" | "location" | "headers" + >; + +@@ -2045,7 +2049,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ + // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would + // retarget a user's custom base back to Google. A leading `./` is required because a bare + // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. +- { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, ++ { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", showThinkingSummary: true, jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, + { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, + { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, + { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, +diff --git a/src/router.ts b/src/router.ts +index 55a0326fce..bf2b9b4b98 100644 +--- a/src/router.ts ++++ b/src/router.ts +@@ -410,6 +410,13 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider + ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined + ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } + : {}), ++ // The request path resolves through routedProviderConfig() and never calls ++ // enrichProviderFromRegistry(), so a saved provider row written before the ++ // registry learned this flag must be backfilled here or route.provider never ++ // carries it and the showThinkingSummary opt-in stays dead. ++ ...(provider.showThinkingSummary === undefined && registryEntry.showThinkingSummary !== undefined ++ ? { showThinkingSummary: registryEntry.showThinkingSummary } ++ : {}), + // Registry-only client-facing repair policy (#938): fill only when the + // saved provider has no explicit policy; clone so runtime never aliases + // the registry constant. +diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts +index 3a93246cd0..93377b5573 100644 +--- a/src/server/auth-cors.ts ++++ b/src/server/auth-cors.ts +@@ -885,6 +885,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { + autoToolChoiceOnlyModels: "editor", + preserveReasoningContentModels: "editor", + requiresReasoningPlaceholderModels: "editor", ++ showThinkingSummary: "editor", + retryOn429: "editor", + transientRetryOn5xx: "editor", + reasoningSplitModels: "editor", +diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts +index cccd942026..852cd9f8b0 100644 +--- a/src/server/responses/core.ts ++++ b/src/server/responses/core.ts +@@ -2467,6 +2467,20 @@ async function resolveSubagentFallbackModelEligibility(args: { + }; + } + ++/** ++ * Whether the client explicitly asked for hidden thinking (`reasoning.summary: "none"`). ++ * ++ * Pinned: parseRequest collapses "omitted" and "none" into one hideThinkingSummary ++ * flag, so the raw request body is the ONLY place that still distinguishes them. ++ * Provider opt-ins like showThinkingSummary must consult this — never the flag ++ * alone — or a future caller that copies only the flag would silently unlock an ++ * explicit opt-out. ++ */ ++function clientExplicitlyHidThinking(parsed: OcxParsedRequest): boolean { ++ const rawReasoning = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning; ++ return typeof rawReasoning === "object" && rawReasoning !== null ++ && (rawReasoning as { summary?: unknown }).summary === "none"; ++} + /** + * Apply every route-dependent request mutation against the final selected route. + * Must run only after subagent fallback has settled the model/provider. +@@ -2508,6 +2522,15 @@ async function applyFinalRouteRequestNormalization(args: { + // this request will actually use (#404). + route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); + route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); ++ // Provider-opted visible thinking (e.g. google-antigravity): parseRequest hides thinking ++ // whenever the client omits reasoning.summary, which is the Codex default. A provider that ++ // serves genuine user-facing reasoning opts back into the summary channel here, so thought ++ // parts (Gemini thought, content-channel reasoning_text) reach the client instead of only ++ // the hidden replay envelopes. An explicit client reasoning.summary "none" still wins. ++ if (route.provider.showThinkingSummary === true && parsed.options.hideThinkingSummary === true ++ && !clientExplicitlyHidThinking(parsed)) { ++ parsed.options.hideThinkingSummary = false; ++ } + if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; + logCtx.model = route.modelId; + logCtx.provider = route.providerName; +diff --git a/src/types/provider.ts b/src/types/provider.ts +index e65130a4fa..b6374a991a 100644 +--- a/src/types/provider.ts ++++ b/src/types/provider.ts +@@ -746,6 +746,15 @@ export interface OcxProviderConfig { + * out explicitly (e.g. MiniMax, where low effort disables thinking). + */ + requiresReasoningPlaceholderModels?: string[]; ++ /** ++ * Opt-in: surface upstream thinking as visible reasoning summaries even when the ++ * client did not send `reasoning.summary`. parseRequest hides thinking by default ++ * (Codex omits the field), which strands genuine reasoning — e.g. Gemini `thought` ++ * parts on the google-antigravity (Cloud Code Assist) wire — in hidden replay ++ * envelopes. An explicit client `reasoning.summary: "none"` still wins. Set `false` ++ * to opt a seeded preset back out. ++ */ ++ showThinkingSummary?: boolean; + /** + * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only, + * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays + +``` + +## Reflection corrections accepted + +Explicit wire reasoning.summary:"none" wins. A client that serializes configured none as omission cannot be distinguished from unspecified preference. No client config rewrite or global catalog summary default changes. Summary classification is limited to built CCA Gemini requests; unknown/uninitialized, direct Google/Vertex and CCA Claude/gpt-oss remain raw. Streaming and buffered summary-to-tool continuations assert exact Google signature on correct call; never emit Google signatures as Anthropic thinking_signature. Hidden unsigned summaries may disappear but required tool replay state survives. Exercise final assistant text and terminal order, fallback in both directions, and remove replay-comparison rewrite alongside SSE/JSON rewrite. Desktop appearance remains client-controlled; source patch comments claiming an unconditional placeholder are replaced during adoption. diff --git a/devlog/_plan/260912_thinking_contract/020_transport_hint.md b/devlog/_plan/260912_thinking_contract/020_transport_hint.md new file mode 100644 index 0000000000..335e339a00 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/020_transport_hint.md @@ -0,0 +1,319 @@ +# Optional hint suppression + +Class C4 review because client metadata policy changes. Independent of presentation; depends only on roadmap. Adopt #3652 only after independent security/transport review. Public proposal removes exactly two x-codex-safety-buffering headers, metadata.type=safety_buffering events and top-level safety_buffering fields at the client relay boundary. Default false; malformed config must remain off and candidate validation rejects nonbooleans. This suppresses optional transport hints; provider safety decisions/refusals and upstream checks are unchanged. Compact and independent WS/other-provider pathways retain existing policy unless a directly exercised shared boundary already applies. + +MODIFY src/config.ts and src/types/config.ts for validated boolean/default; src/server/relay.ts for allowlisted header removal and SSE terminal-boundary transformation; relay-eager.ts for option forwarding; core.ts to compute option only for canonical OpenAI forward destination and pass it to all relevant headers/client output boundaries; index.ts exports if needed by existing test style. Do not apply to custom gateway/key providers. Preserve errors, response.failed/incomplete and terminal sentinel handling. + +MODIFY tests/responses/passthrough-headers.test.ts, openai-responses-passthrough.test.ts and tests/server/config.test.ts. Scenarios: absent/false/true/malformed config; uppercase headers; unrelated headers; split metadata frames; actual failure carrying hint must still fail; noncanonical provider has identical fields and retains them; eager/non-eager client paths. Add missing canonical route coverage if independent review identifies it. MODIFY English/ja/ko/ru/zh-cn server configuration docs and structure owners. Avoid unsupported claims about models being weaker or provider safety bypass. + +Before/after anchor: createSseTerminalOutputBoundary() -> createSseTerminalOutputBoundary(options?: CodexSafetyBufferingFilterOptions); sanitizePassthroughHeaders(upstream) -> sanitizePassthroughHeaders(upstream, options?); canonical true => filter option, every other provider => undefined. Full public source diff is pinned by #3652 head in 000_plan.md and inspected locally; any needed correction is recorded here before B. + +## Independent design corrections + +H1 accepted: policy rewrite and hint stripping compose. Build policyFailurePayload first, then remove top-level safety_buffering from the effective emitted payload, preserving response.failed/error data and retryable:false. H2 accepted: extend current relaySseWithFailedTail fourth options object with terminalBoundary; never replace upstreamError. Core passes both existing upstreamError and new terminalBoundary; update the existing source-contract assertion to preserve its original guarantee. H3 accepted: native WebSocket codex.response.metadata.headers and /responses/compact are explicitly excluded; their hints remain unfiltered. No new WS metadata filter. Docs must not claim the old WS allowlist excludes these headers. Regression fixtures cover CRLF/split/malformed input, policy error plus hint, EOF upstreamError, canonical true and noncanonical preservation. + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/config.ts b/src/config.ts +index fdcda9547c..cd0641feb2 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -1125,6 +1125,8 @@ const configSchema = z.object({ + configRebaseProvenance: z.unknown().optional(), + // A retry can be billable, so absence and malformed hand edits both stay off. + emptyCompletionRetry: z.boolean().optional().catch(false), ++ // Header suppression changes what Codex sees, so absence and malformed edits stay off. ++ dropCodexSafetyBuffering: z.boolean().optional().catch(false), + // A malformed hand edit must not silently stop opening the browser: fall back + // to undefined, which resolves to the historical auto-open behavior. + oauthOpenBrowser: z.boolean().optional().catch(undefined), +@@ -2613,6 +2615,14 @@ function emptyCompletionRetryError(value: unknown): string | null { + return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted"; + } + ++function dropCodexSafetyBufferingError(value: unknown): string | null { ++ const raw = rawConfigRecord(value); ++ if (!raw || !Object.hasOwn(raw, "dropCodexSafetyBuffering")) return null; ++ const enabled = raw.dropCodexSafetyBuffering; ++ if (enabled === undefined || typeof enabled === "boolean") return null; ++ return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted"; ++} ++ + function oauthOpenBrowserError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null; +@@ -2718,6 +2728,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx + ?? codexQuotaAutoRefreshError(value) + ?? codexAccountPickerEnabledError(value) + ?? emptyCompletionRetryError(value) ++ ?? dropCodexSafetyBufferingError(value) + ?? oauthOpenBrowserError(value) + ?? runtimeRoleError(value) + ?? remoteGuiConfigError(value) +@@ -3684,6 +3695,7 @@ export function getDefaultConfig(): OcxConfig { + return { + port: 10100, + emptyCompletionRetry: false, ++ dropCodexSafetyBuffering: false, + managementUsageMaxReadBytes: 64 * 1024 * 1024, + appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), + // Fresh/re-initialized configs are already written in the current three-tier +diff --git a/src/server/index.ts b/src/server/index.ts +index aedd6bf236..c6ce73b2f1 100644 +--- a/src/server/index.ts ++++ b/src/server/index.ts +@@ -142,6 +142,7 @@ import { + } from "./relay"; + export { + consumeForInspection, ++ codexSafetyBufferingFilterOptions, + relaySseWithFailedTail, + relaySseWithHeartbeat, + relayWithAbort, +diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts +index 655997b813..a6e60d3d02 100644 +--- a/src/server/relay-eager.ts ++++ b/src/server/relay-eager.ts +@@ -26,6 +26,7 @@ + + import { + adapterEofIncompleteFrame, ++ type CodexSafetyBufferingFilterOptions, + createSseTerminalOutputBoundary, + doneFrame, + failedTailFrame, +@@ -83,6 +84,8 @@ export type EagerRelayOptions = { + postCancelDrainBytes?: number; + /** Injectable clock for tests. */ + now?: () => number; ++ /** Client output boundary filters (Codex safety-buffering hints). */ ++ terminalBoundary?: CodexSafetyBufferingFilterOptions; + }; + + const DEFAULT_MAX_QUEUE_BYTES = 8 * 1024 * 1024; +@@ -111,7 +114,7 @@ export function relaySseEagerBounded( + const terminalEncoder = new TextEncoder(); + const adapterEofFrame = adapterEofIncompleteFrame(terminalEncoder); + const terminalSentinel = doneFrame(terminalEncoder); +- const terminalBoundary = createSseTerminalOutputBoundary(); ++ const terminalBoundary = createSseTerminalOutputBoundary(opts?.terminalBoundary); + const activeRewrite: SseBlockRewrite | undefined = hooks.rewriteBlocks + ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined); + const encodeFailedTail = (error: unknown): Uint8Array | null => { +diff --git a/src/server/relay.ts b/src/server/relay.ts +index 60b57ea025..d840b2e59c 100644 +--- a/src/server/relay.ts ++++ b/src/server/relay.ts +@@ -162,7 +162,10 @@ export type SseTerminalOutputBoundary = { + * terminal, and drops every later block/byte. A premature [DONE] is held until + * a terminal arrives so clean EOF can synthesize one terminal and one sentinel. + */ +-export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { ++export function createSseTerminalOutputBoundary( ++ options?: CodexSafetyBufferingFilterOptions, ++): SseTerminalOutputBoundary { ++ const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES); +@@ -181,6 +184,10 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { + const payload = sseDataPayload(decoder.decode(frame.block)); + const isDone = payload === "[DONE]"; + const parsed = payload === null ? undefined : parseSsePayload(payload); ++ const safetyBuffering = dropSafetyBuffering && parsed !== undefined ++ ? codexSafetyBufferingBlockAction(parsed) ++ : "keep"; ++ if (safetyBuffering === "drop") continue; + const policyError = parsed !== undefined && isPolicyRewriteType(parsed) + ? cyberPolicyTerminalError(parsed) + : undefined; +@@ -189,7 +196,9 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { + decoder.decode(frame.block), + policyFailurePayload(policyError, parsed), + )) +- : frame.block; ++ : safetyBuffering === "strip" ++ ? encoder.encode(stripCodexSafetyBufferingField(decoder.decode(frame.block), parsed)) ++ : frame.block; + if (isDone) { + done = true; + if (responsesTerminal) { +@@ -260,10 +269,11 @@ export function relaySseWithFailedTail( + body: ReadableStream, + upstream: AbortController, + onClientGone?: (reason?: unknown) => void, ++ boundaryOptions?: CodexSafetyBufferingFilterOptions, + ): ReadableStream { + const reader = body.getReader(); + const encoder = new TextEncoder(); +- const terminalBoundary = createSseTerminalOutputBoundary(); ++ const terminalBoundary = createSseTerminalOutputBoundary(boundaryOptions); + let closed = false; + const relayChunk = ( + controller: ReadableStreamDefaultController, +@@ -438,6 +448,29 @@ function isPolicyRewriteType(parsed: unknown): boolean { + return type === "response.failed" || type === "response.incomplete" || type === "error"; + } + ++/** ++ * Codex emits its safety-buffering hint in the SSE body as well as in headers: ++ * a `response.metadata` event whose `metadata.type` is `safety_buffering`, or a ++ * `safety_buffering` field on another event. The metadata event is dropped whole; ++ * the field is stripped so the carrying event is otherwise relayed unchanged. ++ */ ++function codexSafetyBufferingBlockAction(parsed: unknown): "keep" | "drop" | "strip" { ++ const root = asJsonRecord(parsed); ++ if (!root) return "keep"; ++ if (root.type === "response.metadata") { ++ const metadata = asJsonRecord(root.metadata); ++ if (metadata?.type === "safety_buffering") return "drop"; ++ } ++ return Object.hasOwn(root, "safety_buffering") ? "strip" : "keep"; ++} ++ ++function stripCodexSafetyBufferingField(block: string, parsed: unknown): string { ++ const root = asJsonRecord(parsed); ++ if (!root) return block; ++ const { safety_buffering: _safetyBuffering, ...rest } = root; ++ return replaceSseDataPayload(block, JSON.stringify(rest)); ++} ++ + function rewritePolicyTerminalBlock(block: string, payload: string): string { + const newline = block.includes("\r\n") ? "\r\n" : "\n"; + const rewritten = replaceSseDataPayload(block, payload); +@@ -1422,7 +1455,31 @@ export function consumeForResponseLogMetadata( + * body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough. + * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim. + */ +-export function sanitizePassthroughHeaders(upstream: Headers): Headers { ++export const CODEX_SAFETY_BUFFERING_HEADERS = [ ++ "x-codex-safety-buffering-enabled", ++ "x-codex-safety-buffering-faster-model", ++] as const; ++ ++const CODEX_SAFETY_BUFFERING_HEADER_SET: ReadonlySet = new Set(CODEX_SAFETY_BUFFERING_HEADERS); ++ ++export interface CodexSafetyBufferingFilterOptions { ++ /** ++ * Drop Codex safety-buffering hints: the `x-codex-safety-buffering-*` response ++ * headers and the `safety_buffering` SSE metadata event / field. Absent and ++ * `false` relay everything unchanged. ++ */ ++ dropCodexSafetyBuffering?: boolean; ++} ++ ++/** Resolve the passthrough header policy from the loaded config (absent means "forward everything"). */ ++export function codexSafetyBufferingFilterOptions( ++ config: { dropCodexSafetyBuffering?: boolean }, ++): CodexSafetyBufferingFilterOptions { ++ return { dropCodexSafetyBuffering: config.dropCodexSafetyBuffering === true }; ++} ++ ++export function sanitizePassthroughHeaders(upstream: Headers, options?: CodexSafetyBufferingFilterOptions): Headers { ++ const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; + const DROP = new Set([ + "content-encoding", + "content-length", +@@ -1439,7 +1496,10 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers { + ]); + const out = new Headers(); + upstream.forEach((value, key) => { +- if (!DROP.has(key.toLowerCase())) out.set(key, value); ++ const lower = key.toLowerCase(); ++ if (DROP.has(lower)) return; ++ if (dropSafetyBuffering && CODEX_SAFETY_BUFFERING_HEADER_SET.has(lower)) return; ++ out.set(key, value); + }); + return out; + } +diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts +index 9d0eea0d76..e199917968 100644 +--- a/src/server/responses/core.ts ++++ b/src/server/responses/core.ts +@@ -304,6 +304,7 @@ import { + markEagerRelaySseResponse, + markNativePassthroughSseResponse, + relaySseWithFailedTail, ++ codexSafetyBufferingFilterOptions, + relayWithAbort, + sanitizePassthroughHeaders, + } from "../relay"; +@@ -3850,6 +3851,9 @@ async function handleResponsesInner( + let hostAdmissionLease = pendingHostAdmissionLease; + pendingHostAdmissionLease = null; + try { ++ const codexSafetyBufferingOptions = isCanonicalOpenAiForwardProvider(route.provider) ++ ? codexSafetyBufferingFilterOptions(config) ++ : undefined; + const imageGenCallAliases = route.provider.authMode === "forward" + ? new Map() + : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); +@@ -4732,7 +4736,7 @@ async function handleResponsesInner( + } + break; + } +- const headers = sanitizePassthroughHeaders(upstreamResponse.headers); ++ const headers = sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions); + const resolvedModel = headers.get("openai-model")?.trim(); + if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; + if (isUsageDebugEnabled()) { +@@ -4824,7 +4828,7 @@ async function handleResponsesInner( + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, +- headers: sanitizePassthroughHeaders(upstreamResponse.headers), ++ headers: sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions), + }); + } + if (!upstreamResponse.ok) { +@@ -5027,6 +5031,7 @@ async function handleResponsesInner( + onDone: () => unregisterTurn(turnAc), + }, { + clientGoneSignal: options.abortSignal, ++ terminalBoundary: codexSafetyBufferingOptions, + ...(inlineEagerRewrite ? { rewriteBudget: translatorBudget } : {}), + }); + // When selected, this relay closes response.completed even if upstream +@@ -5110,7 +5115,8 @@ async function handleResponsesInner( + const rewrittenBody = clientBlockRewrite !== undefined + ? relaySseWithBlockRewrite(nativeBody, clientBlockRewrite, translatorBudget) + : nativeBody; +- const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason)); ++ const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason), ++ codexSafetyBufferingOptions); + return markNativePassthroughSseResponse(new Response(clientBody, { + status: upstreamResponse.status, + headers, +@@ -5238,7 +5244,7 @@ async function handleResponsesInner( + } + throw error; + } +- const sseHeaders = sanitizePassthroughHeaders(headers); ++ const sseHeaders = sanitizePassthroughHeaders(headers, codexSafetyBufferingOptions); + sseHeaders.set("content-type", "text/event-stream"); + sseHeaders.set("cache-control", "no-store"); + return new Response(stream, { +diff --git a/src/types/config.ts b/src/types/config.ts +index 8cf1246979..4d2c63fdf1 100644 +--- a/src/types/config.ts ++++ b/src/types/config.ts +@@ -335,6 +335,16 @@ export interface OcxConfig { + client?: OcxClientConnectionConfig; + /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ + emptyCompletionRetry?: boolean; ++ /** ++ * Drop the Codex safety-buffering hints from a Codex Responses passthrough: the ++ * `x-codex-safety-buffering-*` response headers, `response.metadata` SSE events of ++ * type `safety_buffering`, and the `safety_buffering` field on other SSE events. ++ * The Codex TUI turns those hints into a "retry with a faster model" prompt whose ++ * default action switches the session to a weaker model, so an unattended session ++ * can lose its model to a stray keystroke. Absent and `false` relay everything ++ * unchanged. ++ */ ++ dropCodexSafetyBuffering?: boolean; + /** + * Whether a login may open a browser on the machine running the proxy. + * + +``` diff --git a/devlog/_plan/260912_thinking_contract/030_spark.md b/devlog/_plan/260912_thinking_contract/030_spark.md new file mode 100644 index 0000000000..3997aaa6d7 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/030_spark.md @@ -0,0 +1,66 @@ +# Spark Lite metadata follows body shape + +Class C3 bounded compatibility. Independent of presentation/hint; depends on roadmap. MODIFY src/adapters/openai-responses.ts only inside canonical OpenAI forwarding and final wire model gpt-5.3-codex-spark. Add bodyCarriesLiteToolShape next to existing tool-shape helpers: Array.isArray(body.input) && body.input.some(item => isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) && item.tools.length > 0). After final Spark body construction, delete all case variants of CODEX_RESPONSES_LITE_HEADER then set it to liteShaped ? "true" : "false". Existing prepareCodexWsRequest projects it onto native frame metadata. + +Before: Spark deletes the header, allowing stale native metadata to survive. After: tool-less/top-level-tool Spark frames advertise false; nonempty Lite catalog frames advertise true despite conflicting inherited header. No retirement, no changes to model availability, no user service changes. + +MODIFY tests/codex-integration/codex-metadata-integrity.test.ts: alias resolved final model, inherited true/false/mixed-case/absent header, Lite tool body true, empty Lite group false, malformed metadata keeps HTTP fallback/body, noncanonical remains unchanged. MODIFY tests/responses/ws-upstream-reuse.test.ts: legacy true socket retires when adapter produces false, replacement same identity reused, raw request immutable. MODIFY all eight existing architecture locale pages and structure/transports/responses.md, referencing body-shape rule from shared area owners. Adopt latest #4130 source diff, preserving author; do not import historical earlier heads. + +Verification: source diff review and final-branch hosted ci.yml lane=all. Tests NOT RUN locally. Success proves framing and connection identity, not a live backend EOF fix or all tool-bearing EOF cases. Remaining acceptance: broader tool-format conversion stays out of scope. + +## Pinned source hunks (apply with corrections above) + +```diff +diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts +index c4aa523ee6..8fbe43816d 100644 +--- a/src/adapters/openai-responses.ts ++++ b/src/adapters/openai-responses.ts +@@ -864,6 +864,21 @@ function promoteClientLoadedTools(body: unknown): unknown { + } + + const MAX_RESPONSES_CALL_ID_LENGTH = 64; ++ ++/** ++ * Whether the outgoing body still delivers tools through the responses-lite shape. ++ * ++ * Lite carries the client catalog as an `additional_tools` input item; the non-Lite wire shape ++ * expects top-level `tools`. Anything that flips the Lite advertisement has to agree with the ++ * shape actually being sent, or the destination silently loses the tool surface. ++ */ ++function bodyCarriesLiteToolShape(body: Record): boolean { ++ if (!Array.isArray(body.input)) return false; ++ return body.input.some(item => ++ isPlainObject(item) && item.type === "additional_tools" ++ && Array.isArray(item.tools) && item.tools.length > 0 ++ ); ++} + const REPAIRED_CALL_ID_PREFIX = "call_ocx_"; + const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length; + +@@ -2515,12 +2530,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): + parsed.modelId, + ); + if (isCanonicalOpenAiForwardProvider(provider)) { +- // Spark closes Responses Lite streams before a terminal completion. Select compatibility +- // from the final wire model so aliases cannot leave the caller or a static header enabled. ++ // Select Spark's Lite compatibility from the final wire model, including aliases, and ++ // let the BODY decide it. The header also overrides native WS metadata downstream, so a ++ // forwarded or statically configured value must never contradict the shape being sent. ++ // ++ // The synchronized catalog keeps `use_responses_lite: true` for Spark precisely because ++ // it selects tool delivery (`input[].additional_tools` instead of top-level `tools`), and ++ // stripSparkCompatibility filters that group in place rather than promoting it. So a ++ // Lite-shaped body is pinned back ON — otherwise an inherited `false` advertises non-Lite ++ // while the tools exist only in the Lite shape, and Spark loses the tool surface. Only a ++ // body with no Lite tool group is downgraded, which is what the stream fix needs. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { ++ const liteShaped = bodyCarriesLiteToolShape(finalBody); + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; + } ++ headers[CODEX_RESPONSES_LITE_HEADER] = liteShaped ? "true" : "false"; + } + const routingHeaders = new Headers(headers); + applyCodexRoutingHint(routingHeaders, finalBody); + +``` diff --git a/devlog/_plan/260912_thinking_contract/040_delivery.md b/devlog/_plan/260912_thinking_contract/040_delivery.md new file mode 100644 index 0000000000..a70e62bfb6 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/040_delivery.md @@ -0,0 +1,7 @@ +# Final heads and handoff + +Class C3 delivery evidence. Depends on all dispositions. MODIFY branch-owned numbered completion docs and ignored .tmp/thinking/handoff.md. Read existing .github/PULL_REQUEST_TEMPLATE.md; write every section, credits and precise NOT RUN limitation. Publish only own codex/260912-60plus-thinking* branches with git push --no-verify; PR bases dev for independent units, ordinary parent branch only for actual dependencies. No merge/auto-merge/closures. + +NEW .tmp/thinking/*-ci.json captures gh run view JSON for final SHA plus all jobs. NEW .tmp/thinking/*-review.md captures independent implementation findings with accepted/rebutted disposition. Refresh head/base, native stack membership (unknown if API unsupported), current reviews and CI before handoff. Inspect .github/workflows/ci.yml and dispatch lane=all at each final branch where needed. Existing automatic runs stay untouched. If final-head CI fails, inspect failing logs, repair scoped source or fixtures, commit/push --no-verify and validate new final tip. Do not label skipped/cancelled/old-head runs passing. + +Final handoff fields: own worktree, branch per PR, source PR disposition, exact head, PR URL, dependency order, original author trailers, remaining acceptance, unresolved reviews, CI run id/url/head/result/job conclusions, own cycle records and local tests NOT RUN. Parent performs any subsequent integration. No evidence claims from peer commentary alone. From 2303f7883c764181989ec9d0859397b4969df804 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:57:20 +0900 Subject: [PATCH 028/224] fix(responses): align Spark Lite metadata with serialized body Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../260912_thinking_contract/030_spark.md | 8 + .../content/docs/fr/reference/architecture.md | 9 + .../content/docs/ja/reference/architecture.md | 9 + .../content/docs/ko/reference/architecture.md | 9 + .../content/docs/reference/architecture.md | 9 + .../content/docs/ru/reference/architecture.md | 9 + .../content/docs/tr/reference/architecture.md | 10 +- .../docs/zh-cn/reference/architecture.md | 7 + .../docs/zh-tw/reference/architecture.md | 8 + src/adapters/openai-responses.ts | 50 ++++-- structure/adapters/registry.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/ops/docs-and-release.md | 2 + structure/providers/chat-compat.md | 2 + structure/providers/cursor.md | 2 + structure/runtime.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 12 ++ .../codex-metadata-integrity.test.ts | 154 +++++++++++++++++- tests/responses/ws-upstream-reuse.test.ts | 30 ++++ 20 files changed, 319 insertions(+), 19 deletions(-) diff --git a/devlog/_plan/260912_thinking_contract/030_spark.md b/devlog/_plan/260912_thinking_contract/030_spark.md index 3997aaa6d7..3b013f75aa 100644 --- a/devlog/_plan/260912_thinking_contract/030_spark.md +++ b/devlog/_plan/260912_thinking_contract/030_spark.md @@ -64,3 +64,11 @@ index c4aa523ee6..8fbe43816d 100644 applyCodexRoutingHint(routingHeaders, finalBody); ``` + +## Spark P revalidation + +Prior D: hint source/security review PASS, final hosted tests pending. This independent branch starts from bd34120180. Latest #4130 hunks still apply cleanly. CCA summary and hint branches do not modify this adapter. Body-dependent Lite true/false, canonical final wire model and no retirement remain the acceptance contract. + +## Spark design reflection amendments + +S1 accepted: apply the existing modelSuffixBracketStrip normalization to finalBody before deciding Lite, using the same immutable object serialized later. A canonical gpt-5.3-codex-spark[1m] request that strips to Spark gets the policy; a final non-Spark model does not. S2 accepted: all eight architecture paragraphs say nonempty additional_tools tools array, not merely group presence; source PR outstanding documentation finding is addressed. S3 accepted: tests cover catalog filtered empty, surviving functions group, only top-level tools, both alias directions and preserved noncanonical configured Lite. Check both actual serialized body and WS header metadata; shape detection is not tool-support validation. diff --git a/docs-site/src/content/docs/fr/reference/architecture.md b/docs-site/src/content/docs/fr/reference/architecture.md index f197d85c11..746ea7f726 100644 --- a/docs-site/src/content/docs/fr/reference/architecture.md +++ b/docs-site/src/content/docs/fr/reference/architecture.md @@ -89,6 +89,15 @@ Par défaut, `server/index.ts` sert HTTP/SSE sur `/v1/responses`. Si Codex tente Indépendamment de ce réglage côté client, les requêtes canoniques transmises à ChatGPT avec `stream: true` à la racine peuvent utiliser le transport WebSocket en amont de Codex avec une version stable de Bun 1.4.0 ou ultérieure. La version intégrée Bun 1.3.14, les préversions et les identités de runtime impossibles à vérifier utilisent HTTP/SSE. Les réponses WS en amont qui réussissent conservent le contrat SSE en aval et contournent `tee()` au moyen d’un relais borné à lecteur unique et avide (4 MiB par trame brute/enveloppée et une file de production de 8 MiB). Le dépassement de la file ferme la connexion en amont et émet en aval un événement terminal `response.failed`, suivi de `[DONE]`. +Pour le modèle sortant final `gpt-5.3-codex-spark`, la transmission canonique à ChatGPT +désactive explicitement Responses Lite dans l’en-tête HTTP et les métadonnées natives des +trames WS, même lorsqu’un alias sélectionne Spark — uniquement si le corps sortant ne porte pas +de groupe `additional_tools` contenant un tableau `tools` non vide. Ce groupe EST la forme Lite de livraison des outils : un corps Spark +qui l’utilise conserve Lite ACTIF même si un en-tête appelant ou configuré disait l’inverse. Un changement d’identité Lite retire +l’ancien socket ; les requêtes admissibles suivantes ayant la même identité peuvent réutiliser +le nouveau socket. Les autres modèles et passerelles conservent leur politique Lite. +Des métadonnées natives mal formées entraînent toujours un repli HTTP, sans modifier le corps. + Le compactage du contexte Codex fonctionne avec les modèles routés. `server/responses/compact.ts` traite `POST /v1/responses/compact` en exécutant un tour interne de synthèse routé et en renvoyant un historique compacté, tandis que `responses/parser.ts` et `bridge.ts` traitent les tours de compactage distant v2 `compaction_trigger` en émettant exactement un élément de sortie synthétique `compaction`. ## Mise en cache et catalogue diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index cd2ce3db56..3500cbaf86 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -97,6 +97,15 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン `server/index.ts` はデフォルトで `/v1/responses` を HTTP/SSE で提供します。`websockets` が `false` の状態で Codex が Responses WebSocket アップグレードを試みると、opencodex は `426 upgrade_required` を返し、Codex はそのセッションで HTTP にフォールバックします。`"websockets": true` を設定すると同じエンドポイントがアップグレードを受け入れ WebSocket ブリッジを使います。 +最終送信モデルが `gpt-5.3-codex-spark` の場合、canonical ChatGPT 転送は HTTP ヘッダーと +ネイティブ WS フレームのメタデータの両方で Responses Lite を明示的に無効にします。 +エイリアスで Spark を選択した場合も同様です。ただし無効化は、送信本文が空でない `tools` 配列を持つ `additional_tools` +項目を持たない場合に限ります。このグループ自体が Lite のツール受け渡し形式なので、それを +使う Spark 本文は呼び出し元や設定のヘッダーに関わらず Lite を有効のまま保ちます。Lite の識別値が変わると古いソケットは退役し、 +以後の条件を満たす同じ識別値のリクエストは新しいソケットを再利用できます。他のモデルと +ゲートウェイの Lite ポリシーは維持されます。不正なネイティブメタデータは引き続き、 +本文を変更せずに HTTP にフォールバックします。 + Codex コンテキスト compaction はルーティングされたモデルでも動作します。`server/responses/compact.ts` は `POST /v1/responses/compact` を内部ルーティング要約ターンとして扱い、圧縮されたヒストリーを返します。 `responses/parser.ts` と `bridge.ts` は remote compaction v2 の `compaction_trigger` ターンを扱い、合成 `compaction` 出力項目を正確に 1 つ送ります。 diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index fa3056fb49..d95bf391ad 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -127,6 +127,15 @@ envelope를 각각 4 MiB로 제한하고 8 MiB producer queue 상한이 있는 b relay를 거칩니다. queue overflow 시 업스트림을 닫고 downstream에는 terminal `response.failed` 이벤트와 `[DONE]`을 내보냅니다. +최종 전송 모델이 `gpt-5.3-codex-spark`이면 canonical ChatGPT forward 경로는 HTTP 헤더와 +네이티브 WS 프레임 메타데이터 모두에서 Responses Lite를 명시적으로 끕니다. 별칭으로 Spark를 +선택해도 동일합니다. 다만 이 비활성화는 전송 본문에 비어 있지 않은 `tools` 배열을 가진 `additional_tools` 항목이 없을 때만 +적용됩니다. 이 그룹 자체가 Lite의 도구 전달 형식이므로, 그것을 사용하는 Spark 본문은 호출자나 +설정 헤더가 무엇이든 Lite를 켠 상태로 유지합니다. Lite 식별값이 바뀌면 기존 소켓은 사용을 종료하며, 이후 같은 식별값으로 +재사용 조건을 충족하는 요청은 새 소켓을 재사용할 수 있습니다. 다른 모델과 게이트웨이는 기존 +Lite 정책을 유지합니다. 네이티브 메타데이터 형식이 잘못된 경우에는 본문을 바꾸지 않고 +기존처럼 HTTP로 폴백합니다. + Codex 컨텍스트 compaction은 라우팅된 모델에서도 동작합니다. `server/responses/compact.ts`는 `POST /v1/responses/compact`를 내부 라우팅 요약 턴으로 처리해 압축된 히스토리를 반환합니다. `responses/parser.ts`와 `bridge.ts`는 remote compaction v2의 `compaction_trigger` 턴을 처리해 diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index e0fbc8bcba..a9bdf48978 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -157,6 +157,15 @@ upstream WS responses keep the downstream SSE contract and bypass `tee()` throug single-reader relay (4 MiB per raw/enveloped frame and an 8 MiB producer queue). Queue overflow closes the upstream and emits a terminal downstream `response.failed` event followed by `[DONE]`. +For the final outgoing model `gpt-5.3-codex-spark`, canonical ChatGPT forwarding explicitly +disables Responses Lite in both the HTTP header and native WS frame metadata, including when +an alias selects Spark — but only when the outgoing body carries no `additional_tools` item with a nonempty `tools` array. +That group IS the Lite tool-delivery shape, so a Spark body that still uses it keeps Lite ON even +if a caller or configured header said otherwise; otherwise the frame would advertise non-Lite +while the tools exist only in the Lite shape. A changed Lite identity retires the old socket; subsequent eligible +requests with the same identity can reuse the new socket. Other models and gateways keep +their existing Lite policy. Malformed native metadata still falls back to HTTP with its body unchanged. + When a provider rejects a streaming request with HTTP 413 before SSE begins, OpenCodex emits one terminal `response.failed` event with `context_length_exceeded` instead of relaying the retryable unknown status. This lets Codex stop its reconnect loop and apply its own context-compaction policy diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index c46da773c0..cd3f776efb 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -151,6 +151,15 @@ loopback; настроенные записи `corsAllowOrigins` расширя `426 upgrade_required`; Codex тогда откатывается на HTTP для этой сессии. Когда установлено `"websockets": true`, та же конечная точка принимает апгрейд и использует WebSocket-мост. +Для итоговой исходящей модели `gpt-5.3-codex-spark` каноническая пересылка в ChatGPT явно +отключает Responses Lite в HTTP-заголовке и нативных метаданных WS-кадра, в том числе при +выборе Spark через псевдоним — но только если в исходящем теле нет элемента `additional_tools` с непустым массивом `tools`. +Эта группа и ЕСТЬ Lite-форма доставки инструментов, поэтому тело Spark, которое её использует, +сохраняет Lite ВКЛЮЧЁННЫМ независимо от заголовка вызывающего клиента или конфигурации. Изменение идентичности Lite выводит старый сокет из использования; +последующие подходящие запросы с той же идентичностью могут повторно использовать новый сокет. +Другие модели и шлюзы сохраняют прежнюю политику Lite. Некорректные нативные метаданные +по-прежнему приводят к откату на HTTP без изменения тела запроса. + Compaction контекста Codex работает для маршрутизируемых моделей. `server/responses/compact.ts` обрабатывает `POST /v1/responses/compact`, выполняя внутренний маршрутизируемый ход суммаризации и возвращая сжатую историю, а `responses/parser.ts` и `bridge.ts` обрабатывают ходы diff --git a/docs-site/src/content/docs/tr/reference/architecture.md b/docs-site/src/content/docs/tr/reference/architecture.md index 24d2bbb7aa..5dd9b99957 100644 --- a/docs-site/src/content/docs/tr/reference/architecture.md +++ b/docs-site/src/content/docs/tr/reference/architecture.md @@ -170,6 +170,15 @@ opencodex `426 upgrade_required` döndürür; Codex daha sonra bu oturum için HTTP'ye geri döner. `"websockets": true` ayarlandığında aynı uç nokta yükseltmeyi kabul eder ve WebSocket köprüsünü kullanır. +Son gönderilen model `gpt-5.3-codex-spark` olduğunda, kanonik ChatGPT iletimi HTTP başlığında +ve yerel WS çerçevesi meta verilerinde Responses Lite'ı açıkça kapatır; Spark bir takma adla +seçildiğinde de bu geçerlidir — ancak yalnızca giden gövde boş olmayan `tools` dizisine sahip bir `additional_tools` grubu +taşımıyorsa. Bu grup Lite'ın araç teslim biçiminin kendisidir; onu kullanan bir Spark gövdesi, +çağıran veya yapılandırılmış başlık ne derse desin Lite'ı AÇIK tutar. Lite kimliği değişince eski soket kullanım dışı bırakılır; +aynı kimliğe sahip sonraki uygun istekler yeni soketi yeniden kullanabilir. Diğer modeller ve +ağ geçitleri mevcut Lite politikalarını korur. Bozuk yerel meta verilerde, istek gövdesi +değiştirilmeden HTTP'ye geri dönülmeye devam edilir. + Codex bağlam sıkıştırması yönlendirilen modeller için çalışır. `server/responses/compact.ts`, dahili bir yönlendirilen özetleme turu çalıştırarak ve sıkıştırılmış geçmişi döndürerek `POST /v1/responses/compact`'ı @@ -220,4 +229,3 @@ Dahili model `types.ts` içinde yer alır: `OcxParsedRequest`, `OcxContext`, `OcxProviderConfig`). İki yardımcı yaygın olarak kullanılır: `namespacedToolName()` ve `modelInList()` (`noVisionModels` / `noReasoningModels` için toleranslı `:size` etiketi eşleştirmesi). - diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index 94c5eb8ea0..b1edbd1e8b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -134,6 +134,13 @@ thread affinity 位于 `codex/` 下,不会出现在管理 API 响应中。请 session 中回退到 HTTP。设置 `"websockets": true` 后,同一 endpoint 会接受 upgrade 并使用 WebSocket bridge。 +当最终发送的模型为 `gpt-5.3-codex-spark` 时,canonical ChatGPT 转发会在 HTTP 请求头和 +原生 WS 帧元数据中明确关闭 Responses Lite,通过别名选择 Spark 时也一样;但这仅适用于发送正文 +不含带有非空 `tools` 数组的 `additional_tools` 分组的情况。该分组本身就是 Lite 的工具投递形态,因此仍使用它的 Spark +正文会保持 Lite 开启,无论调用方或配置的请求头如何。Lite 标识变化时, +旧 socket 会退出使用;后续标识相同且满足复用条件的请求可以复用新 socket。其他模型和网关 +保留原有 Lite 策略。原生元数据格式不合法时,仍会回退到 HTTP,并保持请求正文不变。 + Codex context compaction 同样适用于路由模型。`server/responses/compact.ts` 处理 `POST /v1/responses/compact`,运行一次内部路由 summarization turn 并返回压缩后的历史; `responses/parser.ts` 与 `bridge.ts` 则处理 remote compaction v2 的 `compaction_trigger` turn, diff --git a/docs-site/src/content/docs/zh-tw/reference/architecture.md b/docs-site/src/content/docs/zh-tw/reference/architecture.md index daf9bc9080..be0b2c5003 100644 --- a/docs-site/src/content/docs/zh-tw/reference/architecture.md +++ b/docs-site/src/content/docs/zh-tw/reference/architecture.md @@ -134,6 +134,14 @@ thread affinity 位於 `codex/` 下,不會出現在管理 API 回應中。請 session 中回退到 HTTP。設定 `"websockets": true` 後,同一 endpoint 會接受 upgrade 並使用 WebSocket bridge。 +當最終傳送的模型為 `gpt-5.3-codex-spark` 時,canonical ChatGPT 轉送會在 HTTP 請求標頭與 +原生 WS 訊框中繼資料中明確關閉 Responses Lite,透過別名選擇 Spark 時也一樣;但僅限於傳送本文 +不含帶有非空 `tools` 陣列的 `additional_tools` 群組的情況。該群組本身就是 Lite 的工具傳遞形態,因此仍使用它的 Spark +本文會保持 Lite 開啟,無論呼叫端或設定的標頭為何。Lite 識別值 +改變時,舊 socket 會停止使用;後續識別值相同且符合重用條件的請求可以重用新 socket。 +其他模型與閘道保留既有 Lite 政策。原生中繼資料格式不合法時,仍會退回 HTTP,並保持 +請求本文不變。 + Codex context compaction 同樣適用於路由模型。`server/responses/compact.ts` 處理 `POST /v1/responses/compact`,執行一次內部路由 summarization turn 並回傳壓縮後的歷史; `responses/parser.ts` 與 `bridge.ts` 則處理 remote compaction v2 的 `compaction_trigger` turn, diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 30e1c14c85..10be9c516f 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -865,6 +865,21 @@ function promoteClientLoadedTools(body: unknown): unknown { } const MAX_RESPONSES_CALL_ID_LENGTH = 64; + +/** + * Whether the outgoing body still delivers tools through the responses-lite shape. + * + * Lite carries the client catalog as an `additional_tools` input item; the non-Lite wire shape + * expects top-level `tools`. Anything that flips the Lite advertisement has to agree with the + * shape actually being sent, or the destination silently loses the tool surface. + */ +function bodyCarriesLiteToolShape(body: Record): boolean { + if (!Array.isArray(body.input)) return false; + return body.input.some(item => + isPlainObject(item) && item.type === "additional_tools" + && Array.isArray(item.tools) && item.tools.length > 0 + ); +} const REPAIRED_CALL_ID_PREFIX = "call_ocx_"; const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length; @@ -2506,7 +2521,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ), isXaiSchemaTarget(provider), ); - const finalBody = stripDisabledVerbosity( + const unnormalizedBody = stripDisabledVerbosity( stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), provider, @@ -2515,13 +2530,32 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): provider, parsed.modelId, ); + // Normalize the wire model before deriving model-dependent transport metadata. + const finalBody = + provider.modelSuffixBracketStrip + && unnormalizedBody !== null + && typeof unnormalizedBody === "object" + && !Array.isArray(unnormalizedBody) + && typeof (unnormalizedBody as { model?: unknown }).model === "string" + ? { ...(unnormalizedBody as Record), model: stripBracketedModelSuffix((unnormalizedBody as { model: string }).model) } + : unnormalizedBody; if (isCanonicalOpenAiForwardProvider(provider)) { - // Spark closes Responses Lite streams before a terminal completion. Select compatibility - // from the final wire model so aliases cannot leave the caller or a static header enabled. + // Select Spark's Lite compatibility from the final wire model, including aliases, and + // let the BODY decide it. The header also overrides native WS metadata downstream, so a + // forwarded or statically configured value must never contradict the shape being sent. + // + // The synchronized catalog keeps `use_responses_lite: true` for Spark precisely because + // it selects tool delivery (`input[].additional_tools` instead of top-level `tools`), and + // stripSparkCompatibility filters that group in place rather than promoting it. So a + // Lite-shaped body is pinned back ON — otherwise an inherited `false` advertises non-Lite + // while the tools exist only in the Lite shape, and Spark loses the tool surface. Only a + // body with no Lite tool group is downgraded, which is what the stream fix needs. if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { + const liteShaped = bodyCarriesLiteToolShape(finalBody); for (const name of Object.keys(headers)) { if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; } + headers[CODEX_RESPONSES_LITE_HEADER] = liteShaped ? "true" : "false"; } const routingHeaders = new Headers(headers); applyCodexRoutingHint(routingHeaders, finalBody); @@ -2548,15 +2582,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // here, on the serialized body, not on the parsed selector. One place covers both the // HTTP and the WebSocket outbound, because the WS path transports this same request // instead of rebuilding it. - const body = JSON.stringify( - provider.modelSuffixBracketStrip - && finalBody !== null - && typeof finalBody === "object" - && !Array.isArray(finalBody) - && typeof (finalBody as { model?: unknown }).model === "string" - ? { ...(finalBody as Record), model: stripBracketedModelSuffix((finalBody as { model: string }).model) } - : finalBody, - ); + const body = JSON.stringify(finalBody); const releaseBodyObservation = translatorBudget.observeExternallyCapped( "passthrough_serialization", new TextEncoder().encode(body).byteLength, diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index b0fab66633..24f4e16537 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -63,3 +63,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 45fe1c11e7..401aed4b34 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -96,3 +96,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index b06c1ce393..1bf486d5d1 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -308,3 +308,5 @@ The Combo guides describe the distinction between display quota and single-crede The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e0b87add2d..1f159e3355 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -264,3 +264,5 @@ fragments are not guessed onto pending ID-only calls. parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum safe-integer boundary, invalid index types, missing/null continuations and UTF-8 byte-limit boundaries. + +Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index be42793e0b..fee3576257 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -82,3 +82,5 @@ constraints cannot widen the canonical shape. Bare shell bridge names are reject on the freeform path. Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in `tests/providers/cursor/cursor-tool-definitions.test.ts`. + +Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). diff --git a/structure/runtime.md b/structure/runtime.md index 6d733bf8b5..acbe3c464a 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -212,3 +212,5 @@ cooldowns and response-driven retry remain authoritative. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](transports/responses.md). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0348c1cdd..268248ce48 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -67,3 +67,5 @@ Quota publication distinguishes display reports from explicitly supplied inferen The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 5624a2e04e..a7c9d8b6f9 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -402,6 +402,16 @@ final outgoing model/tier. No caller identity is synthesized. Noncanonical opt-in gateways keep their own metadata policy. Oversized/unsupported-runtime HTTP fallback preserves the original HTTP body and Lite header. +For the final wire model `gpt-5.3-codex-spark`, the canonical forward adapter normalizes the +Lite header from the BODY, overriding caller/configured headers and stale native WS Lite +metadata in both directions. A body carrying a nonempty `additional_tools` input item is +pinned to `true`: that item IS the Lite tool-delivery format and the non-Lite wire shape +expects top-level `tools`, so an inherited `false` would advertise non-Lite while the tools +exist only in the Lite shape and hide the client tool surface. Any other Spark body is set to +`false`, selecting the non-Lite framing policy. A changed Lite identity retires the previous socket; +subsequent eligible Spark requests with the same identity can reuse the new socket. Malformed +native metadata retains HTTP fallback eligibility without rewriting its body. + Canonical WS quota and response metadata preceding the first Responses event are projected into bounded, allowlisted HTTP headers before the response is committed. Later quota observations update only the captured serving account; @@ -520,3 +530,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Spark Lite and routing metadata use the same suffix-normalized model object as serialization, including configured bracket-suffix removal. diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index ee70d4829e..329e90a973 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -208,33 +208,115 @@ describe("Codex request transport metadata", () => { expect(new Headers(dropped.headers).get(hintHeader)).toBe("model=gpt-5.6-sol"); }); - test("canonical adapter drops Lite only for the Spark wire model", async () => { + test("canonical adapter disables Spark Lite in HTTP headers and WS metadata without mutating input", async () => { + const { prepareCodexWsRequest } = await import("../../src/server/responses/codex-ws-request"); const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", headers: { "X-OpenAI-Internal-Codex-Responses-Lite": "true" }, }); for (const [model, incomingLite, expectedLite] of [ - ["gpt-5.3-codex-spark", "true", null], - ["gpt-5.3-codex-spark", undefined, null], + ["gpt-5.3-codex-spark", "true", "false"], + ["gpt-5.3-codex-spark", "false", "false"], + ["gpt-5.3-codex-spark", undefined, "false"], ["gpt-5.6-sol", "true", "true"], + ["gpt-5.6-sol", "false", "false"], + ["gpt-5.6-sol", undefined, "true"], ] as const) { const parsed = minimalParsed(); parsed.modelId = model; - parsed._rawBody = { model, input: [], stream: true }; + parsed._rawBody = { model, input: [], stream: true, + client_metadata: { [liteKey]: "true", other: "preserved" } }; + const before = JSON.stringify(parsed._rawBody); const incoming = new Headers(); if (incomingLite !== undefined) incoming.set(liteHeader, incomingLite); const request = await adapter.buildRequest(parsed, { headers: incoming, }); expect(new Headers(request.headers).get(liteHeader)).toBe(expectedLite); + const prepared = prepareCodexWsRequest(url, { body: request.body, headers: request.headers })!; + expect(JSON.parse(prepared.frameText).client_metadata).toEqual({ + [liteKey]: expectedLite, other: "preserved", + }); + expect(prepared.httpInit.body).toBe(request.body); + expect(JSON.stringify(parsed._rawBody)).toBe(before); + expect(incoming.get(liteHeader)).toBe(incomingLite ?? null); } const routed = minimalParsed(); routed.modelId = "spark-alias"; routed._rawBody = { model: "gpt-5.3-codex-spark", input: [], stream: true }; const request = await adapter.buildRequest(routed, { headers: new Headers({ [liteHeader]: "true" }) }); - expect(new Headers(request.headers).get(liteHeader)).toBeNull(); + expect(new Headers(request.headers).get(liteHeader)).toBe("false"); + const prepared = prepareCodexWsRequest(url, { body: request.body, headers: request.headers })!; + expect(JSON.parse(prepared.frameText).client_metadata[liteKey]).toBe("false"); + + routed.modelId = "gpt-5.3-codex-spark"; + routed._rawBody = { model: "gpt-5.6-sol", input: [], stream: true }; + const otherWireModel = await adapter.buildRequest(routed, { headers: new Headers({ [liteHeader]: "true" }) }); + expect(new Headers(otherWireModel.headers).get(liteHeader)).toBe("true"); + }); + + test("a Lite-shaped Spark body pins Lite back on, whatever the inherited header said", async () => { + const { prepareCodexWsRequest } = await import("../../src/server/responses/codex-ws-request"); + // The catalog keeps use_responses_lite: true for Spark because it selects tool DELIVERY: + // the client catalog rides `input[].additional_tools`, not top-level `tools`. A forwarded or + // configured `false` must not survive on such a body, or the frame advertises non-Lite while + // the tools exist only in the Lite shape and Spark loses them. + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + headers: { "X-OpenAI-Internal-Codex-Responses-Lite": "false" }, + }); + const liteShapedInput = [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { type: "additional_tools", tools: [{ type: "function", name: "shell", parameters: {} }] }, + ]; + + for (const incomingLite of ["false", "true", undefined] as const) { + const parsed = minimalParsed(); + parsed.modelId = "gpt-5.3-codex-spark"; + parsed._rawBody = { model: "gpt-5.3-codex-spark", input: liteShapedInput, stream: true, + client_metadata: { [liteKey]: "false", other: "preserved" } }; + const incoming = new Headers(); + if (incomingLite !== undefined) incoming.set(liteHeader, incomingLite); + const request = await adapter.buildRequest(parsed, { headers: incoming }); + expect(new Headers(request.headers).get(liteHeader)).toBe("true"); + const prepared = prepareCodexWsRequest(url, { body: request.body, headers: request.headers })!; + expect(JSON.parse(prepared.frameText).client_metadata).toEqual({ + [liteKey]: "true", other: "preserved", + }); + } + + // An empty group is not a Lite tool surface, so the stream fix still applies. + const toolless = minimalParsed(); + toolless.modelId = "gpt-5.3-codex-spark"; + toolless._rawBody = { model: "gpt-5.3-codex-spark", stream: true, + input: [{ type: "additional_tools", tools: [] }] }; + const downgraded = await adapter.buildRequest(toolless, { headers: new Headers() }); + expect(new Headers(downgraded.headers).get(liteHeader)).toBe("false"); + }); + + test("Spark disables Lite without configured headers and retains malformed-metadata HTTP fallback", async () => { + const { prepareCodexWsRequest } = await import("../../src/server/responses/codex-ws-request"); + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + }); + for (const client_metadata of [undefined, {}, null, [], { [liteKey]: true }]) { + const parsed = minimalParsed(); + parsed._rawBody = { model: "gpt-5.3-codex-spark", input: [], stream: true, + ...(client_metadata === undefined ? {} : { client_metadata }) }; + const before = JSON.stringify(parsed._rawBody); + const request = await adapter.buildRequest(parsed, { headers: new Headers() }); + expect(new Headers(request.headers).get(liteHeader)).toBe("false"); + const prepared = prepareCodexWsRequest(url, { body: request.body, headers: request.headers }); + if (client_metadata === undefined || JSON.stringify(client_metadata) === "{}") { + expect(JSON.parse(prepared!.frameText).client_metadata).toEqual({ [liteKey]: "false" }); + } else { + expect(prepared).toBeNull(); + expect(JSON.parse(request.body).client_metadata).toEqual(client_metadata); + } + expect(JSON.stringify(parsed._rawBody)).toBe(before); + } }); test("noncanonical adapters neither forward caller Lite nor synthesize a routing hint", async () => { @@ -243,7 +325,10 @@ describe("Codex request transport metadata", () => { adapter: "openai-responses", authMode, baseUrl: "https://gateway.example/v1", headers: { [hintHeader]: "operator-owned" }, }); - const request = await adapter.buildRequest(minimalParsed(), { + const parsed = minimalParsed(); + parsed.modelId = "gpt-5.3-codex-spark"; + parsed._rawBody = { model: parsed.modelId, input: [] }; + const request = await adapter.buildRequest(parsed, { headers: new Headers({ [liteHeader]: "true", [hintHeader]: "caller-owned" }), }); expect(new Headers(request.headers).has(liteHeader)).toBe(false); @@ -363,3 +448,60 @@ describe("Codex request transport metadata", () => { expect(headers.get(hintHeader)).toBe("model=gpt-5.6-luna"); }); }); + + +describe("Spark Lite follows serialized model and surviving tool shape", () => { + const liteHeader = "x-openai-internal-codex-responses-lite"; + const liteKey = "ws_request_header_x_openai_internal_codex_responses_lite"; + test("bracket normalization and both alias directions use the wire model", async () => { + const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", modelSuffixBracketStrip: true }); + for (const [selector, model, expectedModel, expectedLite] of [ + ["alias", "gpt-5.3-codex-spark[1m]", "gpt-5.3-codex-spark", "false"], + ["gpt-5.3-codex-spark", "gpt-5.6-sol", "gpt-5.6-sol", "true"], + ]) { + const parsed = minimalParsed(); + parsed.modelId = selector; + parsed._rawBody = { model, input: [] }; + const before = JSON.stringify(parsed._rawBody); + const built = await adapter.buildRequest(parsed, { headers: new Headers({ [liteHeader]: "true" }) }); + expect(JSON.parse(built.body).model).toBe(expectedModel); + expect(new Headers(built.headers).get(liteHeader)).toBe(expectedLite); + expect(new Headers(built.headers).get("x-codex-routing-hint")).toContain(`model=${expectedModel}`); + expect(JSON.stringify(parsed._rawBody)).toBe(before); + } + }); + + for (const [name, inputTools, topTools, expectedTools, expectedLite] of [ + ["filtered empty", [{ type: "tool_search" }], undefined, [], "false"], + ["reserved functions", [{ type: "namespace", name: "functions", tools: [{ type: "function", name: "lookup", parameters: { type: "object" } }] }], undefined, + [{ type: "namespace", name: "functions", tools: [{ type: "function", name: "lookup", parameters: { type: "object" } }] }], "true"], + ["top-level only", undefined, [{ type: "function", name: "lookup", parameters: { type: "object" } }], undefined, "false"], + ] as const) test(`post-transform body: ${name}`, async () => { + const { prepareCodexWsRequest } = await import("../../src/server/responses/codex-ws-request"); + const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", headers: { [liteHeader]: expectedLite === "true" ? "false" : "true" } }); + const parsed = minimalParsed(); + parsed.modelId = "gpt-5.3-codex-spark"; + parsed._rawBody = { model: parsed.modelId, input: inputTools ? [{ type: "additional_tools", tools: inputTools }] : [], + ...(topTools ? { tools: topTools } : {}) }; + const built = await adapter.buildRequest(parsed); + const body = JSON.parse(built.body); + expect(body.input.find((item: { type: string }) => item.type === "additional_tools")?.tools).toEqual(expectedTools); + if (topTools) expect(body.tools).toEqual(topTools); + expect(new Headers(built.headers).get(liteHeader)).toBe(expectedLite); + const prepared = prepareCodexWsRequest("https://chatgpt.com/backend-api/codex/responses", { body: built.body, headers: built.headers }); + expect(JSON.parse(prepared!.frameText).client_metadata[liteKey]).toBe(expectedLite); + }); + + test("noncanonical static Lite remains operator-owned", async () => { + for (const authMode of ["key", "forward"] as const) { + const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", authMode, + baseUrl: "https://gateway.example/v1", headers: { [liteHeader]: "operator-owned" } }); + const parsed = minimalParsed(); + parsed._rawBody = { model: "gpt-5.3-codex-spark", input: [] }; + const built = await adapter.buildRequest(parsed, { headers: new Headers({ [liteHeader]: "true" }) }); + expect(new Headers(built.headers).get(liteHeader)).toBe("operator-owned"); + } + }); +}); diff --git a/tests/responses/ws-upstream-reuse.test.ts b/tests/responses/ws-upstream-reuse.test.ts index b957fdb317..48b720d2ea 100644 --- a/tests/responses/ws-upstream-reuse.test.ts +++ b/tests/responses/ws-upstream-reuse.test.ts @@ -3,6 +3,8 @@ import { codexWsUpstreamFetch } from "../../src/server/responses/ws-upstream"; import { runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; import { CodexWsPool, codexWsPool } from "../../src/server/responses/codex-ws-pool"; import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; const URL = "https://chatgpt.com/backend-api/codex/responses"; const realWebSocket = globalThis.WebSocket; @@ -307,3 +309,31 @@ test("a Lite mode change retires the old handshake", async () => { expect(Socket.all).toHaveLength(2); expect(Socket.all[0]!.readyState).toBe(3); }); + +test("adapter Spark Lite override retires a legacy socket and reuses the disabled identity", async () => { + const liteHeader = "x-openai-internal-codex-responses-lite"; + const liteKey = "ws_request_header_x_openai_internal_codex_responses_lite"; + const options = init(); + const rawBody = { ...JSON.parse(options.body as string), model: "gpt-5.3-codex-spark", + client_metadata: { thread_id: "fixture-thread", turn_id: "fixture-turn", [liteKey]: "true" } }; + const before = JSON.stringify(rawBody); + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + })); + const built = await adapter.buildRequest({ modelId: "spark-alias", context: { messages: [] }, + stream: true, options: {}, _rawBody: rawBody, + }, { headers: new Headers(options.headers) }); + const current = { ...options, body: built.body, headers: built.headers }; + // Keep the exact same Spark model/scope/headers; only the old delete-only Lite policy differs. + const legacyHeaders = new Headers(current.headers); + legacyHeaders.delete(liteHeader); + await drain({ ...current, headers: legacyHeaders }); + await drain(current); + await drain(current); + expect(Socket.all).toHaveLength(2); + expect(Socket.all.map(socket => socket.readyState)).toEqual([3, 1]); + expect(Socket.all.map(socket => socket.frames.map(frame => + (frame.client_metadata as Record)[liteKey]))).toEqual([["true"], ["false", "false"]]); + expect(Socket.all.flatMap(socket => socket.frames).every(frame => frame.model === rawBody.model)).toBe(true); + expect(JSON.stringify(rawBody)).toBe(before); +}); From d593599714bd218a2eb2535ad8facd13937ac2f2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:00:54 +0900 Subject: [PATCH 029/224] docs: plan latest-dev conflict refresh for thinking carries --- devlog/_plan/260912_thinking_contract/050_refresh.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 devlog/_plan/260912_thinking_contract/050_refresh.md diff --git a/devlog/_plan/260912_thinking_contract/050_refresh.md b/devlog/_plan/260912_thinking_contract/050_refresh.md new file mode 100644 index 0000000000..79d0133c85 --- /dev/null +++ b/devlog/_plan/260912_thinking_contract/050_refresh.md @@ -0,0 +1,5 @@ +# Integration conflict repair + +Parent explicitly requests own hint branch latest-dev integration with independent resolution-only audit and no-verify push. Latest fetched dev ca5ac39124671ee05349e7873231f672824ea26c also conflicts with presentation; preserve all three independent dev-based PRs. Most collisions are adjacent structure-document additions; core received continuation recovery changes that must survive. No source PR/other worktree modifications or merges into dev. Rebase only owned branches, preserve pre-rebase refs in ignored evidence and compare range-diff; use explicit expected old remote SHA with force-with-lease plus --no-verify. This is branch refresh, not native restacking. Product tests remain NOT RUN. + +Prior D: Spark source audit PASS; hosted tests remain pending. Work-phase added per parent collision instruction, before final delivery. MODIFY conflict paths only, retaining source contracts and new dev changes. Verification: git range-diff, git diff --check, docs source validator, independent resolution-only review; hosted tests rerun only on refreshed final heads. From 4b8eaed3449b4e10dd67aefd0b6e9b0bfe06005e Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:02:45 +0900 Subject: [PATCH 030/224] docs: bind conflict refresh to active delivery cycle --- devlog/_plan/260912_thinking_contract/050_refresh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog/_plan/260912_thinking_contract/050_refresh.md b/devlog/_plan/260912_thinking_contract/050_refresh.md index 79d0133c85..16d4aa80dd 100644 --- a/devlog/_plan/260912_thinking_contract/050_refresh.md +++ b/devlog/_plan/260912_thinking_contract/050_refresh.md @@ -2,4 +2,4 @@ Parent explicitly requests own hint branch latest-dev integration with independent resolution-only audit and no-verify push. Latest fetched dev ca5ac39124671ee05349e7873231f672824ea26c also conflicts with presentation; preserve all three independent dev-based PRs. Most collisions are adjacent structure-document additions; core received continuation recovery changes that must survive. No source PR/other worktree modifications or merges into dev. Rebase only owned branches, preserve pre-rebase refs in ignored evidence and compare range-diff; use explicit expected old remote SHA with force-with-lease plus --no-verify. This is branch refresh, not native restacking. Product tests remain NOT RUN. -Prior D: Spark source audit PASS; hosted tests remain pending. Work-phase added per parent collision instruction, before final delivery. MODIFY conflict paths only, retaining source contracts and new dev changes. Verification: git range-diff, git diff --check, docs source validator, independent resolution-only review; hosted tests rerun only on refreshed final heads. +Prior D: Spark source audit PASS; hosted tests remain pending. Refresh is a subtask of the already-active delivery cycle; no separate cycle is claimed. MODIFY conflict paths only, retaining source contracts and new dev changes. Verification: git range-diff, git diff --check, docs source validator, independent resolution-only review; hosted tests rerun only on refreshed final heads. From 321b9b1cd1e9031732f46893d6cbbc0c774cfca1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:07:33 +0900 Subject: [PATCH 031/224] fix(live): validate sideband upstream before client upgrade Co-authored-by: Kosta Milovanovic --- .../content/docs/reference/proxy-formats.md | 8 + src/server/index.ts | 368 ++++++++++++- src/server/ws-bridge.ts | 21 + structure/runtime.md | 4 + tests/server/server-live.test.ts | 493 +++++++++++++++++- 5 files changed, 865 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..dedd5b83ec 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,14 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Live sideband connection failures + +The proxy completes the upstream live sideband handshake before accepting the client +WebSocket. An upstream rejection fails the upgrade with 502; a ten-second handshake timeout +returns 504. Bun does not expose the exact upstream handshake status, so an upstream 404/410 +cannot currently be forwarded precisely. A successful connection preserves the initial session +frames in order. This handshake policy is separate from the Responses WebSocket transport. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..e0af64d255 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,8 @@ import { buildResponsesWsData, sendResponseToWebSocket, sendTextFrame, + type LiveSidebandUpstreamFailure, + type LiveSidebandUpstreamHandoff, type WsData, } from "./ws-bridge"; import type { Server, ServerWebSocket } from "bun"; @@ -319,6 +321,29 @@ function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmissio const LIVE_SIDEBAND_PENDING_MAX = 32; const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024; const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000; +/** + * Bound the pre-upgrade upstream handshake. A sideband join that cannot reach 101 + * must fail the client upgrade promptly rather than hold it open indefinitely. + */ +export const LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS = 10_000; + +/** + * Outcome of the upstream sideband handshake performed before the client upgrade. + * + * `ok: false` carries the HTTP status the client upgrade must fail with. Only an + * upgrade failure reaches codex-rs as a connect error, and only a connect error + * ends its sideband reconnect loop (`realtime_conversation/sideband.rs`: the `Err` + * arm always breaks). A 101 followed by a close is instead read as `TransportLost` + * and retried forever against the same, permanently dead call id. + */ +export type LiveSidebandUpstreamOpenResult = + | { + ok: true; + socket: WebSocket; + /** Owns capture and terminal events until the downstream relay attaches. */ + handoff: LiveSidebandUpstreamHandoff; + } + | { ok: false; status: number; code: string; message: string; socket?: WebSocket }; export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean { return frameBytes > MAX_WS_FRAME_BYTES; @@ -416,6 +441,48 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: Web }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); } +function closeLiveSidebandBeforeUpgrade( + upstream: WebSocket, + release: () => void, + code = 1000, + reason = "", +): void { + // There is no downstream socket to own this transport yet. Mirror + // closeLiveSideband's bounded close contract directly: release only after a + // close event or an observed CLOSED state, never merely after requesting close. + let released = false; + let fallback: ReturnType | undefined; + const releaseOnce = (): void => { + if (released) return; + released = true; + if (fallback !== undefined) clearTimeout(fallback); + release(); + }; + upstream.addEventListener("close", releaseOnce, { once: true }); + if (upstream.readyState === WebSocket.CLOSED) { + releaseOnce(); + return; + } + fallback = setTimeout(() => { + if (upstream.readyState === WebSocket.CLOSED) { + releaseOnce(); + return; + } + try { + upstream.close(1000, "upstream close timeout"); + } catch { + /* retain ownership until CLOSED is observed */ + } + if ((upstream.readyState as number) === 3) releaseOnce(); + }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); + try { + upstream.close(code, reason); + } catch { + /* the bounded fallback retries without releasing ownership */ + } + if ((upstream.readyState as number) === 3) releaseOnce(); +} + function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = ""): void { if (ws.data.liveClosing) return; ws.data.liveClosing = true; @@ -448,29 +515,243 @@ function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = "" } } -function attachLiveSidebandUpstream( +/** + * Dial the upstream sideband and report whether its handshake reached 101. + * + * Bun's client WebSocket does not surface the upstream handshake status, so the + * result is "opened" or "failed" and nothing finer. That is sufficient for the + * property this exists to guarantee: the client is never told the relay is live + * when it is not. Frames the upstream sends before the client socket exists are + * captured and handed back by `drain`, because a session preamble such as + * `session.created` arrives immediately after the upstream opens. + */ +export function openLiveSidebandUpstream( + url: string, + headers: Record, + createWebSocket: LiveSidebandWebSocketFactory = (socketUrl, socketHeaders) => ( + new WebSocket(socketUrl, { headers: socketHeaders } as unknown as string[]) + ), + timeoutMs: number = LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, + signal?: AbortSignal, +): Promise { + return new Promise(resolve => { + let socket: WebSocket; + try { + socket = createWebSocket(url, headers); + } catch { + resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" }); + return; + } + + const buffered: Array = []; + let bufferedBytes = 0; + let capturing = true; + let settled = false; + let terminalFailure: LiveSidebandUpstreamFailure | undefined; + let removeAbortListener = (): void => {}; + + const finish = (result: LiveSidebandUpstreamOpenResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + removeAbortListener(); + resolve(result); + }; + const timer = setTimeout(() => { + const failure = { status: 504, code: "upstream_timeout", message: "voice upstream did not open in time" }; + terminalFailure = failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...failure, socket }); + try { + socket.close(); + } catch { + /* ignore */ + } + }, timeoutMs); + + const failCapture = (failure: LiveSidebandUpstreamFailure): void => { + if (!capturing || terminalFailure) return; + terminalFailure = failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...failure, socket }); + try { + socket.close(1009, "sideband preamble overflow"); + } catch { + /* the terminal failure is already retained for the downstream handoff */ + } + }; + const handoff: LiveSidebandUpstreamHandoff = { + failure: () => terminalFailure, + take: () => { + capturing = false; + if (terminalFailure) return { ok: false, failure: terminalFailure }; + const frames = buffered.slice(); + buffered.length = 0; + bufferedBytes = 0; + return { ok: true, frames }; + }, + }; + + socket.addEventListener("message", event => { + if (!capturing) return; + const frameBytes = webSocketFrameBytes(event.data); + if (exceedsLiveSidebandFrameByteLimit(frameBytes)) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble frame is too large" }); + return; + } + if (buffered.length >= LIVE_SIDEBAND_PENDING_MAX) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream sent too many preamble frames" }); + return; + } + if (exceedsLiveSidebandPendingByteLimit(bufferedBytes, frameBytes)) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble is too large" }); + return; + } + if (typeof event.data === "string") buffered.push(event.data); + else if (event.data instanceof ArrayBuffer) buffered.push(Buffer.from(new Uint8Array(event.data))); + else if (ArrayBuffer.isView(event.data)) { + buffered.push(Buffer.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength))); + } else return; + bufferedBytes += frameBytes; + }); + socket.addEventListener("open", () => { + finish({ + ok: true, + socket, + handoff, + }); + }); + socket.addEventListener("error", () => { + const failure = { status: 502, code: "upstream_error", message: "voice upstream rejected the sideband join" }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + try { + socket.close(); + } catch { + /* the terminal failure is already retained */ + } + }); + socket.addEventListener("close", event => { + const failure = { + status: 502, + code: "upstream_error", + message: `voice upstream closed before opening (code ${event.code})`, + closeCode: event.code, + closeReason: event.reason, + }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + }); + const abortOpen = (): void => { + const failure = { status: 499, code: "request_cancelled", message: "voice sideband join was cancelled" }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + try { + socket.close(); + } catch { + /* the cancelled join no longer owns the socket */ + } + }; + if (signal) { + signal.addEventListener("abort", abortOpen, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", abortOpen); + if (signal.aborted) abortOpen(); + } + }); +} + +export function attachLiveSidebandUpstream( ws: ServerWebSocket, createWebSocket: LiveSidebandWebSocketFactory = (url, headers) => ( new WebSocket(url, { headers } as unknown as string[]) ), ): void { - const url = ws.data.liveUpstreamUrl; - if (!url) { - closeLiveSideband(ws, 1011, "missing upstream"); - return; - } + // A socket carried in from the upgrade handler already completed its handshake + // before the client was told 101. Reuse it rather than dialing a second upstream. + const preOpened = ws.data.liveUpstream; let upstream: WebSocket; - try { - // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. - upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}); - } catch { - closeLiveSideband(ws, 1011, "upstream connect failed"); - return; + if (preOpened) { + upstream = preOpened; + } else { + const url = ws.data.liveUpstreamUrl; + if (!url) { + closeLiveSideband(ws, 1011, "missing upstream"); + return; + } + try { + // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. + upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}); + } catch { + closeLiveSideband(ws, 1011, "upstream connect failed"); + return; + } } ws.data.liveUpstream = upstream; ws.data.liveClosing = false; ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed"); + upstream.addEventListener("close", (event) => { + if (ws.data.liveUpstream !== upstream) return; + ws.data.liveClosing = true; + finalizeLiveSideband(ws, upstream); + try { + ws.close(event.code || 1000, event.reason || ""); + } catch { + /* ignore */ + } + }); + upstream.addEventListener("error", () => { + if (ws.data.liveUpstream !== upstream) return; + closeLiveSideband(ws, 1011, "upstream error"); + }); + + if (preOpened) { + // The upstream opened before this socket existed, so its `open` event has already + // fired and the listener below will never run. Its early frames were captured for + // us; forward the capture now rather than dropping the session preamble. + const handoff = ws.data.liveUpstreamHandoff; + ws.data.liveUpstreamHandoff = undefined; + const takeover = handoff?.take(); + if (!takeover?.ok || preOpened.readyState !== WebSocket.OPEN) { + const failure = takeover && !takeover.ok ? takeover.failure : undefined; + closeLiveSideband( + ws, + failure?.closeCode ?? 1011, + failure?.closeReason ?? "upstream closed before relay attachment", + ); + return; + } + ws.data.liveOpened = true; + for (const frame of takeover.frames) { + try { + // Mirror the live message listener exactly: same ceiling, same diagnostic + // record. These frames are upstream-to-client like any other. + if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(frame))) { + closeLiveSideband(ws, 1009, "message too large"); + return; + } + logLiveSidebandFrame("u2c", frame); + ws.send(frame); + } catch { + closeLiveSideband(ws, 1011, "client send failed"); + return; + } + } + } + upstream.addEventListener("open", () => { if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return; ws.data.liveOpened = true; @@ -503,20 +784,6 @@ function attachLiveSidebandUpstream( closeLiveSideband(ws, 1011, "client send failed"); } }); - upstream.addEventListener("close", (event) => { - if (ws.data.liveUpstream !== upstream) return; - ws.data.liveClosing = true; - finalizeLiveSideband(ws, upstream); - try { - ws.close(event.code || 1000, event.reason || ""); - } catch { - /* ignore */ - } - }); - upstream.addEventListener("error", () => { - if (ws.data.liveUpstream !== upstream) return; - closeLiveSideband(ws, 1011, "upstream error"); - }); } // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the @@ -2185,19 +2452,64 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server turnAdmissionLease.release()); + } else { + turnAdmissionLease.release(); + } + addFinalRequestLog(requestId, start, logCtx, upstreamHandshake.status); + console.error(`[live] sideband upstream handshake failed: ${upstreamHandshake.message}`); + return withCors( + formatErrorResponse(upstreamHandshake.status, upstreamHandshake.code, upstreamHandshake.message), + req, + policy, + ); + } + const handoffFailure = upstreamHandshake.handoff.failure(); + if (handoffFailure || upstreamHandshake.socket.readyState !== WebSocket.OPEN) { + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => turnAdmissionLease.release()); + const failure = handoffFailure ?? { + status: 502, + code: "upstream_error", + message: "voice upstream closed before client upgrade", + }; + addFinalRequestLog(requestId, start, logCtx, failure.status); + return withCors(formatErrorResponse(failure.status, failure.code, failure.message), req, policy); + } addFinalRequestLog(requestId, start, logCtx, 101); if (requestServer.upgrade(req, { data: { kind: "live-sideband", + liveUpstream: upstreamHandshake.socket, liveUpstreamUrl: resolved.upstreamWsUrl, liveUpstreamHeaders: resolved.headers, + liveUpstreamHandoff: upstreamHandshake.handoff, livePending: [], livePendingBytes: 0, - liveOpened: false, + liveOpened: true, liveTurnAdmissionLease: turnAdmissionLease, } satisfies WsData, })) return undefined as unknown as Response; - turnAdmissionLease.release(); + // The upgrade was refused after the upstream had already opened; drop it. + try { + upstreamHandshake.handoff.take(); + } catch { + /* ignore */ + } + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => turnAdmissionLease.release()); return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy); } diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 5777b45a10..7b4e4c37f8 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -39,6 +39,8 @@ export interface WsData { /** Total encoded bytes retained in livePending while the upstream connects. */ livePendingBytes?: number; liveOpened?: boolean; + /** Owns captured frames and terminal state until the downstream relay attaches. */ + liveUpstreamHandoff?: LiveSidebandUpstreamHandoff; /** Once teardown starts, ignore new client frames until the upstream closes. */ liveClosing?: boolean; /** Schedules one bounded close retry without surrendering native-main ownership. */ @@ -48,6 +50,25 @@ export interface WsData { admissionLease?: AdmissionReservation>; } +export interface LiveSidebandUpstreamFailure { + status: number; + code: string; + message: string; + closeCode?: number; + closeReason?: string; +} + +export type LiveSidebandUpstreamTakeover = + | { ok: true; frames: Array } + | { ok: false; failure: LiveSidebandUpstreamFailure }; + +export interface LiveSidebandUpstreamHandoff { + /** Observe failure before the downstream upgrade without ending capture. */ + failure(): LiveSidebandUpstreamFailure | undefined; + /** Atomically ends capture and transfers buffered frames or terminal state. */ + take(): LiveSidebandUpstreamTakeover; +} + /** * Build the Responses WebSocket upgrade payload. * diff --git a/structure/runtime.md b/structure/runtime.md index 6d733bf8b5..19f7dc3c95 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -212,3 +212,7 @@ cooldowns and response-driven retry remain authoritative. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +### Live sideband handshake + +`src/server/index.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index f6d4da8916..441d6bf01a 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -15,13 +15,20 @@ import { type ReadinessGate, } from "../../src/server/readiness"; import { + attachLiveSidebandUpstream, enqueueLiveSidebandPendingFrame, exceedsLiveSidebandFrameByteLimit, exceedsLiveSidebandPendingByteLimit, MAX_WS_FRAME_BYTES, + openLiveSidebandUpstream, startServer, } from "../../src/server"; -import { beginShutdownDrain, isDraining, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { + activeRegistryMetrics, + beginShutdownDrain, + isDraining, + resetLifecycleDrainStateForTests, +} from "../../src/server/lifecycle"; import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -1739,3 +1746,487 @@ describe("GET /readyz while draining", () => { } }); }); + +/** + * A sideband join must not report 101 unless the upstream handshake actually + * succeeded. A 101 followed by a close is read by codex-rs as `TransportLost`, + * which it recovers from by rejoining the same call id indefinitely; a failed + * upgrade is a connect error instead, and that is the only outcome that ends the + * loop. These cases pin the handshake result and its client-visible consequence. + */ +class FakeUpstreamSocket { + private readonly listeners = new Map void>>(); + closed = false; + closeCalls = 0; + closeMode: "closed" | "closing" | "closing-then-close" = "closed"; + readyState = WebSocket.CONNECTING; + + addEventListener(type: string, listener: (event: { code?: number; data?: unknown; reason?: string }) => void): void { + const bucket = this.listeners.get(type) ?? []; + bucket.push(listener); + this.listeners.set(type, bucket); + } + + emit(type: string, event: { code?: number; data?: unknown; reason?: string } = {}): void { + if (type === "open") this.readyState = WebSocket.OPEN; + if (type === "close") this.readyState = WebSocket.CLOSED; + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + close(code = 1000, reason = ""): void { + this.closed = true; + this.closeCalls += 1; + if (this.closeMode === "closing") { + this.readyState = WebSocket.CLOSING; + return; + } + if (this.closeMode === "closing-then-close") this.readyState = WebSocket.CLOSING; + this.emit("close", { code, reason }); + } +} + +function fakeSidebandClient( + upstream: FakeUpstreamSocket, + handoff: { + failure(): { status: number; code: string; message: string; closeCode?: number; closeReason?: string } | undefined; + take(): { ok: true; frames: Array } | { + ok: false; + failure: { status: number; code: string; message: string; closeCode?: number; closeReason?: string }; + }; + }, + send: (frame: string | Buffer) => void = () => {}, +) { + let releases = 0; + const ws = { + data: { + kind: "live-sideband" as const, + liveUpstream: upstream as unknown as WebSocket, + liveUpstreamHandoff: handoff, + liveOpened: true, + liveTurnAdmissionLease: { + release: () => { releases += 1; }, + }, + }, + readyState: WebSocket.OPEN, + close: () => {}, + send, + }; + return { ws, releases: () => releases }; +} + +describe("attachLiveSidebandUpstream ownership", () => { + test("transfers the actual captured preamble before subsequent live frames", async () => { + const upstream = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/fixture", {}, () => upstream as unknown as WebSocket); + upstream.emit("open"); + upstream.emit("message", { data: "first" }); + upstream.emit("message", { data: new Uint8Array([2]) }); + const result = await pending; + if (!result.ok) throw new Error("expected open handshake"); + const sent: Array = []; + const client = fakeSidebandClient(upstream, result.handoff, frame => { sent.push(frame); }); + attachLiveSidebandUpstream(client.ws as never); + upstream.emit("message", { data: "third" }); + expect(sent).toEqual(["first", Buffer.from([2]), "third"]); + upstream.emit("close", { code: 1000 }); + expect(client.releases()).toBe(1); + }); + + test("retains admission through a failed takeover until a CLOSING upstream actually closes", async () => { + const upstream = new FakeUpstreamSocket(); + upstream.readyState = WebSocket.OPEN; + upstream.closeMode = "closing"; + const client = fakeSidebandClient(upstream, { + failure: () => undefined, + take: () => ({ + ok: false, + failure: { status: 502, code: "upstream_error", message: "closed", closeCode: 1008 }, + }), + }); + + attachLiveSidebandUpstream(client.ws as never); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(client.releases()).toBe(0); + await Bun.sleep(1_100); + expect(upstream.closeCalls).toBe(2); + expect(client.releases()).toBe(0); + upstream.emit("close", { code: 1008, reason: "call ended" }); + expect(client.releases()).toBe(1); + upstream.emit("close", { code: 1008, reason: "duplicate close" }); + expect(client.releases()).toBe(1); + }); + + test("registers close ownership before forwarding a pre-opened preamble", () => { + const upstream = new FakeUpstreamSocket(); + upstream.readyState = WebSocket.OPEN; + upstream.closeMode = "closing-then-close"; + const client = fakeSidebandClient( + upstream, + { + failure: () => undefined, + take: () => ({ ok: true, frames: ["session.created"] }), + }, + () => { throw new Error("downstream send failed"); }, + ); + + attachLiveSidebandUpstream(client.ws as never); + + expect(upstream.closeCalls).toBe(1); + expect(upstream.readyState).toBe(WebSocket.CLOSED); + expect(client.releases()).toBe(1); + }); +}); + +describe("openLiveSidebandUpstream", () => { + test("drains the preamble captured before the client socket exists", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + // The session preamble arrives the moment the upstream opens, before the client. + socket.emit("message", { data: "session.created" }); + socket.emit("message", { data: new Uint8Array([1, 2, 3]) }); + socket.emit("open", {}); + + const result = await pending; + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected an open upstream"); + expect(result.socket).toBe(socket); + const takeover = result.handoff.take(); + expect(takeover.ok).toBe(true); + if (!takeover.ok) throw new Error("expected a successful handoff"); + const drained = takeover.frames; + expect(drained[0]).toBe("session.created"); + expect(Buffer.isBuffer(drained[1])).toBe(true); + expect(drained[1]).toEqual(Buffer.from([1, 2, 3])); + // Drain is one-shot: the relay owns capture from here on. + expect(result.handoff.take()).toEqual({ ok: true, frames: [] }); + socket.emit("message", { data: "after-drain" }); + expect(result.handoff.take()).toEqual({ ok: true, frames: [] }); + }); + + test("fails explicitly before copying an aggregate preamble overflow", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + const retained = new Uint8Array(1024 * 1024); + socket.emit("message", { data: retained }); + const rejectedView = new Uint8Array(retained.buffer, 0, 1); + socket.emit("message", { data: rejectedView }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected an overflow failure"); + expect(result.code).toBe("upstream_overflow"); + expect(socket.closed).toBe(true); + }); + + test("fails explicitly when the preamble frame-count limit is exceeded", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + for (let index = 0; index < 33; index += 1) socket.emit("message", { data: String(index) }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected an overflow failure"); + expect(result.code).toBe("upstream_overflow"); + expect(socket.closed).toBe(true); + }); + + test("preserves an open-then-close terminal event until relay handoff", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("open", {}); + socket.emit("close", { code: 1008 }); + + const result = await pending; + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected the completed opening handshake"); + const takeover = result.handoff.take(); + expect(takeover.ok).toBe(false); + if (takeover.ok) throw new Error("expected the terminal handoff"); + expect(takeover.failure.closeCode).toBe(1008); + }); + + test("reports failure when the upstream rejects the handshake", async () => { + const socket = new FakeUpstreamSocket(); + socket.closeMode = "closing"; + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("error", {}); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + expect(result.socket).toBe(socket); + expect(socket.readyState).toBe(WebSocket.CLOSING); + }); + + test("reports failure when the upstream closes before opening", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("close", { code: 1006 }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + }); + + test("cancels a pending join and closes its upstream socket", async () => { + const socket = new FakeUpstreamSocket(); + const controller = new AbortController(); + const pending = openLiveSidebandUpstream( + "ws://upstream/v1/live/x", + {}, + () => socket as unknown as WebSocket, + 1_000, + controller.signal, + ); + controller.abort(); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a cancelled handshake"); + expect(result.code).toBe("request_cancelled"); + expect(socket.closed).toBe(true); + expect(result.socket).toBe(socket); + }); + + test("times out and drops the socket when the upstream never opens", async () => { + const socket = new FakeUpstreamSocket(); + const result = await openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 20); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a timeout"); + expect(result.status).toBe(504); + expect(socket.closed).toBe(true); + }); + + test("reports failure when the upstream socket cannot be constructed", async () => { + const result = await openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => { + throw new Error("connect refused"); + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + expect(result.socket).toBeUndefined(); + }); +}); + +test("a failed pre-upgrade handshake retains admission until its CLOSING upstream closes", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + upstream.closeMode = "closing"; + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => upstream.emit("error", {})); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_failed_handshake_closing", server.url); + wsUrl.protocol = "ws:"; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_failed_handshake_closing", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never observed failed upgrade")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("error", settle, { once: true }); + client.addEventListener("close", settle, { once: true }); + }); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + upstream.emit("close", { code: 1006, reason: "closed after handshake failure" }); + await Bun.sleep(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + upstream.emit("close", { code: 1006, reason: "duplicate close" }); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); + +test("a failed pre-upgrade handoff retains admission until its CLOSING upstream closes", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + upstream.closeMode = "closing"; + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => { + upstream.emit("open", {}); + upstream.emit("error", {}); + }); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_failed_handoff_closing", server.url); + wsUrl.protocol = "ws:"; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_failed_handoff_closing", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never observed failed handoff")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("error", settle, { once: true }); + client.addEventListener("close", settle, { once: true }); + }); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + upstream.emit("close", { code: 1008, reason: "closed after failed handoff" }); + await Bun.sleep(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + upstream.emit("close", { code: 1008, reason: "duplicate close" }); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); + +test("a sideband join whose upstream handshake fails never opens the client socket", async () => { + // An upstream that refuses the upgrade: the shape OpenAI returns for a call id it + // no longer knows (`404 call_id_not_found`). + const upstream = Bun.serve({ + port: 0, + fetch(req) { + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + return new Response(JSON.stringify({ error: { code: "call_id_not_found" } }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + saveConfig(forwardConfig()); + + const RealWebSocket = globalThis.WebSocket; + const upstreamPort = upstream.port; + globalThis.WebSocket = class extends RealWebSocket { + constructor(url: string | URL, protocols?: string | string[] | Record) { + const parsed = new URL(String(url)); + const target = parsed.hostname === "api.openai.com" + ? `ws://127.0.0.1:${upstreamPort}${parsed.pathname}${parsed.search}` + : String(url); + super(target, protocols as string[]); + } + } as typeof WebSocket; + + const server = startServer(0); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL(`/v1/realtime?call_id=rtc_dead_call`, server.url); + wsUrl.protocol = "ws:"; + const events: string[] = []; + const client = new RealWebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_dead", + }, + } as unknown as string[]); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never settled")), 15_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("open", () => { + events.push("open"); + settle(); + }); + client.addEventListener("error", () => { + events.push("error"); + settle(); + }); + client.addEventListener("close", () => { + events.push("close"); + settle(); + }); + }); + + // The relay never became live, so the client must not have been told it did. + expect(events).not.toContain("open"); + expect(events.length).toBeGreaterThan(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + globalThis.WebSocket = RealWebSocket; + await server.stop(true); + await upstream.stop(true); + } +}, { timeout: 20_000 }); + +test("an upstream that opens then closes before relay attachment refuses the client and releases admission", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => { + upstream.emit("open", {}); + upstream.emit("close", { code: 1008, reason: "call ended" }); + }); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_closed_handoff", server.url); + wsUrl.protocol = "ws:"; + const events: string[] = []; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_closed_handoff", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never settled")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("open", () => { + events.push("open"); + settle(); + }); + client.addEventListener("error", () => { + events.push("error"); + settle(); + }); + client.addEventListener("close", () => { + events.push("close"); + settle(); + }); + }); + + expect(events).not.toContain("open"); + expect(events.length).toBeGreaterThan(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); From 7dc0bf4ea6f94476a5f839d3f09e18fa4a79b011 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:10:13 +0900 Subject: [PATCH 032/224] fix: restore native collaboration namespace and child identity --- devlog/_plan/260912_v2_contracts/000_plan.md | 3 +- .../_plan/260912_v2_contracts/020_recovery.md | 4 +- .../030_native_identity.md | 13 ++++++ ...30_verification.md => 040_verification.md} | 0 src/responses/plaintext-v2-agent-messages.ts | 40 +++++++++---------- structure/subagents.md | 5 +++ .../plaintext-v2-agent-messages.test.ts | 23 ++++++++++- tests/responses/ws-upstream.test.ts | 2 +- ...plaintext-v2-agent-messages-server.test.ts | 2 +- 9 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 devlog/_plan/260912_v2_contracts/030_native_identity.md rename devlog/_plan/260912_v2_contracts/{030_verification.md => 040_verification.md} (100%) diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_plan/260912_v2_contracts/000_plan.md index 5ba4c2022d..6dc10ac825 100644 --- a/devlog/_plan/260912_v2_contracts/000_plan.md +++ b/devlog/_plan/260912_v2_contracts/000_plan.md @@ -9,7 +9,8 @@ Loop: satisfy-spec, triggered by the authorized v2 lane. Goal: scoped carry PRs | wp0 | Docs-only roadmap locked by independent design reflection and A review | this document | | wp1 | Exact plaintext request/response contract and regression coverage | [010](010_plaintext.md) | | wp2 | Bounded encrypted envelope handling and residual disposition | [020](020_recovery.md) | -| wp3 | Final cumulative hosted verification and durable handoff | [030](030_verification.md) | +| wp3 | Restore exact native collaboration dispatch identities | [030](030_native_identity.md) | +| wp4 | Final cumulative hosted verification and durable handoff | [040](040_verification.md) | wp1 and wp2 are distinct capabilities; execution order does not itself create a PR dependency. Use independent dev-based PRs if neither consumes the other's changes. A shared final cumulative verification branch may be needed to prove composition; do not silently call intermediate CI final-tip evidence. diff --git a/devlog/_plan/260912_v2_contracts/020_recovery.md b/devlog/_plan/260912_v2_contracts/020_recovery.md index 1d1495e058..7dbcb075db 100644 --- a/devlog/_plan/260912_v2_contracts/020_recovery.md +++ b/devlog/_plan/260912_v2_contracts/020_recovery.md @@ -45,4 +45,6 @@ content.splice(envelope.encryptedStartIndex, envelope.ciphertexts.length, The snapshot is request-local and not logged/persisted. JSON request parsing is the input boundary, so getters/cycles are not supported client states. Tests use the existing Request/recovery public entrypoints, not exported parser internals. -Reflection amendment: also MODIFY `src/server/responses/encrypted-payload.ts` only for the narrow multi-slot discriminator and MODIFY `tests/server/agent-task-recovery.test.ts` with `post()` integration assertions that recovery is not attempted and routed fetch is absent. Whole-token recovery tests remain at the recovery API. The discriminator runs before sanitization; no fragment text is forwarded when it matches. General malformed payload detection remains outside this claim. +Reflection amendment: also MODIFY `src/server/responses/encrypted-payload.ts` only for the narrow multi-slot discriminator and MODIFY `tests/server/agent-task-recovery.test.ts` with `post()` integration assertions that recovery is not attempted and routed fetch is absent. Whole-token recovery tests remain at the recovery API. The discriminator runs before sanitization; for otherwise unreadable envelopes, matched fragments do not reach the routed provider. General malformed payload detection remains outside this claim. + +wp2 reflection synthesis: preserve only identified fragment objects during sanitization, not an entire content array. Independent plaintext slots still normalize. Fragment refusal applies only when no independent readable task text remains, retaining current mixed-content policy; mixed input is explicitly outside the refusal claim. All encrypted slots in a recovery envelope must be valid consecutive whole tokens, including malformed non-string slots (which refuse). MODIFY `tests/server/agent-task-recovery-security.test.ts`: replace formerly unsupported duplicate-whole-token fixture with a genuinely noncontiguous encrypted run; keep fragment and admission-negative coverage, add positive multipart regression separately. diff --git a/devlog/_plan/260912_v2_contracts/030_native_identity.md b/devlog/_plan/260912_v2_contracts/030_native_identity.md new file mode 100644 index 0000000000..14fa64a80c --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/030_native_identity.md @@ -0,0 +1,13 @@ +# Native plaintext tool identity correction + +Prior D: wp2 source and static audit complete, hosted acceptance pending. Final consumer tracing found a missing identity component; split correction from final hosted verification rather than accepting helper-only mock expectations. + +MODIFY `src/responses/plaintext-v2-agent-messages.ts`: private bare and qualified aliases in calls/selectors must restore both `namespace: "collaboration"` and the unqualified declared child name. Namespace-member declarations restore only their child name, without injecting a redundant namespace field. Foreign namespaces remain untouched. Add an explicit namespace-member traversal context so declarations and selectors are not conflated. + +Before: an unqualified `start_delegated_task` becomes bare `spawn_agent`, or a qualified private name becomes `collaboration__spawn_agent`. After: a call becomes `{namespace:"collaboration",name:"spawn_agent"}`, preserving encrypted_function_args. A declaration inside restored namespace has `{type:"function",name:"spawn_agent"}`. + +MODIFY `tests/responses/plaintext-v2-agent-messages.test.ts`, `tests/server/plaintext-v2-agent-messages-server.test.ts`, `tests/responses/ws-upstream.test.ts`: pin exact namespace+child identity for bare, dotted, double-underscore, JSON, SSE and WS restoration; assert a compatible namespace/name plus empty marker selects the documented native plaintext path. Keep foreign and opaque data negatives. + +MODIFY `structure/subagents.md`: canonical dispatch identity is namespace plus unqualified child name. Source authority: locally inspected upstream `protocol/src/tool_name.rs` constructor preserves name literally; with_default_namespace assigns functions to absent namespace. `core/src/tools/router.rs` direct_source requires collaboration plus exact spawn_agent/send_message/followup_task and empty marker. This is source evidence, not a live backend canary. + +No new settings or APIs; same request alias metadata and collision gates. Product checks remain hosted-only; local tests/build/typecheck/install NOT RUN. Independent design and A review precede code; final evidence remains wp4. This amendment adds work and does not remove any original acceptance requirement. diff --git a/devlog/_plan/260912_v2_contracts/030_verification.md b/devlog/_plan/260912_v2_contracts/040_verification.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/030_verification.md rename to devlog/_plan/260912_v2_contracts/040_verification.md diff --git a/src/responses/plaintext-v2-agent-messages.ts b/src/responses/plaintext-v2-agent-messages.ts index 6863e28c1f..97ea9ae698 100644 --- a/src/responses/plaintext-v2-agent-messages.ts +++ b/src/responses/plaintext-v2-agent-messages.ts @@ -608,6 +608,7 @@ function restoreToolIdentity( value: unknown, context: RestoreContext, allowNamespaceDeclaration = false, + namespaceMember = false, ): RestoreOutcome { if (!isPlainObject(value)) return unchanged(value); if (!reserveIdentities(context, 1)) return { ...unchanged(value), overflow: true }; @@ -618,7 +619,7 @@ function restoreToolIdentity( && value.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE ) { if (value.tools !== undefined && !Array.isArray(value.tools)) return { ...unchanged(value), overflow: true }; - const children = restoreIdentityList(value.tools, context, false); + const children = restoreIdentityList(value.tools, context, false, true); if (children.overflow) return { ...unchanged(value), overflow: true }; return { value: { @@ -665,32 +666,27 @@ function restoreToolIdentity( return { ...unchanged(value), overflow: privateIdentity }; } - let restored = value; - let changed = false; - if (value.namespace === PLAINTEXT_V2_COLLABORATION_NAMESPACE) { - restored = { ...restored, namespace: COLLABORATION_NAMESPACE }; - changed = true; - } - if (allowAgentMessageAlias && PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.has(value.name as string)) { - restored = { ...restored, name: childName }; - changed = true; - } else if (typeof value.name === "string" && value.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX)) { - restored = { ...restored, name: `${COLLABORATION_NAME_PREFIX}${childName}` }; - changed = true; - } else if ( - typeof value.name === "string" - && value.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX) - ) { - restored = { ...restored, name: `${COLLABORATION_DOTTED_NAME_PREFIX}${childName}` }; - changed = true; - } - return { value: restored, changed, overflow: false }; + const privateIdentity = value.namespace === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || (typeof value.name === "string" && hasPrivateToolName(value.name)); + if (!privateIdentity) return unchanged(value); + // Codex dispatches by the namespace/name pair; qualified names are literal + // names there. Only namespace member declarations inherit their container. + return { + value: { + ...value, + name: childName, + ...(!namespaceMember || value.namespace !== undefined ? { namespace: COLLABORATION_NAMESPACE } : {}), + }, + changed: true, + overflow: false, + }; } function restoreIdentityList( values: unknown, context: RestoreContext, allowNamespaceDeclaration: boolean, + namespaceMember = false, ): RestoreOutcome { if (!Array.isArray(values)) return unchanged(values); if (values.length > context.remainingIdentities) { @@ -698,7 +694,7 @@ function restoreIdentityList( } let restored: unknown[] | undefined; for (let index = 0; index < values.length; index += 1) { - const result = restoreToolIdentity(values[index], context, allowNamespaceDeclaration); + const result = restoreToolIdentity(values[index], context, allowNamespaceDeclaration, namespaceMember); if (result.overflow) return { ...unchanged(values), overflow: true }; if (!result.changed) continue; restored ??= values.slice(); diff --git a/structure/subagents.md b/structure/subagents.md index f883b7f553..8ecb0c5742 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -218,3 +218,8 @@ argument/metadata values remain unchanged; the empty encrypted-function-args mar Startup warns that task text can remain in Codex history, selected-provider requests and local response/debug state. This is application-level plaintext over HTTPS, depends on undocumented upstream behavior, and does not decrypt existing tasks or replace authenticated recovery. + +Restored calls and selectors carry an explicit collaboration namespace and unqualified child name. +Codex treats qualified names literally and defaults absent namespaces to functions. Only child +declarations inherit their restored namespace container; the compiler never invents an empty +encryption marker when the upstream omitted it or returned a nonempty marker. diff --git a/tests/responses/plaintext-v2-agent-messages.test.ts b/tests/responses/plaintext-v2-agent-messages.test.ts index 763a405887..d5727ca654 100644 --- a/tests/responses/plaintext-v2-agent-messages.test.ts +++ b/tests/responses/plaintext-v2-agent-messages.test.ts @@ -571,7 +571,7 @@ describe("plaintext v2 agent message response restoration", () => { expect(namespaced!.encrypted_function_args).toEqual([]); expect(JSON.parse(namespaced!.arguments as string).message) .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); - expect(flattened!.name).toBe("collaboration__send_message"); + expect(flattened!.name).toBe("send_message"); expect(flattened!.encrypted_function_args).toEqual([]); expect(toolOutput!.output).toEqual({ namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE }); expect(restored.response.tool_choice.namespace).toBe("collaboration"); @@ -595,7 +595,7 @@ describe("plaintext v2 agent message response restoration", () => { restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), ) as Record; expect(restored.namespace).toBe("collaboration"); - expect(restored.name).toBe("collaboration__spawn_agent"); + expect(restored.name).toBe("spawn_agent"); expect(JSON.parse(restored.arguments as string).message) .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); expect(restored.encrypted_function_args).toEqual([]); @@ -881,3 +881,22 @@ test("namespace refinement follows every bound coordinate", () => { type: "function_call", call_id: "c1", namespace: "foreign", name: "spawn_agent", arguments: "{}", }] } }))).toThrow(PlaintextV2AgentMessageRestoreOverflowError); }); + + +test("every generated alias spelling restores the exact native dispatch pair", () => { + const names = new Set(["spawn_agent"]); + for (const name of ["start_delegated_task", `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`, `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}.start_delegated_task`]) { + for (const marker of [undefined, [], ["message"]]) { + const value = { type: "function_call", name, arguments: "{}", ...(marker === undefined ? {} : { encrypted_function_args: marker }) }; + const restored = JSON.parse(restorePlaintextV2AgentMessageCallsInJson(JSON.stringify(value), names)); + expect(restored).toMatchObject({ namespace: "collaboration", name: "spawn_agent" }); + expect(restored.encrypted_function_args).toEqual(marker); + } + } + const snapshot = JSON.parse(restorePlaintextV2AgentMessageCallsInJson(JSON.stringify({ + tools: [{ type: "namespace", name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, tools: [{ type: "function", name: "start_delegated_task", parameters: {} }] }], + tool_choice: { type: "function", name: "start_delegated_task" }, + }), names)); + expect(snapshot.tools[0].tools[0]).toEqual({ type: "function", name: "spawn_agent", parameters: {} }); + expect(snapshot.tool_choice).toEqual({ type: "function", namespace: "collaboration", name: "spawn_agent" }); +}); diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index bbafa3cfa8..706337e26f 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -498,7 +498,7 @@ describe("handleResponses Codex WS relay selection", () => { .find(line => line.includes('"response.function_call_arguments.done"'))!; const argumentDone = JSON.parse(argumentDoneLine.replace(/^data: /, "")) as Record; expect(argumentDone.namespace).toBe("collaboration"); - expect(argumentDone.name).toBe("collaboration__spawn_agent"); + expect(argumentDone.name).toBe("spawn_agent"); expect(argumentDone.encrypted_function_args).toEqual([]); const completedLine = clientText.split("\n") .find(line => line.includes('"response.completed"'))!; diff --git a/tests/server/plaintext-v2-agent-messages-server.test.ts b/tests/server/plaintext-v2-agent-messages-server.test.ts index 6eba4cd552..2d8e18755a 100644 --- a/tests/server/plaintext-v2-agent-messages-server.test.ts +++ b/tests/server/plaintext-v2-agent-messages-server.test.ts @@ -183,7 +183,7 @@ describe("plaintext v2 agent messages at the Responses server boundary", () => { expect(sentBody.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBeUndefined(); expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); expect(clientBody).toContain('"namespace":"collaboration"'); - expect(clientBody).toContain('"name":"collaboration__spawn_agent"'); + expect(clientBody).toContain('"name":"spawn_agent"'); expect(clientBody).toContain('"encrypted_function_args":[]'); }); From 536de7994f64799f7752554924044148b7b9e745 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:11:45 +0900 Subject: [PATCH 033/224] docs: record V2 implementation and review cycle outcomes --- devlog/_plan/260912_v2_contracts/000_plan.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_plan/260912_v2_contracts/000_plan.md index 6dc10ac825..1646f58064 100644 --- a/devlog/_plan/260912_v2_contracts/000_plan.md +++ b/devlog/_plan/260912_v2_contracts/000_plan.md @@ -25,3 +25,9 @@ wp0: P entered with own session binding; roadmap in progress. Product validation wp0 A: Gauss GO-WITH-FIXES (blockers=0); WP1-A01 cache ordering and WP2-A01 fragment owner folded into decade docs. Pasteur reflection ALIGNED; generic inherited-model consultation, native architect not selected. wp0 check correction: initial D was refused because the roadmap task had not yet been marked done. The subsequent P command re-entered planning; no completed cycle is claimed for that attempt. Re-audit retains the unchanged independent verdict, and a fresh docs-only B/C/D closes the actual cycle after recording its task outcome. + +wp1 D: plaintext implementation published as #4351, static review findings resolved; local tests NOT RUN and hosted proof deferred. +wp2 D: independent multipart implementation published as #4364; static security review PASS. Exact-count, multiplicity, aggregate-byte and mutation regression code added. Token-split reconstruction and live backend fidelity remain issue acceptance, not claimed solved. +wp3 D: source inspection of native Codex at 095da4b7e8b70b01afb5c6131ef926dcb8c0d85d required exact namespace/name restoration. Implementation at 7dc0bf4ea6 received independent static PASS. The earlier helper-only expectations did not establish native dispatch compatibility. Final hosted validation is wp4. + +Disposition: #4242/#4243 were rejected as-is after contract audit; #2496 is the credited adaptation source. #2495 remains open pending integration/retention approval and backend canary judgment. #3661 remains partial. The two carry PRs are independent dev-based siblings; no manual dependency chain or native stack was introduced. Public source/reference facts only are recorded here; detailed security audit material stays in ignored scratch. From 115e3475f48b249c7653e41f1ead98281d9b93eb Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:12:15 +0900 Subject: [PATCH 034/224] docs: synchronize shared V2 roadmap and review outcomes --- devlog/_plan/260912_v2_contracts/000_plan.md | 9 ++++++++- .../260912_v2_contracts/030_native_identity.md | 13 +++++++++++++ .../{030_verification.md => 040_verification.md} | 0 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260912_v2_contracts/030_native_identity.md rename devlog/_plan/260912_v2_contracts/{030_verification.md => 040_verification.md} (100%) diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_plan/260912_v2_contracts/000_plan.md index 5ba4c2022d..1646f58064 100644 --- a/devlog/_plan/260912_v2_contracts/000_plan.md +++ b/devlog/_plan/260912_v2_contracts/000_plan.md @@ -9,7 +9,8 @@ Loop: satisfy-spec, triggered by the authorized v2 lane. Goal: scoped carry PRs | wp0 | Docs-only roadmap locked by independent design reflection and A review | this document | | wp1 | Exact plaintext request/response contract and regression coverage | [010](010_plaintext.md) | | wp2 | Bounded encrypted envelope handling and residual disposition | [020](020_recovery.md) | -| wp3 | Final cumulative hosted verification and durable handoff | [030](030_verification.md) | +| wp3 | Restore exact native collaboration dispatch identities | [030](030_native_identity.md) | +| wp4 | Final cumulative hosted verification and durable handoff | [040](040_verification.md) | wp1 and wp2 are distinct capabilities; execution order does not itself create a PR dependency. Use independent dev-based PRs if neither consumes the other's changes. A shared final cumulative verification branch may be needed to prove composition; do not silently call intermediate CI final-tip evidence. @@ -24,3 +25,9 @@ wp0: P entered with own session binding; roadmap in progress. Product validation wp0 A: Gauss GO-WITH-FIXES (blockers=0); WP1-A01 cache ordering and WP2-A01 fragment owner folded into decade docs. Pasteur reflection ALIGNED; generic inherited-model consultation, native architect not selected. wp0 check correction: initial D was refused because the roadmap task had not yet been marked done. The subsequent P command re-entered planning; no completed cycle is claimed for that attempt. Re-audit retains the unchanged independent verdict, and a fresh docs-only B/C/D closes the actual cycle after recording its task outcome. + +wp1 D: plaintext implementation published as #4351, static review findings resolved; local tests NOT RUN and hosted proof deferred. +wp2 D: independent multipart implementation published as #4364; static security review PASS. Exact-count, multiplicity, aggregate-byte and mutation regression code added. Token-split reconstruction and live backend fidelity remain issue acceptance, not claimed solved. +wp3 D: source inspection of native Codex at 095da4b7e8b70b01afb5c6131ef926dcb8c0d85d required exact namespace/name restoration. Implementation at 7dc0bf4ea6 received independent static PASS. The earlier helper-only expectations did not establish native dispatch compatibility. Final hosted validation is wp4. + +Disposition: #4242/#4243 were rejected as-is after contract audit; #2496 is the credited adaptation source. #2495 remains open pending integration/retention approval and backend canary judgment. #3661 remains partial. The two carry PRs are independent dev-based siblings; no manual dependency chain or native stack was introduced. Public source/reference facts only are recorded here; detailed security audit material stays in ignored scratch. diff --git a/devlog/_plan/260912_v2_contracts/030_native_identity.md b/devlog/_plan/260912_v2_contracts/030_native_identity.md new file mode 100644 index 0000000000..14fa64a80c --- /dev/null +++ b/devlog/_plan/260912_v2_contracts/030_native_identity.md @@ -0,0 +1,13 @@ +# Native plaintext tool identity correction + +Prior D: wp2 source and static audit complete, hosted acceptance pending. Final consumer tracing found a missing identity component; split correction from final hosted verification rather than accepting helper-only mock expectations. + +MODIFY `src/responses/plaintext-v2-agent-messages.ts`: private bare and qualified aliases in calls/selectors must restore both `namespace: "collaboration"` and the unqualified declared child name. Namespace-member declarations restore only their child name, without injecting a redundant namespace field. Foreign namespaces remain untouched. Add an explicit namespace-member traversal context so declarations and selectors are not conflated. + +Before: an unqualified `start_delegated_task` becomes bare `spawn_agent`, or a qualified private name becomes `collaboration__spawn_agent`. After: a call becomes `{namespace:"collaboration",name:"spawn_agent"}`, preserving encrypted_function_args. A declaration inside restored namespace has `{type:"function",name:"spawn_agent"}`. + +MODIFY `tests/responses/plaintext-v2-agent-messages.test.ts`, `tests/server/plaintext-v2-agent-messages-server.test.ts`, `tests/responses/ws-upstream.test.ts`: pin exact namespace+child identity for bare, dotted, double-underscore, JSON, SSE and WS restoration; assert a compatible namespace/name plus empty marker selects the documented native plaintext path. Keep foreign and opaque data negatives. + +MODIFY `structure/subagents.md`: canonical dispatch identity is namespace plus unqualified child name. Source authority: locally inspected upstream `protocol/src/tool_name.rs` constructor preserves name literally; with_default_namespace assigns functions to absent namespace. `core/src/tools/router.rs` direct_source requires collaboration plus exact spawn_agent/send_message/followup_task and empty marker. This is source evidence, not a live backend canary. + +No new settings or APIs; same request alias metadata and collision gates. Product checks remain hosted-only; local tests/build/typecheck/install NOT RUN. Independent design and A review precede code; final evidence remains wp4. This amendment adds work and does not remove any original acceptance requirement. diff --git a/devlog/_plan/260912_v2_contracts/030_verification.md b/devlog/_plan/260912_v2_contracts/040_verification.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/030_verification.md rename to devlog/_plan/260912_v2_contracts/040_verification.md From 718a7ccf00e56e29da5ebf2bc573b4a7dddeed58 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:09:00 +0900 Subject: [PATCH 035/224] feat(codex): add reset-first pool ordering through canonical settings Adapt #4080 for the shared pool kernel, canonical settings API, cache-affinity policy, reset unit normalization and independent quota scopes. Co-authored-by: Terry Tan --- devlog/_plan/260912_accounts/030_reset.md | 6 + .../260912_accounts/031_reset_delivery.md | 9 ++ .../fr/reference/configuration/providers.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- gui/src/account-pool-strategy.ts | 3 +- .../AccountPoolStrategyControls.tsx | 6 +- gui/src/components/CodexAccountPool.tsx | 2 +- gui/src/components/CodexAutoSwitchSetting.tsx | 4 + .../components/CodexPoolStrategySetting.tsx | 1 + 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 + gui/tests/account-pool-strategy.test.tsx | 27 ++++- src/cli/account-extended.ts | 2 +- src/cli/account.ts | 2 +- src/codex/auth-api.ts | 14 +-- src/codex/routing.ts | 79 +++++++++++-- src/oauth/pool-kernel.ts | 9 ++ src/oauth/pool-settings-capability.ts | 4 +- src/server/management/oauth-account-routes.ts | 7 +- src/types/config.ts | 2 +- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/codex-home.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/openai-tiers.md | 8 ++ structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + .../codex-pool-rotation.test.ts | 111 ++++++++++++++++++ .../account-pool-management-api.test.ts | 32 +++++ 54 files changed, 363 insertions(+), 35 deletions(-) create mode 100644 devlog/_plan/260912_accounts/031_reset_delivery.md diff --git a/devlog/_plan/260912_accounts/030_reset.md b/devlog/_plan/260912_accounts/030_reset.md index 875a6dccc5..c2347f8782 100644 --- a/devlog/_plan/260912_accounts/030_reset.md +++ b/devlog/_plan/260912_accounts/030_reset.md @@ -14,3 +14,9 @@ Additional MODIFY `src/oauth/pool-settings-capability.ts` and `src/server/manage Field chain: CLI/GUI strategy creation → canonical PUT parser → config.accountPoolStrategy write → config load + canonical GET parser → pool rotation/preview/failover, CLI and GUI display. Audit every existing strategy comparison/default, not just the union. No schema migration or new dependency. Exact contributor diff remains `.tmp/accounts-20260912/pr4080.diff` during planning; changes are adapted to current callers before B. Extend regression sources for canonical PUT/GET/save/reload, legacy endpoint, non-Codex rejection, tied/missing/elapsed resets, threshold zero, priorities, affinity and failover. Existing #4080 test cases are retained/adapted. Update all source ownership docs; screenshot of final rendered strategy control is included with PR. Local suites/build/typecheck/install NOT RUN; final head hosted CI supplies proof. #3376 remains partial until history/capacity; monthly/Anthropic/latest-first scope is reported separately. + +P revalidation: #4080 head unchanged. Current pool-rotation.ts is a compatibility facade, so Codex parser/normalizer live in existing src/oauth/pool-kernel.ts leaf and are reexported. Canonical GET DTO and PUT parser use Codex-specific parser only for kind=codex. Use existing resetAtToMs for both seconds/milliseconds before comparing future deadlines. Existing manualPreferenceBlocks remains at promotion; reset-first affinity calls mayRebindAffinityForQuota so pool.cacheAffinity retains a healthy bound account until genuine exhaustion. Current config parser preserves accountPoolStrategy through passthrough, so canonical save/reload regression is required. User limits unchanged; previous eligibility D delivered PR4361 with hosted/render pending, reset-first remains independent. + +A1 accepted: independent spark/reserve quota scopes use the existing quota strategy consistently for initial selection, preview, affinity and alternates; shared 5h/weekly reset timestamps are not their evidence. Add private `accountPoolStrategyForScope(config, quotaScope)` in routing.ts: normalize the configured Codex strategy, then return quota when reset-first and isIndependentCodexQuotaScope(scope), otherwise the normalized strategy. Use it in pickUnboundStrategyAccount, pickAlternateCodexAccount, previewReusableAffinityAccount and reevaluateAffinityQuota. Shared promotion remains scope-guarded and uses configured normalized strategy. Config remains reset-first, DTO shows configured value and docs explain effective independent-scope fallback. Tests oppose shared reset versus usage order, include scoped cooldown and unchanged shared cursor. + +Config decision: retain existing passthrough compatibility rather than add an unrelated disk-validation policy in this carry. Canonical/legacy management writes validate through Codex parser, and all runtime consumers normalize malformed direct config values to quota as before. Explicit invalid parser/API and save/reload tests verify this boundary; no whole-config reset is introduced. diff --git a/devlog/_plan/260912_accounts/031_reset_delivery.md b/devlog/_plan/260912_accounts/031_reset_delivery.md new file mode 100644 index 0000000000..06ea2777c2 --- /dev/null +++ b/devlog/_plan/260912_accounts/031_reset_delivery.md @@ -0,0 +1,9 @@ +# Reset-first carry follows the current pool contract + +Adapts #4080 ecf6b4e48a4c2992c296fada2caf6a8132313eaa by Terry Tan. The Codex parser now lives in the existing shared kernel leaf, canonical and legacy settings round-trip the configured strategy, and the GUI offers it only on Codex. Existing runtime priority, manual preference and cache-affinity behavior is preserved. Mixed reset units are normalized before ordering; independent model quota scopes retain existing quota selection. + +Regression sources include original reset-first cases plus mixed units, cacheAffinity on/off, scoped fallback/health/shared cursor, canonical and legacy persistence, non-Codex rejection and GUI empty-response normalization. UI hints reflect current cache-affinity and scope semantics. Local tests/build/typecheck/install: NOT RUN. git diff --check is whitespace evidence only; independent source review and hosted final-tip CI/render evidence follow. + +Source search: accountPoolStrategy, normalizeAccountPoolStrategy, resetAtToMs, pool/settings, mayRebindAffinityForQuota, manualPreferenceBlocks and all strategy consumers. Existing pool-kernel and routing owners extended; no new dependency or separate pool implementation. Config passthrough behavior preserved deliberately; write routes validate through the Codex-specific parser. + +Co-authored-by: Terry Tan diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 576b13c1bb..e642700298 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -39,7 +39,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `codexAccountPriorities?` | `Record` | — | Ordre de sélection par compte pour le pool Codex : identifiant de compte → entier de `-100` à `100`, **les valeurs élevées sont prioritaires**, une valeur absente équivaut à `0`. Cette limite porte sur le classement, et non sur l'admissibilité : la sélection retient, parmi les comptes déjà admissibles, le niveau prioritaire le plus élevé qui dispose encore d'une marge de quota, puis `accountPoolStrategy` choisit un compte dans ce niveau. Un niveau est ignoré uniquement lorsque chacun de ses membres dépasse `autoSwitchThreshold`, est en temporisation, est temporairement évité, est suspendu ou doit être réauthentifié ; un quota inconnu ne suffit jamais à considérer un niveau comme épuisé. L'ordre ne rend jamais admissible un compte qui ne l'est pas et ne réaffecte jamais une tâche déjà liée à un compte. Le compte principal `__main__` participe selon les mêmes règles ; la connexion Codex Desktop peut ainsi être configurée pour être utilisée en dernier. Sans entrée, le pool se comporte exactement comme auparavant. Un mappage mal formé est ignoré avec un avertissement dans la console : l'ordre est désactivé et la configuration n'est pas réparée. Ce champ est géré par `ocx account priority` et la page Codex Auth. | | `activeCodexAccountPinned?` | `string` | — | Identifiant du compte du dernier opérateur sélectionné manuellement. Lorsqu'il est défini, un niveau `codexAccountPriorities` supérieur ne peut pas le préempter jusqu'à ce que la broche soit libérée par drainage, exclusion, suppression ou un failover/promotion explicite. Un mouvement circulaire ordinaire à l’intérieur du niveau plafonné ne le libère pas. L'écriture d'une entrée `codexAccountPriorities` libère également le pin, donc un pin créé avant qu'un ordre n'existe ne peut pas surpasser un ensemble par la suite. `GET /api/codex-auth/active` indique à la fois si le compte effectif est épinglé (`pinned`) et le compte portant le plafond (`pinnedAccountId`). | | `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. `reset-first`: Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation. Les resets mensuels ne déterminent pas cet ordre. | | `pool.cacheAffinity?` | `boolean` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc un réordonnancement, pas un verrouillage. | | `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 5523430c80..31451fa18c 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -37,7 +37,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が `autoSwitchThreshold` 超過、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | | `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index c63dfc9bc7..dae4836490 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -37,7 +37,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 `autoSwitchThreshold` 초과, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | | `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 23b7e5e92d..c7b350be0e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -52,7 +52,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is over `autoSwitchThreshold`, cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | | `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. | | `pool.cacheAffinity?` | `boolean` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, a live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — so affinity is a reordering, not a pin. | | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 9d132b0e94..d471f31c25 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -38,7 +38,7 @@ ocx models provider openrouter on | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты превысили `autoSwitchThreshold`, находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | | `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold`. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 3d72f129a7..05cb47c56e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -39,7 +39,7 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `codexAccountPriorities?` | `Record` | — | Codex havuzu için hesap başına seçim sırası: hesap kimliği → `-100` ile `100` arası tam sayı, **daha yüksek olan daha önce kullanılır**, yoksa `0` anlamına gelir. Bu bir öncelik sırası sınırıdır, bir uygunluk sınırı değildir: seçim, zaten uygun olan hesapları hala kota payı bulunan en yüksek katmana daraltır ve `accountPoolStrategy` daha sonra bu katman içinde seçim yapar. Bir katman, yalnızca her üye `autoSwitchThreshold` üzerinde olduğunda, soğumada olduğunda, yumuşak kaçınıldığında, duraklatıldığında veya yeniden kimlik doğrulama gerektiğinde atlanır — bilinmeyen kota asla bir katmanı boşaltmaz. Sıralama asla uygun olmayan bir hesabı seçilebilir yapmaz ve zaten bir hesabı olan bir iş parçacığını asla yeniden bağlamaz. Ana `__main__` hesap eşit şartlarda katılır, bu sayede Codex Desktop girişi en son tükenecek şekilde ayarlanabilir. Hiçbir girdi olmadığında havuz tam olarak eskisi gibi davranır. Hatalı biçimlendirilmiş bir harita bir konsol uyarısıyla yok sayılır (sıralama kapalı, yapılandırma onarımı yok). `ocx account priority` ve Codex Auth sayfası tarafından yönetilir. | | `activeCodexAccountPinned?` | `string` | — | Operatörün en son elle seçtiği hesap kimliği. Ayarlandığı sürece, pin tükenme, hariç tutma, silme veya açık bir yük devretme/yükseltme ile serbest bırakılana kadar daha yüksek bir `codexAccountPriorities` katmanı onu öncelikleyemez. Sınırlı katman içindeki sıradan round-robin hareketi onu serbest bırakmaz. Herhangi bir `codexAccountPriorities` girdisi yazmak da pini serbest bırakır, böylece bir sıra var olmadan önce yapılan bir pin daha sonra ayarlanan bir pinin önüne geçemez. `GET /api/codex-auth/active`, hem geçerli hesabın sabitlenip sabitlenmediğini (`pinned`) hem de tavanı taşıyan hesabı (`pinnedAccountId`) bildirir. | | `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. `reset-first`: Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır. Aylık sıfırlamalar bu sıralamayı belirlemez. | | `pool.cacheAffinity?` | `boolean` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; bağlılık bir sabitleme değil yeniden sıralamadır. | | `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 9d0ed2dd76..62e8d4447c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -37,7 +37,7 @@ ocx models provider openrouter on | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都超过 `autoSwitchThreshold`、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | | `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index ff67793990..a5ca056c18 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -35,7 +35,7 @@ ocx models provider openrouter on | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | | `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | | `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts index b2532b0fc7..ef2de0a670 100644 --- a/gui/src/account-pool-strategy.ts +++ b/gui/src/account-pool-strategy.ts @@ -1,9 +1,10 @@ -export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first"; +export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first" | "reset-first"; export const ACCOUNT_POOL_STRATEGIES: readonly AccountPoolStrategy[] = [ "quota", "round-robin", "fill-first", + "reset-first", ] as const; /** Which cached usage bar the `quota` strategy scores. Mirrors `OcxAccountPoolQuotaWindow`. */ diff --git a/gui/src/components/AccountPoolStrategyControls.tsx b/gui/src/components/AccountPoolStrategyControls.tsx index d5023ca43f..2a813bcc4e 100644 --- a/gui/src/components/AccountPoolStrategyControls.tsx +++ b/gui/src/components/AccountPoolStrategyControls.tsx @@ -8,12 +8,14 @@ import { NumberStepper } from "./NumberStepper"; import { Select } from "../ui"; const STRATEGY_LABEL_KEYS = { + "reset-first": "accountPool.strategyResetFirst", quota: "accountPool.strategyQuota", "round-robin": "accountPool.strategyRoundRobin", "fill-first": "accountPool.strategyFillFirst", } as const; const STRATEGY_HINT_KEYS = { + "reset-first": "accountPool.strategyHintResetFirst", quota: "accountPool.strategyHintQuota", "round-robin": "accountPool.strategyHintRoundRobin", "fill-first": "accountPool.strategyHintFillFirst", @@ -21,6 +23,7 @@ const STRATEGY_HINT_KEYS = { export interface AccountPoolStrategyControlsProps { strategy: AccountPoolStrategy; + codex?: boolean; stickyDraft: string; disabled?: boolean; strategySelectId?: string; @@ -41,6 +44,7 @@ export interface AccountPoolStrategyControlsProps { */ export default function AccountPoolStrategyControls({ strategy, + codex = false, stickyDraft, disabled = false, strategySelectId = "account-pool-strategy", @@ -50,7 +54,7 @@ export default function AccountPoolStrategyControls({ onStickyCommit, }: AccountPoolStrategyControlsProps) { const t = useT(); - const strategyOptions = ACCOUNT_POOL_STRATEGIES.map((value) => ({ + const strategyOptions = ACCOUNT_POOL_STRATEGIES.filter(value => codex || value !== "reset-first").map((value) => ({ value, label: t(STRATEGY_LABEL_KEYS[value]), })); diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index f211f1c689..f2503c71c6 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -63,7 +63,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban invalid: t("codexAuth.autoSwitchThresholdInvalid"), }); const [poolStrategy, setPoolStrategy] = useState< - typeof DEFAULT_ACCOUNT_POOL_STRATEGY | "round-robin" | "fill-first" | null + typeof DEFAULT_ACCOUNT_POOL_STRATEGY | "round-robin" | "fill-first" | "reset-first" | null >(null); const { beginServerRead, acceptServerRead, rejectServerRead, hydrateServerValue } = autoSwitch; // A hook cannot be called conditionally, so the fallback instance is always created diff --git a/gui/src/components/CodexAutoSwitchSetting.tsx b/gui/src/components/CodexAutoSwitchSetting.tsx index 76d10825d5..cf1bcfcdf2 100644 --- a/gui/src/components/CodexAutoSwitchSetting.tsx +++ b/gui/src/components/CodexAutoSwitchSetting.tsx @@ -7,6 +7,10 @@ import { NumberStepper } from "./NumberStepper"; export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null; const AUTO_SWITCH_DESCRIPTION_KEYS = { + "reset-first": { + on: "accountPool.strategyHintResetFirst", + off: "codexAuth.autoSwitchQuotaOffDesc", + }, quota: { on: "codexAuth.autoSwitchQuotaDesc", off: "codexAuth.autoSwitchQuotaOffDesc", diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx index e575baed0c..6e44d793ce 100644 --- a/gui/src/components/CodexPoolStrategySetting.tsx +++ b/gui/src/components/CodexPoolStrategySetting.tsx @@ -213,6 +213,7 @@ export default function CodexPoolStrategySetting({ )} {!loadError && ( = { "accountPool.strategy": "Rotationsstrategie", "accountPool.strategyDesc": "Wie OpenCodex einer neuen/ungebundenen Aufgabe ein Konto zuweist.", + "accountPool.strategyResetFirst": "Nächste Rücksetzung zuerst", + "accountPool.strategyHintResetFirst": "Unterhalb der Nutzungsschwelle wird die nächste 5-Stunden- oder Wochenrücksetzung bevorzugt. Gebundene Aufgaben folgen der konfigurierten Affinitätsregel. Unabhängige Modellkontingente werden nach Nutzung geordnet.", "accountPool.strategyQuota": "Kontingent", "accountPool.strategyRoundRobin": "Round-Robin", "accountPool.strategyFillFirst": "Fill-first", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1847a7af7e..d56980ec25 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1985,6 +1985,8 @@ export const en = { "accountPool.strategy": "Rotation strategy", "accountPool.strategyDesc": "How OpenCodex assigns an account to a new/unbound task.", + "accountPool.strategyResetFirst": "Soonest reset first", + "accountPool.strategyHintResetFirst": "Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering.", "accountPool.strategyQuota": "Quota", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "Fill-first", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e465adbb10..b7bf9bc613 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1915,6 +1915,8 @@ export const fr: Record = { "anthropicPool.off": "Désactivé", "accountPool.strategy": "Stratégie de rotation", "accountPool.strategyDesc": "Méthode utilisée par OpenCodex pour affecter un compte à une tâche nouvelle/non liée.", + "accountPool.strategyResetFirst": "Réinitialisation la plus proche", + "accountPool.strategyHintResetFirst": "Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation.", "accountPool.strategyQuota": "Quota", "accountPool.strategyRoundRobin": "Rotation", "accountPool.strategyFillFirst": "Remplissage prioritaire", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9a3d9f58b..37ce5fe420 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1842,6 +1842,8 @@ export const ja: Record = { "accountPool.strategy": "ローテーション戦略", "accountPool.strategyDesc": "OpenCodex が新規/未紐付けタスクへアカウントを割り当てる方法です。", + "accountPool.strategyResetFirst": "リセットが近い順", + "accountPool.strategyHintResetFirst": "使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。", "accountPool.strategyQuota": "クォータ", "accountPool.strategyRoundRobin": "ラウンドロビン", "accountPool.strategyFillFirst": "フィルファースト", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67ee251970..456e7b7769 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1445,6 +1445,8 @@ export const ko: Record = { "accountPool.strategy": "로테이션 전략", "accountPool.strategyDesc": "OpenCodex가 새 작업/바인딩 없는 작업에 계정을 배정하는 방식입니다.", + "accountPool.strategyResetFirst": "가장 가까운 초기화 우선", + "accountPool.strategyHintResetFirst": "사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다.", "accountPool.strategyQuota": "할당량", "accountPool.strategyRoundRobin": "라운드로빈", "accountPool.strategyFillFirst": "필 퍼스트", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56d43fc301..36d3751de2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1912,6 +1912,8 @@ export const ru: Record = { "accountPool.strategy": "Стратегия ротации", "accountPool.strategyDesc": "Как OpenCodex назначает аккаунт новой/непривязанной задаче.", + "accountPool.strategyResetFirst": "Ближайший сброс первым", + "accountPool.strategyHintResetFirst": "Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию.", "accountPool.strategyQuota": "Квота", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "Fill-first", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b627813bb9..e6576889d4 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1931,6 +1931,8 @@ export const tr: Record = { "accountPool.strategy": "Rotasyon stratejisi", "accountPool.strategyDesc": "OpenCodex'in yeni bir göreve nasıl hesap atayacağı.", + "accountPool.strategyResetFirst": "En yakın sıfırlama önce", + "accountPool.strategyHintResetFirst": "Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır.", "accountPool.strategyQuota": "Kota", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "İlk doldurma", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ce06556fd4..622de701d1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1474,6 +1474,8 @@ export const zhTW: Record = { "anthropicPool.off": "關", "accountPool.strategy": "輪換策略", "accountPool.strategyDesc": "新會話如何從帳號池中選擇帳號。", + "accountPool.strategyResetFirst": "額度即將重設優先", + "accountPool.strategyHintResetFirst": "在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。", "accountPool.strategyQuota": "配額", "accountPool.strategyRoundRobin": "輪詢", "accountPool.strategyFillFirst": "填滿優先", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ae2fdfec92..be9db90ed2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1426,6 +1426,8 @@ export const zh: Record = { "accountPool.strategy": "轮换策略", "accountPool.strategyDesc": "OpenCodex 如何为新建/未绑定任务分配账号。", + "accountPool.strategyResetFirst": "额度即将刷新优先", + "accountPool.strategyHintResetFirst": "在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。", "accountPool.strategyQuota": "配额", "accountPool.strategyRoundRobin": "轮询", "accountPool.strategyFillFirst": "填满优先", diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index 5f98969f99..1eb9ebcb5d 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -1,4 +1,4 @@ -import { putCodexPoolStrategy } from "../src/pool-settings"; +import { getPoolSettings, putPoolSettings, putCodexPoolStrategy } from "../src/pool-settings"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; @@ -91,6 +91,7 @@ describe("account pool strategy helpers", () => { expect(normalizeAccountPoolStrategy("quota")).toBe("quota"); expect(normalizeAccountPoolStrategy("round-robin")).toBe("round-robin"); expect(normalizeAccountPoolStrategy("fill-first")).toBe("fill-first"); + expect(normalizeAccountPoolStrategy("reset-first")).toBe("reset-first"); expect(normalizeAccountPoolStrategy("weighted")).toBe(DEFAULT_ACCOUNT_POOL_STRATEGY); expect(normalizeAccountPoolStrategy(undefined)).toBe("quota"); }); @@ -180,6 +181,19 @@ describe("AccountPoolStrategyControls", () => { expect(rr).toContain('value="2"'); }); + test("reset-first renders the dual-window threshold explanation", () => { + const markup = renderToStaticMarkup( + + {}} onStickyDraftChange={() => {}} onStickyCommit={() => {}} /> + , + ); + expect(markup).toContain("Soonest reset first"); + expect(markup).toContain("nearest future 5-hour or weekly reset"); + expect(markup).toContain("Bound tasks follow the configured affinity policy"); + expect(markup).not.toContain("New/unbound assignments before rotate"); + }); + test("renders a canonical setting row: visible name, control beside it, no sr-only label", () => { const markup = renderToStaticMarkup( @@ -518,3 +532,14 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { expect(select?.getAttribute("aria-label")).toBe("Rotation strategy"); }); }); + + +test("canonical reset-first settings survive a read and an empty successful write", async () => { + const read = await getPoolSettings("", "openai", async () => Response.json({ provider: "openai", kind: "codex", strategy: "reset-first", stickyLimit: 1 })); + expect(read?.strategy).toBe("reset-first"); + const written = await putPoolSettings("", "openai", { strategy: "reset-first" }, async (_url, init) => { + expect(JSON.parse(String(init?.body))).toMatchObject({ provider: "openai", strategy: "reset-first" }); + return new Response(null, { status: 204 }); + }); + expect(written?.strategy).toBe("reset-first"); +}); diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 18fca00fb7..1f22e41283 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -44,7 +44,7 @@ const EXTENDED_USAGE = `Usage: ocx account pause [--json] ocx account resume [--json] ocx account pause-exhausted [--json] - ocx account strategy [] [--json] + ocx account strategy [] [--json] ocx account sticky [<1-100>] [--json] ocx account remove --yes [--json] ocx account clear-cooldown [--json] diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..9313a15147 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -50,7 +50,7 @@ const ACCOUNT_USAGE = `Usage: ocx account pause [--json] ocx account resume [--json] ocx account pause-exhausted [--json] - ocx account strategy [] [--json] + ocx account strategy [] [--json] ocx account sticky [<1-100>] [--json] ocx account remove --yes [--json] ocx account clear-cooldown [--json] diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 09becf51ea..a6c418c600 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -58,9 +58,9 @@ import { MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, - normalizeAccountPoolStrategy, + normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, - parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, parseAccountPriority, } from "./pool-rotation"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; @@ -2457,7 +2457,7 @@ export async function handleCodexAuthAPI( pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null, autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80, upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3, - accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), }); } @@ -2488,12 +2488,12 @@ export async function handleCodexAuthAPI( return jsonResponse({ error: "strategy or stickyLimit required" }, 400); } const runtimeConfig = getRuntimeConfig(config); - let nextStrategy: NonNullable> | undefined; + let nextStrategy: NonNullable> | undefined; let nextSticky: NonNullable> | undefined; if (body.strategy !== undefined) { - const parsed = parseAccountPoolStrategy(body.strategy); + const parsed = parseCodexAccountPoolStrategy(body.strategy); if (parsed === null) { - return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400); + return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400); } nextStrategy = parsed; } @@ -2509,7 +2509,7 @@ export async function handleCodexAuthAPI( saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, - accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), }); } diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 04c5b8b1ab..1c2dd4c76c 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { POOL_KEY_CODEX, normalizeAccountPoolStickyLimit, - normalizeAccountPoolStrategy, + normalizeCodexAccountPoolStrategy, notePoolRotationFailure, notePoolRotationSuccess, peekRoundRobinAccount, @@ -1363,6 +1363,12 @@ function listEligibleCodexAccountIds( return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions); } +/** Shared reset timestamps are not evidence for independent model-quota groups. */ +function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) { + const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy); + return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy; +} + function stickyLimitForConfig(config: OcxConfig): number { return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit); } @@ -1394,6 +1400,32 @@ function hasCodexQuotaHeadroom( return usage < threshold; } +/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */ +function pickResetFirstCodexAccount( + config: OcxConfig, + ids: readonly string[], + now: number, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); + if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now); + let earliest = Number.POSITIVE_INFINITY; + let candidates: string[] = []; + for (const id of available) { + const quota = getAccountQuota(id); + const resets = [quota?.shortResetAt, quota?.weeklyResetAt] + .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset)) + .map(resetAtToMs) + .filter(reset => reset > now); + const next = Math.min(...resets); + if (next < earliest) { + earliest = next; + candidates = [id]; + } else if (next === earliest) candidates.push(id); + } + return pickLowestUsageAmong(config, candidates, selectionOptions, now); +} + /** * Fill-first: keep selectable active under threshold; otherwise advance to the next * eligible id in stable sorted order after the current active (wrapping). @@ -1484,7 +1516,7 @@ function pickUnboundStrategyAccount( commitSharedActive = commit, commitAffinity = commit, ): string | null { - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = accountPoolStrategyForScope(config, quotaScope); if (strategy === "quota") return null; const poolKey = codexPoolKeyForScope(quotaScope); @@ -1508,8 +1540,10 @@ function pickUnboundStrategyAccount( return picked; } - if (strategy === "fill-first") { - picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); + if (strategy === "fill-first" || strategy === "reset-first") { + picked = strategy === "reset-first" + ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions) + : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); if (!picked) return null; if (commitSharedActive) { if (!isIndependentCodexQuotaScope(quotaScope) @@ -1642,7 +1676,7 @@ export function pickAlternateCodexAccount( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = accountPoolStrategyForScope(config, quotaScope); // The exclusion is passed into eligibility rather than post-filtered off its // result: when the excluded account is the only healthy member of the top // tier, the tier walk must be free to descend instead of selecting that tier @@ -1655,6 +1689,9 @@ export function pickAlternateCodexAccount( const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions); } + if (strategy === "reset-first") { + return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions); + } return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions); } @@ -1751,7 +1788,7 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void { /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") { setActiveCodexAccount(config, accountId); return; } @@ -1996,9 +2033,12 @@ function previewReusableAffinityAccount( ) { return null; } + if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") { + return resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions) ?? entry.accountId; + } // Quota strategy only: non-quota strategies keep affinity for ongoing threads // (new-session-only rotation — docs / affinity policy A). - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + if (accountPoolStrategyForScope(config, quotaScope) === "quota") { const threshold = config.autoSwitchThreshold ?? 80; if (threshold > 0) { const usage = computeCodexUsageScore( @@ -2051,6 +2091,21 @@ function mayRebindAffinityForQuota( || (!isUnknownUsage(usage) && usage >= 100); } +/** Reset ordering may move a binding only under the existing cache-affinity release policy. */ +function resetFirstAffinityReplacement( + entry: ThreadAffinityEntry, + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now); + if (!mayRebindAffinityForQuota(config, entry.accountId, usage, config.autoSwitchThreshold ?? 80, selectionOptions)) return null; + const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true) + .filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); + return pickResetFirstCodexAccount(config, candidates, now, selectionOptions); +} + /** * Re-evaluate an affined account under the quota strategy. Returns a strictly * cooler replacement, or null when the current binding should remain. @@ -2062,7 +2117,13 @@ function reevaluateAffinityQuota( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null; + const strategy = accountPoolStrategyForScope(config, quotaScope); + if (strategy === "reset-first") { + const replacement = resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions); + if (replacement || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) entry.lastReevalAt = now; + return replacement; + } + if (strategy !== "quota") return null; const threshold = config.autoSwitchThreshold ?? 80; const usage = threshold > 0 ? computeCodexUsageScore( @@ -2283,7 +2344,7 @@ export function resolveCodexAccountForThreadDetailed( const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions); if (cooler) { if (!isIndependentCodexQuotaScope(quotaScope)) { - setActiveCodexAccount(config, cooler); + promoteActiveCodexAccount(config, cooler); } bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks return { status: "selected", accountId: cooler }; diff --git a/src/oauth/pool-kernel.ts b/src/oauth/pool-kernel.ts index b36ac88e03..ffd535fe60 100644 --- a/src/oauth/pool-kernel.ts +++ b/src/oauth/pool-kernel.ts @@ -41,6 +41,15 @@ export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationSt return null; } +/** Codex alone supports ordering by the next shared quota reset. */ +export function parseCodexAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | "reset-first" | null { + return raw === "reset-first" ? raw : parseAccountPoolStrategy(raw); +} + +export function normalizeCodexAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | "reset-first" { + return parseCodexAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY; +} + /** Strict parse for management APIs — returns null instead of defaulting. */ export function parseAccountPoolStickyLimit(raw: unknown): number | null { if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index 946a92d357..cf17309807 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -1,5 +1,5 @@ import { isGenericFailoverProvider } from "./generic-account-failover"; -import { parseAccountPoolStickyLimit, parseAccountPoolStrategy } from "./pool-kernel"; +import { parseAccountPoolStickyLimit, parseAccountPoolStrategy, parseCodexAccountPoolStrategy } from "./pool-kernel"; import type { OcxConfig, OcxProviderConfig } from "../types"; /** @@ -147,7 +147,7 @@ export function unifiedPoolSettingsDto( // honest answer is "not a field here" rather than a fabricated true. enabled: null, enabledEffective: true, - strategy: parseGenericPoolStrategy(config.accountPoolStrategy) ?? "quota", + strategy: parseCodexAccountPoolStrategy(config.accountPoolStrategy) ?? "quota", stickyLimit: parseGenericStickyLimit(config.accountPoolStickyLimit) ?? 1, autoSwitchThreshold: parseGenericAutoSwitchThreshold(config.autoSwitchThreshold) ?? 80, quotaWindow: null, diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 89f80e5f88..e795a3632d 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -39,6 +39,7 @@ import { normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, } from "../../codex/pool-rotation"; import { normalizeAccountPoolQuotaWindow, parseAccountPoolQuotaWindow } from "../../oauth/anthropic-routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; @@ -383,8 +384,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // sticky limit is refused identically whichever pool is addressed. let strategy: string | undefined; if (fields.strategy !== undefined) { - const parsed = parseGenericPoolStrategy(fields.strategy); - if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400); + const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) : parseGenericPoolStrategy(fields.strategy); + if (parsed === null) return jsonResponse({ error: kind === "codex" + ? "strategy must be one of: quota, round-robin, fill-first, reset-first" + : "strategy must be one of: quota, round-robin, fill-first" }, 400); strategy = parsed; } let stickyLimit: number | undefined; diff --git a/src/types/config.ts b/src/types/config.ts index acfa35f868..39c09c5b42 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -860,7 +860,7 @@ export interface OcxConfig { /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ autoSwitchThreshold?: number; /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */ - accountPoolStrategy?: OcxAccountPoolRotationStrategy; + accountPoolStrategy?: OcxAccountPoolRotationStrategy | "reset-first"; /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ accountPoolStickyLimit?: number; /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */ diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index b0fab66633..bff454695e 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -63,3 +63,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/catalog.md b/structure/catalog.md index fbb356e2d3..b7b0f443e7 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -275,3 +275,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 36511b6f00..0b441e7e08 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -82,3 +82,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/codex-home.md b/structure/codex-home.md index b11ddd3f1a..2f57624bed 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -236,3 +236,5 @@ Injection preflights affected history using the normalized config candidate befo The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/config.md b/structure/config.md index 825811d9b3..37b888e2c8 100644 --- a/structure/config.md +++ b/structure/config.md @@ -199,3 +199,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 01d0cd4b0f..a697fa66a7 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -76,3 +76,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 45fe1c11e7..9de12f5b9e 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -96,3 +96,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/design-methodology.md b/structure/design-methodology.md index 51a2f158bc..8c73a17c4c 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -39,3 +39,5 @@ surfaces, run through all 3 stages in order. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a38b7970f0..5bf31db0e0 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -534,3 +534,5 @@ advances the observation clock, so a retained older row cannot defer evaluation ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 78d0e038ec..c78644da1b 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -312,3 +312,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 21bafe5b3b..3d42434fd5 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -139,3 +139,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/overview.md b/structure/overview.md index da3f2dc473..08a57d21d5 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -106,3 +106,5 @@ would pass while the rule was violated. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a44edff557..f88b50f51b 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -402,3 +402,11 @@ successful main usage refresh clears the runtime mark. ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +## Reset-first account ordering + +`src/codex/routing.ts` supports Codex-only `accountPoolStrategy: "reset-first"`. For new shared-quota assignments it chooses the earliest future short/weekly reset after existing eligibility, priority and usage-threshold filtering; ties and absent/elapsed deadlines use the existing usage order. Seconds and milliseconds are normalized with `resetAtToMs`. Threshold zero disables usage filtering while retaining reset ordering. Monthly deadlines do not order this strategy. + +Live bindings obey the existing cache-affinity release policy: with `pool.cacheAffinity`, threshold crossing alone retains a healthy account. Manual preference, scoped health and shared-cursor guards remain authoritative. Independent `spark`/`reserve` quota scopes resolve reset-first to existing quota selection because shared reset timestamps do not describe those windows. The configured value stays unchanged. + +The Codex parser in `src/oauth/pool-kernel.ts` is reexported by the compatibility facade and used by both `/api/pool/settings` and the legacy Codex settings route. Generic and Anthropic parsers reject reset-first. The dashboard offers it only for Codex; API, CLI and translated guides preserve the same contract. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5c497d4084..05c2512fc6 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -62,3 +62,5 @@ Account-scoped OAuth quota remains display evidence for provider-level Combo sel The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/runtime.md b/structure/runtime.md index 4763842283..8e70d7916d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -216,3 +216,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/subagents.md b/structure/subagents.md index 251d3ead81..f83ede0153 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -211,3 +211,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0348c1cdd..28a1937c57 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -67,3 +67,5 @@ Quota publication distinguishes display reports from explicitly supplied inferen The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 5624a2e04e..912b6d757e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -520,3 +520,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index ae20c7c7ea..8383697d91 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -196,3 +196,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 1f7905a16f..a2005b7635 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -4,6 +4,8 @@ import { normalizeAccountPriority, notePoolRotationSuccess, parseAccountPriority, + parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, peekRoundRobinAccount, pickRoundRobinAccount, selectPriorityTier, @@ -33,6 +35,7 @@ import { import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { setAccountQuotaFromParsed } from "../../src/codex/quota"; import { getConfigPath } from "../../src/config"; import type { OcxConfig } from "../../src/types"; import { existsSync, mkdirSync, rmSync } from "node:fs"; @@ -358,6 +361,114 @@ describe("accountPoolStrategy new-session routing", () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); }); + test("reset-first is accepted only by the Codex strategy parser", () => { + expect(parseCodexAccountPoolStrategy("reset-first")).toBe("reset-first"); + expect(parseAccountPoolStrategy("reset-first")).toBeNull(); + expect(parseCodexAccountPoolStrategy("invalid")).toBeNull(); + }); + + test("reset-first compares both windows, previews without writes, and uses the same failover order", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + const seconds = now / 1000; + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: seconds + 600, shortPercent: 10, shortResetAt: seconds + 300 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 60, weeklyResetAt: seconds + 100, shortPercent: 20, shortResetAt: seconds + 500 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, weeklyResetAt: seconds + 900, shortPercent: 30, shortResetAt: seconds + 200 }); + expect(previewCodexAccountForRequest("reset-task", config, now)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread("reset-task", config, now)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); + expect(pickAlternateCodexAccount(config, "b", now)).toBe("c"); + }); + + test("reset-first compares seconds and milliseconds in the same clock", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now + 30_000 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 60 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now - 1 }); + expect(previewCodexAccountForRequest(null, config, now)).toBe("a"); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + + test("reset-first falls back to quota behavior for independent model windows", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 300 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 60, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 200 }); + expect(previewCodexAccountForRequest("independent", config, now, "spark")).toBe("a"); + expect(resolveCodexAccountForThread("independent", config, now, "spark")).toBe("a"); + expect(resolveCodexAccountForThread(null, config, now, "shared")).toBe("b"); + expect(resolveCodexAccountForThread("independent", config, now, "spark")).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + recordCodexUpstreamOutcome(config, "a", 429, { now, resetAt: now / 1000 + 100, modelId: "gpt-5.3-codex-spark" }); + expect(pickAlternateCodexAccount(config, "a", now + 1, "spark")).toBe("c"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(config.accountPoolStrategy).toBe("reset-first"); + }); + + test.each([false, true])("reset-first respects cacheAffinity=%s for bound tasks", cacheAffinity => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first", pool: { cacheAffinity } }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 30 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 60 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 90 }); + expect(resolveCodexAccountForThread("cached-reset", config, now)).toBe("a"); + setAccountQuotaFromParsed("a", { weeklyPercent: 90 }); + expect(previewCodexAccountForRequest("cached-reset", config, now + 1)).toBe(cacheAffinity ? "a" : "b"); + expect(resolveCodexAccountForThread("cached-reset", config, now + 1)).toBe(cacheAffinity ? "a" : "b"); + }); + + test("reset-first keeps affinity until either window reaches the threshold", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + const seconds = now / 1000; + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: seconds + 100 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: seconds + 200 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: seconds + 300 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("a"); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: seconds + 50 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("a"); + expect(resolveCodexAccountForThread("new", config, now)).toBe("b"); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, shortPercent: 80, shortResetAt: seconds + 10 }); + expect(previewCodexAccountForRequest("bound", config, now)).toBe("b"); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("b"); + setAccountQuotaFromParsed("b", { weeklyPercent: 80 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("c"); + }); + + test("reset-first ignores past/missing resets and breaks ties by usage", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 - 1 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, shortPercent: 10, shortResetAt: now / 1000 + 20 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("c"); + expect(resolveCodexAccountForThread(null, config, now + 20_000)).toBe("a"); + clearAccountQuota(); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + + test("reset-first preserves priority and availability and honors disabled thresholds", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 90, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 30 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("b"); + config.autoSwitchThreshold = 0; + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + config.autoSwitchThreshold = 80; + setCodexAccountPriority(config, "c", 2); + expect(resolveCodexAccountForThread(null, config, now)).toBe("c"); + expect(pickAlternateCodexAccount(config, "c", now)).toBe("b"); + setAccountQuotaFromParsed("b", { weeklyPercent: 95 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 99 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + test("round-robin strategy rotates unbound new sessions", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); updateAccountQuota("a", 10); diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index feec9a8151..5c21a4c7fc 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -667,6 +667,38 @@ describe("unified pool-settings contract (#695 wp5c)", () => { if (dir) removeTreeWithRetry(dir); }); + test("reset-first round-trips through canonical and legacy Codex settings only", async () => { + const server = startServer(0); + try { + const write = async (provider: string, strategy: string) => fetch(new URL("/api/pool/settings", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, strategy }), + }); + const result = await write("openai", "reset-first"); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ kind: "codex", strategy: "reset-first" }); + expect(loadConfig().accountPoolStrategy).toBe("reset-first"); + const canonical = await fetch(new URL("/api/pool/settings?provider=openai", server.url)); + expect(await canonical.json()).toMatchObject({ strategy: "reset-first" }); + const legacy = new Request("http://localhost/api/codex-auth/active"); + const legacyRead = await handleCodexAuthAPI(legacy, new URL(legacy.url), loadConfig()); + expect(await legacyRead!.json()).toMatchObject({ accountPoolStrategy: "reset-first" }); + for (const provider of ["anthropic", "google-antigravity"]) { + const rejected = await write(provider, "reset-first"); + expect(rejected.status).toBe(400); + await rejected.text(); + } + const compatibility = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ strategy: "reset-first" }), + }); + const compatibilityWrite = await handleCodexAuthAPI(compatibility, new URL(compatibility.url), loadConfig()); + expect(compatibilityWrite!.status).toBe(200); + expect(await compatibilityWrite!.json()).toMatchObject({ accountPoolStrategy: "reset-first" }); + expect(loadConfig().accountPoolStrategy).toBe("reset-first"); + } finally { + await server.stop(true); + } + }); + test("every kind answers with the same keys and declares what it supports", async () => { const server = startServer(0); try { From 1c8457afc231a66a2b6b959394c33b81f73d4ee1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:11:11 +0900 Subject: [PATCH 036/224] fix(codex): retain reset-first affinity when threshold is disabled --- devlog/_plan/260912_accounts/030_reset.md | 2 ++ .../_plan/260912_accounts/031_reset_delivery.md | 2 ++ src/codex/routing.ts | 4 +++- .../codex-pool-rotation.test.ts | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260912_accounts/030_reset.md b/devlog/_plan/260912_accounts/030_reset.md index c2347f8782..be34c48373 100644 --- a/devlog/_plan/260912_accounts/030_reset.md +++ b/devlog/_plan/260912_accounts/030_reset.md @@ -20,3 +20,5 @@ P revalidation: #4080 head unchanged. Current pool-rotation.ts is a compatibilit A1 accepted: independent spark/reserve quota scopes use the existing quota strategy consistently for initial selection, preview, affinity and alternates; shared 5h/weekly reset timestamps are not their evidence. Add private `accountPoolStrategyForScope(config, quotaScope)` in routing.ts: normalize the configured Codex strategy, then return quota when reset-first and isIndependentCodexQuotaScope(scope), otherwise the normalized strategy. Use it in pickUnboundStrategyAccount, pickAlternateCodexAccount, previewReusableAffinityAccount and reevaluateAffinityQuota. Shared promotion remains scope-guarded and uses configured normalized strategy. Config remains reset-first, DTO shows configured value and docs explain effective independent-scope fallback. Tests oppose shared reset versus usage order, include scoped cooldown and unchanged shared cursor. Config decision: retain existing passthrough compatibility rather than add an unrelated disk-validation policy in this carry. Canonical/legacy management writes validate through Codex parser, and all runtime consumers normalize malformed direct config values to quota as before. Explicit invalid parser/API and save/reload tests verify this boundary; no whole-config reset is introduced. + +C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending. diff --git a/devlog/_plan/260912_accounts/031_reset_delivery.md b/devlog/_plan/260912_accounts/031_reset_delivery.md index 06ea2777c2..573f0b0f12 100644 --- a/devlog/_plan/260912_accounts/031_reset_delivery.md +++ b/devlog/_plan/260912_accounts/031_reset_delivery.md @@ -7,3 +7,5 @@ Regression sources include original reset-first cases plus mixed units, cacheAff Source search: accountPoolStrategy, normalizeAccountPoolStrategy, resetAtToMs, pool/settings, mayRebindAffinityForQuota, manualPreferenceBlocks and all strategy consumers. Existing pool-kernel and routing owners extended; no new dependency or separate pool implementation. Config passthrough behavior preserved deliberately; write routes validate through the Codex-specific parser. Co-authored-by: Terry Tan + +C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending. diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 1c2dd4c76c..0c9fab2c56 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2099,8 +2099,10 @@ function resetFirstAffinityReplacement( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold <= 0) return null; const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now); - if (!mayRebindAffinityForQuota(config, entry.accountId, usage, config.autoSwitchThreshold ?? 80, selectionOptions)) return null; + if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null; const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true) .filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); return pickResetFirstCodexAccount(config, candidates, now, selectionOptions); diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index a2005b7635..d832cc5cdd 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -421,6 +421,22 @@ describe("accountPoolStrategy new-session routing", () => { expect(resolveCodexAccountForThread("cached-reset", config, now + 1)).toBe(cacheAffinity ? "a" : "b"); }); + test.each([false, true])("reset-first threshold zero retains a spent binding with cacheAffinity=%s", cacheAffinity => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first", autoSwitchThreshold: 0, pool: { cacheAffinity } }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 30 }); + expect(resolveCodexAccountForThread("zero-reset", config, now)).toBe("a"); + for (const id of ["a", "b", "c"]) setAccountQuotaFromParsed(id, { weeklyPercent: 100 }); + for (const later of [now + 1, now + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1]) { + expect(previewCodexAccountForRequest("zero-reset", config, later)).toBe("a"); + expect(resolveCodexAccountForThread("zero-reset", config, later)).toBe("a"); + } + recordCodexUpstreamOutcome(config, "a", 429, { now: now + 2, resetAt: now / 1000 + 300 }); + expect(pickAlternateCodexAccount(config, "a", now + 3)).not.toBe("a"); + }); + test("reset-first keeps affinity until either window reaches the threshold", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); const now = Date.now(); From 5c648ea329ad81c390320b87883e71417c8c948c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:12:46 +0900 Subject: [PATCH 037/224] test(codex): require a usable reset-first recovery alternate --- devlog/_plan/260912_accounts/031_reset_delivery.md | 2 ++ tests/codex-integration/codex-pool-rotation.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260912_accounts/031_reset_delivery.md b/devlog/_plan/260912_accounts/031_reset_delivery.md index 573f0b0f12..4b20ad2950 100644 --- a/devlog/_plan/260912_accounts/031_reset_delivery.md +++ b/devlog/_plan/260912_accounts/031_reset_delivery.md @@ -9,3 +9,5 @@ Source search: accountPoolStrategy, normalizeAccountPoolStrategy, resetAtToMs, p Co-authored-by: Terry Tan C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending. + +Independent C re-audit PASS at eddc8c7b08; nonblocking oracle improvement accepted: assert actual alternate b, excluding null as a false recovery result. Hosted/runtime acceptance remains pending. diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index d832cc5cdd..89909b2391 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -434,7 +434,7 @@ describe("accountPoolStrategy new-session routing", () => { expect(resolveCodexAccountForThread("zero-reset", config, later)).toBe("a"); } recordCodexUpstreamOutcome(config, "a", 429, { now: now + 2, resetAt: now / 1000 + 300 }); - expect(pickAlternateCodexAccount(config, "a", now + 3)).not.toBe("a"); + expect(pickAlternateCodexAccount(config, "a", now + 3)).toBe("b"); }); test("reset-first keeps affinity until either window reaches the threshold", () => { From d3b3b6d525ec58b44ce5561fe6feebbdad38a4dc Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:15:57 +0900 Subject: [PATCH 038/224] feat(remote): integrate opt-in workspace dashboard and admission Carry #3458 dashboard and CLI wiring with explicit Hub opt-in, session-only mutations and awaited optional cleanup. Preserve current server and documentation owners; hosted preview and final cumulative CI remain pending. Co-authored-by: Ingwannu --- .../030_integration.md | 18 + docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/remote-hub.md | 4 +- .../content/docs/guides/remote-workspace.md | 189 +++++++++ docs-site/src/content/docs/reference/cli.md | 13 + .../content/docs/reference/management-api.md | 28 ++ gui/src/App.tsx | 4 + gui/src/app-routing.ts | 2 + gui/src/i18n/de.ts | 60 +++ gui/src/i18n/en.ts | 60 +++ gui/src/i18n/fr.ts | 60 +++ gui/src/i18n/ja.ts | 60 +++ gui/src/i18n/ko.ts | 60 +++ gui/src/i18n/ru.ts | 60 +++ gui/src/i18n/tr.ts | 60 +++ gui/src/i18n/zh-TW.ts | 60 +++ gui/src/i18n/zh.ts | 60 +++ gui/src/pages/RemoteWorkspace.tsx | 381 ++++++++++++++++++ gui/src/remote-workspace-command.ts | 18 + gui/src/styles-remote-workspace.css | 75 ++++ gui/src/styles.css | 1 + gui/tests/fr-localization.test.ts | 4 + gui/tests/locale-parity.test.ts | 2 + gui/tests/remote-workspace.test.tsx | 180 +++++++++ gui/tests/sidebar-rows.test.ts | 4 +- scripts/test-layout/layout.json | 3 + .../ocx/references/01_management_surface.md | 47 ++- src/cli/capabilities.ts | 79 ++++ src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/registry.ts | 11 + src/remote-control/workspace-activation.ts | 9 + src/remote-control/workspace-sessions.ts | 5 + src/server/index.ts | 182 ++++++++- src/server/management-api.ts | 16 + src/server/management/context.ts | 15 + .../management/remote-workspace-routes.ts | 140 +++++++ src/server/management/route-registry.ts | 9 + src/server/ws-bridge.ts | 5 +- structure/INDEX.md | 2 +- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 2 + structure/manifest.json | 2 +- structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/xai-grok.md | 2 + structure/remote-workspace.md | 10 +- structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-headless-parity.test.ts | 6 + .../remote-workspace-activation.test.ts | 44 ++ .../remote-workspace-management.test.ts | 195 +++++++++ tests/clients/remote-workspace-server.test.ts | 369 +++++++++++++++++ .../clients/remote-workspace-sessions.test.ts | 17 + tests/fixtures/test-layout-expected.json | 3 + .../loopback-listener-integration.test.ts | 2 +- 66 files changed, 2660 insertions(+), 14 deletions(-) create mode 100644 docs-site/src/content/docs/guides/remote-workspace.md create mode 100644 gui/src/pages/RemoteWorkspace.tsx create mode 100644 gui/src/remote-workspace-command.ts create mode 100644 gui/src/styles-remote-workspace.css create mode 100644 gui/tests/remote-workspace.test.tsx create mode 100644 src/remote-control/workspace-activation.ts create mode 100644 src/server/management/remote-workspace-routes.ts create mode 100644 tests/clients/remote-workspace-activation.test.ts create mode 100644 tests/clients/remote-workspace-management.test.ts create mode 100644 tests/clients/remote-workspace-server.test.ts diff --git a/devlog/_plan/260912_remote_workspace_carry/030_integration.md b/devlog/_plan/260912_remote_workspace_carry/030_integration.md index 5c126f5987..ad6fcc94d3 100644 --- a/devlog/_plan/260912_remote_workspace_carry/030_integration.md +++ b/devlog/_plan/260912_remote_workspace_carry/030_integration.md @@ -67,3 +67,21 @@ Local tests/build/typecheck/install NOT RUN by user instruction. Text comparison NEW src/remote-control/workspace-activation.ts exports a side-effect-free guard requiring runtimeRole=hub AND process.env.OCX_REMOTE_WORKSPACE_ENABLED === "1". This guard imports only the config type. Pair and agent branches call it before dynamic import; disabled requests return 404. Management namespace returns a disabled status before importing runtime. Shutdown uses already retained workspace references or initialized-only lazy import only when explicitly enabled; a disabled Hub never creates identity or probes model CLIs. CLI pairing remains explicit Executor-local authorization and never modifies server environment. Document the opt-in variable and require an explicit environment choice to enable the feature. Test disabled Hub, non-Hub with flag, and enabled Hub, with no ambient inheritance in fixtures. Existing-file conflicts observed by git apply --check: management-api.ts, management/context.ts and ws-bridge.ts. Port the namespace-dispatch addition into current management handler, append only type/dependency seam fields after current imports, and extend current WebSocket discriminator/handlers without replacing newer fields. The check was text applicability only, not a product test. + +## Phase-3 revalidation + +Previous D: runtime source cycle closed at a3182185f0 after corrected whitespace receipt. Final executable/native proof remains open; Windows commands unsupported. Continue integration from that exact parent. Carry current React resource/Select/Notice/icon conventions with no dependency additions. All locales inherit original translations with the unavailable-state opt-in message added consistently. + +Server adaptation: preserve current quota-reset and Grok coupon lazy dispatch. Add remote namespace handler before normal configuration routes. It answers disabled GET status with available:false and empty collections before loading workspace runtime; mutations when disabled refuse. Pair/agent paths require explicit guard before lazy imports and existing Origin/device-token validation. WebSocket data stores only structural receive/open/close callbacks; no concrete Hub class imports in ws-bridge. Upgrade closure owns hub/device association and close cleanup. Management dependency seams use structural Pick projections of only public Hub/session operations; all are import type and erased at runtime. Runtime modules use narrow config imports from phase 2, eliminating the prior broad runtime cycle. + +Shutdown: a promise-local initialized workspace module reference is set only on actual workspace route activation; shutdown calls initialized service getters only when that reference exists. It never dynamically imports remote runtime merely because runtimeRole is hub. Management-only activation also needs lifecycle-owned shutdown registration or a retained optional shutdown callback; resolve before B and test both paths. + +NEW tests/clients/remote-workspace-activation.test.ts covers hub+flag guard, disabled management status without store writes and unauthorized principal refusal before dependency construction. Existing server tests get explicit isolated flag setup/restore; no real devices. CLI capabilities list pair/agent/status, no Hub-status automation introduced. Regenerate skills/ocx reference surface through its existing generator (documentation only). Docs state OCX_REMOTE_WORKSPACE_ENABLED=1 opt-in, default read-only sessions, Linux conditional exec and both desktop native helpers refusing commands. + +Rendering: this worktree has no node_modules or gui/node_modules. Do not install or run a local build. Prefer final hosted package artifacts for a local static render with synthetic API responses; if no artifact exists, retain rendering as unmet acceptance and attach no historical screenshot as current evidence. + +### Awaited per-server cleanup decision + +The existing optional-shutdown registry is synchronous best-effort and cannot prove awaited Remote Workspace shutdown. Reuse server.stop's existing runListenerShutdown array instead. Add a per-server retained shutdown callback and a ManagementApiDeps onRemoteWorkspaceShutdown callback setter. Workspace management resolves its already-loaded services then registers an initialized-only cleanup closure through that setter; pair/agent loader registers the same kind of closure. server.stop calls the retained callback if present. No callback means no remote import/work. Keep registration idempotent and closure references scoped to the current config/server; tests cover management-only initialization and explicit stop. Do not change the global optional-shutdown API. + +In-flight initialization refinement: management checks per-server stopping before and after module import, creates Hub/session services synchronously in one turn, then registers initialized-only teardown. Pair/upgrade paths check stopping after lazy load. SessionService rejects create/resume after shutdown even when an availability promise completes later; a regression holds availability across shutdown. This prevents request initialization from creating resources after stop. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index b25586f7a6..b76a01cdd5 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -86,6 +86,7 @@ export default defineConfig({ translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" }, items: [ { label: "Remote Hub Deployment", translations: { fr: "Déploiement Remote Hub", ko: "Remote Hub 배포", "zh-CN": "Remote Hub 部署", "zh-TW": "Remote Hub 部署", ru: "Развёртывание Remote Hub", ja: "Remote Hub のデプロイ", tr: "Remote Hub Dağıtımı" }, slug: "guides/remote-hub" }, + { label: "Remote Workspace", translations: { fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, { label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" }, { label: "Cursor Private Inference", translations: { ko: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" }, diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..bedd3e73a4 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -13,7 +13,9 @@ the hub's own processes dial `127.0.0.1:` with no credential, thr companion listener. Start from [the recipe below](#linux-systemd-or-macos-launchd), then hand a second machine a ready-made command with [`ocx hub invite`](#inviting-another-machine). -The management ingress never serves `/v1/*`, `/healthz`, `/readyz`, or WebSockets. Do not publish its +The management ingress never serves `/v1/*`, `/healthz`, or `/readyz`. When explicitly enabled, +Remote Workspace admits only its paired bearer-authenticated agent WebSocket and one-time pairing +exchange; see [Remote Workspace](/guides/remote-workspace/). Do not publish its port directly, do not add a cloud-firewall rule for it, and do not use Tailscale Funnel. Funnel is a public-internet surface and is outside this deployment model. diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md new file mode 100644 index 0000000000..2d5ffb814d --- /dev/null +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -0,0 +1,189 @@ +--- +title: Remote Workspace +description: Keep Codex, Claude Code, Pi, and their logins on one OCX Hub while OCX-only computers provide the workspace and build environment. +--- + +Remote Workspace lets one OpenCodex Hub run your coding agents while another computer supplies the +project files, commands, tests, and build compute. A phone or third computer can control the session +through the Hub dashboard. + +```text +Phone browser -> Computer 1 OCX Hub -> encrypted channel -> Computer 2 OCX Executor + Codex / Claude / Pi project and commands + logins and sessions no coding CLI login +``` + +The Executor needs OpenCodex only. It does not need Codex, Claude Code, Pi, a ChatGPT login, or a +provider API key. It opens an outbound WebSocket to the Hub, so the Executor needs no public port or +router port-forward. + +:::caution[Experimental foundation] +Remote Workspace is opt-in and not a production rollout. Linux offers file tools and conditional +bubblewrap command execution. Windows and macOS offer file tools only: their official native +helpers reject probe and command requests. Windows commands remain unsupported until a verified +lifecycle owner can retain cleanup authority through cancellation. Missing command support never +falls back to executing on the Hub. +::: + +## Set up the Hub + +Computer 1 owns every coding-agent login and model session. Install and log in to whichever agents +you want to use there, then run OpenCodex as a Hub: + +```bash +ocx config set runtimeRole hub +OCX_REMOTE_WORKSPACE_ENABLED=1 ocx start +ocx gui +``` + +Set `OCX_REMOTE_WORKSPACE_ENABLED=1` on the Hub process itself; setting it only for a dashboard +command does not enable an already-running service. A Hub with no explicit opt-in returns disabled +status without creating workspace keys or probing coding-agent runtimes. + +Use an authenticated HTTPS deployment when opening the dashboard from a phone or another computer. +See [Remote Hub Deployment](/guides/remote-hub/) for the supported management-ingress and Tailscale +pattern. Do not publish an unauthenticated local dashboard port. + +Codex Remote Workspace uses current App Server permission profiles. If the Hub's selected Codex +configuration still sets legacy `sandbox_mode` or `sandbox_workspace_write`, the dashboard reports +Codex as unavailable instead of starting with a weaker boundary. Migrate that Codex profile before +using the feature; do not configure both the legacy sandbox and a permission profile. + +## Pair an Executor + +1. Open **Remote Workspace** in the Hub dashboard. +2. Select **Create pairing code**. +3. On Computer 2, change into the project directory you want to expose. +4. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer. + It pairs the current directory and keeps + `ocx remote-workspace agent` connected in that terminal. + +The equivalent manual flow is: + +```bash +cd /path/to/project +printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \ + --pairing-code-stdin --root "$PWD" +ocx remote-workspace agent +``` + +On Windows PowerShell, use the command shown in the dashboard. The equivalent manual form is: + +```powershell +$pairingCode = 'ONE-TIME-CODE' +$pairingCode | ocx remote-workspace pair 'https://your-hub.example' ` + --pairing-code-stdin --root (Get-Location).Path +if ($LASTEXITCODE -eq 0) { ocx remote-workspace agent } +``` + +The current OCX Bun executable is added as one read-only file to the Linux sandbox automatically. If +the project needs a user-installed toolchain outside the system paths, pair it explicitly without +exposing the rest of the home directory: + +```bash +printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \ + --pairing-code-stdin --root "$PWD" \ + --toolchain-root "$HOME/.nvm/versions/node/v24/bin" +``` + +The native helper source is packaged for review. Building it does not enable Windows or macOS +commands in this carry. `--executor-helper` remains a reviewed-helper selector; binary existence +or a configured path does not prove command support. + +The one-time code is read from standard input, not command-line arguments. Pairing creates a local +device signing key and a device-scoped bearer. The Hub stores only its hash and never receives the +real Executor path. Stop the foreground agent with Ctrl+C; running it again reconnects the same +device. + +Check local enrollment without printing secrets: + +```bash +ocx remote-workspace status +``` + +## Start a remote coding session + +In the dashboard choose: + +1. the online computer; +2. one locally approved workspace folder; +3. Codex, Claude Code, or Pi from the Hub; and +4. an access mode. + +**Read only** is the default and exposes directory listing and file reading. The write option is +shown as **Edit files and run commands** only when that Executor passed a command-sandbox probe; +otherwise it is shown as **Edit files only**. The dashboard shows two separate locations so it is +clear that the model and login remain on the Hub while workspace operations run on the selected +computer. + +Send prompts from the Hub dashboard on Computer 1, Computer 3, or a phone. The session cannot switch +to another computer or folder silently. If the Executor disconnects, the session enters +**Executor offline** and never falls back to the Hub's filesystem. + +**Stop** remains available while a prompt is running. It interrupts the Hub coding-agent turn, +cancels an active Executor command, and prevents a late response from reopening the stopped +session. + +## Restart and reconnect behavior + +The Hub persists bounded session metadata and a small recent event snapshot. After a Hub restart, +an unfinished session waits for its original Executor. Once that device reconnects, the next prompt +resumes the original Codex thread, Claude Code session, or Pi session ID. + +Claude Code creates its durable history on the first completed prompt. If the Hub stops before a +new Claude session has completed any prompt, there is no conversation to resume; start a new +session instead. + +A changed capability manifest does not silently weaken an existing session. Start a new session if +the Executor loses command containment or its available tools change. Revoking a computer closes its +socket and stops sessions bound to it. + +## Security boundaries + +- Provider credentials and coding-agent history remain on the Hub. +- Executor private keys, device bearer, and real root paths remain in its owner-only OCX state. +- Pairing-code failures are limited per kernel-observed peer on every listener. Ten failed codes in + ten minutes return a generic `429` with `Retry-After`; the Hub retains only bounded, expiring + hashes of those source identities. Tailscale Serve users share the management listener's loopback + bucket because a direct local caller could forge its identity header. +- Each work session uses an Ed25519-signed ephemeral P-256 ECDH handshake and ordered + AES-256-GCM messages. +- A socket is not shown as online until both sides agree on its current capability manifest. +- Reconnection may remove a capability when its local sandbox is unavailable, but never adds a + capability outside the grant recorded at pairing. +- Every request is bound to one model thread, device, root, access mode, and capability set. +- Paths are relative, canonicalized, bounded, and rejected on symlink, junction, or parent-directory + escape. Windows device names, alternate data streams, and trailing-dot/space aliases are denied. +- Executor operations are serialized, opened file identities are rechecked, and write hashes are + checked again immediately before atomic replacement. Replacing an approved root requires pairing + it again, and toolchain roots are revalidated before each command. +- File reads/writes reject hard-linked files. Before command execution, OCX scans at most 250,000 + workspace entries and disables the command path if any non-directory entry has multiple links; + path sandboxes cannot prove whether the other name for that inode is outside the approved root. +- Linux commands run through bubblewrap with one writable workspace, cleared environment, private + process namespaces, the current OCX Bun executable as one read-only file, bounded output + and timeout, and network disabled by default. Dedicated confinement tests require an explicitly + configured hosted environment; a green generic suite does not prove they ran. +- macOS advertises file tools only. A process group cannot contain a descendant after it calls + `setsid()`, and importing a broad Apple Seatbelt system profile merely to start a command would + expose unrelated host-service authority. The native helper therefore rejects both its probe and + direct command requests until OCX has a narrow, revocable descendant-containment owner. +- Windows and macOS native command requests fail closed. Their direct-helper refusal tests must be + distinguished from functioning command-confinement evidence; Windows command acceptance is open. +- The pinned native helper must be outside every approved writable workspace. OCX checks this both + before advertising command support and immediately before each command, so workspace code cannot + replace the binary that enforces its next sandbox. +- Stopping a session cancels an active Executor command and cleans up the Hub model process and + loopback tool bridge. Windows stops the owned npm-wrapper process tree rather than leaving its + Node child behind; Linux and macOS force-stop a CLI only if it ignores the graceful stop window. + +The Hub intentionally sees prompts and model output because it runs the coding agent. End-to-end +encryption protects Executor RPC payloads. The paired Hub is trusted to select approved roots over +authenticated WSS; it is not blind to its own model conversation. + +## Current scope + +Remote Workspace does not copy or synchronize credentials to other computers. It is separate from +Remote Hub provider routing and from any future hosted compute or Super Sync product. A production +release still requires signed Windows helper packaging, native CI proof on the exact binaries, +independent maintainer review, and a real three-computer acceptance run. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index e39d0cc018..d91e6c1865 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -18,6 +18,19 @@ opencodex state. `ocx alias list [--json]` shows effective user and built-in aliases. Use `ocx alias set [/] ` and `ocx alias rm [/]` to edit them. Native model ids may contain additional slashes because the selector splits only at the first slash. Enable shipped defaults with `ocx alias defaults on|off [--provider ]`. +### `ocx remote-workspace` + +`ocx remote-workspace pair --pairing-code-stdin --root ` enrolls the local +computer as an OCX-only Executor. Repeat `--root` to approve more folders and use `--name` to +override the hostname. Repeat `--toolchain-root ` to expose a user-installed +Node, Rust, Go, or other toolchain directory read-only inside the command sandbox. On macOS and +Windows private-dogfood builds, `bun run build:remote-workspace-helper` creates the Rust helper that +the pair command discovers automatically; `--executor-helper ` selects another +explicitly reviewed build and pins its digest in local Executor state. +`ocx remote-workspace agent` maintains the outbound encrypted connection; +`ocx remote-workspace status [--json]` reports the Hub, device, roots, and advertised capabilities +without printing its bearer or private key. See [Remote Workspace](/guides/remote-workspace/). + - [Lifecycle](/reference/cli/lifecycle/) — setup, proxy and service lifecycle, health, diagnostics, catalog sync, the dashboard, and updates. - [Providers, accounts, and models](/reference/cli/providers-accounts/) — provider configuration, diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index cd8b2450c2..75a0f89fb2 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -145,6 +145,34 @@ should use the dedicated paths above so an older proxy cannot ignore a profile s See [Aside profile controls](/guides/integrations/#aside-profile-controls) for CLI commands and the proxy upgrade, restart, and retry sequence. +### Remote Workspace + +Requires Hub mode and `OCX_REMOTE_WORKSPACE_ENABLED=1` on the Hub process. Disabled status is +readable; mutations refuse without initializing workspace services. + +| Method and path | Purpose | Notable errors | +| --- | --- | --- | +| `GET /api/remote-workspace` | Read paired computers, current capabilities, Hub runtimes, and session snapshots | Disabled status when Hub role or explicit opt-in is absent | +| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | GUI session only; 429 pairing capacity | +| `GET /api/remote-workspace/runtimes` | Read Codex, Claude Code, and Pi availability on the Hub | — | +| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST is GUI session only; 409 offline/unavailable/invalid target | +| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | GUI session only; 409 active turn, offline Executor, or resume failure | +| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | GUI session only; 404 unknown session | +| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | GUI session only; 404 unknown device | + +Executor enrollment exchanges a one-use code at `POST /remote-workspace/pair` and then opens +`/remote-workspace/agent` as a bearer-authenticated outbound WebSocket. Those two machine endpoints +are not general management API authority. The bearer is device-scoped, and each work session adds a +signed E2EE handshake. Ten failed pairing codes from one kernel-observed peer return `429` with +`Retry-After` for the remainder of the fixed ten-minute window. Tailscale Serve clients share the +management listener's loopback peer bucket; the identity header is not used for throttling because +a direct local process could forge it. See [Remote Workspace](/guides/remote-workspace/) for the +end-user flow and trust boundaries. + +Session snapshots include `resumable`. It becomes true only after the selected coding-agent runtime +has durable history; notably, a new Claude Code session remains false until its first prompt +completes. + ### Combos | Method and path | Purpose | Notable errors | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 91890ce664..95b175711f 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -10,6 +10,7 @@ import Storage from "./pages/Storage"; import CodexSet from "./pages/CodexSet"; import Integrations from "./pages/Integrations"; import Startup from "./pages/Startup"; +import RemoteWorkspace from "./pages/RemoteWorkspace"; import ErrorBoundary from "./components/ErrorBoundary"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; @@ -35,6 +36,7 @@ const PAGE_TKEY: Record = { logs: "nav.logs", usage: "nav.usage", storage: "nav.storage", + remote: "nav.remote", "codex-set": "nav.codexSet", integrations: "nav.integrations", }; @@ -68,6 +70,7 @@ const NAV: NavEntry[] = [ { id: "logs", tkey: "nav.logs", Icon: IconList }, { id: "usage", tkey: "nav.usage", Icon: IconActivity }, { id: "storage", tkey: "nav.storage", Icon: IconHardDrive }, + { id: "remote", tkey: "nav.remote", Icon: IconMonitor }, { id: "integrations", tkey: "nav.integrations", Icon: IconGlobe }, ]; @@ -432,6 +435,7 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } + {page === "remote" && } {page === "codex-set" && } {page === "integrations" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index cf9762ab71..5d8588358b 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -11,6 +11,7 @@ export type Page = | "logs" | "usage" | "storage" + | "remote" | "codex-set" | "integrations"; @@ -23,6 +24,7 @@ export const VALID_PAGES = new Set([ "logs", "usage", "storage", + "remote", "codex-set", "integrations", ]); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 868e2a2855..5fc7be9304 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2705,4 +2705,64 @@ export const de: Record = { "models.pickerOrder.saveDraft": "Entwurf speichern", "models.pickerOrder.reloadDraft": "Neu laden und Entwurf verwerfen", "models.pickerOrder.catalogRequired": "Modellidentitäten fehlen oder sind mehrdeutig. Laden Sie die Modellseite neu, um den Katalog vor der Bearbeitung zu aktualisieren.", + "nav.remote": "Remote-Arbeitsbereich", + "remote.title": "Remote-Arbeitsbereich", + "remote.subtitle": "Codex, Claude Code oder Pi laufen auf diesem Hub; Dateien, Befehle, Tests und Builds bleiben auf dem ausgewählten Computer.", + "remote.loading": "Remote-Arbeitsbereich wird geladen…", + "remote.loadFailed": "Remote-Arbeitsbereich konnte nicht geladen werden.", + "remote.hubRequired": "Starten Sie den Hub im Hub-Modus mit OCX_REMOTE_WORKSPACE_ENABLED=1, um Remote Workspace zu aktivieren.", + "remote.refresh": "Aktualisieren", + "remote.addComputer": "Computer hinzufügen", + "remote.addComputerHint": "Gib lokal Ordner frei und halte den reinen OCX-Executor mit diesem Hub verbunden.", + "remote.createPairing": "Kopplungscode erstellen", + "remote.pairingCode": "Einmaliger Kopplungscode", + "remote.pairingExpires": "Läuft um {time} ab", + "remote.pairingCommand": "Auf dem hinzuzufügenden Computer ausführen", + "remote.pairingCommandPosix": "Linux- / macOS-Terminal", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Befehl kopieren", + "remote.copied": "Kopiert", + "remote.devices": "Computer", + "remote.noDevices": "Noch keine Computer gekoppelt.", + "remote.online": "Online", + "remote.offline": "Offline", + "remote.revoke": "Computer widerrufen", + "remote.revokeConfirm": "{name} widerrufen? Aktive Sitzungen auf diesem Computer werden beendet.", + "remote.newSession": "Neue Remote-Sitzung", + "remote.device": "Computer", + "remote.folder": "Arbeitsordner", + "remote.runtime": "Coding-Agent", + "remote.access": "Workspace-Zugriff", + "remote.access.readOnly": "Nur lesen", + "remote.access.workspace": "Dateien bearbeiten und Befehle ausführen", + "remote.access.workspaceFilesOnly": "Nur Dateien bearbeiten", + "remote.unavailable": "Nicht verfügbar", + "remote.capability.full": "Dateien + isolierte Befehle", + "remote.capability.files": "Nur Dateiwerkzeuge", + "remote.runsOnHub": "Modell und Anmeldung bleiben auf diesem Hub", + "remote.runsReadOnly": "Dateien können auf diesem Computer nur gelesen werden", + "remote.runsFilesCommands": "Dateien, Builds und Befehle laufen hier", + "remote.runsFilesOnly": "Dateiwerkzeuge laufen hier; Befehls-Sandbox nicht verfügbar", + "remote.execUnavailable": "Dieser Computer kann Dateien bearbeiten, aber Builds und Terminalbefehle sind ohne unterstützte Betriebssystem-Sandbox deaktiviert.", + "remote.notResumable": "Diese Sitzung wurde beendet, bevor der Coding-Agent einen dauerhaften Verlauf erstellt hat. Starten Sie eine neue Remote-Sitzung.", + "remote.startSession": "Remote-Sitzung starten", + "remote.sessionStarted": "Remote-Sitzung ist bereit.", + "remote.sessions": "Sitzungen", + "remote.noSessions": "Wähle einen Online-Computer, Ordner und Coding-Agenten.", + "remote.events": "Aktivität der Remote-Sitzung", + "remote.noEvents": "Noch keine Aktivität.", + "remote.prompt": "Nachricht", + "remote.promptPlaceholder": "Bitte den Hub-Agenten, im ausgewählten Remote-Ordner zu arbeiten…", + "remote.send": "Senden", + "remote.stop": "Sitzung stoppen", + "remote.requestFailed": "Remote-Workspace-Anfrage fehlgeschlagen.", + "remote.status.starting": "Startet", + "remote.status.ready": "Bereit", + "remote.status.running": "Läuft", + "remote.status.waiting": "Executor offline", + "remote.status.failed": "Fehlgeschlagen", + "remote.status.stopped": "Gestoppt", + "remote.event.status": "Status", + "remote.event.tool": "Remote-Werkzeug", + "remote.event.error": "Fehler", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index dfa9ad90e9..558e3cd7b2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2739,6 +2739,66 @@ export const en = { "models.pickerOrder.saveDraft": "Save draft", "models.pickerOrder.reloadDraft": "Reload and discard draft", "models.pickerOrder.catalogRequired": "Model identities are missing or ambiguous. Reload the Models page to refresh its catalog before editing Custom.", + "nav.remote": "Remote Workspace", + "remote.title": "Remote Workspace", + "remote.subtitle": "Run Codex, Claude Code, or Pi from this Hub while files, commands, tests, and builds stay on the computer you select.", + "remote.loading": "Loading Remote Workspace…", + "remote.loadFailed": "Could not load Remote Workspace.", + "remote.hubRequired": "Use Hub mode and start the Hub with OCX_REMOTE_WORKSPACE_ENABLED=1 to enable Remote Workspace.", + "remote.refresh": "Refresh", + "remote.addComputer": "Add a computer", + "remote.addComputerHint": "Approve one or more folders locally, then keep the OCX-only executor connected to this Hub.", + "remote.createPairing": "Create pairing code", + "remote.pairingCode": "One-time pairing code", + "remote.pairingExpires": "Expires at {time}", + "remote.pairingCommand": "Run on the computer you are adding", + "remote.pairingCommandPosix": "Linux / macOS terminal", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Copy command", + "remote.copied": "Copied", + "remote.devices": "Computers", + "remote.noDevices": "No computers are paired yet.", + "remote.online": "Online", + "remote.offline": "Offline", + "remote.revoke": "Revoke computer", + "remote.revokeConfirm": "Revoke {name}? Active sessions on this computer will stop.", + "remote.newSession": "New remote session", + "remote.device": "Computer", + "remote.folder": "Workspace folder", + "remote.runtime": "Coding agent", + "remote.access": "Workspace access", + "remote.access.readOnly": "Read only", + "remote.access.workspace": "Edit files and run commands", + "remote.access.workspaceFilesOnly": "Edit files only", + "remote.unavailable": "Unavailable", + "remote.capability.full": "Files + sandboxed commands", + "remote.capability.files": "File tools only", + "remote.runsOnHub": "Model and login stay on this Hub", + "remote.runsReadOnly": "Files can only be read on this computer", + "remote.runsFilesCommands": "Files, builds, and commands run here", + "remote.runsFilesOnly": "File tools run here; command sandbox unavailable", + "remote.execUnavailable": "This computer can edit files, but builds and terminal commands are disabled because a supported OS sandbox is not available.", + "remote.notResumable": "This session stopped before the coding agent created durable history. Start a new remote session.", + "remote.startSession": "Start remote session", + "remote.sessionStarted": "Remote session is ready.", + "remote.sessions": "Sessions", + "remote.noSessions": "Choose an online computer, folder, and coding agent to start.", + "remote.events": "Remote session activity", + "remote.noEvents": "No activity yet.", + "remote.prompt": "Message", + "remote.promptPlaceholder": "Ask the Hub agent to work inside the selected remote folder…", + "remote.send": "Send", + "remote.stop": "Stop session", + "remote.requestFailed": "Remote Workspace request failed.", + "remote.status.starting": "Starting", + "remote.status.ready": "Ready", + "remote.status.running": "Running", + "remote.status.waiting": "Executor offline", + "remote.status.failed": "Failed", + "remote.status.stopped": "Stopped", + "remote.event.status": "Status", + "remote.event.tool": "Remote tool", + "remote.event.error": "Error", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2cf33a7a96..849dfb4e50 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2693,4 +2693,64 @@ export const fr: Record = { "models.pickerOrder.saveDraft": "Enregistrer le brouillon", "models.pickerOrder.reloadDraft": "Recharger et supprimer le brouillon", "models.pickerOrder.catalogRequired": "Les identités des modèles sont manquantes ou ambiguës. Rechargez la page Modèles pour actualiser le catalogue avant de personnaliser l’ordre.", + "nav.remote": "Espace distant", + "remote.title": "Espace de travail distant", + "remote.subtitle": "Codex, Claude Code ou Pi s'exécutent sur ce Hub tandis que fichiers, commandes, tests et builds restent sur l'ordinateur choisi.", + "remote.loading": "Chargement de l'espace distant…", + "remote.loadFailed": "Impossible de charger l'espace distant.", + "remote.hubRequired": "Démarrez le Hub en mode Hub avec OCX_REMOTE_WORKSPACE_ENABLED=1 pour activer Remote Workspace.", + "remote.refresh": "Actualiser", + "remote.addComputer": "Ajouter un ordinateur", + "remote.addComputerHint": "Autorisez localement un ou plusieurs dossiers, puis gardez l'exécuteur OCX connecté à ce Hub.", + "remote.createPairing": "Créer un code d'association", + "remote.pairingCode": "Code d'association à usage unique", + "remote.pairingExpires": "Expire à {time}", + "remote.pairingCommand": "À exécuter sur l'ordinateur à ajouter", + "remote.pairingCommandPosix": "Terminal Linux / macOS", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Copier la commande", + "remote.copied": "Copié", + "remote.devices": "Ordinateurs", + "remote.noDevices": "Aucun ordinateur associé.", + "remote.online": "En ligne", + "remote.offline": "Hors ligne", + "remote.revoke": "Révoquer l'ordinateur", + "remote.revokeConfirm": "Révoquer {name} ? Ses sessions actives seront arrêtées.", + "remote.newSession": "Nouvelle session distante", + "remote.device": "Ordinateur", + "remote.folder": "Dossier de travail", + "remote.runtime": "Agent de code", + "remote.access": "Accès à l’espace de travail", + "remote.access.readOnly": "Lecture seule", + "remote.access.workspace": "Modifier les fichiers et exécuter des commandes", + "remote.access.workspaceFilesOnly": "Modifier uniquement les fichiers", + "remote.unavailable": "Indisponible", + "remote.capability.full": "Fichiers + commandes isolées", + "remote.capability.files": "Outils de fichiers uniquement", + "remote.runsOnHub": "Le modèle et la connexion restent sur ce Hub", + "remote.runsReadOnly": "Les fichiers de cet ordinateur sont accessibles en lecture seule", + "remote.runsFilesCommands": "Les fichiers, builds et commandes s’exécutent ici", + "remote.runsFilesOnly": "Les outils de fichiers s’exécutent ici ; bac à sable indisponible", + "remote.execUnavailable": "Cet ordinateur peut modifier les fichiers, mais les builds et commandes de terminal sont désactivés faute de bac à sable système pris en charge.", + "remote.notResumable": "Cette session s’est arrêtée avant que l’agent de code ne crée un historique durable. Démarrez une nouvelle session distante.", + "remote.startSession": "Démarrer la session distante", + "remote.sessionStarted": "La session distante est prête.", + "remote.sessions": "Sessions", + "remote.noSessions": "Choisissez un ordinateur en ligne, un dossier et un agent de code.", + "remote.events": "Activité de la session distante", + "remote.noEvents": "Aucune activité pour le moment.", + "remote.prompt": "Message", + "remote.promptPlaceholder": "Demandez à l'agent du Hub de travailler dans le dossier distant choisi…", + "remote.send": "Envoyer", + "remote.stop": "Arrêter la session", + "remote.requestFailed": "La requête d'espace distant a échoué.", + "remote.status.starting": "Démarrage", + "remote.status.ready": "Prêt", + "remote.status.running": "En cours", + "remote.status.waiting": "Exécuteur hors ligne", + "remote.status.failed": "Échec", + "remote.status.stopped": "Arrêté", + "remote.event.status": "État", + "remote.event.tool": "Outil distant", + "remote.event.error": "Erreur", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0f51bbb2d9..6d3c3a4666 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2726,4 +2726,64 @@ export const ja: Record = { "models.pickerOrder.saveDraft": "下書きを保存", "models.pickerOrder.reloadDraft": "下書きを破棄して再読み込み", "models.pickerOrder.catalogRequired": "モデルの識別情報が不足しているか曖昧です。モデルページを再読み込みしてカタログを更新してからカスタム順序を編集してください。", + "nav.remote": "リモートワークスペース", + "remote.title": "リモートワークスペース", + "remote.subtitle": "Codex、Claude Code、Pi はこの Hub で実行し、ファイル、コマンド、テスト、ビルドは選択したコンピューターで処理します。", + "remote.loading": "リモートワークスペースを読み込み中…", + "remote.loadFailed": "リモートワークスペースを読み込めませんでした。", + "remote.hubRequired": "Hub モードで OCX_REMOTE_WORKSPACE_ENABLED=1 を設定して Hub を起動すると、Remote Workspace を有効にできます。", + "remote.refresh": "更新", + "remote.addComputer": "コンピューターを追加", + "remote.addComputerHint": "ローカルでフォルダーを承認し、OCX 専用エグゼキューターをこの Hub に接続したままにします。", + "remote.createPairing": "ペアリングコードを作成", + "remote.pairingCode": "ワンタイムペアリングコード", + "remote.pairingExpires": "{time} に期限切れ", + "remote.pairingCommand": "追加するコンピューターで実行", + "remote.pairingCommandPosix": "Linux / macOS ターミナル", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "コマンドをコピー", + "remote.copied": "コピー済み", + "remote.devices": "コンピューター", + "remote.noDevices": "ペアリング済みのコンピューターはありません。", + "remote.online": "オンライン", + "remote.offline": "オフライン", + "remote.revoke": "コンピューターを解除", + "remote.revokeConfirm": "{name} を解除しますか?このコンピューターの実行中セッションは停止します。", + "remote.newSession": "新しいリモートセッション", + "remote.device": "コンピューター", + "remote.folder": "ワークスペースフォルダー", + "remote.runtime": "コーディングエージェント", + "remote.access": "ワークスペース権限", + "remote.access.readOnly": "読み取り専用", + "remote.access.workspace": "ファイル編集とコマンド実行", + "remote.access.workspaceFilesOnly": "ファイル編集のみ", + "remote.unavailable": "利用不可", + "remote.capability.full": "ファイル + 分離されたコマンド", + "remote.capability.files": "ファイルツールのみ", + "remote.runsOnHub": "モデルとログインはこの Hub に保持", + "remote.runsReadOnly": "このコンピューターのファイルは読み取りのみ", + "remote.runsFilesCommands": "ファイル、ビルド、コマンドはここで実行", + "remote.runsFilesOnly": "ファイルツールのみここで実行、コマンド分離は未対応", + "remote.execUnavailable": "このコンピューターではファイル編集はできますが、対応する OS サンドボックスがないためビルドとターミナルコマンドは無効です。", + "remote.notResumable": "コーディングエージェントが永続的な履歴を作成する前にセッションが停止しました。新しいリモートセッションを開始してください。", + "remote.startSession": "リモートセッションを開始", + "remote.sessionStarted": "リモートセッションの準備ができました。", + "remote.sessions": "セッション", + "remote.noSessions": "オンラインのコンピューター、フォルダー、エージェントを選択してください。", + "remote.events": "リモートセッションのアクティビティ", + "remote.noEvents": "まだアクティビティはありません。", + "remote.prompt": "メッセージ", + "remote.promptPlaceholder": "選択したリモートフォルダーでの作業を Hub エージェントに依頼…", + "remote.send": "送信", + "remote.stop": "セッションを停止", + "remote.requestFailed": "リモートワークスペースの要求に失敗しました。", + "remote.status.starting": "開始中", + "remote.status.ready": "準備完了", + "remote.status.running": "実行中", + "remote.status.waiting": "エグゼキューターがオフライン", + "remote.status.failed": "失敗", + "remote.status.stopped": "停止済み", + "remote.event.status": "状態", + "remote.event.tool": "リモートツール", + "remote.event.error": "エラー", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1db3ad6a32..347321bdc0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2727,4 +2727,64 @@ export const ko: Record = { "models.pickerOrder.saveDraft": "초안 저장", "models.pickerOrder.reloadDraft": "초안 버리고 다시 불러오기", "models.pickerOrder.catalogRequired": "모델 식별 정보가 없거나 모호합니다. 모델 페이지를 새로고침해 목록을 갱신한 뒤 사용자 지정 순서를 편집하세요.", + "nav.remote": "원격 워크스페이스", + "remote.title": "원격 워크스페이스", + "remote.subtitle": "Codex, Claude Code, Pi는 이 Hub에서 실행하고 파일·명령·테스트·빌드는 선택한 컴퓨터에서 처리합니다.", + "remote.loading": "원격 워크스페이스 불러오는 중…", + "remote.loadFailed": "원격 워크스페이스를 불러오지 못했습니다.", + "remote.hubRequired": "허브 모드에서 OCX_REMOTE_WORKSPACE_ENABLED=1로 허브를 시작하면 원격 작업 공간을 사용할 수 있습니다.", + "remote.refresh": "새로고침", + "remote.addComputer": "컴퓨터 추가", + "remote.addComputerHint": "추가할 컴퓨터에서 폴더를 승인하고 OCX 전용 실행기를 이 Hub에 계속 연결하세요.", + "remote.createPairing": "페어링 코드 만들기", + "remote.pairingCode": "일회용 페어링 코드", + "remote.pairingExpires": "{time}에 만료", + "remote.pairingCommand": "추가할 컴퓨터에서 실행", + "remote.pairingCommandPosix": "Linux / macOS 터미널", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "명령어 복사", + "remote.copied": "복사됨", + "remote.devices": "컴퓨터", + "remote.noDevices": "아직 페어링된 컴퓨터가 없습니다.", + "remote.online": "온라인", + "remote.offline": "오프라인", + "remote.revoke": "컴퓨터 연결 해제", + "remote.revokeConfirm": "{name} 연결을 해제할까요? 이 컴퓨터의 활성 세션이 중지됩니다.", + "remote.newSession": "새 원격 세션", + "remote.device": "컴퓨터", + "remote.folder": "워크스페이스 폴더", + "remote.runtime": "코딩 에이전트", + "remote.access": "워크스페이스 권한", + "remote.access.readOnly": "읽기 전용", + "remote.access.workspace": "파일 편집 및 명령 실행", + "remote.access.workspaceFilesOnly": "파일 편집만", + "remote.unavailable": "사용 불가", + "remote.capability.full": "파일 + 격리된 명령 실행", + "remote.capability.files": "파일 도구만 지원", + "remote.runsOnHub": "모델과 로그인은 이 Hub에서 유지", + "remote.runsReadOnly": "이 컴퓨터의 파일은 읽기만 가능", + "remote.runsFilesCommands": "파일, 빌드, 명령은 이 컴퓨터에서 실행", + "remote.runsFilesOnly": "파일 도구만 이 컴퓨터에서 실행, 명령 격리 미지원", + "remote.execUnavailable": "이 컴퓨터의 파일은 편집할 수 있지만, 지원되는 OS 격리 기능이 없어 빌드와 터미널 명령은 비활성화됩니다.", + "remote.notResumable": "코딩 에이전트가 세션 기록을 만들기 전에 중단되었습니다. 새 원격 세션을 시작하세요.", + "remote.startSession": "원격 세션 시작", + "remote.sessionStarted": "원격 세션이 준비되었습니다.", + "remote.sessions": "세션", + "remote.noSessions": "온라인 컴퓨터, 폴더, 코딩 에이전트를 선택해 시작하세요.", + "remote.events": "원격 세션 활동", + "remote.noEvents": "아직 활동이 없습니다.", + "remote.prompt": "메시지", + "remote.promptPlaceholder": "Hub 에이전트에게 선택한 원격 폴더에서 작업을 요청하세요…", + "remote.send": "보내기", + "remote.stop": "세션 중지", + "remote.requestFailed": "원격 워크스페이스 요청에 실패했습니다.", + "remote.status.starting": "시작 중", + "remote.status.ready": "준비됨", + "remote.status.running": "실행 중", + "remote.status.waiting": "실행기 오프라인", + "remote.status.failed": "실패", + "remote.status.stopped": "중지됨", + "remote.event.status": "상태", + "remote.event.tool": "원격 도구", + "remote.event.error": "오류", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index fc6f61c152..a6e7f5cedd 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2728,4 +2728,64 @@ export const ru: Record = { "models.pickerOrder.saveDraft": "Сохранить черновик", "models.pickerOrder.reloadDraft": "Перезагрузить и сбросить черновик", "models.pickerOrder.catalogRequired": "Идентификаторы моделей отсутствуют или неоднозначны. Перезагрузите страницу моделей, чтобы обновить каталог перед редактированием порядка.", + "nav.remote": "Удалённое рабочее пространство", + "remote.title": "Удалённое рабочее пространство", + "remote.subtitle": "Codex, Claude Code или Pi работают на этом Hub, а файлы, команды, тесты и сборки остаются на выбранном компьютере.", + "remote.loading": "Загрузка удалённого рабочего пространства…", + "remote.loadFailed": "Не удалось загрузить удалённое рабочее пространство.", + "remote.hubRequired": "Для включения Remote Workspace запустите Hub в режиме Hub с OCX_REMOTE_WORKSPACE_ENABLED=1.", + "remote.refresh": "Обновить", + "remote.addComputer": "Добавить компьютер", + "remote.addComputerHint": "Разрешите локальные папки и держите исполнитель только с OCX подключённым к этому Hub.", + "remote.createPairing": "Создать код сопряжения", + "remote.pairingCode": "Одноразовый код сопряжения", + "remote.pairingExpires": "Истекает в {time}", + "remote.pairingCommand": "Запустите на добавляемом компьютере", + "remote.pairingCommandPosix": "Терминал Linux / macOS", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Копировать команду", + "remote.copied": "Скопировано", + "remote.devices": "Компьютеры", + "remote.noDevices": "Сопряжённых компьютеров пока нет.", + "remote.online": "В сети", + "remote.offline": "Не в сети", + "remote.revoke": "Отозвать компьютер", + "remote.revokeConfirm": "Отозвать {name}? Активные сеансы на этом компьютере будут остановлены.", + "remote.newSession": "Новый удалённый сеанс", + "remote.device": "Компьютер", + "remote.folder": "Папка рабочего пространства", + "remote.runtime": "Агент программирования", + "remote.access": "Доступ к рабочей области", + "remote.access.readOnly": "Только чтение", + "remote.access.workspace": "Изменять файлы и выполнять команды", + "remote.access.workspaceFilesOnly": "Только изменять файлы", + "remote.unavailable": "Недоступно", + "remote.capability.full": "Файлы + изолированные команды", + "remote.capability.files": "Только файловые инструменты", + "remote.runsOnHub": "Модель и вход остаются на этом Hub", + "remote.runsReadOnly": "Файлы на этом компьютере доступны только для чтения", + "remote.runsFilesCommands": "Файлы, сборки и команды выполняются здесь", + "remote.runsFilesOnly": "Здесь работают только файловые инструменты; песочница команд недоступна", + "remote.execUnavailable": "На этом компьютере можно редактировать файлы, но сборки и команды терминала отключены без поддерживаемой системной песочницы.", + "remote.notResumable": "Сеанс остановился до создания постоянной истории агентом. Запустите новый удалённый сеанс.", + "remote.startSession": "Запустить удалённый сеанс", + "remote.sessionStarted": "Удалённый сеанс готов.", + "remote.sessions": "Сеансы", + "remote.noSessions": "Выберите компьютер в сети, папку и агента программирования.", + "remote.events": "Активность удалённого сеанса", + "remote.noEvents": "Активности пока нет.", + "remote.prompt": "Сообщение", + "remote.promptPlaceholder": "Попросите агент Hub работать в выбранной удалённой папке…", + "remote.send": "Отправить", + "remote.stop": "Остановить сеанс", + "remote.requestFailed": "Запрос удалённого рабочего пространства завершился ошибкой.", + "remote.status.starting": "Запуск", + "remote.status.ready": "Готово", + "remote.status.running": "Выполняется", + "remote.status.waiting": "Исполнитель не в сети", + "remote.status.failed": "Ошибка", + "remote.status.stopped": "Остановлено", + "remote.event.status": "Состояние", + "remote.event.tool": "Удалённый инструмент", + "remote.event.error": "Ошибка", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ee6ae93adf..1aaeee8027 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2728,4 +2728,64 @@ export const tr: Record = { "models.pickerOrder.saveDraft": "Taslağı kaydet", "models.pickerOrder.reloadDraft": "Yeniden yükle ve taslağı sil", "models.pickerOrder.catalogRequired": "Model kimlikleri eksik veya belirsiz. Özel sırayı düzenlemeden önce kataloğu yenilemek için Modeller sayfasını yeniden yükleyin.", + "nav.remote": "Uzak Çalışma Alanı", + "remote.title": "Uzak Çalışma Alanı", + "remote.subtitle": "Codex, Claude Code veya Pi bu Hub üzerinde çalışır; dosyalar, komutlar, testler ve derlemeler seçtiğiniz bilgisayarda kalır.", + "remote.loading": "Uzak çalışma alanı yükleniyor…", + "remote.loadFailed": "Uzak çalışma alanı yüklenemedi.", + "remote.hubRequired": "Remote Workspace özelliğini açmak için Hub modunda OCX_REMOTE_WORKSPACE_ENABLED=1 ile Hub başlatın.", + "remote.refresh": "Yenile", + "remote.addComputer": "Bilgisayar ekle", + "remote.addComputerHint": "Klasörleri yerel olarak onaylayın ve yalnızca OCX kurulu yürütücüyü bu Hub'a bağlı tutun.", + "remote.createPairing": "Eşleştirme kodu oluştur", + "remote.pairingCode": "Tek kullanımlık eşleştirme kodu", + "remote.pairingExpires": "{time} saatinde sona erer", + "remote.pairingCommand": "Eklenecek bilgisayarda çalıştırın", + "remote.pairingCommandPosix": "Linux / macOS terminali", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Komutu kopyala", + "remote.copied": "Kopyalandı", + "remote.devices": "Bilgisayarlar", + "remote.noDevices": "Henüz eşleştirilmiş bilgisayar yok.", + "remote.online": "Çevrimiçi", + "remote.offline": "Çevrimdışı", + "remote.revoke": "Bilgisayarı iptal et", + "remote.revokeConfirm": "{name} iptal edilsin mi? Bu bilgisayardaki etkin oturumlar durur.", + "remote.newSession": "Yeni uzak oturum", + "remote.device": "Bilgisayar", + "remote.folder": "Çalışma alanı klasörü", + "remote.runtime": "Kodlama aracısı", + "remote.access": "Çalışma alanı erişimi", + "remote.access.readOnly": "Salt okunur", + "remote.access.workspace": "Dosyaları düzenle ve komut çalıştır", + "remote.access.workspaceFilesOnly": "Yalnızca dosyaları düzenle", + "remote.unavailable": "Kullanılamıyor", + "remote.capability.full": "Dosyalar + yalıtılmış komutlar", + "remote.capability.files": "Yalnızca dosya araçları", + "remote.runsOnHub": "Model ve oturum bu Hub üzerinde kalır", + "remote.runsReadOnly": "Bu bilgisayardaki dosyalar yalnızca okunabilir", + "remote.runsFilesCommands": "Dosyalar, derlemeler ve komutlar burada çalışır", + "remote.runsFilesOnly": "Burada yalnızca dosya araçları çalışır; komut yalıtımı yok", + "remote.execUnavailable": "Bu bilgisayar dosyaları düzenleyebilir; ancak desteklenen bir işletim sistemi yalıtımı olmadığı için derlemeler ve terminal komutları devre dışıdır.", + "remote.notResumable": "Kodlama aracısı kalıcı geçmiş oluşturmadan önce oturum durdu. Yeni bir uzak oturum başlatın.", + "remote.startSession": "Uzak oturumu başlat", + "remote.sessionStarted": "Uzak oturum hazır.", + "remote.sessions": "Oturumlar", + "remote.noSessions": "Çevrimiçi bir bilgisayar, klasör ve kodlama aracısı seçin.", + "remote.events": "Uzak oturum etkinliği", + "remote.noEvents": "Henüz etkinlik yok.", + "remote.prompt": "Mesaj", + "remote.promptPlaceholder": "Hub aracısından seçili uzak klasörde çalışmasını isteyin…", + "remote.send": "Gönder", + "remote.stop": "Oturumu durdur", + "remote.requestFailed": "Uzak çalışma alanı isteği başarısız oldu.", + "remote.status.starting": "Başlatılıyor", + "remote.status.ready": "Hazır", + "remote.status.running": "Çalışıyor", + "remote.status.waiting": "Yürütücü çevrimdışı", + "remote.status.failed": "Başarısız", + "remote.status.stopped": "Durduruldu", + "remote.event.status": "Durum", + "remote.event.tool": "Uzak araç", + "remote.event.error": "Hata", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 8f38f5c0f4..985beab4aa 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2691,4 +2691,64 @@ export const zhTW: Record = { "models.pickerOrder.saveDraft": "儲存草稿", "models.pickerOrder.reloadDraft": "捨棄草稿並重新載入", "models.pickerOrder.catalogRequired": "模型識別資訊缺失或不明確。請重新載入模型頁面以更新目錄,再編輯自訂順序。", + "nav.remote": "遠端工作區", + "remote.title": "遠端工作區", + "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 執行,檔案、命令、測試與建置則留在所選電腦上處理。", + "remote.loading": "正在載入遠端工作區…", + "remote.loadFailed": "無法載入遠端工作區。", + "remote.hubRequired": "請在 Hub 模式下使用 OCX_REMOTE_WORKSPACE_ENABLED=1 啟動 Hub,以啟用遠端工作區。", + "remote.refresh": "重新整理", + "remote.addComputer": "新增電腦", + "remote.addComputerHint": "在本機核准一個或多個資料夾,並讓僅安裝 OCX 的執行端持續連線此 Hub。", + "remote.createPairing": "建立配對碼", + "remote.pairingCode": "一次性配對碼", + "remote.pairingExpires": "{time} 到期", + "remote.pairingCommand": "在要新增的電腦上執行", + "remote.pairingCommandPosix": "Linux / macOS 終端機", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "複製命令", + "remote.copied": "已複製", + "remote.devices": "電腦", + "remote.noDevices": "尚未配對電腦。", + "remote.online": "上線", + "remote.offline": "離線", + "remote.revoke": "撤銷電腦", + "remote.revokeConfirm": "撤銷 {name}?此電腦上的作用中工作階段將停止。", + "remote.newSession": "新增遠端工作階段", + "remote.device": "電腦", + "remote.folder": "工作區資料夾", + "remote.runtime": "程式設計代理", + "remote.access": "工作區權限", + "remote.access.readOnly": "唯讀", + "remote.access.workspace": "編輯檔案並執行命令", + "remote.access.workspaceFilesOnly": "僅編輯檔案", + "remote.unavailable": "無法使用", + "remote.capability.full": "檔案 + 沙箱命令", + "remote.capability.files": "僅檔案工具", + "remote.runsOnHub": "模型與登入保留在此 Hub", + "remote.runsReadOnly": "此電腦上的檔案僅可讀取", + "remote.runsFilesCommands": "檔案、建置與命令在此電腦執行", + "remote.runsFilesOnly": "僅檔案工具在此執行;命令沙箱無法使用", + "remote.execUnavailable": "此電腦可以編輯檔案,但因沒有支援的作業系統沙箱,建置與終端命令已停用。", + "remote.notResumable": "程式設計代理尚未建立持久歷史記錄時工作階段就已停止。請啟動新的遠端工作階段。", + "remote.startSession": "啟動遠端工作階段", + "remote.sessionStarted": "遠端工作階段已就緒。", + "remote.sessions": "工作階段", + "remote.noSessions": "請選擇上線電腦、資料夾與程式設計代理。", + "remote.events": "遠端工作階段活動", + "remote.noEvents": "尚無活動。", + "remote.prompt": "訊息", + "remote.promptPlaceholder": "請 Hub 代理在所選遠端資料夾中工作…", + "remote.send": "傳送", + "remote.stop": "停止工作階段", + "remote.requestFailed": "遠端工作區要求失敗。", + "remote.status.starting": "正在啟動", + "remote.status.ready": "就緒", + "remote.status.running": "執行中", + "remote.status.waiting": "執行端離線", + "remote.status.failed": "失敗", + "remote.status.stopped": "已停止", + "remote.event.status": "狀態", + "remote.event.tool": "遠端工具", + "remote.event.error": "錯誤", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1cfd82623c..fc198fbb20 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2726,4 +2726,64 @@ export const zh: Record = { "models.pickerOrder.saveDraft": "保存草稿", "models.pickerOrder.reloadDraft": "丢弃草稿并重新加载", "models.pickerOrder.catalogRequired": "模型标识信息缺失或不明确。请重新加载模型页面以刷新目录,再编辑自定义顺序。", + "nav.remote": "远程工作区", + "remote.title": "远程工作区", + "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 上运行,文件、命令、测试和构建则留在所选电脑上执行。", + "remote.loading": "正在加载远程工作区…", + "remote.loadFailed": "无法加载远程工作区。", + "remote.hubRequired": "请在 Hub 模式下使用 OCX_REMOTE_WORKSPACE_ENABLED=1 启动 Hub,以启用远程工作区。", + "remote.refresh": "刷新", + "remote.addComputer": "添加电脑", + "remote.addComputerHint": "在本机批准一个或多个文件夹,并让仅安装 OCX 的执行端持续连接此 Hub。", + "remote.createPairing": "创建配对码", + "remote.pairingCode": "一次性配对码", + "remote.pairingExpires": "{time} 过期", + "remote.pairingCommand": "在要添加的电脑上运行", + "remote.pairingCommandPosix": "Linux / macOS 终端", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "复制命令", + "remote.copied": "已复制", + "remote.devices": "电脑", + "remote.noDevices": "尚未配对电脑。", + "remote.online": "在线", + "remote.offline": "离线", + "remote.revoke": "撤销电脑", + "remote.revokeConfirm": "撤销 {name}?该电脑上的活动会话将停止。", + "remote.newSession": "新建远程会话", + "remote.device": "电脑", + "remote.folder": "工作区文件夹", + "remote.runtime": "编程代理", + "remote.access": "工作区权限", + "remote.access.readOnly": "只读", + "remote.access.workspace": "编辑文件并运行命令", + "remote.access.workspaceFilesOnly": "仅编辑文件", + "remote.unavailable": "不可用", + "remote.capability.full": "文件 + 沙箱命令", + "remote.capability.files": "仅文件工具", + "remote.runsOnHub": "模型和登录保留在此 Hub", + "remote.runsReadOnly": "此电脑上的文件仅可读取", + "remote.runsFilesCommands": "文件、构建和命令在此电脑运行", + "remote.runsFilesOnly": "仅文件工具在此运行;命令沙箱不可用", + "remote.execUnavailable": "此电脑可以编辑文件,但由于没有受支持的操作系统沙箱,构建和终端命令已禁用。", + "remote.notResumable": "编码代理尚未创建持久历史记录时会话就已停止。请启动新的远程会话。", + "remote.startSession": "启动远程会话", + "remote.sessionStarted": "远程会话已就绪。", + "remote.sessions": "会话", + "remote.noSessions": "请选择在线电脑、文件夹和编程代理。", + "remote.events": "远程会话活动", + "remote.noEvents": "暂无活动。", + "remote.prompt": "消息", + "remote.promptPlaceholder": "让 Hub 代理在所选远程文件夹中工作…", + "remote.send": "发送", + "remote.stop": "停止会话", + "remote.requestFailed": "远程工作区请求失败。", + "remote.status.starting": "正在启动", + "remote.status.ready": "就绪", + "remote.status.running": "运行中", + "remote.status.waiting": "执行端离线", + "remote.status.failed": "失败", + "remote.status.stopped": "已停止", + "remote.event.status": "状态", + "remote.event.tool": "远程工具", + "remote.event.error": "错误", }; diff --git a/gui/src/pages/RemoteWorkspace.tsx b/gui/src/pages/RemoteWorkspace.tsx new file mode 100644 index 0000000000..9295e39da2 --- /dev/null +++ b/gui/src/pages/RemoteWorkspace.tsx @@ -0,0 +1,381 @@ +import { useMemo, useRef, useState } from "react"; +import { useKeyedClientResource } from "../client-resource"; +import { readJsonOrThrow } from "../fetch-json"; +import { IconLink, IconMonitor, IconPlus, IconRefresh, IconTerminal, IconTrash } from "../icons"; +import { type TKey, useT } from "../i18n/shared"; +import { Notice, Select } from "../ui"; +import { remoteWorkspacePairingCommands } from "../remote-workspace-command"; + +type RuntimeProfile = "codex" | "claude" | "pi"; +type RemoteCapability = "workspace.read" | "workspace.write" | "workspace.exec"; +type RemoteAccessMode = "read-only" | "workspace"; +type SessionStatus = "starting" | "ready" | "running" | "waiting_for_executor" | "failed" | "stopped"; + +interface RemoteRoot { id: string; label: string } +interface RemoteDevice { + id: string; + name: string; + platform: string; + capabilities: RemoteCapability[]; + roots: RemoteRoot[]; + online: boolean; + createdAt: string; + lastSeenAt: string | null; +} +interface RuntimeAvailability { available: boolean; version?: string; reason?: string } +interface SessionEvent { sequence: number; at: string; type: "status" | "assistant" | "tool" | "error"; text: string } +interface RemoteSession { + id: string; + profile: RuntimeProfile; + accessMode: RemoteAccessMode; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteCapability[]; + tools: string[]; + threadId: string | null; + resumable: boolean; + status: SessionStatus; + createdAt: string; + updatedAt: string; + events: SessionEvent[]; +} +interface RemoteWorkspaceState { + available: boolean; + reason?: string; + devices: RemoteDevice[]; + runtimes: Record; + sessions: RemoteSession[]; +} +interface PairingGrant { code: string; expiresAt: string } + +const PROFILES: RuntimeProfile[] = ["codex", "claude", "pi"]; +const PROFILE_LABEL: Record = { codex: "Codex", claude: "Claude Code", pi: "Pi" }; +const STATUS_TKEY: Record = { + starting: "remote.status.starting", + ready: "remote.status.ready", + running: "remote.status.running", + waiting_for_executor: "remote.status.waiting", + failed: "remote.status.failed", + stopped: "remote.status.stopped", +}; +const EVENT_TKEY: Record, TKey> = { + status: "remote.event.status", + tool: "remote.event.tool", + error: "remote.event.error", +}; + +function isRuntimeProfile(value: string): value is RuntimeProfile { + return value === "codex" || value === "claude" || value === "pi"; +} + +function isRemoteAccessMode(value: string): value is RemoteAccessMode { + return value === "read-only" || value === "workspace"; +} + +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +export default function RemoteWorkspace({ apiBase }: { apiBase: string }) { + const t = useT(); + const resource = useKeyedClientResource( + `remote-workspace:${apiBase}`, + [apiBase], + async signal => { + const response = await fetch(`${apiBase}/api/remote-workspace`, { signal, cache: "no-store" }); + return await readJsonOrThrow(response, t("remote.loadFailed")); + }, + { pollMs: 3_000, deadlineMs: 10_000 }, + ); + const state = resource.data; + const [selectedDeviceId, setSelectedDeviceId] = useState(""); + const [selectedRootId, setSelectedRootId] = useState(""); + const [selectedProfile, setSelectedProfile] = useState("codex"); + const [selectedAccessMode, setSelectedAccessMode] = useState("read-only"); + const [selectedSessionId, setSelectedSessionId] = useState(""); + const [localSession, setLocalSession] = useState(null); + const [pairing, setPairing] = useState(null); + const [prompt, setPrompt] = useState(""); + const [busy, setBusy] = useState<"pair" | "session" | "revoke" | null>(null); + const [promptPending, setPromptPending] = useState(false); + const [stopPending, setStopPending] = useState(false); + const stoppedSessionId = useRef(null); + const [notice, setNotice] = useState<{ tone: "ok" | "err"; text: string } | null>(null); + const [copiedCommand, setCopiedCommand] = useState<"posix" | "powershell" | null>(null); + + const devices = state?.devices ?? []; + const effectiveDevice = devices.find(device => device.id === selectedDeviceId) + ?? devices.find(device => device.online) + ?? devices[0] + ?? null; + const effectiveRoot = effectiveDevice?.roots.find(root => root.id === selectedRootId) + ?? effectiveDevice?.roots[0] + ?? null; + const selectedCanExecute = selectedAccessMode === "workspace" + && (effectiveDevice?.capabilities.includes("workspace.exec") ?? false); + const workspaceAccessLabel = effectiveDevice && !effectiveDevice.capabilities.includes("workspace.exec") + ? t("remote.access.workspaceFilesOnly") + : t("remote.access.workspace"); + const availableProfiles = PROFILES.filter(profile => state?.runtimes?.[profile]?.available); + const effectiveProfile = availableProfiles.includes(selectedProfile) + ? selectedProfile + : availableProfiles[0] ?? selectedProfile; + const remoteSessions = state?.sessions ?? []; + const effectiveSession = remoteSessions.find(session => session.id === selectedSessionId) + ?? (localSession && localSession.id === selectedSessionId ? localSession : null) + ?? [...remoteSessions].reverse().find(session => session.status !== "stopped") + ?? localSession; + + const pairingCommands = useMemo(() => { + if (!pairing) return { posix: "", powershell: "" }; + const hub = typeof window === "undefined" ? "https://hub.example" : window.location.origin; + return remoteWorkspacePairingCommands(pairing.code, hub); + }, [pairing]); + + const mutate = async (path: string, init: RequestInit, fallback: string): Promise => { + const response = await fetch(`${apiBase}${path}`, init); + const body = await readJsonOrThrow(response, fallback); + if (body === undefined) throw new Error(fallback); + return body; + }; + + const createPairing = async () => { + setBusy("pair"); + setNotice(null); + try { + const grant = await mutate("/api/remote-workspace/pairing", { method: "POST" }, t("remote.requestFailed")); + setPairing(grant); + setCopiedCommand(null); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const createSession = async () => { + if (!effectiveDevice || !effectiveRoot) return; + setBusy("session"); + setNotice(null); + try { + const session = await mutate("/api/remote-workspace/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile: effectiveProfile, + deviceId: effectiveDevice.id, + rootId: effectiveRoot.id, + accessMode: selectedAccessMode, + }), + }, t("remote.requestFailed")); + setLocalSession(session); + setSelectedSessionId(session.id); + setNotice({ tone: "ok", text: t("remote.sessionStarted") }); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const sendPrompt = async () => { + if (!effectiveSession || !prompt.trim() || promptPending || stopPending || busy !== null) return; + const target = effectiveSession; + const submitted = prompt; + setPrompt(""); + setPromptPending(true); + setNotice(null); + try { + const session = await mutate(`/api/remote-workspace/sessions/${target.id}/prompt`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: submitted }), + }, t("remote.requestFailed")); + if (stoppedSessionId.current !== target.id) setLocalSession(session); + void resource.refresh(); + } catch (error) { + if (stoppedSessionId.current !== target.id) { + setPrompt(submitted); + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } + } finally { setPromptPending(false); } + }; + + const stopSession = async () => { + if (!effectiveSession || stopPending || busy !== null) return; + const target = effectiveSession; + setStopPending(true); + try { + await mutate(`/api/remote-workspace/sessions/${target.id}`, { method: "DELETE" }, t("remote.requestFailed")); + stoppedSessionId.current = target.id; + setLocalSession({ ...target, status: "stopped" }); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setStopPending(false); } + }; + + const revokeDevice = async (device: RemoteDevice) => { + if (!confirm(t("remote.revokeConfirm", { name: device.name }))) return; + setBusy("revoke"); + try { + await mutate(`/api/remote-workspace/devices/${device.id}`, { method: "DELETE" }, t("remote.requestFailed")); + if (selectedDeviceId === device.id) setSelectedDeviceId(""); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const copyPairingCommand = async (kind: "posix" | "powershell", command: string) => { + setCopiedCommand(await copyText(command) ? kind : null); + }; + + if (resource.loading && !state) return
{t("remote.loading")}
; + if (resource.error && !state) { + return <>{t("remote.loadFailed")}; + } + if (state?.available === false) return {t("remote.hubRequired")}; + + return ( +
+
+
+

{t("remote.title")}

+

{t("remote.subtitle")}

+
+ +
+ + {notice ? {notice.text} : null} + +
+
+
+
+
+

{t("remote.addComputer")}

{t("remote.addComputerHint")}

+
+ + {pairing ? ( +
+ {t("remote.pairingCode")} +
{pairing.code}
+
{t("remote.pairingExpires", { time: new Date(pairing.expiresAt).toLocaleTimeString() })}
+ {t("remote.pairingCommandPosix")} +
{pairingCommands.posix}
+ + {t("remote.pairingCommandWindows")} +
{pairingCommands.powershell}
+ +
+ ) : null} +
+ +
+

{t("remote.devices")}

{devices.length}
+ {devices.length === 0 ?

{t("remote.noDevices")}

: ( +
+ {devices.map(device => ( +
+ + +
+ ))} +
+ )} +
+
+ +
+
+

{t("remote.newSession")}

+
+ + +
+ {effectiveDevice ? ( +
+ {PROFILE_LABEL[effectiveProfile]}{t("remote.runsOnHub")} + {effectiveDevice.name}{selectedAccessMode === "read-only" ? t("remote.runsReadOnly") : selectedCanExecute ? t("remote.runsFilesCommands") : t("remote.runsFilesOnly")} +
+ ) : null} + {selectedAccessMode === "workspace" && !selectedCanExecute && effectiveDevice ? {t("remote.execUnavailable")} : null} + {!state?.runtimes?.[effectiveProfile]?.available && state?.runtimes?.[effectiveProfile]?.reason + ?

{state.runtimes[effectiveProfile].reason}

+ : null} + +
+ +
+
+

{t("remote.sessions")}

{effectiveSession ? {PROFILE_LABEL[effectiveSession.profile]} · {effectiveSession.deviceName}/{effectiveSession.rootLabel} · {effectiveSession.accessMode === "read-only" ? t("remote.access.readOnly") : t("remote.access.workspace")} : null}
+ {effectiveSession ? {t(STATUS_TKEY[effectiveSession.status])} : null} +
+ {remoteSessions.length > 1 ? ( +