diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7ce87801756..ba075268d55 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.79.0" +version = "2.80.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b06efc8253c..9886719215e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.79.0" +version = "2.80.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 076d4e3ba94..d39a1725311 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.79.0", + "version": "2.80.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/devlog/_plan/261006_lane_c_provider_compat/000_roadmap.md b/devlog/_plan/261006_lane_c_provider_compat/000_roadmap.md new file mode 100644 index 00000000000..2a32e491745 --- /dev/null +++ b/devlog/_plan/261006_lane_c_provider_compat/000_roadmap.md @@ -0,0 +1,16 @@ +# 000 — Lane C (provider compat) roadmap + +Lane session 01a11129 (fork of coordinator 01a11110). Worktree /Users/jun/Developer/new/700_projects/opencodex/.tmp/lanes/sweep-c-providers, +branch codex/sweep-c-providers at origin/dev f494403fba. This unit rides the lane's own PR; the coordinator owns devlog/_plan/261006_compat_hardening_sweep. + +| wp | Item | Plan | Exit | +|---|---|---|---| +| wp1 | roadmap | this file | audited, locked | +| wp2 | PR #6657 (luvs01, Devin) | worktree sweep-c-providers-6657 on the PR head; merge origin/dev (no force push); gpt-6.1-sol adversarial review of src/adapters/devin.ts change against tests/providers/devin-chat-wire-fixes.test.ts; fold real defects as commits on the PR branch; bun test focused file, typecheck, structure:check; push; gh pr ready; exact-head CI | READY or NEEDS_HUMAN | +| wp3 | PR #6654 (Ollama, branch on origin) | same shape in sweep-c-providers-6654 with focused commands: bun test tests/providers/ollama/ollama-native-tool-continuations.test.ts tests/providers/ollama/ollama-native-v4.test.ts tests/adapters/abort-race.test.ts; verify initial and rebuild 413, ordinary build error 400, cancellation precedence and zero upstream sends on refusal; also confirm the 413 mapping keeps ordinary build errors at 400 and that docs-site/structure text matches code; shared files structure/transports/responses.md and docs-site reference/adapters.md are B/C shared (second lander reconciles) | READY or NEEDS_HUMAN | +| wp4 | issue #6674 | in the lane worktree: widen qwen38LeadingSystemTemplate in src/adapters/openai-chat/messages.ts from ^(?:Qwen/)? to ^(?:[^/]+/)* so provider-namespaced served ids (openai/Qwen3.8-27B, hosted_vllm/Qwen/Qwen3.8-27B) match while Qwen3.8-27B-FP8 and api.openai.com stay excluded; add positive cases openai/Qwen3.8-27B and hosted_vllm/Qwen/Qwen3.8-27B plus namespaced negative (openai/Qwen3.8-27B-FP8) and native OpenAI control (openai/Qwen3.8-27B on api.openai.com) to tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts; update the owning structure doc if it states the matcher; one PR, Closes #6674 | READY | +| wp4 | issue #6502 | no code change: the report is 2.76.0 and Antigravity effort families were regrouped in 2.77.0 (#6501). resolveAntigravityWireModelId resolves discovery mappings first, aliases second, identity last, so the 404 alone does not tell an endpoint, mapping or account cause apart. Comment asking for a current-version repro with a redacted discovered model list and the resolved wire id; record NEEDS_HUMAN for insufficient evidence | NEEDS_HUMAN | + +Verification per PR: focused bun test, bun run typecheck, bun run structure:check, no full local suite (four lanes share this Mac; coverage left to hosted CI), exact-head CI with only path/event-filtered skips. +Bounds: tools gh/git/bun; writes only in the lane write scope plus own registry/structure/docs entries. Branch synchronization (git merge origin/dev into a PR head branch, then push) is lane-owned and granted by the packet; PR integration into dev (gh pr merge) is coordinator-only and never done here. + diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5b688af5fbf..945a0c56834 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -794,6 +794,19 @@ depend on them for long unattended runs. +## Client tools on Cursor routes + +When Claude Code uses a Cursor-backed model, OpenCodex preserves recognized client tool names +such as `Bash`, `Read`, `Write`, `Edit`, `Grep`, `Glob`, and `Task` in the Cursor catalog, tool +guidance, and replayed calls. This keeps the names consistent with Claude Code's instructions. +Denied Cursor-native execution frames direct the model to the client tools available that turn. + +A mixed catalog containing a bare Codex `exec_command` or `shell_command` bridge uses wire +aliases such as `ocx_client_Read` to avoid Cursor tool collisions; calls returned to Claude Code +still use its original tool names. Namespaced MCP tools keep their namespaced identities. +OpenCodex includes these aliases in Cursor's transport byte budget, so a tool whose definition +would exceed the limit is omitted for that turn. + ## Reasoning effort Claude Code's `/effort` setting is preserved across the adapter: @@ -818,6 +831,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | Assistant text | `output_text` | | Assistant `tool_use` | `function_call` (`input` → JSON-stringified `arguments`) | | User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) | +| `tool_reference` in a tool result | Text `Tool loaded: ` in the paired result; preserves loaded-tool names without adding declarations or enabling translated server-side deferral | | `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads | | Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` | diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 8a294b7563a..6c983ad6b8d 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -474,9 +474,11 @@ The decision state is deliberately bounded: up to 500 characters of the current 240-character previous-assistant tail, a 520-character latest-tool-output tail, the tool name, and boolean image/tool signals may be sent to the selected decision backend. It excludes credentials, request headers, raw image bytes, tool arguments, encrypted reasoning, and full conversation history. -Use a decision method only for content you are willing to send to that backend. Recognized OpenCodex machine-context -envelopes are removed from all three text samples, but ordinary assistant and tool-output text is -not a secret scanner and may still contain sensitive content. TypeSafe states that Jev is not +Use a decision method only for content you are willing to send to that backend. Recognized Codex machine-context +envelopes and Claude Code `` blocks are removed from all three text samples before +clipping, so injected reminders do not displace the actual task. The original model request is +unchanged. This is not a secret scanner: ordinary assistant and tool-output text may still contain +sensitive content. TypeSafe states that Jev is not trained on customer requests, but its terms set no fixed retention period for submitted state and offer zero data retention only on enterprise plans ([models](https://docs.typesafe.ai/models), [legal](https://docs.typesafe.ai/legal)). TypeSafe @@ -486,6 +488,13 @@ target/effort, a coarse decision gate, latency, optional confidence/probability, Automated tests use mocked TypeSafe responses plus a no-key fail-open smoke; a live TypeSafe decision requires an operator-supplied key and is not run implicitly. +When JEV's selected effort is applied to the Responses child, request and attempt logs show a +differing effort as `high->xhigh` or `high->low`. Later effort adjustments remain in the chain, +such as `high->xhigh->medium`. An unchanged effort or a target without effort control keeps the +existing label; fallback attempts do not inherit the initial JEV effort. With the opt-in +`protocols.rollout.nativeChatCombos` path, native Chat children currently do not apply JEV's +selected effort; their labels retain the caller's effort and any native child adjustments instead. + After the Combo has served requests, open **Models → Combos → jev-auto → Stats** to inspect JEV's picks without replacing the normal model picker or Usage page. The tab separates backend-reported decision tokens from tokens reported by physical model sends, and shows decision gates, fail-open picks, diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 4d25d6f664b..d6e437216a7 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -71,10 +71,26 @@ registration, including installations under `Program Files`. Previously enabled registrations are updated once on launch. Startup entries you disabled in the tray or Task Manager remain disabled. -## Startup safety on macOS +## Startup safety on macOS and Linux Startup safety reports **Desktop app** protection when OpenCodex's recorded ownership, **Start at Login** registration, and live supervision of its bundled proxy all match. +On Linux, the pinned autostart backend writes the login entry to +`~/.config/autostart/OpenCodex.desktop`, even when `$XDG_CONFIG_HOME` is set. Startup +safety reads that entry and the desktop install-id under `~/.config`. When +`$XDG_CONFIG_HOME` points elsewhere, the login session searches a different autostart +directory, so startup safety stays **At risk** instead of crediting the entry. +The entry counts only while it is not marked `Hidden=true` or +`X-GNOME-Autostart-enabled=false`, and has no `OnlyShowIn`, `NotShowIn`, or `TryExec` +condition. Its `Exec` must be an unquoted absolute path to `opencodex-desktop`, without +spaces, escapes, or field codes, followed by exactly `--autostart`. The resolved +executable must still be named `opencodex-desktop`, with its bundled `ocx` beside it. +Startup safety reads the full evidence chain twice and grants protection only when +both reads agree. + +AppImage installations remain **At risk**: the autostart backend registers the outer +AppImage path, while the live desktop process runs inside its mount. Startup safety +cannot verify that relationship and does not credit AppImage protection. A missing or stale check remains **At risk**. If the desktop app owns the proxy but protection cannot be verified, reopen OpenCodex and check **Start at Login**. Service and launcher installation or repair stays disabled while that ownership remains; diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 378e672bd99..b545d4ca0e0 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -524,6 +524,7 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. | Assistant 텍스트 | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 문자열로 변환한 `arguments`) | | 사용자 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 접두사) | +| 도구 결과의 `tool_reference` | 짝지어진 결과에 `Tool loaded: ` 텍스트로 보존합니다. 도구 선언을 추가하거나 번역 경로의 서버 측 지연 로딩을 활성화하지는 않습니다. | | `thinking` / `redacted_thinking` 재생 | 서명과 비공개 페이로드를 제한된 `ocxr1` 봉투에 담은 `reasoning` 항목 | | Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, 이름 지정 함수→`{type:"function",name}`, 호스팅 WebSearch/web_search→`{type:"web_search"}` | diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 816b43fcf9b..1a788461e3b 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -55,7 +55,8 @@ transport; it does not infer subscription attribution from the inbound protocol. tool message as the anchor. - **Rewrites Codex's GPT-5 identity prompt** to a model-agnostic intro so routed models don't claim to be OpenAI. -- For translated `Qwen3.8-27B` requests, a text-only developer reminder after the leading system +- For translated `Qwen3.8-27B` requests (including gateway-namespaced ids such as + `openai/Qwen3.8-27B`), a text-only developer reminder after the leading system message stays in its conversation slot but is sent as `user`. The model's [chat template](https://huggingface.co/Qwen/Qwen3.8-27B/blob/1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0/chat_template.jinja) rejects later `system` messages and does not accept `developer`, while later `user` messages @@ -543,7 +544,13 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and Foreground `shellArgs` and `shellStreamArgs` are an exception: both are rejected before spawn on every platform until kernel-backed descendant ownership is available. Use client shell tools; background-shell execution and other native operations retain their existing policy. -- The denial reply is a silent redirect whose wording follows the request catalog. +- The denial reply follows the request catalog. Explicit Claude-family Cursor models receive + factual wording that names the available tools without instructions to hide or avoid discussing + the redirect. Factual routing commentary can precede the next tool call without ending the turn. + When no client or configured MCP tools are available, Claude models are asked to answer without + tools or report the limitation instead of calling nonexistent shell/edit tools. + Other models, including Auto/default, retain the existing silent-redirect wording and commentary + guard. Tool availability, execution policy, and approval/sandbox behavior are unchanged. In code mode — a freeform unified `exec` and no bare shell bridge — the redirect points inside `exec`, where shell, file, search, and fetch are nested `tools.(...)` helpers of the JavaScript cell, and never recommends the top-level shell bridge code mode does not expose. A flat catalog that diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index ba797a4a561..d9a323ae3c5 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -47,8 +47,9 @@ full owner inventory for the Responses surface. `server/index/serve-options.ts` owns the HTTP boundary and delegates the Responses data plane to the `server/responses.ts` facade and its `server/responses/*.ts` modules: -1. `server/index/serve-options.ts` applies CORS and API authentication, rejects new work while draining, and - records request lifecycle metadata. It serves `GET /v1/models`, `POST /v1/responses`, +1. `server/index/serve-options.ts` applies CORS and API authentication, rejects new work while draining + with HTTP 503, JSON `error.code: "server_restarting"`, and `Retry-After: 5`, and + records request lifecycle metadata. Drain responses use the receiving listener's CORS policy. It serves `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (relayed to an OpenAI-family upstream by `server/images.ts` for codex's built-in `image_gen` tool), `POST /v1/live` / `POST /v1/realtime/calls` (ChatGPT / Codex App voice and OpenAI diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 2115a9c203e..7baa048e2ab 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -879,6 +879,11 @@ Migration restores the recorded native launcher without replacing a newer launch then installs the private wrapper. If private installation fails after native restoration, the native launcher stays restored and the operation can be retried. Missing or unusable native launchers require package-manager repair; OpenCodex does not guess another installation or rewrap the manager's path. +Migration also refuses when the recorded `codex-shim.json` is not a regular file owned by you or is +group- or world-writable. Older releases wrote that file with the process umask, so a umask of `002` +left it at mode `0664`. The refusal names the file and the `chmod 600` that clears only the +group- or world-writable refusal. Symlink and foreign-owner refusals remain; OpenCodex does not +change the permissions itself. Launcher installation alone does not prove that Codex requests will use OpenCodex. After a runnable install, the command checks the current Codex routing and reports a warning instead of a green result diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index b78cbf6087e..3bd3700766c 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -662,6 +662,12 @@ whether to star the repository. ### System lifecycle +During a restart drain, new data-plane requests receive HTTP 503 with JSON +`error.type: "server_error"`, `error.code: "server_restarting"`, and the message +"OpenCodex is restarting; retry this request." Responses retain `Retry-After: 5` +and the receiving listener's CORS policy. This code lets every Codex version retry +the 503 without reporting model capacity; provider overload errors retain their separate mapping. + | Method and path | Purpose | Notable errors | | --- | --- | --- | | `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps. `spillLastWriteFailureOrigin` is `retry_returned_timeout`, `timeout_memo_refusal`, or null; cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications. See [Windows spill diagnostics](/troubleshooting/windows-memory/) for process-local semantics. Raw errors and paths are never returned. | — | diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index 10bc3799cc8..e65b9c74cfe 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -78,7 +78,8 @@ runtime the leak itself remains an upstream problem: continuation-size fields from this endpoint and offers a confirm-gated **Drain & restart** action: it shows the current active-turn count, waits up to 60s for active turns (reusing - the existing 503 + `Retry-After` drain), then aborts any remaining turns. + the existing HTTP 503 JSON `server_restarting` + `Retry-After: 5` drain), + then aborts any remaining turns. The running proxy owns restart authorization and drain coordination, then exits; an installed service manager launches the replacement when applicable. The action reports success only after a different, identity-verified process diff --git a/package.json b/package.json index ea86f9aca55..a1effc4bc46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.79.0", + "version": "2.80.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f331f7c3c5c..5a6f6c0f21f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -73,8 +73,7 @@ "codex-pool-credit-policy.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", "codex-credits-after-limit-main.test.ts": "codex-integration", - "service-desktop-startup-health.test.ts": "service", - "service-desktop-startup.test.ts": "service", + "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", @@ -388,7 +387,7 @@ "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", - "claude-inbound.test.ts": "claude-integration", + "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", @@ -603,7 +602,7 @@ "codex-inject.test.ts": "codex-integration", "codex-injected-marker.test.ts": "codex-integration", "codex-integration-record.test.ts": "codex-integration", - "codex-journal.test.ts": "codex-integration", + "codex-journal-recovery.test.ts": "codex-integration", "codex-journal.test.ts": "codex-integration", "codex-legacy-config-keys.test.ts": "codex-integration", "codex-lineage-placement.test.ts": "codex-integration", "codex-log-guard-coderabbit.test.ts": "codex-integration", @@ -761,7 +760,7 @@ "cursor-adapter.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", - "cursor-blob.test.ts": "providers/cursor", + "cursor-blob-claude-client-tools.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", "cursor-call-id.test.ts": "providers/cursor", "cursor-cancel-provenance.test.ts": "providers/cursor", "cursor-catalog.test.ts": "providers/cursor", @@ -819,7 +818,7 @@ "cursor-tool-finalize-race.test.ts": "providers/cursor", "cursor-tool-result-image.test.ts": "providers/cursor", "cursor-tool-result-invocation.test.ts": "providers/cursor", - "cursor-tool-suspended-checkpoint.test.ts": "providers/cursor", + "cursor-tool-suspended-checkpoint.test.ts": "providers/cursor", "cursor-tool-wording.test.ts": "providers/cursor", "cursor-toolresult-normalize.test.ts": "providers/cursor", "cursor-transport-retry.test.ts": "providers/cursor", "cursor-ultra-mode.test.ts": "providers/cursor", @@ -1674,7 +1673,7 @@ "server-images-pool-admission.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", - "server-jev-combo-e2e.test.ts": "server", + "server-jev-combo-e2e.test.ts": "server", "combo-requested-effort.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", @@ -1916,7 +1915,7 @@ "web-search-retry-heartbeat.test.ts": "web-search", "web-search-sidecar-429.test.ts": "web-search", "web-search-sources.test.ts": "web-search", - "web-search-timeout-contract.test.ts": "web-search", + "web-search-timeout-contract.test.ts": "web-search", "web-search-pacing-deadline.test.ts": "web-search", "web-search-timeout-plan.test.ts": "web-search", "web-search.test.ts": "web-search", "win-exec.test.ts": "windows", diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 92801783c08..d9aa283fbc7 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -65,6 +65,7 @@ import { type CursorTransportFactory, } from "./cursor/transport"; import { cursorLiveRosterScope } from "./cursor/catalog"; +import { cursorUsesPlainToolWording } from "./cursor/tool-wording"; export const CURSOR_API_URL = "https://api2.cursor.sh"; @@ -313,8 +314,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const armMidstreamEcho = cursorNeedsExternalToolContinuation(activeRequest.modelId) && replaysToolResult; const midstreamObserver = armMidstreamEcho ? new CursorMidstreamEchoObserver() : undefined; const midstreamFilter = armMidstreamEcho ? new ToolEnvelopeEchoFilter() : undefined; + // Claude's factual redirects allow truthful routing prose; it is not a failure claim. const armRoutingCommentarySniffer = isCursorExternalWireModel(activeRequest.modelId) + && !cursorUsesPlainToolWording(activeRequest.modelId) && ( cursorRequestUsesCodeMode(activeRequest.tools, activeRequest.toolChoice) || cursorRequestHasShellAlias(activeRequest.tools) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index a1c59f8d394..b5fe5ca31fd 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -16,6 +16,7 @@ import { import { activePromptText, prepareCursorRunRequest } from "./protobuf-request"; import { prepareCursorRawMessages, resolveActiveCursorImages } from "./images"; import { isCursorExternalWireModel } from "./discovery"; +import { cursorUsesPlainToolWording } from "./tool-wording"; import { cursorRequestMessagesFromRaw } from "./request-builder"; import { createCursorContextUsageTracker, @@ -706,7 +707,7 @@ class LiveCursorTransport implements CursorTransport { const syntheticStructuredEditToolNames = new Set( (cursorVisibleTools ?? []) .filter(isCursorSyntheticStructuredEditTool) - .map(cursorToolWireName), + .map(tool => cursorToolWireName(tool, cursorVisibleTools)), ); const freeformToolNames = new Set( (cursorVisibleTools ?? []) @@ -718,12 +719,13 @@ class LiveCursorTransport implements CursorTransport { clientToolDefs, rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice), structuredEditAvailable: syntheticStructuredEditToolNames.size > 0, - nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools, this.execContext.mcpToolDefs ?? []), + nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools, this.execContext.mcpToolDefs ?? [], activeRequest.modelId), + plainToolWording: cursorUsesPlainToolWording(activeRequest.modelId), }; const toolSchemas = new Map(); const cursorToolNameMap = new Map(); for (const tool of cursorVisibleTools ?? []) { - const cursorWireName = cursorToolWireName(tool); + const cursorWireName = cursorToolWireName(tool, cursorVisibleTools); // Normalize against Responses/Codex field names, not the Cursor advertisement schema. // Advertising `cmd` while also storing that schema here left `cmd` unmapped and Codex // rejected shell_command with "missing field `command`" (#399). diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index 2558f91faca..b9bc94a7434 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -35,14 +35,17 @@ import { } from "./gen/agent_pb"; import { errorText, execBytes, lineCount, textDecoder, textEncoder } from "./native-exec-common"; +import { CURSOR_TOOL_CALL_CONTINUATION } from "./tool-wording"; + const MAX_GREP_FILES = 500; const MAX_GREP_RESULTS = 200; const MAX_FILE_BYTES = 1_000_000; -function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean): string { +function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean, plainToolWording = false): string { const structuredHint = structuredEditAvailable ? " Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout." : " Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout."; + if (plainToolWording) return `Cursor-native ${operation} is not available for this request.${structuredHint} No file was changed. ${CURSOR_TOOL_CALL_CONTINUATION}`; return `Make this ${operation} through the Codex edit path instead.${structuredHint} No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call.`; } @@ -87,24 +90,24 @@ export function readExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array { +export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false, plainToolWording = false): Uint8Array { if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "writeResult", create(WriteResultSchema, { result: { case: "rejected", - value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write", structuredEditAvailable) }), + value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write", structuredEditAvailable, plainToolWording) }), }, })); } -export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { +export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, hint?: string, plainToolWording = false): Uint8Array { if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "writeResult", create(WriteResultSchema, { result: { case: "rejected", - value: create(WriteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), + value: create(WriteRejectedSchema, { path, reason: plainToolWording ? `No file was changed. ${hint ?? NATIVE_LOCAL_EXEC_DISABLED}` : `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), }, })); } @@ -136,24 +139,24 @@ export function writeExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array { +export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false, plainToolWording = false): Uint8Array { if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, { result: { case: "rejected", - value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete", structuredEditAvailable) }), + value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete", structuredEditAvailable, plainToolWording) }), }, })); } -export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { +export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, hint?: string, plainToolWording = false): Uint8Array { if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, { result: { case: "rejected", - value: create(DeleteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), + value: create(DeleteRejectedSchema, { path, reason: plainToolWording ? `No file was changed. ${hint ?? NATIVE_LOCAL_EXEC_DISABLED}` : `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), }, })); } diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index f08e98a9313..56cfb9a8041 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -57,6 +57,7 @@ import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; import { CODEX_UNIFIED_EXEC_TOOL, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolWireName } from "./tool-naming"; import { CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize"; import type { OcxTool } from "../../types"; +import { cursorPlainNativeExecFallback, cursorPlainNativeExecRedirectHint, cursorUsesPlainToolWording } from "./tool-wording"; export type CursorNativeExecDeps = CursorNativeNetworkDeps & CursorNativeToolDeps; @@ -80,6 +81,8 @@ export interface CursorNativeExecContext extends CursorNativeExecDeps { structuredEditAvailable?: boolean; /** Catalog-aware redirect text for denied native fs/shell attempts (undefined = default bridge wording). */ nativeExecRedirectHint?: string; + /** Claude-family Cursor targets receive factual redirects, without narration restrictions. */ + plainToolWording?: boolean; } const REDIRECT_HINT_MAX_TOOLS = 16; @@ -93,7 +96,9 @@ const REDIRECT_HINT_MAX_TOOLS = 16; export function cursorNativeExecRedirectHint( tools: readonly Pick[] | undefined, mcpToolDefs: readonly Pick[] = [], + modelId?: string, ): string | undefined { + if (cursorUsesPlainToolWording(modelId)) return cursorPlainNativeExecRedirectHint(tools, mcpToolDefs); const clientTools = tools ?? []; if (cursorRequestHasShellAlias(clientTools)) return undefined; // Code mode (freeform unified `exec`, no bare shell bridge): the default bridge wording names @@ -107,7 +112,7 @@ export function cursorNativeExecRedirectHint( // A request with no client tools but configured MCP tools still gets those named; a request that // advertises nothing at all keeps the default bridge wording. const names = [...new Set([ - ...clientTools.map(cursorToolWireName), + ...clientTools.map(tool => cursorToolWireName(tool, clientTools)), ...mcpToolDefs.map(def => `mcp_${def.providerIdentifier}_${def.name}`), ])]; if (names.length === 0) return undefined; @@ -704,6 +709,9 @@ export function cursorBlobStoreDebugSnapshotForTests(): Array<{ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: CursorNativeExecContext = {}): Promise { const execCase = execMsg.message.case; + if (deps.plainToolWording && !deps.nativeExecRedirectHint) { + deps = { ...deps, nativeExecRedirectHint: cursorPlainNativeExecFallback([...(deps.clientToolDefs ?? []), ...(deps.mcpToolDefs ?? [])]) }; + } if (execCase === "requestContextArgs") { const tools = [...(deps.mcpToolDefs ?? []), ...(deps.clientToolDefs ?? [])]; return [execBytes(execMsg, "requestContextResult", create(RequestContextResultSchema, { @@ -712,8 +720,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C } if (!cursorUnsafeNativeLocalExecEnabled(deps)) { if (execCase === "readArgs") return [rejectReadExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; - if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; - if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg, deps.nativeExecRedirectHint, deps.plainToolWording)]; + if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg, deps.nativeExecRedirectHint, deps.plainToolWording)]; if (execCase === "lsArgs") return [rejectLsExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; if (execCase === "grepArgs") return [rejectGrepExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; if (execCase === "shellArgs") return [rejectShellExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; @@ -723,8 +731,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; } if (execCase === "readArgs") return [readExec(execMsg)]; - if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : writeExec(execMsg)]; - if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : deleteExec(execMsg)]; + if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true, deps.plainToolWording) : writeExec(execMsg)]; + if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true, deps.plainToolWording) : deleteExec(execMsg)]; if (execCase === "lsArgs") return [lsExec(execMsg)]; if (execCase === "grepArgs") return [grepExec(execMsg)]; if (execCase === "shellArgs") return [shellExec(execMsg, deps.nativeExecRedirectHint)]; diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 2cb3dca33e7..a4264f20112 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -1,7 +1,7 @@ import { create, fromBinary, toBinary, toJson } from "@bufbuild/protobuf"; import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { ValueSchema } from "@bufbuild/protobuf/wkt"; -import type { OcxAssistantContentPart, OcxMessage, OcxToolResultMessage } from "../../types"; +import type { OcxAssistantContentPart, OcxMessage, OcxTool, OcxToolResultMessage } from "../../types"; import { namespacedToolName } from "../../types"; import type { CursorRunRequest } from "./types"; import { cursorStructuredOutputInstructions } from "./structured-output"; @@ -224,6 +224,7 @@ function systemPromptBlobs(request: CursorRunRequest): RootBlobCandidate[] { const cursorToolGuidance = buildCursorToolGuidanceSystemNote( cursorToolsForActivePrompt(request.tools, activePromptText(request), request.toolChoice), request.toolChoice, + request.modelId, ); if (cursorToolGuidance) prompts.push(cursorToolGuidance); return prompts.map(content => rootBlobCandidate({ role: "system", content }, "system")); @@ -1299,12 +1300,13 @@ function toolCallStep( requestScope: CursorBlobRequestScopeToken, result?: OcxToolResultMessage, codeMode = false, + catalog?: readonly OcxTool[], ): Uint8Array { const args: Record = {}; for (const [key, value] of Object.entries(part.arguments ?? {})) args[key] = argBytes(value); // Replay the same provider-isolated identity advertised in this request. Returned calls are // restored to the client name, so transcript parts carry the client name again on the next turn. - const toolName = cursorToolWireName(part); + const toolName = cursorToolWireName(part, catalog); const decodedResult = result ? decodeResultParts(result) : undefined; const serialize = (maxImages: number): Uint8Array => toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { @@ -1357,8 +1359,12 @@ function toolResultPart(message: OcxToolResultMessage, codeMode: boolean, decode }); } -function assistantStep(part: OcxAssistantContentPart, requestScope: CursorBlobRequestScopeToken): Uint8Array | undefined { - if (part.type === "toolCall") return toolCallStep(part, requestScope); +function assistantStep( + part: OcxAssistantContentPart, + requestScope: CursorBlobRequestScopeToken, + catalog?: readonly OcxTool[], +): Uint8Array | undefined { + if (part.type === "toolCall") return toolCallStep(part, requestScope, undefined, false, catalog); if (part.type === "thinking") { return storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { @@ -1409,7 +1415,7 @@ function conversationTurns( const flush = () => { if (!current) return; for (const part of pendingToolCalls.values()) { - current.steps.push(toolCallStep(part, requestScope, missingToolResultFor(part), codeMode)); + current.steps.push(toolCallStep(part, requestScope, missingToolResultFor(part), codeMode, request.tools)); } turns.push(storeCursorBlob(toBinary(ConversationTurnStructureSchema, create(ConversationTurnStructureSchema, { turn: { @@ -1451,7 +1457,7 @@ function conversationTurns( pendingToolCalls.set(part.id, part); continue; } - const step = assistantStep(part, requestScope); + const step = assistantStep(part, requestScope, request.tools); if (step) current.steps.push(step); } continue; @@ -1478,7 +1484,7 @@ function conversationTurns( } const priorCall = pendingToolCalls.get(message.toolCallId); if (priorCall) { - current.steps.push(toolCallStep(priorCall, requestScope, message, codeMode)); + current.steps.push(toolCallStep(priorCall, requestScope, message, codeMode, request.tools)); pendingToolCalls.delete(message.toolCallId); } else { current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, { diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index bce63a78e3a..2b693f53f72 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -102,8 +102,8 @@ export function applyCursorToolBudget( const tryKeep = (tool: OcxTool): boolean => { if (keptSet.has(tool) || kept.length >= CURSOR_TOOL_COUNT_LIMIT) return keptSet.has(tool); // Repeated protobuf message fields serialize as concatenated tag/length/value entries, - // so each one-entry wrapper size is the exact additive contribution to McpTools. - const candidateBytes = cursorMcpToolEncodedSize(tool, toolChoice); + // so each wrapper uses the same catalog-dependent names and choice as registration. + const candidateBytes = cursorMcpToolEncodedSize(tool, toolChoice, eligible); if (keptBytes + candidateBytes > CURSOR_TOOL_BYTES_LIMIT) return false; kept.push(tool); keptSet.add(tool); @@ -129,7 +129,7 @@ export function applyCursorToolBudget( if (!occupant || isCursorExecutionPathTool(occupant)) continue; kept.splice(i, 1); keptSet.delete(occupant); - keptBytes -= cursorMcpToolEncodedSize(occupant, toolChoice); + keptBytes -= cursorMcpToolEncodedSize(occupant, toolChoice, eligible); if (kept.length < CURSOR_TOOL_COUNT_LIMIT && keptBytes + needBytes <= CURSOR_TOOL_BYTES_LIMIT) { return; } @@ -141,7 +141,7 @@ export function applyCursorToolBudget( // earlier same-priority pins; evict wait/patch/filler rather than ship wait-only. for (const tool of eligible) { if (!isCursorExecutionPathTool(tool) || keptSet.has(tool)) continue; - const need = cursorMcpToolEncodedSize(tool, toolChoice); + const need = cursorMcpToolEncodedSize(tool, toolChoice, eligible); if (need > CURSOR_TOOL_BYTES_LIMIT) continue; evictNonExecutionPath(need); tryKeep(tool); @@ -157,7 +157,7 @@ export function applyCursorToolBudget( keptSet.delete(tool); const index = kept.indexOf(tool); if (index >= 0) kept.splice(index, 1); - keptBytes -= cursorMcpToolEncodedSize(tool, toolChoice); + keptBytes -= cursorMcpToolEncodedSize(tool, toolChoice, eligible); } } @@ -171,8 +171,8 @@ export function applyCursorToolBudget( function catalogLimitNote(kept: readonly OcxTool[], omitted: readonly OcxTool[]): string | undefined { if (omitted.length === 0) return undefined; - const recoverable = kept.some(tool => tool.toolSearch || cursorToolWireName(tool) === "tool_search"); - const names = omitted.slice(0, 12).map(cursorToolWireName); + const recoverable = kept.some(tool => tool.toolSearch || cursorToolWireName(tool, kept) === "tool_search"); + const names = omitted.slice(0, 12).map(tool => cursorToolWireName(tool, kept)); const remainder = omitted.length - names.length; const omittedSummary = `${names.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`; return recoverable @@ -509,8 +509,21 @@ export function createCursorRequest( ): CursorRunRequest { const messages = cursorRequestMessagesFromRaw(parsed.context.messages); const activeText = [...messages].reverse().find(message => message.role === "user" || message.role === "developer")?.content ?? ""; - const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice); - const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice); + const catalog = parsed.context.tools ?? []; + const originalChoice = parsed.options.toolChoice; + // Resolve accepted bare wire aliases before filtering can remove their shell-bridge context. + // Keep the original selection so a semantic name cannot widen to a namespaced sibling. + const selectedTools = catalog.filter(tool => cursorToolAllowedByChoice(tool, originalChoice, catalog)); + const semanticChoiceName = (name: string): string => selectedTools.find(tool => + !tool.namespace && cursorToolWireName(tool, catalog) === name + && cursorToolAllowedByChoice(tool, { name }, catalog))?.name ?? name; + const toolChoice = originalChoice && typeof originalChoice === "object" + ? "name" in originalChoice + ? { ...originalChoice, name: semanticChoiceName(originalChoice.name) } + : { ...originalChoice, allowedTools: originalChoice.allowedTools.map(semanticChoiceName) } + : originalChoice; + const visibleTools = cursorToolsForActivePrompt(selectedTools, activeText, toolChoice); + const budget = applyCursorToolBudget(visibleTools, toolChoice); const limitNote = catalogLimitNote(budget.tools, budget.omitted); const model = normalizeCursorModelId( parsed.modelId, @@ -536,7 +549,7 @@ export function createCursorRequest( ...(budget.tools.length === 0 && !cursorClientThreadOwner(parsed) ? { suppressDefaultCursorToolCatalog: true } : {}), - ...(parsed.options.toolChoice ? { toolChoice: parsed.options.toolChoice } : {}), + ...(toolChoice ? { toolChoice } : {}), ...(parsed.options.parallelToolCalls !== undefined ? { parallelToolCalls: parsed.options.parallelToolCalls } : {}), }; const resolved = resolveCursorCheckpoint(parsed, request, options); diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 12be95cdf53..0942d9a08a8 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -4,7 +4,7 @@ import type { OcxRequestOptions, OcxTool } from "../../types"; import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from "./gen/agent_pb"; import { CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, cursorRequestAdvertisesApplyPatch, cursorToolAllowedByChoice, cursorToolWireName, OCX_RESPONSES_TOOL_PROVIDER } from "./tool-naming"; import { CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, cursorToolInputSchema } from "./tool-schemas"; -export { OCX_RESPONSES_TOOL_PROVIDER, CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL, CODEX_UNIFIED_EXEC_TOOL, CODEX_WAIT_TOOL, CODEX_APPLY_PATCH_TOOL, CODEX_TOOL_SEARCH_TOOL, CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, CURSOR_STRUCTURED_EDIT_TOOLS, CURSOR_EXEC_COMMAND_TOOL, CODEX_SHELL_BRIDGE_TOOL_NAMES, isCodexShellBridgeToolName, resolveShellBridgeAliasKey, cursorToolChoiceAliases, isBareCodexShellBridgeTool, isCursorExecutionPathTool, isCursorWaitTool, isCursorCodeModeExecTool, cursorRequestUsesCodeMode, cursorRequestHasShellAlias, cursorRequestAdvertisesApplyPatch, isCursorStructuredEditToolName, isCursorSyntheticStructuredEditTool, cursorToolWireName, normalizeCursorWireName, normalizeCursorTextToolMarkers, responsesToolNameFromCursorWire, cursorToolAllowedByChoice } from "./tool-naming"; +export { OCX_RESPONSES_TOOL_PROVIDER, CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL, CODEX_UNIFIED_EXEC_TOOL, CODEX_WAIT_TOOL, CODEX_APPLY_PATCH_TOOL, CODEX_TOOL_SEARCH_TOOL, CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, CURSOR_STRUCTURED_EDIT_TOOLS, CURSOR_EXEC_COMMAND_TOOL, CODEX_SHELL_BRIDGE_TOOL_NAMES, isCodexShellBridgeToolName, resolveShellBridgeAliasKey, cursorToolChoiceAliases, isBareCodexShellBridgeTool, isCursorExecutionPathTool, isCursorWaitTool, isCursorCodeModeExecTool, cursorRequestUsesCodeMode, cursorRequestHasShellAlias, cursorRequestAdvertisesApplyPatch, isCursorStructuredEditToolName, isCursorSyntheticStructuredEditTool, CLAUDE_CLIENT_BARE_TOOL_NAMES, isClaudeClientBareToolName, isCursorBareClientToolWireAliased, cursorToolWireName, normalizeCursorWireName, normalizeCursorTextToolMarkers, responsesToolNameFromCursorWire, cursorToolAllowedByChoice } from "./tool-naming"; export { CURSOR_EXEC_COMMAND_INPUT_SCHEMA, CURSOR_FREEFORM_INPUT_SCHEMA, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, cursorToolInputSchema, cursorToolArgNormalizeSchema, shellBridgeRequiredCommandKeys, defaultShellBridgeArgNormalizeSchema, cursorShellBridgeDropError, nonEmptyShellBridgeCommandFromArgs, cursorShellBridgeArgsValid } from "./tool-schemas"; export { CURSOR_SHELL_ALIAS_SYSTEM_NOTE, CURSOR_GENERIC_TOOL_USE_USER_HINT, isGenericToolUseCountDemoPrompt, requestedCursorToolUseCount, shouldAppendCursorGenericToolUseHint, appendCursorGenericToolUseHint, shouldUseNativeExecOnlyForGenericToolUse, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote } from "./tool-guidance"; @@ -80,10 +80,11 @@ export function encodeCursorInputSchema(schema: unknown): Uint8Array { export function buildCursorToolDefinitions( tools: readonly OcxTool[] | undefined, toolChoice?: OcxRequestOptions["toolChoice"], + catalog: readonly OcxTool[] = tools ?? [], ): McpToolDefinition[] { if (!tools?.length) return []; - return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools)).map(tool => { - const wireName = cursorToolWireName(tool); + return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog)).map(tool => { + const wireName = cursorToolWireName(tool, catalog); return create(McpToolDefinitionSchema, { name: wireName, toolName: wireName, @@ -98,15 +99,17 @@ export function buildCursorToolDefinitions( export function cursorMcpToolsEncodedSize( tools: readonly OcxTool[] | undefined, toolChoice?: OcxRequestOptions["toolChoice"], + catalog: readonly OcxTool[] = tools ?? [], ): number { - const definitions = buildCursorToolDefinitions(tools, toolChoice); + const definitions = buildCursorToolDefinitions(tools, toolChoice, catalog); return toBinary(McpToolsSchema, create(McpToolsSchema, { mcpTools: definitions })).byteLength; } -/** Exact additive contribution of one repeated McpToolDefinition entry. */ +/** Exact additive contribution of one entry, using the registration catalog for wire naming and choice. */ export function cursorMcpToolEncodedSize( tool: OcxTool, toolChoice?: OcxRequestOptions["toolChoice"], + catalog: readonly OcxTool[] = [tool], ): number { - return cursorMcpToolsEncodedSize([tool], toolChoice); + return cursorMcpToolsEncodedSize([tool], toolChoice, catalog); } diff --git a/src/adapters/cursor/tool-guidance.ts b/src/adapters/cursor/tool-guidance.ts index 16daf244b89..f8df7223bc3 100644 --- a/src/adapters/cursor/tool-guidance.ts +++ b/src/adapters/cursor/tool-guidance.ts @@ -1,6 +1,7 @@ import type { OcxRequestOptions, OcxTool } from "../../types"; +import { CURSOR_TOOL_CALL_CONTINUATION, cursorUsesPlainToolWording } from "./tool-wording"; import { CODE_MODE_HOST_CONTRACT_SENTENCE, CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize"; -import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EXEC_TOOL, clientSemanticToolNameFromCursorWire, cursorRequestAdvertisesApplyPatch, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolAllowedByChoice, cursorToolWireName, isCodexShellBridgeToolName, isCursorExecutionPathTool, isCursorStructuredEditToolName } from "./tool-naming"; +import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EXEC_TOOL, clientSemanticToolNameFromCursorWire, cursorRequestAdvertisesApplyPatch, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolAllowedByChoice, cursorToolWireName, isBareCodexShellBridgeTool, isClaudeClientBareToolName, isCodexShellBridgeToolName, isCursorExecutionPathTool, isCursorStructuredEditToolName } from "./tool-naming"; export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE = 'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell.'; @@ -129,7 +130,15 @@ function advertisedCoversNeighbor(wireNames: readonly string[], neighbor: (typeo return NEIGHBOR_AGENT_TOOL_ALIASES[neighbor].some(alias => advertised.has(alias.toLowerCase())); } -function unavailableNeighborAgentToolNames(wireNames: readonly string[]): string[] { +function unavailableNeighborAgentToolNames( + wireNames: readonly string[], + catalog?: readonly Pick[], +): string[] { + if ( + catalog + && !catalog.some(isBareCodexShellBridgeTool) + && catalog.some(tool => !tool.namespace && isClaudeClientBareToolName(tool.name)) + ) return []; return NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertisedCoversNeighbor(wireNames, name)); } @@ -144,12 +153,14 @@ function discoveryToolLabel(wireNames: readonly string[]): string | undefined { export function buildCursorToolGuidanceSystemNote( tools: readonly Pick[] | undefined, toolChoice?: OcxRequestOptions["toolChoice"], + modelId?: string, ): string | undefined { if (!tools?.length) return undefined; + const plainToolWording = cursorUsesPlainToolWording(modelId); const wireNames = [...new Set( tools .filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools)) - .map(tool => cursorToolWireName(tool)), + .map(tool => cursorToolWireName(tool, tools)), )]; if (wireNames.length === 0) return undefined; @@ -170,7 +181,7 @@ export function buildCursorToolGuidanceSystemNote( ?.filter(tool => !tool.namespace && isCursorStructuredEditToolName(tool.name)) .map(tool => tool.name) ?? []; const discoveryTools = discoveryToolLabel(wireNames); - const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames); + const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames, tools); // Host-shell-neutral: the Codex client executes bridge commands, and may differ from // the OpenCodex proxy OS (LAN/SSH remote-proxy). Always cover PowerShell 5.1 pitfalls. const hostShellNote = hasBareExec @@ -179,6 +190,9 @@ export function buildCursorToolGuidanceSystemNote( const notes = [ `Cursor tool calls: available tool names are exactly ${listedNames}.`, "Use the current tool catalog as ground truth and call only those exact names with their listed argument keys.", + plainToolWording && !codeMode && !hasBareExec + ? "Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not available in this request. Use the listed tool that fits the operation." + : undefined, unavailableNeighborNames.length > 0 ? `This turn does not expose neighboring-agent tool names ${quotedNames(unavailableNeighborNames)}; do not call or suggest them unless the catalog lists them.` : undefined, @@ -191,18 +205,24 @@ export function buildCursorToolGuidanceSystemNote( ? CODE_MODE_RESULT_ECHO_SENTENCE + " There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers. " + CODE_MODE_HOST_CONTRACT_SENTENCE : undefined, codeMode - ? "NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces." + ? plainToolWording + ? "Cursor-native Shell, Read, Grep, List, and Write are not available in this request. The exec code cell and its nested helpers provide the execution path; separately listed tools remain callable at the top level." + : "NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces." : undefined, hasBareExec ? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.` : undefined, hasBareExec - ? "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user." + ? plainToolWording + ? "Your tool list may display the same shell bridge under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; call the name listed in the catalog." + : "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user." : undefined, hasBareExec - ? `NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. ${shellBridgeLabel} is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces ("native is blocked, using the bridge instead") — there is exactly one surface.` + ? plainToolWording + ? `Cursor-native Shell, Read, Grep, List, and Write are not available in this request. ${shellBridgeLabel} provides shell execution through the Codex client host.` + : `NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. ${shellBridgeLabel} is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces ("native is blocked, using the bridge instead") — there is exactly one surface.` : undefined, - hasBareExec + hasBareExec && !plainToolWording ? "Tool-selection commentary is forbidden: for any shell, read, grep, list, or file operation, your FIRST visible action is the bridge call itself — never a sentence about which tool you will use, which tool was redirected, or switching surfaces. Words like 차단/전환/blocked/switching must not appear in your output for tool-routing reasons." : undefined, hostShellNote, @@ -233,5 +253,6 @@ export function buildCursorToolGuidanceSystemNote( ? `For every file read, directory listing, grep, or shell operation use ${shellBridgeLabel} directly with host-shell-safe commands (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). For file edits, use ${structuredEditNames.length > 0 ? `the structured edit tools (${quotedNames(structuredEditNames)}) or ` : ""}\`apply_patch\` when available.` : undefined, ].filter((note): note is string => typeof note === "string"); + if (plainToolWording) notes.push(CURSOR_TOOL_CALL_CONTINUATION); return notes.join(" "); } diff --git a/src/adapters/cursor/tool-naming.ts b/src/adapters/cursor/tool-naming.ts index a36fb3a4f2c..4f5f3f3d499 100644 --- a/src/adapters/cursor/tool-naming.ts +++ b/src/adapters/cursor/tool-naming.ts @@ -67,11 +67,11 @@ function cursorToolChoiceMatches( if (catalogHasBareCodexShellBridge(catalog)) { return isBareCodexShellBridgeTool(tool); } - return tool.name === choiceName || cursorToolWireName(tool) === choiceName; + return tool.name === choiceName || cursorToolWireName(tool, catalog) === choiceName; } if (tool.name === choiceName) return true; if (cursorToolChoiceAliases(tool).includes(choiceName)) return true; - return cursorToolWireName(tool) === choiceName + return cursorToolWireName(tool, catalog) === choiceName && !catalog.some(candidate => candidate.name === choiceName); } @@ -178,16 +178,49 @@ const CURSOR_PROXY_OWNED_BARE_TOOL_NAMES = new Set([ CODEX_TOOL_SEARCH_TOOL, ]); +export const CLAUDE_CLIENT_BARE_TOOL_NAMES = new Set([ + "Bash", + "Read", + "Write", + "Edit", + "Grep", + "Glob", + "Task", + "GetDynamicTools", + "EnterMode", + "ExitMode", + "Todo", + "AddTodo", + "UpdateTodo", + "ViewTodo", + "WebSearch", + "FetchUrl", + "KillProcess", + "ListProcesses", + "ViewProcessOutput", +]); + +export function isClaudeClientBareToolName(name: string): boolean { + return CLAUDE_CLIENT_BARE_TOOL_NAMES.has(name); +} + /** Avoid collisions with Cursor's private bare-tool namespace. */ -function isCursorBareClientToolWireAliased( +export function isCursorBareClientToolWireAliased( tool: Pick, + catalog?: readonly Pick[], ): boolean { - return !tool.namespace - && !CURSOR_PROXY_OWNED_BARE_TOOL_NAMES.has(tool.name); + if (tool.namespace || CURSOR_PROXY_OWNED_BARE_TOOL_NAMES.has(tool.name)) return false; + if (isClaudeClientBareToolName(tool.name)) { + return catalog ? catalogHasBareCodexShellBridge(catalog) : false; + } + return true; } -export function cursorToolWireName(tool: Pick): string { - if (isCursorBareClientToolWireAliased(tool)) { +export function cursorToolWireName( + tool: Pick, + catalog?: readonly Pick[], +): string { + if (isCursorBareClientToolWireAliased(tool, catalog)) { return `${CURSOR_CLIENT_TOOL_WIRE_PREFIX}${tool.name}`; } return namespacedToolName(tool.namespace, tool.name); diff --git a/src/adapters/cursor/tool-wording.ts b/src/adapters/cursor/tool-wording.ts new file mode 100644 index 00000000000..be35eb1acbc --- /dev/null +++ b/src/adapters/cursor/tool-wording.ts @@ -0,0 +1,46 @@ +import type { OcxTool } from "../../types"; +import type { McpToolDefinition } from "./gen/agent_pb"; +import { CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize"; +import { OCX_RESPONSES_TOOL_PROVIDER, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolWireName } from "./tool-naming"; + +/** Cursor may expose Claude with version-first, family-first, or effort-suffixed ids. */ +export function cursorUsesPlainToolWording(modelId?: string): boolean { + return /^(?:cursor\/)?claude-/i.test(modelId?.trim() ?? ""); +} + +export const CURSOR_TOOL_CALL_CONTINUATION = "Continue the task with that tool call."; +const CURSOR_NO_CLIENT_TOOLS = "Cursor-native filesystem, shell, and fetch tools are not available through this execution channel. No client tools are available in this request. Answer without tools using the information already provided, or report that the requested operation cannot be completed without tools."; + +/** Factual catalog wording; legacy model wording is owned by native-exec.ts unchanged. */ +export function cursorPlainNativeExecRedirectHint( + tools: readonly Pick[] | undefined, + mcpToolDefs: readonly Pick[], +): string | undefined { + const clientTools = tools ?? []; + const names = [...new Set([ + ...clientTools.map(tool => cursorToolWireName(tool, clientTools)), + ...mcpToolDefs.map(def => `mcp_${def.providerIdentifier}_${def.name}`), + ])]; + if (names.length === 0) return CURSOR_NO_CLIENT_TOOLS; + const shown = names.slice(0, 16).map(name => `\`${name}\``).join(", "); + const more = names.length > 16 ? ` (+${names.length - 16} more)` : ""; + const catalog = `Available tools in this request's catalog: ${shown}${more}. ` + + `The harness displays a \`${OCX_RESPONSES_TOOL_PROVIDER}\` entry as \`mcp_${OCX_RESPONSES_TOOL_PROVIDER}_\`; that is the same tool. ` + + "Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not available in this request. "; + if (!cursorRequestHasShellAlias(clientTools) && cursorRequestUsesCodeMode(clientTools)) { + return catalog + "`exec` takes a JavaScript body evaluated in a V8 isolate, not a shell command. Shell, file, search, and fetch are nested helpers called inside it as `await tools.(...)`, for example `text(await tools.exec_command({cmd: \"ls\"}))`. " + + "`shell_command` and `exec_command` are nested helpers, not top-level tools here. Every other listed tool remains callable at the top level. " + + CODE_MODE_RESULT_ECHO_SENTENCE + " " + CURSOR_TOOL_CALL_CONTINUATION; + } + const operation = cursorRequestHasExecutionPath(clientTools) + ? "Use the listed execution tool with its advertised argument schema and the Codex client host's shell syntax; use a listed edit tool for file edits. " + : "Use the listed tool that fits the operation: a file, search, or fetch tool when provided, or a listed worker-agent delegation tool. "; + return catalog + operation + CURSOR_TOOL_CALL_CONTINUATION; +} + +/** Fallback uses advertised dispatcher definitions, never assumed shell/edit tools. */ +export function cursorPlainNativeExecFallback( + toolDefs: readonly Pick[] = [], +): string { + return cursorPlainNativeExecRedirectHint([], toolDefs)!; +} diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 584703593c4..4c93aa8e452 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -524,30 +524,44 @@ export function mapOcxMessagesToDevin( if (system) items.push({ role: "system", content: system }); let previousToolResult: ChatHistoryItem | undefined; + let textChunks: string[] | undefined; + const flushText = () => { + if (previousToolResult && textChunks) previousToolResult.content = textChunks.join("\n\n"); + textChunks = undefined; + }; for (const message of parsed.context.messages) { // Original-message adjacency matters even when an intervening message maps to nothing. - if (message.role !== "toolResult") previousToolResult = undefined; + if (message.role !== "toolResult") { + flushText(); + previousToolResult = undefined; + } const mapped = mapOneMessage(message, parsed.modelId, options); if (!mapped) continue; if (mapped.role === "tool") { if (previousToolResult && previousToolResult.tool_call_id === mapped.tool_call_id) { const previous = previousToolResult; - previous.content = typeof previous.content === "string" && typeof mapped.content === "string" - ? `${previous.content}\n\n${mapped.content}` - : [ - ...(typeof previous.content === "string" - ? [{ type: "text" as const, text: previous.content }] : previous.content), - { type: "text", text: "\n\n" }, - ...(typeof mapped.content === "string" - ? [{ type: "text" as const, text: mapped.content }] : mapped.content), - ]; + if (textChunks && typeof mapped.content === "string") { + textChunks.push(mapped.content); + } else { + flushText(); + // mapOneMessage owns these wire arrays; append without recopying their prefix. + const parts: ContentPart[] = typeof previous.content === "string" + ? [{ type: "text", text: previous.content }] : previous.content; + parts.push({ type: "text", text: "\n\n" }); + if (typeof mapped.content === "string") parts.push({ type: "text", text: mapped.content }); + else for (const part of mapped.content) parts.push(part); + previous.content = parts; + } if (mapped.is_error) previous.is_error = true; continue; } + flushText(); previousToolResult = mapped; + textChunks = typeof mapped.content === "string" ? [mapped.content] : undefined; } items.push(mapped); } + flushText(); return items; } @@ -795,7 +809,7 @@ export function createDevinAdapter( // An admitted HTTP turn owns globally shared capacity until this call // emits. Without an explicit wait allowance, preserve the typed reset // delay in generated diagnostic wording and return immediately. - const signedMessages = mapOcxMessagesToDevin(parsed); + const signedMessages = messages; // Reuse the history already mapped above. // A Claude signature is replayed because it is what carries the reasoning into this // turn, but Cognition streams Claude's thinking as a summary the signature does not // cover, and some replays are refused with invalid_argument before any output. That diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 64cb97d20ec..d7f5c16e697 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -144,7 +144,9 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv const nativeOpenAI = isNativeOpenAIChatTarget(provider); // Qwen3.8-27B's pinned chat template rejects non-leading system and every developer role; // a later user is rendered in place. Keep this exception specific to that template family. - const qwen38LeadingSystemTemplate = !nativeOpenAI && /^(?:Qwen\/)?(?:Qwen3\.8-27B|qwen3-8-27b(?:-(?:fp8|lora))?)$/i.test(parsed.modelId); + // Gateways serve it under namespaced ids (LiteLLM's openai/Qwen3.8-27B, #6674), so any number + // of leading path segments is accepted; the final segment still has to name the checkpoint. + const qwen38LeadingSystemTemplate = !nativeOpenAI && /^(?:[^/]+\/)*(?:Qwen3\.8-27B|qwen3-8-27b(?:-(?:fp8|lora))?)$/i.test(parsed.modelId); // Which role a developer message carries, and why the unrecorded state folds, is stated once // in ./developer-role.ts and read from there by the native passthrough as well. Either way the // message keeps the slot it arrived in — only the role changes, never the position. diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 1b190193362..243473beae6 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -72,6 +72,15 @@ function toolResultOutput(block: Rec): string | Rec[] { if (!isRec(item)) continue; if (item.type === "text" && typeof item.text === "string") { out.push({ type: "input_text", text: item.text }); + } else if (item.type === "tool_reference" && typeof item.tool_name === "string" && item.tool_name.length > 0) { + // Responses has no tool_reference result carrier. Preserve ToolSearch's loaded-name + // evidence as result text so a reference-only reply does not become empty (#6647). + // This describes client output; it must not create tools or widen authorization. + // Text-only tool output is later joined without separators, so the marker carries its + // own line breaks to keep each loaded name distinct from its neighbours. + const prev = out[out.length - 1]; + const lead = prev?.type === "input_text" && typeof prev.text === "string" && !prev.text.endsWith("\n") ? "\n" : ""; + out.push({ type: "input_text", text: `${lead}Tool loaded: ${item.tool_name}\n` }); } else if (item.type === "image") { const img = imageBlockToInputImage(item); if (img) out.push(img); diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index f9cee5c7942..58768c4a42b 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -4,7 +4,7 @@ import type { OcxConfig } from "../types"; import { getCodexRoutingKind, type CodexRoutingKind } from "./inject"; import { collectRoutingAdoption, type RoutingAdoptionEvidence } from "./routing-adoption"; import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim"; -import { diagnoseMacDesktopStartup, type DesktopStartupDiagnostic } from "../service/desktop-startup"; +import { diagnoseDesktopStartup, type DesktopStartupDiagnostic } from "../service/desktop-startup"; export type StartupProtection = "service" | "desktop" | "shim" | "none"; export type StartupHealthStatus = "native" | "protected" | "at-risk"; @@ -90,7 +90,7 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth // We can only credit an opencodex service/shim for routing that opencodex owns. // An arbitrary localhost gateway has an independent lifecycle that OCX cannot repair. const ownsLocalRouting = inputs.routingKind === "opencodex-local"; - const desktopEffective = inputs.platform === "darwin" && !inputs.diagnosticStale + const desktopEffective = (inputs.platform === "darwin" || inputs.platform === "linux") && !inputs.diagnosticStale && inputs.desktop?.owned === true && inputs.desktop.loginEnabled && inputs.desktop.running && inputs.desktop.viable; const protection: StartupProtection = ownsLocalRouting && inputs.serviceViable @@ -147,7 +147,7 @@ export function collectStartupHealth( const shim = diagnostics.shim ?? diagnoseCodexShim(); const service = diagnostics.service ?? diagnoseService(); const routingKind = diagnostics.routingKind ?? getCodexRoutingKind(); - const desktop = diagnostics.desktop ?? diagnoseMacDesktopStartup(); + const desktop = diagnostics.desktop ?? diagnoseDesktopStartup(); const routingAdoption = diagnostics.routingAdoption ?? (routingKind === "opencodex-local" ? collectRoutingAdoption({ routingKind }) : undefined); return deriveStartupHealth({ diff --git a/src/codex/journal.ts b/src/codex/journal.ts index b05f10476e9..384211a07d3 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -85,6 +85,9 @@ export interface RestoreJournalResult { ownershipRefusal?: CodexHomeOwnerRefusalReason; configRestored: boolean; profileRestored: boolean; + /** True only when restore rewrote or removed a file (not when state was already original). */ + configRewritten: boolean; + profileRewritten: boolean; configChanged: boolean; profileChanged: boolean; complete: boolean; @@ -299,12 +302,13 @@ export function restoreJournalState(): RestoreJournalResult { try { assertCodexHomeOwner(CODEX_HOME); } catch (error) { if (!(error instanceof CodexHomeOwnerRefusal)) throw error; - return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, - complete: false, unverified: true, ownershipRefusal: error.reason }; + return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, + configChanged: false, profileChanged: false, complete: false, unverified: true, ownershipRefusal: error.reason }; } const journal = readJournal(); if (!journal) { - return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false }; + return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, + configChanged: false, profileChanged: false, complete: false, unverified: false }; } const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null; const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; @@ -316,6 +320,8 @@ export function restoreJournalState(): RestoreJournalResult { return { configRestored: comparison.configAlreadyOriginal, profileRestored: comparison.profileAlreadyOriginal, + configRewritten: false, + profileRewritten: false, configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete: false, @@ -326,14 +332,18 @@ export function restoreJournalState(): RestoreJournalResult { assertCodexHomeOwner(CODEX_HOME); let configRestored = comparison.configAlreadyOriginal; let profileRestored = comparison.profileAlreadyOriginal; + let configRewritten = false; + let profileRewritten = false; if (configUnchanged && !configRestored) { atomicWriteFile(CODEX_CONFIG_PATH, comparison.originalConfig); configRestored = true; + configRewritten = true; } if (profileUnchanged && !profileRestored) { if (comparison.originalProfile !== null) { atomicWriteFile(CODEX_PROFILE_PATH, comparison.originalProfile); profileRestored = true; + profileRewritten = true; } else if (existsSync(CODEX_PROFILE_PATH)) { // "There was no profile before, so remove the one we generated." Claiming success // without checking is how a caller ends up deleting the journal, reporting a clean @@ -343,6 +353,7 @@ export function restoreJournalState(): RestoreJournalResult { try { unlinkSync(CODEX_PROFILE_PATH); profileRestored = true; + profileRewritten = true; } catch (error) { profileRestored = (error as NodeJS.ErrnoException).code === "ENOENT"; } @@ -355,6 +366,8 @@ export function restoreJournalState(): RestoreJournalResult { return { configRestored, profileRestored, + configRewritten, + profileRewritten, configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete, @@ -367,6 +380,11 @@ export function restoreJournal(): boolean { return restoreJournalState().complete; } +/** A no-rewrite recovery is quiet only when the stale journal is actually gone. */ +function warnIfJournalRetained(): void { + if (existsSync(JOURNAL_PATH)) console.error("⚠️ Codex journal recovery found nothing to restore, but the journal could not be removed; it will be retried on the next start."); +} + export interface ReconcileJournalOptions { activeClientApiKeyId?: string; } @@ -388,7 +406,13 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); return false; } - if (!restored.configRestored && !restored.profileRestored) return false; + if (!restored.complete) { + console.error("⚠️ Codex journal recovery was incomplete; the journal was preserved — check the Codex files."); + return false; + } + // Warn only when restore actually rewrote state; an already-original + // snapshot still cleans itself up but has nothing to report. + if (!restored.configRewritten && !restored.profileRewritten) { warnIfJournalRetained(); return false; } console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`); return true; } @@ -406,7 +430,14 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); return false; } - if (!restored.configRestored && !restored.profileRestored) return false; + if (!restored.complete) { + console.error("⚠️ Codex journal recovery was incomplete; the journal was preserved — check the Codex files."); + return false; + } + // Warn only when restore actually rewrote state. A stale journal whose + // artifacts are already original still cleans itself up (complete removes + // it) but has nothing to report — claiming a restore then cries wolf. + if (!restored.configRewritten && !restored.profileRewritten) { warnIfJournalRetained(); return false; } console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`); return true; } diff --git a/src/codex/shim-overlay.ts b/src/codex/shim-overlay.ts index c35187fd937..22f70d3adca 100644 --- a/src/codex/shim-overlay.ts +++ b/src/codex/shim-overlay.ts @@ -48,11 +48,19 @@ function lexical(path: string): Stats | null { try { return lstatSync(path); } catch (error) { if (fileErrorCode(error) === "ENOENT") return null; throw error; } } +/** Name the path and the failed check; a group/world-writable owned file gets the chmod that fixes it. */ +function untrustedArtifact(path: string, stat: Stats): Error { + const prefix = `Private Codex artifact is not an owned regular file: ${path}`; + if (stat.isSymbolicLink() || !stat.isFile()) return new Error(`${prefix} is not a regular file`); + if (stat.uid !== process.getuid?.()) return new Error(`${prefix} is owned by uid ${stat.uid}`); + const mode = (stat.mode & 0o777).toString(8).padStart(4, "0"); + return new Error(`${prefix} is group- or world-writable (mode ${mode}); run chmod 600 ${shQuote(path)} and retry`); +} function snapshot(path: string): Snapshot | null { const before = lexical(path); if (!before) return null; if (!before.isFile() || before.isSymbolicLink() || before.uid !== process.getuid?.() || (before.mode & 0o022)) { - throw new Error("Private Codex artifact is not an owned regular file"); + throw untrustedArtifact(path, before); } const bounded = readBoundedRegularFile(path, PRIVATE_FILE_MAX_BYTES); if (!bounded || "warning" in bounded) throw new Error("Private Codex artifact is unreadable or changed"); diff --git a/src/combos/jev.ts b/src/combos/jev.ts index d04e9cd0cd6..a9de050f493 100644 --- a/src/combos/jev.ts +++ b/src/combos/jev.ts @@ -47,6 +47,7 @@ const TOOL_NAME_CHARS = 160; const VISIBLE_TEXT_CHUNK_CHARS = 16_384; const ENVELOPE_TAGS = [ + "system-reminder", "codex_internal_context", "recommended_plugins", "environment_context", @@ -302,7 +303,8 @@ function taskWithoutProtectedEnvelopes(text: string): string { } } else { if (stack.length === 0) appendTrimmedRange(visible, "\n", 0, 1); - if (!completedGoal && !goal && tag === "codex_internal_context") { + // A Codex goal quoted inside a Claude reminder is context, never a fallback task. + if (!completedGoal && !goal && tag === "codex_internal_context" && !stack.includes("system-reminder")) { goal = { sample: emptyTextSample(), pendingWhitespace: emptyTextSample() }; goalDepth = stack.length; } diff --git a/src/server/index.ts b/src/server/index.ts index 02d8e03c196..525edba5978 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -463,21 +463,19 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe return serveGuiFile(rawPath) !== null; } - // Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and - // the server_is_overloaded code so clients can back off, but always return a JSON envelope. - // These two run BEFORE the auth/origin checks, so they need the receiving listener's policy - // explicitly (#1102). Reaching for the shared `config` here would attach public-policy CORS - // headers to a 503 on the loopback listener — no model runs and no credential is spent, but - // it is the one error path that would answer a rebinding origin with its own origin echoed - // back. + // Codex maps 503 + server_is_overloaded to ServerOverloaded ("model at capacity"), retried + // only with retry advice that older clients ignore (#6642); server_restarting is a + // retryable UnexpectedStatus in every version and never reads as model capacity. + // Keep JSON (empty / non-JSON 503 means "Unknown error", #452) and Retry-After. + // Drain and busy run BEFORE auth/origin checks: use the receiving listener's policy + // (#1102), because shared public `config` could echo a rebinding origin on loopback. function drainingResponse(req: Request, policy: RequestPolicyView): Response { - const response = formatErrorResponse(503, "server_error", "Service shutting down"); - const headers = new Headers(response.headers); - for (const [name, value] of Object.entries(corsHeaders(req, policy))) { - headers.set(name, value); - } - headers.set("Retry-After", "5"); - return new Response(response.body, { status: 503, headers }); + return withCors(new Response(JSON.stringify({ + error: { type: "server_error", code: "server_restarting", message: "OpenCodex is restarting; retry this request." }, + }), { + status: 503, + headers: { "Content-Type": "application/json", "Retry-After": "5" }, + }), req, policy); } function serverBusyResponse(req: Request, resource: string, policy: RequestPolicyView): Response { diff --git a/src/server/messages-native-scope.ts b/src/server/messages-native-scope.ts index fcc660013fe..ba18004ef16 100644 --- a/src/server/messages-native-scope.ts +++ b/src/server/messages-native-scope.ts @@ -36,7 +36,10 @@ export function nativeMessagesToolScopeDenial( const definition = tool.definition; if (typeof definition.name !== "string") { anonymous.push(definition); continue; } // A temporary replacement keeps the declaration it shadows in scope as well. - definitions.set(definition.name, permanent ? [definition] : [...definitions.get(definition.name) ?? [], definition]); + // These arrays belong to this check, not to the caller's declarations. + const declarations = permanent ? [] : definitions.get(definition.name) ?? []; + declarations.push(definition); + definitions.set(definition.name, declarations); withdrawn.delete(definition.name); } else if (tool.type === "tool_reference" && typeof tool.name === "string") { if (block.type === "tool_removal" && permanent) withdrawn.add(tool.name); diff --git a/src/server/responses/combo-requested-effort.ts b/src/server/responses/combo-requested-effort.ts new file mode 100644 index 00000000000..926d22d6da0 --- /dev/null +++ b/src/server/responses/combo-requested-effort.ts @@ -0,0 +1,14 @@ +/** Restore the caller label while retaining JEV's forced effort and later child transitions. */ +export function comboRequestedEffortLabel( + originalRequestedEffort: string | undefined, + childRequestedEffort: string | undefined, + forcedEffort?: string | null, +): string | undefined { + if (originalRequestedEffort === undefined) return childRequestedEffort; + const transitionIndex = childRequestedEffort?.indexOf("->") ?? -1; + const laterTransitions = transitionIndex >= 0 ? childRequestedEffort!.slice(transitionIndex) : ""; + const forcedTransition = forcedEffort != null && forcedEffort !== originalRequestedEffort + ? `->${forcedEffort}` + : ""; + return `${originalRequestedEffort}${forcedTransition}${laterTransitions}`; +} diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 7b34d1eddab..36a4eec59f1 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -106,6 +106,7 @@ import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay"; import { settleOperatorReplacement } from "../../lib/upstream-retry"; import { createComboProtocolLanes, dispatchNativeComboChild } from "./core-combo-native"; import { createJevModelInvoker } from "./jev-model-invoke"; +import { comboRequestedEffortLabel } from "./combo-requested-effort"; import { clientWireOf } from "../inference/client-wire"; /** @@ -656,13 +657,9 @@ export async function executeComboResponses( && isDeclaredReasoningEffort(originalRequestedEffortValue) ? originalRequestedEffortValue : undefined; - const restoreOriginalRequestedEffort = (childLog: RequestLogContext): void => { + const restoreOriginalRequestedEffort = (childLog: RequestLogContext, forcedEffort?: string | null): void => { if (originalRequestedEffort === undefined) return; - const normalizedRequestedEffort = childLog.requestedEffort; - const transitionIndex = normalizedRequestedEffort?.indexOf("->") ?? -1; - childLog.requestedEffort = transitionIndex >= 0 - ? `${originalRequestedEffort}${normalizedRequestedEffort!.slice(transitionIndex)}` - : originalRequestedEffort; + childLog.requestedEffort = comboRequestedEffortLabel(originalRequestedEffort, childLog.requestedEffort, forcedEffort); recordAttemptRequestedEffort(childLog); }; @@ -907,7 +904,8 @@ export async function executeComboResponses( onNativePassthroughCancel: callbackGate.onCancel, onResponseComplete: callbackGate.onResponseComplete, }); - restoreOriginalRequestedEffort(childLog); + // Native Chat rebuilds its own body and does not apply the initial JEV effort. + restoreOriginalRequestedEffort(childLog, nativeChild ? undefined : initialJevDecision?.effort); } catch (error) { callbackGate.discard(); if (options.abortSignal?.aborted) { diff --git a/src/service/desktop-startup.ts b/src/service/desktop-startup.ts index 551bca9507f..e211ff9bf95 100644 --- a/src/service/desktop-startup.ts +++ b/src/service/desktop-startup.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; -import { accessSync, constants, readFileSync, realpathSync } from "node:fs"; +import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { readPid } from "../config/process-state"; import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state"; @@ -29,11 +29,28 @@ interface DesktopStartupDeps { ownership?: () => ServiceOwnershipResolution; readPid?: () => number | null; run?: typeof run; + env?: NodeJS.ProcessEnv; + proc?: ProcReader; } +/** Linux process identity from procfs: executable target and parent pid. */ +interface ProcReader { + exe(pid: number): string; + parent(pid: number): number; +} +const procfs: ProcReader = { + exe: pid => readlinkSync(`/proc/${pid}/exe`), + // comm may contain spaces or parentheses, so fields are counted after the last ')'. + parent: pid => { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]); + }, +}; + /** Cheap ownership-only snapshot: no launchd/process probes on the server request path. */ export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined { - if ((deps.platform ?? process.platform) !== "darwin") return undefined; + const platform = deps.platform ?? process.platform; + if (platform !== "darwin" && platform !== "linux") return undefined; const owner = (deps.ownership ?? resolveServiceOwnership)(); return owner.kind === "owned" && owner.ownership.owner === "desktop" ? deriveDesktopStartup({ owned: true, loginEnabled: false, running: false }) : undefined; @@ -91,3 +108,83 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto return deriveDesktopStartup({ ...facts, running: false }); } } + +/** HOME-based autostart entry written by the desktop app's login item (`tauri-plugin-autostart`). */ +function linuxLoginApp(entry: string): string | null { + const fields = new Map(); + let section = ""; + for (const raw of entry.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("[")) { section = line; continue; } + const at = line.indexOf("="); + if (section === "[Desktop Entry]" && at > 0 && !fields.has(line.slice(0, at).trim())) { + fields.set(line.slice(0, at).trim(), line.slice(at + 1).trim()); + } + } + if (fields.get("Type") !== "Application" || fields.get("Hidden") === "true" + || fields.get("X-GNOME-Autostart-enabled") === "false" + || ["OnlyShowIn", "NotShowIn", "TryExec"].some(key => fields.has(key))) return null; + // Credit only the backend's simple Exec form; desktop quoting/escaping/field codes are unsupported. + const app = /^([^\s"'\\%`><~|&;$*?#()]+) --autostart$/.exec(fields.get("Exec") ?? "")?.[1]; + return app && isAbsolute(app) && basename(app) === "opencodex-desktop" ? app : null; +} + +/** Read-only Linux desktop ownership, HOME login entry and live parent/child checks via procfs. */ +export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined { + if ((deps.platform ?? process.platform) !== "linux") return undefined; + const ownership = deps.ownership ?? resolveServiceOwnership; + const owner = ownership(); + if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined; + const facts = { owned: true, loginEnabled: false, running: false }; + const proc = deps.proc ?? procfs; + const pidReader = deps.readPid ?? readPid; + try { + const env = deps.env ?? process.env; + const home = deps.home ?? homedir(); + const configured = env.XDG_CONFIG_HOME; + const config = configured && isAbsolute(configured) ? configured : join(home, ".config"); + // auto-launch 0.5.0 writes autostart under HOME; Tauri app_config_dir uses XDG for install-id. + // A relocated XDG_CONFIG_HOME puts that entry outside the session's autostart search path. + if (resolve(config) !== resolve(home, ".config")) return deriveDesktopStartup(facts); + const capture = () => { + const id = readFileSync(join(config, "com.opencodex.desktop", "install-id"), "utf8"); + if (id.trim() !== owner.ownership.installId) return null; + const entry = readFileSync(join(home, ".config", "autostart", "OpenCodex.desktop"), "utf8"); + const login = linuxLoginApp(entry); + if (!login) return null; + const app = realpathSync(login); + if (basename(app) !== "opencodex-desktop") return null; + const proxy = realpathSync(join(dirname(app), "ocx")); + if (dirname(proxy) !== dirname(app) || basename(proxy) !== "ocx") return null; + accessSync(app, constants.X_OK); + accessSync(proxy, constants.X_OK); + const pid = pidReader(); + const parent = pid !== null ? proc.parent(pid) : null; + const child = pid !== null ? realpathSync(proc.exe(pid)) : null; + const parentApp = parent !== null && parent > 1 ? realpathSync(proc.exe(parent)) : null; + const running = pid !== null && child === proxy && parentApp === app && pidReader() === pid; + const currentOwner = ownership(); + if (currentOwner.kind === "unknown" || !sameServiceOwnershipSubject(owner, currentOwner)) return null; + return { id, entry, login, app, proxy, pid, parent, child, parentApp, running }; + }; + const first = capture(); + if (!first) return deriveDesktopStartup(facts); + const final = capture(); + // Re-read the entire evidence chain, including files, targets and the exact parent PID. + // A changed or unreadable snapshot cannot retain protection from the first read. + if (final && JSON.stringify(first) === JSON.stringify(final)) { + facts.loginEnabled = true; + facts.running = final.running; + } + return deriveDesktopStartup(facts); + } catch { + // Unreadable login or procfs evidence never grants protection or releases the claim. + return deriveDesktopStartup({ ...facts, running: false }); + } +} + +/** Platform dispatch for the full (probing) desktop startup diagnostic. */ +export function diagnoseDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined { + return (deps.platform ?? process.platform) === "linux" ? diagnoseLinuxDesktopStartup(deps) : diagnoseMacDesktopStartup(deps); +} diff --git a/src/web-search/header-deadline.ts b/src/web-search/header-deadline.ts new file mode 100644 index 00000000000..5157614bae1 --- /dev/null +++ b/src/web-search/header-deadline.ts @@ -0,0 +1,67 @@ +import { clearableDeadline } from "../lib/abort"; +import { sendTrackingRequestSlot } from "../providers/request-pacing"; +import type { ProviderFetch } from "../server/responses/fetch-helpers"; + +/** One iteration's cumulative header budget; only local pacing admission pauses its clock. */ +export function pacingHeaderDeadline(timeoutMs: number, parent: AbortSignal) { + const controller = new AbortController(); + const signal = AbortSignal.any([parent, controller.signal]); + let remainingMs = timeoutMs; + let armedAt = performance.now(); + let current = clearableDeadline(remainingMs, parent); + const timeoutReason = current.timeoutReason; + let pacingWaits = 0; + let cleared = false; + const relayExpiry = () => { + if (current.didExpire()) controller.abort(timeoutReason); + }; + current.signal.addEventListener("abort", relayExpiry, { once: true }); + + const pause = () => { + if (cleared || pacingWaits++ > 0) return; + remainingMs = Math.max(0, remainingMs - (performance.now() - armedAt)); + current.clear(); + current.signal.removeEventListener("abort", relayExpiry); + // A delayed timer task must not let an already-spent budget escape into the queue. + if (remainingMs === 0 && !signal.aborted) controller.abort(timeoutReason); + }; + const resume = () => { + if (cleared || --pacingWaits > 0 || signal.aborted) return; + armedAt = performance.now(); + current = clearableDeadline(remainingMs, parent); + current.signal.addEventListener("abort", relayExpiry, { once: true }); + }; + + return { + signal, + didExpire: () => signal.aborted && signal.reason === timeoutReason, + clear: () => { + cleared = true; + current.clear(); + current.signal.removeEventListener("abort", relayExpiry); + }, + /** Preserve the pacing seams used by adapter physical-send helpers, without double admission. */ + pacedFetch(executor: typeof globalThis.fetch): typeof globalThis.fetch { + const pacing = executor as ProviderFetch; + if (!pacing.waitForPacing) return executor; + const waitForPacing: NonNullable = async (sendSignal) => { + pause(); + try { + return await pacing.waitForPacing!(sendSignal ? AbortSignal.any([signal, sendSignal]) : signal); + } finally { + resume(); + } + }; + const unpacedFetch = pacing.unpacedFetch ?? executor; + const wrapped = async (input: Parameters[0], init?: RequestInit) => { + const slot = await waitForPacing(init?.signal ?? undefined); + return sendTrackingRequestSlot(slot, () => { + signal.throwIfAborted(); + init?.signal?.throwIfAborted(); + return unpacedFetch(input, init); + }); + }; + return Object.assign(wrapped, { preconnect: executor.preconnect, waitForPacing, unpacedFetch }) as ProviderFetch; + }, + }; +} diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 2af4c4bb301..940ef296d1f 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -11,7 +11,7 @@ import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor"; import { runGeminiWebSearch } from "./gemini-executor"; import { runExaWebSearch } from "./exa-executor"; import type { WebSearchBackendId } from "./index"; -import { clearableDeadline } from "../lib/abort"; +import { pacingHeaderDeadline } from "./header-deadline"; import { redactSecretString } from "../lib/redact"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; @@ -455,10 +455,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise` text lines in the paired `function_call_output`, preserving mixed-content order and the error marker. Each marker ends with a newline and starts with one after text that does not, because text-only tool output is joined without separators downstream. References describe client output; they do not declare or authorize executable tools, enable translated server-side deferral, or alter native passthrough. `tests/claude-integration/claude-inbound-tool-reference.test.ts` covers reference-only results, mixed/error output, malformed names, caller immutability and the real Responses parser. + `src/claude/inbound.ts` bounds the text-carrier skill-directory probe to 4,096 UTF-16 code units, plus one character to recognize the terminating newline. A longer first line is preserved intact instead of being scanned or stubbed; normal POSIX, Windows, mixed and UNC separators retain their basename matching. The existing 10,000-character payload threshold and `claudeCode.blockedSkills` policy remain: `claude-api` is blocked by default, and an explicit empty list disables elision. Native Anthropic passthrough and tool-call/result pairing are unchanged. `tests/claude-integration/claude-inbound.test.ts` covers the exact 4,096/4,097 boundary and a long newline-free carrier. `src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability. diff --git a/structure/codex-home.md b/structure/codex-home.md index 5fd0211942f..1997cdf2382 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -80,6 +80,17 @@ injected hash. Native content can establish a fresh snapshot; routed content can unverified older original. Existing hash-backed edit preservation and external-provider opt-out remain separate paths. +`src/codex/journal.ts` distinguishes restored state from actual writes with `configRewritten` +and `profileRewritten`; removing a generated profile counts as a write, while already-original +state and an ENOENT removal outcome do not. `reconcileJournal` checks unverified recovery first, +then incomplete recovery, before its silent no-write return. Both dead-process and mismatched-client +recovery warn without file contents or client identity when either artifact remains unrestored, +return false and retain the journal, including when config was rewritten but profile removal failed. +Only complete recovery with an actual write reports a restore; complete already-original recovery +removes the stale journal silently, and warns instead when the journal file is still present +afterwards (`removeJournal` ignores unlink errors such as a Windows lock). Ownership/hash checks and `profileRestoreFailed` remain intact. +`tests/codex-integration/codex-journal-recovery.test.ts` covers these diagnostics and write flags. + The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and `ocx-state` respectively, retaining a read-only root. The image creates owner-only diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 124c872b3ca..6c6b247606a 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -297,7 +297,10 @@ in such a message are always checked, and only a permanent removal withdraws a d Ordinary custom schemas, arguments and historical tool results remain opaque. Provider-only and unrestricted admissions retain their existing scope. `tests/claude-integration/messages-native-scope.test.ts` covers refusal with zero upstream sends -and byte-preserving allowed tool definitions through both credential lanes. +and byte-preserving allowed tool definitions through both credential lanes. Temporary declarations append to checker-owned lists instead of copying +the accumulated prefix, so collection work is linear in the declarations visited. Permanent +replacement, withdrawal and denied-shadow checks remain unchanged; this is not a request-size +or wall-time guarantee. `buildAnthropicMessagesPassthroughRequest` in `src/adapters/anthropic/passthrough.ts` builds the request from that body: the top-level allowlist (`model, messages, system, max_tokens, metadata, diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index bdbd0bfa793..1cc4c3c10f5 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -499,7 +499,7 @@ unvalidated Bun builds is unchanged (`src/lib/bun-stream-caps.ts`). ## Startup safety -Startup safety credits macOS `desktop` protection only after matching the durable app ownership, enabled and loaded login item, and live app-to-bundled-proxy process relationship. This does not claim an independently installed CLI service. Missing or stale evidence remains at risk. The durable desktop claim remains visible when identity, login registration, or supervision fails; service/shim install and repair controls and their copyable commands stay disabled until ownership changes. Recovery guidance asks the user to reopen OpenCodex and check Start at Login. Compiled startup probes use the standalone-aware self-launch argument builder. +Startup safety credits macOS and Linux `desktop` protection only after matching the durable app ownership, enabled and loaded login item (Linux: enabled XDG autostart entry), and live app-to-bundled-proxy process relationship. This does not claim an independently installed CLI service. Missing or stale evidence remains at risk. The durable desktop claim remains visible when identity, login registration, or supervision fails; service/shim install and repair controls and their copyable commands stay disabled until ownership changes. Recovery guidance asks the user to reopen OpenCodex and check Start at Login. Compiled startup probes use the standalone-aware self-launch argument builder. **Startup safety** is reachable by route (`/#startup`) and rendered by the app, but it is not a sidebar entry: it is entered from the dashboard's startup-state row, which links there whether the diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 764e0e5a811..4d72608adbd 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -454,6 +454,18 @@ endpoint can never restart. Focused coverage is `tests/update/update-stop-classi ## Restart handoff +During drain, `src/server/index.ts` rejects new data-plane work with HTTP 503 and +an explicit JSON envelope: `error.type` is `server_error`, `error.code` is +`server_restarting`, and `error.message` is "OpenCodex is restarting; retry this request." +The response keeps `Content-Type: application/json`, `Retry-After: 5`, and the +receiving listener's CORS policy, including on the unauthenticated loopback listener. +Codex maps a 503 with `server_is_overloaded` to `ServerOverloaded` ("Selected model is at +capacity"); current Codex retries it only when retry advice survives mapping, and older +clients do not retry it at all. `server_restarting` falls through to retryable +`UnexpectedStatus` in either case and never reports a restart as model capacity. This drain-only +response bypasses the shared provider-overload mapping in `src/lib/errors.ts`. +`tests/codex-integration/issue-452-empty-503.test.ts` pins its body and both listeners' CORS. + A dashboard drain-and-restart (`src/server/management/system-restart.ts`, which is also the restart after a join into a Child) and the client runtime's standalone recycle (`src/client/runtime.ts`) replace their process through `src/server/restart-replacement.ts`. Every replacement `ocx start` @@ -509,6 +521,6 @@ src/update/async-check.ts uses the existing owner-bound registry target with a b The desktop badge snapshot in src/update/desktop-badge.ts is process-local display state keyed by a Tauri session id. A 60-second shell heartbeat renews receipt time; entries expire after 180 seconds and the store retains at most 32 sessions. It is separate from the package version cache and from the updater job/ownership transaction. A proxy restart reports unknown until a bound desktop shell republishes; no update installation can be authorized by this snapshot. -MacOS desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, launchd login registration and exact parent/child executable paths without mutating them. A durable desktop claim survives a failed identity or supervision check; only fresh matching identity, enabled login registration, and live supervision grant protection. Ownership and PID are re-read before crediting the result. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. +Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; only fresh matching identity, enabled login registration, and live supervision grant protection. On macOS, ownership and PID are re-read before crediting the result; on Linux, the complete evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 6fe108beb52..3bd23a59d0a 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,6 +1,6 @@ # Providers And Adapters -Devin combines only [consecutive same-ID tool results](adapters/registry.md#devin-consecutive-tool-results), preserving intervening message slots, image parts and error markers without mutating the parsed request. +Devin combines only [consecutive same-ID tool results](adapters/registry.md#devin-consecutive-tool-results), using linear accumulation while preserving intervening message slots, image parts and error markers without mutating the parsed request. Anthropic account pause, model routes, and quota labels follow the [Anthropic account-pool contract](providers/anthropic-account-pool.md). Devin Messages follows the [per-turn output ordering contract](clients/claude-desktop.md#devin-messages-output-ordering), preserving late signatures before text/tools without changing Responses or Chat ordering. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index df366b1281a..808e8b02f8e 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -31,7 +31,7 @@ against `api.openai.com`, so every OpenAI-compatible gateway was assumed not to role until proven otherwise, and the instruction silently lost `developer` precedence. The translated `Qwen3.8-27B` Chat route follows its [pinned template](https://huggingface.co/Qwen/Qwen3.8-27B/blob/1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0/chat_template.jinja): a late `system` raises, `developer` is unsupported, and a late `user` renders in place. -Text-only developer items therefore keep their content and slot but use the `user` wire role, +Gateway-namespaced ids such as LiteLLM's `openai/Qwen3.8-27B` match too. Text-only developer items therefore keep their content and slot but use the `user` wire role, losing developer precedence. Other models retain the mapping above; native Chat passthrough is unchanged. The rule is recorded in `tests/fixtures/qwen38-27b-chat-template-contract.json` and exercised by `tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts`. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 319adb626f0..19a500bf880 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -48,10 +48,56 @@ expose; separately listed namespaced tools remain callable as usual. A flat cata bridge or a non-freeform unified `exec` keeps the default bridge wording, and a catalog with no execution path at all keeps naming the request's actual client/MCP wire names. -In external Cursor turns using code mode or shell aliases, the bounded leading-commentary guard in `src/adapters/cursor/envelope-echo.ts` counts `Shell`, `네이티브 셸`, and `네이티브 쉘` as one `shell` identity, including spacing variants and names split across text deltas. Korean aliases require a Unicode-aware left token boundary so wording embedded in a larger word or identifier is not counted; punctuation and following Korean grammatical suffixes remain supported. Rejection still requires a failure claim plus either an explicit redirect or at least two distinct native-tool identities; repeated aliases alone do not count as multiple tools. +Explicit Claude-family targets (`claude-*`, including `cursor/claude-*` selections) receive +factual tool guidance and native-exec redirects from `src/adapters/cursor/tool-wording.ts`: +they name available catalog tools, state that Cursor-native tools are unavailable, and end with +“Continue the task with that tool call.” An empty client/MCP catalog instead states that no client +tools are available and asks for an answer without tools or a report of the limitation; it never +names assumed shell/edit tools. Catalog-less dispatcher fallback uses its advertised definitions. +They impose no tool-routing narration restrictions. `src/adapters/cursor.ts` leaves the routing- +commentary sniffer unarmed for these targets so factual routing prose can precede a tool call; +tool-envelope echo guards remain active under their existing predicates. +`src/adapters/cursor/protobuf-request.ts` uses the final request model for system guidance; +`src/adapters/cursor/live-transport.ts` re-derives the redirect and `plainToolWording` exec flag +on every turn. The flag also covers catalog-less fallback and apply-patch mutation refusals. +Other targets, including Auto/default whose underlying family is unknown, retain byte-identical +legacy wording. Execution policy, tool names, argument schemas, and denial wire shapes are unchanged. +`tests/providers/cursor/cursor-tool-wording.test.ts` covers encoded roots, per-turn target changes, +all denied fs/shell/fetch frame kinds, empty catalogs, factual-prose continuations through the next +tool call, and pre-change non-Claude wording snapshots. + +In non-Claude external Cursor turns using code mode or shell aliases, the bounded leading-commentary guard in `src/adapters/cursor/envelope-echo.ts` counts `Shell`, `네이티브 셸`, and `네이티브 쉘` as one `shell` identity, including spacing variants and names split across text deltas. Korean aliases require a Unicode-aware left token boundary so wording embedded in a larger word or identifier is not counted; punctuation and following Korean grammatical suffixes remain supported. Rejection still requires a failure claim plus either an explicit redirect or at least two distinct native-tool identities; repeated aliases alone do not count as multiple tools. > Decision record: [ADR-0048](../decisions/ADR-0048-cursor-native-exec.md) +## Client tool wire names and transport budget + +`src/adapters/cursor/tool-naming.ts` derives client wire names from the turn's tool catalog. +Recognized bare Claude client tools such as `Bash`, `Read`, `Write`, `Edit`, `Grep`, `Glob`, and +`Task` keep their names when the catalog has no bare Codex `exec_command` or `shell_command` +bridge. When either bare bridge is present, those tools receive the `ocx_client_` prefix to avoid +Cursor-private tool collisions. Other non-proxy-owned bare tools always receive that prefix; +proxy-owned execution/edit tools and namespaced MCP tools retain their existing identities. +Registration, guidance, denied-native-exec redirects, live call mapping, and protobuf history +replay use this catalog-dependent rule. Return mapping restores the client's semantic tool name. +Claude factual redirects resolve each tool against the complete visible request catalog, regardless +of declaration order, so their listed names match registration with or without a bare shell bridge. +`tests/providers/cursor/cursor-tool-wording.test.ts` checks both bridge orders and no-bridge catalogs +through redirect guidance and live transport request preparation. +Request construction resolves accepted bare wire aliases in forced and allowed-tool choices to +semantic names against the original catalog, retaining only the originally selected identities +before prompt and budget filtering so a removed bridge cannot invalidate registration or widen +the choice to a namespaced sibling. + +`src/adapters/cursor/tool-definitions.ts` measures actual repeated protobuf entries using the +same catalog context for tool-choice matching and wire naming as registration. +`src/adapters/cursor/request-builder.ts` passes the eligible catalog to every admission and +eviction size calculation, so both serialized name fields include alias bytes. The transport +budget remains 330 tools and 120,000 bytes. If a bridge is omitted, surviving Claude tools can +use shorter bare names; the eligible-catalog measurement remains conservative in that case. +`tests/providers/cursor/cursor-blob-claude-client-tools.test.ts` covers replay naming and mixed +catalogs exactly at, and immediately above, the byte limit. + ## Structured final output Cursor's Connect wire has no native Responses `text.format` schema field. The adapter preserves diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md index 1913a988cfc..29d67aaa59d 100644 --- a/structure/providers/jev-decision.md +++ b/structure/providers/jev-decision.md @@ -54,7 +54,11 @@ the usage aggregate reports per-backend counts and latency with older rows as `u `src/combos/jev.ts` extracts bounded user-task, previous-assistant, and latest-tool-output text plus the tool name and boolean signals; raw image data, tool arguments, encrypted reasoning, headers, and -the JEV credential are excluded. It owns the joint target/effort choice map, strict response +the JEV credential are excluded. All three text samples omit recognized Codex protected envelopes +and Claude Code `` blocks before clipping, including nested and unclosed blocks. +Only an envelope-only `codex_internal_context` goal outside a reminder may supply a fallback task; +reminder-only text supplies no task. Reminder-free inputs keep their existing sampling behavior. +It owns the joint target/effort choice map, strict response validation, canonical `jev-latest` destination, default four-second deadline, no-redirect policy, bounded response, and caller-cancellation propagation. Missing credentials or safe state, transport failures, and invalid answers fail open to the first eligible target; no response can escape the configured choice map. @@ -75,9 +79,15 @@ applicable decision, so operators must keep secrets and private paths out of it. An absent note leaves the prior decision payload shape intact. `src/server/responses/core-combo.ts` computes current eligibility, asks JEV once for the initial pick, -applies the validated effort, and removes caller `service_tier` for that child. A retryable child -failure re-enters the ordinary Combo fallback loop from the untouched request without another JEV -call. Each target may carry an optional non-empty `reasoningEfforts` allowlist. Omission keeps the +applies the validated effort, and removes caller `service_tier` on the Responses child body. The +opt-in native Chat lane builds a separate body in `src/server/responses/core-combo-native.ts` and +does not apply that JEV effort override. A retryable child failure re-enters the ordinary Combo +fallback loop from the untouched request without another JEV call. Request and attempt +`requestedEffort` labels retain an applied differing JEV effort as `caller->forced`, followed by +later child transitions; native children omit the unapplied override and preserve their own +transitions. Absent or unchanged forced efforts keep the existing label. +`src/server/responses/combo-requested-effort.ts` owns this pure label calculation. +Each target may carry an optional non-empty `reasoningEfforts` allowlist. Omission keeps the backward-compatible all-advertised behavior; a present list is intersected with current capabilities, and an empty intersection removes that target from the JEV choice map rather than broadening it. Direct models and every other Combo strategy bypass this path. The shared Combo editor owns the GUI diff --git a/structure/runtime.md b/structure/runtime.md index a4ba9577f82..d3eac545039 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -143,7 +143,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/cli/tray-proxy.ts` | Proxy restart coordination. Replacement re-observation captures one clock value for both its remaining budget and absolute end, so the observation window never extends the caller deadline. CLI proxy update restart: `src/cli/system-restart-client.ts` preserves a private newer-CLI candidate with its original runtime record and physical home snapshot. `src/cli/update-restart.ts` accepts only known versions and positively classified standalone POSIX targets: PID-1 parent, no installed/active manager, no ownership record, no client/sibling role. It holds the existing ownership mutation lease through stop and spawn and revalidates the captured target before stop. `src/cli/update-restart-transport.ts` proves identity and sends stop on one direct TCP connection, refuses reconnects and requires complete successful teardown acknowledgement. It never signals a PID or reclaims a port. Confirmed predecessor exit, runtime-record removal, port availability and definitive endpoint absence precede the one launch. The executable that launch spawns must pass the shared `REAL_BUN_MIN_BYTES` gate: an incomplete runtime refuses before any stop byte, and after a confirmed stop the transaction waits for it within the deadline, then rechecks deadline and physical home synchronously before spawning. `src/cli/update-restart-child.ts` strips inherited lease delegation, acquires its own lease before CLI preflight and checks the frozen physical homes, ownership revision, CLI version, endpoint and deadline before startup, each bind and publication. It keeps custody through rollback; its deadline ends an unfinished child. `src/cli/update-restart-home.ts` owns physical-home and unclaimed-state checks. Success requires that launched child PID, fresh attestation and the exact known version; all incomplete outcomes terminate without generic restart recovery. Supervised, Windows and uncertain cases refuse this update path. | -| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration; `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | +| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration. Legacy Unix shim migration rejects symlink, foreign-owned, and group/world-writable state files. The writable-mode refusal prints a shell-quoted `chmod 600` hint; it clears only that mode refusal, leaving file-type and ownership checks intact. `tests/codex-integration/codex-shim-migration.test.ts` executes the hint through `/bin/sh` with a space and apostrophe in the state path, then verifies mode `0600` and schema-2 migration. `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects @@ -193,7 +193,7 @@ until shutdown. Normal shutdown restores native Codex; a sibling instance beside `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. -The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. +The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. Startup journal reconciliation, including replacement after restart/drain, follows the [Codex-home recovery contract](codex-home.md#codex-home): already-original recovery is silent, incomplete recovery retains its journal and warns without claiming restoration, and only complete recovery that rewrites or removes an artifact reports a restore; restart admission, drain deadlines and replacement ownership are unchanged. A busy preferred port is never resolved by starting somewhere else. Both questions a start asks about an existing proxy — the pre-bind owner check and the port-is-busy check in `src/cli/index.ts` @@ -206,7 +206,7 @@ holder is refused with the same message the owner check prints (intentional stay `OCX_SERVICE=1`, using the [Windows wrapper protocol](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)), and a holder that does not identify as opencodex is reported as such rather than called foreign, because an identity probe cannot distinguish a foreign server from an unreachable one. An explicit `--port` still never hops — it waits for the -pin through `src/server/port-reclaim.ts` — and a configured `port: 0` still means "ask the OS". A restart replacement that finds its own draining parent waits for it instead ([restart handoff](ops/service-and-sidecars.md#restart-handoff)). +pin through `src/server/port-reclaim.ts` — and a configured `port: 0` still means "ask the OS". A restart replacement that finds its own draining parent waits for it instead ([restart handoff](ops/service-and-sidecars.md#restart-handoff)). During drain, `src/server/index.ts` answers new data-plane work with HTTP 503 JSON `server_restarting`, `Retry-After: 5`, and receiving-listener CORS; the same handoff section owns that response contract. Every `startServer` invocation acquires the `src/lib/spend-ledger-owner.ts` SQLite writer lease for its resolved OpenCodex state directory before loading configuration or binding a listener. @@ -400,7 +400,7 @@ Automatic Codex pool selection and account status share the [plan exclusion cont Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools. `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, only while an iteration remains. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. `src/web-search/run-turn-loop.ts` shares this recovery and checks the ceiling before dispatch so it cannot start an unused request; below the search cap, `emptyCompletionRetry` permits one identical empty-answer retry per request with current tools and results. Errors and invalid terminals never authorize another search. Batched results from one call share one 8,000-character budget in `src/web-search/format-result.ts`, planned per query before serialization (#6621): every query keeps its label and status, shortened answers and unlisted sources carry markers with their original counts outside the untrusted boundary, structured output stays one valid JSON document, and a batch whose fixed parts alone overflow is condensed to status lines with a per-status count of any query that still did not fit. -OpenAI sidecar 429 replays run only when their backoff fits the remaining sidecar deadline; otherwise the original 429 remains the routing-health outcome rather than becoming a timeout. Reset recovery and 429 replays share one three-send budget per search, so the two layers cannot multiply physical sends. Direct vision/search helpers and the alpha/search relay retain the resolver's live [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) through physical sends and retries; a local hold prevents the send without becoming an upstream connection failure. +`src/web-search/loop.ts` uses `src/web-search/header-deadline.ts` to suspend the cumulative response-header deadline while each physical send awaits local pacing admission. Ordinary fetches, adapter executors, reset retries and pool-key rotations retain the remaining budget after admission; same-target 429 backoff still clears and restarts that budget. The default remains 200,000 ms. Client cancellation remains linked during queued admission and after final headers, and the existing pacing slot wrapper retains leases through body completion or cancellation. Streaming retry heartbeats and body inactivity deadlines keep their existing ownership. `tests/web-search/web-search-pacing-deadline.test.ts` covers queued success, real header stalls, queued cancellation, retry admission and cumulative rotation time. OpenAI sidecar 429 replays run only when their backoff fits the remaining sidecar deadline; otherwise the original 429 remains the routing-health outcome rather than becoming a timeout. Reset recovery and 429 replays share one three-send budget per search, so the two layers cannot multiply physical sends. Direct vision/search helpers and the alpha/search relay retain the resolver's live [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) through physical sends and retries; a local hold prevents the send without becoming an upstream connection failure. ## Scoped provider quota for Combo selection `src/providers/quota/report-cache.ts` publishes routing evidence only when a producer explicitly supplies its @@ -543,7 +543,7 @@ Codex compaction uses a request-local model override for the configured triggers state, beside the install provenance. The claim carries an `owner` (`cli` or `desktop`), an opaque `installId` naming the owning installation rather than the user or the machine, and a `consentGeneration`. An absent claim means the CLI install that registered the service owns -the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS desktop claim from startup viability. The bounded probe verifies matching install identity, loaded and enabled login registration, and exact app-to-bundled-proxy process paths; PID or ownership changes fail closed. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance. +the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS and Linux desktop claim from startup viability. The bounded probe verifies matching install identity, loaded and enabled login registration, and exact app-to-bundled-proxy process paths. On Linux, auto-launch 0.5.0 writes the login entry under HOME/.config/autostart, so a relocated XDG_CONFIG_HOME (outside that search path) is uncredited. Conditional entries and ambiguous Exec syntax are uncredited; the resolved desktop basename must match and its resolved ocx stays in the same directory. Both complete evidence reads must agree on ownership, install-id, entry contents, executable targets, PID and parent relationship; changes fail closed. AppImage registrations are uncredited because they name the outer image rather than the mounted executable. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance. Every write goes through `swapServiceInstallState`. With a custom home, the default-home record is the authority every writer can derive and the active-home record is a compatibility diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 8669c912882..dc5434d5337 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -432,7 +432,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; a streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, -`core-combo-failure.ts`, `core-combo-native.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, +`core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, `core-opaque-recovery.ts` and `core-replay.ts`. `core-options.ts` owns the public option types and small composition contracts, including [finite model-refusal evidence](../providers-and-adapters.md#combo-model-refusal-evidence). Existing public helper names are re-exported by `core.ts`. Adapter construction remains with the existing registry; `fetch-helpers.ts` remains a leaf. For Kiro OAuth with load settings, `request-transport.ts` acquires a lease on the admitted account and transfers it before a reactive replacement send; cancellation permanently fences the request holder so recovery cannot install a late lease after abort cleanup. `core.ts` and `core-lifetime.ts` release the lease on returned-body completion, error, or cancellation, outside the inner admission `finally`. @@ -541,9 +541,9 @@ not `openai-chat` is decided without a copy. An eligible child is dispatched thr already opened (its opening stays hand-rolled: the ordinal comes from the parent context while the active attempt and requested effort land on the child context, which `beginInferenceAttempt` does not express). The child gets the combo's per-target send budget, the client's abort signal -and the turn lease, and the combo's reasoning-effort policy mapped onto `reasoning_effort` -through `concreteComboRequestBody`, so the two lanes cannot disagree on effort. It records its -attempt path as native (`[chat, chat]`) and its answer is marked `chat`. +and the turn lease, and the configured combo effort policy mapped onto `reasoning_effort` through `concreteComboRequestBody`. +The initial JEV override changes only the separate Responses body; native labels omit it and keep native child transitions +([JEV Decision Routing](../providers/jev-decision.md)). Its attempt path is native (`[chat, chat]`) and its answer is marked `chat`. Send accounting: the combo's hop reservation already booked the target's first send, so the native child opens no spend tracker; it reports each physical send to the target budget (the first diff --git a/tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts b/tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts index ffed9c45f13..e632ce5e62b 100644 --- a/tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts +++ b/tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts @@ -52,6 +52,9 @@ describe("Qwen3.8-27B leading-system template", () => { // Internal Eliza serves the same pinned template under dashed checkpoint ids. "qwen3-8-27b-fp8", "qwen3-8-27b-lora", + // Gateways prefix the served id with their own namespace (LiteLLM, #6674). + "openai/Qwen3.8-27B", + "hosted_vllm/Qwen/Qwen3.8-27B", ])("keeps a late reminder after the first user without an invalid system role: %s", modelId => { const messages = serialize(modelId); expect(messages).toEqual([ @@ -74,6 +77,10 @@ describe("Qwen3.8-27B leading-system template", () => { expect(serialize("other-model", { ...provider, foldDeveloperRoleToSystem: false })[2]) .toEqual({ role: "developer", content: "Answer in one sentence." }); expect(serialize("Qwen3.8-27B-FP8")[2]).toEqual({ role: "system", content: "Answer in one sentence." }); + expect(serialize("openai/Qwen3.8-27B-FP8")[2]).toEqual({ role: "system", content: "Answer in one sentence." }); + expect(serialize("openai/Qwen3.8-27B-Instruct")[2]).toEqual({ role: "system", content: "Answer in one sentence." }); + expect(serialize("openai/Qwen3.8-27B", { ...provider, baseUrl: "https://api.openai.com/v1" })[2]) + .toEqual({ role: "system", content: "Answer in one sentence." }); expect(serialize("Qwen3.8-27B", { ...provider, baseUrl: "https://api.openai.com/v1" })[2]) .toEqual({ role: "system", content: "Answer in one sentence." }); }); diff --git a/tests/claude-integration/claude-inbound-tool-reference.test.ts b/tests/claude-integration/claude-inbound-tool-reference.test.ts new file mode 100644 index 00000000000..f01427ab741 --- /dev/null +++ b/tests/claude-integration/claude-inbound-tool-reference.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { responsesRequestSchema } from "../../src/responses/schema"; + +function request(content: unknown[], isError = false) { + return { + model: "routed-model", max_tokens: 256, + tools: [{ name: "ToolSearch", input_schema: { type: "object", properties: {} } }], + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "search_1", name: "ToolSearch", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "search_1", is_error: isError, content }] }, + { role: "user", content: "Continue the original task." }, + ], + }; +} + +describe("translated Claude tool references", () => { + test("reference-only ToolSearch results survive schema parsing and retain the call slot", () => { + const raw = request([ + { type: "tool_reference", tool_name: "WebFetch" }, + { type: "tool_reference", tool_name: "mcp__docs__lookup" }, + ]); + const before = JSON.stringify(raw); + const body = anthropicToResponsesBody(raw); + expect(() => responsesRequestSchema.parse(body)).not.toThrow(); + expect(() => parseRequest(body)).not.toThrow(); + expect(body.input).toEqual([ + { type: "function_call", call_id: "search_1", name: "ToolSearch", arguments: "{}" }, + { type: "function_call_output", call_id: "search_1", output: [ + { type: "input_text", text: "Tool loaded: WebFetch\n" }, + { type: "input_text", text: "Tool loaded: mcp__docs__lookup\n" }, + ] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "Continue the original task." }] }, + ]); + expect(JSON.stringify(raw)).toBe(before); + // A result describes client-loaded tools; it must not add executable declarations. + expect(body.tools).toHaveLength(1); + }); + + test("mixed output preserves order and errors without mutating frozen references", () => { + const reference = Object.freeze({ type: "tool_reference", tool_name: "Read" }); + const body = anthropicToResponsesBody(request([ + { type: "text", text: "Before" }, reference, + { type: "image", source: { type: "url", url: "https://example.invalid/fixture.png" } }, + { type: "text", text: "After" }, + ], true)); + expect(body.input[1]).toEqual({ type: "function_call_output", call_id: "search_1", output: [ + { type: "input_text", text: "[tool error]" }, + { type: "input_text", text: "Before" }, + { type: "input_text", text: "\nTool loaded: Read\n" }, + { type: "input_image", image_url: "https://example.invalid/fixture.png" }, + { type: "input_text", text: "After" }, + ] }); + expect(reference).toEqual({ type: "tool_reference", tool_name: "Read" }); + }); + + test("loaded names stay line-separated after the parser joins text-only output", () => { + const parsed = parseRequest(anthropicToResponsesBody(request([ + { type: "text", text: "Before" }, + { type: "tool_reference", tool_name: "Read" }, + { type: "tool_reference", tool_name: "Write" }, + { type: "text", text: "After" }, + ]))); + const result = parsed.context.messages.find((message) => message.role === "toolResult"); + expect(result?.content).toBe("Before\nTool loaded: Read\nTool loaded: Write\nAfter"); + const onlyRefs = parseRequest(anthropicToResponsesBody(request([ + { type: "tool_reference", tool_name: "WebFetch" }, + { type: "tool_reference", tool_name: "mcp__docs__lookup" }, + ]))); + expect(onlyRefs.context.messages.find((message) => message.role === "toolResult")?.content) + .toBe("Tool loaded: WebFetch\nTool loaded: mcp__docs__lookup\n"); + }); + + test("missing, non-string and empty names do not fabricate loaded tools", () => { + const body = anthropicToResponsesBody(request([ + { type: "tool_reference" }, { type: "tool_reference", tool_name: 42 }, + { type: "tool_reference", tool_name: "" }, { type: "text", text: "Still present" }, + ])); + expect(body.input[1]).toEqual({ type: "function_call_output", call_id: "search_1", output: [ + { type: "input_text", text: "Still present" }, + ] }); + }); +}); diff --git a/tests/claude-integration/messages-native-scope.test.ts b/tests/claude-integration/messages-native-scope.test.ts index 729ad69990d..1d64aca4c06 100644 --- a/tests/claude-integration/messages-native-scope.test.ts +++ b/tests/claude-integration/messages-native-scope.test.ts @@ -6,6 +6,7 @@ import { saveConfig } from "../../src/config"; import { saveCredential } from "../../src/oauth/store"; import { clearAnthropicAccountPoolState, forgetAnthropicFailoverQuorum } from "../../src/oauth/anthropic-routing"; import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { nativeMessagesToolScopeDenial } from "../../src/server/messages-native-scope"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -168,3 +169,72 @@ describe("managed native Messages billed tool scope", () => { expect(sent).toHaveLength(0); }); }); + + +// Count prefix traversal instead of timing a busy shared CI runner. +describe("temporary native tool declaration accumulation", () => { + const scope = { providers: [], models: [EXECUTOR] }; + const blocks = (name: string, count: number) => Array.from({ length: count }, () => ({ + type: "tool_addition", tool: { type: "tool_definition", definition: { name } }, + })); + + test("same-name temporary additions do not rewalk the accumulated prefix", () => { + const seed = Object.freeze({ name: "lookup" }); + const additions = blocks(seed.name, 1_024); + const body = { tools: Object.freeze([seed]), messages: [{ + role: "system", clear_at: "next_user_message", content: additions, + }] }; + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator)!; + let visits = 0; + let denial: ReturnType; + try { + Object.defineProperty(Array.prototype, Symbol.iterator, { ...descriptor, + value: function(this: unknown[]) { + const iterator = descriptor.value.call(this) as IterableIterator; + if (this[0] === seed) { + const next = iterator.next.bind(iterator); + iterator.next = () => { + const item = next(); + if (!item.done) visits += 1; + return item; + }; + } + return iterator; + }, + }); + denial = nativeMessagesToolScopeDenial(scope, "operator-anth", EXECUTOR, body); + } finally { Object.defineProperty(Array.prototype, Symbol.iterator, descriptor); } + expect(denial).toBeUndefined(); + expect(visits).toBeLessThanOrEqual(3 * (additions.length + 1)); + expect(body.tools).toEqual([seed]); + }); + + test("large temporary chains retain a denied shadow without changing frozen inputs", () => { + const denied = Object.freeze(advisor()); + const content = blocks("advisor", 2_048); + for (const block of content) { + Object.freeze(block.tool.definition); Object.freeze(block.tool); Object.freeze(block); + } + const temporary = Object.freeze({ role: "system", clear_at: "next_user_message", content: Object.freeze(content) }); + const body = Object.freeze({ tools: Object.freeze([denied]), messages: Object.freeze([temporary]) }); + const snapshot = JSON.stringify(body); + expect(nativeMessagesToolScopeDenial(scope, "operator-anth", EXECUTOR, body)?.deniedModel).toBe(ADVISOR); + expect(JSON.stringify(body)).toBe(snapshot); + // A permanent replacement may remove that shadow; a temporary one may not. + const permanent = { ...body, messages: [...body.messages, addition({ ...custom, name: "advisor" })] }; + expect(nativeMessagesToolScopeDenial(scope, "operator-anth", EXECUTOR, permanent)).toBeUndefined(); + }); + + test("the production native handler preserves dense additions and rejects a denied tail", async () => { + const content: Rec[] = blocks("lookup", 2_048); + const message = { role: "system", clear_at: "next_user_message", content }; + const allowed = await send({ tools: [custom], messages: [user, message] }); + expect(allowed.response.status, allowed.text).toBe(200); + expect(sent).toHaveLength(1); + expect(sent[0]!.messages).toEqual([user, message]); + content.push({ type: "tool_addition", tool: { type: "tool_definition", definition: advisor() } }); + const denied = await send({ tools: [custom], messages: [user, message] }); + expect(denied.response.status, denied.text).toBe(403); + expect(sent).toHaveLength(1); + }); +}); diff --git a/tests/cli/cli-help.test.ts b/tests/cli/cli-help.test.ts index 6065aacfaf6..f48b6ef44e4 100644 --- a/tests/cli/cli-help.test.ts +++ b/tests/cli/cli-help.test.ts @@ -348,8 +348,8 @@ describe("CLI subcommand help", () => { }); test("recover-history repairs one explicitly selected ocx1-compacted thread", () => { - const codexHome = mkdtempSync(join(tmpdir(), "ocx-recover-compaction-")); - const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-recover-compaction-state-")); + const codexHome = mkdtempSync(join(tmpdir(), "ocx-rc-")); + const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-rc-state-")); try { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); const threadId = "01a018e6-242f-7801-81b8-ffc0a5c6d589"; diff --git a/tests/codex-integration/codex-journal-recovery.test.ts b/tests/codex-integration/codex-journal-recovery.test.ts new file mode 100644 index 00000000000..4f3a5846add --- /dev/null +++ b/tests/codex-integration/codex-journal-recovery.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SPAWN_BUDGET_MS); + +const original = '# original config\nmodel_provider = "openai"\n'; +const injected = '# modified\nmodel_provider = "opencodex"\n'; +const profile = '# generated profile\nmodel_provider = "opencodex"\n'; +const deadPid = 1234567; +const incompleteWarning = "Codex journal recovery was incomplete; the journal was preserved — check the Codex files."; +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); + +function runScript(codexHome: string, script: string) { + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot(), + env: { ...process.env, CODEX_HOME: codexHome }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + return { stdout: result.stdout?.trim() ?? "", stderr: result.stderr?.trim() ?? "", status: result.status ?? 1 }; +} + +// The fixture PID is dead by construction, independent of the host's process table. +const deadOwnerScript = ` + const { spyOn } = require("bun:test"); + const originalKill = process.kill; + let probes = 0; + const killSpy = spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === ${deadPid} && signal === 0) { + probes += 1; + throw Object.assign(new Error("fixture owner exited"), { code: "ESRCH" }); + } + return originalKill.call(process, pid, signal); + }); +`; + +describe("codex-journal recovery diagnostics", () => { + let testDir: string; + beforeEach(() => { + testDir = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-journal-recovery-"))); + writeFileSync(join(testDir, "config.toml"), original); + }); + afterEach(() => removeTreeWithRetry(testDir)); + + function seed(owner: "process" | "client", config: string, generatedProfile: boolean) { + writeFileSync(join(testDir, "config.toml"), config); + if (generatedProfile) writeFileSync(join(testDir, "opencodex.config.toml"), profile); + const journal = JSON.stringify({ + version: 1, + originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, + ...(generatedProfile ? { injectedConfigHash: hash(config), injectedProfileHash: hash(profile) } : {}), + pid: deadPid, + ...(owner === "client" ? { owner: { kind: "client", apiKeyId: "fixture-client-owner" } } : {}), + timestamp: new Date().toISOString(), + }); + writeFileSync(join(testDir, "opencodex-journal.json"), journal); + return journal; + } + + for (const owner of ["process", "client"] as const) { + test(`reconcileJournal stays silent when a ${owner === "process" ? "dead-owner" : "client-owner"} journal needs no rewrite`, () => { + seed(owner, original, false); + const r = runScript(testDir, `${deadOwnerScript} + const config = require("./src/config"); + const originalWrite = config.atomicWriteFile; + let writes = 0; + const writeSpy = spyOn(config, "atomicWriteFile").mockImplementation((...args) => { + writes += 1; + return originalWrite(...args); + }); + const { reconcileJournal } = require("./src/codex/journal"); + try { console.log(JSON.stringify({ restored: reconcileJournal(), probes, writes })); } + finally { writeSpy.mockRestore(); killSpy.mockRestore(); } + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ restored: false, probes: owner === "process" ? 1 : 0, writes: 0 }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(false); + expect(r.stderr).toBe(""); + }); + + test(`${owner}-owner no-rewrite recovery warns when the journal cannot be removed`, () => { + const journal = seed(owner, original, false); + const r = runScript(testDir, `${deadOwnerScript} + const fs = require("node:fs"); + const { syncBuiltinESMExports } = require("node:module"); + const journalPath = require("node:path").join(process.env.CODEX_HOME, "opencodex-journal.json"); + const originalUnlink = fs.unlinkSync; + let denied = 0; + const unlinkSpy = spyOn(fs, "unlinkSync").mockImplementation((path) => { + if (path === journalPath) { + denied += 1; + throw Object.assign(new Error("fixture journal locked"), { code: "EBUSY" }); + } + return originalUnlink(path); + }); + syncBuiltinESMExports(); + const { reconcileJournal } = require("./src/codex/journal"); + try { console.log(JSON.stringify({ restored: reconcileJournal(), denied })); } + finally { unlinkSpy.mockRestore(); syncBuiltinESMExports(); killSpy.mockRestore(); } + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ restored: false, denied: 1 }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(readFileSync(join(testDir, "opencodex-journal.json"), "utf8")).toBe(journal); + expect(r.stderr).toContain("the journal could not be removed"); + expect(r.stderr).not.toContain("restored"); + }); + + for (const configRewritten of [false, true]) { + test(`${owner}-owner incomplete recovery warns when ${configRewritten ? "config was rewritten" : "config is already original"} and profile unlink fails`, () => { + const journal = seed(owner, configRewritten ? injected : original, true); + const r = runScript(testDir, `${deadOwnerScript} + const fs = require("node:fs"); + const { syncBuiltinESMExports } = require("node:module"); + const profilePath = require("node:path").join(process.env.CODEX_HOME, "opencodex.config.toml"); + const originalUnlink = fs.unlinkSync; + let denied = 0; + const unlinkSpy = spyOn(fs, "unlinkSync").mockImplementation((path) => { + if (path === profilePath) { + denied += 1; + throw Object.assign(new Error("fixture profile unlink denied"), { code: "EPERM" }); + } + return originalUnlink(path); + }); + syncBuiltinESMExports(); + const { reconcileJournal } = require("./src/codex/journal"); + try { console.log(JSON.stringify({ restored: reconcileJournal(), denied, probes })); } + finally { unlinkSpy.mockRestore(); syncBuiltinESMExports(); killSpy.mockRestore(); } + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.denied).toBe(1); + expect(out.probes).toBe(owner === "process" ? 1 : 0); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(readFileSync(join(testDir, "opencodex.config.toml"), "utf8")).toBe(profile); + expect(readFileSync(join(testDir, "opencodex-journal.json"), "utf8")).toBe(journal); + expect(out.restored).toBe(false); + expect(r.stderr).toBe(`⚠️ ${incompleteWarning}`); + expect(r.stderr).not.toContain("restored"); + expect(r.stderr).not.toContain("fixture-client-owner"); + expect(r.stderr).not.toContain(testDir); + }); + } + } + + for (const mode of ["already-original", "rewrite-profile", "remove-profile", "unlink-ENOENT", "unlink-EPERM"] as const) { + test(`restoreJournalState reports actual writes for ${mode}`, () => { + seed("process", mode === "already-original" ? original : injected, mode !== "already-original"); + if (mode === "rewrite-profile") { + const journalPath = join(testDir, "opencodex-journal.json"); + const journal = JSON.parse(readFileSync(journalPath, "utf8")); + journal.originalProfile = Buffer.from("# original profile\n").toString("base64"); + writeFileSync(journalPath, JSON.stringify(journal)); + } + const r = runScript(testDir, ` + const { spyOn } = require("bun:test"); + const fs = require("node:fs"); + const { syncBuiltinESMExports } = require("node:module"); + const profilePath = require("node:path").join(process.env.CODEX_HOME, "opencodex.config.toml"); + const originalUnlink = fs.unlinkSync; + let denied = 0; + const unlinkSpy = spyOn(fs, "unlinkSync").mockImplementation((path) => { + if (path === profilePath && ${mode === "unlink-ENOENT" || mode === "unlink-EPERM"}) { + denied += 1; + if (${mode === "unlink-ENOENT"}) originalUnlink(path); + throw Object.assign(new Error("fixture unlink result"), { code: ${JSON.stringify(mode === "unlink-ENOENT" ? "ENOENT" : "EPERM")} }); + } + return originalUnlink(path); + }); + syncBuiltinESMExports(); + const { restoreJournalState } = require("./src/codex/journal"); + try { console.log(JSON.stringify({ result: restoreJournalState(), denied })); } + finally { unlinkSpy.mockRestore(); syncBuiltinESMExports(); } + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.result).toMatchObject({ + configRestored: true, + profileRestored: mode !== "unlink-EPERM", + configRewritten: mode !== "already-original", + profileRewritten: mode === "rewrite-profile" || mode === "remove-profile", + complete: mode !== "unlink-EPERM", + unverified: false, + }); + expect(out.result.profileRestoreFailed).toBe(mode === "unlink-EPERM" ? true : undefined); + expect(out.denied).toBe(mode.startsWith("unlink-") ? 1 : 0); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(mode === "unlink-EPERM"); + if (mode === "rewrite-profile") expect(readFileSync(join(testDir, "opencodex.config.toml"), "utf8")).toBe("# original profile\n"); + else expect(existsSync(join(testDir, "opencodex.config.toml"))).toBe(mode === "unlink-EPERM"); + }); + } +}); diff --git a/tests/codex-integration/codex-shim-migration.test.ts b/tests/codex-integration/codex-shim-migration.test.ts index 93bfeecbf9e..f4fd449040c 100644 --- a/tests/codex-integration/codex-shim-migration.test.ts +++ b/tests/codex-integration/codex-shim-migration.test.ts @@ -5,9 +5,9 @@ import { join } from "node:path"; import { autoRestoreCodexShim, buildUnixCodexShim, codexShimStatus, diagnoseCodexShim, installCodexShim, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, uninstallCodexShim } from "../../src/codex/shim"; import { readState, type ShimState } from "../../src/codex/shim-state-file"; -function fixture(run: (f: { root: string; home: string; native: string; backup: string; quarantine: string; wrapper: string; statePath: string; state: ShimState; legacyBytes: string; version: (name: string) => string }) => void): void { +function fixture(run: (f: { root: string; home: string; native: string; backup: string; quarantine: string; wrapper: string; statePath: string; state: ShimState; legacyBytes: string; version: (name: string) => string }) => void, homeName = "home"): void { const root = fs.mkdtempSync(join(tmpdir(), "ocx-overlay-migration-")); - const home = join(root, "home"); + const home = join(root, homeName); const bin = join(root, "manager"); fs.mkdirSync(home, { mode: 0o700 }); fs.mkdirSync(bin); @@ -80,6 +80,41 @@ describe.skipIf(process.platform === "win32")("legacy Unix shim migration", () = expect(installCodexShim()).toMatchObject({ installed: false, runnable: true }); })); + test("group-writable legacy state (umask 002) refuses with the path and the chmod that unblocks migration", () => fixture(f => { + fs.chmodSync(f.statePath, 0o664); + const raw = fs.readFileSync(f.statePath, "utf8"); + const refused = installCodexShim(); + expect(refused).toMatchObject({ installed: false, refused: true }); + expect(refused.message).toContain(`Private Codex artifact is not an owned regular file: ${f.statePath}`); + expect(refused.message).toContain("group- or world-writable (mode 0664)"); + expect(refused.message).toContain(`run chmod 600 '${f.statePath}' and retry`); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(raw); + expect(fs.readFileSync(f.native, "utf8")).toBe(f.legacyBytes); + fs.chmodSync(f.statePath, 0o600); + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); + expect(readState()).toMatchObject({ schema: 2, launcherPath: f.native }); + })); + + test("printed chmod hint handles spaces and apostrophes and permits legacy migration", () => fixture(f => { + fs.chmodSync(f.statePath, 0o664); + const raw = fs.readFileSync(f.statePath, "utf8"); + const refused = installCodexShim(); + expect(refused).toMatchObject({ installed: false, refused: true }); + expect(refused.message).toContain(`run chmod 600 '${f.root}/owner'\\''s home/codex-shim.json' and retry`); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(raw); + expect(fs.lstatSync(f.statePath).mode & 0o777).toBe(0o664); + const hint = refused.message.match(/run (chmod 600 .+) and retry/)?.[1]; + expect(hint).toBeDefined(); + const result = Bun.spawnSync(["/bin/sh", "-c", hint!]); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(fs.lstatSync(f.statePath).mode & 0o777).toBe(0o600); + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); + expect(readState()).toMatchObject({ schema: 2, launcherPath: f.native }); + expect(fs.lstatSync(f.statePath).mode & 0o777).toBe(0o600); + }, "owner's home")); + test("regular backup restoration retains its inode", () => fixture(f => { fs.unlinkSync(f.backup); fs.writeFileSync(f.backup, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); diff --git a/tests/codex-integration/issue-452-empty-503.test.ts b/tests/codex-integration/issue-452-empty-503.test.ts index c04b6a8fdfb..d8aa8049535 100644 --- a/tests/codex-integration/issue-452-empty-503.test.ts +++ b/tests/codex-integration/issue-452-empty-503.test.ts @@ -11,6 +11,7 @@ import { getDebugLogEntries, resetDebugLogBufferForTests } from "../../src/lib/d import { resetDebugSettingsForTests } from "../../src/lib/debug-settings"; import { setDraining } from "../../src/server/lifecycle"; import { startServer } from "../../src/server"; +import { findAvailablePort } from "../../src/server/ports"; import { readDisplaySafeErrorText } from "../../src/server/responses/core"; import { formatPassthroughUpstreamError } from "../../src/server/responses/passthrough-error"; import type { OcxConfig, OcxParsedRequest } from "../../src/types"; @@ -348,13 +349,17 @@ describe("passthrough empty 503 (#452)", () => { }); describe("drain 503 JSON (#452)", () => { - test("POST /v1/responses while draining returns JSON error body", async () => { + test("POST /v1/responses while draining returns retryable JSON with listener-policy CORS (#6642)", async () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; - delete process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OPENCODEX_API_AUTH_TOKEN = "drain-test-token"; + const loopbackPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "0.0.0.0", { reservedPort: loopbackPort }); saveConfig({ - port: 0, + port: publicPort, + hostname: "0.0.0.0", + unauthenticatedLoopbackListener: { enabled: true, port: loopbackPort }, defaultProvider: "xiaomi", providers: { xiaomi: { @@ -366,24 +371,35 @@ describe("drain 503 JSON (#452)", () => { }, } as OcxConfig); - const server = startServer(0); + const server = startServer(publicPort); try { setDraining(true); - const response = await originalGlobalFetch(new URL("/v1/responses", server.url), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "mimo-v2.5-pro", input: "hi" }), - }); - expect(response.status).toBe(503); - expect(response.headers.get("retry-after")).toBe("5"); - const json = await response.json() as { error?: { message?: string; code?: string | null } }; - expect(json.error?.message).toContain("shutting down"); - expect(json.error?.code).toBe("server_is_overloaded"); + for (const port of [server.port, loopbackPort]) { + for (const origin of ["http://localhost:20100", "http://rebinding.example.test"]) { + const allowed = origin === "http://localhost:20100"; + // The denied loopback request is same-origin by Host: public policy would echo it. + const host = !allowed && port === loopbackPort ? "rebinding.example.test" : `127.0.0.1:${port}`; + const response = await originalGlobalFetch(`http://127.0.0.1:${port}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json", Origin: origin, Host: host }, + body: JSON.stringify({ model: "mimo-v2.5-pro", input: "hi" }), + }); + expect(response.status).toBe(503); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(response.headers.get("retry-after")).toBe("5"); + expect(response.headers.get("access-control-allow-origin")) + .toBe(allowed ? origin : `http://localhost:${server.port}`); + const json = await response.json() as { error?: { type?: string; message?: string; code?: string | null } }; + expect(json.error?.code).toBe("server_restarting"); + expect(json.error?.type).toBe("server_error"); + expect(json.error?.message).toBe("OpenCodex is restarting; retry this request."); + } + } } finally { setDraining(false); await server.stop(true); } - }); + }, SERVER_BUDGET_MS); }); describe("openai-chat provider debug (#452)", () => { diff --git a/tests/fixtures/cursor-tool-wording.json b/tests/fixtures/cursor-tool-wording.json new file mode 100644 index 00000000000..7f84e3603b8 --- /dev/null +++ b/tests/fixtures/cursor-tool-wording.json @@ -0,0 +1,68 @@ +{ + "catalogs": { + "shell": [ + { + "name": "shell_command" + } + ], + "code": [ + { + "name": "exec", + "freeform": true + }, + { + "name": "lookup", + "namespace": "docs" + } + ], + "client": [ + { + "name": "task" + }, + { + "name": "read_file" + } + ], + "mixed": [ + { + "name": "exec", + "freeform": true + }, + { + "name": "exec_command" + }, + { + "name": "apply_patch", + "freeform": true + } + ] + }, + "guidance": { + "shell": "Cursor tool calls: available tool names are exactly `shell_command`. Use the current tool catalog as ground truth and call only those exact names with their listed argument keys. This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Glob`, `Bash`, `LS`, `Write`; do not call or suggest them unless the catalog lists them. `shell_command` is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. `shell_command` and `exec_command` are aliases of the same bridge. Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user. NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. `shell_command` is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces (\"native is blocked, using the bridge instead\") — there is exactly one surface. Tool-selection commentary is forbidden: for any shell, read, grep, list, or file operation, your FIRST visible action is the bridge call itself — never a sentence about which tool you will use, which tool was redirected, or switching surfaces. Words like 차단/전환/blocked/switching must not appear in your output for tool-routing reasons. Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<(...)`, for example `text(await tools.exec_command({cmd: \"ls\"}))`. Read the tool description and the isolate global `ALL_TOOLS` (not `tools.ALL_TOOLS`) for helpers this turn provides; absence from the top-level catalog or from `exec`'s description is not absence. Those nested helpers are not themselves top-level tools, so do not call `exec_command` or `shell_command` at the top level here; every other tool this turn lists, including `docs__lookup`, remains callable at the top level as usual. Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch`, each marker line being three asterisks, one space, the two words, then end of line with no further asterisks. OpenCodex does not rewrite JavaScript inside exec, so extra asterisks on a marker line are rejected by Codex before the file is touched. Nothing in the isolate is echoed automatically: a bare trailing `await tools.(...)` or final expression value is DISCARDED, and the cell reports empty output. Pass anything you need to read to `text(...)` (or `notify(...)`) in the same cell — for example `text(JSON.stringify(await tools.exec_command({cmd: 'ls'})))` — and treat an empty result as your own missing `text(...)` call rather than a failed command or lost context. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers. Host contract for the nested helpers: `tools.apply_patch(patch)` takes exactly one string, never an object such as `{input: ...}`; the patch text opens with the bare marker line `*** Begin Patch` and closes with the bare marker line `*** End Patch`, written without a code fence, prose, or extra asterisks on those lines (blank lines or indentation around the markers are tolerated; a decorated or missing marker is rejected). The isolate has no `import`, `require`, or module loader; use the globals the exec tool description lists (for example `tools`, `text`, `notify`, `store`/`load`, `ALL_TOOLS`). For a command that may outlive `yield_time_ms`, let `tools.exec_command` return a `session_id` and poll it on later calls with `tools.write_stdin({session_id, chars: \"\"})` instead of blocking a shell in a sleep loop. NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces. Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise. For independent read-only tool-count or batch requests, prefer one response containing multiple tool calls before waiting for results when the runtime supports parallel tool calls. Do not count or report a tool call unless a tool result was actually returned.", + "client": "Cursor tool calls: available tool names are exactly `ocx_client_task`, `ocx_client_read_file`. Use the current tool catalog as ground truth and call only those exact names with their listed argument keys. This turn does not expose neighboring-agent tool names `Grep`, `Glob`, `Bash`, `LS`, `Write`; do not call or suggest them unless the catalog lists them. Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise. For independent read-only tool-count or batch requests, prefer one response containing multiple tool calls before waiting for results when the runtime supports parallel tool calls. Do not count or report a tool call unless a tool result was actually returned.", + "mixed": "Cursor tool calls: available tool names are exactly `exec`, `exec_command`, `apply_patch`. Use the current tool catalog as ground truth and call only those exact names with their listed argument keys. This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Glob`, `Bash`, `LS`, `Write`; do not call or suggest them unless the catalog lists them. `exec_command` is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. `shell_command` and `exec_command` are aliases of the same bridge. Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user. NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. `exec_command` is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces (\"native is blocked, using the bridge instead\") — there is exactly one surface. Tool-selection commentary is forbidden: for any shell, read, grep, list, or file operation, your FIRST visible action is the bridge call itself — never a sentence about which tool you will use, which tool was redirected, or switching surfaces. Words like 차단/전환/blocked/switching must not appear in your output for tool-routing reasons. Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<`, `>>`, `printf`/`echo` into a file, `cat <(...)`, for example `text(await tools.exec_command({cmd: \"ls\"}))`. Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not part of this request's catalog; do not retry them, and do not call `shell_command` or `exec_command` at the top level here — code mode exposes no bare shell bridge, only the nested helpers. Every other tool this turn lists remains callable at the top level as usual. Nothing in the isolate is echoed automatically: a bare trailing `await tools.(...)` or final expression value is DISCARDED, and the cell reports empty output. Pass anything you need to read to `text(...)` (or `notify(...)`) in the same cell — for example `text(JSON.stringify(await tools.exec_command({cmd: 'ls'})))` — and treat an empty result as your own missing `text(...)` call rather than a failed command or lost context. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the `exec` call.", + "client": "Re-issue this operation NOW through one of the tools listed in this request's catalog: `ocx_client_task`, `ocx_client_read_file` (the harness displays a `opencodex-responses` entry as `mcp_opencodex-responses_`; that is the same tool). Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not part of this request's catalog; do not retry them. Pick the listed tool that fits the operation — a listed file, search, or fetch tool if there is one, otherwise the listed tool that delegates work to a worker agent. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the catalog tool call.", + "mcp": "Re-issue this operation NOW through one of the tools listed in this request's catalog: `mcp_docs_lookup` (the harness displays a `opencodex-responses` entry as `mcp_opencodex-responses_`; that is the same tool). Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not part of this request's catalog; do not retry them. Pick the listed tool that fits the operation — a listed file, search, or fetch tool if there is one, otherwise the listed tool that delegates work to a worker agent. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the catalog tool call." + }, + "defaults": { + "fs": "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call.", + "fetch": "Re-issue this fetch NOW through the catalog shell tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do NOT narrate this redirect or comment on tool availability — just make the bridge call.", + "shell": "Re-issue this command NOW through the catalog shell tool (`shell_command` or `exec_command`; the long `mcp_opencodex-responses_*` display name is the same tool). Adapt the command for the Codex client host shell before calling the bridge (Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call.", + "write": "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call. No file was changed.", + "delete": "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call. No file was changed." + }, + "mutation": { + "false": { + "write": "Make this write through the Codex edit path instead. Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call.", + "delete": "Make this delete through the Codex edit path instead. Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call." + }, + "true": { + "write": "Make this write through the Codex edit path instead. Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call.", + "delete": "Make this delete through the Codex edit path instead. Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call." + } + } +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71e304011d0..700841acd0a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -78,7 +78,7 @@ "codex-credits-after-limit.test.ts": "codex-integration", "codex-pool-credit-policy.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", - "codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service", + "codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", "responses-hosted-image-display.test.ts": "responses", "responses-hosted-image-limits.test.ts": "responses", @@ -261,6 +261,7 @@ "claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", + "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-local-ca-files.test.ts": "claude-integration", @@ -383,7 +384,7 @@ "codex-inject-retained-table.test.ts": "codex-integration", "codex-inject-v1-reconcile.test.ts": "codex-integration", "codex-inject-write-lock.test.ts": "codex-integration", "codex-inject.test.ts": "codex-integration", "codex-injected-marker.test.ts": "codex-integration", "codex-integration-record.test.ts": "codex-integration", - "codex-journal.test.ts": "codex-integration", "codex-legacy-config-keys.test.ts": "codex-integration", + "codex-journal-recovery.test.ts": "codex-integration", "codex-journal.test.ts": "codex-integration", "codex-legacy-config-keys.test.ts": "codex-integration", "codex-lineage-placement.test.ts": "codex-integration", "codex-log-guard-coderabbit.test.ts": "codex-integration", "codex-log-guard-doctor-coderabbit.test.ts": "codex-integration", "codex-log-guard-doctor-protection.test.ts": "codex-integration", @@ -469,7 +470,7 @@ "cost-scoring.test.ts": "usage", "crash-guard.test.ts": "service", "credential-redirect-guard.test.ts": "lib", "crusoe-provider.test.ts": "providers", "cursor-adapter.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", - "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", + "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob-claude-client-tools.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", "cursor-call-id.test.ts": "providers/cursor", "cursor-cancel-provenance.test.ts": "providers/cursor", "cursor-catalog.test.ts": "providers/cursor", "cursor-claude-id.test.ts": "providers/cursor", "cursor-continuation-invariants.test.ts": "providers/cursor", @@ -501,6 +502,7 @@ "cursor-tool-finalize-race.test.ts": "providers/cursor", "cursor-tool-result-image.test.ts": "providers/cursor", "cursor-tool-result-invocation.test.ts": "providers/cursor", "cursor-tool-suspended-checkpoint.test.ts": "providers/cursor", + "cursor-tool-wording.test.ts": "providers/cursor", "cursor-toolresult-normalize.test.ts": "providers/cursor", "cursor-transport-retry.test.ts": "providers/cursor", "cursor-ultra-mode.test.ts": "providers/cursor", "cursor-umbrella-rows.test.ts": "providers/cursor", "cursor-uncallable-quarantine.test.ts": "providers/cursor", "cursor-vision-wire-harness.test.ts": "providers/cursor", @@ -953,6 +955,7 @@ "server-images-bodyless-content-length.test.ts": "server", "server-images-pool-admission.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", "server-jev-combo-e2e.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", + "combo-requested-effort.test.ts": "server", "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", "server-live-frame-log.test.ts": "server", @@ -1077,7 +1080,7 @@ "web-search-candidates.test.ts": "web-search", "web-search-parse.test.ts": "web-search", "web-search-passthrough-bridge.test.ts": "web-search", "web-search-progress-stream.test.ts": "web-search", "web-search-retry-heartbeat.test.ts": "web-search", "web-search-sidecar-429.test.ts": "web-search", - "web-search-sources.test.ts": "web-search", "web-search-timeout-contract.test.ts": "web-search", + "web-search-sources.test.ts": "web-search", "web-search-timeout-contract.test.ts": "web-search", "web-search-pacing-deadline.test.ts": "web-search", "web-search-timeout-plan.test.ts": "web-search", "web-search.test.ts": "web-search", "win-exec.test.ts": "windows", "win-paths.test.ts": "windows", "windows-acl-start-cost.test.ts": "windows", diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index eb2c58fee3a..42052227e2e 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -28,6 +28,7 @@ export const RESPONSES_CORE_MODULES = [ "core-auth.ts", "core-normalize.ts", "core-combo.ts", + "combo-requested-effort.ts", "core-combo-native.ts", // Reached from core-combo.ts: the JEV model backend's internal decision turn. "jev-model-invoke.ts", diff --git a/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts b/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts new file mode 100644 index 00000000000..875440421c3 --- /dev/null +++ b/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { + handleCursorNativeKv, + resetCursorBlobStateForTests, + setCursorBlobLimitsForTests, +} from "../../../src/adapters/cursor/native-exec"; +import { resetAppOwnedMemoryForTests } from "../../../src/lib/app-owned-memory"; +import { encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { applyCursorToolBudget, createCursorRequest, CURSOR_TOOL_BYTES_LIMIT } from "../../../src/adapters/cursor/request-builder"; +import { buildCursorToolDefinitions, cursorMcpToolEncodedSize, cursorMcpToolsEncodedSize } from "../../../src/adapters/cursor/tool-definitions"; +import { parseRequest } from "../../../src/responses/parser"; +import type { OcxTool } from "../../../src/types"; +import { resetCursorCallIdProvenanceForTests } from "../../../src/adapters/cursor/call-id"; +import { + AgentClientMessageSchema, + ConversationStepSchema, + ConversationTurnStructureSchema, + GetBlobArgsSchema, + KvServerMessageSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; + +beforeEach(() => { + resetCursorBlobStateForTests(); + resetAppOwnedMemoryForTests(); +}); +afterEach(() => { + setCursorBlobLimitsForTests(); + resetAppOwnedMemoryForTests(); +}); + +function blobData(blobId: Uint8Array): Uint8Array { + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) }, + }))); + expect(reply.message.case).toBe("kvClientMessage"); + const kv = reply.message.value; + expect(kv.message.case).toBe("getBlobResult"); + return kv.message.value.blobData; +} + + +describe("Cursor blob handshake", () => { + test("replays tool calls with catalog-aware wire names", () => { + resetCursorCallIdProvenanceForTests(); + const local = "call_catalog_replay"; + // 1. When catalog has exec_command and Read, replayed call should use ocx_client_Read + const withBridgeBytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-bridge-replay", + system: ["You are helpful."], + tools: [{ name: "exec_command", parameters: {} }, { name: "Read", parameters: {} }], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: Read\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: local, name: "Read", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: local, toolName: "Read", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msgWithBridge = fromBinary(AgentClientMessageSchema, withBridgeBytes); + const runWithBridge = msgWithBridge.message.case === "runRequest" ? msgWithBridge.message.value : undefined; + const turnIdsWithBridge = runWithBridge?.conversationState?.turns ?? []; + const turnWithBridge = fromBinary(ConversationTurnStructureSchema, blobData(turnIdsWithBridge[0]!)); + const stepWithBridge = fromBinary(ConversationStepSchema, blobData(turnWithBridge.turn.value?.steps[0]!)); + if (stepWithBridge.message.case === "toolCall" && stepWithBridge.message.value.tool.case === "mcpToolCall") { + expect(stepWithBridge.message.value.tool.value.args?.toolName).toBe("ocx_client_Read"); + } else { + throw new Error("Expected mcpToolCall"); + } + + // 2. When catalog has only Claude client tools (no shell bridge), replayed call preserves bare Read + resetCursorCallIdProvenanceForTests(); + const noBridgeBytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-nobridge-replay", + system: ["You are helpful."], + tools: [{ name: "Read", parameters: {} }], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: Read\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: local, name: "Read", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: local, toolName: "Read", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msgNoBridge = fromBinary(AgentClientMessageSchema, noBridgeBytes); + const runNoBridge = msgNoBridge.message.case === "runRequest" ? msgNoBridge.message.value : undefined; + const turnIdsNoBridge = runNoBridge?.conversationState?.turns ?? []; + const turnNoBridge = fromBinary(ConversationTurnStructureSchema, blobData(turnIdsNoBridge[0]!)); + const stepNoBridge = fromBinary(ConversationStepSchema, blobData(turnNoBridge.turn.value?.steps[0]!)); + if (stepNoBridge.message.case === "toolCall" && stepNoBridge.message.value.tool.case === "mcpToolCall") { + expect(stepNoBridge.message.value.tool.value.args?.toolName).toBe("Read"); + } else { + throw new Error("Expected mcpToolCall"); + } + }); + +}); + +describe("Cursor Claude client tool catalog budget", () => { + test.each([ + { bridge: true, name: "Read", expected: ["Read"] }, + { bridge: true, name: "ocx_client_Read", expected: ["Read"] }, + { bridge: false, name: "Read", expected: ["Read"] }, + { bridge: false, name: "ocx_client_Read", expected: [] }, + ])("registers forced $name with bridge=$bridge after choice filtering", ({ bridge, name, expected }) => { + const parsed = parseRequest({ + model: "cursor/auto", input: "read a file", + tools: [...(bridge ? ["exec_command"] : []), "Read", "Write"].map(name => ({ type: "function", name, parameters: {} })), + tool_choice: { type: "function", name }, + }); + const request = createCursorRequest(parsed); + expect(buildCursorToolDefinitions(request.tools, request.toolChoice).map(tool => tool.toolName)).toEqual(expected); + }); + + test.each([ + { allowed: ["ocx_client_Read"], expected: ["Read"] }, + { allowed: ["exec_command", "ocx_client_Read"], expected: ["exec_command", "ocx_client_Read"] }, + ])("registers allowed aliases $allowed after choice filtering", ({ allowed, expected }) => { + const parsed = parseRequest({ + model: "cursor/auto", input: "read a file", + tools: ["exec_command", "Read", "Write"].map(name => ({ type: "function", name, parameters: {} })), + tool_choice: { type: "allowed_tools", mode: "required", tools: allowed.map(name => ({ type: "function", name })) }, + }); + const request = createCursorRequest(parsed); + expect(buildCursorToolDefinitions(request.tools, request.toolChoice).map(tool => tool.toolName)).toEqual(expected); + }); + + test.each(["function", "allowed_tools"])("keeps an aliased %s choice scoped to its original bare identity", type => { + const parsed = parseRequest({ + model: "cursor/auto", input: "read a file", + tools: [ + { type: "function", name: "exec_command", parameters: {} }, + { type: "function", name: "Read", parameters: {} }, + { type: "namespace", name: "mcp__remote", tools: [{ type: "function", name: "Read", parameters: {} }] }, + ], + tool_choice: type === "function" + ? { type, name: "ocx_client_Read" } + : { type, mode: "required", tools: [{ type: "function", name: "ocx_client_Read" }] }, + }); + const request = createCursorRequest(parsed); + expect(request.tools?.map(tool => [tool.namespace, tool.name])).toEqual([[undefined, "Read"]]); + expect(buildCursorToolDefinitions(request.tools, request.toolChoice).map(tool => tool.toolName)).toEqual(["Read"]); + }); + + test("preserves both allowed semantic names when a literal name shadows a generated alias", () => { + const parsed = parseRequest({ + model: "cursor/auto", input: "read a file", + tools: ["exec_command", "Read", "ocx_client_Read"].map(name => ({ type: "function", name, parameters: {} })), + tool_choice: { type: "allowed_tools", mode: "required", tools: ["Read", "ocx_client_Read"].map(name => ({ type: "function", name })) }, + }); + const request = createCursorRequest(parsed); + expect(buildCursorToolDefinitions(request.tools, request.toolChoice).map(tool => tool.toolName)) + .toEqual(["Read", "ocx_client_ocx_client_Read"]); + }); + + test("individual sizes use catalog wire names and aliased tool choices", () => { + const bridge: OcxTool = { name: "exec_command", parameters: {} }; + const read: OcxTool = { name: "Read", parameters: {} }; + const tools = [bridge, read]; + expect(tools.reduce((sum, tool) => sum + cursorMcpToolEncodedSize(tool, "auto", tools), 0)) + .toBe(cursorMcpToolsEncodedSize(tools, "auto")); + const choice = { name: "ocx_client_Read" }; + expect(cursorMcpToolEncodedSize(read, choice, tools)).toBeGreaterThan(0); + expect(cursorMcpToolEncodedSize(read, choice, tools)).toBe(cursorMcpToolsEncodedSize(tools, choice)); + expect(cursorMcpToolEncodedSize(bridge, choice, tools)).toBe(0); + }); + + test("omits a Claude tool when its mixed-catalog wire alias exceeds the byte limit", () => { + const bridge: OcxTool = { name: "exec_command", parameters: {} }; + const read: OcxTool = { name: "Read", description: "x".repeat(118_450), parameters: {} }; + const tools = [bridge, read]; + expect(cursorMcpToolsEncodedSize([bridge]) + cursorMcpToolsEncodedSize([read])) + .toBe(CURSOR_TOOL_BYTES_LIMIT); + expect(cursorMcpToolsEncodedSize(tools)).toBe(CURSOR_TOOL_BYTES_LIMIT + 22); + + const budget = applyCursorToolBudget(tools, "auto"); + expect(cursorMcpToolsEncodedSize(budget.tools)).toBeLessThanOrEqual(CURSOR_TOOL_BYTES_LIMIT); + expect(budget.tools).toEqual([bridge]); + expect(budget.omitted).toEqual([read]); + expect(applyCursorToolBudget([read], "auto").tools).toEqual([read]); + }); + + test.each(["exec_command", "shell_command"])( + "retains a mixed catalog exactly at the byte limit with %s", + (name) => { + const bridge: OcxTool = { name, parameters: {} }; + const read: OcxTool = { name: "Read", description: "x".repeat(118_450), parameters: {} }; + const overhead = cursorMcpToolsEncodedSize([bridge, read]) - CURSOR_TOOL_BYTES_LIMIT; + read.description = read.description!.slice(overhead); + expect(cursorMcpToolsEncodedSize([bridge, read])).toBe(CURSOR_TOOL_BYTES_LIMIT); + const budget = applyCursorToolBudget([bridge, read], "auto"); + expect(budget.tools).toEqual([bridge, read]); + expect(budget.omitted).toEqual([]); + expect(cursorMcpToolsEncodedSize(budget.tools)).toBe(CURSOR_TOOL_BYTES_LIMIT); + + const tooLarge = { ...read, description: read.description + "x" }; + const overflow = applyCursorToolBudget([bridge, tooLarge], "auto"); + expect(overflow.tools).toEqual([bridge]); + expect(overflow.omitted).toEqual([tooLarge]); + expect(cursorMcpToolsEncodedSize(overflow.tools)).toBeLessThanOrEqual(CURSOR_TOOL_BYTES_LIMIT); + }, + ); +}); diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index fb990bc716d..4a512c77fcc 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -19,6 +19,7 @@ import { cursorToolWireName, isGenericToolUseCountDemoPrompt, nonEmptyShellBridgeCommandFromArgs, + responsesToolNameFromCursorWire, } from "../../../src/adapters/cursor/tool-definitions"; import type { OcxTool } from "../../../src/types"; @@ -824,6 +825,83 @@ describe("Cursor code mode tool guidance", () => { expect(note).not.toContain("V8 isolate"); expect(note).not.toContain("Host contract for the nested helpers"); }); + + test("preserves Claude Code client bare tool wire names when no Codex shell bridge is advertised", () => { + const claudeTools: OcxTool[] = [ + { name: "Bash", description: "Run a bash command", parameters: { type: "object", properties: { command: { type: "string" } } } }, + { name: "Read", description: "Read a file", parameters: {} }, + { name: "Edit", description: "Edit a file", parameters: {} }, + { name: "Write", description: "Write a file", parameters: {} }, + { name: "Grep", description: "Search file contents", parameters: {} }, + { name: "Glob", description: "Find files by glob", parameters: {} }, + { name: "Task", description: "Spawn an agent task", parameters: {} }, + { name: "GetDynamicTools", description: "Get dynamic tools", parameters: {} }, + ]; + + for (const tool of claudeTools) { + expect(cursorToolWireName(tool, claudeTools)).toBe(tool.name); + } + + const defs = buildCursorToolDefinitions(claudeTools); + expect(defs.map(tool => tool.toolName)).toEqual([ + "Bash", + "Read", + "Edit", + "Write", + "Grep", + "Glob", + "Task", + "GetDynamicTools", + ]); + + // When no shell bridge is advertised, guidance should not warn about neighboring-agent tool names or ocx_client_* + const note = buildCursorToolGuidanceSystemNote(claudeTools); + expect(note).toBeDefined(); + expect(note).not.toContain("neighboring-agent tool names"); + expect(note).not.toContain("ocx_client_"); + }); + + test("aliases client tools when Codex shell bridge is present in catalog", () => { + const toolsWithBridge: OcxTool[] = [ + { name: "exec_command", description: "Codex shell", parameters: {} }, + { name: "Glob", description: "Client tool", parameters: {} }, + { name: "Read", description: "Client tool", parameters: {} }, + ]; + + expect(cursorToolWireName({ name: "Glob" }, toolsWithBridge)).toBe("ocx_client_Glob"); + expect(cursorToolWireName({ name: "Read" }, toolsWithBridge)).toBe("ocx_client_Read"); + expect(cursorToolWireName({ name: "exec_command" }, toolsWithBridge)).toBe("exec_command"); + + const defs = buildCursorToolDefinitions(toolsWithBridge); + expect(defs.map(tool => tool.toolName)).toEqual([ + "exec_command", + "ocx_client_Glob", + "ocx_client_Read", + ]); + + const note = buildCursorToolGuidanceSystemNote(toolsWithBridge); + expect(note).toBeDefined(); + if (!note) throw new Error("Expected Cursor tool guidance note"); + expect(note).toContain("`ocx_client_Glob`"); + expect(note).toContain("`ocx_client_Read`"); + expect(note).toContain("neighboring-agent tool names"); + + // Both bare and prefixed response names map back to Read + const nameMap = new Map([ + [cursorToolWireName({ name: "Read" }, toolsWithBridge), "Read"], + ]); + expect(responsesToolNameFromCursorWire("ocx_client_Read", nameMap)).toBe("Read"); + expect(responsesToolNameFromCursorWire("Read", nameMap)).toBe("Read"); + }); + + test("preserves neighboring-agent tool warning for MCP-only catalogs without Claude bare tools", () => { + const mcpCatalog: OcxTool[] = [ + { name: "read_file", namespace: "mcp__fs", description: "Read file", parameters: {} }, + ]; + const note = buildCursorToolGuidanceSystemNote(mcpCatalog); + expect(note).toBeDefined(); + expect(note).toContain("neighboring-agent tool names"); + }); }); test("tool-definitions preserves leaf identities and naming stays the dependency root", async () => { diff --git a/tests/providers/cursor/cursor-tool-wording.test.ts b/tests/providers/cursor/cursor-tool-wording.test.ts new file mode 100644 index 00000000000..ebda9f259e2 --- /dev/null +++ b/tests/providers/cursor/cursor-tool-wording.test.ts @@ -0,0 +1,299 @@ +import { create, fromBinary } from "@bufbuild/protobuf"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + AgentClientMessageSchema, BackgroundShellSpawnArgsSchema, DeleteArgsSchema, ExecServerMessageSchema, + FetchArgsSchema, GrepArgsSchema, LsArgsSchema, ReadArgsSchema, ShellArgsSchema, WriteArgsSchema, WriteShellStdinArgsSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; +import { createCursorAdapter } from "../../../src/adapters/cursor"; +import { CURSOR_ROUTING_COMMENTARY_RETRY_TEXT } from "../../../src/adapters/cursor/envelope-echo"; +import { createLiveCursorTransport } from "../../../src/adapters/cursor/live-transport"; +import { cursorBlobTextForEstimate, cursorNativeExecRedirectHint, handleCursorNativeExec, resetCursorBlobStateForTests, type CursorNativeExecContext } from "../../../src/adapters/cursor/native-exec"; +import { nativeShellDisabledMessage } from "../../../src/adapters/cursor/native-exec-shell"; +import { encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { createCursorRequest } from "../../../src/adapters/cursor/request-builder"; +import { buildCursorToolGuidanceSystemNote } from "../../../src/adapters/cursor/tool-guidance"; +import { buildCursorToolDefinitions } from "../../../src/adapters/cursor/tool-definitions"; +import { CURSOR_TOOL_CALL_CONTINUATION, cursorUsesPlainToolWording } from "../../../src/adapters/cursor/tool-wording"; +import { parseRequest } from "../../../src/responses/parser"; +import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; +import type { AdapterEvent } from "../../../src/types"; +import legacy from "../../fixtures/cursor-tool-wording.json"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const CONCEALMENT = /do not (narrate|comment|mention|re-announce)|commentary is forbidden|must not appear in your output/i; +const CLAUDE_MODELS = ["claude-sonnet-5-5", "cursor/claude-4.6-opus-high", "claude-opus-5-thinking-high-fast", "claude-4.5-haiku", "claude-fable-5", "claude-4-sonnet-1m"]; +const OTHER_MODELS = [undefined, "cursor-grok-4.6-high", "cursor/gpt-5", "gemini-3-pro", "composer-2.5", "cursor/auto", "default", "not-claude-sonnet"]; +const CLAUDE_CLIENT_CATALOGS = [ + { names: ["Bash", "Read"], wireNames: ["Bash", "Read"] }, + { names: ["Read", "exec_command"], wireNames: ["ocx_client_Read", "exec_command"] }, + { names: ["exec_command", "Read"], wireNames: ["exec_command", "ocx_client_Read"] }, +]; + +function execMessage(message: Parameters>[1]["message"]) { + return create(ExecServerMessageSchema, { id: 1, execId: "wording-test", message }); +} +function refusalTexts(bytes: Uint8Array[]): string[] { + const texts: string[] = []; + function visit(value: unknown) { + if (!value || typeof value !== "object") return; + for (const [key, entry] of Object.entries(value)) { + if (["error", "reason", "stderr", "data"].includes(key) && typeof entry === "string" && entry) texts.push(entry); + else visit(entry); + } + } + for (const reply of bytes) visit(fromBinary(AgentClientMessageSchema, reply)); + expect(texts.length).toBeGreaterThan(0); + return texts; +} +const frames = { + read: execMessage({ case: "readArgs", value: create(ReadArgsSchema, { path: "fixture.txt" }) }), + ls: execMessage({ case: "lsArgs", value: create(LsArgsSchema, { path: "fixture-dir" }) }), + grep: execMessage({ case: "grepArgs", value: create(GrepArgsSchema, { path: "fixture-dir", pattern: "fixture" }) }), + write: execMessage({ case: "writeArgs", value: create(WriteArgsSchema, { path: "fixture.txt", fileText: "unwritten" }) }), + delete: execMessage({ case: "deleteArgs", value: create(DeleteArgsSchema, { path: "fixture.txt" }) }), + shell: execMessage({ case: "shellArgs", value: create(ShellArgsSchema, { command: "echo fixture" }) }), + stream: execMessage({ case: "shellStreamArgs", value: create(ShellArgsSchema, { command: "echo fixture" }) }), + background: execMessage({ case: "backgroundShellSpawnArgs", value: create(BackgroundShellSpawnArgsSchema, { command: "echo fixture" }) }), + stdin: execMessage({ case: "writeShellStdinArgs", value: create(WriteShellStdinArgsSchema, { shellId: 1, chars: "fixture" }) }), + fetch: execMessage({ case: "fetchArgs", value: create(FetchArgsSchema, { url: "https://example.com" }) }), +}; + +afterEach(() => resetCursorBlobStateForTests()); + +describe("Cursor target-specific tool wording", () => { + test.each(CLAUDE_CLIENT_CATALOGS)("Claude redirect matches the request catalog: %j", ({ names, wireNames }) => { + const tools = names.map(name => ({ name, description: "Fixture tool", parameters: {} })); + const definitions = buildCursorToolDefinitions(tools); + expect(definitions.map(tool => tool.name)).toEqual(wireNames); + const hint = cursorNativeExecRedirectHint(tools, [], "claude-sonnet-5-5")!; + expect(hint.match(/Available tools in this request's catalog: (.*?)\. /)?.[1]) + .toBe(wireNames.map(name => `\`${name}\``).join(", ")); + const guidance = buildCursorToolGuidanceSystemNote(tools, undefined, "claude-sonnet-5-5")!; + for (const name of wireNames) expect(guidance).toContain(`\`${name}\``); + }); + test.each(CLAUDE_CLIENT_CATALOGS)("Claude live transport prepares the request catalog: %j", async ({ names, wireNames }) => { + const transport = createLiveCursorTransport({ provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "fixture-token" }, + translatorBudget: createTestTranslatorBudget(), headers: new Headers() }); + const internal = transport as unknown as { open(bytes: Uint8Array): void; execContext: CursorNativeExecContext }; + let preparedBytes: Uint8Array | undefined; + internal.open = bytes => { preparedBytes = bytes; throw new Error("catalog fixture stops before network"); }; + try { + const request: CursorRunRequest = { modelId: "claude-sonnet-5-5", conversationId: "catalog-fixture", system: [], + messages: [{ role: "user", content: "Inspect the project." }], + tools: names.map(name => ({ name, description: "Fixture tool", parameters: {} })) }; + await expect(transport.run(request)[Symbol.asyncIterator]().next()).rejects.toThrow("catalog fixture stops before network"); + expect(preparedBytes).toBeDefined(); + const decoded = fromBinary(AgentClientMessageSchema, preparedBytes!); + if (decoded.message.case !== "runRequest") throw new Error("Expected runRequest"); + expect(decoded.message.value.mcpTools?.mcpTools.map(tool => tool.name)).toEqual(wireNames); + expect(internal.execContext.clientToolDefs?.map(tool => tool.name)).toEqual(wireNames); + expect(internal.execContext.nativeExecRedirectHint?.match(/Available tools in this request's catalog: (.*?)\. /)?.[1]) + .toBe(wireNames.map(name => `\`${name}\``).join(", ")); + expect(internal.execContext.plainToolWording).toBe(true); + } finally { await transport.close?.(); } + }); + test.each(CLAUDE_MODELS)("recognizes Claude target %s", modelId => { + expect(cursorUsesPlainToolWording(modelId)).toBe(true); + }); + test.each(OTHER_MODELS)("preserves legacy mode for %s", modelId => { + expect(cursorUsesPlainToolWording(modelId)).toBe(false); + }); + test.each(CLAUDE_MODELS)("Claude guidance and redirects contain no concealment: %s", modelId => { + for (const tools of Object.values(legacy.catalogs)) { + const guidance = buildCursorToolGuidanceSystemNote(tools, undefined, modelId)!; + const hint = cursorNativeExecRedirectHint(tools, [], modelId)!; + for (const text of [guidance, hint]) { + expect(text).not.toMatch(CONCEALMENT); + expect(text).toContain("not available"); + expect(text.endsWith(CURSOR_TOOL_CALL_CONTINUATION)).toBe(true); + } + } + const codeHint = cursorNativeExecRedirectHint(legacy.catalogs.code, [], modelId)!; + expect(codeHint).toContain("text(await tools.exec_command("); + expect(codeHint).toContain("not top-level tools"); + expect(codeHint).toContain("Every other listed tool remains callable"); + expect(cursorNativeExecRedirectHint(legacy.catalogs.client, [], modelId)).not.toContain("shell_command"); + }); + test.each(OTHER_MODELS)("non-Claude text matches pre-change snapshots byte-for-byte: %s", modelId => { + for (const key of Object.keys(legacy.catalogs) as Array) { + expect(buildCursorToolGuidanceSystemNote(legacy.catalogs[key], undefined, modelId)).toBe(legacy.guidance[key]); + } + expect(cursorNativeExecRedirectHint(legacy.catalogs.code, [], modelId)).toBe(legacy.redirect.code); + expect(cursorNativeExecRedirectHint(legacy.catalogs.client, [], modelId)).toBe(legacy.redirect.client); + expect(cursorNativeExecRedirectHint(undefined, [{ name: "lookup", providerIdentifier: "docs" }], modelId)).toBe(legacy.redirect.mcp); + for (const tools of [undefined, [], legacy.catalogs.shell, legacy.catalogs.mixed, [{ name: "exec" }]]) { + expect(cursorNativeExecRedirectHint(tools, [], modelId)).toBeUndefined(); + } + expect(nativeShellDisabledMessage()).toBe(legacy.defaults.shell); + }); + test("Claude catalog guidance respects tool choice, MCP names, and display caps", () => { + const modelId = CLAUDE_MODELS[0]; + expect(buildCursorToolGuidanceSystemNote(legacy.catalogs.shell, "none", modelId)).toBeUndefined(); + const hint = cursorNativeExecRedirectHint(undefined, [{ name: "lookup", providerIdentifier: "docs" }], modelId)!; + expect(hint).toContain("`mcp_docs_lookup`"); + expect(hint).not.toContain("shell_command"); + const capped = cursorNativeExecRedirectHint(Array.from({ length: 20 }, (_, i) => ({ name: `tool_${i}` })), [], modelId)!; + expect(capped).toContain("`ocx_client_tool_15`"); + expect(capped).not.toContain("`ocx_client_tool_16`"); + expect(capped).toContain("(+4 more)"); + }); + test.each(["code", "shell"] as const)("Claude factual routing prose reaches the next tool call without retry (%s)", async catalog => { + const tools = legacy.catalogs[catalog]; + const toolName = tools[0]!.name; + const fragments = ["Cursor-native Shell is unavailable. ", "I will use the advertised ", `${toolName} tool.\n`]; + const runRequests: CursorRunRequest[] = []; + let reachedToolCall = 0; + const adapter = createCursorAdapter({ adapter: "cursor", apiKey: "fixture-token" }, { + createTransport: () => ({ + async *run(request) { + runRequests.push(request); + for (const text of fragments) yield { type: "text", text } satisfies CursorServerMessage; + reachedToolCall++; + yield { type: "tool_call_start", id: "call-next", name: toolName } satisfies CursorServerMessage; + yield { type: "tool_call_delta", arguments: catalog === "code" ? "text(await tools.exec_command({cmd: 'pwd'}))" : '{"cmd":"pwd"}' } satisfies CursorServerMessage; + yield { type: "tool_call_end", id: "call-next" } satisfies CursorServerMessage; + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + }); + const parsed = parseRequest({ model: "cursor/claude-4.6-opus-high", input: [ + { role: "user", content: "Inspect the project." }, + { type: "function_call", call_id: "call-prior", name: toolName, arguments: "{}" }, + { type: "function_call_output", call_id: "call-prior", output: "prior result" }, + ], tools: tools.map(tool => ({ type: ("freeform" in tool && tool.freeform) ? "custom" : "function", name: tool.name, description: "Fixture tool", parameters: {} })) }); + expect(parsed.context.messages.at(-1)?.role).toBe("toolResult"); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.filter(event => event.type === "error")).toEqual([]); + expect(runRequests).toHaveLength(1); + expect(runRequests[0]!.echoRetryContinuationText).toBeUndefined(); + expect(reachedToolCall).toBe(1); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(fragments.join("")); + expect(events.some(event => event.type === "tool_call_end")).toBe(true); + }); + test("non-Claude factual routing prose retains the legacy abort and corrective retry", async () => { + const runRequests: CursorRunRequest[] = []; + let reachedToolCall = 0; + const adapter = createCursorAdapter({ adapter: "cursor", apiKey: "fixture-token" }, { + createTransport: () => ({ + async *run(request) { + runRequests.push(request); + yield { type: "text", text: "Cursor-native Shell is unavailable. I will use exec.\n" } satisfies CursorServerMessage; + reachedToolCall++; + yield { type: "tool_call_start", id: "call-next", name: "exec" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + }); + const parsed = parseRequest({ model: "cursor/kimi-k3-1m", input: "Inspect the project.", tools: [{ type: "custom", name: "exec", description: "Fixture tool" }] }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(runRequests).toHaveLength(2); + expect(runRequests[1]!.echoRetryContinuationText).toBe(CURSOR_ROUTING_COMMENTARY_RETRY_TEXT); + expect(reachedToolCall).toBe(0); + expect(events.some(event => event.type === "error")).toBe(true); + expect(events.some(event => event.type === "text_delta" || event.type === "tool_call_start")).toBe(false); + }); + test.each(["explicit empty catalog", "empty dispatcher catalog"])("Claude denials accurately report no tools (%s)", async kind => { + const deps: CursorNativeExecContext = kind === "explicit empty catalog" + ? { plainToolWording: true, nativeExecRedirectHint: cursorNativeExecRedirectHint([], [], CLAUDE_MODELS[0]) } + : { plainToolWording: true, clientToolDefs: [], mcpToolDefs: [] }; + for (const frame of Object.values(frames)) { + for (const text of refusalTexts(await handleCursorNativeExec(frame, deps))) { + expect(text).toContain("No client tools are available"); + expect(text).toContain("Answer without tools"); + expect(text).toContain("report"); + expect(text).not.toMatch(/shell_command|exec_command|apply_patch|curl|wget|tool call/i); + expect(text).not.toMatch(CONCEALMENT); + } + } + }); + test("Claude fallback uses the dispatcher catalog without inventing a shell tool", async () => { + const deps: CursorNativeExecContext = { plainToolWording: true, clientToolDefs: [], mcpToolDefs: [{ name: "lookup", providerIdentifier: "docs" } as NonNullable[number]] }; + const [text] = refusalTexts(await handleCursorNativeExec(frames.fetch, deps)); + expect(text).toContain("`mcp_docs_lookup`"); + expect(text).not.toMatch(/shell_command|exec_command|curl|wget/); + expect(text!.endsWith(CURSOR_TOOL_CALL_CONTINUATION)).toBe(true); + }); + test("all Claude fs/shell/fetch policy denials use factual wording, including fallback dispatch", async () => { + let fetchCalls = 0; + for (const tools of [undefined, ...Object.values(legacy.catalogs)]) { + const deps: CursorNativeExecContext = { + plainToolWording: true, + nativeExecRedirectHint: cursorNativeExecRedirectHint(tools, [], CLAUDE_MODELS[0]), + fetch: async () => { fetchCalls++; return new Response("unexpected"); }, + }; + for (const frame of Object.values(frames)) { + for (const text of refusalTexts(await handleCursorNativeExec(frame, deps))) { + expect(text).not.toMatch(CONCEALMENT); + expect(text).toContain("not available"); + if (tools?.length) expect(text.endsWith(CURSOR_TOOL_CALL_CONTINUATION)).toBe(true); + else expect(text).toContain("Answer without tools"); + } + } + } + expect(fetchCalls).toBe(0); + }); + test("default non-Claude fs/shell/fetch denials are byte-identical", async () => { + const expected = { ...legacy.defaults, read: legacy.defaults.fs, ls: legacy.defaults.fs, grep: legacy.defaults.fs, + stream: legacy.defaults.shell, background: legacy.defaults.shell, stdin: legacy.defaults.shell }; + for (const [key, frame] of Object.entries(frames)) { + for (const text of refusalTexts(await handleCursorNativeExec(frame))) { + expect(text).toBe(expected[key as keyof typeof expected]); + } + } + }); + test.each([false, true])("mutation redirects preserve non-Claude snapshots and remove concealment for Claude (structured=%s)", async structuredEditAvailable => { + for (const operation of ["write", "delete"] as const) { + const deps = { unsafeAllowNativeLocalExec: true, rejectNativeFileMutations: true, structuredEditAvailable }; + const [original] = refusalTexts(await handleCursorNativeExec(frames[operation], deps)); + expect(original).toBe(legacy.mutation[structuredEditAvailable ? "true" : "false"][operation]); + const [plain] = refusalTexts(await handleCursorNativeExec(frames[operation], { ...deps, plainToolWording: true })); + expect(plain).not.toMatch(CONCEALMENT); + expect(plain).toContain("No file was changed."); + expect(plain!.endsWith(CURSOR_TOOL_CALL_CONTINUATION)).toBe(true); + } + }); + test("foreground native shells retain factual redirects even with unsafe native exec enabled", async () => { + for (const key of ["shell", "stream"] as const) { + const texts = refusalTexts(await handleCursorNativeExec(frames[key], { unsafeAllowNativeLocalExec: true, plainToolWording: true })); + for (const text of texts) { + expect(text).not.toMatch(CONCEALMENT); + expect(text).toContain("not available"); + expect(text).toContain("Answer without tools"); + expect(text).not.toMatch(/shell_command|exec_command/); + } + } + }); + test.each(["claude-sonnet-5-5", "cursor-grok-4.6-high"])("encoded system roots use the final target model: %s", modelId => { + for (const tools of [legacy.catalogs.shell, legacy.catalogs.code]) { + const request = createCursorRequest(parseRequest({ model: `cursor/${modelId}`, input: "Inspect the project.", + tools: tools.map(tool => ({ type: ("freeform" in tool && tool.freeform) ? "custom" : "function", name: tool.name, description: "Fixture tool", parameters: {} })) })); + const decoded = fromBinary(AgentClientMessageSchema, encodeCursorRunRequest(request)); + if (decoded.message.case !== "runRequest") throw new Error("Expected runRequest"); + const roots = decoded.message.value.conversationState!.rootPromptMessagesJson.map(id => JSON.parse(cursorBlobTextForEstimate(id)!)); + const expected = buildCursorToolGuidanceSystemNote(request.tools, request.toolChoice, request.modelId)!; + expect(roots.some(root => root.role === "system" && root.content === expected)).toBe(true); + if (cursorUsesPlainToolWording(modelId)) expect(expected).not.toMatch(CONCEALMENT); + else expect(expected).toMatch(CONCEALMENT); + } + }); + test("live transport refreshes wording with each turn's target rather than retaining a Claude flag", async () => { + const transport = createLiveCursorTransport({ provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "fixture-token" }, + translatorBudget: createTestTranslatorBudget(), headers: new Headers() }); + const internal = transport as unknown as { open(): void; execContext: CursorNativeExecContext }; + internal.open = () => { throw new Error("wording fixture stops before network"); }; + try { + for (const modelId of ["claude-sonnet-5-5", "cursor-grok-4.6-high", "claude-4.6-opus-high"]) { + const request = { modelId, conversationId: "wording-fixture", system: [], messages: [{ role: "user" as const, content: "Inspect the project." }], tools: legacy.catalogs.shell.map(tool => ({ ...tool, description: "Run", parameters: {} })) }; + await expect(transport.run(request)[Symbol.asyncIterator]().next()).rejects.toThrow("wording fixture stops before network"); + expect(internal.execContext.plainToolWording).toBe(cursorUsesPlainToolWording(modelId)); + expect(internal.execContext.nativeExecRedirectHint).toBe(cursorNativeExecRedirectHint(request.tools, [], modelId)); + const [text] = refusalTexts(await handleCursorNativeExec(frames.fetch, internal.execContext)); + if (cursorUsesPlainToolWording(modelId)) expect(text).not.toMatch(CONCEALMENT); + else expect(text).toBe(legacy.defaults.fetch); + } + } finally { await transport.close?.(); } + }); +}); diff --git a/tests/providers/devin-chat-wire-fixes.test.ts b/tests/providers/devin-chat-wire-fixes.test.ts index f2fd80f9c3f..eceadc3b438 100644 --- a/tests/providers/devin-chat-wire-fixes.test.ts +++ b/tests/providers/devin-chat-wire-fixes.test.ts @@ -14,6 +14,7 @@ import { buildGetChatMessageRequestForTests, type ChatHistoryItem } from "../../ import { normalizeDevinToolParameters } from "../../src/adapters/devin/cloud-direct/tool-schema"; import { isDevinHistoryOverflow } from "../../src/adapters/devin/context-overflow"; import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { parseRequest } from "../../src/responses/parser"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxToolResultMessage } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -170,6 +171,89 @@ describe("consecutive Devin tool results", () => { ] }]); }); + test("structured results append without repeatedly traversing their accumulated prefix", () => { + const followups = 128; + const initialParts = 1_024; + const request = parseRequest({ model: "swe-1-6", stream: true, input: [ + { type: "function_call", call_id: "a", name: "read", arguments: "{}" }, + { type: "function_call_output", call_id: "a", output: [ + { type: "input_image", image_url: "data:image/png;base64,YQ==" }, + ...Array.from({ length: initialParts }, () => ({ type: "input_text", text: "x" })), + ] }, + ...Array.from({ length: followups }, () => ({ type: "function_call_output", call_id: "a", output: "" })), + ] }); + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator)!; + let visits = 0; + let mapped: ChatHistoryItem[]; + try { + Object.defineProperty(Array.prototype, Symbol.iterator, { ...descriptor, + value: function(this: unknown[]) { + const iterator = descriptor.value.call(this) as IterableIterator; + const first = this[0] as { type?: unknown; base64Data?: unknown } | undefined; + // Input parts use imageUrl; this sentinel matches only mapper-owned wire arrays. + if (first?.type === "image" && first.base64Data === "YQ==") { + const next = iterator.next.bind(iterator); + iterator.next = () => { + const item = next(); + if (!item.done) visits += 1; + return item; + }; + } + return iterator; + }, + }); + mapped = mapOcxMessagesToDevin(request); + } finally { Object.defineProperty(Array.prototype, Symbol.iterator, descriptor); } + const tools = mapped!.filter(item => item.role === "tool"); + expect(tools).toHaveLength(1); + expect(tools[0]!.content).toHaveLength(1 + initialParts + 2 * followups); + expect(visits).toBeLessThanOrEqual(3 * (1 + initialParts + 2 * followups)); + }); + + test("text runs join once at their structured transition and retain empty chunks", () => { + const chunks = Array.from({ length: 512 }, (_, i) => i % 2 ? "" : String(i)); + const request = parsed([ + ...chunks.map(chunk => result("a", chunk)), + result("a", [{ type: "image", imageUrl: "data:image/png;base64,YQ==" }]), + result("a", "", true), result("a", "tail"), + result("b", "separate"), result("b", ""), + ]); + const mapped = mapOcxMessagesToDevin(request); + expect(mapped).toEqual([ + { role: "tool", tool_call_id: "a", is_error: true, content: [ + { type: "text", text: chunks.join("\n\n") }, { type: "text", text: "\n\n" }, + { type: "image", mimeType: "image/png", base64Data: "YQ==" }, + { type: "text", text: "\n\n" }, { type: "text", text: "ERROR: " }, + { type: "text", text: "\n\n" }, { type: "text", text: "tail" }, + ] }, + { role: "tool", tool_call_id: "b", content: "separate\n\n" }, + ]); + }); + + test("each mapping owns its structured arrays even when the input is frozen", () => { + const request = parsed([ + result("a", [{ type: "image", imageUrl: "data:image/png;base64,YQ==" }]), + result("a", [{ type: "text", text: "later" }]), result("a", "last"), + ]); + for (const message of request.context.messages) { + if (Array.isArray(message.content)) { + for (const part of message.content) Object.freeze(part); + Object.freeze(message.content); + } + Object.freeze(message); + } + Object.freeze(request.context.messages); Object.freeze(request.context); Object.freeze(request); + const snapshot = JSON.stringify(request); + const first = mapOcxMessagesToDevin(request); + const second = mapOcxMessagesToDevin(request); + expect(first).toEqual(second); + expect(first[0]!.content).not.toBe(second[0]!.content); + if (typeof first[0]!.content === "string") throw new Error("expected structured content"); + first[0]!.content.push({ type: "text", text: "owned-only" }); + expect(first).not.toEqual(second); + expect(JSON.stringify(request)).toBe(snapshot); + }); + test("consolidation leaves the parsed request unchanged", () => { const request = parsed([ result("a", [{ type: "text", text: "started" }, { type: "image", imageUrl: "data:image/png;base64,YQ==" }]), diff --git a/tests/responses/chat-native-combo.test.ts b/tests/responses/chat-native-combo.test.ts index 55b9d15a679..650868db4a7 100644 --- a/tests/responses/chat-native-combo.test.ts +++ b/tests/responses/chat-native-combo.test.ts @@ -175,6 +175,44 @@ describe("native Chat candidates in a combo", () => { }); }); + test.each([ + ["native", "low", "unset", "high"], + ["native", "xhigh", "unset", "high"], + ["native", "high", "unset", "high"], + ["native", "low", "medium", "high->medium"], + ["bridge", "low", "unset", "high->low"], + ["bridge", "xhigh", "unset", "high->xhigh"], + ["bridge", "low", "medium", "high->low->medium"], + ] as const)("JEV wire effort: lane=%s, selected=%s, pin=%s, label=%s", async (lane, selected, pinned, label) => { + const native = lane === "native"; + const pin = pinned === "unset" ? undefined : pinned; + const a = upstream(body => body.stream === true ? chatStream("effort fixture") : twoChoiceCompletion()); + const config = comboConfig({ + a: provider("openai-chat", a.baseUrl, { + models: ["m1"], liveModels: false, reasoningEfforts: ["low", "medium", "high", "xhigh"], + ...(pin ? { pinnedReasoningEffort: pin } : {}), + }), + jev: { + adapter: "jev-decision", baseUrl: "https://api.typesafe.ai/v1/systemone", + authMode: "key", apiKey: "decision-fixture", liveModels: false, + fetch: (async () => Response.json({ answers: { route: { choice: `a/m1:${selected}` } } })) as typeof fetch, + }, + }, [{ provider: "a", model: "m1" }], native ? NATIVE_ON : undefined); + config.combos!.pair!.strategy = "jev"; + + const { response, rows } = await send(config, { stream: false, reasoning_effort: "high" }); + + expect(response.status).toBe(200); + expect(a.bodies).toHaveLength(1); + expect(a.bodies[0]!.reasoning_effort).toBe(pin ?? (native ? "high" : selected)); + expect(rows).toHaveLength(1); + expect(rows[0]!.jevDecision?.selected).toEqual({ provider: "a", model: "m1", effort: selected }); + expect(rows[0]!.requestedEffort).toBe(label); + expect(rows[0]!.attempts).toHaveLength(1); + expect(rows[0]!.attempts![0]!.requestedEffort).toBe(label); + expect(rows[0]!.protocolTrace!.attempts![0]!.mode).toBe(native ? "native" : "legacy-bridge"); + }); + test("a failed native candidate fails over to the bridge within the shared send budget", async () => { // Five sends on its own ladder, but the combo's per-target budget holds one back for the // second declared target: the native child may reach its upstream three times, not five. diff --git a/tests/routing/jev-decision.test.ts b/tests/routing/jev-decision.test.ts index e22984a0290..f1a5b228c80 100644 --- a/tests/routing/jev-decision.test.ts +++ b/tests/routing/jev-decision.test.ts @@ -10,6 +10,7 @@ import { type JevCandidate, type ResolveJevDecisionOptions, } from "../../src/combos/jev"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; import type { OcxConfig } from "../../src/types"; const candidates: JevCandidate[] = [ @@ -138,6 +139,133 @@ describe("JEV bounded decision state", () => { expect(JSON.stringify(state)).not.toContain("TOOL_MACHINE_SECRET"); }); + test.each(["string", "blocks"])("extracts the real first Claude Code task from %s content", shape => { + const task = "Fix the parser and add a regression test."; + const reminders = [ + `Project CLAUDE.md guidance\n${"Follow project conventions.\n".repeat(200)}`, + "User context and attribution guidance.", + ]; + const content = shape === "string" + ? [...reminders, task].join("\n\n") + : [...reminders, task].map(text => ({ type: "text", text })); + const body = anthropicToResponsesBody({ + model: "claude-sonnet-4-6", + messages: [{ role: "user", content }], + }); + const original = JSON.stringify(body); + + expect(original).toContain(""); + expect(buildJevState(body)).toEqual({ + task, + signals: { has_image: false, tool_history: false }, + step: { type: "user_turn" }, + }); + expect(JSON.stringify(body)).toBe(original); + }); + + test("strips reminders from the latest task and preserves head/tail clipping", () => { + const task = `${"h".repeat(380)}${"t".repeat(380)}`; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-4-6", + messages: [ + { role: "user", content: "An older request." }, + { role: "assistant", content: "Ready for the next request." }, + { + role: "user", + content: `${"context".repeat(40_000)}\n${task}`, + }, + ], + }); + + expect(buildJevState(body)).toEqual({ + task: `${"h".repeat(320)}\n[...]\n${"t".repeat(173)}`, + signals: { has_image: false, tool_history: false }, + step: { type: "user_turn" }, + previous_assistant: "Ready for the next request.", + }); + }); + + test("uses protected nesting semantics without salvaging reminder contents as a Codex goal", () => { + const reminder = 'Quoted goal'; + expect(buildJevState({ input: reminder })).toMatchObject({ task: "" }); + expect(buildJevState({ + input: `Active goal${reminder}`, + })).toMatchObject({ task: "Active goal" }); + expect(buildJevState({ + input: "OuterInnerOuterReal task", + })).toMatchObject({ task: "Real task" }); + expect(buildJevState({ + input: "Visible taskUnclosed context", + })).toMatchObject({ task: "Visible task" }); + expect(buildJevState({ input: "Unclosed context" })).toMatchObject({ task: "" }); + expect(buildJevState({ input: "BeforeContextafter" })) + .toMatchObject({ task: "Before\nafter" }); + }); + + test("removes Claude Code reminders from converted assistant and tool-result tails", () => { + const reminder = `${"Context only. ".repeat(200)}`; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-4-6", + messages: [ + { role: "user", content: "Continue the investigation." }, + { + role: "assistant", + content: [ + { type: "text", text: `Visible plan${reminder}visible follow-up${reminder}` }, + { type: "tool_use", id: "call-1", name: "Read", input: { file: "example.ts" } }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call-1", content: `Visible result${reminder}` }], + }, + ], + }); + + expect(buildJevState(body)).toEqual({ + task: "Continue the investigation.", + signals: { has_image: false, tool_history: true }, + step: { type: "tool_step", last_tool_output_tail: "Visible result", tool_call: { name: "Read" } }, + previous_assistant: "Visible plan\nvisible follow-up", + }); + }); + + test("keeps reminder-free states byte-identical to the pre-reminder behavior", () => { + const task = ` ${"h".repeat(380)}ordinary markup${"t".repeat(380)} `; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-4-6", + messages: [ + { role: "user", content: task }, + { + role: "assistant", + content: [ + { type: "text", text: ` ${"a".repeat(300)}plan ` }, + { type: "tool_use", id: "call-1", name: "Read", input: { file: "example.ts" } }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call-1", content: ` ${"z".repeat(600)} ` }], + }, + ], + }); + const expected = { + task: `${"h".repeat(320)}\n[...]\n${"t".repeat(173)}`, + signals: { has_image: false, tool_history: true }, + step: { type: "tool_step", last_tool_output_tail: "z".repeat(520), tool_call: { name: "Read" } }, + previous_assistant: `${"a".repeat(217)}plan`, + }; + + expect(JSON.stringify(buildJevState(body))).toBe(JSON.stringify(expected)); + expect(JSON.stringify(buildJevState({ input: task }))).toBe(JSON.stringify({ + task: expected.task, + signals: { has_image: false, tool_history: false }, + step: { type: "user_turn" }, + })); + expect(buildJevState({ input: "ContextCodex task" })) + .toMatchObject({ task: "Codex task" }); + }); + test("salvages an envelope-only active goal but drops catalog-only envelopes", () => { expect(buildJevState({ input: 'Keep implementing JEV.', diff --git a/tests/server/combo-requested-effort.test.ts b/tests/server/combo-requested-effort.test.ts new file mode 100644 index 00000000000..6a937174203 --- /dev/null +++ b/tests/server/combo-requested-effort.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { comboRequestedEffortLabel } from "../../src/server/responses/combo-requested-effort"; + +describe("Combo requested effort labels", () => { + test.each([ + ["high", "xhigh", "xhigh", "high->xhigh"], + ["high", "low", "low", "high->low"], + ["none", "high", "high", "none->high"], + ["high", undefined, "xhigh", "high->xhigh"], + ["high", "xhigh->medium", "xhigh", "high->xhigh->medium"], + ["high", "xhigh->medium->low", "xhigh", "high->xhigh->medium->low"], + ["high", "low->high", "low", "high->low->high"], + ["high", "high", "high", "high"], + ["high", "high->low", "high", "high->low"], + ["high", "medium", undefined, "high"], + ["high", undefined, undefined, "high"], + ["high", "medium->low", undefined, "high->low"], + ["high", "medium->low->none", null, "high->low->none"], + ["high", undefined, null, "high"], + [undefined, "xhigh", "xhigh", "xhigh"], + [undefined, "xhigh->low", "xhigh", "xhigh->low"], + [undefined, undefined, "xhigh", undefined], + ] as const)("caller %s, child %s, forced %s produces %s", (original, child, forced, expected) => { + expect(comboRequestedEffortLabel(original, child, forced)).toBe(expected); + }); +}); diff --git a/tests/server/server-jev-combo-e2e.test.ts b/tests/server/server-jev-combo-e2e.test.ts index e4c013bee47..16c00e2d1bd 100644 --- a/tests/server/server-jev-combo-e2e.test.ts +++ b/tests/server/server-jev-combo-e2e.test.ts @@ -184,6 +184,78 @@ describe("JEV Combo runtime", () => { }); }); + test.each(["xhigh", "low", "high"])("records caller high and JEV's forced %s on the request and attempt", async forced => { + const config = makeConfig({ jevFetch: choiceFetch(`sol/gpt-5.6-sol:${forced}`) }); + const parentLog: RequestLogContext = { model: "", provider: "" }; + const childBodies: Record[] = []; + + const response = await execute(config, (body, childLog) => { + childBodies.push(body); + childLog.requestedEffort = (body.reasoning as { effort: string }).effort; + return success(String(body.model)); + }, { reasoning: { effort: "high", summary: "auto" } }, undefined, parentLog); + + expect(response.status).toBe(200); + expect(childBodies).toEqual([expect.objectContaining({ reasoning: { effort: forced, summary: "auto" } })]); + const expected = forced === "high" ? "high" : `high->${forced}`; + expect(parentLog.requestedEffort).toBe(expected); + expect(parentLog.attempts).toHaveLength(1); + expect(parentLog.attempts![0]!.requestedEffort).toBe(expected); + }); + + test("preserves a later child effort transition after JEV's forced effort", async () => { + const config = makeConfig({ jevFetch: choiceFetch("sol/gpt-5.6-sol:xhigh") }); + const parentLog: RequestLogContext = { model: "", provider: "" }; + + const response = await execute(config, (body, childLog) => { + expect(body.reasoning).toMatchObject({ effort: "xhigh" }); + childLog.requestedEffort = "xhigh->medium->low"; + return success(String(body.model)); + }, { reasoning: { effort: "high" } }, undefined, parentLog); + + expect(response.status).toBe(200); + expect(parentLog.requestedEffort).toBe("high->xhigh->medium->low"); + expect(parentLog.attempts![0]!.requestedEffort).toBe("high->xhigh->medium->low"); + }); + + test("keeps JEV's forced effort on the failed attempt without leaking it into fallback", async () => { + const config = makeConfig({ jevFetch: choiceFetch("sol/gpt-5.6-sol:low") }); + const parentLog: RequestLogContext = { model: "", provider: "" }; + const efforts: string[] = []; + + const response = await execute(config, (body, childLog) => { + const effort = (body.reasoning as { effort: string }).effort; + efforts.push(effort); + childLog.requestedEffort = effort; + return String(body.model).startsWith("sol/") + ? Response.json({ error: { message: "temporary outage" } }, { status: 503 }) + : success(String(body.model)); + }, { reasoning: { effort: "high" } }, undefined, parentLog); + + expect(response.status).toBe(200); + expect(efforts).toEqual(["low", "high"]); + expect(parentLog.requestedEffort).toBe("high"); + expect(parentLog.attempts?.map(attempt => attempt.requestedEffort)).toEqual(["high->low", "high"]); + }); + + test("retains today's label when the JEV target has no effort control", async () => { + const config = makeConfig({ + jevFetch: choiceFetch("sol/gpt-5.6-sol:none"), + providerOverrides: { sol: { modelReasoningEfforts: { "gpt-5.6-sol": [] } } }, + }); + const parentLog: RequestLogContext = { model: "", provider: "" }; + + const response = await execute(config, (body, childLog) => { + expect(body.reasoning).toEqual({ summary: "auto" }); + childLog.requestedEffort = undefined; + return success(String(body.model)); + }, { reasoning: { effort: "high", summary: "auto" } }, undefined, parentLog); + + expect(response.status).toBe(200); + expect(parentLog.requestedEffort).toBe("high"); + expect(parentLog.attempts![0]!.requestedEffort).toBe("high"); + }); + test("routes the initial call to JEV's allowlisted target and keeps direct/catalog rows", async () => { const jevRequests: Array> = []; const config = makeConfig({ diff --git a/tests/service/service-desktop-startup-linux.test.ts b/tests/service/service-desktop-startup-linux.test.ts new file mode 100644 index 00000000000..fd942cbf8f0 --- /dev/null +++ b/tests/service/service-desktop-startup-linux.test.ts @@ -0,0 +1,232 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { deriveStartupHealth, startupHealthSummary } from "../../src/codex/autostart-health"; +import { desktopStartupOwnership, diagnoseDesktopStartup, diagnoseLinuxDesktopStartup } from "../../src/service/desktop-startup"; +import type { ServiceOwnershipResolution } from "../../src/service/state"; + +// Fixtures use POSIX paths, execute bits and symlinks, and the diagnostic only runs on Linux. +const linuxTest = test.skipIf(process.platform === "win32"); +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +function fixture() { + const home = mkdtempSync(join(tmpdir(), "ocx-desktop-startup-linux-")); + roots.push(home); + const app = join(home, "usr", "bin", "opencodex-desktop"); + const proxy = join(home, "usr", "bin", "ocx"); + const idPath = join(home, ".config", "com.opencodex.desktop", "install-id"); + const entryPath = join(home, ".config", "autostart", "OpenCodex.desktop"); + for (const path of [app, proxy, idPath]) { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, path === idPath ? "installation-a\n" : "fixture"); + chmodSync(path, 0o700); + } + mkdirSync(join(entryPath, ".."), { recursive: true }); + const entry = (exec: string, extra = "") => writeFileSync(entryPath, + `[Desktop Entry]\nType=Application\nVersion=1.0\nName=OpenCodex\nExec=${exec}\nStartupNotify=false\nTerminal=false${extra}`); + entry(`${app} --autostart`); + const owner: ServiceOwnershipResolution = { kind: "owned", revision: 1, + ownership: { owner: "desktop", installId: "installation-a", consentGeneration: 2 } }; + const state = { + owner, exe: { 100: proxy, 200: app } as Record, parent: { 100: 200, 200: 1 } as Record, + pid: 100 as number | null, ownerReads: 0, pidReads: 0, changedPid: false, changedOwner: false, + }; + const deps = { + platform: "linux" as const, home, env: {} as NodeJS.ProcessEnv, + ownership: (): ServiceOwnershipResolution => { + state.ownerReads++; + return state.changedOwner && state.ownerReads > 1 ? { kind: "none", revision: 2 } : state.owner; + }, + readPid: () => { state.pidReads++; return state.changedPid && state.pidReads > 1 ? 101 : state.pid; }, + proc: { + exe: (pid: number) => { const exe = state.exe[pid]; if (!exe) throw new Error("no such process"); return exe; }, + parent: (pid: number) => { const parent = state.parent[pid]; if (parent === undefined) throw new Error("no such process"); return parent; }, + }, + }; + return { state, deps, idPath, entryPath, entry, proxy, app, home }; +} + +const healthBase = { + routingKind: "opencodex-local" as const, platform: "linux" as const, + autostartEnabled: true, serviceInstalled: true, serviceViable: false, + serviceEnabled: true, serviceRunning: false, serviceStale: false, + serviceConflict: false, serviceSupported: true, shimInstalled: false, shimHealthy: false, +}; + +linuxTest("matching install, XDG login entry and the app supervising its sidecar grant desktop protection on Linux", () => { + const { deps } = fixture(); + const desktop = diagnoseLinuxDesktopStartup(deps); + expect(desktop).toEqual({ owned: true, loginEnabled: true, running: true, viable: true }); + expect(diagnoseDesktopStartup(deps)).toEqual(desktop); + const health = deriveStartupHealth({ ...healthBase, desktop }); + expect(health).toMatchObject({ status: "protected", rebootSafe: true, protection: "desktop", recommendedCommand: null }); + expect(startupHealthSummary(health)).toBe("protected by desktop app at login and its proxy supervisor"); +}); + +linuxTest("a relocated XDG_CONFIG_HOME is not credited: the HOME-based entry is outside its autostart path", () => { + const f = fixture(); + const moved = join(f.home, "xdg"); + mkdirSync(join(moved, "com.opencodex.desktop"), { recursive: true }); + writeFileSync(join(moved, "com.opencodex.desktop", "install-id"), "installation-a"); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: false }); + mkdirSync(join(moved, "autostart"), { recursive: true }); + writeFileSync(join(moved, "autostart", "OpenCodex.desktop"), `[Desktop Entry] +Type=Application +Exec=${f.app} --autostart +`); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: false }); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: join(f.home, ".config") } })).toMatchObject({ viable: true }); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: `${join(f.home, ".config")}/` } })).toMatchObject({ viable: true }); +}); + +for (const field of ["OnlyShowIn=GNOME;", "NotShowIn=GNOME;", "TryExec=/missing", "OnlyShowIn=", "NotShowIn=", "TryExec="]) { + linuxTest(`conditional autostart entry is not credited: ${field}`, () => { + const f = fixture(); f.entry(`${f.app} --autostart`, `\n${field}`); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); + }); +} + +for (const command of [ + (app: string) => `"${app}" --autostart`, + (app: string) => `'${app}' --autostart`, + (app: string) => `${app.replace("/usr/", "/%Z/usr/")} --autostart`, + (app: string) => `${app.replace("/usr/", "/%f/usr/")} --autostart`, + (app: string) => `${app.replace("/usr/", "/escaped\\/usr/")} --autostart`, + (app: string) => `${app.replace("/usr/", "/has space/usr/")} --autostart`, + (app: string) => `${app} --autostart extra`, +]) { + linuxTest(`ambiguous Exec is not credited: ${command("/usr/opencodex-desktop")}`, () => { + const f = fixture(); + // Make the literal path real so rejection tests parsing, not a missing executable. + const exec = command(f.app); + const literal = exec.slice(0, exec.indexOf(" --autostart")).replace(/^["']|["']$/g, ""); + mkdirSync(join(literal, ".."), { recursive: true }); + writeFileSync(literal, "fixture"); chmodSync(literal, 0o700); + if (literal !== f.app) { + const proxy = join(literal, "..", "ocx"); + writeFileSync(proxy, "fixture"); chmodSync(proxy, 0o700); + f.state.exe[100] = proxy; f.state.exe[200] = literal; + } + f.entry(exec); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); + }); +} + +linuxTest("an opencodex-desktop symlink to another executable cannot grant protection", () => { + const f = fixture(); + const target = join(f.home, "usr", "bin", "sh"); + writeFileSync(target, "fixture"); chmodSync(target, 0o700); + rmSync(f.app); symlinkSync(target, f.app); + f.state.exe[200] = target; + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); +}); + +linuxTest("a desktop symlink uses the ocx beside its resolved executable", () => { + const f = fixture(); + const alias = join(f.home, "alias", "opencodex-desktop"); + mkdirSync(join(alias, "..")); symlinkSync(f.app, alias); f.entry(`${alias} --autostart`); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ viable: true }); + const outside = join(f.home, "outside-ocx"); + writeFileSync(outside, "fixture"); chmodSync(outside, 0o700); + rmSync(f.proxy); symlinkSync(outside, f.proxy); f.state.exe[100] = outside; + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, viable: false }); +}); + +linuxTest("AppImage registration cannot credit the executable running inside its mount", () => { + const f = fixture(); + const image = join(f.home, "OpenCodex.AppImage"); + writeFileSync(image, "fixture"); chmodSync(image, 0o700); f.entry(`${image} --autostart`); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); +}); + +type F = ReturnType; +const changes: [string, (f: F) => void][] = [ + ["install-id", f => writeFileSync(f.idPath, "different-install")], + ["Hidden=true", f => f.entry(`${f.app} --autostart`, "\nHidden=true")], + ["valid entry contents", f => f.entry(`${f.app} --autostart`, "\n# rewritten")], + ["desktop executable", f => { + const target = join(f.home, "replacement", "opencodex-desktop"); + mkdirSync(join(target, "..")); writeFileSync(target, "fixture"); chmodSync(target, 0o700); + const proxy = join(target, "..", "ocx"); writeFileSync(proxy, "fixture"); chmodSync(proxy, 0o700); + rmSync(f.app); symlinkSync(target, f.app); f.state.exe[200] = target; f.state.exe[100] = proxy; + }], + ["proxy executable", f => { + const target = join(f.home, "replacement-ocx"); + writeFileSync(target, "fixture"); chmodSync(target, 0o700); + rmSync(f.proxy); symlinkSync(target, f.proxy); f.state.exe[100] = target; + }], + ["executable permission", f => chmodSync(f.app, 0o600)], + ["proxy permission", f => chmodSync(f.proxy, 0o600)], + ["PID", f => { f.state.pid = 101; }], + ["parent relationship", f => { f.state.parent[100] = 300; f.state.exe[300] = f.app; }], + ["child process executable", f => { f.state.exe[100] = f.app; }], + ["parent process executable", f => { f.state.exe[200] = f.proxy; }], + ["ownership", f => { f.state.owner = { kind: "none", revision: 2 }; }], +]; +for (const [name, mutate] of changes) { + linuxTest(`final evidence read fails closed after ${name} changes`, () => { + const f = fixture(); + const original = f.deps.ownership; + let changed = false; + f.deps.ownership = () => { + const result = original(); + // Change evidence after the first process/PID checks, immediately before the final read. + if (f.state.ownerReads === 2 && !changed) { changed = true; mutate(f); } + return result; + }; + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, viable: false }); + expect(changed).toBe(true); + }); +} + +linuxTest("failed identity, login entry or process evidence retains the durable desktop claim", () => { + type F = ReturnType; + const mutations: ((f: F) => void)[] = [ + f => writeFileSync(f.idPath, "different-install"), + f => rmSync(f.idPath), + f => rmSync(f.entryPath), + f => f.entry(`${f.app} --autostart`, "\nHidden=true"), + f => f.entry(`${f.app} --autostart`, "\nX-GNOME-Autostart-enabled=false"), + f => f.entry(f.app), + f => f.entry(`${f.app} --wrong`), + f => f.entry(`${f.proxy} --autostart`), + f => f.entry(`opencodex-desktop --autostart`), + f => { f.state.exe[100] = f.app; }, + f => { f.state.parent[100] = 1; }, + f => { f.state.exe[200] = f.proxy; }, + f => { delete f.state.exe[100]; }, + f => { f.state.pid = null; }, + f => { f.state.changedPid = true; }, + f => { f.state.changedOwner = true; }, + ]; + for (const mutate of mutations) { + const f = fixture(); mutate(f); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, viable: false }); + } +}); + +linuxTest("Linux desktop ownership never recommends the service it superseded", () => { + const f = fixture(); + expect(desktopStartupOwnership(f.deps)).toEqual({ owned: true, loginEnabled: false, running: false, viable: false }); + f.state.pid = null; + const health = deriveStartupHealth({ ...healthBase, desktop: diagnoseLinuxDesktopStartup(f.deps) }); + expect(health).toMatchObject({ status: "at-risk", protection: "none", recommendedCommand: null }); + expect(startupHealthSummary(health)).toContain("Start at Login"); + expect(startupHealthSummary(health)).not.toContain("ocx service"); +}); + +linuxTest("other platforms and absent, CLI or unknown ownership cannot grant Linux desktop protection", () => { + const f = fixture(); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, platform: "win32" })).toBeUndefined(); + expect(f.state.ownerReads).toBe(0); + for (const owner of [ + { kind: "none", revision: 0 }, { kind: "unknown", reason: "unreadable" }, + { kind: "owned", revision: 1, ownership: { owner: "cli", installId: "installation-a", consentGeneration: 1 } }, + ] as ServiceOwnershipResolution[]) { + f.state.owner = owner; + expect(diagnoseLinuxDesktopStartup(f.deps)).toBeUndefined(); + expect(f.state.pidReads).toBe(0); + } +}); diff --git a/tests/web-search/web-search-pacing-deadline.test.ts b/tests/web-search/web-search-pacing-deadline.test.ts new file mode 100644 index 00000000000..0776e49f18f --- /dev/null +++ b/tests/web-search/web-search-pacing-deadline.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import { createAdapterPhysicalSend } from "../../src/adapters/physical-send"; +import { sleepWithAbort } from "../../src/lib/upstream-retry"; +import { + providerRequestPacingStatus, + resetProviderRequestPacingForTest, + waitForProviderRequestSlot, +} from "../../src/providers/request-pacing"; +import { parseRequest } from "../../src/responses/parser"; +import { providerFetch, type ProviderFetch } from "../../src/server/responses/fetch-helpers"; +import type { OcxProviderConfig } from "../../src/types"; +import { runWithWebSearch, type WebSearchLoopDeps } from "../../src/web-search/loop"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const HEADER_MS = 60; +const QUEUE_MS = 180; +const NAME = "search-pacing-test"; +const MODEL = "model"; +const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://routed.test/v1", + requestPacing: { enabled: true, maxConcurrentRequests: 1 }, +}; + +afterEach(() => resetProviderRequestPacingForTest()); + +function adapter(adapterSend = false): ProviderAdapter { + return { + name: "pacing-deadline", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + ...(adapterSend ? { + fetchResponse: (request, ctx) => createAdapterPhysicalSend(ctx)({ + url: request.url, + dispatch: executor => executor(request.url, { method: request.method, signal: ctx?.abortSignal }), + }), + } satisfies Pick : {}), + async *parseStream(response) { + await response.text(); + yield { type: "text_delta", text: "answer" }; + yield { type: "done" }; + }, + }; +} + +function fetcher(send: (init?: RequestInit) => Promise, configured = provider): ProviderFetch { + const physical = Object.assign(async (_input: Parameters[0], init?: RequestInit) => send(init), + { preconnect() {} }) as typeof fetch; + return providerFetch({ ...configured, fetch: physical } as OcxProviderConfig & { fetch: typeof fetch }, + undefined, { providerName: NAME, modelId: MODEL }); +} + +function run(executor: ProviderFetch, overrides: Partial = {}) { + return runWithWebSearch({ + parsed: parseRequest({ model: `routed/${MODEL}`, input: "hello", stream: true, tools: [{ type: "web_search" }] }), + adapter: adapter(), + incomingMeta: { headers: new Headers(), providerFetch: executor, translatorBudget: createTestTranslatorBudget() }, + forwardProvider: { adapter: "openai-responses", baseUrl: "https://chatgpt.test/v1", authMode: "forward" }, + hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers(), + settings: { model: "search", reasoning: "low", timeoutMs: 1000 }, maxSearches: 1, + connectTimeoutMs: HEADER_MS, + ...overrides, + }); +} + +async function expectSuccess(pending: Promise) { + const response = await pending; + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain("response.completed"); + expect(body).not.toContain("response.failed"); +} + +async function expectTimeout(pending: Promise, timeoutMs = HEADER_MS) { + const response = await pending; + expect(response.status).toBe(504); + expect(await response.json()).toEqual({ error: { + message: `Provider response-header timeout after ${timeoutMs}ms during web-search`, + type: "upstream_error", code: null, + } }); +} + +async function waitUntilQueued(configured = provider) { + for (let i = 0; i < 1000; i++) { + if (providerRequestPacingStatus(NAME, configured).queued > 0) return; + await Bun.sleep(1); + } + throw new Error("send never entered pacing queue"); +} + +describe("web-search pacing and response-header deadline", () => { + for (const adapterSend of [false, true]) { + test(`${adapterSend ? "adapter fetchResponse" : "ordinary fetch"} excludes a queue wait longer than its header deadline`, async () => { + const held = await waitForProviderRequestSlot(NAME, provider, MODEL); + let sends = 0; + const executor = fetcher(async () => { sends++; return new Response("ok"); }); + const pending = run(executor, { adapter: adapter(adapterSend) }); + const release = setTimeout(() => held.release(), QUEUE_MS); + try { + await expectSuccess(pending); + expect(sends).toBe(1); + expect(providerRequestPacingStatus(NAME, provider)).toMatchObject({ queued: 0, inFlight: 0 }); + } finally { clearTimeout(release); held.release(); } + }); + + test(`${adapterSend ? "adapter fetchResponse" : "ordinary fetch"} still times out genuine header stalls and releases its lease`, async () => { + const executor = fetcher(async init => { + await sleepWithAbort(10_000, init?.signal ?? undefined); + return new Response("unreachable"); + }); + await expectTimeout(run(executor, { adapter: adapter(adapterSend) })); + expect(providerRequestPacingStatus(NAME, provider)).toMatchObject({ queued: 0, inFlight: 0 }); + }); + } + + test("client cancellation removes a queued send and leaves the next slot available", async () => { + const held = await waitForProviderRequestSlot(NAME, provider, MODEL); + const caller = new AbortController(); + let sends = 0; + const executor = fetcher(async () => { sends++; return new Response("ok"); }); + const pending = run(executor, { abortSignal: caller.signal }); + await waitUntilQueued(); + caller.abort(new DOMException("client left", "AbortError")); + const response = await pending; + expect(response.status).toBe(499); + expect(sends).toBe(0); + expect(providerRequestPacingStatus(NAME, provider)).toMatchObject({ queued: 0, inFlight: 1 }); + held.release(); + await expectSuccess(run(executor)); + expect(sends).toBe(1); + expect(providerRequestPacingStatus(NAME, provider).inFlight).toBe(0); + }); + + for (const recovery of ["same-target", "rotation", "reset"] as const) { + test(`${recovery} replay excludes its own pacing wait`, async () => { + const configured = { ...provider, requestPacing: { ...provider.requestPacing, + minIntervalMs: recovery === "reset" ? QUEUE_MS * 5 : QUEUE_MS } }; + let sends = 0; + const executor = fetcher(async () => { + if (++sends === 1) { + if (recovery === "reset") throw Object.assign(new Error("socket reset"), { code: "ECONNRESET" }); + return new Response("limited", { status: 429 }); + } + return new Response("ok"); + }, configured); + await expectSuccess(run(executor, { + // Reset recovery retains its existing 120-180ms backoff inside the budget. + connectTimeoutMs: recovery === "reset" ? HEADER_MS * 5 : HEADER_MS, + ...(recovery === "same-target" ? { retryOn429Policy: { + enabled: true, attempts: 1, intervalMs: 1, maxIntervalMs: 1, respectRetryAfter: false, + } } : {}), + ...(recovery === "rotation" ? { on429: () => ({ adapter: adapter(true), recoveryKind: "key-429" }) } : {}), + })); + expect(sends).toBe(2); + expect(providerRequestPacingStatus(NAME, configured)).toMatchObject({ queued: 0, inFlight: 0 }); + }); + } + + test("an adapter that calls its executor directly also excludes pacing", async () => { + const held = await waitForProviderRequestSlot(NAME, provider, MODEL); + const direct = adapter(); + direct.fetchResponse = (request, ctx) => ctx!.executor!(request.url, { signal: ctx?.abortSignal }); + const pending = run(fetcher(async () => new Response("ok")), { adapter: direct }); + const release = setTimeout(() => held.release(), QUEUE_MS); + try { await expectSuccess(pending); } + finally { clearTimeout(release); held.release(); } + expect(providerRequestPacingStatus(NAME, provider)).toMatchObject({ queued: 0, inFlight: 0 }); + }); + + test("adapter-owned retries retain pacing seams on every physical send", async () => { + const configured = { ...provider, requestPacing: { ...provider.requestPacing, minIntervalMs: QUEUE_MS } }; + let sends = 0; + const executor = fetcher(async () => ++sends === 1 + ? new Response("limited", { status: 429 }) : new Response("ok"), configured); + const retrying = adapter(); + retrying.fetchResponse = async (request, ctx) => { + const send = createAdapterPhysicalSend(ctx); + const dispatch = (physical: typeof fetch) => physical(request.url, { signal: ctx?.abortSignal }); + const first = await send({ url: request.url, dispatch }); + expect(first.status).toBe(429); + await first.body?.cancel(); + return send({ url: request.url, recovery: "rate-limit-429", dispatch }); + }; + await expectSuccess(run(executor, { adapter: retrying })); + expect(sends).toBe(2); + expect(providerRequestPacingStatus(NAME, configured)).toMatchObject({ queued: 0, inFlight: 0 }); + }); + + test("final headers keep the pacing lease until the streamed body finishes", async () => { + let bodyController!: ReadableStreamDefaultController; + const upstream = new ReadableStream({ start(controller) { bodyController = controller; } }); + const response = await run(fetcher(async () => new Response(upstream))); + expect(response.status).toBe(200); + expect(providerRequestPacingStatus(NAME, provider).inFlight).toBe(1); + bodyController.enqueue(new TextEncoder().encode("ok")); + bodyController.close(); + expect(await response.text()).toContain("response.completed"); + expect(providerRequestPacingStatus(NAME, provider).inFlight).toBe(0); + }); + + test("rotation retains cumulative network time instead of renewing the header budget", async () => { + const configured = { ...provider, requestPacing: { ...provider.requestPacing, minIntervalMs: QUEUE_MS } }; + let sends = 0; + let started = 0; + const executor = fetcher(async init => { + started++; + await sleepWithAbort(70, init?.signal ?? undefined); + return ++sends === 1 ? new Response("limited", { status: 429 }) : new Response("ok"); + }, configured); + await expectTimeout(run(executor, { connectTimeoutMs: 110, + on429: () => ({ adapter: adapter(), recoveryKind: "key-429" }), + }), 110); + expect(sends).toBe(1); + expect(started).toBe(2); + expect(providerRequestPacingStatus(NAME, configured)).toMatchObject({ queued: 0, inFlight: 0 }); + }); +});