diff --git a/README.md b/README.md index 22d6956c6c..380510496d 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Qwen Cloud, SiliconFlow, and more. Full list: `ocx init` or the ocx init # interactive setup (writes config, wires Codex, offers the shim) ocx start [--port 10100] # start the proxy in the foreground ocx stop # stop + restore native Codex -ocx service [install|start|stop|status|uninstall|remove] # background service +ocx service [install|repair|restart|start|stop|status|uninstall|remove] # background service ocx codex-shim install # start the proxy on demand whenever `codex` launches ocx health [--json] # check immediate proxy liveness ocx ready [--json] [--wait [--timeout ]] # check post-sync readiness diff --git a/devlog/_plan/260818_fastwire_b2_xai/evidence/010_logs_priority_lower_bound.png b/devlog/_plan/260818_fastwire_b2_xai/evidence/010_logs_priority_lower_bound.png new file mode 100644 index 0000000000..e7ac2ea007 Binary files /dev/null and b/devlog/_plan/260818_fastwire_b2_xai/evidence/010_logs_priority_lower_bound.png differ diff --git a/devlog/_plan/260818_fastwire_b2_xai/evidence/README.md b/devlog/_plan/260818_fastwire_b2_xai/evidence/README.md new file mode 100644 index 0000000000..0ae66d31f3 --- /dev/null +++ b/devlog/_plan/260818_fastwire_b2_xai/evidence/README.md @@ -0,0 +1,17 @@ +# FastWire B2 (xAI) — UI evidence + +`010_logs_priority_lower_bound.png` — Logs & Debug, three seeded `xai/grok-4.6` rows +that exercise every branch of the new pricing path: + +| Row | Situation | Cost cell | +| --- | --- | --- | +| `req-standard` | no Fast requested | `~$0.0300` | +| `req-priority` | response-confirmed priority, prompt under the long-context threshold | `~$0.0600` — exactly the documented 2x premium over the row above | +| `req-longctx-priority` | response-confirmed priority, prompt at or above 200k | `≥$0.8760` — the published long-context rate, marked a lower bound because xAI publishes no combined price | + +The `≥` prefix is the visible change: a cost that is a known floor rather than an +estimate now says so instead of rendering as `~$`. The detail drawer explains why +via the `priority_lower_bound` estimate reason. + +Captured against a local proxy with a seeded `usage.jsonl`; no live xAI request was +billed to produce it. diff --git a/devlog/_plan/260820_sidecar_selection_unification/160_vision_external_research.md b/devlog/_plan/260820_sidecar_selection_unification/160_vision_external_research.md new file mode 100644 index 0000000000..34ae854f80 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/160_vision_external_research.md @@ -0,0 +1,110 @@ +# 160 — Vision external-backend research (xai Grok / Antigravity Gemini describers) + +Continuation of #2188. Web-search shipped four external backends (L6-L9, docs +060-090); the vision sidecar still dispatches only openai-forward and +anthropic-OAuth. GUI evidence: the vision dropdown lists only Codex/Claude +rows while the web-search dropdown already lists Grok/Gemini. + +## Current vision dispatch inventory + +- Types: `OcxVisionSidecarConfig.backend?: "openai" | "anthropic"` (src/types.ts). +- Union: `VisionSidecarBackend` (src/vision/eligibility.ts:30) — 2 arms. +- Candidate mapping: `visionBackendForCandidate` (eligibility.ts:150-165) — + native/openai → openai; anthropic only via the resolved OAuth provider name. +- Options: `visionEligibleModelOptions` (eligibility.ts:201+) iterates + `["openai","anthropic"] as const` and injects `BASELINE_VISION_MODELS`. +- Enabled backends: `enabledVisionBackends` + (src/server/management/vision-sidecar-options.ts:31-43); empty-auth fallback + returns both universal sides. +- Write gate: `visionDescriberIsProvablyBlind` (vision-sidecar-options.ts:94+) + probes ONLY the openai/anthropic vendor tables. +- PUT validation: config-routes.ts:594-596 rejects backends outside the two + literals; hint fall-through at :623; claude-code override near :738-740. +- Runtime plan: `planVisionSidecar` (src/vision/index.ts) — anthropic arm and + openai-forward arm only. `resolveVisionBackend`: explicit > anthropic-if-auth + > openai. +- GUI: `SidecarBackend = "openai" | "anthropic"` (gui/src/pages/ + dashboard-shared.ts:62, claude-manual-env.ts:8). NOTE: this type is shared + with WebSearchModelOption and is ALREADY stale — the server emits + xai/gemini/exa web rows today. + +## Wire research (from shipped web-search executors, probe-verified 2026-08-20/21) + +### xai describe wire + +Mirror src/web-search/xai-executor.ts: POST `https://api.x.ai/v1/responses` +(origin pinned; provider baseUrl honored only on same origin), stored OAuth +bearer via `getValidAccessToken`, `redirect: "manual"`. Body for describe: + +```json +{ + "model": "", + "instructions": "", + "input": [{ "role": "user", "content": [ + { "type": "input_text", "text": "" }, + { "type": "input_image", "image_url": "" } + ]}], + "reasoning": { "effort": "" }, + "stream": true +} +``` + +SSE reduction: reuse the `response.output_text.delta` / `.done` handling +shape from parseXaiResponsesSSE, without the citation/source machinery. +Grok Responses accepts `input_image` with data URLs (same shape the OpenAI +forward describer already posts — describe.ts builds input_image parts). + +### Gemini (Antigravity CCA) describe wire + +Mirror src/web-search/gemini-executor.ts: POST +`{registry base}/v1internal:generateContent`, `ANTIGRAVITY_REQUEST_UA`, +token + projectId via `getValidAccessTokenSnapshot`, envelope: + +```json +{ + "model": "", + "userAgent": "antigravity", "requestType": "agent", + "project": "", "requestId": "agent-", + "request": { + "systemInstruction": { "role": "user", "parts": [{ "text": "" }] }, + "contents": [{ "role": "user", "parts": [ + { "text": "" }, + { "inlineData": { "mimeType": "", "data": "" } } + ]}] + } +} +``` + +inlineData shape matches src/adapters/google.ts:972/:1233. Response mapping: +`candidates[0].content.parts[].text` join (mapCcaGroundedResponse shape, +minus grounding). https: image URLs cannot be inlined without proxy-side +fetch — REJECTED for gemini describe (data: URLs only, documented delta, +same stance as anthropic-describe's stricter base64 rule). + +## Metadata facts + +- xai vendor table: bare grok-2/grok-3/grok-4 are `text`-only; grok-4.x + fast/4.3/4.5/4.6 and grok-2-vision are `text,image`. +- No bare model id collides across the four vendor tables (openai 48, + anthropic 26, xai 32, google 43; collision scan 2026-08-21: zero) — the + "vendor tables never disagree" premise of visionDescriberIsProvablyBlind + survives widening to four families. + +## Audit deltas folded into this unit (sol-medium audit, 2026-08-21) + +- **Blocker A**: `BASELINE_VISION_MODELS` is a TOTAL + `Record`; widening the union without a + decision breaks typecheck. Decision → doc 170: baselines become + descriptor-owned (only openai/anthropic carry one). +- **Blocker B**: `visionDescriberIsProvablyBlind` collapses non-anthropic + hints to openai and probes two families; a bare grok id absent from + candidates would slip the gate. Decision → doc 170: probe all four vendor + families. +- Empty-auth fallback stays `["openai","anthropic"]` — never offer + xai/gemini unauthenticated. +- GUI shared `SidecarBackend` must split (web-search has exa; vision does + not). +- New executors: `sidecarEnter("vision")` (NOT "web-search"), + `signalWithTimeout` + `cancelBodyOnAbort`, `redactSecretString` on all + error paths, timeout-bounds.ts as single authority. + diff --git a/devlog/_plan/260820_sidecar_selection_unification/170_vision_backend_union.md b/devlog/_plan/260820_sidecar_selection_unification/170_vision_backend_union.md new file mode 100644 index 0000000000..9094c1fb2a --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/170_vision_backend_union.md @@ -0,0 +1,80 @@ +# 170 — Backend union: "routed" describer (wp2, REVISED) + +Depends on: 160. REVISION 2026-08-22: user directive — vision does not need +per-backend executors. Any picker-visible model with image input can describe; +the proxy's own router already speaks every provider wire. The earlier +xai/gemini backend literals were implemented but never released; this revision +replaces them before any push. + +## Design + +- `VisionSidecarBackend = "openai" | "anthropic" | "routed"`. +- "openai"/"anthropic" arms unchanged (forward Responses / OAuth Messages) — + they carry auth semantics loopback routing cannot replicate (forwarded + headers, OAuth beta fences), and their defaults must not drift. +- "routed": the describer is ANY routed model, dispatched through the proxy's + own /v1/chat/completions on loopback (pattern: src/claude/gateway-cache.ts + self-fetch). One executor, every provider. + +## Filter (#2188 rules, unchanged shape) + +1. Picker-visible ∪ auth slots (pickerVisibleSidecarCandidates). +2. − provably text-only (modelAcceptsImageInput === false drops the row). + +visionBackendForCandidate: native/openai → openai; resolved-OAuth anthropic +row → anthropic; ANY OTHER provider row → "routed". Routed option values are +NAMESPACED ("provider/model") so routeModel is unambiguous; legacy sides keep +bare ids (GUI/current-value compatibility). + +## Gate + +visionDescriberIsProvablyBlind keeps the four-family probe widening AND +learns namespaced ids: split on first "/", probe that provider's config row + +metadata family. Bare ids keep the existing all-family probe. + +## Runtime + +- planVisionSidecar routed arm requires: cfg.backend === "routed", explicit + cfg.model, and plan-time modelAcceptsImageInput !== false for the target. +- Recursion safety: the loopback request re-enters the vision planner only if + the routed model is provably text-only; the plan-time check excludes exactly + that set, so describe recursion is structurally impossible. +- resolveVisionBackend: explicit honored; unset default order UNCHANGED. + +## Files (wp2 scope, revised) + +- src/vision/eligibility.ts: union, visionBackendForCandidate routed arm, + namespaced option values, BASELINE narrow-key record (kept from r1). +- src/vision/backends.ts (r1 descriptor table): SIMPLIFIED — descriptors for + openai/anthropic/routed; xai/gemini entries dropped. +- vision-sidecar-options.ts: enabledVisionBackends offers "routed" whenever + any routed row exists; gate learns namespaced ids. +- config-routes.ts + agent-settings-routes.ts: literal sets accept "routed" + (xai/gemini literals removed). +- types: backend unions. +- tests: vision-backend-union.test.ts rewritten for routed. + + +## Audit round 2 amendments (2026-08-22, sol-medium) + +- **Recursion fence is a MECHANISM, not a predicate claim.** The loopback + describe request carries a terminal marker header + `x-opencodex-vision-describe: 1`. The Responses plan site treats a marked + request as terminal: images are STRIPPED, never described (depth cap 1). + This holds under predicate drift (modelInputModalities is invisible to a + row-less plan-time target) and combo re-resolution (router.ts:625-631 can + land a different sibling). Belt-and-braces: the routed arm also requires + `!isModelTextOnly(resolvedRoute.provider, resolvedRoute.modelId)` at plan + time — the exact re-entry predicate on the resolved route. +- **PUT-gate coherence:** a namespaced model with backend openai/anthropic is + REJECTED (forward executor POSTs the string verbatim — web-search F1 + selector/slug failure); backend "routed" REQUIRES a namespaced id. +- **GUI inference:** `value.includes("/") → "routed"` in + visionSidecarBackendForModel's fallback; persisted backend keeps traveling + as currentBackend. +- Known limitation (recorded, not fixed here): a non-loopback-only bindHost + where 127.0.0.1 does not answer — same latent limitation gateway-cache has. +- handleNativeChatCompletions fast path has no vision handling; the marked + describe request must not regress it (marker check lives at the Responses + plan site the bridge replays into). + diff --git a/devlog/_plan/260820_sidecar_selection_unification/180_vision_describe_executors.md b/devlog/_plan/260820_sidecar_selection_unification/180_vision_describe_executors.md new file mode 100644 index 0000000000..859363bd55 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/180_vision_describe_executors.md @@ -0,0 +1,73 @@ +# 180 — Routed describe executor + dispatch (wp3, REVISED) + +Depends on: 170 (revised). + +## src/vision/routed-describe.ts (new) + +Loopback POST http://127.0.0.1:{config.port}/v1/chat/completions: + +```json +{ "model": "", "stream": false, + "messages": [ + { "role": "system", "content": "" }, + { "role": "user", "content": [ + { "type": "text", "text": "" }, + { "type": "image_url", "image_url": { "url": "" } } + ]}]} +``` + +- Auth: none on loopback binds (resolveApiAuth admits loopback without a + token); when OPENCODEX_API_AUTH_TOKEN is set, send it as Authorization + bearer (auth-cors.ts:399-400 accepts bearer on /v1/chat/completions). +- signalWithTimeout(settings.timeoutMs) + cancelBodyOnAbort; + sidecarEnter("vision"); redactSecretString on error paths; response text + from choices[0].message.content; DESC clamp caller-side (existing). +- validateImageUrl reused (data: mime allowlist + 20MB, https passthrough). +- The chat inbound translates image_url → input_image and every adapter + compiles its own wire (anthropic blocks, CCA inlineData, xai Responses), + so provider coverage is the router's, not this file's. + +## planVisionSidecar routed arm + +VisionPlan gains { backend: "routed", routedModel: string }. Arm requires +explicit model + plan-time modelAcceptsImageInput !== false (recursion +fence). executeDescription routed arm calls describeImageRouted. + +## Tests + +vision-routed.test.ts: wire shape against a mock loopback server; recursion +fence (text-only target never plans routed); timeout/error taxonomy; +redaction. E2E: routed describer via a second mock provider. + + +## Audit round 2 amendments (2026-08-22) + +- **Admission ladder (blocker 2):** token = + configuredApiAuthToken() || loadServiceTokenFromFile(env) || first + config.apiKeys entry; sent as `x-opencodex-api-key` (never Authorization — + gateway-cache.ts:77-86 rule); omitted entirely on loopback binds where + isApiAuthRequired is false. +- **Terminal marker:** executor sets `x-opencodex-vision-describe: 1`; the + core.ts plan site checks it and strips images instead of planning vision. +- Executor also passes stream:false and reads choices[0].message.content; + non-2xx → {error} with redacted body slice. + + +## Audit round 3 amendment (2026-08-22) — marker propagation + +The chat→responses bridge rebuilds headers from the FORWARD_HEADERS allowlist +(chat-completions.ts:198-203, openai-responses.ts:28-36), which would DROP +`x-opencodex-vision-describe` before the plan site — on exactly the one path +recursion lives. Therefore: + +- The marker is detected AT THE CHAT SURFACE (raw req.headers before the + bridge) and carried as an explicit option/flag into handleResponses + (`visionDescribeTerminal: true`), not as a header the bridge must + preserve. The Responses surface ALSO honors the raw header directly for + native /v1/responses callers. +- Regression test drives the FULL chat-surface path: marked POST to + /v1/chat/completions with an image + text-only routed model → assert the + plan site STRIPS (no describe dispatch, no recursion), while the same + unmarked POST plans normally. A predicate-only test is insufficient and + would stay green with the marker broken. + diff --git a/devlog/_plan/260820_sidecar_selection_unification/190_vision_surfaces_and_delivery.md b/devlog/_plan/260820_sidecar_selection_unification/190_vision_surfaces_and_delivery.md new file mode 100644 index 0000000000..4b5e70032b --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/190_vision_surfaces_and_delivery.md @@ -0,0 +1,71 @@ +# 190 — Surfaces, live proof, delivery (wp4 cycle) + +Depends on: 180. + +## GUI + +- Split the shared SidecarBackend (dashboard-shared.ts:62): web-search side + keeps its server-provided backend strings (already emits xai/gemini/exa — + stale type fixed by the split); vision side gets + VisionBackend = "openai" | "anthropic" | "xai" | "gemini". +- visionSidecarBackendForModel fallback stays server-provenance-first; + catalog inference (anthropic-vs-openai guess) only for legacy rows. +- claude-manual-env.ts SidecarOverride backend union widens for vision. +- No new dropdown UI: options arrive from visionModels server list already. + +## CLI + +- src/cli/agent.ts: usage already names xai|gemini; verify backend values + pass through PUT unvalidated client-side (server gate authoritative); + vision --list renders new backends' rows. + +## Live proof (acceptance 3-5) + +- GET /api/sidecar-settings on live :10100 shows visionModels containing + xai/gemini rows (auth present on this machine for both — web-search rows + prove it). +- PUT vision {backend:"xai", model:"grok-4.3"} → 200; PUT model grok-4 + (bare) → 400 provably-blind; restore original settings after proof. +- GUI screenshot of the vision dropdown listing Grok/Gemini rows. + +## Delivery + +- Small commits per layer (backends table / eligibility+gate / executors / + GUI+CLI / tests+devlog), full bun run typecheck + bun run test green at + final head, push directly to dev (user-authorized, no PR). +- devlog docs 160-190 land with the same push train; unit stays in _plan + until the release train closes it. + + +## Delivery evidence (2026-08-22, wp4) + +- Live dev server (commit 3ff19c33e, port 11100, copied auth home): + - GET /api/sidecar-settings visionModels: 25 rows — legacy openai/anthropic + sides + 17 namespaced [routed] rows (xai/grok-4.6, + google-antigravity/gemini-3.7-flash, cursor/kimi-k3, zenmux/…, + alibaba…/qwen3.8-max, …). Rule 2 confirmed live: no text-only rows. + - PUT gates live: routed+xai/grok-4.6 → 200; routed+xai/grok-3 → + 400 provably-blind; openai+namespaced → 400 coherence. + - GET after PUT reports the routed model verbatim + ({"model":"xai/grok-4.6","backend":"routed"}) — fixed the legacy-collapse + display bug found during this verification. + - GUI screenshot: vision dropdown lists namespaced routed rows; current + selection renders as xai/grok-4.6. + - CLI: `ocx agent sidecar vision --list` prints the same 25 rows with + [routed] backend tags (server-computed list, no drift). + - LIVE describe e2e: POST /v1/chat/completions with a 64x64 red PNG to + xai/grok-composer-2.5-fast (noVisionModels) with routed describer + xai/grok-4.6 → main answer "red"; request history shows the inner + grok-4.6 describe call followed by the outer composer call. (A 1x1 probe + earlier failed with xai invalid_image min-8px — upstream constraint, not + a pipeline defect; the graceful degradation path handled it and the main + call still succeeded.) +- Verification-side effect handled: the 11100 dev server rewrote + ~/.grok/config.toml to port 11100 during startup sync; restored to 10100 + via production `ocx ensure` and confirmed (27x base_url 10100, zero + 11100). Temp verify home moved aside (/tmp/trash-ocx-vision-verify-*). +- privacy:scan green; root+gui tsc clean; focused suites green (185 pass). +- Full-suite run at final head queued behind another worktree's runner + (scripts/test.ts exclusive-run queue); recorded separately below when it + lands. + diff --git a/devlog/_plan/260820_sidecar_selection_unification/assets/vision_routed_dropdown.png b/devlog/_plan/260820_sidecar_selection_unification/assets/vision_routed_dropdown.png new file mode 100644 index 0000000000..645759e52d Binary files /dev/null and b/devlog/_plan/260820_sidecar_selection_unification/assets/vision_routed_dropdown.png differ diff --git a/devlog/_plan/260821_260821-windows-picker-full-restart/000_plan.md b/devlog/_plan/260821_260821-windows-picker-full-restart/000_plan.md new file mode 100644 index 0000000000..b8b0900c67 --- /dev/null +++ b/devlog/_plan/260821_260821-windows-picker-full-restart/000_plan.md @@ -0,0 +1,91 @@ +# 000 Plan: Windows model-picker full-restart path + +## Problem + +ocx sync --restart-codex rewrites the Codex catalog JSON and restarts the Codex +app-server (codex.exe app-server). Observed behavior: + +- macOS: the desktop app model picker reflects the new catalog right away. +- Windows (stable/beta, MSIX package OpenAI.Codex_26.818.3698.0): the picker + keeps the stale list until the whole desktop app is quit and relaunched. + +Local evidence (2026-08-21): + +- Desktop UI processes are ChatGPT.exe (Electron shell), installed as MSIX + package family OpenAI.Codex_2p2nqsd0c76g0, start app id (AUMID) + OpenAI.Codex_2p2nqsd0c76g0!App. +- ocx sync --restart-codex matches only codex.exe app-server and + codex-code-mode-host.exe command lines + (src/codex/app-server-processes.ts, isCodexAppServerCommandLine). The + Electron UI is never signalled, so its cached picker survives. +- After the 20:57 sync + restart, codex.exe (PID 8592) started fresh at 20:59 + while all ChatGPT.exe UI processes kept their earlier start time, and the + picker still showed only OpenAI models. + +Research findings (subagent, bundle inspection of app.asar): + +- The renderer fetches model/list and config/read over stdio JSON-RPC into a + TanStack Query cache; there is no filesystem watcher on the catalog file. +- The UI invalidates those queries only on a codex-app-server-initialized + event. On Windows, externally killing the codex.exe child may not produce + that event reliably (hypothesis, untested from inside this session), which + would explain why ocx restart alone does not refresh the picker here while + macOS recovers. +- Official docs say to restart the desktop app after changing model_catalog_json; + no supported refresh hook exists. Known upstream cluster: openai/codex + issues 19694, 26308, 32349, 34487 (desktop picker vs CLI catalog divergence). +- Relaunch must go through MSIX activation (shell:AppsFolder AUMID), not the + exe path under WindowsApps (ACL-restricted, no package identity). + +## Scope + +IN (audit amendments folded in): + +- A supported, documented way to fully restart the Windows Codex desktop app + after a catalog sync: graceful WM_CLOSE first, bounded taskkill /T /F + fallback, relaunch via AUMID. Targets resolve InstallLocation at runtime + via Get-AppxPackage -PackageFamilyName (the family string is NOT a + substring of the install path); only the root ChatGPT.exe whose parent lies + outside the package is selected so taskkill /T cascades to codex.exe and + codex-code-mode-host.exe; the script refuses to kill its own ancestry. +- A GitHub issue on lidge-jun/opencodex recording the platform gap, the beta + caveat, upstream issue links, and the requested UX (sync should offer a full + app restart on Windows). The issue MUST include Version (installed + @bitkyc08/opencodex version) and Operating system fields, which + enforce-issue-quality hard-requires once Client or integration is present; + Reproduction carries the PID/start-time evidence; upstream issues are cited + as related-but-unverified. + +OUT: + +- Changing ocx sync runtime behavior in this unit (the issue proposes it; + implementation is a later unit). +- Killing processes outside the OpenAI.Codex_2p2nqsd0c76g0 package family. +- Testing the unverified stdio-respawn hypothesis by killing codex.exe from + inside this session (would kill our own host); recorded as an open question + for an external terminal test. + +## Work phases + +- wp1 (010): add scripts/restart-codex-desktop-app.ps1 with -DryRun/-Force, + graceful-close then bounded forced fallback, relaunch via AUMID; file the + templated GitHub issue; record evidence. + +## Accept criteria + +- Script -DryRun exits 0 AND lists the specific live root PID(s) it would + stop and the relaunch command, without stopping anything (an exit-0 no-op + does not pass). Focused probe evidence per scripts/AGENTS.md is the real + gate (tsconfig includes only src/); bun x tsc --noEmit still runs as a + no-regression check. +- Issue exists on origin with bug_report template headings. + +## Safety notes + +- Running the restart from inside a Codex conversation kills that conversation + host app; the script warns and docs say to run it from an external terminal. +- Forced kill is limited to processes whose Path is under the runtime-resolved + InstallLocation. Close-to-tray behavior is explicitly checked: if + CloseMainWindow() only hides the window, the wait expires and the forced + path runs; record observed behavior. Record the PowerShell edition the + probe ran under (Get-AppxPackage differs between 5.1 and 7). diff --git a/devlog/_plan/260821_260821-windows-picker-full-restart/010_phase1.md b/devlog/_plan/260821_260821-windows-picker-full-restart/010_phase1.md new file mode 100644 index 0000000000..1c243fd97b --- /dev/null +++ b/devlog/_plan/260821_260821-windows-picker-full-restart/010_phase1.md @@ -0,0 +1,63 @@ +# 010 wp1: Restart script + issue (diff level) + +## NEW: scripts/restart-codex-desktop-app.ps1 (amended per audit) + +PowerShell 5.1-compatible script: + +- param([switch]$DryRun, [switch]$Force). +- Constants: package family OpenAI.Codex_2p2nqsd0c76g0, AUMID + OpenAI.Codex_2p2nqsd0c76g0!App, process names ChatGPT, codex, + codex-code-mode-host. +- Resolve $installLoc = (Get-AppxPackage -PackageFamilyName + OpenAI.Codex_2p2nqsd0c76g0).InstallLocation at runtime; fail with an + actionable message when empty. Wrap process Path access in try/catch + (Access denied for other users processes). +- Select ONLY the root ChatGPT.exe whose ParentProcessId lies outside + $installLoc (Win32_Process via Get-CimInstance). taskkill /PID /T /F + cascades to codex.exe and its codex-code-mode-host.exe child. Never list + code-mode-host as an independent target. +- Self-kill guard: walk $PID ancestry; abort with a clear message when any + selected target is in it. +- Warn: active Codex turns are interrupted; run from an external terminal. +- Graceful pass: CloseMainWindow() on the process with a MainWindowHandle, + wait up to 15 s in 1 s polls for all targets to exit. If the process + survives past the timeout, print that close-to-tray behavior is suspected + before escalating. +- Forced pass (remaining targets, or immediately with -Force): + taskkill /PID /T /F per remaining PID (/T covers child tree so + codex.exe is not orphaned). +- Relaunch: Start-Process "shell:AppsFolder\" unless -DryRun. +- -DryRun: print planned actions (targets, method, relaunch command), touch + nothing, exit 0. + +## MODIFY: none (runtime untouched in this unit) + +## Verification + +## Cycle 2 addendum (2026-08-21, provider verification + push) + +- command-code stealth/ox-alpha re-probed after credit purchase: /v1/chat/completions + and /v1/responses both return 200 with valid completions. No code change needed. +- opencode-go upstream (https://opencode.ai/zen/go/v1) returns 500 Internal server + error for every model probed directly (kimi-k2.7-code, ox-alpha-free); the proxy + 502 "upstream stream ended" is an upstream outage, not an adapter defect. + ox-alpha-free is also absent from models.dev opencode-go roster and from + scripts/model-metadata.source.json, so the opencode-go/ox-alpha-free slug was + never a registered catalog model; opencode-free/x-preview-f-free is the working + free-tier route (verified 200 on both endpoints). +- Direct push to origin/dev rejected by ruleset 20763889 (pull_request rule, admin + bypass = pull_requests_only). Fallback per user intent: branch + codex/windows-restart-helper pushed, PR #2293 opened targeting dev (MERGEABLE). + +- powershell -File scripts/restart-codex-desktop-app.ps1 -DryRun -> exit 0 + AND output names the live root PID (e.g. 9928) and its child codex.exe; + nothing stopped. Record $PSVersionTable.PSVersion. +- bun x tsc --noEmit -> exit 0. +- gh issue create with bug_report.yml headings: Client or integration = Codex + App; Area = Platform (Windows / macOS / Linux); Version = installed + @bitkyc08/opencodex version (package.json); Operating system = Windows 11 + (build from systeminfo); Reproduction includes the 20:57 sync / 20:59 fresh + codex.exe vs stale UI start-time evidence; upstream issues + 19694/26308/32349/34487 cited as related-unverified; beta-channel caveat + stated. After creation, re-read state with gh issue view until the + enforce-issue-quality workflow settles (creation alone can auto-close). diff --git a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md new file mode 100644 index 0000000000..343090b978 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md @@ -0,0 +1,27 @@ +# 000 — Bug merge-train triage matrix (2026-08-21) + +Session: 01a024bb-1acb-7633-908b-29e4fe4d96c5 (worktree a6a7, detached at c0cbe494e). +Objective: drive the six open bug-labeled PRs to merged on `dev` with strict review, +adversarial xai/grok-4.6 subagent verdicts, and a final green dev CI gate. + +## In-scope PRs (state as of 2026-08-21T14:30Z) + +| PR | Title | Head | Behind dev | Draft | CI on head | Existing review state | +|----|-------|------|-----------:|-------|------------|----------------------| +| #2294 | fix(release): reject credential-bearing SSH remotes | 71598fa45 + hardening 2cdfba24d (train-stacked) | 3 | yes | green | MERGED to train; grok blocker fixed; re-verdict PASS; landing on dev | +| #2289 | fix(service): restart existing installs w/o re-register | 2df92a270 + locale sync 174f03b60 (train-stacked) | 2 | yes | green incl. Service lifecycle | MERGED to train; grok P2 fixed; re-verdict PASS; Closes #2287 | +| #2295 | fix(codex): recover zero-byte coordinator remnants | 6d5f0cf2c (ingw/fix-zero-byte-coordinator-2291) | 0 | yes | green | MERGED to train 728ca1e8b; suite green; landing on dev | +| #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 + pin ec32a8d52 (train-stacked) | merged into train | yes | MERGED to train; grok P2 fixed; re-verdict PASS | Linux shards green; lidge full suite green | +| #2281 | fix: call_id thought-signature replay for Claude Code | b31f3dbed + normalization bc6d6b516 (train-stacked) | merged into train | yes | MERGED to train; two reviewers PASS; CodeRabbit normalization done | hygiene resolved by shipped regression rows | +| #2296 | fix(codex): bind Desktop reconnects to one pool account | e672b0fd0 + scope fix 698228e40 (train-stacked) | 2 | yes | green | MERGED to train; grok major fixed; re-verdict PASS; landing on dev | + +## Baseline dev CI status (pre-train blocker) + +Run 32486877508 on dev head c0cbe494e: attempt 1 **failed** on +`(fail) multiAgentGuidanceText > the v2 default catalog path uses the request collector, not the synchronous one (#1852)` (macos job). Rerun of failed jobs (attempt 2) is **green** (conclusion: success), and the test passes locally at c0cbe494e (52/52). Cycle 1 exits as recorded flake per 010; no direct dev push needed. Watch for recurrence during the train. + +## Hygiene notes + +- #2281 carries `intake: hygiene-blocked` (missing_regression_test) despite having test files — the label state needs re-check after any new commit. +- #2281 is a first-time contributor PR; gate binds completion to exact head. New commits reset the checklist; since we (maintainer) will merge manually, that is acceptable. +- User authorized: stash/merge/cherry-pick/close/extra commits, push with --no-verify, suite on ssh lidge if needed, final CI green on dev is the exit gate. diff --git a/devlog/_plan/260821_bug_merge_train/001_dependency_analysis.md b/devlog/_plan/260821_bug_merge_train/001_dependency_analysis.md new file mode 100644 index 0000000000..1ba285b840 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/001_dependency_analysis.md @@ -0,0 +1,40 @@ +# 001 — Dependency and conflict analysis (r2, post-audit) + +Audit r1 (grok-4.6 "Avicenna") failed the initial order; accepted findings are folded in below. +Rejected findings and why: none rejected outright; the "2270 has no file overlap" observation was +accepted and 2270 moved before 2281 (it still sits after 2289 because its 48-behind rebase wants a +stable dev, and nothing else touches its files so waiting costs only one rebase, which it owes anyway). + +## File overlap between PR heads + +- **src/server/responses/core.ts**: #2281 (+12) and #2296 (+6/-9). Semantic neighborhood: _reasoningReplayScope creation (2281) vs pool-affinity key derivation (2296) both hang off handleResponsesInner request-context setup. +- **src/cli/registry.ts** + **docs .../reference/cli/lifecycle.md**: #2289 and #2295. Disjoint commands (service vs doctor); textual conflict likely trivial. +- **Runtime semantic risk without file overlap**: #2270's routed custom-tool lowering executes in the same request path as 2281's replay scope and 2296's affinity key. Post-merge full-suite runs after each of these three is the guard, plus a targeted cross-check at 2281/2296 time that replay-scope and lowering still compose (tests in tests/responses-custom-tool-repair.test.ts + tests/claude-code-thought-signature-scope.test.ts both green on the merged tree). +- All other files disjoint. + +## Disposition order (r2 — least-rebase, lock-current-first) + +1. **CI fix**: restore dev green (multiAgentGuidanceText #1852 macos failure; rerun already green — confirm and root-cause flakiness). +2. **#2295** (0 behind, green head CI, no rebase owed; lands registry.ts/lifecycle.md first so #2289 absorbs the conflict in the rebase it already owes). +3. **#2294** (3 behind, tiny, no overlap; NAMED SECURITY REVIEW GATE — see below). +4. **#2296** (0 behind; lock core.ts while its base is current; C4 auth — NAMED SECURITY REVIEW GATE; cancelled enforce-target check must be re-run green on the pre-merge head). +5. **#2289** (9 behind; rebase absorbs 2295's registry/lifecycle hunks; Service lifecycle CI green required). +6. **#2270** (48 behind; no file overlap with anything above; single rebase onto stable dev; full suite on the rebased head BEFORE merge). +7. **#2281** (50 behind; takes the core.ts conflict on rebase as the last mover; pre-merge blockers below). + +## Named gates (merge-blocking, not notes) + +- **Security review gate (#2294, #2296)**: per MAINTAINERS.md/AGENTS.md these surfaces (release automation; auth/account binding) require explicit security review. The maintainer (this session, acting for the owner account) performs and RECORDS a written security review in the cycle doc: threat cases checked, rejection matrix, log-boundary check (no token/secret in output), before merge. The grok-4.6 adversarial verdict is additive, not the security review itself. +- **Pre-merge CI-on-head gate (all)**: merge only from a head whose CI (or local full suite for shared-surface PRs: #2270, #2281, #2296) is green ON THE REBASED HEAD, not a stale ancestor. Cancelled/skipped required checks are re-run, not ignored. +- **#2281 pre-merge blockers**: (a) stacked commit normalizing promptCacheKey via anthropicSessionKeyFromParts (CodeRabbit finding) + test rows; (b) hygiene label missing_regression_test resolved — the PR does carry tests, so re-trigger the deterministic check after the stacked commit and confirm the label drops, or record the maintainer override rationale; (c) rebase onto final-form dev; (d) full suite green on that head. +- **Post-merge dev CI check after EVERY merge** before starting the next cycle (train stops on red). + +## Merge mechanics per PR + +fetch pr/N -> read full diff (AGENTS.md review rules) -> rebase onto current dev if behind -> focused tests + typecheck -> FULL SUITE (bun run test) pre-merge for every non-trivial PR (AGENTS.md bar; ssh lidge if local env-limited) -> grok-4.6 adversarial verdict -> security review doc where gated -> stack fix commits if needed. Head remotes: #2294/#2295/#2296/#2289 are in-repo branches (push origin); #2270 head is olddonkey/opencodex, #2281 head is Hsia97/opencodex, both maintainerCanModify=true -> push https://github.com//opencodex.git HEAD: (--no-verify is a local-hook flag). Then merge to dev (merge commit convention) -> push --no-verify -> dev CI green -> next. #2270 extra: dismiss/refresh the stale CHANGES_REQUESTED review so reviewDecision matches the converged head. + +## Issue closure map + +- #2287 -> close after #2289 lands (manual, base is dev). +- #2291 -> close after #2295 lands. +- #2046 -> #2296 fixes reconnect-rotation only; comment with landing commit; keep open unless the remaining Desktop-UI half is split into its own issue at wp6 D. diff --git a/devlog/_plan/260821_bug_merge_train/002_audit_synthesis.md b/devlog/_plan/260821_bug_merge_train/002_audit_synthesis.md new file mode 100644 index 0000000000..ca457a0a11 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/002_audit_synthesis.md @@ -0,0 +1,20 @@ +# 002 — Audit synthesis (round 1 -> round 2) + +Reviewers: Avicenna (grok-4.6, plan-shape audit) FAIL; Hegel (grok-4.6, deep repo audit) FAIL. + +## Accepted (folded into r3 docs) +1. Order rework (Avicenna): CI -> 2295 -> 2294 -> 2296 -> 2289 -> 2270 -> 2281. Adopted in 001 r2 and decade docs 020-065. +2. Fork mechanics (Hegel): #2270 head lives on olddonkey/opencodex, #2281 on Hsia97/opencodex, both maintainerCanModify=true — verified via gh. Stacked commits to those heads push to the FORK remote (https://github.com//opencodex.git :), enabled by maintainerCanModify; --no-verify applies locally. 001 mechanics corrected. +3. Full-suite bar (both): bun run typecheck + bun run test required before approving ANY non-trivial PR (AGENTS.md:178 area); full suite explicitly pre-merge for #2281/#2289/#2295 too, not only 2270/2296. Decade docs updated. +4. #2294 gates (Hegel): add bun run prepush (scripts/AGENTS.md), and record the non-author maintainer review — author is Ingwannu; the merging maintainer account (lidge-jun) supplies the non-author security APPROVE, satisfying MAINTAINERS.md no-self-approval. +5. #2270 stale CHANGES_REQUESTED (Hegel): reviewDecision still CHANGES_REQUESTED although the same reviewer's later comment on exact head 398b7ade4 says no remaining technical blocker. Pre-merge step: dismiss the stale review with rationale (or fresh APPROVE) so the recorded decision matches the converged state. +6. #2294 head drift (Hegel): head moved 86ed0a46a -> 71598fa45; re-fetch and re-review at the new head. 000 corrected. +7. CI cycle-1 (Hegel): rerun attempt 2 green + local 52/52 pass -> exit as flake (010 rewritten); no direct dev push. +8. Docs-sync (Hegel): after both 2295 (en-only doctor docs) and 2289 (8-locale lifecycle) land, verify locales do not contradict the English lifecycle page; added to 070. +9. CODEOWNERS/owner review for core.ts PRs (Hegel): lidge-jun review recorded at 040/065 merge time. + +## Rejected (with evidence) +1. "#2270 already collides with intervening dev on src/providers/registry.ts" (Hegel): git merge-tree merge-base(origin/dev, pr/2270) shows 0 conflict markers; same for pr/2281. Rebase risk is semantic, not textual; covered by full suite on rebased head. +2. "#2281 hygiene failure is unsponsored_surface" (Hegel): latest pr-hygiene comment on #2281 says missing_regression_test (fetched via gh api). Treated per 065: re-trigger after stacked commit; drop or record maintainer override. +3. "#2296 cancelled enforce-target ignored" (Avicenna): not ignored — 040 requires it re-run green pre-merge. Kept. + diff --git a/devlog/_plan/260821_bug_merge_train/010_fix_dev_macos_ci.md b/devlog/_plan/260821_bug_merge_train/010_fix_dev_macos_ci.md new file mode 100644 index 0000000000..3ac7d089f7 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/010_fix_dev_macos_ci.md @@ -0,0 +1,7 @@ +# 010 — Cycle 1: dev CI status (resolved as flake) + +Evidence: +- Run 32486877508 (dev c0cbe494e) attempt 1: platform-macos failed on multiAgentGuidanceText #1852 test; attempt 2 (rerun --failed): conclusion success. +- Local repro at exact c0cbe494e: bun test tests/multi-agent-compat.test.ts -> 52 pass / 0 fail; paired with server-combo-failover-e2e -> 120 pass. +Exit: flake recorded; dev is green at c0cbe494e. No dev push. If the same test fails again during the train, escalate to root-cause mode (test reads catalog collector timing — suspect CI-runner timing sensitivity). + diff --git a/devlog/_plan/260821_bug_merge_train/020_merge_2295.md b/devlog/_plan/260821_bug_merge_train/020_merge_2295.md new file mode 100644 index 0000000000..68e5f87cb5 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/020_merge_2295.md @@ -0,0 +1,46 @@ +# 020 — Cycle 2: PR #2295 (zero-byte coordinator, #2291) + +0 behind dev; lands first among PRs. Review: coordinator-doctor state machine (8 classifications), fail-closed defaults, doctor --recover-zero-byte-coordinator gating (proxy stopped + BEGIN IMMEDIATE + identity revalidation + backup-not-delete), no SQLite sidecar creation on diagnosis path, age-gate race reasoning. +Verify: bun test tests/codex-coordinator-doctor.test.ts tests/codex-inject-write-lock.test.ts tests/codex-transition-state*.test.ts tests/cli-doctor.test.ts tests/cli-dispatch.test.ts, bun run typecheck, bun run privacy:scan, FULL SUITE (bun run test) pre-merge. grok verdict. Merge, push --no-verify, dev CI green. Close #2291 with landing commit. + +## Review round 1 (Volta, grok-4.6) — FAIL — synthesis + +Finding 1 (age gate bypasses lock): ACCEPTED AS RESIDUAL RISK, REBUTTED AS BLOCKER. +RCA: a creator stalled >1s between file creation and BEGIN IMMEDIATE is classified stable-zero-byte. +But the consequence is exactly the ENOENT behavior: clean homes still enter the coordinated path +(inject-coordination.ts:96-99 comment + code — the SQLite transaction safely initializes the same file, +still serialized by the lock); ONLY residue/indeterminate legacy homes take legacy-uncoordinated, which +is the identical compatibility boundary those homes used for years pre-coordination and would use today +if the remnant pathname were absent. The trade fixes #2291 (zero-byte blocks sync forever, fail-closed +with no operator exit). Residual: legacy-residue home + creator stalled >1s + concurrent write — +accepted; the alternative is the unfixable wedge this PR exists to remove. + +Finding 2 (recovery rename TOCTOU): REBUTTED AS BLOCKER. +RCA: window between final sameIdentity check (coordinator-doctor.ts:306) and renameSync (:312) allows a +same-uid attacker to swap a file that then gets MOVED (not deleted) to a same-directory backup. +The namespace is 0o700/owner-checked and the file 0o600/owner-checked (inspectTarget); only the same +user can race it. Per AGENTS.md's own boundary statement, a same-user local process is outside the +enforceable threat model (it can already rename these files itself). Recovery is opt-in (--yes), +proxy-stopped, and evidence-preserving. Non-blocking. + +Finding 3 (fail-open vs dev): REBUTTED. +RCA: on dev, an existing zero-byte coordinator stayed "coordinated" and then wedged sync (issue #2291's +literal symptom). The PR routes only proven (zero bytes + user_version 0 + no tables via immutable read ++ 1s settled identity) remnants to the absent-file boundary. unversioned-nonempty / rowless / +unsupported / changed / unsafe all remain fail-closed. This is the intended fix, not an accident. + +Disposition: proceed to merge; findings 1-2 recorded as accepted residual risks in this doc. +Focused tests 55/55, typecheck pass, privacy:scan pass, full suite pending (bg session). + +## Verification close-out (train head 728ca1e8b) + +Full suite re-run on lidge after completing the temporary worktree's gui +dependency install: the first run's 7 failures were all "Unhandled error +between tests: Cannot find package 'react'" (gui/src/i18n/shared.ts and +friends) — an incomplete `gui/node_modules` environment artifact, not test +logic. After `bun install --cwd gui` on the same commit: **14175 pass / +16 skip / 0 fail across 890 files (464.61s), exit 0** +(/tmp/ocx-train-suite-r2.log on lidge). Locally, the same four representative +files that hit the missing-package path pass 55/55 after the identical fix. +Merge-blocker verdict stands; accepted residuals unchanged. Train branch is +ready to land on dev. diff --git a/devlog/_plan/260821_bug_merge_train/030_merge_2294.md b/devlog/_plan/260821_bug_merge_train/030_merge_2294.md new file mode 100644 index 0000000000..359cb6d045 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/030_merge_2294.md @@ -0,0 +1,43 @@ +# 030 — Cycle 3: PR #2294 (release SSH credential boundary) + +NAMED SECURITY REVIEW GATE (scripts/release.ts). Written review in this doc before merge: userinfo rejection matrix (ssh:// password, encoded ':', scp-like user:pass@), control-char/query/fragment rejection, GIT_SSH_COMMAND single-literal '-i' proof, log-boundary check (accepted value printed pre-push — verify nothing secret-bearing can pass validation). +Head moved to 71598fa45 — re-fetch and review the live head. Verify: bun test tests/release-helper.test.ts, bun run typecheck, bun run privacy:scan, bun run prepush (scripts/AGENTS.md bar for release tooling). Non-author security review: author is Ingwannu; merging maintainer (lidge-jun) records the security APPROVE (no self-approval). grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live head 71598fa45, confirmed via branch fetch) + +Scope under review: c0cbe494e..71598fa45 — two commits touching only +scripts/release.ts (+36/-2) and tests/release-helper.test.ts (+85). Base has +drifted far behind the train; merge onto the train head and re-run checks +there. Steps: + +1. Adversarial security review (grok-4.6 subagent): userinfo rejection matrix, + encoded-char and control-character handling, scp-like remotes, + GIT_SSH_COMMAND single-literal '-i' proof, log-boundary bypasses + (secret-bearing values reaching printed output), missing test coverage. +2. Local gates at train-merged head: bun test tests/release-helper.test.ts, + bun run typecheck (shared runtime touched? release script only — focused + bar), bun run privacy:scan, bun run prepush per scripts/AGENTS.md. +3. Merge into train, full suite on lidge at merged head, land via train PR + to dev (rules require PR path), close #2294, record non-author security + approval evidence. + +## Security review (Euler, grok-4.6) — GO-WITH-FIXES (blockers=1) → fix → re-verdict PASS + +Blocker: scp-like host class allowed a second '@' +(`git@SECRET@host:path` accepted and printed to both log sinks — the push +target line and the failure command echo). Fix: host class excludes '@' +(`^git@[^:@\s/?#]+:[^?#]+$`, scripts/release.ts:215) plus raw-userinfo ':' +rejection before URL parse (WHATWG collapses empty password, so +`ssh://git:@host` was indistinguishable from a bare principal). Regression +rows added for both shapes. Hardening commit: 2cdfba24d. + +Re-verdict (same reviewer): PASS — "extra-@ host hole and empty-password +collapse are both closed; good remotes still pass; encoded and non-git +usernames stay rejected." Accepted residuals: scp-like IPv6 not deeply parsed +(same class as trailing-@ path text), U+2028/NBSP log-splitting (C0/DEL +already rejected; maintainer-facing log). + +Gates at train head 2cdfba24d: release-helper 24/24 pass, typecheck pass, +privacy:scan pass, prepush satisfied by the same suite run, lidge full suite +14211 pass / 16 skip / 0 fail exit 0 (r5). Non-author security approval: +recorded by merging maintainer lidge-jun per this doc + PR review. diff --git a/devlog/_plan/260821_bug_merge_train/040_merge_2296.md b/devlog/_plan/260821_bug_merge_train/040_merge_2296.md new file mode 100644 index 0000000000..52ec04cc87 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/040_merge_2296.md @@ -0,0 +1,39 @@ +# 040 — Cycle 4: PR #2296 (Desktop reconnect pool affinity, #2046) + +C4 auth surface — NAMED SECURITY REVIEW GATE: HMAC fallback key non-persistence + non-correlatability across restarts, no raw session/thread-id storage or logging (privacy:scan + manual grep), account-qualified selector exclusion from automatic affinity, failover/terminal accounting carries the same key. +Cancelled enforce-target check on head must re-run green pre-merge. Verify: bun test tests/codex-auth-context.test.ts, typecheck, privacy:scan, FULL SUITE on head (shared server surface). grok verdict. Merge, push --no-verify, dev CI green. Comment on #2046 (rotation half fixed; UI-denial half remains). + +## Plan (live head e672b0fd0 — 3 commits over old base 69907dde; dev now 15 ahead) + +The fork branch already merged origin/dev at 69907dde (pre-train). Merge the +PR head into the TRAIN and resolve there; leave the fork branch untouched. +Steps: +1. Adversarial review via inherited-model subagent (user directive: spawn + without a model name): HMAC fallback key non-persistence/correlatability, + no raw session/thread-id storage or logging, account-qualified selector + exclusion from automatic affinity, failover/terminal accounting parity. +2. Local gates at merged train head: codex-auth focused tests, typecheck, + privacy:scan. Full suite on lidge at merged head. +3. Land via train PR to dev (rules path). Comment rotation-half status on + #2046 after landing. + +## Security review (Huygens, inherited model) — GO-WITH-FIXES (blockers=0) → major fixed → re-verdict PASS + +Clean: HMAC fallback key memory-only + restart-regenerated (no persistence, +not derivable); raw session/thread ids never leave the HMAC digest; exact +selectors excluded from affinity at auth-context.ts:383; all six terminal/ +outcome sites carry authCtx.affinityKey; no src/lab/ import in core.ts. + +MAJOR: subagent-fallback preview read the legacy quota-scope slot +(undefined scope) while final resolve binds under codexQuotaScopeForModel — +preview could never find the Desktop binding and diverged from the +authenticating account, contradicting the structure doc's invariant. +Fix: pass codexQuotaScopeForModel(route.modelId) at core.ts:2311 (commit +698228e40) plus end-to-end postSpawn test and legacy-slot divergence pin. +Re-verdict (same reviewer): PASS. Accepted residual: shared-slot test does +not exercise an independent native scope (covered by construction — both +sides use the identical derivation). + +Gates at train head 698228e40: codex-auth + subagent-fallback tests 87/87, +typecheck pass, privacy:scan pass, lidge full suite r7 pending → recorded in +ledger receipt. diff --git a/devlog/_plan/260821_bug_merge_train/050_merge_2289.md b/devlog/_plan/260821_bug_merge_train/050_merge_2289.md new file mode 100644 index 0000000000..a3a7408b55 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/050_merge_2289.md @@ -0,0 +1,38 @@ +# 050 — Cycle 5: PR #2289 (service restart, closes #2287) + +Rebase (9 behind) absorbs #2295's registry.ts/lifecycle.md hunks. Review: bare 'ocx service' idempotency, repair/restart alias routing (src/service.ts, src/cli/registry.ts), Windows WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED path, 8-locale docs consistency. +Verify: bun test tests/cli-help.test.ts tests/service.test.ts tests/winsw.test.ts, bun run typecheck, FULL SUITE (bun run test) pre-merge; Service lifecycle CI green on head. grok verdict. Merge, push --no-verify, dev CI green. Close #2287 with landing commit. + +## Plan (live head 2df92a270 — 2 commits over base 401c24f7; merged into train as 6e1202fa5) + +The branch was already rebased by its author onto a recent dev (401c24f7), so +the historical rebase concern is resolved; the train merge took it cleanly. +Scope: src/service.ts +136/-, src/cli/registry.ts +5, 8-locale lifecycle.md +sync, structure doc, focused tests (+89). Steps: +1. Adversarial review (inherited model): idempotent restart semantics, + fail-closed unknown installation state, Windows schtasks access-denied + path, docs/behavior parity across locales. +2. Local gates: service/cli-help/winsw tests, typecheck, privacy:scan. +3. lidge full suite at merged head; land via train PR to dev; close #2287. + +## Review (Euclid, inherited model) — GO-WITH-FIXES (blockers=0) → P2 fixed → re-verdict PASS + +Clean: bare `ocx service` idempotency (installed → repair, actively +refreshed + serving-verified, never silently blessed); unknown-state is a +pre-validated fail-closed exit(1) with actionable guidance; Windows tri-state +probe never guesses absence; the #2287 wedge (unknown collapsed into absent → +elevated re-registration) is genuinely closed. + +P2 fixed (commit 174f03b60): English-source Windows fail-closed caveat was +missing from all 7 translations — added to ko/ja/fr/ru/tr/zh-cn/zh-tw per +repo docs-sync rule. Re-verdict: PASS. + +Accepted residuals (P3, pre-existing or non-blocking): end-to-end +serviceCommand wiring test, darwin/linux hook-based probe tests, localized +access-denied markers beyond en/de. + +Gates at train head 174f03b60: service/cli-help/winsw tests 174/174 + +cli-help 13/13, typecheck pass, privacy:scan pass. lidge r8 hit a single +SIGTERM-shutdown timeout (20s wall cap; known timing-sensitive test, passes +locally and in isolation on both hosts); full-suite re-run r8b executed as +the merge gate. diff --git a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md new file mode 100644 index 0000000000..aaab6ea8bf --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md @@ -0,0 +1,36 @@ +# 060 — Cycle 6: PR #2270 (apply_patch routed lowering) + +48 behind; single rebase onto now-stable dev. Preserve the !isCanonicalOpenAiForwardProvider boundary (already on head 398b7ade4; maintainer review r3 found no remaining technical blocker). Review: supportsResponsesCustomTools capability plumbing (registry/derive/types), compaction-body-last reorder invariant, byte-identical non-compaction pin test. +Fork head (olddonkey/opencodex, maintainerCanModify=true): stacked commits push to the fork remote. Pre-merge: dismiss stale CHANGES_REQUESTED (converged per reviewer's own head-398b7ade4 comment) or record fresh APPROVE. Verify on REBASED head BEFORE merge: bun test tests/custom-tool-compat.test.ts tests/namespace-tool-compat.test.ts tests/openai-responses-passthrough.test.ts tests/responses-custom-tool-repair.test.ts, bun run typecheck, FULL SUITE (shared routing/adapter surface; ssh lidge if local env-limited). grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live PR head 398b7ade4 — 4 commits over base 7881319e, ~50 behind dev) + +The fork branch is not directly fetchable as a remote ref (fork: olddonkey); +use the PR ref. The branch carries its own rebase history — do NOT rebase the +fork branch; merge the PR ref into the TRAIN and let the train carry it. +Fork push only needed if we stack new commits on the PR itself. Steps: +1. Merge pr/2270 into train, resolve conflicts there. +2. Adversarial review (inherited model): supportsResponsesCustomTools + plumbing, compaction-body-last reorder invariant, byte-identical + non-compaction pin, !isCanonicalOpenAiForwardProvider boundary. +3. Focused custom-tool tests + typecheck + privacy locally at merged head; + lidge full suite; land via train PR to dev; dismiss stale review state via + merge admin path. + +## Review (Bohr, inherited model) — GO-WITH-FIXES (blockers=0) → P2 fixed → re-verdict PASS + +Clean: capability plumbing consistent (undefined/true = passthrough, false = +lowering, explicit-override precedence tested); all consumption sites behind +the exact-base-URL canonical gate; reorder fixes the real latent bug +(compaction replayed custom_tool_call reached strict upstreams unlowered) +with byte-identical non-compaction pin intact; response restoration +fail-closed via buildToolBridgeMaps; no privacy/logging regressions. + +P2 fixed (commit ec32a8d52): negative pin proving the canonical Codex forward +surface ignores supportsResponsesCustomTools:false. Re-verdict: PASS. +Accepted residuals (P3): composed registry-to-handleResponses e2e, +namespace-child deny dedup coverage, tool_choice + lowered apply_patch case. + +Gates at train head ec32a8d52: focused tests 138/138 (+ pin 100/100), +typecheck pass, privacy:scan pass, lidge r9 full suite 14233 pass / 0 fail +exit 0 at 668512a58 + pin-only delta after. diff --git a/devlog/_plan/260821_bug_merge_train/065_merge_2281.md b/devlog/_plan/260821_bug_merge_train/065_merge_2281.md new file mode 100644 index 0000000000..928d768383 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/065_merge_2281.md @@ -0,0 +1,39 @@ +# 065 — Cycle 7: PR #2281 (thought-signature replay, last mover) + +Takes the core.ts rebase conflict deliberately. Pre-merge blockers (ALL merge-blocking): (a) stacked commit: normalize promptCacheKey via anthropicSessionKeyFromParts before assigning clientThreadId (src/server/responses/core.ts ~1888-1896; helper at src/oauth/anthropic-routing.ts:573-594) + trimmed/overlong-key test rows; (b) missing_regression_test hygiene label re-checked after stacked commit — drop or record maintainer override; (c) rebase onto final dev, resolve core.ts against #2296's affinity changes with a semantic re-check (replay scope + affinity key compose; both test files green on merged tree); (d) FULL SUITE green on that head. +Fork head (Hsia97/opencodex, maintainerCanModify=true): stacked commits push to the fork remote. Also: reviewDecision is CHANGES_REQUESTED (lidge-jun priority-63 review) — the stacked fixes must answer that review, then refresh/dismiss it. Verify: bun test tests/claude-code-thought-signature-scope.test.ts tests/google-signature-history-roundtrip.test.ts, bun run typecheck, FULL SUITE. Owner (CODEOWNERS core.ts) review recorded at merge. grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live PR head b31f3dbed — 2 commits over base e3b2136b, far behind dev) + +Merge the PR ref into the train and resolve the core.ts conflict there against +the landed affinity work. Steps: +1. Merge pr/2281 into train; resolve core.ts semantically (promptCacheKey + normalization + affinity compose). +2. Stacked commit (a): normalize promptCacheKey via + anthropicSessionKeyFromParts before clientThreadId assignment, with + trimmed/overlong-key test rows. +3. Adversarial review (inherited model) on the merged head: replay scope + correctness, signature integrity, cache-key normalization, privacy. +4. Focused signature tests + typecheck + privacy locally; lidge full suite; + land via train PR to dev; hygiene label (b) resolved by the stacked test + coverage; record owner approval at merge. + +## Review (Locke + second inherited reviewer) — both PASS + +Blocker (a) fixed by the stacked normalization commit bc6d6b516: promptCacheKey +routed through anthropicSessionKeyFromParts before scope assignment — trim + +sha256-over-128 parity with the affinity path; overlong-hash and whitespace +rows added. Reviewers verified: shared-cohort leak structurally blocked twice +(cacheKeySource gate + in-helper re-check); replay cache keys carry the full +provider/adapter/model/credential identity tuple plus serving-identity guard, +so no cross-session or cross-account signature leak; privacy clean (stored +scope is always the translator's opaque hash, never raw user_id). + +Accepted residuals (P3): provenance comment for future client-supplied +cache-key ingress; exact-digest pin and padded-trim row; header-priority row. +Hygiene label (b) resolved: regression coverage shipped in this train +(claude-code-thought-signature-scope.test.ts rows). + +Gates at train head bc6d6b516: focused 27/27, typecheck pass, privacy:scan +pass, lidge r10 full suite 14240 pass / 0 fail exit 0. Owner approval for +core.ts recorded by merging maintainer per repo policy. diff --git a/devlog/_plan/260821_bug_merge_train/070_final_gate.md b/devlog/_plan/260821_bug_merge_train/070_final_gate.md new file mode 100644 index 0000000000..595c30f123 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/070_final_gate.md @@ -0,0 +1,8 @@ +# 070 — Cycle 7: final gate + +1. Confirm final dev head CI fully green (gh run list --branch dev; the ci aggregate job). +1b. Docs-sync check: after 2295 (en-only doctor docs) + 2289 (8-locale lifecycle) both land, confirm locale lifecycle pages do not contradict the English page (AGENTS.md docs-sync rule). +2. If macos/windows shard flakes, rerun; if real regression from the train, fix forward on dev. +3. Close remaining linked issues with landing-commit comments (#2287, #2291, #2046 decision). +4. Move devlog unit to _fin with terminal outcomes recorded per PR. +5. Goalplan criteria capturedEvidence filled; cxc loop validate green; update_goal complete. diff --git a/devlog/_plan/260822_dev_release_readiness/000_plan.md b/devlog/_plan/260822_dev_release_readiness/000_plan.md new file mode 100644 index 0000000000..4841be6d11 --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/000_plan.md @@ -0,0 +1,91 @@ +# 000 — dev release readiness (main..dev regression audit) + +origin/main is v2.29.0 (231e622be); origin/dev is 146 commits ahead +(1af7a1e26 at planning time). Goal: a dev head a maintainer can promote — +every subsystem delta audited for regressions, P0/P1s fixed with tests, full +gates green, honest GO/NO-GO. + +## Scope of the delta (inventory in 001) + +Major landings since main: SelectedImage native vision (#1742), Bun 1.4 +stable bump + canary retirement, model-catalog refresh (Ox Alpha, DeepSeek +vision preview), release deploy-key push path (#2290), Windows restart +helper (#2293), senpi T01/T03/T05 (#2320/#2321/#2322), clean Connect +terminal (#2307), Auto [Tool Result] echo fix (#2318), H2 discovery pool +(#2332), credential router module (#2334, unwired), Z.AI quota (#2028), Pi +route sep fix (#2272), xai web-search normalize (#2312), merge-train units +(#2281 prompt_cache_key, #2270 custom-tool passthrough, #2289 docs locales, +#2296 subagent quota scope, #2294 release host hardening), T04 watchdog +(#2337), T07+shutdown (#2338), #2305 text-marker fix (#2341), bare-RE size +prior (#2342), round-2/3 devlog units. + +Audit-round additions (first plan audit caught these missing): xAI Fast / +Priority Processing enablement + pricing (f87698c0d, 1d7d8177a, 057f93ea5, +#2072 train), Claude Code thought-signature call_id replay (6c748663e, +b31f3dbed), zero-byte coordinator remnant recovery (6d5f0cf2c, #2295), +desktop pool affinity / reconnect binding (0e5a43459, 72df5e0de), vision +routed-backend sidecar incl. loopback describe executor (21aec549d.. +3ff19c33e, #2306/#2188), Windows service fail-closed installation state +(948fb5db1, 2df92a270). 001 must inventory from the ACTUAL log, not this +summary. + +## Audit lanes (WP4, read-only subagents; ox-alpha preferred) + +- L1 cursor adapter stack: vision, watchdog, terminal paths, error + classification interplay (esp. #2342 size prior vs #2320 mapping vs T04 + watchdog error paths), request-builder text channel rebuild. +- L2 providers/registry + quota: catalog refresh, noVision curation, Z.AI + windows, subagent quota scope, Ox Alpha entries. +- L3 release surface: deploy-key push path, scp-host rejection, release.ts + vs workflows, version/tag consistency. +- L4 GUI/dashboard + management API: sidebar/star routes, models API + parity with registry changes. +- L5 runtime/CI: Bun 1.4 bump fallout, workflow hardening test shape, + Windows shard skips, test-queue behavior. +- L6 responses-core + client adapters: prompt_cache_key normalization + (#2281), custom-tool passthrough (#2270), compaction body ordering / + apply_patch lowering, Claude Code thought-signature replay, vision routed + describe executor (server half of #2306), desktop pool affinity + + zero-byte coordinator recovery. + +Lane ownership rule: every commit in the 001 inventory is assigned to +exactly one lane in 002; unassigned commits fail the matrix (the first +audit found L1-L5 left responses-core uncovered). + +## Write-scope contract (WP boundaries) + +- WP1 (this cycle): docs-only. Probe TRANSCRIPT capture for the 210/290 + re-probe is allowed (read-only wire calls, redacted); no src/ edits. +- WP4: audit lanes are READ-ONLY subagents; ALL production fixes are + main-agent edits, each with a regression test, each its own commit. +- Promotion itself is out of scope (maintainer decision). + +## 210/290 correction contract (re-probe, not prose edit) + +The "fast is callable" correction REPLACES the 210 entitlement +interpretation, so it must carry its own probe transcript (already captured +live this session: opus-4-8-high-fast succeeded both maxMode arms; +4-7-low-fast RE persists; bare 4-7-fast not_found) AND must reopen the 290 +parity verdict: maxMode becomes provable, so 290's "unprovable on this plan +tier" row is amended to point at 310 (big-ctx A/B, billing approved) as the +deciding probe. 260's size-prior evidence stands, but its "entitlement +rejections share the shape" framing is softened to "non-overflow rejections +share the shape" since the tier-specific RE cause is now unknown. + +Each lane returns: findings ranked P0(release blocker)/P1(fix before +promote)/P2(note), each with file:line, repro or verifying command, and a +confidence tag. Main agent falsifies P0/P1 before fixing (no snippet-only +fixes). + +## Gates for GO + +- bun run typecheck + full bun run test green (local or ssh lidge). +- bun run privacy:scan green; lint:gui if gui touched. +- Cross-platform CI green on final head. +- No open P0/P1 from any lane. +- Security-review sign-off recorded for release-surface changes (#2290, + #2294, workflow edits) per MAINTAINERS.md — L3 lane must produce an + explicit security-review section, and its findings gate GO. +- Docs-sync check: user-facing behavior changes (catalog refresh, quota, + vision) verified against docs-site; locale parity spot-check beyond #2289. +- GO/NO-GO recorded in 090_go_verdict.md with evidence pointers. diff --git a/devlog/_plan/260822_dev_release_readiness/001_delta_inventory.md b/devlog/_plan/260822_dev_release_readiness/001_delta_inventory.md new file mode 100644 index 0000000000..f815562a95 --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/001_delta_inventory.md @@ -0,0 +1,121 @@ +# 001 — main..dev commit inventory (mechanical) + +Source: `git log origin/main..origin/dev --oneline --no-merges` at +planning head 1af7a1e26 (109 non-merge commits; merges excluded — +PR numbers appear in subject lines). + +``` +5eb56409c devlog: 290 post-landing status — 230/231 and 260 landed, final CI gate noted +f3a7cd4a1 fix(cursor): keep provably-small bare resource_exhausted on the 429 class +ab6a54e4d fix(cursor): fold display aliases in textual pseudo tool-call markers back to wire names +d6b8f8b5d devlog: round-3 live-probe evidence and lock (docs-only) +ce15bf9ff fix(cursor): fail OAuth polling on terminal statuses and shut the discovery H2 pool down at lifecycle exit +994e5ba87 fix(cursor): fail silent and heartbeat-only streams at the transport instead of the 300s bridge watchdog +6b889c36e devlog: round-2 Cursor stabilization research and roadmap lock (docs-only) +525568652 feat(cursor): add weighted credential router with cooldown failover (#2334) +d79b1b444 perf(cursor): add HTTP/2 session pool for discovery calls (#2332) +a69d291fb fix(cursor): stop native Auto from echoing [Tool Result] as chat (#2318) +b513a9142 fix(cursor): unknown exec replies with ExecClientThrow + streamClose instead of silence (#2322) +fd0605868 fix(cursor): close HTTP/2 after turnEnded so a held-open response cannot stall the turn (#2321) +b08ea715c fix(cursor): classify bare 0-token resource_exhausted as context overflow (#2320) +c836ffbff devlog: triage matrix — mark #2281 hardened and merged on the train +3b18d288b devlog: 2281 review rounds — both reviewers pass +bc6d6b516 fix(responses): normalize Claude Code prompt_cache_key through anthropicSessionKeyFromParts +0fb80bdeb devlog: 2281 cycle plan — merge-ref strategy with stacked normalization +c7f341a80 devlog: triage matrix — mark #2270 hardened and merged on the train +65c0fd362 devlog: 2270 review round — boundary pin added, re-verdict pass +ec32a8d52 test(responses): pin canonical forward custom-tool passthrough against explicit denial +3bbe4e411 devlog: 2270 cycle plan — PR-ref merge strategy for fork +5bbca70ab devlog: triage matrix — mark #2289 hardened and merged on the train +7957756ea devlog: 2289 review round — locale parity fixed, re-verdict pass +174f03b60 docs(lifecycle): sync Windows bare-service fail-closed caveat across all 7 locales +d846ad4e0 devlog: 2289 cycle plan — live-head scope after author rebase +c16d5ffde devlog: triage matrix — mark #2296 hardened and merged on the train +d83222154 devlog: 2296 security review round — major fixed, re-verdict pass +698228e40 fix(codex): derive subagent preview quota scope from the route model +c142cc72c devlog: 2296 cycle plan — live-head scope, inherited-model reviewer +f52de33f8 devlog: triage matrix — mark #2294 hardened and merged on the train +08bd08641 devlog: 2294 security review round — blocker fixed, re-verdict pass +2cdfba24d fix(release): reject credential-shaped scp-like hosts and colon-bearing userinfo +aea77b84c devlog: 2294 cycle plan — live-head scope and gate sequence +584a3e3e5 devlog: triage matrix — mark #2295 merged on the train +7f00202d4 devlog: 2295 cycle — full-suite rerun green after gui deps fix (14175 pass / 0 fail, lidge) +64cd6e5a9 fix(xai): normalize Responses web search tools +fcc3f5c05 fix(cursor): keep mixed tool terminals fail-closed +76166608f fix(cursor): preserve drained terminal on clean end +56bff341a test(cursor): harden clean terminal teardown +2df92a270 fix(service): fail closed on unknown installation state +948fb5db1 fix(service): restart existing installations without re-registering +0e5a43459 fix(codex): align Desktop affinity preview +72df5e0de fix(codex): bind Desktop reconnects to one pool account +c9c818d13 fix(cursor): settle clean Connect terminal without HTTP EOF +a228ed741 devlog: vision routed dropdown screenshot (PR evidence) +362377a03 test(vision): pin routed GET verbatim reporting (live-found regression) +a211e6d9e devlog: record vision routed-backend live delivery evidence (190) +3ff19c33e feat(vision): GUI/CLI routed surfaces + GET reports the routed describer verbatim +316190447 feat(vision): routed describe executor via loopback self-fetch (#2188 roadmap 180) +21aec549d feat(vision): routed describer backend — options, gates, namespaced ids (#2188 roadmap 170) +7317dde30 devlog: bug merge-train roadmap (260821) — triage, dependency analysis, audited disposition order +1d7099328 devlog: vision external-backend roadmap (160-190) under sidecar-selection unit +71598fa45 test(release): close SSH target log bypasses +4c7b3ceb8 fix(release): reject credential-bearing SSH remotes +6d5f0cf2c fix(codex): recover zero-byte coordinator remnants +6c33ea5dd devlog: record provider verification and PR fallback for restart helper +4430742f6 scripts: add Windows Codex desktop full-restart helper +569d0208c fix(test): compare terminal-guard rebuild content, not wall-clock stamps +25b0c11a9 fix(release): harden the deploy-key push path against three review findings +7a6d9c23f fix(release): derive the ssh push target from origin instead of hardcoding it +59d6367d4 fix(release): quote the deploy-key path in GIT_SSH_COMMAND +ed727d0e5 feat(release): push the version bump through a dedicated release deploy key +3e130d239 devlog: record the 260821 model-catalog-refresh unit +d23c3179f feat(providers): Ox Alpha (stealth 1M multimodal) and the DeepSeek vision preview across the catalog +27764f342 chore(runtime): move the bundled Bun to 1.4.0 stable and retire the canary channel +293276e0d docs(runtime): record the green full-suite run under Bun 1.4 canary +6889825bf fix(codex): keep multi_agent_v2 readable when the TOML parser rejects the document +68137e200 test(codex): stop relying on Bun 1.3.14 leaking PATH into children +876ebf320 docs(runtime): record what the Bun 1.4 canary lane found +d9ff528f9 test(codex): pin the datetime catalog contract across Bun TOML versions +8a3d43552 test(ci): teach the workflow hardening test the new CI shape +4cc735344 docs(runtime): README reflects the GitHub canary channel +90eabcc42 ci(runtime): qualify Bun 1.4 from the GitHub canary channel +d3ec5abd1 docs(runtime): add preview-dev branch README and upstream track pointer +1d76525eb docs(runtime): Bun 1.4 preview-dev roadmap with diff-level decade docs +a0fa018e7 ci(runtime): source Bun version from package.json and qualify preview-dev +aedc223c8 test(cursor): wait for RunSSE fetch instead of two microtasks +4729b37d6 test(quota): lock real Z.AI v2 and new-protocol responses as fixtures +d884d2c4a docs(providers): document the Z.AI GLM Coding Plan quota probe +10b3dee58 fix(quota): tighten zai window matching and legacy fallback gate +dcda7fa59 feat(quota): support GLM coding plan quota on z.ai and bigmodel.cn +e8c62a90d test(fastwire): expect xAI key-auth chat to forward Fast +4fbfb27d1 test(clients): assert the Pi override with join, not a POSIX separator +398b7ade4 test(responses): lower apply_patch on noncanonical forward destinations +2785aa29d test(responses): assert the terminal SSE marker on namespace replay +88ffe3272 fix(responses): build the routed compaction body last +df16e0a78 fix(responses): lower apply_patch for upstreams that reject custom tools +3124cb13d docs(cursor): use French typographic apostrophe in Vision omission wording +61ad6653e docs(cursor): describe history and omission markers in Vision sections +4e82029f5 fix(cursor): fail closed on untrusted sniff and soft-cap misses +c688bace5 fix(cursor): address post-rebase CodeRabbit nits on SelectedImage +40d096475 fix(cursor): avoid duplicate prepared binding in live transport +a0b96ec43 fix(cursor): reuse prepared SelectedImage bytes +e6a4a232c fix(cursor): keep image-only history in external root replay +e332aa2b6 docs(cursor): add glm-5.3 and French Vision section +43ad5ae87 fix(cursor): validate small JPEGs before passthrough +6097e60b4 fix(cursor): abort before image-count guard +2d703c89e fix(cursor): address second CodeRabbit pass on SelectedImage +0e5924366 fix(cursor): address CodeRabbit findings on native SelectedImage +82d2f32ff feat(cursor): native SelectedImage vision for verified models (data: only) +b31f3dbed test: cover Claude Code thought-signature replay scope +6c748663e fix: enable call_id thought-signature replay for Claude Code +d4023aedd docs(xai): separate the OAuth gateway row in the remaining locales +33e1c3e08 docs(xai): separate OAuth gateway rows +c13981b5a docs(xai): clarify API key transport +d887a4f2d fix(gui): translate estimated cost labels +1d7d8177a fix(xai): address B2 pricing review +057f93ea5 docs(devlog): capture the xAI Fast pricing UI evidence +f87698c0d feat(xai): enable Priority Processing on the API-key transport +``` + +Lane assignment for every commit lives in 002 (lane-ownership rule: exactly +one lane each; unassigned commits fail the matrix). + diff --git a/devlog/_plan/260822_dev_release_readiness/002_risk_matrix.md b/devlog/_plan/260822_dev_release_readiness/002_risk_matrix.md new file mode 100644 index 0000000000..0213841c5b --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/002_risk_matrix.md @@ -0,0 +1,95 @@ +# 002 — risk matrix (main..dev, head 1af7a1e26) + +Inventory source: `git log origin/main..origin/dev --oneline --no-merges` (109 commits, regenerated this audit). +Lane ownership rule satisfied: every commit assigned exactly one lane; counts sum to 109 (assertion at end). +Docs-only devlog commits inherit the lane of their subject unit; they carry no direct regression risk and are never ranked below. + +## L1 — cursor adapter stack (32 commits) + +Commits: 5eb56409c f3a7cd4a1 ab6a54e4d d6b8f8b5d ce15bf9ff 994e5ba87 6b889c36e 525568652 d79b1b444 a69d291fb b513a9142 fd0605868 b08ea715c fcc3f5c05 76166608f 56bff341a c9c818d13 569d0208c aedc223c8 3124cb13d 61ad6653e 4e82029f5 c688bace5 40d096475 a0b96ec43 e6a4a232c e332aa2b6 43ad5ae87 6097e60b4 2d703c89e 0e5924366 82d2f32ff + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | 82d2f32ff + 0e5924366 2d703c89e c688bace5 43ad5ae87 6097e60b4 a0b96ec43 40d096475 (SelectedImage train) | New native vision path: base64 data: gating, JPEG validation, image-count guard ordering, prepared-byte reuse — many interacting guards; a regression silently drops or corrupts images on verified models | `bun test tests/cursor*selected*image* -i` (nearest existing SelectedImage coverage; else `bun test --isolate tests/cursor-vision*.test.ts`) | H | +| 2 | 525568652 (#2334 weighted credential router) | New failover/cooldown state machine; cooldown misclassification could rotate away healthy credentials or pin dead ones | focused router test file covering cooldown/failover transitions (`bun test --isolate tests/*credential-router*`) | H | +| 3 | b08ea715c (#2320) × f3a7cd4a1 (#2342 size prior) × 994e5ba87 (T04 watchdog handoff) | Three overlapping resource_exhausted/silent-stream classifiers — error may be mapped twice (overflow then 429) or watchdog fires after transport already settled | `bun test --isolate tests/cursor-error-classification*.test.ts` (covers bare-RE mapping and 429-class prior) | H | +| 4 | ce15bf9ff | OAuth polling terminal-status failure + discovery H2 pool shutdown at lifecycle exit — wrong teardown order leaks sockets or hangs exit | `bun test --isolate tests/cursor-oauth*.test.ts` | M | +| 5 | fd0605868 (#2321) + d79b1b444 (#2332) | HTTP/2 session lifetime: close-after-turnEnded vs pooled discovery sessions — held-open response stalls turn or pool reuse returns a closed session | `bun test --isolate tests/cursor-h2*.test.ts` | M | +| 6 | fcc3f5c05 + 76166608f + 56bff341a + c9c818d13 | Terminal-state machine rework (mixed terminals fail-closed, drained-terminal preservation, clean Connect without EOF) — ordering bugs produce silent turn loss | `bun test --isolate tests/cursor-terminal*.test.ts tests/cursor-connect*.test.ts` | M | +| 7 | ab6a54e4d | Display-alias folding inside textual pseudo tool-call markers can over-fold legitimate user text containing alias strings | `bun test --isolate tests/cursor-text-marker*.test.ts` | M | + +## L2 — providers / registry + quota (17 commits) + +Commits: c16d5ffde d83222154 698228e40 c142cc72c 64cd6e5a9 3e130d239 d23c3179f 4729b37d6 d884d2c4a 10b3dee58 dcda7fa59 d4023aedd 33e1c3e08 c13981b5a 1d7d8177a 057f93ea5 f87698c0d + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | d23c3179f (Ox Alpha + DeepSeek vision preview catalog) | Registry-wide entries: wrong context/vision/pricing metadata propagates to routing, noVision curation, GUI cost estimates | `bun test --isolate tests/model-catalog*.test.ts` (or nearest registry contract test) | H | +| 2 | dcda7fa59 + 10b3dee58 (GLM coding-plan quota) | Window-matching rewrite affects legacy fallback gate — mis-window reports wrong remaining quota and could trigger false exhaustion routing | `bun test --isolate tests/quota-zai*.test.ts` (fixtures pinned in 4729b37d6) | H | +| 3 | 698228e40 (#2296 subagent preview quota scope) | Scope derived from route model — wrong derivation double-counts or bypasses subagent quota | `bun test --isolate tests/subagent-quota-scope*.test.ts` | M | +| 4 | f87698c0d + 1d7d8177a (xAI Priority Processing + B2 pricing) | Pricing-tier enablement gated on transport type; wrong gate bills priority rates on key-auth-less transports or misprices | `bun test --isolate tests/xai-pricing*.test.ts` | M | +| 5 | 64cd6e5a9 (xAI web-search tool normalize) | Tool-shape rewriting in request path — malformed normalize breaks every xAI search-enabled request | `bun test --isolate tests/xai-web-search*.test.ts` | M | + +## L3 — release surface (11 commits) + +Commits: f52de33f8 08bd08641 2cdfba24d aea77b84c 7317dde30 71598fa45 4c7b3ceb8 25b0c11a9 7a6d9c23f 59d6367d4 ed727d0e5 + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | ed727d0e5 + 59d6367d4 + 25b0c11a9 (#2290 deploy-key push) | Release automation now authenticates pushes via dedicated deploy key through GIT_SSH_COMMAND — token handling, quoting, and target derivation are release-blocker surface per AGENTS.md | `bun test --isolate tests/release-deploy-key*.test.ts` (plus targeted `bun x tsc --noEmit scripts/release.ts` if no dedicated file) | H | +| 2 | 2cdfba24d (#2294) + 4c7b3ceb8 + 71598fa45 (scp-host rejection) | Remote-string parsing that rejects credential-shaped scp hosts — over-rejection breaks legitimate remotes; under-rejection leaks credentials into logs/errors | `bun test --isolate tests/release-ssh-host*.test.ts` (covers log-bypass cases from 71598fa45) | H | +| 3 | 7a6d9c23f (push target from origin) | Deriving push target from origin instead of hardcoding — wrong remote parse pushes a version bump to an unintended host | same SSH-target test file as rank 2 | M | +| 4 | 7317dde30 (merge-train roadmap docs) | Planning artifact only — risk is process drift, not runtime | none (docs) | L | + +### SECURITY REVIEW — L3 (required per MAINTAINERS.md / AGENTS.md) + +Scope: #2290 deploy-key push path, #2294 scp-host rejection, workflow edits in range. + +- **#2290 deploy-key push** (ed727d0e5, 59d6367d4, 7a6d9c23f, 25b0c11a9) — **pass.** Token handling: key material stays in GIT_SSH_COMMAND env, not argv/logs after 59d6367d4 quoting; three review findings fixed in 25b0c11a9 and the blocker-fix round recorded (08bd08641, re-verdict pass). Push target now derived from origin (7a6d9c23f), eliminating the hardcoded-remote drift. No mutable third-party action refs introduced. Residual note (P2): confirm the deploy key is least-scope (single-repo write) in host config — outside code audit reach. Pointer: `scripts/release.ts` (deploy-key push section). +- **#2294 scp-host rejection** (2cdfba24d, 4c7b3ceb8, 71598fa45) — **pass.** Rejects credential-bearing scp-like hosts and colon-bearing userinfo before any spawn; log-bypass avenues closed by 71598fa45 tests. Secret-exposure check: rejection errors must render the sanitized host only — covered by the bypass tests; no raw remote echoed. Blocker found in review was fixed pre-merge (08bd08641 re-verdict pass). +- **Workflow edits** (90eabcc42 Bun canary qualification, a0fa018e7 version sourcing from package.json, 8a3d43552 hardening-test shape update) — **pass.** No new secrets, no pull_request_target expansion, no mutable third-party action refs added (canary channel is a runtime download, not an action ref; its integrity rests on Bun's release artifacts — P2 note: pin/checksum if this becomes a supply-chain concern). Permissions scope unchanged. + +Verdict summary: all three security-sensitive change sets **pass**; no needs-fix items. Findings above gate GO only via the two P2 operational notes. + +## L4 — GUI/dashboard + management API (5 commits) + +Commits: a228ed741 362377a03 a211e6d9e 3ff19c33e d887a4f2d + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | 3ff19c33e (GUI/CLI routed vision surfaces + GET verbatim) | Management GET must report the routed describer exactly — parity break between registry state and dashboard display misleads operators | `bun test --isolate tests/vision-routed-reporting*.test.ts` (pinned by 362377a03) | M | +| 2 | d887a4f2d (estimated cost labels translation) | Label i18n keyed off catalog entries changed in L2 — mismatch shows raw keys or wrong currency figures | `bun run lint:gui` + focused GUI i18n test if present | L | + +## L5 — runtime / CI (21 commits) + +Commits: 5bbca70ab 7957756ea 174f03b60 d846ad4e0 7f00202d4 2df92a270 948fb5db1 6c33ea5dd 4430742f6 27764f342 293276e0d 6889825bf 68137e200 876ebf320 d9ff528f9 8a3d43552 4cc735344 90eabcc42 d3ec5abd1 1d76525eb a0fa018e7 + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | 27764f342 (Bun 1.4 stable bump, canary retired) + a0fa018e7 (version from package.json) | Runtime version bump touches every subsystem; TOML/datetime/PATH behaviors differ across Bun versions (see 6889825bf, d9ff528f9, 68137e200 mitigations) | `bun run test` (full suite — shared-runtime change) | H | +| 2 | 2df92a270 + 948fb5db1 (Windows service install state) | Fail-closed on unknown installation state + restart-without-reregister — wrong state machine bricks existing installs on upgrade | `bun test --isolate tests/service-install*.test.ts` (Windows-skipped shards verified on a Windows CI run) | H | +| 3 | 4430742f6 (Windows desktop full-restart helper) | Script kills/relaunches desktop processes — overly broad match kills unrelated processes | manual dry-run review of script + `zsh -n`-equivalent syntax check | M | +| 4 | 90eabcc42 + 8a3d43552 (CI canary qualification + workflow-hardening test shape) | CI shape change invalidates the hardening test's assumptions; silent skip hides regressions | `bun test --isolate tests/workflow-hardening*.test.ts` | M | + +## L6 — responses-core + client adapters (23 commits) + +Commits: c836ffbff 3b18d288b bc6d6b516 0fb80bdeb c7f341a80 65c0fd362 ec32a8d52 3bbe4e411 584a3e3e5 0e5a43459 72df5e0de 316190447 21aec549d 1d7099328 6d5f0cf2c e8c62a90d 4fbfb27d1 398b7ade4 2785aa29d 88ffe3272 df16e0a78 b31f3dbed 6c748663e + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | bc6d6b516 (#2281 prompt_cache_key normalization) | anthropicSessionKeyFromParts normalization sits on every Claude Code request — bad split leaks or mangles session keys and breaks cache affinity | `bun test --isolate tests/responses-prompt-cache-key*.test.ts` | H | +| 2 | df16e0a78 + 88ffe3272 (apply_patch lowering + compaction-body-last ordering) | Request-body assembly reorder: lowering custom tools AND building compaction last interact — a rebuilt body that drops lowered tools or stale compaction sends malformed upstream requests | `bun test --isolate tests/responses-apply-patch*.test.ts tests/responses-compaction*.test.ts` | H | +| 3 | 6c748663e + b31f3dbed (thought-signature call_id replay) | Replay scope change alters what Claude Code sees mid-conversation; too-broad replay duplicates signatures, too-narrow drops them and upstream rejects | `bun test --isolate tests/thought-signature*.test.ts` | M | +| 4 | 316190447 + 21aec549d (routed describe executor, loopback self-fetch) | Server-side self-fetch creates a request path back into the proxy — deadlock/gate-bypass risk if loopback auth or gates are mishandled | `bun test --isolate tests/vision-describe-executor*.test.ts` | M | +| 5 | 0e5a43459 + 72df5e0de + 6d5f0cf2c (desktop pool affinity/reconnect binding + zero-byte remnant recovery) | Pool account binding and remnant recovery touch connection reuse — wrong binding splits sessions across accounts; recovery of zero-byte remnants may resurrect stale state | `bun test --isolate tests/desktop-pool*.test.ts` (+ coordinator remnant recovery test) | M | +| 6 | ec32a8d52 + 398b7ade4 + 2785aa29d + 4fbfb27d1 + e8c62a90d | Contract pins for custom-tool denial/passthrough, SSE namespace marker, Pi separator join, xAI fastwire — pins encode cross-version behavior; a drifted upstream fails these first | run each named test file with `bun test --isolate` | L | + +## Lane-coverage assertion + +Every commit in the regenerated 109-line inventory is assigned to exactly one lane. Counts: L1 = 32, L2 = 17, L3 = 11, L4 = 5, L5 = 21, L6 = 23. Sum = 109 ✓. No commit unassigned; no commit double-assigned. + +> Provenance: matrix produced by a read-only ox-alpha classification lane; L3 +> security verdicts rest on recorded review rounds (08bd08641, d83222154) plus +> commit evidence. WP4's L3 lane re-reads scripts/release.ts and workflows at +> head for file:line-grade confirmation before GO. + diff --git a/devlog/_plan/260822_dev_release_readiness/009_roadmap_lock.md b/devlog/_plan/260822_dev_release_readiness/009_roadmap_lock.md new file mode 100644 index 0000000000..d5c62ef9e9 --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/009_roadmap_lock.md @@ -0,0 +1,24 @@ +# 009 — WP roadmap lock (release readiness) + +Locked after the audited 000 plan (Faraday PASS), mechanical 001 inventory +(109 commits), and the 002 risk matrix (all lanes assigned, sum 109, +security-review section pass with 2 P2 notes). + +## Cycle order + +1. WP2 -> 300_opus_fast_catalog.md (senpi unit): catalog repair, tests, + live smoke on macmini. +2. WP3 -> 310_maxmode_bigctx.md: 2-run big-context A/B (billing approved); + conditional maxMode propagation or NOOP. +3. WP4 -> execute 002 matrix: read-only lanes L1-L6 verify their ranked + rows (run the named commands, falsify or confirm); main agent fixes + P0/P1 with regression tests; full suite + typecheck + privacy + (if gui) + lint. L3 lane re-reads release.ts + workflows at head for file:line + security confirmation. +4. WP5 -> 090_go_verdict.md: final CI green + GO/NO-GO with evidence. + +## Standing constraints + +Write scope per 000 (WP4 lanes read-only, main-agent fixes only); +promotion excluded; probe hygiene per senpi-unit doc 200. + diff --git a/devlog/_plan/260822_dev_release_readiness/010_wp4_findings.md b/devlog/_plan/260822_dev_release_readiness/010_wp4_findings.md new file mode 100644 index 0000000000..e1d2e0bcbf --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/010_wp4_findings.md @@ -0,0 +1,38 @@ +# 010 — WP4 audit lane findings (consolidated) + +Six read-only lanes executed the 002 matrix at head 67b5fa019 (inherited- +model fallback after ox-alpha 429'd on 6 parallel spawns — the stealth +model's rate pool cannot host 6 concurrent lanes). + +## Verdicts + +| Lane | Verdict | Notes | +|---|---|---| +| L1 cursor stack | 0 P0 / 0 P1 / 1 P2 | 351 tests green across 7 rows; classifier chain single-pass proven; watchdog disarm ordering verified | +| L2 registry/quota | CLEAN | 5 rows; noVision substring fear disproven (modelInList exact/colon match); quota display-only | +| L3 release surface | CLEAN | file:line security confirmation delivered: key path env-only (release.ts:244), fixed-string rejection errors, all 16 workflows SHA-pinned, release.yml permissions {} | +| L4 GUI/mgmt API | 0 P0 / 0 P1 / 1 P2 | GET/PUT/runtime parity proven; i18n keys typed-complete | +| L5 runtime/CI | CLEAN | Bun 1.4 mitigations individually green; Windows service state machine fail-closed; aggregate-gate derives needs from all jobs | +| L6 responses-core | CLEAN | 635 tests green; compaction ordering invariant honored; describe-executor recursion fenced at depth 1 | + +## P2 register (not promote blockers) + +1. **[L1] ~1MiB invalid_argument replay burn** — oversized single message + triggers one guaranteed-pointless fresh-conversation replay (~doubles + time-to-error). Fix sketch recorded (pre-flight size guard before + runOnce). Own cycle later. +2. **[L4] routed-vision GET display drift** — GET does not re-verify + targetVisible, so a later noVisionModels edit shows stale routed pair + while runtime falls through. Display-only; reachable only by hand-edit. +3. (carried from 002) deploy-key least-scope is host-config, outside code + audit; Bun canary pinning moot since stable bump. + +## Gates run this phase + +- bun x tsc --noEmit: clean. +- Full suite: 14264 pass / 10 skip / 0 fail (897 files, 613s — slow due to + parallel audit lanes on the same machine, not test regressions). +- privacy:scan: green (run in WP1/WP3 closes; re-run at WP5 close). +- Matrix note: several 002 "Verify" globs named nonexistent files; lanes + located and ran the real nearest coverage (recorded per lane report). + diff --git a/devlog/_plan/260822_dev_release_readiness/090_go_verdict.md b/devlog/_plan/260822_dev_release_readiness/090_go_verdict.md new file mode 100644 index 0000000000..83f7cb97df --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/090_go_verdict.md @@ -0,0 +1,44 @@ +# 090 — GO/NO-GO verdict + +Head at close: 2b4ddf3b0 (docs-only merges above a012a460e; the last +code-bearing commit is the opus-fast catalog, PR #2346). + +## Verdict: GO (promotion-ready dev) + +## Evidence + +- **CI**: Cross-platform CI completed success on a012a460e (last code head) + and on 8f3ac5fe9 before it. Subsequent commits are devlog-only and skip CI + by path filter; no code differs between a012a460e and this head + (verify: git diff a012a460e..HEAD --stat -- ':!devlog'). +- **Full suite**: 14264 pass / 10 skip / 0 fail (897 files) at 67b5fa019 + content (code-identical to head); bun x tsc --noEmit clean. +- **Regression audit**: 6 lanes over the 002 matrix (109 commits, all + assigned) — ZERO P0/P1. Lane reports in 010. +- **Security review (GO gate)**: L3 file:line confirmation — deploy-key + path env-only (release.ts:244), fixed-string rejection errors, all 16 + workflows SHA-pinned, release.yml permissions {} + OIDC scoped to publish + job. Matrix + lane verdicts: pass. +- **privacy:scan**: green at every docs close in this loop. +- **Docs-sync**: catalog/vision/quota changes carry devlog units; locale + parity for cost labels verified in L4 (9 locales typed-complete). + +## Open items (not blockers, tracked) + +- P2: ~1MiB per-message pre-flight guard (L1, fix sketch in 010). +- P2: routed-vision GET display drift on post-write noVision edits (L4). +- P2 ops: deploy-key least-scope is host-side config (outside repo). +- NEEDS_HUMAN: #2334 CursorCredentialRouter wiring (product decision); + unwired module confirmed zero runtime reach (L1). +- Deferred probes: T02 rotation (unreproduced), maxMode propagation (NOOP + by 310 A/B), client-version bump (NOOP by 240). + +## What this loop landed since v2.29.0 relevant to release notes + +Opus Fast families with verified tiers (#2346), #2305 text-marker fix +(#2341), bare-RE size prior (#2342), T04 stream-health watchdog (#2337), +OAuth fail-fast + H2 pool shutdown (#2338), plus the senpi round-2/3 and +readiness research units. + +Promotion itself is a maintainer action (dev -> preview/main per +MAINTAINERS.md); this verdict only certifies dev's state. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md b/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md new file mode 100644 index 0000000000..75685484b4 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md @@ -0,0 +1,54 @@ +# 260822 — senpi Cursor transfer investigation + +Docs-only research unit. No production patches in this cycle. +Session `01a02665-e4c1-75a3-9660-c71284a1bba2`. Goalplan `investigate-whether-opencodex-can-adopt-any-curs`. + +## Loop spec + +- Loop archetype: satisfy-spec research (inventory + classify). Not an optimization loop. +- Trigger: user asked whether OpenCodex can take Cursor-runtime mechanisms from senpi, with unlimited explorer dispatch, no model-name overrides. +- Goal: evidence-bearing transfer verdict in this unit. Every comparison row cites OpenCodex `path:line` and senpi GitHub blob/commit. +- Non-goals: production `src/` edits; copying senpi protobuf wholesale; starring repos; live Cursor account mutation; spawning `cursor-agent` CLI; extracting secrets. +- Verifier: files exist under this unit; `git status` shows no production `src/` diffs from this loop; 090 table rows have both-codebase citations. +- Stop: 090 locked and wp0 criteria captured. Implementation is a later appended work-phase, not this cycle. +- Memory artifact: this directory. +- Terminal: DONE (research lock) / NOOP (no residual gaps) / NEEDS_HUMAN (ToS) / UNSAFE (native-app patching). +- Escalation: live Cursor probes, ToS/product-policy, or proto-regen risk. + +## Sources + +- OpenCodex tree: local checkout (explorers also cited `dev` `a228ed74` / GitHub `lidge-jun/opencodex`). +- senpi: `code-yeongyu/senpi` `main` SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717` (2026-08-21), files also fetched as current default-branch blobs. +- Explorer lanes (inherit parent model; no model field): Helmholtz (protocol), Planck (auth/catalog), Hypatia (exec), Leibniz (stream/usage), Pasteur (senpi protocol), Archimedes (senpi auth/catalog), Plato (exec-bridge + CLI), Ohm (overflow/RE). + +## Docs + +- 000 (this file) — unit map + later-implementation slice order. +- 001 — OpenCodex Cursor inventory. +- 002 — senpi Cursor inventory. +- 003 — protocol / transport compare. +- 004 — auth / catalog / effort / max-mode. +- 005 — exec / interactionQuery / tool pairing. +- 006 — stream completion / usage / overflow / rotation. +- 007 — CLI fallback lane. +- 090 — transfer verdict (ADOPT / ADAPT / REJECT / ALREADY-HAVE / NEEDS_HUMAN). + +## Work-phase map (dependency order, not effort) + +1. **wp0 (this cycle, docs-only):** inventories + 090 lock. Independent of later code. +2. **wp1 (010, later):** Cursor error mapping + 0-token `resource_exhausted` surface. Owner: `src/adapters/cursor/cursor-errors.ts`, `src/lib/errors.ts`, `src/adapters/cursor/transport-retry.ts`. +3. **wp2 (020, later):** `turnEnded` as application-complete + adapter stream-health. Owner: `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/protobuf-events.ts`. +4. **wp3 (030, later):** unknown-exec typed reply (`ExecClientThrow` + stream-close) and optional newer exec oneofs as refusals. Owner: `src/adapters/cursor/native-exec.ts`. Do not regenerate protobuf in the same cycle as error mapping. +5. **wp4 (040, later, optional):** live `GetUsableModels.maxMode` + richer catalog decode. Owner: `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/protobuf-request.ts`, `src/adapters/cursor/discovery.ts`. + +Do not implement two slices in one B. Do not start wp1 until this research cycle D-locks 090. + +## IN / OUT + +IN: this `devlog/_plan/260822_senpi_cursor_transfer/` directory. +OUT: `src/`, `tests/`, `gui/`, `docs-site/`; senpi vendored proto copy; CLI spawn of `cursor-agent`. + +## Already-have headline + +OpenCodex is not missing a Cursor provider. It already speaks `agent.v1.AgentService/Run` over Connect, answers `interactionQuery`, owns HTTP/1 `RunSSE` fallback, conversation-keyed `usedTokens` accounting, native-exec policy, and Responses-tool suspend. senpi's newer work is mostly overflow classification, turn-end close, exec-frame completeness, and a CLI fallback lane that OpenCodex deliberately does not have. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md b/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md new file mode 100644 index 0000000000..1f950e5026 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md @@ -0,0 +1,50 @@ +# 001 — OpenCodex Cursor inventory + +Research only. Local tree + explorer Helmholtz / Planck / Hypatia / Leibniz. + +## Layout + +`src/adapters/cursor/` owns the live protobuf adapter. Supporting files: + +- Transport: `live-transport.ts` (1443 lines), `transport.ts`, `transport-retry.ts`, `http1-bidi.ts`, `framing.ts` +- Request: `request-builder.ts`, `protobuf-request.ts`, `tool-definitions.ts` +- Events / usage: `protobuf-events.ts`, `checkpoint-store.ts`, `thread-continuity.ts`, `kv-store.ts` +- Exec: `native-exec.ts` + `native-exec-*.ts`, `exec-policy.ts`, `mcp-manager.ts`, `mcp-config.ts` +- Catalog: `discovery.ts`, `live-models.ts`, `effort-map.ts` +- Errors: `cursor-errors.ts` +- Generated proto: `gen/agent_pb.ts` +- OAuth: `src/oauth/cursor.ts` (not under adapters) +- Adapter entry: `src/adapters/cursor.ts` +- Tests: `tests/cursor-*.test.ts` (39 files) + +## Protocol + +OpenCodex posts `POST /agent.v1.AgentService/Run` as Connect proto, 5s `clientHeartbeat`, client version `cli-2026.07.08-0c04a8a`: + +```90:92:src/adapters/cursor/live-transport.ts +const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; +const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; +const HEARTBEAT_MS = 5_000; +``` + +HTTP/1 fallback exists: `RunSSE` + `BidiAppend` in `http1-bidi.ts:10-11`. First-frame timeout is 30s (`live-transport.ts:93`). After that, liveness is the Responses bridge stall watchdog (default 300s, `src/stall-timeout.ts:8`), kept alive by synthetic `heartbeat` events on swallowed progress frames (`live-transport.ts:1304-1309`). + +`turnEnded` maps to `finalizeTurnEvents` (`protobuf-events.ts:1327-1328`). Transport still waits for Connect EOF. If EOF arrives after assistant text without `turnEnded`, it synthesizes `done` (`live-transport.ts:1147-1150`). Client-tool Responses path **intentionally** ends turn 1 without waiting for `turnEnded` (`live-transport.ts:203-206`). + +Unknown `interactionQuery` replies empty with matching id so the server unblocks (`live-transport.ts:376-382`, issue #116). Web/exa queries are approved; askQuestion/switchMode rejected (`live-transport.ts:287-366`). + +## Auth / catalog + +Same Cursor PKCE poll as senpi: `loginDeepControl`, `auth/poll`, `exchange_user_api_key` (`src/oauth/cursor.ts:13-15`). After login, catalog uses stored tokens via `getValidAccessToken`. `GetUsableModels` is empty-body unary (`live-models.ts:12-14, 28`). Decode keeps **ids only** (`live-models.ts:115-131`). Static seed in `discovery.ts` is filtered by live ids; `stripCursorWirePrefix` at the comparison boundary (`discovery.ts:67-84`, issue #117). Effort is a static suffix table (`effort-map.ts`). `RequestedModel.maxMode` is hardcoded `false` (`protobuf-request.ts:963-966`). + +## Exec + +Known proto cases end at `writeShellStdinArgs` (`gen/agent_pb.ts:6886+`). Dispatcher: `native-exec.ts:550-609`. Default `nativeLocalExec` is **off**; only `"on"` authorizes local fs/shell/fetch (`exec-policy.ts:17-44`). Unknown exec returns `[]` to keep the stream alive (`native-exec.ts:605-609`). Responses `mcpArgs` are **not** executed locally (`live-transport.ts:226-246, 1236-1246`). Native exec emits `local_side_effect` before running so `invalid_argument` remint cannot replay (`live-transport.ts:1248-1252`). + +## Usage / overflow + +Checkpoint `usedTokens` is absolute context, not an output delta (`protobuf-events.ts:1233-1238`). Conversation-keyed cache: 200 entries / 60 minutes (`protobuf-events.ts:21-22`). Generated `TurnEndedUpdate` is empty (`gen/agent_pb.ts:3083-3085`), so billed cacheRead is not ingested. Generic `resource_exhausted` classifies as 429 unless an explicit size phrase wins (`cursor-errors.ts:131-163`). Transport retry never retries RE (`transport-retry.ts:25`). Conversation remint exists only for external-model `invalid_argument` (`src/adapters/cursor.ts:231-247`). Compaction uses an isolated conversation and does not store its checkpoints (`request-builder.ts:397, 443-444`; `src/server/responses/core.ts:2247-2249`). + +## OpenCodex-only keepers + +HTTP/1 RunSSE; interactionQuery matrix; fail-closed nativeLocalExec; Responses-tool suspend; JWT-sub multiauth; classified discovery errors; bounded blob KV / checkpoint store; `createTerminalSettler`. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md b/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md new file mode 100644 index 0000000000..90589612c0 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md @@ -0,0 +1,43 @@ +# 002 — senpi Cursor inventory + +Research only. senpi `main` SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. Explorers Pasteur / Archimedes / Plato / Ohm. + +## Layout + +Cursor is a first-class builtin provider, not an OpenCodex-style proxy adapter. + +- Provider: [packages/ai/src/providers/cursor.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts) — OAuth, empty static catalog, `fetchModels` = live `GetUsableModels` +- Run client: [packages/ai/src/api/cursor-agent.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts) (~4439 lines, Node http2) +- Lazy load: `cursor-agent.lazy.ts`; Bun static register: `cursor-agent-provider.ts` +- Catalog grouping: `packages/ai/src/cursor/catalog-grouping.ts`, `model-capabilities.ts`, `selection-descriptor.ts`, `store-migration.ts` +- OAuth: [packages/ai/src/auth/oauth/cursor.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts) +- Rotation: `packages/ai/src/api/cursor-conversation-rotation.ts` +- Overflow: `packages/ai/src/utils/overflow.ts` +- Host exec-bridge: `packages/coding-agent/src/core/cursor-exec-bridge.ts` +- CLI fallback: `packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/` +- PRs: [#905](https://github.com/code-yeongyu/senpi/pull/905) OAuth, [#910](https://github.com/code-yeongyu/senpi/pull/910) protocol, [#921](https://github.com/code-yeongyu/senpi/pull/921) CLI, [#948](https://github.com/code-yeongyu/senpi/pull/948) reasoning levels, [#1013](https://github.com/code-yeongyu/senpi/pull/1013) ANTML skip, [#1015](https://github.com/code-yeongyu/senpi/pull/1015) compact-before-rotate, [#1062](https://github.com/code-yeongyu/senpi/pull/1062) turnEnded completion + +## Protocol + +Same `AgentService/Run` Connect path, 5s client heartbeat, client version `cli-2026.07.23-e383d2b`. HTTP/2 only; ALPN-stripping proxy is fatal (no h1 fallback). `turnEnded` is the application completion signal: drain exec ≤5s, then close the client HTTP/2 stream ([cursor-agent.ts L249-254, L698-704](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L249-L254)). HTTP close without `turnEnded` is an error. Stream-health: 30s no inbound frames, 90s heartbeat/checkpoint-only. + +Unknown exec is `ExecClientThrow` + `streamClose` so the server is never left blocked. Per-exec 3s heartbeat while a handler runs (`exec-lifecycle.ts`). + +`handleServerMessage` has **no `interactionQuery` case** (open [#1026](https://github.com/code-yeongyu/senpi/issues/1026)). + +## Auth / catalog + +Same PKCE poll. Fail-fast on poll 400/401/403/410; 429 does not burn the transient budget. Catalog is fully dynamic: `models: []`, live GetUsableModels, then `normalizeCursorCatalog` grouping with `thinkingLevelMap` / `cursorReasoning` / `cursorMaxMode`. Live `maxMode` is copied onto `RequestedModel` ([reasoning-params.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20)). + +## Exec + +Host-injected `CursorExecHandlers` map frames onto senpi tools (`read`/`bash`/`edit`/`write`/`grep`/`find`/`ls` + MCP). Exec-synthesized tool calls are stamped `kCursorExecResolved` so the agent loop does not re-run them. Pi exec family (proto 45–51) is dispatched. Computer-use / canvas / subagents / conversation-search are typed refusals (PR 910). CLI lane is a **separate** spawn of official `cursor-agent -p --output-format stream-json`; tools are display-only; `--force` needs `noApprovalAcknowledgedAt`; kill switch is verbatim `enabled: false`. + +## Overflow + +0-token `resource_exhausted` is payload overflow for compact-before-rotate (`overflow.ts` `isCursorPayloadResourceExhausted`). First 0-token RE is **surfaced** so session compaction can run; later ones rotate the wire id up to 3 times (`cursor-conversation-rotation.ts`). Billed `turnEnded` cacheRead that dwarfs checkpoint `usedTokens` (>3×) is ignored ([cursor-agent.ts L3544](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3544)). ANTML text-tool recovery is skipped for `api === "cursor-agent"` (PR #1013). Compact while a Cursor Run is live is skipped (#984). Open: [#1043](https://github.com/code-yeongyu/senpi/issues/1043) compact-reload restores full toolResult bodies. + +## Deliberately not ported (senpi) + +Computer use, subagents, Cursor-managed background shells (typed refuse; OpenCodex actually implements bg shell when native exec is on), canvas, smart-mode classifier, conversation search, Kimi-K3 thinking replay, proxy tunneling (PR 910). + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md new file mode 100644 index 0000000000..c6883b3513 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md @@ -0,0 +1,35 @@ +# 003 — Protocol / transport compare + +Helmholtz + Pasteur. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. + +## Same + +Both speak `agent.v1.AgentService/Run` over HTTP/2 Connect (`application/connect+proto`, `connect-protocol-version: 1`, Bearer, `x-ghost-mode: true`, `x-cursor-client-type: cli`). Both write a 5s `clientHeartbeat`. Both rebuild `rootPromptMessagesJson` as the model prompt and treat `turns[]` as display metadata. Both implement blob KV `getBlobArgs`/`setBlobArgs`. + +OpenCodex: + +```90:92:src/adapters/cursor/live-transport.ts +const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; +const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; +const HEARTBEAT_MS = 5_000; +``` + +senpi: [cursor-agent.ts L522-547, L746-747](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L522-L547). + +## Different + +| Topic | OpenCodex | senpi | +|---|---|---| +| Client version | `cli-2026.07.08-0c04a8a` | `cli-2026.07.23-e383d2b` | +| HTTP/1 | `RunSSE` + `BidiAppend` (`http1-bidi.ts:10-11`) | HTTP/2-only; ALPN strip is fatal | +| Session header | sends `x-session-id` | does not | +| Completion | `turnEnded` finalizes mapper; transport waits for EOF; may synthesize `done` | `turnEnded` closes client HTTP/2 after ≤5s exec drain | +| Mid-turn health | 30s first-frame only; then 300s bridge stall | 30s silence / 90s heartbeat-only inside the adapter | +| Abort owner | `failAndClear` + `createTerminalSettler` | `settleH2` | +| Exec heartbeat | none (types exist) | 3s per-exec heartbeat | +| Blob store | TTL / 4096 / 64MiB | unbounded per-conversation Map | + +## Transfer suspicion + +High: close HTTP/2 on `turnEnded` (frozen turns until bridge 300s). Medium: heartbeat-only stall fail. Low: bump client version without a live probe. Do not copy senpi's unbounded blob Map. Keep OpenCodex HTTP/1 fallback. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md new file mode 100644 index 0000000000..00e50f16ad --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md @@ -0,0 +1,29 @@ +# 004 — Auth / catalog / effort / max-mode + +Planck + Archimedes. + +## Auth — ALREADY-HAVE + +Same three URLs and PKCE params (`challenge`, `uuid`, `mode=login`, `redirectTarget=cli`). + +OpenCodex `src/oauth/cursor.ts:13-15, 78-85`. senpi [oauth/cursor.ts L17-19, L123-130](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts#L17-L19). + +Delta worth a small adapt: senpi fail-fasts poll 400/401/403/410 and does not spend the transient budget on 429. OpenCodex retries any non-ok as consecutive errors up to 3 (`src/oauth/cursor.ts:121-148`). OpenCodex-only keepers: JWT `sub`/`email` multiauth, 15s refresh timeout, 429/5xx refresh retry. + +Login catalog refresh: senpi auto `fetchModels` after `/login cursor`. OpenCodex clears model cache and tells the operator to `ocx sync` (`src/oauth/index.ts:1234`, `src/oauth/login-cli.ts:95`). + +## Catalog — different-shape + +OpenCodex: static seed + live id filter + `stripCursorWirePrefix` (`discovery.ts:67-84`). Decode keeps ids only (`live-models.ts:4-6`). Empty 0-byte GetUsableModels body is a Bun HTTP/2 requirement (`live-models.ts:12-14`). + +senpi: no static baseline; live GetUsableModels is the catalog; grouping produces `thinkingLevelMap` / `cursorReasoning` / `legacyAliases` ([providers/cursor.ts L10-17](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts#L10-L17), [catalog-grouping.ts L19-31](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/cursor/catalog-grouping.ts#L19-L31)). Decode keeps `maxMode`, display name, `thinkingDetails` ([cursor-agent.ts L4354-4369](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L4354-L4369)). + +## Effort / max-mode + +OpenCodex flattens Codex effort onto a static suffix table (`effort-map.ts:96-108`). Grok Fast is parameterized (`request-builder.ts:182-204`). `RequestedModel.maxMode` is always `false` (`protobuf-request.ts:963-966`; the 934-937 window is debug logging, not maxMode). + +senpi copies live `cursorMaxMode` onto the wire ([reasoning-params.ts L8-20](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20)). Family-specific parameters (Claude thinking/context/effort, GPT extra-high, etc.) come from a captured AvailableModels capability table, not from GetUsableModels fields. + +## Transfer suspicion + +Medium-high: honor live `maxMode` instead of hardcoding false (proto field already exists at `gen/agent_pb.ts:2617`). Medium: fail-fast OAuth poll. Low/product: replace static seed with fully dynamic catalog (OpenCodex still needs logged-out fallback and `auto-{cost,balance,intelligence}` router ids). Do not copy senpi's 204-id alias JSON wholesale. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md new file mode 100644 index 0000000000..f196a95bfb --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md @@ -0,0 +1,29 @@ +# 005 — Exec / interactionQuery / tool pairing + +Hypatia + Plato + Pasteur. + +## Architecture mismatch (do not ignore) + +senpi is a **host**. Exec frames map onto senpi tools via `CursorExecHandlers`, then the agent loop skips `kCursorExecResolved` blocks ([cursor-exec-bridge.ts L1-16](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/cursor-exec-bridge.ts#L1-L16), [block-symbols.ts L40-49](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/block-symbols.ts#L40-L49)). + +OpenCodex is a **Responses proxy**. Exec either runs locally inside the adapter (only if `nativeLocalExec: "on"`) or is rejected. Codex-owned tools travel as `opencodex-responses` MCP and are **not** executed on the exec channel (`live-transport.ts:226-246`). Copying senpi's host-tool bridge would invert OpenCodex's trust model. + +## Frames + +OpenCodex known cases end at `writeShellStdinArgs` (`gen/agent_pb.ts:6886+`). Dispatcher `native-exec.ts:550-609`. Default policy off (`exec-policy.ts:17-44`). + +senpi additionally dispatches Pi family 45–51 and answers newer oneofs with typed refusals (mcpState, hooks, subagents, canvas, conversation search). Unknown/unset: `ExecClientThrow` + `streamClose` ([cursor-agent.ts L1288-1316](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1288-L1316)). OpenCodex unknown: empty `[]` (`native-exec.ts:605-609`, #116). That is the stall class senpi refused. + +OpenCodex-only: real background shell / fetch / optional computer-use when native exec is on. senpi refuses those. + +## interactionQuery + +OpenCodex answers immediately (`live-transport.ts:287-382, 1256-1269`): createPlan success; ask/switch reject; web/exa approve; setupVm + unknown empty. senpi has **no** interactionQuery branch ([cursor-agent.ts L922-946](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L922-L946), issue #1026). Do not copy senpi here. + +## Pairing / double-exec + +OpenCodex: `local_side_effect` before native exec (`live-transport.ts:1248-1252`); `completedToolCalls` for Responses mapper idempotency (`protobuf-events.ts:1052-1056`). senpi: `kCursorExecResolved` so the **agent loop** does not re-run host tools. Different layer. Only needed if OpenCodex starts synthesizing native exec as Codex-visible tool calls. + +## Transfer suspicion + +High: unknown-exec typed reply + stream-close (without enabling local fs). Medium: proto refresh to name Pi/mcpState/hook frames **as typed refusals**, not as implementations. Reject: host-tool bridge, enabling nativeLocalExec by default, copying senpi's missing interactionQuery. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md new file mode 100644 index 0000000000..91887fd0f3 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md @@ -0,0 +1,40 @@ +# 006 — Stream completion / usage / overflow / rotation + +Leibniz + Ohm. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. + +## usedTokens — ALREADY-HAVE + +Both treat checkpoint `usedTokens` as absolute conversation window, not additive output. + +OpenCodex `protobuf-events.ts:1233-1238`. senpi [cursor-agent.ts L3566-3582](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3566-L3582). OpenCodex tests lock 10000→10300 not 20300 (`tests/cursor-protobuf-events.test.ts`). + +OpenCodex cache: 200 entries / 60 minutes (`protobuf-events.ts:21-22`). Older memory said 30m/256; current code wins. + +## cacheRead — senpi-only billed split + +senpi reads billed `turnEnded` fields and drops cacheRead when `cacheRead > liveUsed * 3` ([cursor-agent.ts L3516-3547](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3516-L3547); [cursor-usage.test.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/test/cursor-usage.test.ts)). Compact threshold uses local estimate if billed > 8× and estimate ≥ 50k. + +OpenCodex generated `TurnEndedUpdate` is `{}` (`gen/agent_pb.ts:3083-3085`), so billed cacheRead cannot spike totals. Do not add billed fields without the 3×/8× guards. Live wire still emitting those int64s is **unverified** this cycle (client versions differ). + +## 0-token resource_exhausted — inverted + +OpenCodex: generic RE is 429 unless an explicit size phrase wins (`cursor-errors.ts:131-163`; `tests/cursor-errors.test.ts:15-17` expects bare `Connect error resource_exhausted: Error` → rate limit). Retry layer never retries RE (`transport-retry.ts:25`). + +senpi: 0-token RE is payload overflow for compact-before-rotate (`overflow.ts` `isCursorPayloadResourceExhausted`, [L211-222](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L211-L222)). First failure is **surfaced** so session compact can run; later ones rotate wire id ≤3 ([cursor-conversation-rotation.ts L34-46](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-conversation-rotation.ts#L34-L46), [cursor-agent.ts L789-812](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L789-L812)). Stale senpi comment still says 0-token RE is rate-limit; code does the opposite. + +OpenCodex remint is only external-model `invalid_argument` (`src/adapters/cursor.ts:231-247`). Compaction is client-driven and isolated (`request-builder.ts:397, 443-444`). Architectural bound: OpenCodex cannot copy senpi `AgentSession._runPrePromptCompaction`. Transfer is **HTTP mapping** so Codex compact can fire, plus optional remint after that, not an in-adapter compact loop. + +## turnEnded hang — senpi newer + +#1062: Cursor can leave HTTP/2 open after content is done. senpi closes the client stream on `turnEnded`. OpenCodex waits for EOF / 300s bridge stall. First-frame 30s is not a mid-turn health watchdog. + +OpenCodex-only: synthesize `done` on clean EOF after assistant text without `turnEnded` (`live-transport.ts:1147-1150`). senpi fails that case. Comment/test tension: `tests/cursor-eof-terminal.test.ts` vs hardening tests vs transport `settleFinish`. + +## ANTML / interactionQuery + +ANTML skip is senpi-only because senpi has Claude-name text-tool recovery. OpenCodex has zero ANTML hits — already-have by absence. interactionQuery is OpenCodex-only (senpi gap #1026). + +## #1043 toolResult reload + +senpi compact reloads full jsonl bodies (still open). OpenCodex truncates toolResult blobs for **external-model replay budget** 512KiB / 192 roots (`protobuf-request.ts:64-72, 140-143`), not as a post-compact native admission pass. Medium residual if native-model full replay after Codex compact still ships verbatim tool results. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md b/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md new file mode 100644 index 0000000000..fcc97cf194 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md @@ -0,0 +1,20 @@ +# 007 — CLI fallback lane + +Plato. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. PR [#921](https://github.com/code-yeongyu/senpi/pull/921). + +## What senpi added + +`cursor-cli-oauth` is a **documented fallback**, never a replacement for native `cursor` ([AGENTS.md L1-5](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/AGENTS.md#L1-L5)). + +It spawns official `cursor-agent -p --output-format stream-json --stream-partial-output --trust` ([spawn-args.ts L18-34](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/spawn-args.ts#L18-L34)). CLI tools are display-only. `--force` requires `noApprovalAcknowledgedAt` ([guardrails.ts L136-154](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/guardrails.ts#L136-L154)). Kill switch: verbatim `enabled: false` outranks stored accounts. Implicit fallback is refused while force-ack is pending (`index.ts:77-85`). File-store HOMEs, `AGENT_CLI_CREDENTIAL_STORE=file`, 130 KB prompt cap, process-group kill. senpi remains context owner for usage numbers. + +## What OpenCodex has + +Native protobuf only. OAuth comment: no dependency on a local Cursor IDE/CLI (`src/oauth/cursor.ts:1-4`). Repo `rg` has no `cursor-agent` spawn, `stream-json`, or `cursor-cli-oauth`. Native-exec kill is `nativeLocalExec` default off (`exec-policy.ts:17-45`) — different layer. + +## Transfer class + +**REJECT for OpenCodex core.** OpenCodex is a Codex/Claude proxy. Spawning Cursor's own agent CLI would fork tool execution out of Codex sandbox/approvals, add a binary dependency, and spend Cursor quota through a second harness. If a fallback is ever wanted, it is a separate opt-in product surface (NEEDS_HUMAN), not an adapter default. + +Do not confuse this with native protobuf hardening. Native-first is the senpi recommendation too. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md b/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md new file mode 100644 index 0000000000..1b4c080938 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md @@ -0,0 +1,52 @@ +# 090 — Transfer verdict + +Locked from explorer reports + local reads. senpi `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. No production code in this cycle. + +Class keys: ADOPT (port the mechanism), ADAPT (same idea, OpenCodex-shaped), REJECT (wrong product/trust model), ALREADY-HAVE, NEEDS_HUMAN (policy), UNSAFE (do not recommend). + +## Table + +| ID | Mechanism | Class | OpenCodex owner | senpi source | Residual risk | +|---|---|---|---|---|---| +| T01 | Bare 0-token `resource_exhausted` mapped as 429 | **ADAPT** | `src/adapters/cursor/cursor-errors.ts:131-163`, `src/lib/errors.ts`, `tests/cursor-errors.test.ts:15-17` | [overflow.ts L211-222](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L211-L222), issues #1009/#1036 | Must not reclassify quota RE as overflow. Codex compact must actually fire; if not, remint is a second step. | +| T02 | Surface-first then rotate conversationId | **ADAPT** | `src/adapters/cursor.ts:231-247` (today only external invalid_argument) | [cursor-conversation-rotation.ts L34-46](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-conversation-rotation.ts#L34-L46) | Do not persist unbounded maps. Cap + migrate usage cache via existing `rekey`. | +| T03 | Close HTTP/2 on `turnEnded` after exec drain | **ADOPT** | `src/adapters/cursor/live-transport.ts:1132-1154`, `protobuf-events.ts:1327-1328` | [cursor-agent.ts L249-254, L698-704](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L249-L254) PR #1062 | Must preserve Responses client-tool path that **intentionally** ends without turnEnded (`live-transport.ts:203-206`). | +| T04 | Adapter heartbeat-only stall fail (30s/90s) | **ADAPT** | `live-transport.ts:92-93` first-frame only; `src/stall-timeout.ts:8` 300s | [cursor-agent.ts L592-610](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L592-L610) | Do not fight synthetic progress heartbeats that keep the bridge alive. Scope to inbound-frame silence, not "no assistant text". | +| T05 | Unknown exec empty `[]` vs throw+close | **ADAPT** | `native-exec.ts:605-609` | [cursor-agent.ts L1288-1316](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1288-L1316) | Empty reply was the #116 stream-kill fix. Prefer typed `ExecClientThrow` + stream-close **without** re-throwing into `failAndClear`. Live stall vs empty is unverified. | +| T06 | Live `GetUsableModels.maxMode` on the wire | **ADAPT** | `live-models.ts:115-131` decode keeps ids only; `gen/agent_pb.ts:2617` is catalog `ModelDetails.maxMode`; wire field is `RequestedModel.maxMode` at `gen/agent_pb.ts:2665-2667`; hardcode `protobuf-request.ts:963-966` | [reasoning-params.ts L8-20](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20) | Product: 1M windows / quota. Needs a live probe before claiming user-visible gain. Keep static seed + auto router ids. | +| T07 | OAuth poll fail-fast 400/401/403/410 | **ADAPT** | `src/oauth/cursor.ts:121-148` | [oauth/cursor.ts L165-178](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts#L165-L178) PR #905 | Small. Keep OpenCodex refresh retry / JWT accountId. | +| T08 | Per-exec 3s heartbeat | **ADAPT** | `ExecClientHeartbeat` exists in `gen/agent_pb.ts`; stream-close bytes at `native-exec-common.ts:41-49`; no heartbeat writer in `native-exec.ts` | [exec-lifecycle.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/exec-lifecycle.ts) | Only if long native-exec stays enabled. Default native exec is off. | +| T09 | Billed turnEnded cacheRead 3× clamp | **ADAPT** (only with proto decode) | `TurnEndedUpdate` is `{}` `gen/agent_pb.ts:3083-3085` | [cursor-agent.ts L3516-3547](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3516-L3547) PR #985 | Do not add billed fields without the clamp. Live wire unverified vs OCX client version. | +| T10 | Newer exec oneofs as typed refusals | **ADAPT** | `gen/agent_pb.ts:6886+` oneof ends at `writeShellStdinArgs`; dispatcher `native-exec.ts:550-609` | [cursor-agent.ts L1655-2010](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1655-L2010) PR #910 | Proto regen is its own unit. Until then, T05 covers unknown frames. Do not implement Pi tools in the proxy. | +| T11 | Host-tool exec-bridge onto Codex tools | **REJECT** | `native-exec.ts` + `exec-policy.ts:17-44` fail-closed | [cursor-exec-bridge.ts L1-16](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/cursor-exec-bridge.ts#L1-L16) | Wrong architecture. OpenCodex already surfaces Responses tools; native fs default-off is the trust gate. | +| T12 | `cursor-agent` CLI fallback lane | **REJECT** (core) / **NEEDS_HUMAN** (optional product) | none; `src/oauth/cursor.ts:1-4` | [cursor-cli-oauth/AGENTS.md](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/AGENTS.md) PR #921 | Binary dep, `--force` spends Cursor tools outside Codex sandbox. | +| T13 | Fully dynamic catalog, drop static seed | **REJECT** | `discovery.ts:76-88` seed filter; `discovery.ts:90-104` router ids; `src/codex/catalog/provider-fetch.ts:1197` live gather | [providers/cursor.ts L10-17](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts#L10-L17) | OpenCodex needs logged-out catalog and `auto-*` router models. T06 is the live-field adapt. | +| T14 | thinkingLevelMap / 204-id grouping | **REJECT** for now | `effort-map.ts:96-108` static tiers; `request-builder.ts:187-204` suffix flatten | [catalog-grouping.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/cursor/catalog-grouping.ts) PR #948 | Codex picker already maps effort. Revisit only if live ids stop matching suffixes. | +| T15 | ANTML skip on cursor-agent | **ALREADY-HAVE** (by absence) | no ANTML in `src/` | [tool-call-middleware/index.ts L48-54](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/tool-call-middleware/index.ts#L48-L54) PR #1013 | Only if OCX later adds Claude-name text-tool recovery on Cursor models. | +| T16 | interactionQuery replies | **ALREADY-HAVE** (OpenCodex ahead) | `live-transport.ts:287-382` | missing; [#1026](https://github.com/code-yeongyu/senpi/issues/1026) | Do not copy senpi. | +| T17 | Absolute `usedTokens` cache | **ALREADY-HAVE** | `protobuf-events.ts:1233-1238` | [cursor-agent.ts L3566](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3566) | Keep. | +| T18 | Compact isolation / skip mid-run compact | **ALREADY-HAVE** (different-shape) | `request-builder.ts:397, 443-444`; `responses/core.ts:2247-2249` | [agent-session.ts L1293-1296](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/agent-session.ts#L1293-L1296) #984 | Keep OCX isolated-conversation approach. | +| T19 | HTTP/1 RunSSE fallback | **ALREADY-HAVE** (OpenCodex-only) | `http1-bidi.ts:10-11` | [cursor-agent.ts L378-381](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L378-L381) | Keep. | +| T20 | Native-app / Safe Storage patching | **UNSAFE** | n/a | n/a | Out of scope. Prior ocx-cursor probe already forbade this. | +| T21 | Unofficial Cursor protocol ToS | **NEEDS_HUMAN** | whole adapter | whole provider | Both projects already ship it. No new disclosure in this unit. | +| T22 | Copy senpi protobuf / unbounded blob maps | **REJECT** | bounded KV/checkpoint (`native-exec.ts:81-92`, `checkpoint-store.ts:30`) | [cursor-agent.ts L314](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L314); [#1024](https://github.com/code-yeongyu/senpi/issues/1024) | Keep OCX bounds. | +| T23 | Overflow compact `keepRecentTokens: 0` | **REJECT** for adapter | Codex owns compact | [overflow.ts L244-251](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L244-L251) | Only relevant if Codex compact keeps a large tail; that is a Codex-side setting, not ocx Cursor. | +| T24 | Fail EOF without `turnEnded` | **ADAPT** (careful) | `live-transport.ts:1147-1150` synthesizes done | [cursor-agent.ts L477-478](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L477-L478) | Conflicts with OCX client-tool suspend and some hardening tests. Fold into T03, do not land as a blanket fail. | + +## Recommended later implementation order + +Matches `000_plan.md` wp1–wp4: + +1. T01 error mapping (highest user-visible: overflow vs 429). +2. T03 + T04 turn-end / stream health (protocol hang). +3. T05 unknown-exec typed reply; T10 only with a dedicated proto unit. +4. T06 maxMode + T07 poll fail-fast (catalog/auth polish). + +Do not start T12. Do not start T11. + +## Residual unknowns (not blockers for this research lock) + +- Whether live `api2.cursor.sh` still emits billed `turnEnded` int64s against OCX client `cli-2026.07.08-0c04a8a`. +- Whether mapping 0-token RE to overflow/400 makes Codex auto-compact, or still needs remint (T02). +- Whether empty unknown-exec replies currently stall modern Pi frames on OCX's proto. +- Native-model toolResult size after Codex compact (#1043 analogue). diff --git a/devlog/_plan/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md b/devlog/_plan/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md new file mode 100644 index 0000000000..251529c72d --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md @@ -0,0 +1,71 @@ +# 100 — Stabilization round 2 (research + implementation loop) + +Continuation of the 090 verdict. T01/T03/T05 landed (#2320/#2321/#2322). This +round re-inventories what REMAINS transferable from senpi and +yelixir-dev/cursor-ai-proxy-bridge (and any other public Cursor bridge found +during the swarm), locks a new decade-doc roadmap (110+), then implements the +top candidates as separate cycles, each pushed to dev. + +## Inputs + +- origin/dev head at research time: `525568652` (weighted credential router, unwired). +- Selected remaining 090 verdict candidates: T02 (conversation rotation), T04 + (heartbeat stall), T06 (maxMode), T07 (OAuth poll fail-fast), T09 (cacheRead + clamp), T24 (EOF-without-turnEnded fold into T03). Unlanded ADAPT rows T08 + (per-exec heartbeat — conditional on long native-exec staying enabled; + default off, so deferred unless research contradicts) and T10 (dedicated + proto unit prerequisite) are dispositioned in 190, not silently dropped. +- New-in-dev artifacts needing follow-up regardless of senpi: #2334 + CursorCredentialRouter is dead code (only tests import it); cursorH2Pool has + no shutdown hook wiring. + +## Security / ToS boundary (binding, per AGENTS.md) + +- No pre-disclosure security material in this public devlog: if research + surfaces an unfixed weakness (in Cursor, senpi, or OpenCodex), the analysis + goes to `.tmp/` scratch and the devlog records only a neutral + "handled out-of-band" pointer once resolved. +- Excluded transfer classes regardless of source value: leaked/private + artifacts, credential extraction, auth bypass, Safe Storage / native-app + patching (090 T20 stays UNSAFE), live account mutation. +- ToS/product-policy questions (e.g. new endpoints whose use may be + policy-sensitive) are NEEDS_HUMAN, not merely "needs live probe". +- Reference clones live in gitignored scratch (`.tmp/chase/`), matching the + `devlog/_chase/` license rule: third-party source never enters this + repository's history. + +## Research lanes (Luna swarm, candidates only — main agent proves) + +1. senpi delta since a5eed44536f3 (commits/releases): new Cursor mechanisms. +2. senpi issues/PRs: open stability reports naming Cursor adapter defects. +3. yelixir-dev/cursor-ai-proxy-bridge full file inventory beyond + h2-session-pool.ts / credentials.ts. +4. Other public Cursor-protocol bridges/proxies (GitHub sweep). +5. Cursor upstream changes (client version strings, api2 endpoints, protocol + deprecations) that could break the adapter soon. +6. Local-clone deep read (main agent, .tmp/chase/senpi + + .tmp/chase/cursor-ai-proxy-bridge): git history, issues-referenced diffs, + and rationale not visible in file inventories. +7. OpenCodex's own Cursor issue/PR/test delta on GitHub since 090 lock, so + locally-reported regressions rank alongside external candidates. + +## Verification lane (sol-medium, read-only) + +Audit backlog items (a)-(f) from the goal objective against origin/dev head +with file/line evidence: wired-or-dead status of #2334, shutdown hook absence, +T04/T06/T07/T24 current state in live-transport.ts / oauth/cursor.ts / +live-models.ts. Every NEW candidate from lanes 1-7 gets the same falsification +pass against the current tree before it may enter a decade doc — no candidate +is roadmapped on snippet evidence alone. + +## Output contract + +- Decade docs 110, 120, ... — one per implementation cycle, diff-level + (target files, function names, test names, expected diff shape). +- 190_roadmap_lock.md — ranked order, rejected/deferred candidates with + reasons, NEEDS_HUMAN items (live-probe-only) explicitly marked. +- No production code in this cycle. +- Gate: implementation cycles may not start until 190 is locked (the D of + this docs-only cycle). "Pushed to dev" in the header describes those later + cycles, each separately gated by typecheck + full tests; the docs-only + cycle pushes documentation only. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/110_stream_health_watchdog.md b/devlog/_plan/260822_senpi_cursor_transfer/110_stream_health_watchdog.md new file mode 100644 index 0000000000..ef82190220 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/110_stream_health_watchdog.md @@ -0,0 +1,67 @@ +# 110 — Inbound stream-health watchdog (T04, senpi #1062 second half) + +## Why now + +OpenCodex issue #2210 reports Cursor/Grok turns dying with +`upstream_stall_timeout` after a silent stream — the 300s bridge default +(`src/stall-timeout.ts:8`) is the only guard after the first frame. senpi +PR #1062 pairs the turnEnded close (already landed as #2321) with an +inbound-frame watchdog we did NOT take: 30s of total inbound silence, or 90s +of heartbeat/checkpoint-only traffic, fails the turn instead of waiting for +the bridge. + +## Current state (verified 525568652) + +- `live-transport.ts:93` `CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000` — armed + once, cleared permanently by the FIRST raw chunk (`onData` calls + `clearFirstFrameTimer()` unconditionally, live-transport.ts:1133). +- The 5s HEARTBEAT_MS at :92 is OUTBOUND client traffic, not a detector. +- No transport-level watchdog exists after the first chunk; sol audit lane + confirmed GAP (c) with file/line refs. + +## Design (ADAPT, not copy) + +senpi resets `lastInboundFrameAt` on every decoded frame and +`lastMeaningfulFrameAt` only when the frame is not liveness-only +(heartbeat / conversationCheckpointUpdate), then arms one timer at +`min(lastInbound+30s, lastMeaningful+90s)` (cursor-agent.ts:589-673 in the +.tmp/chase clone). OpenCodex differences to respect: + +- Our decode path is `handleFrame` inside live-transport.ts, protobuf event + mapping in protobuf-events.ts; liveness classification must happen where + the AgentServerMessage case is visible, not on raw chunks — raw-chunk + resets would let TLS keepalive noise defeat the watchdog. +- Client-tool suspend (live-transport.ts:203-206) intentionally ends without + turnEnded: the watchdog must disarm when the transport is settling or a + client-tool suspend is in progress, mirroring the #2321 grace-timer guards + (expectedClose). +- Long native-exec turns emit synthetic progress; those count as inbound + frames already (they arrive as real server frames), so no special case — + 090's warning about "not fighting synthetic progress heartbeats" is + satisfied by the meaningful/liveness split. +- Timeout action: fail the turn through the SAME error path a transport + error takes today (failAndClear with a typed message naming the stall + class), so bridge mapping and tests stay uniform. + +## Diff shape + +- `src/adapters/cursor/live-transport.ts`: two constants + (`CURSOR_STREAM_SILENCE_FAIL_MS = 30_000`, + `CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS = 90_000`), fields + `lastInboundFrameAt` / `lastMeaningfulFrameAt` / `streamHealthTimer`, + arm/reset/disarm helpers; reset hooks in the decoded-frame path; disarm in + finalize/cleanup paths alongside firstFrameTimer/turnEndedCloseTimer. +- Optional input knobs on CursorTransportFactoryInput mirroring + `firstFrameTimeoutMs` for tests. +- Tests: `tests/cursor-stream-health.test.ts` — (1) silent stream after + first frame fails at ~30s (fake timers); (2) heartbeat-only stream + survives 30s but fails at 90s; (3) meaningful frames keep resetting both; + (4) client-tool suspend path never trips the watchdog; (5) turnEnded + disarms it. + +## Risks + +- False positives on genuinely slow models: thresholds are senpi-live-tested + but our traffic mix differs; keep knobs overridable and document defaults. +- Interaction with #2307 clean-terminal settle: watchdog must check the + settler state before firing (same guard the grace timer uses). diff --git a/devlog/_plan/260822_senpi_cursor_transfer/120_small_hardening_pair.md b/devlog/_plan/260822_senpi_cursor_transfer/120_small_hardening_pair.md new file mode 100644 index 0000000000..883aac9aaf --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/120_small_hardening_pair.md @@ -0,0 +1,51 @@ +# 120 — Small hardening pair: OAuth poll fail-fast (T07) + H2 pool shutdown + +Two independent, low-risk fixes small enough to share one cycle; neither +depends on 110. + +## 120a — OAuth poll fail-fast on terminal statuses (T07) + +### Current state (verified 525568652) + +`src/oauth/cursor.ts:108-149` `pollCursorAuth`: 404 = pending, 200 = done, +EVERY other status throws into the generic catch and retries until +3 consecutive errors. A denied/expired login (400/401/403/410) costs three +extra round-trips and surfaces as "Too many consecutive errors" instead of +the real reason. senpi oauth/cursor.ts L165-178 (PR #905) fails immediately +on 400/401/403/410. + +### Diff shape + +- `src/oauth/cursor.ts`: inside the status dispatch, add + `if ([400, 401, 403, 410].includes(response.status)) throw new CursorAuthTerminalError(...)` + where the error carries the status and is NOT retried by the catch block + (rethrow when `err instanceof CursorAuthTerminalError`). +- Keep 5xx/network on the existing 3-strike retry path (OpenCodex keeps its + refresh retry / JWT accountId handling — 090 T07 note). +- Tests: extend `tests/cursor-oauth.test.ts` — 401 fails on FIRST attempt + with status in message; 500 still retries 3x; 404→200 still succeeds. + +## 120b — cursorH2Pool shutdown registration + +### Current state + +`cursorH2Pool.shutdown()` (`src/adapters/cursor/h2-pool.ts:41`) has no +caller. The core-owned seam exists: `src/lib/optional-shutdown-hooks.ts:32` +registry, invoked by `src/server/lifecycle.ts:454`. Lab registers teardown +at activation (orchestrator.ts:109). The seam's hook contract must be +checked: if it is sync-only, register `() => { void cursorH2Pool.shutdown(); }` +or extend the seam if it already awaits promises (verify before coding). + +### Diff shape + +- Registration at the point the pool first activates — lazily inside + `h2-pool.ts` on first `request()` (keeps core free of adapter imports, + matching the optional-subsystem doctrine) via + `registerOptionalShutdownHook("cursor-h2-pool", ...)`. +- Also correct the pool doc comment: it claims "GetUsableModels / Run + requests" reuse, but the Run path dials its own session + (live-transport.ts:928); comment must say discovery-only until Run-path + integration is a separate, deliberate cycle (deferred — see 190). +- Tests: `tests/cursor-h2-pool.test.ts` (or extend existing) — after + registration, invoking the registered hook closes sessions (pool.size 0) + and is idempotent. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md b/devlog/_plan/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md new file mode 100644 index 0000000000..207f50978d --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md @@ -0,0 +1,72 @@ +# 190 — Round-2 roadmap lock + +Locked from: 5 Luna research lanes (senpi delta, senpi issues, yelixir +inventory, other bridges, upstream), local clones under .tmp/chase/ (senpi +@041bb5e64, cursor-ai-proxy-bridge @main), sol read-only code audit of +origin/dev 525568652, and OpenCodex open Cursor issues (#1527, #2210, #2300, +#2305). + +## Implementation order (this loop) + +1. **110 — inbound stream-health watchdog (T04)**. Directly addresses open + issue #2210 (silent stream → upstream_stall_timeout at 300s). Verified + GAP: only a first-frame timer exists (live-transport.ts:93,1133). senpi + constants live-verified in clone (cursor-agent.ts:250-252, 589-673). +2. **120 — OAuth poll fail-fast (T07) + cursorH2Pool shutdown hook**. + Verified GAPs: cursor.ts:117-147 retries terminal 4xx thrice; + h2-pool.ts:41 shutdown() has no caller; hook seam is sync-only + (optional-shutdown-hooks.ts:23) so the registration wraps the async + shutdown in a void fire-and-forget. + +## Deferred / rejected this round (with reasons) + +- **#2334 CursorCredentialRouter wiring — NEEDS_HUMAN.** Natural seam is the + OAuth snapshot-selection boundary (oauth/index.ts:463 → + responses/core.ts:2615), but wiring weighted rotation there overrides the + user's explicit activeAccountId choice. That is a product decision + (multi-account rotation semantics), not a stabilization patch. Until + decided, the module stays test-covered but unwired; its doc comment + already says "complements" rather than "replaces". +- **H2 pool Run-path integration — deferred.** Run streams are long-lived + bidi; pooling them changes lifecycle/EOF semantics that #2307/#2321 just + stabilized. Discovery-only stays. 120b fixes the overclaiming comment. +- **T02 conversation rotation — deferred.** senpi #998 persists rotated ids + under its own agent dir; OpenCodex equivalent needs checkpoint-store + migration via existing rekey and evidence that Codex compact does not + already recover (090 residual unknown still unresolved; #1527 may be this + class — needs a live reproduction first). +- **T06 maxMode — deferred (live probe).** GAP confirmed (hardcoded false, + protobuf-request.ts:970; discovery drops ModelDetails.maxMode, + live-models.ts:116), but 090 requires a live probe to show user-visible + gain and billing semantics before flipping a wire flag. +- **T08 per-exec heartbeat — deferred.** Long native exec remains + default-off; senpi's 3s ExecClientHeartbeat only matters with it enabled. +- **T09 cacheRead clamp — deferred.** Needs live billed turnEnded int64 + evidence (090 residual unknown). +- **T10 protobuf regen — deferred.** Requires a dedicated proto unit per + 090; touching gen/ ad hoc is not stabilization. +- **senpi #1020 suffix-alias — NOOP for OpenCodex.** Our effort-map already + flattens suffix variants (090 T14 kept static tiers; request-builder + suffix flatten at :187-204 on the audited head). +- **senpi #1016 stop-with-pending-tools, #1002 exec run ownership — out of + adapter scope here.** Both live in senpi's agent loop; OpenCodex's + analogues are the bridge/Responses layer. Issue #2305 (tool-call-like + text to Pi on client-tool continuation) is the closest local symptom and + deserves its own unit with a reproduction, not a blind port. +- **yelixir retry.ts / auto-runtime failover — partially rejected.** The + transport-code retry table overlaps cursor-errors.ts mapping already + landed (T01). The API→CLI backend failover is a product architecture + OpenCodex does not have (no CLI backend); single useful residue is the + non-retryable Cursor errorType detail sniffing, folded as a candidate + into a future cursor-errors extension if live reports justify it. +- **cursor/sdk-bridge (official SDK) — tracked, not actioned.** A future + migration study unit; policy-sensitive surface questions are NEEDS_HUMAN + per the 100 boundary. +- **api2direct host migration reports — watch only.** Forum-level evidence, + no reproducible breakage against our pinned client version yet. + +## Gate + +This lock is the D of the docs-only cycle. Implementation cycles 110 → 120 +follow, one decade doc per PABCD cycle, each gated by focused tests + +typecheck + full suite before its dev push. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/200_round3_probe_plan.md b/devlog/_plan/260822_senpi_cursor_transfer/200_round3_probe_plan.md new file mode 100644 index 0000000000..4b896ecf6c --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/200_round3_probe_plan.md @@ -0,0 +1,58 @@ +# 200 — Round-3 live-probe plan + +Round 2 (100-190) landed T04/T07/shutdown. The 190 lock deferred five rows +for lack of live evidence (T06 maxMode, T02 rotation, #2305 external +continuation, client version watch, T09 cacheRead clamp); T08/T10 stay +deferred for non-live reasons and #2334 stays NEEDS_HUMAN by design. A live +Cursor account now exists on the probe host (macmini, ocx preview), so this +cycle buys the evidence. + +## Probes + +1. **P-1 maxMode (T06).** Dump GetUsableModels with full ModelDetails — + which models report maxMode=true, and what contextTokenLimit pairs with + it. Compare a Run with RequestedModel.maxMode=true vs false on one + maxMode-capable model: does the server accept it, and does the reported + context window / usage change? Wire flag only lands if this shows a real + user-visible gain. +2. **P-2 rotation (T02 / #1527 suspect).** Drive a conversation toward the + bare 0-token resource_exhausted shape (large-context turns on a pinned + conversationId). If the server pins the rejection to the conversationId + (fresh id succeeds with identical payload), T02 rotation is justified; + implement bounded rotation + checkpoint rekey. If not reproducible within + quota bounds, record and keep deferred. +3. **P-3 issue #2305.** Reproduce the client-tool continuation returning + tool-call-like assistant text to Pi: drive a client-tool turn through the + external continuation path and capture what text frames come back. + Root-cause lives in rootPromptMessages / userMessageAction continuation + (the a69d291fb fix covered native Auto; #2305 is the external path). +4. **P-4 client version.** GetUsableModels + one Run with the current pinned + cli-2026.07.08-0c04a8a vs a newer senpi-observed string + (cli-2026.07.23-e383d2b): any catalog or behavior delta? Bump only if + probe shows the new string is accepted and changes nothing adverse. +5. **P-5 billed usage / cacheRead (T09).** Capture the billed turnEnded + usage int64s (inputTokens / outputTokens / cacheRead*) from the SAME live + Runs P-1 and P-4 already make (no extra quota): decode and record whether + cacheRead exceeds 3x input the way senpi's clamp assumes, and whether our + protobuf-events usage mapping already reports these fields sanely. Verdict + IMPLEMENT (clamp justified) / NOOP (values sane, clamp unnecessary) / + BLOCKED (fields absent on this plan tier). + +## Probe hygiene (binding, extends doc 100 boundary) + +- All transcripts REDACTED before entering devlog: no bearer tokens, no + account ids, no email, no checksum headers. Raw dumps stay in .tmp/ on the + probe host and are deleted after the docs lock. +- Quota respect: P-2 large-context attempts are capped (<= 5 runs); if the + account rate-limits, stop and record BLOCKED for that probe. +- No Safe Storage access, no client patching, no endpoints beyond what the + adapter already ships (Run, GetUsableModels, RunSSE fallback). + +## Outputs + +- 210_maxmode.md, 220_rotation.md, 230_issue2305.md, 240_client_version.md — + each with verdict IMPLEMENT / NOOP / BLOCKED / NEEDS_HUMAN and, for + IMPLEMENT, diff-level shape. +- 250_billed_usage.md — P-5 verdict for T09 (same contract). +- 290_round3_lock.md — ranked implementation order + updated senpi + superiority verdict. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md b/devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md new file mode 100644 index 0000000000..0288e6b34e --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md @@ -0,0 +1,37 @@ +# 210 — P-1 maxMode probe (T06) + +## Evidence (macmini live, 2026-08-22, redacted) + +- GetUsableModels decoded: 204 entries; ModelDetails keys = + modelId, displayModelId, displayName, displayNameShort, aliases, maxMode. +- maxMode=true on exactly 28 ids — ALL of them opus "-fast" variants + (claude-opus-5-*-fast, claude-opus-4-8-*-fast, claude-opus-4-7-*-fast). + No contextTokenLimit field is present in this response shape. +- Run A/B on claude-opus-4-7-low-fast, tiny prompt: + - RequestedModel.maxMode=false -> bare Connect resource_exhausted. + - RequestedModel.maxMode=true -> same bare resource_exhausted. + The server ACCEPTED the flag both ways (no invalid_argument); the model is + plan-gated for this account regardless. + +## Verdict: BLOCKED (plan tier) — WITHDRAWN by re-probe (see below) + +Original interpretation: the account cannot run -fast at all. Wire flag +stayed hardcoded false pending an entitled account. + +## Correction (same-day re-probe, supersedes the interpretation above) + +A follow-up probe with a different tier disproved the entitlement story: +- claude-opus-4-8-high-fast -> SUCCESS ("FP-OK"); BOTH maxMode arms succeed. +- claude-opus-4-7-low-fast -> bare resource_exhausted persists + (tier-specific; cause unknown — not account-wide). +- claude-opus-4-7-fast (bare) -> not_found (wire has only suffixed forms). +The original probe sampled ONLY 4-7-low-fast and over-generalized. -fast IS +callable on this account; maxMode therefore IS provable — the deciding +probe is 310 (big-context A/B). Catalog repair: 300. + +## Side finding (feeds 260) + +A TINY prompt on a plan-gated model returns the same bare 0-token +resource_exhausted shape that #2320 (T01) now classifies as CONTEXT OVERFLOW. +Live proof that bare RE != always overflow: entitlement rejections share the +shape. See 260_re_classification_refinement.md. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/220_rotation.md b/devlog/_plan/260822_senpi_cursor_transfer/220_rotation.md new file mode 100644 index 0000000000..d6f573bf34 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/220_rotation.md @@ -0,0 +1,15 @@ +# 220 — P-2 rotation probe (T02 / #1527 suspect) + +## Evidence + +4 consecutive ~101K-token turns on one pinned conversationId +(composer-2.5-fast) all completed (OK1..OK4, usage.totalTokens ~101,111-147). +No 0-token resource_exhausted, no conversation poisoning within the capped +attempt budget (probe cap <= 5 runs, quota hygiene doc 200). + +## Verdict: NOT REPRODUCED — T02 stays deferred + +The senpi #998 pathology (server pinning a rejection to a conversationId) did +not manifest at this size on this plan. #1527 remains open without a local +reproduction; rotation-with-persistence stays deferred until a live +reproduction exists. No implementation this round. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/230_issue2305.md b/devlog/_plan/260822_senpi_cursor_transfer/230_issue2305.md new file mode 100644 index 0000000000..62f0aadce3 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/230_issue2305.md @@ -0,0 +1,27 @@ +# 230 — P-3 issue #2305: display-alias leak in assistant text + +## Root cause (code-grounded, ox-alpha lane + main-agent verification) + +OpenCodex has NO text-mode tool-call parser; assistant text passes through +verbatim: protobuf-events.ts textDelta (~:1245) -> message-mapper.ts -> +bridge.ts text_delta -> chat-completions client. Pi parses +"[TOOL_CALL]name[ARGS]{...}" text itself, so when a Cursor model emits the +textual pseudo-frame with the DISPLAY name (mcp_opencodex-responses_grep), +Pi sees an undeclared tool and the turn dies. Real tool-call FRAMES are +already normalized via mcpWireNameFromArgs -> normalizeCursorWireName +(protobuf-events.ts:278-281); text deltas bypass that. + +## Verdict: IMPLEMENT + +## Diff shape + +- protobuf-events.ts textDelta case: scrub via marker-scoped regex + \[TOOL_CALL\](mcp_opencodex-responses_[^\[\]]+)\[ARGS\] -> + normalizeCursorWireName inside markers only. Prose mentions stay untouched; + scope-guarded to the exact OCX_RESPONSES_TOOL_PROVIDER prefix. +- Streaming caveat: a marker can straddle two deltas. Start WITHOUT tail + buffering; add only if live traces show split markers (recorded risk). +- Tests: tests/cursor-protobuf-events.test.ts — marker normalized, prose + untouched, real frames unaffected. +- Precedent: a69d291fb (request-side [Tool Result] envelope strip) — same + failure family, response-side analogue. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/240_client_version.md b/devlog/_plan/260822_senpi_cursor_transfer/240_client_version.md new file mode 100644 index 0000000000..45b304c659 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/240_client_version.md @@ -0,0 +1,14 @@ +# 240 — P-4 client version probe + +## Evidence + +GetUsableModels accepted all three version strings with byte-identical +catalogs (204 entries): cli-2026.07.08-0c04a8a (ours), cli-2026.07.23-e383d2b +(senpi), cli-2026.02.13-41ac335 (our discovery pin). Live Run on the 07.08 +pin works (P-5 turns completed). + +## Verdict: NOOP (no forced bump) + +No behavioral delta proven. Optional freshness bump to 07.23 is safe by this +probe but buys nothing measurable; keep the pin, keep the drift watch from +190 (api2direct reports). diff --git a/devlog/_plan/260822_senpi_cursor_transfer/250_billed_usage.md b/devlog/_plan/260822_senpi_cursor_transfer/250_billed_usage.md new file mode 100644 index 0000000000..06a59f8c8e --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/250_billed_usage.md @@ -0,0 +1,13 @@ +# 250 — P-5 billed usage / cacheRead (T09) + +## Evidence + +Two live proxy turns (composer-2.5-fast) report sane Responses usage: +input_tokens 11085/11162, output 11/10, cached_tokens 0, no inflation, no +cacheRead > 3x input pathology. Transport-level runs report estimated usage +consistently (~101K totals on the big turns, matching payload size). + +## Verdict: NOOP for the clamp + +No evidence of senpi's billed-int64 pathology on this plan tier. T09 clamp +stays unimplemented; revisit only if live usage reports regress. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/260_re_classification_refinement.md b/devlog/_plan/260822_senpi_cursor_transfer/260_re_classification_refinement.md new file mode 100644 index 0000000000..a32bf650a3 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/260_re_classification_refinement.md @@ -0,0 +1,34 @@ +# 260 — bare resource_exhausted refinement (T01 follow-up, live-evidenced) + +## Problem + +#2320 classifies a bare 0-token resource_exhausted (no quota cue, no size +phrase) as CONTEXT OVERFLOW -> 400-class so Codex compacts. Live probe 210 +found a counterexample: a ~20-token prompt on claude-opus-4-7-low-fast +returns the SAME bare shape. (Re-probe note: the cause of that RE is +tier-specific and unknown — the entitlement story was withdrawn — but the +evidence stands as-is: NON-OVERFLOW rejections share the bare shape, so the +shape alone cannot justify compaction.) Misclassifying a tiny turn as +overflow makes Codex compact it — wrong remedy, and the retry can never +succeed. + +## Design + +Classification needs a size prior: only classify bare RE as overflow when the +REQUEST was plausibly large relative to the model's context window; small +requests keep the 429-class quota/entitlement mapping. The adapter already +computes an input-token estimate (prepareCursorRunRequest +estimateInputTokens; estimateTokens lib). Shape: + +- cursor-errors.ts: classifyCursorError gains an optional context + { estimatedInputTokens?, contextWindow? }. +- live-transport/adapter passes the estimate it already has for the turn. +- Rule: bare RE + estimate >= OVERFLOW_MIN_FRACTION (0.5) * contextWindow -> + overflow (current behavior); otherwise -> existing rate-limit mapping. + Unknown estimate/window -> keep current overflow mapping (fail toward + compaction, today's behavior) so the refinement only ever REDUCES + false overflows it can prove. +- Tests: tests/cursor-errors.test.ts — tiny-estimate bare RE -> 429 class; + large-estimate -> overflow; no-estimate -> overflow (unchanged). + +## Verdict: IMPLEMENT (beyond-senpi refinement; senpi T01 shares this bug) diff --git a/devlog/_plan/260822_senpi_cursor_transfer/290_round3_lock.md b/devlog/_plan/260822_senpi_cursor_transfer/290_round3_lock.md new file mode 100644 index 0000000000..1f59d7cb50 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/290_round3_lock.md @@ -0,0 +1,50 @@ +# 290 — Round-3 lock + +Probes executed on macmini (live account, redacted transcripts in 210-250; +raw dumps deleted from probe host after lock per 200 hygiene). + +## Implementation order (this loop) + +1. **230 — #2305 text-marker normalization** (IMPLEMENT; clear defect, open + issue, code-grounded fix point). +2. **260 — bare-RE size prior** (IMPLEMENT; live-evidenced false-overflow + class; a refinement senpi's own T01 lacks). + +## Closed by probe (no code) + +- 210 maxMode: BLOCKED (plan tier) — flag accepted but -fast entitlement + absent; NEEDS_HUMAN to provision a -fast-capable account for re-probe. +- 220 rotation: NOT REPRODUCED at 4x101K; T02 stays deferred. +- 240 client version: NOOP — three version strings byte-identical catalogs. +- 250 billed usage: NOOP — no cacheRead pathology on this plan. + +## Updated senpi verdict + +With 230+260 landed, remaining senpi-ahead rows shrink to: rotation +persistence (unreproducible here), maxMode (plan-gated for both projects +without entitlement), agent-loop-level stop/exec ownership (out of adapter +scope; #2305's actual defect is ours to fix and is fixed). OpenCodex keeps +its unique-side advantages (interactionQuery, HTTP/1 fallback, SelectedImage +vision, bounded memory, T04 watchdog with senpi-matching thresholds, typed +exec errors, EOF fail-closed tests). Verdict: at parity or ahead on every +row that is provable on this plan tier; the two rows senpi still leads +require entitlement or a reproduction neither project can show today. + +## Post-landing status (locked after implementation) + +- 230 landed: PR #2341 (896cb5720), closes #2305 — 4 regression tests. +- 260 landed: PR #2342 (8f3ac5fe9) — size prior with 5 regression tests; + strictly narrowing (unknown context keeps the #2320 overflow mapping). +- Final gate: Cross-platform CI on the resulting dev head (see PR checks); + the verdict above stands as written — no remaining provable senpi-ahead + row on this plan tier. + +## Amendment (re-probe reopens the maxMode row) + +The 210 entitlement interpretation was withdrawn by a same-day re-probe +(claude-opus-4-8-high-fast works; only 4-7-low-fast RE persists). maxMode is +therefore PROVABLE on this plan tier: the parity claim's "unprovable" basis +for that row no longer holds, and the row is reopened pending 310 (big- +context A/B, billing approved). The 300 catalog repair also supersedes the +"no remaining provable row" phrasing: the static catalog itself under- +exposed working -fast families, which is our defect, now roadmapped. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/300_opus_fast_catalog.md b/devlog/_plan/260822_senpi_cursor_transfer/300_opus_fast_catalog.md new file mode 100644 index 0000000000..70a5dcc0e8 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/300_opus_fast_catalog.md @@ -0,0 +1,45 @@ +# 300 — opus-fast catalog repair (from live re-probe) + +## Corrected evidence (supersedes 210's entitlement interpretation) + +Live wire probes (this session, macmini): +- claude-opus-4-8-high-fast -> SUCCESS ("FP-OK"); both maxMode arms succeed. +- claude-opus-4-7-low-fast -> bare resource_exhausted (tier-specific; cause + unknown — NOT account-wide entitlement). +- claude-opus-4-7-fast (bare) -> not_found: the wire only has suffixed forms. +- Proxy-side cursor/claude-opus-4-7-fast -> not_found today because the + static catalog sends the bare id (discovery.ts:239-240 "tiers unverified"). + +GetUsableModels dump (204 entries) lists the -fast families as +{base}-{effort}-fast, matching effort-map.ts:130-131's existing suffix rule. +maxMode=true rides exactly these 28 opus -fast ids. + +## Diff shape + +- src/adapters/cursor/discovery.ts CURSOR_STATIC_MODELS: + - claude-opus-4-7-fast: add supportsReasoningEffort: true (tiers now + live-verified); keep CONTEXT_200K. + - add claude-opus-4-8-fast and claude-opus-5-fast entries + (supportsReasoningEffort: true, CONTEXT_200K) so the routed catalog + exposes the working families. +- src/adapters/cursor/effort-map.ts CURSOR_EFFORT_TIERS: + - "claude-opus-4-7-fast": from dump: low/medium/high (+ thinking variants + are separate wire ids — out of scope; only non-thinking tiers). + - "claude-opus-4-8-fast": low/medium/high/xhigh/max per dump. + - "claude-opus-5-fast": tiers per dump (verify exact list from the + transcript at implementation P). + - The -fast suffix rule at :130-131 already produces + {base-without-fast}-{effort}-fast — verify it yields e.g. + claude-opus-4-8-high-fast (it did live). +- CURSOR_NO_VISION_MODELS: opus families are Claude-hosted (vision-capable); + no curation change. +- Tests: tests/cursor-static-catalog.test.ts + effort-map tests — pin the + new ids, tier ladders, and wire-id derivation for one example per family. +- Live smoke after merge: macmini proxy turn on cursor/claude-opus-4-8-fast + (effort high) expecting text output. + +## Risk + +4-7-low-fast RE stays unexplained; the catalog change only ADDS working +families and upgrades 4-7-fast from bare (broken) to suffixed. Worst case a +tier 404s -> same not_found class as today, no regression. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/310_maxmode_bigctx.md b/devlog/_plan/260822_senpi_cursor_transfer/310_maxmode_bigctx.md new file mode 100644 index 0000000000..298d6855f5 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/310_maxmode_bigctx.md @@ -0,0 +1,60 @@ +# 310 — big-context maxMode A/B (billing approved) + +## Question + +Does RequestedModel.maxMode=true actually EXTEND usable context on a +maxMode-capable model (claude-opus-4-8-high-fast, static window 200K)? +Small-turn A/B showed the server accepts both values with no delta; the +decisive test is a payload ABOVE the normal window. + +## Design (2 runs, billing approved by user) + +- Payload: ~230K tokens of filler text + a needle question (verify the + needle to prove the context was actually consumed, not truncated). +- Run A: maxMode=false -> expect bare RE (overflow) or truncation. +- Run B: maxMode=true -> if it completes AND answers the needle, maxMode + extends context: IMPLEMENT propagation (discovery retains maxMode per + model; protobuf-request sets RequestedModel.maxMode for capable ids; + registry context window bump gated on the flag). +- If B fails identically: NOOP — flag is cosmetic on this plan; record and + keep hardcoded false. + +## Hygiene + +Transcripts redacted; raw dumps in probe-host scratch, deleted after +verdict. Cost cap: exactly 2 runs (~460K input tokens total). Abort rule: +if run A errors before body completes upload, do not burn run B; record +BLOCKED-transport. + +## Executed results (260822, claude-opus-4-8-high-fast) + +Round 1 (single ~230K-token message): BOTH arms failed identically with +Connect invalid_argument (~16-19s in). Not overflow, not maxMode: a +PER-MESSAGE BYTE CAP. + +Round 2 (cap bisection + multi-message): +- single ~150K tokens (~1.06MB) -> invalid_argument. +- single ~120K tokens (~850KB) -> SUCCESS, needle answered + ("TANGERINE-4471"). Cap sits between ~0.85MB and ~1.06MB — consistent + with a 1 MiB UserMessage blob limit. +- multi-message history summing well past the window, needle in EARLY + history: model answers "no launch code" on BOTH maxMode arms — server + keeps recent context and drops old history; maxMode does not change + retention. + +## Verdict: NOOP for maxMode propagation + +maxMode=true produced no behavioral difference in any shape (small turn, +oversize single message, over-window history). The flag stays hardcoded +false. Re-open only if Cursor documents maxMode semantics or a Max-mode +plan shows different retention. + +## Side findings (feed the readiness audit) + +1. Single messages over ~1MiB fail as invalid_argument. The adapter's + invalid_argument handling includes a fresh-conversation replay fallback — + an oversized message could burn a pointless replay. P2: consider a + pre-flight size guard with a clear client error before the wire call. +2. Over-window history is silently truncated server-side (old turns + dropped). Matches the checkpoint/context-usage design assumption; no + action. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 59381edd60..40ee8021f1 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -115,7 +115,7 @@ ocx logout | Fournisseur | Adaptateur | URL de base | Remarques | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | Catalogue Grok découvert en direct en priorité ; `grok-4.5` est le modèle de repli par défaut. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth utilise la passerelle d'abonnement Grok CLI distincte. Le remplacement par clé API utilise `https://api.x.ai/v1` et peut injecter Priority Processing. Catalogue Grok découvert en direct en priorité ; `grok-4.5` est le modèle de repli par défaut. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Modèles Claude ; liste des modèles récupérée en direct depuis `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Modèles de programmation Kimi K2.7/K2.6/K2.5. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Passerelle d'abonnement Nous Research (le même service en amont que celui utilisé par Hermes Agent). Connexion par autorisation d'appareil auprès de `portal.nousresearch.com` ; le jeton d'accès est le JWT d'inférence envoyé avec chaque requête. Le catalogue mixte de modèles payants et `:free` (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) est découvert en direct pour le compte connecté. Les jetons d'actualisation sont à usage unique et renouvelés à chaque actualisation. | diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index f677871a28..c63d3065a6 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -146,15 +146,16 @@ Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit rec ## Service d’arrière-plan -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Exécute opencodex comme service d’arrière-plan géré à l’ouverture de session — **launchd** sous macOS, **unité utilisateur systemd** sous Linux et **Task Scheduler** sous Windows — qui démarre automatiquement à la connexion et redémarre après un plantage. Les services définissent `OCX_SERVICE=1` afin qu’un redémarrage ne réécrive pas inutilement la configuration Codex. | Sous-commande | Action | | --- | --- | -| aucune | Crée ou met à jour le service, puis le démarre. | +| aucune | Installe et démarre le service s’il est absent ; sinon, actualise et redémarre le service existant sans le réenregistrer. | | `install` | Crée et démarre le service. L’enregistrement exige une élévation sous Windows. | | `repair` | Actualise sur place un service installé et le redémarre, sans le réenregistrer. | +| `restart` | Alias de `repair`. | | `start` | Démarre un service installé. | | `stop` | Arrête le service et rétablit le fonctionnement natif de Codex. | | `status` | Affiche les diagnostics du service et du proxy, ainsi que les chemins des journaux. | @@ -165,10 +166,13 @@ Exécute opencodex comme service d’arrière-plan géré à l’ouverture de se ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +Sous Windows, un `ocx service` nu n'exécute le chemin d'installation qu'après avoir prouvé l'absence à la fois du Task Scheduler et de WinSW. Si l'une des requêtes de statut est inconcluante, il refuse d'enregistrer quoi que ce soit et demande d'exécuter `ocx service status` ; n'utilisez un `ocx service install` explicite qu'après avoir confirmé l'absence. + Avant de signaler une réussite, `install`, `start` et `repair` vérifient, sur les trois plateformes, qu’un proxy répond effectivement sur le port inscrit dans le service installé. Elles attendent jusqu’à 20 secondes, puis affichent le port utilisé : ```text diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 1485d49b2c..80e1c152dd 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -203,6 +203,31 @@ Routed catalog entries also get their GPT-5 identity rewritten to the real upstr Reasoning controls come from provider/model metadata across Codex's `low | medium | high | xhigh | max | ultra` ladder; unsupported values are mapped or clamped before the upstream request. +### Coordinator diagnosis and recovery + +Native config/history writes use a per-user SQLite coordinator keyed by the canonical `CODEX_HOME`. +If a process terminates in SQLite's initial creation window, a zero-byte coordinator can remain even +though it contains no authoritative transition row. `ocx doctor` reports the exact coordinator path +and distinguishes zero-byte, unversioned, rowless, valid, unsafe, and unreadable states without +creating SQLite sidecars. Automatic sync tolerates only an identity-stable zero-byte file that has +settled for at least one second and whose immutable SQLite snapshot has version zero with no tables; +a newly created zero-byte file remains on the locked coordinator path. + +For a state that doctor proves is a zero-byte creation remnant, stop the OpenCodex proxy/service +and run: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +Recovery moves the still-identical zero-byte file to a same-directory `.zero-byte-backup-*` path; +it does not delete the evidence or adopt legacy routed state. It refuses a running proxy, lock +contention, symlinks/reparse points, foreign ownership, changed files, every non-empty database, +and any coordinator that already has an authoritative row. Desktop renderer filtering is a +separate layer: a correct catalog and coordinator do not by themselves bypass the Codex App model +allowlist. + ### Routed local tools Non-native routed catalog rows use `tool_mode: "code_mode_only"`. This lets Codex expose its official diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0878773ae9..e58af8360c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -110,7 +110,7 @@ ocx logout | Provider | Adapter | Base URL | Notes | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | Live-first Grok catalog; `grok-4.5` is the fallback default. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth uses the separate Grok CLI subscription gateway. The API-key override uses `https://api.x.ai/v1` and may inject Priority Processing. Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 81a19e5e5b..f99bf35aec 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -105,7 +105,7 @@ ocx logout | プロバイダー | アダプター | ベース URL | 備考 | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth は独立した Grok CLI サブスクリプションゲートウェイを使用します。API キーのオーバーライドは `https://api.x.ai/v1` を使用し、Priority Processing を注入する場合があります。ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude モデル; ライブモデル一覧は `/v1/models` から取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research サブスクリプションゲートウェイ(Hermes Agent と同じバックエンド)。`portal.nousresearch.com` へのデバイスグラントログイン; access トークンはリクエストごとの inference JWT。有料 + `:free` モデルの混在カタログ(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` など)はサインイン中のアカウントからライブ探索されます。Refresh トークンは単回使用で、更新のたびにローテーションされます。 | diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 1f8c593e91..0ee91c6351 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -150,15 +150,16 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 ## バックグラウンドサービス -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` opencodex を、ログイン時に自動起動し、クラッシュ時に自動再起動するログイン管理バックグラウンド サービス (macOS **launchd**、Linux **systemd ユーザー ユニット**、Windows **タスク スケジューラ**) として実行します。サービスは `OCX_SERVICE=1` を設定して実行されるため、再起動によって Codex 設定が変更されることはありません。 |サブコマンド |アクション | | --- | --- | -|なし |サービスを作成/更新して開始します。 | +|なし |未インストールなら作成して開始し、既存なら再登録せずに更新して再起動します。 | | `install` |サービスを作成して開始します。 | | `repair` | 既存のサービスを再登録せずに更新して再起動します。 | +| `restart` | `repair` の別名です。 | | `start` |インストールされているサービスを開始します。 | | `stop` |サービスを停止し、ネイティブ Codex を復元します。 | | `status` |サービスとプロキシの診断とログ パスをレポートします。 | @@ -169,10 +170,13 @@ opencodex を、ログイン時に自動起動し、クラッシュ時に自動 ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +Windows では、bare `ocx service` は、タスク スケジューラと WinSW の両方について不在が確認された後にのみ、インストール パスを実行します。どちらかのステータス照会が不確実な場合、何も登録せず、`ocx service status` の実行を案内します。不在を確認した後にのみ、明示的な `ocx service install` を使用してください。 + Windows では、`ocx service status` は、ID 検証済みの OpenCodex プロキシの到達可能性とは別に、タスク スケジューラの登録を報告します。ローカライズされた `schtasks` テーブルは出力されないため、概要は Windows コード ページ間で読み取れるままです。 Windows では、タスク スケジューラ エントリを作成するには昇格が必要です。認識されたローカライズされたアクセス拒否テキストは、既存のガイダンス パスを維持します。そのテキストが判読できない場合、フォールバックには、所有されているコマンド形状 `/create /tn opencodex-proxy /xml /f`、ステータス 1、および確認済みの非昇格トークンが必要です。ダッシュボードのスタートアップ セーフティ アクションは、UAC を自動的に要求できるようになります。そのフォールバックがトークンの状態を判断できない場合、元のスケジューラ エラーが保持されます。外部タスクおよび操作は、自動昇格マーカーを発行することはできません。ダッシュボードの UAC プロンプトを承認するか、管理者特権の PowerShell ウィンドウで `ocx service install` を再実行します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 4f57dab7cc..b24e17f3fc 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -104,7 +104,7 @@ ocx logout | 프로바이더 | 어댑터 | 베이스 URL | 비고 | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth는 별도의 Grok CLI 구독 게이트웨이를 사용합니다. API 키 오버라이드는 `https://api.x.ai/v1`을 사용하며 Priority Processing을 주입할 수 있습니다. 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 모델; 실시간 모델 목록은 `/v1/models`에서 가져옵니다. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 구독 게이트웨이(Hermes Agent와 동일한 백엔드). `portal.nousresearch.com`에 대한 디바이스 그랜트 로그인; access 토큰은 요청별 inference JWT. 유료 + `:free` 모델 혼합 카탈로그(`tencent/hy3:free`, `stepfun/step-3.7-flash:free` 등)는 로그인한 계정에서 실시간으로 발견됩니다. Refresh 토큰은 단회 사용이며, 갱신할 때마다 회전됩니다. | diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 14d8db2cf0..1444cdb2be 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -193,7 +193,7 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ## 백그라운드 서비스 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 로그인 관리형 백그라운드 서비스로 opencodex를 실행합니다(macOS **launchd**, Linux **systemd** 사용자 유닛, Windows **Task Scheduler**). 로그인 시 자동 시작하고 충돌 시 자동 재시작합니다. 서비스 실행은 @@ -201,9 +201,10 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 | 하위 명령 | 동작 | | --- | --- | -| 없음 | 서비스를 생성/업데이트하고 시작합니다. | +| 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 재등록하지 않고 새로 고쳐 재시작합니다. | | `install` | 서비스를 생성하고 시작합니다. | | `repair` | 설치된 서비스를 다시 등록하지 않고 제자리에서 새로 고친 뒤 재시작합니다. | +| `restart` | `repair`의 별칭입니다. | | `start` | 설치된 서비스를 시작합니다. | | `stop` | 서비스를 중지하고 기본 Codex를 복원합니다. | | `status` | 서비스와 프록시 진단, 로그 경로를 보고합니다. | @@ -214,10 +215,15 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +Windows에서는 bare `ocx service`가 Task Scheduler와 WinSW 양쪽 모두 부재가 입증된 후에만 설치 +경로를 실행합니다. 상태 조회 중 하나라도 불확실하면 아무것도 등록하지 않고 `ocx service status` +실행을 안내합니다. 부재를 확인한 뒤에만 명시적인 `ocx service install`을 사용하세요. + Windows에서는 `ocx service status`가 Task Scheduler 등록 상태를 ID가 검증된 OpenCodex 프록시 도달 가능성과 별도로 보고합니다. 로컬라이즈된 `schtasks` 표는 출력하지 않으므로, 요약은 Windows 코드 페이지에서도 읽기 쉽습니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 486d10321e..bcc3a340ff 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -162,6 +162,23 @@ unreachable; and 64 for invalid arguments. ### `ocx doctor` +The default report includes the native-write coordinator state and exact path using immutable +read-only SQLite inspection. Zero-byte, empty-unversioned, and rowless states are shown separately +from catalog/app-server health, so a successful catalog refresh is not mistaken for successful +Codex config injection. + +After stopping the OpenCodex proxy/service, explicitly preserve and move a proven non-authoritative +coordinator, then retry sync: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +The recovery accepts only a proven zero-byte remnant. It refuses every non-empty, valid, unknown, +changed, unsafe, or busy database and creates a same-directory `.zero-byte-backup-*` file instead +of deleting anything. + Run read-only environment and connectivity diagnostics: state paths and filesystem type, WSL dual installs, proxy environment/config, ChatGPT reachability, Codex plugin and project-config warnings, and pending history migration. The Codex app-home targeting section also detects the narrow Windows @@ -198,7 +215,7 @@ same stale-`app-server` warning and optional `--restart-codex` behavior as `ocx ## Background service -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Run opencodex as a login-managed background service (macOS **launchd**, Linux **systemd user unit**, Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash. Service runs set @@ -211,19 +228,25 @@ run `ocx service repair` to refresh the task with the restored package paths. | Subcommand | Action | | --- | --- | -| none | Create/update and start the service. | +| none | Install and start when absent; otherwise refresh and restart the existing service without re-registering it. | | `install` | Create and start the service. Registers it, which on Windows needs elevation. | | `repair` | Refresh an installed service in place and restart it, without re-registering it. | +| `restart` | Alias of `repair`. | | `start` | Start an installed service. | | `stop` | Stop the service and restore native Codex. | | `status` | Report service and proxy diagnostics plus log paths. | | `uninstall` | Remove the service and restore native Codex. | | `remove` | Alias of `uninstall`. | +On Windows, a bare `ocx service` runs the install path only after both Task Scheduler and WinSW are +proven absent. If either status query is inconclusive, it refuses to register anything and asks you +to run `ocx service status`; use explicit `ocx service install` only after confirming absence. + ```bash ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 842d9a6efa..c44b628714 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -87,7 +87,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | -| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → jawcode catalog → expected-price overlay → model-level vendor fallback, and an all-zero entry falls through to the next source in that sequence. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | +| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → exact official correction → jawcode catalog → expected-price overlay → model-level vendor fallback, and an all-zero entry falls through to the next source in that sequence. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | | `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | @@ -152,6 +152,25 @@ contract; existing configurations see these migration deltas: Explicit capability `false` and Responses caller-tier forwarding retain their existing contracts. +### xAI Priority Processing + +The built-in `xai` preset advertises and injects Fast only when its effective transport uses +`authMode: "key"`. API-key mode targets `https://api.x.ai/v1` through the `openai-chat` adapter and +sends `service_tier: "priority"` through Chat Completions. `ocx login xai` +instead stores OAuth credentials for the separate Grok CLI subscription-gateway flow, so OAuth +remains unclassified: its catalog rows do not advertise Fast and the proxy does not inject a tier. + +xAI charges Priority Processing at 2× the standard token price for input, output, cached, and +reasoning tokens; cache discounts are applied before the multiplier. Cost estimates use that premium +only when xAI's response confirms `service_tier: "priority"`. A missing or unparsed response tier is +not confirmation, and an echoed `default` is a downgrade; all three stay at the standard price. + +For `grok-4.6`, the standard rate per 1M tokens is $2.00 input, $0.50 cached input, and $6.00 +output. A prompt of at least 200,000 tokens reprices the whole request at $4.00 / $1.00 / $12.00. +xAI has not published how that long-context band combines with Priority Processing. When a +long-context response confirms `priority`, the dashboard therefore shows the published long-context +cost with a `≥` marker and a lower-bound explanation; it never invents a stacked multiplier. + ### OpenRouter Fast The canonical `https://openrouter.ai/api/v1` preset advertises Fast only for these exact diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1966d9db63..1dfe171a58 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -114,7 +114,7 @@ ocx logout | Провайдер | Адаптер | Базовый URL | Примечания | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth использует отдельный шлюз подписки Grok CLI. Переопределение с API-ключом использует `https://api.x.ai/v1` и может добавлять Priority Processing. Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Модели Claude; актуальный список моделей загружается из `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Шлюз подписки Nous Research (тот же бэкенд, что использует Hermes Agent). Вход по device grant против `portal.nousresearch.com`; access-токен — это JWT для каждого запроса к inference. Смешанный каталог платных + `:free` моделей (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, …) обнаруживается вживую по авторизованному аккаунту. Refresh-токены одноразовые и ротируются при каждом обновлении. | diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 2696370cc5..ed4a42a785 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -209,7 +209,7 @@ opencodex. Предупреждение о stale-`app-server` и optional `--res ## Фоновая служба -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Запустить opencodex как login-managed background service (macOS **launchd**, Linux **systemd user unit**, Windows **Task Scheduler**), которая автоматически стартует при логине и сама @@ -218,9 +218,10 @@ unit**, Windows **Task Scheduler**), которая автоматически | Подкоманда | Действие | | --- | --- | -| none | Создать/обновить и запустить службу. | +| none | Установить и запустить службу, если её нет; иначе обновить и перезапустить существующую службу без повторной регистрации. | | `install` | Создать и запустить службу. | | `repair` | Обновить установленную службу на месте и перезапустить её без повторной регистрации. | +| `restart` | Псевдоним команды `repair`. | | `start` | Запустить уже установленную службу. | | `stop` | Остановить службу и восстановить native Codex. | | `status` | Показать диагностику службы и прокси, а также пути к логам. | @@ -231,10 +232,13 @@ unit**, Windows **Task Scheduler**), которая автоматически ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +На Windows bare `ocx service` выполняет путь установки только после того, как отсутствие подтверждено и для Task Scheduler, и для WinSW. Если любой из запросов статуса не даёт определённого ответа, он отказывается что-либо регистрировать и предлагает выполнить `ocx service status`; явный `ocx service install` используйте только после подтверждения отсутствия. + На Windows `ocx service status` отдельно показывает регистрацию в Task Scheduler и identity-проверенную достижимость прокси OpenCodex. Он не печатает локализованную таблицу `schtasks`, чтобы сводка оставалась читаемой на любых code page Windows. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index ee153a0780..15e2ab3cf4 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -129,7 +129,7 @@ ocx logout | Sağlayıcı | Adaptör | Temel URL | Notlar | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | Canlı öncelikli Grok kataloğu; `grok-4.5` geri dönüş varsayılanıdır. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth ayrı Grok CLI abonelik ağ geçidini kullanır. API anahtarı geçersiz kılması `https://api.x.ai/v1` kullanır ve Priority Processing ekleyebilir. Canlı öncelikli Grok kataloğu; `grok-4.5` geri dönüş varsayılanıdır. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude modelleri; canlı model listesi `/v1/models` üzerinden getirilir. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 kodlama modelleri. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research abonelik ağ geçidi (Hermes Agent'ın kullandığı aynı arka uç). `portal.nousresearch.com`'a karşı cihaz yetkilendirmesi girişi; erişim belirteci istek başına çıkarım JWT'sidir. Oturum açmış hesaptan canlı olarak keşfedilen karışık ücretli + `:free` model kataloğu (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...). Yenileme belirteçleri tek kullanımlıktır ve her yenilemede döndürülür. | diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 134442a2cb..624c4174fb 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -232,7 +232,7 @@ ve isteğe bağlı `--restart-codex` davranışı geçerlidir. ## Arka plan servisi -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` opencodex'i oturum açmada otomatik başlayan ve çökmede otomatik yeniden başlayan oturumla yönetilen bir arka plan servisi (macOS **launchd**, Linux **systemd @@ -242,9 +242,10 @@ yapılandırmasını dalgalandırmaz. | Alt komut | Eylem | | --- | --- | -| none | Servisi oluşturun/güncelleyin ve başlatın. | +| none | Servis yoksa kurup başlatın; varsa yeniden kaydetmeden yenileyip yeniden başlatın. | | `install` | Servisi oluşturun ve başlatın. Kaydeder, bu da Windows'ta yükseltme gerektirir. | | `repair` | Kurulu bir servisi yerinde yenileyin ve yeniden kaydetmeden yeniden başlatın. | +| `restart` | `repair` komutunun takma adıdır. | | `start` | Kurulu bir servisi başlatın. | | `stop` | Servisi durdurun ve yerel Codex'i geri yükleyin. | | `status` | Servis ve proxy tanılamalarını artı günlük yollarını bildirin. | @@ -255,10 +256,13 @@ yapılandırmasını dalgalandırmaz. ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +Windows'ta bare `ocx service`, yükleme yolunu ancak Task Scheduler ve WinSW'nin her ikisinin de yok olduğu kanıtlandıktan sonra çalıştırır. Durum sorgularından herhangi biri belirsizse hiçbir şey kaydetmeyi reddeder ve `ocx service status` çalıştırmanızı ister; yalnızca yokluk doğrulandıktan sonra açık `ocx service install` kullanın. + `install`, `start` ve `repair`, başarı bildirmeden önce kurulu servise yerleştirilmiş portta bir proxy'nin gerçekten yanıt verdiğini onaylar — her üç platformda da. 20 saniyeye kadar beklerler ve ardından sunulan portu @@ -435,5 +439,3 @@ ocx update --tag preview Yeni sürümler, [Sürüm iş akışı](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) bunları npm'de yayınladığında kullanılabilir hale gelir. - - diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 4e924458ee..a73676e496 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -95,7 +95,7 @@ ocx logout | 提供商 | Adapter | 基础 URL | 备注 | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | 优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth 使用独立的 Grok CLI 订阅网关。API 密钥覆盖模式使用 `https://api.x.ai/v1`,并可能注入 Priority Processing。优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 4d103f0f06..964172ec9a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -147,15 +147,16 @@ ocx status --json ## 后台服务 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 将 opencodex 作为登录管理的后台服务运行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登录时自动启动,在崩溃时自动重启。服务运行会设置 `OCX_SERVICE=1`,因此重启时不会反复改动 Codex 配置。 | 子命令 | 操作 | | --- | --- | -| none | 创建/更新并启动服务。 | +| none | 服务不存在时安装并启动;已存在时不重新注册,直接刷新并重启。 | | `install` | 创建并启动服务。 | | `repair` | 就地刷新已安装的服务并重启,不重新注册。 | +| `restart` | `repair` 的别名。 | | `start` | 启动已安装的服务。 | | `stop` | 停止服务并恢复原生 Codex。 | | `status` | 报告服务和代理诊断信息及日志路径。 | @@ -166,10 +167,13 @@ ocx status --json ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +在 Windows 上,bare `ocx service` 只有在 Task Scheduler 和 WinSW 两者的缺失都得到证实后才会走安装路径。如果任一状态查询结果不确定,它会拒绝任何注册并提示运行 `ocx service status`;只有在确认缺失之后才使用显式的 `ocx service install`。 + 在 Windows 上,`ocx service status` 会单独报告 Task Scheduler 注册状态和已身份验证的 OpenCodex 代理可达性。它不会打印本地化的 `schtasks` 表格,因此在不同 Windows 代码页下摘要仍然可读。 在 Windows 上,创建 Task Scheduler 条目需要提升权限。识别到本地化的访问被拒绝文本时,会沿用现有的指导路径。如果该文本不可读,则回退要求命令形态为 `/create /tn opencodex-proxy /xml /f`,状态为 1,并且令牌明确为非提升权限;这时仪表盘的 Startup Safety 操作可以自动请求 UAC。如果该回退无法判断令牌状态,它会保留原始调度器错误。外部任务和操作绝不会发出自动提升标记。请批准仪表盘的 UAC 提示,或在提升权限的 PowerShell 窗口中重新运行 `ocx service install`。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index ee7d709880..28298c768a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -103,7 +103,7 @@ ocx logout | 供應商 | Adapter | Base URL | 備註 | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | 優先使用即時 Grok catalog;fallback 預設為 `grok-4.5`。 | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth 使用獨立的 Grok CLI 訂閱 gateway。API key 覆寫使用 `https://api.x.ai/v1`,並可能注入 Priority Processing。優先使用即時 Grok catalog;fallback 預設為 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;即時模型列表從 `/v1/models` 取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding 模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 訂閱 gateway(Hermes Agent 使用相同 backend)。透過 `portal.nousresearch.com` 做 device-grant 登入;access token 是每次請求使用的 inference JWT。混合付費與 `:free` 模型 catalog(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)會從已登入帳號即時探索。Refresh token 為單次使用,每次 refresh 都會輪換。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 02983cff71..c5c13cb623 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -141,15 +141,16 @@ ocx status --json ## 背景服務 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 將 opencodex 作為登入管理的背景服務執行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登入時自動啟動並在崩潰時自動重啟。服務執行時設定 `OCX_SERVICE=1`,使重啟不會折騰 Codex 設定。 | 子指令 | 動作 | | --- | --- | -| 無 | 建立/更新並啟動服務。 | +| 無 | 服務不存在時安裝並啟動;已存在時不重新註冊,直接重新整理並重啟。 | | `install` | 建立並啟動服務。註冊它,在 Windows 上需要提高權限。 | | `repair` | 就地重新整理已安裝的服務並重啟它,而不重新註冊。 | +| `restart` | `repair` 的別名。 | | `start` | 啟動已安裝的服務。 | | `stop` | 停止服務並還原原生 Codex。 | | `status` | 回報服務與代理診斷及日誌路徑。 | @@ -160,10 +161,13 @@ ocx status --json ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +在 Windows 上,bare `ocx service` 只有在 Task Scheduler 和 WinSW 兩者的缺失都得到證實後才會走安裝路徑。如果任一狀態查詢結果不確定,它會拒絕任何註冊並提示執行 `ocx service status`;只有在確認缺失之後才使用明確的 `ocx service install`。 + `install`、`start` 與 `repair` 會確認代理實際在已安裝服務內建的連接埠上回應,之後才回報成功——在三種平台上皆如此。它們等待最多 20 秒,然後印出伺服連接埠: ``` diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8e64e29b97..41ae5d02e5 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -717,7 +717,7 @@ export const de: Record = { "logs.detail.estimate.cache_detail_missing": "Cache-Details fehlen; Eingabe ist als Obergrenze geschätzt.", "logs.detail.estimate.expected_price_overlay": "Ein verifizierter Expected-Listenpreis wurde verwendet.", "logs.detail.estimate.provider_cost_overlay": "Ein vom Anbieter konfiguriertes Preis-Overlay wurde verwendet.", - "logs.detail.estimate.priority_lower_bound": "Der bestätigte OpenRouter-Priority-Preis ist nicht verfügbar; die angezeigte Standardpreisschätzung ist eine bekannte Untergrenze.", + "logs.detail.estimate.priority_lower_bound": "Der bestätigte Priority-Preis ist nicht verfügbar; die angezeigte Schätzung ist eine bekannte Untergrenze.", "logs.col.error": "Fehler", "logs.col.upstreamReason": "Upstream-Grund", "logs.col.duration": "Dauer", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 262e913162..01d69f0fd1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -750,7 +750,7 @@ export const en = { "logs.detail.estimate.cache_detail_missing": "Cache details were unavailable; input is an upper-bound estimate.", "logs.detail.estimate.expected_price_overlay": "A verified expected list price was used.", "logs.detail.estimate.provider_cost_overlay": "A provider-configured price overlay was used.", - "logs.detail.estimate.priority_lower_bound": "The confirmed OpenRouter priority price is unavailable; the displayed standard-price estimate is a known lower bound.", + "logs.detail.estimate.priority_lower_bound": "The confirmed Priority price is unavailable; the displayed estimate is a known lower bound.", "logs.col.error": "Error", "logs.col.upstreamReason": "Upstream reason", "logs.col.duration": "Duration", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 147773fd93..3765f4d95f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -731,7 +731,7 @@ export const fr: Record = { "logs.detail.estimate.cache_detail_missing": "Les détails du cache n’étaient pas disponibles ; l’entrée est une estimation de la limite supérieure.", "logs.detail.estimate.expected_price_overlay": "Un tarif catalogue attendu et vérifié a été utilisé.", "logs.detail.estimate.provider_cost_overlay": "Un remplacement de tarif configuré pour le fournisseur a été utilisé.", - "logs.detail.estimate.priority_lower_bound": "Le tarif Priority OpenRouter confirmé n’est pas disponible ; l’estimation au tarif standard affichée est une borne inférieure connue.", + "logs.detail.estimate.priority_lower_bound": "Le tarif Priority confirmé n’est pas disponible ; l’estimation affichée est une borne inférieure connue.", "logs.col.error": "Erreur", "logs.col.upstreamReason": "Motif en amont", "logs.col.duration": "Durée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 11a85127ea..63e20efd3b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -693,7 +693,7 @@ export const ja: Record = { "logs.detail.estimate.cache_detail_missing": "キャッシュの詳細が利用できませんでした; 入力は上限の推定です。", "logs.detail.estimate.expected_price_overlay": "検証済みの予想定価が使用されました。", "logs.detail.estimate.provider_cost_overlay": "プロバイダー設定の価格オーバーレイが使用されました。", - "logs.detail.estimate.priority_lower_bound": "確認済みの OpenRouter Priority 価格は取得できないため、表示される標準価格の見積もりは既知の下限です。", + "logs.detail.estimate.priority_lower_bound": "確認済みの Priority 価格を利用できないため、表示される見積もりは既知の下限です。", "logs.col.error": "エラー", "logs.col.upstreamReason": "上流の理由", "logs.col.duration": "所要時間", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5bbc14ae7d..b13cd141ff 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -736,7 +736,7 @@ export const ko: Record = { "logs.detail.estimate.cache_detail_missing": "캐시 상세가 없어 입력 전액을 상한으로 추정했습니다.", "logs.detail.estimate.expected_price_overlay": "검증된 expected 정가를 사용했습니다.", "logs.detail.estimate.provider_cost_overlay": "프로바이더 구성 가격 오버레이를 사용했습니다.", - "logs.detail.estimate.priority_lower_bound": "확인된 OpenRouter Priority 가격을 사용할 수 없어 표시된 표준 가격 추정치는 알려진 하한입니다.", + "logs.detail.estimate.priority_lower_bound": "확인된 Priority 가격을 사용할 수 없어 표시된 추정치는 알려진 하한입니다.", "logs.col.error": "오류", "logs.col.upstreamReason": "업스트림 원인", "logs.col.duration": "소요 시간", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 396ccf3ed0..05cf793779 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -734,7 +734,7 @@ export const ru: Record = { "logs.detail.estimate.cache_detail_missing": "Детализация кэша недоступна; входные токены оценены по верхней границе.", "logs.detail.estimate.expected_price_overlay": "Использована подтверждённая ожидаемая цена из прайс-листа.", "logs.detail.estimate.provider_cost_overlay": "Использован ценовой оверлей провайдера.", - "logs.detail.estimate.priority_lower_bound": "Подтверждённая цена OpenRouter Priority недоступна; показанная оценка по стандартной цене является известной нижней границей.", + "logs.detail.estimate.priority_lower_bound": "Подтверждённая цена Priority недоступна; показанная оценка является известной нижней границей.", "logs.col.error": "Ошибка", "logs.col.upstreamReason": "Причина от провайдера", "logs.col.duration": "Длительность", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f460bbeb36..c98421183e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -741,7 +741,7 @@ export const tr: Record = { "logs.detail.estimate.cache_detail_missing": "Önbellek detayları eksik.", "logs.detail.estimate.expected_price_overlay": "Doğrulanmış liste fiyatı kullanıldı.", "logs.detail.estimate.provider_cost_overlay": "Kullanıcı tarafından yapılandırılan bir sağlayıcı fiyat katmanı kullanıldı.", - "logs.detail.estimate.priority_lower_bound": "Doğrulanan OpenRouter Priority fiyatı kullanılamıyor; gösterilen standart fiyat tahmini bilinen bir alt sınırdır.", + "logs.detail.estimate.priority_lower_bound": "Doğrulanan Priority fiyatı kullanılamıyor; gösterilen tahmin bilinen bir alt sınırdır.", "logs.col.error": "Hata", "logs.col.upstreamReason": "Yukarı akış nedeni", "logs.col.duration": "Süre", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 942e21e61f..d35a96bd34 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1818,7 +1818,7 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.emptyCompletion": "空白完成重試", "logs.detail.attempt.recovery.unknown": "未知的復原原因", "logs.detail.estimate.provider_cost_overlay": "已使用供應商設定的價格覆蓋。", - "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 OpenRouter Priority 價格;目前顯示的標準價格估算是已知下限。", + "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 Priority 價格;目前顯示的估算是已知下限。", "pws.cockpitImportDescription": "從此裝置匯入 Cockpit Tools Antigravity JSON 匯出檔。不會顯示檔案內容。", "pws.cockpitImportFileLabel": "Cockpit Tools Antigravity JSON 匯出檔", "pws.cockpitImportChooseFile": "選擇 JSON 檔案", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b9cd4a3573..dc749ac530 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -729,7 +729,7 @@ export const zh: Record = { "logs.detail.estimate.cache_detail_missing": "缺少缓存明细;输入费用按上限估算。", "logs.detail.estimate.expected_price_overlay": "使用了已验证的 Expected 标价。", "logs.detail.estimate.provider_cost_overlay": "使用了用户配置的提供方价格覆盖。", - "logs.detail.estimate.priority_lower_bound": "暂无已确认的 OpenRouter Priority 价格;当前显示的标准价估算是已知下界。", + "logs.detail.estimate.priority_lower_bound": "暂无已确认的 Priority 价格;当前显示的估算是已知下界。", "logs.col.error": "错误", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗时", diff --git a/gui/src/pages/claude-code-sidecar.ts b/gui/src/pages/claude-code-sidecar.ts index c7861adc4e..14772f4f62 100644 --- a/gui/src/pages/claude-code-sidecar.ts +++ b/gui/src/pages/claude-code-sidecar.ts @@ -4,12 +4,12 @@ * trimmed model is present. */ -import type { SidecarBackend, SidecarOverride } from "./claude-manual-env"; +import type { SidecarOverride, VisionOverrideBackend } from "./claude-manual-env"; -export type SidecarSelectValue = "inherit" | "auto" | SidecarBackend; +export type SidecarSelectValue = "inherit" | "auto" | VisionOverrideBackend; export type PersistedSidecarOverride = { - backend: SidecarBackend | null; + backend: VisionOverrideBackend | null; model: string; }; diff --git a/gui/src/pages/claude-manual-env.ts b/gui/src/pages/claude-manual-env.ts index 59f3360c83..5f22e37165 100644 --- a/gui/src/pages/claude-manual-env.ts +++ b/gui/src/pages/claude-manual-env.ts @@ -6,7 +6,9 @@ import { AUTO_COMPACT_WINDOW_DEFAULT } from "./claude-code-types"; export type SidecarBackend = "openai" | "anthropic"; -export interface SidecarOverride { backend?: SidecarBackend; model?: string } +/** Vision override may carry "routed" (proxy-router describer, #2188). */ +export type VisionOverrideBackend = SidecarBackend | "routed"; +export interface SidecarOverride { backend?: VisionOverrideBackend; model?: string } export interface ClaudeManualEnvState { /** diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 809d08c04f..e528e66260 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -60,9 +60,18 @@ export interface SettingsData { }; } export type SidecarBackend = "openai" | "anthropic"; +/** + * Vision's union is wider than web-search's legacy pair but different from its + * executor set (web has xai/gemini/exa; vision's third arm is "routed" — the + * proxy's own router describing through any provider). Server provenance is + * authoritative; this type exists so a routed option row round-trips without + * being collapsed to a legacy backend. + */ +export type VisionBackend = SidecarBackend | "routed"; export type VisionReasoning = "low" | "medium" | "high" | "xhigh" | "max"; export interface SidecarSetting { - backend?: SidecarBackend; + // Shared by the web-search and vision cards; vision may carry "routed". + backend?: VisionBackend; model: string; reasoning?: VisionReasoning; streamRoutedModelOutput?: boolean; @@ -70,7 +79,7 @@ export interface SidecarSetting { maxDescriptionsPerTurn?: number; timeoutMs?: number; } -export interface VisionModelOption { value: string; label: string; backend: SidecarBackend; baseline?: boolean } +export interface VisionModelOption { value: string; label: string; backend: VisionBackend; baseline?: boolean } export interface WebSearchModelOption { value: string; label: string; @@ -99,7 +108,7 @@ export interface SidecarData { export interface SidecarPatch { webSearch?: { backend?: SidecarBackend | null; model?: string; streamRoutedModelOutput?: boolean }; vision?: { - backend?: SidecarBackend | null; + backend?: VisionBackend | null; model?: string; reasoning?: VisionReasoning; enabled?: boolean; @@ -189,7 +198,7 @@ export function updateJobLabel(status: UpdateJobStatus, t: (key: TKey) => string export function mergeSidecarSetting( current: SidecarSetting, update?: { - backend?: SidecarBackend | null; + backend?: VisionBackend | null; model?: string; reasoning?: VisionReasoning; streamRoutedModelOutput?: boolean; @@ -357,8 +366,8 @@ export function visionModelOptions( serverOptions: VisionModelOption[] | undefined, models: ModelInfo[], current: string | undefined, - currentBackend?: SidecarBackend, -): Array<{ value: string; label: string; backend?: SidecarBackend }> { + currentBackend?: VisionBackend, +): Array<{ value: string; label: string; backend?: VisionBackend }> { const options = serverOptions ? serverOptions.map(option => ({ value: option.value, label: option.label, backend: option.backend })) : sidecarModelOptions(models); @@ -392,13 +401,22 @@ export function webSearchSidecarSelectionForModel( }; } -/** Server eligibility is authoritative; catalog inference only supports legacy picker entries. */ +/** + * Server eligibility is authoritative; catalog inference only supports legacy + * picker entries. A namespaced value ("provider/model") is the routed-backend + * option shape and must never collapse to a legacy backend — the openai + * executor would POST the namespaced string verbatim (the failure the file + * comment above warns about, in the other direction). + */ export function visionSidecarBackendForModel( models: ModelInfo[], - options: Array<{ value: string; backend?: SidecarBackend }>, + options: Array<{ value: string; backend?: VisionBackend }>, modelId: string, -): SidecarBackend { - return options.find(option => option.value === modelId)?.backend ?? sidecarBackendForModel(models, modelId); +): VisionBackend { + const fromServer = options.find(option => option.value === modelId)?.backend; + if (fromServer) return fromServer; + if (modelId.includes("/")) return "routed"; + return sidecarBackendForModel(models, modelId); } let lastInputWasKeyboard = false; diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 9c63d417ce..9e2c4cda3a 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -466,11 +466,14 @@ export function useDashboardData(apiBase: string) { }, [grouped, modelQuery]); const sidecarModels = useMemo(() => { // Server-computed runnable set when present (#2188); legacy union otherwise. + // The shared SidecarSetting type admits vision's "routed", which the + // web-search picker cannot carry — narrow it away for this card. + const webBackend = sidecar?.webSearch.backend; return webSearchModelOptionsForPicker( sidecar?.webSearchModels, models, sidecar?.webSearch.model, - sidecar?.webSearch.backend, + webBackend === "routed" ? undefined : webBackend, ); }, [models, sidecar?.webSearchModels, sidecar?.webSearch]); const visionModels = useMemo( diff --git a/gui/tests/logs-cost-lower-bound.test.ts b/gui/tests/logs-cost-lower-bound.test.ts new file mode 100644 index 0000000000..60759219fa --- /dev/null +++ b/gui/tests/logs-cost-lower-bound.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { DICTS } from "../src/i18n/catalogs"; +import { interpolate, type Locale, type TFn } from "../src/i18n/shared"; +import { + formatEstimatedUsd, + formatEstimatedUsdValue, + summarizeEstimatedCosts, +} from "../src/pages/logs-cost-format"; + +function translator(locale: Locale): TFn { + return (key, vars) => interpolate(DICTS[locale][key], vars); +} + +test("ordinary dashboard costs retain the estimate marker", () => { + expect(formatEstimatedUsdValue(0.77, translator("en"), "en-US", false)).toBe("~$0.7700"); +}); + +test("priority long-context lower bounds render with a greater-than-or-equal marker", () => { + expect(formatEstimatedUsdValue(0.77, translator("en"), "en-US", true)).toBe("≥$0.7700"); +}); + +test("USD placement and separators follow a non-English locale", () => { + expect(formatEstimatedUsdValue(0.77, translator("de"), "de-DE", false)).toBe("ca. 0,7700\u00a0$"); + expect(formatEstimatedUsd({ kind: "unavailable" }, translator("de"), "de-DE")).toBe("nicht verfügbar"); +}); + +describe("conversation cost lower-bound aggregation", () => { + const priced = (total: number, lowerBound: boolean) => ({ + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value" as const, + estimate: { cost: { total }, priorityLowerBound: lowerBound }, + }, + }, + }); + + test("marks a total only when every included priced estimate is a lower bound", () => { + expect(summarizeEstimatedCosts([priced(0.77, true), priced(1.23, true)])).toMatchObject({ + estimatedCostUsd: 2, + priorityLowerBound: true, + }); + expect(summarizeEstimatedCosts([priced(0.77, true), priced(1.23, false)])).toMatchObject({ + estimatedCostUsd: 2, + priorityLowerBound: false, + }); + }); + + test("preserves unpriced and unsupported exclusions without minting a lower bound", () => { + expect(summarizeEstimatedCosts([ + { usageStatus: "reported", displayMetrics: { cost: { kind: "unavailable" } } }, + { usageStatus: "unsupported" }, + ])).toEqual({ + estimatedCostUsd: 0, + priorityLowerBound: false, + unpricedRequests: 1, + unmeteredRequests: 1, + }); + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 52f8c22547..c8cc524a21 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -169,9 +169,50 @@ function sshTargetFromOrigin(originUrl: string): string | undefined { return undefined; } -/** `ssh://host/owner/repo` or the scp-like `user@host:owner/repo`. Used for both derivation and override validation. */ +/** + * `ssh://host/owner/repo` or the scp-like `user@host:owner/repo`. + * + * This check is also a log boundary: the accepted value is printed before the push and appears in + * the failure command. Parse URL userinfo instead of treating any `ssh://` string as safe, and + * reject the scp-like `user:password@host:path` lookalike before either sink can observe it. + */ function isSshRemote(value: string): boolean { - return /^ssh:\/\/[^/]+\/.+$/.test(value) || /^[^@\s/]+@[^:\s/]+:.+$/.test(value); + const trimmed = value.trim(); + if (!trimmed || /[\u0000-\u001f\u007f]/.test(trimmed)) return false; + + if (trimmed.startsWith("ssh://")) { + // WHATWG URL collapses an empty password ("git:@host" -> password ""), so the parsed fields + // cannot distinguish it from a credential-free principal. Reject any ':' in the raw userinfo + // segment instead: a colon there is always credential-shaped. + const authority = trimmed.slice("ssh://".length); + const userinfoEnd = authority.indexOf("@"); + if (userinfoEnd !== -1 && authority.slice(0, userinfoEnd).includes(":")) return false; + try { + const parsed = new URL(trimmed); + let decodedUsername: string; + try { + decodedUsername = decodeURIComponent(parsed.username); + } catch { + return false; + } + return parsed.protocol === "ssh:" + && parsed.hostname.length > 0 + && parsed.pathname.length > 1 + && parsed.password === "" + // The release deploy key uses GitHub's fixed SSH principal. Treat any other userinfo as + // credential-shaped rather than trying to distinguish a harmless username from a token. + && (decodedUsername === "" || decodedUsername === SSH_USER) + && parsed.search === "" + && parsed.hash === ""; + } catch { + return false; + } + } + + // scp-like syntax has no parser-level query/fragment boundary. Reject those delimiters and any + // second '@' in the host segment rather than allowing a credential-shaped suffix to reach the + // target log or failed-command output. + return /^git@[^:@\s/?#]+:[^?#]+$/.test(trimmed); } /** Split out so the scp-like SSH target is assembled rather than written as an address literal. */ @@ -185,7 +226,7 @@ async function releasePushCommand(branch: string): Promise<{ command: string[]; // silently retarget a production release. Check the shape, and print the resolved target either // way so the destination is visible before the push rather than inferred afterwards. if (configured && !isSshRemote(configured)) { - console.error("✗ OCX_RELEASE_SSH_REPO is not an ssh:// or user@host:owner/repo remote; refusing to push."); + console.error("✗ OCX_RELEASE_SSH_REPO is not a credential-free ssh:// or git@host:owner/repo remote; refusing to push."); process.exit(1); } const slug = configured || sshTargetFromOrigin(await capture(["git", "remote", "get-url", "origin"])); diff --git a/scripts/restart-codex-desktop-app.ps1 b/scripts/restart-codex-desktop-app.ps1 new file mode 100644 index 0000000000..8675f69418 --- /dev/null +++ b/scripts/restart-codex-desktop-app.ps1 @@ -0,0 +1,102 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Fully restarts the Windows Codex desktop app (MSIX package) so the model + picker re-reads the on-disk catalog after ocx sync. +.NOTES + Run this from an external terminal. Running it from inside a Codex + conversation kills the app hosting that conversation. +#> +[CmdletBinding()] +param( + [switch]$DryRun, + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +$PackageFamily = "OpenAI.Codex_2p2nqsd0c76g0" +$Aumid = "OpenAI.Codex_2p2nqsd0c76g0!App" + +Import-Module Appx -ErrorAction SilentlyContinue +$pkg = Get-AppxPackage -Name OpenAI.Codex | Where-Object { $_.PackageFamilyName -eq $PackageFamily } +if (-not $pkg -or -not $pkg.InstallLocation) { + Write-Error "MSIX package $PackageFamily was not found; nothing to restart." + exit 1 +} +$InstallLoc = $pkg.InstallLocation + +$nameFilter = "Name='ChatGPT.exe' OR Name='codex.exe' OR Name='codex-code-mode-host.exe'" +$all = @(Get-CimInstance -ClassName Win32_Process -Filter $nameFilter) +$targets = @($all | Where-Object { + $_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallLoc, [System.StringComparison]::OrdinalIgnoreCase) +}) + +if ($targets.Count -eq 0) { + Write-Host "Codex desktop app is not running." + exit 0 +} + +$targetIds = @{} +foreach ($t in $targets) { $targetIds[[uint32]$t.ProcessId] = $t } + +# Roots are targets whose parent is outside the package tree; killing each +# root with taskkill /T cascades to codex.exe and its code-mode-host child. +$roots = @($targets | Where-Object { -not $targetIds.ContainsKey([uint32]$_.ParentProcessId) }) + +# Self-kill guard: never target our own ancestry. Skipped under -DryRun so the +# report stays useful when Codex itself launched this script. +$ancestry = @{} +if (-not $DryRun) { + $cursor = $PID + while ($cursor) { + $ancestry[[uint32]$cursor] = $true + $parent = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$cursor").ParentProcessId + if ($parent -and -not $ancestry.ContainsKey([uint32]$parent)) { $cursor = $parent } else { break } + } + foreach ($r in $roots) { + if ($ancestry.ContainsKey([uint32]$r.ProcessId)) { + Write-Error "Refusing to restart: selected root PID $($r.ProcessId) is an ancestor of this script." + exit 1 + } + } +} + +Write-Host ("Targets ({0}):" -f $targets.Count) +foreach ($t in $targets) { + Write-Host (" PID {0} {1} parent={2}" -f $t.ProcessId, $t.Name, $t.ParentProcessId) +} +Write-Host ("Root(s) to stop: {0}" -f (($roots | ForEach-Object { $_.ProcessId }) -join ", ")) +Write-Host ('Relaunch command: Start-Process "shell:AppsFolder\{0}"' -f $Aumid) + +if ($DryRun) { + Write-Host "Dry run: nothing was stopped or launched." + exit 0 +} + +foreach ($r in $roots) { + $rootPid = [uint32]$r.ProcessId + $stopped = $false + if (-not $Force) { + $proc = Get-Process -Id $rootPid -ErrorAction SilentlyContinue + if ($proc -and $proc.MainWindowHandle -ne 0) { + Write-Host "Sending graceful close to PID $rootPid..." + [void]$proc.CloseMainWindow() + for ($i = 0; $i -lt 15; $i++) { + Start-Sleep -Seconds 1 + if (-not (Get-Process -Id $rootPid -ErrorAction SilentlyContinue)) { $stopped = $true; break } + } + if (-not $stopped) { + Write-Host "PID $rootPid survived graceful close (close-to-tray suspected); forcing." + } + } + } + if (-not $stopped) { + Write-Host "Force-stopping process tree at PID $rootPid..." + & "$env:SystemRoot\System32\taskkill.exe" /PID $rootPid /T /F | Out-Null + } +} + +Start-Sleep -Seconds 1 +Start-Process "shell:AppsFolder\$Aumid" +Write-Host "Codex desktop app restarted." diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index a6cb5cbf9d..4089e81ed3 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,8 +3,8 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors"; -import { cursorCheckpointModelAffinityId, isCursorExternalWireModel } from "./cursor/discovery"; +import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; import { @@ -25,6 +25,7 @@ import { invalidateCursorCheckpoint, } from "./cursor/checkpoint-store"; import { debugProviderDiagnostic } from "../lib/debug"; +import { estimateTokens } from "../lib/token-estimate"; import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { @@ -53,16 +54,29 @@ export interface CursorAdapterDeps { rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void; } -function safeCursorTransportError(err: unknown): string { +function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext): string { if (err instanceof CursorTransportDisabledError) return CURSOR_TRANSPORT_DISABLED_MESSAGE; if (err instanceof CursorMissingCredentialError) { return "Cursor live transport is enabled, but no Cursor access token is configured. Set provider.apiKey or OPENCODEX_CURSOR_TEST_TOKEN."; } const message = err instanceof Error ? err.message : typeof err === "string" ? err : undefined; - if (message) return safeCursorErrorMessage(message); + if (message) return safeCursorErrorMessage(message, sizeContext); return "Cursor upstream error: transport failed before completion."; } +/** + * Size prior for bare resource_exhausted classification (devlog 260): a rough input + * estimate over the outgoing text vs the model's context window. Only used to keep + * SMALL requests on the 429 class — unknown/large stays on the overflow mapping. + */ +function cursorRequestSizeContext(request: { modelId: string; system: string[]; messages: { content: string }[] }): CursorSizeContext { + const text = [...request.system, ...request.messages.map(message => message.content)].join("\n"); + return { + estimatedInputTokens: estimateTokens(text, request.modelId), + contextWindow: inferCursorContextWindow(request.modelId), + }; +} + export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAdapterDeps = {}): ProviderAdapter { return { name: "cursor", @@ -88,6 +102,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda emit({ type: "error", message: "Cursor turn was aborted before start." }); return; } + // Captured after createCursorRequest so the catch block can apply the bare-RE + // size prior (devlog 260) even though `request` is scoped inside the try. + let requestSizeContext: CursorSizeContext | undefined; try { const makeTransport = deps.createTransport ?? createLiveCursorTransport; const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget); @@ -110,6 +127,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; const previousConversationId = _parsed._cursorConversationId; let request = createCursorRequest(_parsed); + requestSizeContext = cursorRequestSizeContext(request); // The builder may derive a stable provider id from the client thread when Responses state // is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn, // and isolated helper/compaction turns must never inherit or donate the parent's usage state. @@ -292,7 +310,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda type: "error", message: isTranslatorBudgetExceededError(err) ? "upstream translation buffer exceeded the safe limit" - : safeCursorTransportError(err), + : safeCursorTransportError(err, requestSizeContext), ...(isTranslatorBudgetExceededError(err) ? { status: 502, errorType: "upstream_error", code: "translation_buffer_limit" } : {}), diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index f2e13c578c..33ded8dc7c 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -112,6 +112,58 @@ export function isCursorInvalidArgumentError(value: unknown): boolean { } const QUOTA_RATE_CUES = ["too many requests", "quota", "rate limit", "rate-limit", "throttl"]; +/** + * A bare `resource_exhausted` end-stream with no detail beyond a generic error wrapper + * ("Error" or empty tail) and zero tokens billed is the shape Cursor's backend emits when + * the request payload exceeded its context window — not when quota ran out (senpi #1009, + * #1036: same wording, two causes). Quota rejections always carry an explicit rate cue + * ("too many requests", "quota exhausted"), so the ABSENCE of those cues plus the + * absence of a size phrase means payload overflow. Classifying it as 429 makes Codex + * back off instead of compacting, which burns retries on an unfixable-by-retry failure. + */ +const BARE_RE_TAILS = new Set(["error", "", "resource_exhausted", "resource exhausted"]); + +/** + * Size prior for bare resource_exhausted classification (devlog 260, live probe 210): + * a plan-gated model returns the SAME bare RE shape on a ~20-token prompt that a real + * payload overflow produces, so the message alone cannot separate "compact and retry" + * from "this account cannot use this model". When the caller can supply how large the + * request actually was relative to the model's window, a small request keeps the + * 429-class mapping; only a plausibly-large one classifies as overflow. Unknown + * sizes keep today's overflow mapping so the prior only ever REMOVES false overflows + * it can prove. + */ +export interface CursorSizeContext { + estimatedInputTokens?: number; + contextWindow?: number; +} + +const OVERFLOW_MIN_FRACTION = 0.5; + +function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean { + if (!context) return true; + const { estimatedInputTokens, contextWindow } = context; + if (estimatedInputTokens === undefined || contextWindow === undefined || contextWindow <= 0) return true; + return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow; +} + +export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean { + if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false; + // Any explicit quota/rate cue wins: this is a real 429. + if (QUOTA_RATE_CUES.some(cue => lowerMessage.includes(cue))) return false; + // An explicit size phrase also wins (already handled by the existing classifier). + if (isCursorRequestTooLargeDetail(lowerMessage)) return false; + // Extract the tail after the resource_exhausted marker. If it names a specific + // non-quota, non-size cause, this is NOT bare overflow. + const idx = Math.max( + lowerMessage.indexOf("resource_exhausted"), + lowerMessage.indexOf("resource exhausted"), + ); + const tail = lowerMessage.slice(idx + "resource_exhausted".length).trim().replace(/^[:\s]+/, "").trim(); + if (!BARE_RE_TAILS.has(tail)) return false; + return true; +} + const REQUEST_TOO_LARGE_PATTERNS: (string | RegExp)[] = [ "tool catalog too large", "tool registration too large", @@ -144,7 +196,7 @@ export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean { * The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords, * so bridge-level error mapping produces the right Codex error type (rate_limit, auth, etc.). */ -export function classifyCursorError(message: string): string { +export function classifyCursorError(message: string, sizeContext?: CursorSizeContext): string { const lower = message.toLowerCase(); if (isCursorBenignCancelError(message)) return "Cursor stream suspended"; @@ -158,9 +210,16 @@ export function classifyCursorError(message: string): string { // client-fixable 400; everything else surfaces as a 429 so Codex backs off // instead of hammering retries (live evidence: 6x 400 retry storm, devlog // 260723_cursor_context_continuity/000_plan.md). - return isCursorRequestTooLargeDetail(lower) - ? "Cursor resource limit exceeded" - : "Cursor rate limit exceeded"; + if (isCursorRequestTooLargeDetail(lower)) return "Cursor resource limit exceeded"; + // A bare resource_exhausted with no quota cue and no size phrase is payload + // overflow, not rate limiting. Classifying it as 429 makes Codex back off on a + // failure that only compaction can fix (senpi #1009 / #1036; research unit T01). + // Refinement (devlog 260): plan-gated models emit the same bare shape on tiny + // requests — when the caller proves the request was small, keep the 429 class. + if (isCursorZeroTokenResourceExhausted(lower)) { + return bareReLooksLikeOverflow(sizeContext) ? "Cursor context limit exceeded" : "Cursor rate limit exceeded"; + } + return "Cursor rate limit exceeded"; } if ( @@ -220,8 +279,8 @@ export function classifyCursorError(message: string): string { * Produce a user-facing, secret-safe Cursor error message with an actionable category prefix. * Mirrors `safeKiroErrorMessage` / `safeKiroHttpErrorMessage` in kiro-errors.ts. */ -export function safeCursorErrorMessage(rawMessage: string): string { - const prefix = classifyCursorError(rawMessage); +export function safeCursorErrorMessage(rawMessage: string, sizeContext?: CursorSizeContext): string { + const prefix = classifyCursorError(rawMessage, sizeContext); const detail = sanitize(rawMessage) .replace(/resource[_ ]exhausted/gi, "resource limit exceeded") .slice(0, 500); diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 4d4e7d964e..119827dc6a 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -236,10 +236,15 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM { id: "claude-4.6-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-4.6-sonnet", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-opus-4-7", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - // opus-4-7-fast: effort-suffix tiers unverified -> no tier picker; sent bare like live-only ids. - { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K }, + // Opus Fast families: live GetUsableModels (260822) lists ONLY effort-suffixed wire ids + // ({base-without-fast}-{effort}-fast; the bare id returns not_found), so every entry + // carries a tier picker. Live-verified: claude-opus-4-8-high-fast completed a turn. + // Tiers per the 260822 dump (devlog 260822_senpi_cursor_transfer/300). + { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "claude-opus-4-8-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-opus-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "claude-opus-5-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "composer-1", contextWindow: CONTEXT_200K }, diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index 346ee4ef2c..979c34e7c5 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -24,8 +24,14 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // against Anthropic's effort ladder docs and Cursor's live model lineup. "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + // Opus Fast tiers from the 260822 GetUsableModels dump (devlog .../300): the wire + // exposes {base-without-fast}-{effort}-fast only; suffix derivation at the bottom of + // this file produces those ids. opus-5-fast has no xhigh/max (non-thinking) yet. + "claude-opus-4-7-fast": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8-fast": ["low", "medium", "high", "xhigh", "max"], "claude-opus-5": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-5-fast": ["low", "medium", "high"], "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], "glm-5.2": ["high", "max"], // 260814 preemptive: glm-5.3 seeded ahead of Cursor's lineup update. Unlike 5.2, Z.AI folds diff --git a/src/adapters/cursor/h2-pool.ts b/src/adapters/cursor/h2-pool.ts new file mode 100644 index 0000000000..36e94e7566 --- /dev/null +++ b/src/adapters/cursor/h2-pool.ts @@ -0,0 +1,123 @@ +import http2 from "node:http2"; +import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks"; + +const DEFAULT_MAX_SESSIONS = 8; +const SESSION_CLOSE_TIMEOUT_MS = 2_000; + +interface PoolEntry { + readonly session: http2.ClientHttp2Session; + readonly streams: Set; + usable: boolean; +} + +/** + * HTTP/2 connection pool for Cursor Connect DISCOVERY calls (GetUsableModels). + * Sessions are keyed by origin (scheme+host+port) and reused to avoid fresh + * TCP+TLS per call. The Run path deliberately dials its own session: Run + * streams are long-lived bidi whose lifecycle/EOF semantics are owned by + * live-transport (see devlog 260822_senpi_cursor_transfer/190 — Run-path + * pooling is a separate, deliberate unit if ever taken). + */ +export class CursorH2SessionPool { + private readonly entries = new Map(); + private closed = false; + + constructor(private readonly maxSessions = DEFAULT_MAX_SESSIONS) {} + + /** + * Lazily registered on first use so a process that never talks to Cursor registers + * nothing (optional-subsystem doctrine). The seam is synchronous and best-effort; + * shutdown() is fire-and-forget there because lifecycle's drainAndShutdown runs + * under its own absolute deadline. + */ + private armShutdownHook: (() => void) | undefined = () => { + this.armShutdownHook = undefined; + registerOptionalShutdownHook("cursor-h2-pool", () => { void this.shutdown(); }); + }; + + request( + url: string, + headers: http2.OutgoingHttpHeaders, + ): http2.ClientHttp2Stream { + if (this.closed) throw new Error("Cursor H2 session pool is closed"); + this.armShutdownHook?.(); + const origin = new URL(url).origin; + const entry = this.usableEntry(origin) ?? this.createEntry(origin); + try { + const stream = entry.session.request(headers); + entry.streams.add(stream); + stream.once("close", () => { entry.streams.delete(stream); }); + return stream; + } catch (error) { + this.drain(entry, true); + throw error; + } + } + + async shutdown(): Promise { + if (this.closed) return; + this.closed = true; + const pending: Promise[] = []; + for (const entry of [...this.entries.values()]) { + for (const stream of [...entry.streams]) stream.destroy(); + entry.session.close(); + if (entry.session.destroyed) continue; + pending.push(new Promise(resolve => { + const timer = setTimeout(resolve, SESSION_CLOSE_TIMEOUT_MS); + timer.unref?.(); + entry.session.once("close", () => { clearTimeout(timer); resolve(); }); + })); + } + this.entries.clear(); + await Promise.all(pending); + } + + get size(): number { return this.entries.size; } + + private usableEntry(origin: string): PoolEntry | undefined { + const entry = this.entries.get(origin); + if (!entry) return undefined; + if (entry.usable && !entry.session.closed && !entry.session.destroyed) return entry; + this.drain(entry, false); + return undefined; + } + + private createEntry(origin: string): PoolEntry { + const session = http2.connect(origin); + const entry: PoolEntry = { + session, + streams: new Set(), + usable: true, + }; + this.entries.set(origin, entry); + session.once("goaway", () => { this.drain(entry, true); }); + session.on("error", () => { this.drain(entry, false); }); + session.once("close", () => { + // Identity check: a stale close event from an old session must not evict + // a healthy replacement entry that was created after drain() removed the old one. + if (this.entries.get(origin) === entry) this.entries.delete(origin); + }); + // Enforce bound: evict oldest when over capacity. + while (this.entries.size > this.maxSessions) { + const oldest = this.entries.keys().next().value; + if (!oldest || oldest === origin) break; + const old = this.entries.get(oldest); + if (old) this.drain(old, true); + } + return entry; + } + + private drain(entry: PoolEntry, closeSession: boolean): void { + entry.usable = false; + for (const stream of [...entry.streams]) stream.destroy(); + entry.streams.clear(); + if (closeSession) entry.session.close(); + // Remove from map by finding the matching key. + for (const [key, value] of this.entries) { + if (value === entry) { this.entries.delete(key); break; } + } + } +} + +/** Shared singleton pool for all Cursor adapter H2 traffic. */ +export const cursorH2Pool = new CursorH2SessionPool(); diff --git a/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index f79fe27398..32bafe1517 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -14,6 +14,7 @@ * 5-byte gRPC/Connect frame makes the server mis-parse it ("illegal tag: field no 0"). */ import http2 from "node:http2"; +import { cursorH2Pool } from "./h2-pool"; import { fromBinary } from "@bufbuild/protobuf"; import type { UpstreamHttpVersion } from "../../types"; import { readBoundedResponseBytes } from "../../lib/bounded-body"; @@ -205,35 +206,29 @@ async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions) resolve(value); }; - let client: http2.ClientHttp2Session; - try { - client = http2.connect(baseUrl); - } catch { - return finish({ ok: false, error: "transport", detail: "HTTP/2 connection setup failed" }); - } - const timer = setTimeout(() => { - finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` }); - client.destroy(); - }, timeoutMs); - const close = (value: CursorUsableModelsResult): void => { - clearTimeout(timer); - client.close(); - finish(value); - }; + const timer = setTimeout(() => { + // Cancel the borrowed pooled stream so it does not continue receiving + // body bytes after the caller has timed out (regression vs pre-pool behavior). + req?.destroy(); + finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` }); + }, timeoutMs); + const close = (value: CursorUsableModelsResult): void => { + clearTimeout(timer); + finish(value); + }; - client.on("error", () => close({ ok: false, error: "transport", detail: "HTTP/2 session failed" })); - let req: http2.ClientHttp2Stream; - try { - req = client.request({ - ":method": "POST", - ":path": CURSOR_GET_USABLE_MODELS_PATH, - ...cursorDiscoveryHeaders(opts), - }); - } catch { - return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" }); - } + let req: http2.ClientHttp2Stream; + try { + req = cursorH2Pool.request(baseUrl, { + ":method": "POST", + ":path": CURSOR_GET_USABLE_MODELS_PATH, + ...cursorDiscoveryHeaders(opts), + }); + } catch { + return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" }); + } let status = 0; const chunks: Buffer[] = []; diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 83658837bf..ad48ec6713 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -91,6 +91,24 @@ const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; const HEARTBEAT_MS = 5_000; const CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000; +/** + * T04 (senpi #1062 second half): after the first frame, a turn with NO inbound decoded + * frames for this long is failed instead of waiting for the 300s bridge stall watchdog + * (issue #2210). Reset on every decoded AgentServerMessage. + */ +const CURSOR_STREAM_SILENCE_FAIL_MS = 30_000; +/** + * A stream that produces ONLY liveness frames (server heartbeat / conversationCheckpointUpdate) + * for this long is equally stuck — the server is alive but the turn is not progressing. + * Reset on every decoded frame that is not liveness-only. + */ +const CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS = 90_000; +/** + * After `turnEnded` is decoded, the application turn is complete. A server that keeps + * HTTP/2 open past this point cannot hold the turn hostage (senpi #1062): we close our side + * after a short grace so any trailing frames (late usage, checkpoint) still land. + */ +const TURN_ENDED_CLOSE_GRACE_MS = 500; const CURSOR_TIMEOUT_DESTROY_GRACE_MS = 1_000; const CLIENT_TOOL_FINALIZE_GRACE_MS = 50; const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750; @@ -414,6 +432,18 @@ class LiveCursorTransport implements CursorTransport { private http1Connection?: CursorHttp1BidiConnection; private heartbeat?: ReturnType; private firstFrameTimer?: ReturnType; + private turnEndedCloseTimer?: ReturnType; + /** + * T04 inbound stream-health watchdog. Armed after the request is on the wire, reset by + * every DECODED frame (raw chunks deliberately do not count — TLS keepalive noise must not + * defeat it), disarmed by any settle/expected-close path. One timer covers both thresholds: + * it always fires at min(lastInbound + silence, lastMeaningful + heartbeatOnly) and re-arms + * when neither deadline has actually elapsed. + */ + private streamHealthTimer?: ReturnType; + private lastInboundFrameAt = 0; + private lastMeaningfulFrameAt = 0; + private streamHealthFail?: (error: Error) => void; private committed = false; private expectedClose = false; /** @@ -753,14 +783,100 @@ class LiveCursorTransport implements CursorTransport { } } + private clearStreamHealthTimer(): void { + if (this.streamHealthTimer) { + clearTimeout(this.streamHealthTimer); + this.streamHealthTimer = undefined; + } + this.streamHealthFail = undefined; + } + + /** + * T04: arm (or re-arm) the inbound stream-health watchdog. `fail` is the turn's + * failAndClear; the timer owns nothing else. Never armed before the first decoded + * frame (the first-frame timer covers dial + first response), and disarmed by + * every settle / expected-close path alongside the other timers. + */ + private armStreamHealthTimer(fail: (error: Error) => void): void { + if (this.streamHealthTimer) clearTimeout(this.streamHealthTimer); + if (this.expectedClose) return; + this.streamHealthFail = fail; + const silenceMs = this.input.streamSilenceFailMs ?? CURSOR_STREAM_SILENCE_FAIL_MS; + const heartbeatOnlyMs = this.input.streamHeartbeatOnlyFailMs ?? CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS; + const now = Date.now(); + const deadline = Math.min( + this.lastInboundFrameAt + silenceMs, + this.lastMeaningfulFrameAt + heartbeatOnlyMs, + ); + this.streamHealthTimer = setTimeout(() => { + this.streamHealthTimer = undefined; + const failFn = this.streamHealthFail; + if (!failFn || this.expectedClose) return; + const stalledFor = Date.now() - this.lastInboundFrameAt; + const meaningfulStalledFor = Date.now() - this.lastMeaningfulFrameAt; + if (stalledFor < silenceMs && meaningfulStalledFor < heartbeatOnlyMs) { + // A frame landed between arming and firing — re-arm for the fresh deadline. + this.armStreamHealthTimer(failFn); + return; + } + const heartbeatOnly = stalledFor < silenceMs; + debugProviderDiagnostic("cursor", "stream-health-timeout", { + stalledMs: stalledFor, + meaningfulStalledMs: meaningfulStalledFor, + heartbeatOnly, + framesReceived: this.framesReceived, + elapsedMs: Date.now() - this.turnStartedAt, + }); + const reason = heartbeatOnly + ? `Cursor stream stalled: heartbeat-only traffic for ${Math.round(meaningfulStalledFor / 1000)}s without turn progress` + : `Cursor stream stalled: no inbound frames for ${Math.round(stalledFor / 1000)}s before turnEnded`; + failFn(new Error(reason)); + try { this.stream?.close(); } catch { this.stream?.destroy(); } + this.session?.close(); + this.http1Connection?.close(); + }, Math.max(0, deadline - now)); + } + + /** + * T04: record a decoded inbound frame. Liveness-only frames (server heartbeat, + * conversationCheckpointUpdate) keep the silence clock fresh but not the progress + * clock — matching senpi's split so a server that only pings still fails at the + * heartbeat-only threshold. + */ + private noteInboundFrame(livenessOnly: boolean): void { + const now = Date.now(); + this.lastInboundFrameAt = now; + if (!livenessOnly) this.lastMeaningfulFrameAt = now; + if (this.streamHealthFail) this.armStreamHealthTimer(this.streamHealthFail); + } + + /** + * A clean Connect END_STREAM owns the turn terminal even when Cursor keeps the + * HTTP body open or tears it down with an abort/reset immediately afterward. + * Stop client-side liveness work and classify that later transport close as + * expected without actively sending an RST_STREAM back to Cursor. + */ + private markProtocolComplete(): void { + this.expectedClose = true; + this.clearPendingFinalize(); + if (this.heartbeat) { + clearInterval(this.heartbeat); + this.heartbeat = undefined; + } + this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); + } + private startShellCleanup(): Promise { return this.shellCleanup ??= terminateBackgroundShellsForSession(this.shellOwnerId); } async close(): Promise { if (this.heartbeat) clearInterval(this.heartbeat); + if (this.turnEndedCloseTimer) clearTimeout(this.turnEndedCloseTimer); this.clearPendingFinalize(); this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); this.stream?.close(); this.session?.close(); this.http1Connection?.close(); @@ -776,6 +892,7 @@ class LiveCursorTransport implements CursorTransport { this.clearPendingFinalize(); if (this.heartbeat) clearInterval(this.heartbeat); this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); if (this.http1Connection) { this.http1Connection.close(); } else { @@ -793,6 +910,46 @@ class LiveCursorTransport implements CursorTransport { void this.startShellCleanup().catch(() => { /* close() observes the same cleanup promise */ }); } + /** + * T03 (#1062): after the server sends `turnEnded`, the application turn is complete. + * A server that keeps the HTTP/2 stream open past this point cannot hold the turn + * hostage until a 300s bridge idle timeout. Close our side after a short grace so any + * trailing frames (late usage, checkpoint) still land before we release the socket. + */ + private closeAfterTurnEnded(): void { + if (this.turnEndedCloseTimer) return; + // The application turn is over: the T03 grace timer owns the socket from here. + // The T04 watchdog must disarm NOW, not at the grace close — a watchdog shorter + // than the grace would otherwise fail a completed turn. + this.clearStreamHealthTimer(); + this.turnEndedCloseTimer = setTimeout(() => { + this.turnEndedCloseTimer = undefined; + // Only expectedClose (client-tool suspend cancel) blocks the close. + // emittedTerminal is intentionally NOT checked here: finalizeTurnEvents sets it + // synchronously during turnEnded mapping, ~500ms before this timer fires, so + // checking it would make the close unreachable on every real path (the exact + // scenario this PR exists to fix — senpi #1062). + if (this.expectedClose) return; + debugProviderDiagnostic("cursor", "turn-ended-close", { + committed: this.committed, + framesReceived: this.framesReceived, + }); + this.expectedClose = true; + this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); + if (this.heartbeat) clearInterval(this.heartbeat); + if (this.http1Connection) { + this.http1Connection.close(); + } else { + try { + this.stream?.close(); + } catch { + this.stream?.destroy(); + } + } + }, TURN_ENDED_CLOSE_GRACE_MS); + } + private releaseBlobRequestScope(): void { const scope = this.blobRequestScope; if (!scope) return; @@ -903,7 +1060,10 @@ class LiveCursorTransport implements CursorTransport { const settler = createTerminalSettler({ fail, finish, - clearTimer: () => this.clearFirstFrameTimer(), + clearTimer: () => { + this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); + }, }); const failAndClear = (error: Error) => { releaseBacklogLease(); @@ -1000,10 +1160,54 @@ class LiveCursorTransport implements CursorTransport { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt, } : { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt }); - if (endError) failAndClear(endError); + if (endError) { + failAndClear(endError); + return; + } + // Connect's clean END_STREAM envelope is the protocol terminal. Cursor's RunSSE body can + // remain open after this frame (or close through an AbortError), so waiting for HTTP EOF + // strands an otherwise completed turn until the outer bridge stall watchdog fires. + // + // Earlier frames in this serialized frameWork chain have already run. Preserve their real + // turnEnded terminal when present; otherwise finalize the clean protocol end once so open + // tool calls still fail closed, a text-only turn receives its normal done event, and a + // drained client-tool turn does not lose the pending terminal when protocol cleanup clears + // its grace timer. + const hasPendingClientToolFinalization = this.pendingFinalize !== undefined; + if ( + !this.expectedClose + && !state.terminated + && !this.emittedTerminal + && ( + state.openToolCalls.size > 0 + || this.sawAssistantText + || hasPendingClientToolFinalization + ) + ) { + const terminal = hasPendingClientToolFinalization && state.openToolCalls.size === 0 + ? finalizeAfterDrain(state) + : finalizeTurnEvents(state); + for (const event of terminal) push(event); + } + this.markProtocolComplete(); + releaseBacklogLease(); + settler.settleFinish(); return; } - await this.handleServerMessage(fromBinary(AgentServerMessageSchema, frame.payload), state, push); + const decoded = fromBinary(AgentServerMessageSchema, frame.payload); + // T04: every decoded frame refreshes the silence clock; only non-liveness frames + // refresh the progress clock. First decoded frame arms the watchdog (the first-frame + // timer owned everything before this point). + const decodedUpdate = decoded.message.case === "interactionUpdate" ? decoded.message.value.message?.case : undefined; + const livenessOnly = decodedUpdate === "heartbeat" || decoded.message.case === "conversationCheckpointUpdate"; + if (!this.streamHealthFail) { + const now = Date.now(); + this.lastInboundFrameAt = now; + this.lastMeaningfulFrameAt = now; + this.streamHealthFail = failAndClear; + } + this.noteInboundFrame(livenessOnly); + await this.handleServerMessage(decoded, state, push); }; const drainPendingFrames = () => { const availableSlots = CURSOR_MAX_PENDING_FRAMES - this.pendingTransportFrames; @@ -1273,6 +1477,12 @@ class LiveCursorTransport implements CursorTransport { // A completion may carry only callId. Capture its ownership before mapping removes the open // call, because the embedded-tool classifier cannot identify that valid compact frame. const update = message.message.case === "interactionUpdate" ? message.message.value.message : undefined; + if (update?.case === "turnEnded") { + // T03: the application turn is complete. Close our side of HTTP/2 after a short + // grace so a held-open server response cannot pin the turn to the bridge's idle + // timeout (senpi #1062). finalizeTurnEvents already emitted done via the mapper. + this.closeAfterTurnEnded(); + } const completesOpenClientTool = update?.case === "toolCallCompleted" && state.openToolCalls.has(update.value.callId); const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted" diff --git a/src/adapters/cursor/native-exec-common.ts b/src/adapters/cursor/native-exec-common.ts index 1afa153074..86715636eb 100644 --- a/src/adapters/cursor/native-exec-common.ts +++ b/src/adapters/cursor/native-exec-common.ts @@ -1,6 +1,7 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { AgentClientMessageSchema, + ExecClientThrowSchema, ExecClientControlMessageSchema, ExecClientMessageSchema, ExecClientStreamCloseSchema, @@ -49,6 +50,22 @@ export function execStreamCloseBytes(execMsg: ExecServerMessage): Uint8Array { }); } +/** + * Exec-channel typed throw (`execClientControlMessage.throw`). senpi's contract (T05): + * a frame that cannot be answered at all must get an explicit error reply + stream-close + * so the server unblocks with a known failure, instead of waiting forever on silence. + */ +export function execThrowBytes(execMsg: ExecServerMessage, error: string): Uint8Array { + return clientBytes({ + message: { + case: "execClientControlMessage", + value: create(ExecClientControlMessageSchema, { + message: { case: "throw", value: create(ExecClientThrowSchema, { id: execMsg.id, error }) }, + }), + }, + }); +} + export function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index c72fa4715a..aee9ddac38 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -50,7 +50,7 @@ import { recordScreenExec, type CursorNativeToolDeps, } from "./native-exec-tools"; -import { clientBytes, execBytes } from "./native-exec-common"; +import { clientBytes, execBytes, execStreamCloseBytes, execThrowBytes } from "./native-exec-common"; import type { McpToolDefinition } from "./gen/agent_pb"; import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; @@ -603,10 +603,15 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C }))]; } // Unknown exec case — Cursor added a new native exec type that our protobuf definition does not - // include yet. Return an empty reply so the stream stays alive instead of throwing (which kills - // the entire gRPC connection via failAndClear). Same class of bug as #116. + // include yet. T05 (senpi contract): reply with ExecClientThrow + stream-close so the server + // unblocks with a known failure. Previously this returned an empty reply (silence), which is + // the stall class senpi explicitly refused (#116 was about throwing into failAndClear and + // killing the whole connection; a typed in-band throw does not do that). debugProviderDiagnostic("cursor", "unknown-exec-case", { execCase: execCase ?? "unknown", execId: execMsg.execId }); - return []; + return [ + execThrowBytes(execMsg, "Unknown exec message variant; this client does not implement it."), + execStreamCloseBytes(execMsg), + ]; } diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index ee126a51d9..c589d4f293 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -11,6 +11,7 @@ import { isCodexShellBridgeToolName, isCursorStructuredEditToolName, normalizeCursorWireName, + normalizeCursorTextToolMarkers, OCX_RESPONSES_TOOL_PROVIDER, resolveShellBridgeAliasKey, responsesToolNameFromCursorWire, @@ -1243,7 +1244,10 @@ export function mapCursorProtobufServerMessage( const update = serverMessage.message.value.message; switch (update.case) { case "textDelta": - return update.value.text ? [{ type: "text", text: update.value.text }] : []; + // #2305: fold Cursor display aliases inside textual pseudo tool-call markers back to + // the advertised wire name before any client sees the text. Real frames are already + // normalized structurally (mcpWireNameFromArgs above). + return update.value.text ? [{ type: "text", text: normalizeCursorTextToolMarkers(update.value.text) }] : []; case "thinkingDelta": return update.value.text ? [{ type: "thinking", thinking: update.value.text }] : []; case "toolCallStarted": { diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 39a18bd6eb..18d5957eab 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -188,10 +188,12 @@ function assistantRootText( } // Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata), -// so prior history — including assistant tool calls and tool results — must be replayed here or a -// ResumeAction has nothing model-visible to continue from. The active user message is excluded -// because it travels in the action. Tool results are assistant-role text with a [Tool Result] -// or [Tool Error] marker so Cursor does not wrap them as `` (#1992). Each entry is a SHA-256 blob ID. +// so prior history must be replayed here or a ResumeAction has nothing model-visible to continue from. +// The active user message is excluded because it travels in the action. When the continuation cannot +// rely on native MCP turn state, tool results stay assistant-role text with a [Tool Result] / +// [Tool Error] marker so Cursor does not wrap them as `` (#1992). Native resume models +// already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto +// few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID. function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken): { ids: Uint8Array[]; byteLength: number; @@ -212,6 +214,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR } const externalModel = isCursorExternalWireModel(request.modelId); + const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId); const lastRawIsToolResult = messages.at(-1)?.role === "toolResult"; const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages); @@ -243,6 +246,10 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR } // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here. } else if (message.role === "toolResult") { + // Native resume models already receive the paired MCP result through turns[]. Replaying + // the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto + // to echo that envelope as chat instead of continuing from the structured result. + if (!echoToolResultInRoot) continue; // #1920: the prefix must reflect the NORMALIZED error state (an empty // node_repl result is an error even when the runtime said isError=false). const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]"; diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 0930f08f26..31d34ee5e7 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -336,6 +336,26 @@ export function normalizeCursorWireName(name: string): string { return name.startsWith(CURSOR_MCP_DISPLAY_PREFIX) ? name.slice(CURSOR_MCP_DISPLAY_PREFIX.length) : name; } +/** + * #2305: some models emit a TEXTUAL pseudo tool call ("[TOOL_CALL]name[ARGS]{...}") + * instead of a real frame, using Cursor's display alias as the name. Text-mode clients + * (Pi) parse that text and then cannot dispatch the undeclared display name. Rewrite the + * display alias to the advertised wire name ONLY inside the marker pair — prose that + * merely mentions the alias stays untouched, and the scope guard is the exact + * `mcp_${OCX_RESPONSES_TOOL_PROVIDER}_` prefix, never generic `mcp_`. + * Known limit (recorded in devlog 230): a marker split across two streaming deltas is + * not rewritten; tail-buffering is deferred until a live trace shows split markers. + */ +const CURSOR_TEXT_TOOL_MARKER = new RegExp( + String.raw`\[TOOL_CALL\](${CURSOR_MCP_DISPLAY_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\[\]]+)\[ARGS\]`, + "g", +); + +export function normalizeCursorTextToolMarkers(text: string): string { + if (!text.includes(CURSOR_MCP_DISPLAY_PREFIX)) return text; + return text.replace(CURSOR_TEXT_TOOL_MARKER, (_match, name: string) => `[TOOL_CALL]${normalizeCursorWireName(name)}[ARGS]`); +} + export function responsesToolNameFromCursorWire(name: string, cursorToolNameMap?: ReadonlyMap): string { const normalized = normalizeCursorWireName(name); if (!cursorToolNameMap) return normalized; diff --git a/src/adapters/cursor/transport.ts b/src/adapters/cursor/transport.ts index 81b924c90b..79f241ca0e 100644 --- a/src/adapters/cursor/transport.ts +++ b/src/adapters/cursor/transport.ts @@ -29,6 +29,16 @@ export interface CursorTransportFactoryInput { firstFrameTimeoutMs?: number; /** Grace (ms) between close() and the force-destroy fallback after a first-frame timeout. Defaults to 1s. */ timeoutDestroyGraceMs?: number; + /** + * T04 watchdog: maximum inbound decoded-frame silence (ms) after the first frame before the + * turn is failed. Defaults to 30s. + */ + streamSilenceFailMs?: number; + /** + * T04 watchdog: maximum heartbeat/checkpoint-only traffic (ms) without turn progress before + * the turn is failed. Defaults to 90s. + */ + streamHeartbeatOnlyFailMs?: number; /** * Grace window (ms) before a drained client-tool turn is finalized, so a sibling tool call * announced in a later receive chunk can revoke a premature finalize. Defaults to 50ms. diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 07dd38e476..746f9490a4 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -413,6 +413,7 @@ interface GoogleResponsePart { thought?: boolean; thoughtSignature?: string; thought_signature?: string; + extra_content?: { google?: { thought_signature?: unknown } }; functionCall?: unknown; } @@ -421,6 +422,18 @@ interface GoogleFunctionCall { args?: unknown; } +/** + * Read a Gemini/Antigravity thought signature from a response part. Antigravity can place it + * either directly on the part (`thoughtSignature` / `thought_signature`) or inside the same + * nested `extra_content.google.thought_signature` shape used on the Responses wire. + */ +function googlePartThoughtSignature(part: GoogleResponsePart): string | undefined { + const direct = part.thoughtSignature ?? part.thought_signature; + if (typeof direct === "string" && direct.length > 0) return direct; + const nested = part.extra_content?.google?.thought_signature; + return typeof nested === "string" && nested.length > 0 ? nested : undefined; +} + /** * Carry a Gemini thought signature with the exact function-call part that produced it. Google * validates the signature against that specific part, so it must ride the individual tool call @@ -430,7 +443,7 @@ function googleToolCallMetadataFromPart( part: GoogleResponsePart, fallbackSignature?: string, ): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined { - const signature = part.thoughtSignature ?? part.thought_signature ?? fallbackSignature; + const signature = googlePartThoughtSignature(part) ?? fallbackSignature; if (!isLikelyRealThoughtSignature(signature)) return undefined; return { providerMetadata: { google: { thoughtSignature: signature } } }; } @@ -960,7 +973,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } if (parts) { for (const part of parts) { - const sig = part.thoughtSignature ?? part.thought_signature; + const sig = googlePartThoughtSignature(part); if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingStreamThoughtSig = sig; } @@ -1224,7 +1237,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } let pendingThoughtSig: string | undefined; for (const part of parts) { - const sig = part.thoughtSignature ?? part.thought_signature; + const sig = googlePartThoughtSignature(part); if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingThoughtSig = sig; } diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 323f9fbf40..eeeb38c386 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -19,6 +19,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; +import { normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { createAdapterTierMetadata, } from "../providers/fastwire"; @@ -1503,17 +1504,55 @@ function stripUnsupportedHostedTools(body: unknown): unknown { * provider capability metadata; an unclassified upstream keeps the fields. */ const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const; -export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.tools)) return body; + +function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): { + tools: unknown[]; + changed: boolean; +} { let changed = false; - const tools = body.tools.map(t => { - if (!isPlainObject(t) || (t.type !== "web_search" && t.type !== "web_search_preview")) return t; - if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(t, field))) return t; - const { external_web_access: _access, search_context_size: _size, ...rest } = t; + const stripped = tools.map(tool => { + if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) { + return tool; + } + if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool; + const { external_web_access: _access, search_context_size: _size, ...rest } = tool; changed = true; return rest; }); - return changed ? { ...body, tools } : body; + return { tools: changed ? stripped : tools, changed }; +} + +export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { + if (!isPlainObject(body)) return body; + + let next: Record = body; + let changed = false; + if (Array.isArray(body.tools)) { + const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools); + if (stripped.changed) { + next = { ...next, tools: stripped.tools }; + changed = true; + } + } + + if (Array.isArray(body.input)) { + let inputChanged = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + return item; + } + const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools); + if (!stripped.changed) return item; + inputChanged = true; + return { ...item, tools: stripped.tools }; + }); + if (inputChanged) { + next = { ...next, input }; + changed = true; + } + } + + return changed ? next : body; } /** Replace every `input_image` part under a routed-compaction body with a short marker. */ @@ -1692,17 +1731,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // that already recorded a single-query web_search_call replays it every turn, and // a strict parser rejects the whole request over it (#930). outBody = backfillWebSearchQueries(outBody); - // Same predicate as the routedCompaction gate in handleResponses(): an - // authMode check would let a noncanonical custom forward provider skip this - // rewrite while the server still routes it as a summarizer turn (#422). - if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { - outBody = buildRoutedCompactionBody(outBody); - } if (!isCanonicalOpenAiForwardProvider(provider)) { outBody = promoteClientLoadedTools(outBody); } if (!isCanonicalOpenAiForwardProvider(provider)) { - const rewritten = rewriteRoutedCustomToolsForUpstream(outBody); + const rewritten = rewriteRoutedCustomToolsForUpstream( + outBody, + provider.supportsResponsesCustomTools, + ); outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; } @@ -1712,12 +1748,6 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedToolSearchForUpstream(outBody); outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; - // xAI rejects these OpenAI web_search extensions with HTTP 400. Keep them - // for OpenAI API-key traffic and unclassified gateways; only an explicit - // provider capability denial activates the compatibility transform. - if (provider.supportsOpenAiWebSearchToolFields === false) { - outBody = stripOpenAiOnlyWebSearchFields(outBody); - } } if (!isCanonicalOpenAiForwardProvider(provider)) { // Codex 0.147 emits private namespace tool groups, while public/third-party Responses @@ -1726,9 +1756,25 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; + // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the + // generic capability fallback removes the private OpenAI fields. + outBody = normalizeXaiResponsesWebSearch(outBody, provider); + // xAI and explicitly classified compatible gateways reject these OpenAI web_search + // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. + if (provider.supportsOpenAiWebSearchToolFields === false) { + outBody = stripOpenAiOnlyWebSearchFields(outBody); + } // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } + // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would + // let a noncanonical custom forward provider skip this rewrite while the server still routes + // it as a summarizer turn (#422). The compaction body build removes the tool surface and must + // therefore be the last routed transform: anything before it may depend on the declarations; + // anything after it cannot. + if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { + outBody = buildRoutedCompactionBody(outBody); + } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( outBody, diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts new file mode 100644 index 0000000000..ce72fe2c54 --- /dev/null +++ b/src/adapters/xai-web-search.ts @@ -0,0 +1,185 @@ +import type { OcxProviderConfig } from "../types"; + +const CODEX_WEB_SEARCH_TOOL = "web_search"; +const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview"; +const XAI_API_HOST = "api.x.ai"; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isCodexWebSearchToolType(value: unknown): boolean { + return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL; +} + +/** Match only xAI's documented public API, not arbitrary Responses-compatible gateways. */ +function isXaiPublicApi(provider: Pick): boolean { + try { + const url = new URL(provider.baseUrl); + return url.protocol === "https:" + && url.hostname.toLowerCase() === XAI_API_HOST + && (url.port === "" || url.port === "443"); + } catch { + return false; + } +} + +type ToolGroupRewrite = { + tools: unknown[]; + changed: boolean; +}; + +/** + * Translate Codex-private hosted-search fields to xAI's public Responses schema. + * + * xAI web search is live-only. A Codex cached/index-only declaration carries + * `external_web_access: false`; dropping that flag while keeping the tool would silently widen + * network access, so the whole tool is omitted instead. `true` maps to xAI's ordinary live + * `{type:"web_search"}` declaration. Requests that omit the private flag are already public-API + * shaped and retain their live-search behavior. + */ +function normalizeToolGroup(tools: unknown[]): ToolGroupRewrite { + const normalized: unknown[] = []; + let changed = false; + + for (const tool of tools) { + if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) { + normalized.push(tool); + continue; + } + + const hasExternalAccess = Object.hasOwn(tool, "external_web_access"); + if (hasExternalAccess && tool.external_web_access !== true) { + // xAI has no cached/index-only equivalent. Fail closed instead of turning it into live search. + changed = true; + continue; + } + + const searchContentTypes = Array.isArray(tool.search_content_types) + ? tool.search_content_types + : undefined; + const enableImageSearch = searchContentTypes?.includes("image") === true; + const next: Record = { ...tool, type: CODEX_WEB_SEARCH_TOOL }; + delete next.external_web_access; + delete next.search_context_size; + delete next.search_content_types; + delete next.user_location; + if (enableImageSearch && !Object.hasOwn(next, "enable_image_search")) { + next.enable_image_search = true; + } + + const toolChanged = Object.keys(next).length !== Object.keys(tool).length + || Object.entries(next).some(([key, value]) => tool[key] !== value); + changed ||= toolChanged; + normalized.push(toolChanged ? next : tool); + } + + return { tools: changed ? normalized : tools, changed }; +} + +function hasWebSearchTool(body: Record): boolean { + if (Array.isArray(body.tools) && body.tools.some(tool => + isPlainObject(tool) && isCodexWebSearchToolType(tool.type) + )) return true; + return Array.isArray(body.input) && body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.some(tool => isPlainObject(tool) && isCodexWebSearchToolType(tool.type)) + ); +} + +function hasAnyDeclaredTool(body: Record): boolean { + if (Array.isArray(body.tools) && body.tools.length > 0) return true; + return Array.isArray(body.input) && body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.length > 0 + ); +} + +/** Remove selectors that would still force a cached-only tool omitted above. */ +function normalizeToolChoice(body: Record): Record { + const choice = body.tool_choice; + if (choice === undefined) return body; + const hasSearch = hasWebSearchTool(body); + + if (isPlainObject(choice) && isCodexWebSearchToolType(choice.type)) { + if (!hasSearch) return { ...body, tool_choice: "none" }; + return choice.type === CODEX_WEB_SEARCH_TOOL + ? body + : { ...body, tool_choice: { ...choice, type: CODEX_WEB_SEARCH_TOOL } }; + } + if (isPlainObject(choice) && choice.type === "allowed_tools" && Array.isArray(choice.tools)) { + let changed = false; + const tools: unknown[] = []; + for (const tool of choice.tools) { + if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) { + tools.push(tool); + continue; + } + if (!hasSearch) { + changed = true; + continue; + } + if (tool.type === CODEX_WEB_SEARCH_PREVIEW_TOOL) { + tools.push({ ...tool, type: CODEX_WEB_SEARCH_TOOL }); + changed = true; + } else { + tools.push(tool); + } + } + if (!changed) return body; + return { + ...body, + tool_choice: tools.length > 0 ? { ...choice, tools } : "none", + }; + } + if (choice === "required" && !hasAnyDeclaredTool(body)) { + return { ...body, tool_choice: "none" }; + } + return body; +} + +/** + * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other + * providers or mutating the caller-owned request body. + */ +export function normalizeXaiResponsesWebSearch( + body: unknown, + provider: Pick, +): unknown { + if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body; + + let next: Record = body; + if (Array.isArray(body.tools)) { + const rewritten = normalizeToolGroup(body.tools); + if (rewritten.changed) { + next = { ...next }; + if (rewritten.tools.length > 0) next.tools = rewritten.tools; + else delete next.tools; + } + } + + if (Array.isArray(next.input)) { + let inputChanged = false; + const input: unknown[] = []; + for (const item of next.input) { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + input.push(item); + continue; + } + const rewritten = normalizeToolGroup(item.tools); + if (!rewritten.changed) { + input.push(item); + continue; + } + inputChanged = true; + if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools }); + } + if (inputChanged) next = { ...next, input }; + } + + return normalizeToolChoice(next); +} diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 9cdef3df63..e16ca8e3e9 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -27,7 +27,8 @@ const USAGE = `Usage: ocx agent effort [--main ] [--subagent ] [--json] ocx agent subagents [model,model...] [--json] ocx agent fallback [model,model...] [--poll-ms <5000-600000>] [--json] - ocx agent sidecar [--list] [--model ] [--backend ] + ocx agent sidecar [--list] [--model ] + [--backend web: vision:] [--reasoning ] [--max-descriptions ] [--json]`; function clearable(value: string | undefined): string | null | undefined { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b70de46548..217e2d8967 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -172,9 +172,9 @@ const commandRunners: Record = { }, doctor: async deps => { const doctorArgs = deps.args.slice(1); - const { runDoctor } = await import("./doctor"); + const { RECOVER_ZERO_BYTE_COORDINATOR_FLAG, runDoctor } = await import("./doctor"); await runDoctor(doctorArgs); - if (!doctorArgs.includes("--fix-codex-runtime")) { + if (!doctorArgs.includes("--fix-codex-runtime") && !doctorArgs.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) { console.log(""); const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor"); printCodexLogGuardDoctor(); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 8af24a2693..d40ba14f2e 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback"; import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim"; import { countPendingOpencodexHistory } from "../codex/history-provider"; +import { + inspectCodexCoordinator, + recoverZeroByteCodexCoordinator, + type CodexCoordinatorDiagnostic, +} from "../codex/coordinator-doctor"; import { inspectAbandonedResponseStateTemps, reclaimAbandonedResponseStateTemps, @@ -684,6 +689,7 @@ export async function fetchServiceMemory( const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`; export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps"; +export const RECOVER_ZERO_BYTE_COORDINATOR_FLAG = "--recover-zero-byte-coordinator"; /** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */ const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096; /** Names the subsystem: other components mint temps with the same shape and are not covered. */ @@ -734,6 +740,60 @@ export function formatResponseTempLines( return lines; } +export function formatCoordinatorDoctorLines(diagnostic: CodexCoordinatorDiagnostic): string[] { + const pathLine = diagnostic.path ? [` path: ${diagnostic.path}`] : []; + const evidenceLines = "evidence" in diagnostic && diagnostic.evidence + ? [ + ` size: ${diagnostic.evidence.sizeBytes} bytes; user_version: ${diagnostic.evidence.schemaVersion}`, + ` tables: ${diagnostic.evidence.tables.length === 0 ? "none" : diagnostic.evidence.tables.join(", ")}`, + ` transition rows: ${diagnostic.evidence.transitionRows ?? "not inspected"}; singleton=1 rows: ${diagnostic.evidence.singletonRows ?? "not inspected"}`, + ] + : []; + switch (diagnostic.kind) { + case "absent": + return [" ok native-write coordinator not created yet", ...pathLine]; + case "ready": + return [" ok native-write coordinator has an authoritative transition row", ...pathLine, ...evidenceLines]; + case "zero-byte": + return [ + " !! native-write coordinator is a zero-byte remnant and has no authority", + ...pathLine, + ...evidenceLines, + ` Action: stop the OpenCodex proxy/service, then run ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`, + ]; + case "unversioned-empty": + return [ + " !! native-write coordinator is a non-empty unversioned database; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "rowless": + return [ + " !! native-write coordinator has schema version 1 but no authoritative row; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "unversioned-nonempty": + return [ + " !! native-write coordinator is unversioned and contains unknown tables; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "unsupported": + return [ + ` !! native-write coordinator schema version ${diagnostic.version} is unsupported; automatic recovery is refused`, + ...pathLine, + ...evidenceLines, + ]; + case "changed": + return [" -- native-write coordinator changed during diagnosis; re-run ocx doctor", ...pathLine]; + case "unsafe": + return [` !! native-write coordinator path is unsafe: ${diagnostic.reason}`, ...pathLine]; + case "unreadable": + return [` !! native-write coordinator is unreadable: ${diagnostic.reason}`, ...pathLine, ...evidenceLines]; + } +} + /** Render the doctor "Memory / runtime" section lines (testable without console capture). */ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] { const lines: string[] = []; @@ -846,6 +906,33 @@ export async function runDoctor(args: string[] = []): Promise { return; } + if (args.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) { + if (!args.includes("--yes")) { + console.log(`Recovery is explicit and creates a same-directory backup. Re-run: ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`); + process.exitCode = 1; + return; + } + const diagnostics = readConfigDiagnostics().config; + const live = await findLiveProxy({ + configFn: () => ({ port: diagnostics.port, hostname: diagnostics.hostname }), + }); + if (live) { + console.log(`Recovery refused: OpenCodex proxy pid ${live.pid} is still running. Stop the proxy/service and retry.`); + process.exitCode = 1; + return; + } + const recovered = recoverZeroByteCodexCoordinator(); + if (!recovered.ok) { + console.log(`Recovery refused: ${recovered.reason}.`); + process.exitCode = 1; + return; + } + console.log(`Moved the non-authoritative coordinator to ${recovered.backupPath}`); + console.log("Run `ocx sync` to retry Codex config injection. The backup was preserved and no Codex config/catalog file was changed by recovery."); + process.exitCode = 0; + return; + } + console.log("opencodex doctor\n"); // Ordering note: the memory/runtime section renders after "Running proxy @@ -1005,6 +1092,8 @@ export async function runDoctor(args: string[] = []): Promise { const reason = cause instanceof CodexUserIdentityRefusal ? cause.message : String(cause); console.log(` -- history coordinator namespace refused: ${reason}`); } + console.log("\nCodex native-write coordinator"); + for (const line of formatCoordinatorDoctorLines(inspectCodexCoordinator())) console.log(line); const pending = countPendingOpencodexHistory(); if (pending.failed) { console.log(" -- state DB locked or unreadable (Codex app open?) — migration state unknown"); diff --git a/src/cli/help.ts b/src/cli/help.ts index ca1efe8c01..89e2a4edb2 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -38,6 +38,8 @@ Usage: ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability) ocx doctor --reclaim-response-temps Reclaim abandoned response-state temp files (works without a running proxy) + ocx doctor --recover-zero-byte-coordinator --yes + Back up a proven zero-byte Codex coordinator after stopping the proxy ocx debug provider/usage/injection/claude on|off|status|reset ocx login OAuth or API-key provider login ocx logout Remove a stored OAuth login diff --git a/src/cli/registry.ts b/src/cli/registry.ts index c8c786b54e..b52a5c81b7 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -61,10 +61,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "service", - usage: "ocx service [install|start|stop|status|uninstall|remove]", + usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]", summary: "Run as a background service.", details: [ - "With no subcommand, installs/updates and starts the background service.", + "With no subcommand, installs when absent or repairs/restarts an existing service.", + "`restart` is an alias of `repair` and does not re-register an installed service.", "Use `ocx service status` to see diagnostics and log paths.", ], }, @@ -108,6 +109,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ name: "doctor", usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability).", + details: [ + "Default mode is observe-only and reports the native-write coordinator state and exact path.", + "After stopping the proxy/service, `--recover-zero-byte-coordinator --yes` moves only a proven zero-byte coordinator to a same-directory backup.", + ], }, { name: "debug", diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 71a79b1b67..7dd58c6b91 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,3 +1,4 @@ +import { createHmac, randomBytes } from "node:crypto"; import { CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, @@ -38,6 +39,38 @@ import { getAccountQuota } from "./quota"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; +import { retainedUtf8Bytes } from "../lib/admission"; + +const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; +const CODEX_APP_AFFINITY_KEY = randomBytes(32); + +function boundedCodexAffinityComponent(value: string | null): string | undefined { + const normalized = value?.trim(); + if (!normalized) return undefined; + if (retainedUtf8Bytes(normalized) > CODEX_AFFINITY_COMPONENT_MAX_BYTES) return undefined; + return normalized; +} + +/** + * Preserve Codex's parent-thread affinity when present. Desktop App requests can omit that + * header while retaining a stable session/thread pair, so derive an opaque process-local key + * only from the complete bounded pair. Raw identifiers and durable hashes never enter Pool state. + */ +export function codexPoolAffinityKey(headers: Headers): string | undefined { + const parentThreadId = boundedCodexAffinityComponent(headers.get("x-codex-parent-thread-id")); + if (parentThreadId) return parentThreadId; + + const sessionId = boundedCodexAffinityComponent(headers.get("session-id")); + const threadId = boundedCodexAffinityComponent(headers.get("thread-id")); + if (!sessionId || !threadId) return undefined; + + return `app:${createHmac("sha256", CODEX_APP_AFFINITY_KEY) + .update("opencodex-app-pool-affinity-v1\0") + .update(sessionId) + .update("\0") + .update(threadId) + .digest("base64url")}`; +} export type CodexAuthContext = | { kind: "main"; accountId: null } @@ -50,6 +83,8 @@ export type CodexAuthContext = chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; + /** Pool binding key; the Desktop fallback is an opaque process-local HMAC. */ + affinityKey?: string; /** * Set when this request was admitted through an active quota cooldown as * the account's single probe. Must be echoed into the upstream outcome so @@ -71,6 +106,8 @@ export type CodexAuthContext = chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; + /** See `pool.affinityKey`. */ + affinityKey?: string; /** See `pool.probeLeaseId`. */ probeLeaseId?: string; quotaScope?: CodexQuotaScope; @@ -343,6 +380,7 @@ export async function resolveCodexAuthContext( } return { kind: "main", accountId: null }; } + const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined; const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config) : undefined; @@ -369,7 +407,6 @@ export async function resolveCodexAuthContext( // routing inspect it. Selectors arriving after the fence skip reconciliation // and may still route to non-main pool accounts without touching switch state. if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState(); - const threadId = headers.get("x-codex-parent-thread-id"); const resolution = fixedAccountId !== undefined ? { status: "selected" as const, accountId: fixedAccountId } : options.excludeAccountId @@ -385,7 +422,7 @@ export async function resolveCodexAuthContext( ? { status: "selected" as const, accountId: selected } : { status: "none" as const }; })() - : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope, selectionOptions); + : resolveCodexAccountForThreadDetailed(affinityKey ?? null, config, Date.now(), quotaScope, selectionOptions); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); const selected = resolution.status === "selected" ? resolution.accountId : null; if (!selected) { @@ -500,6 +537,7 @@ export async function resolveCodexAuthContext( accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), + ...(affinityKey ? { affinityKey } : {}), ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), @@ -516,6 +554,7 @@ export async function resolveCodexAuthContext( accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), + ...(affinityKey ? { affinityKey } : {}), ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 3bf5daa086..0648b64d17 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -147,7 +147,7 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) entry.service_tiers = [{ id: "priority", name: "Fast", - description: "1.5x speed, increased usage", + description: model.fastTierDescription ?? "1.5x speed, increased usage", }]; entry.additional_speed_tiers = ["fast"]; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 0d1b2c2aaa..a2a1c86c7c 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -125,6 +125,8 @@ export interface CatalogModel { supportsVerbosity?: boolean; /** Whether this exact routed model has a verified OpenAI-compatible service tier. */ supportsServiceTier?: boolean; + /** Optional provider-specific copy for the advertised Fast tier. */ + fastTierDescription?: string; supportsReasoningSummaries?: boolean; /** * Codex tool calling mode for this routed model. diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 9c3384b4e3..565f6057a4 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -34,7 +34,8 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { captureFastPolicyAuthority, - serviceTierSupportForModel, + fastPolicyForModel, + serviceTierSupportFromPolicy, } from "../../providers/service-tier"; import type { FastPolicyAuthority } from "../../providers/fastwire"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; @@ -647,8 +648,13 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); - const supportsServiceTier = serviceTierSupportForModel(prov, model.id, name); - const { supportsServiceTier: _staleServiceTier, ...modelWithoutServiceTier } = model; + const fastPolicy = fastPolicyForModel(prov, model.id, name); + const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy); + const { + supportsServiceTier: _staleServiceTier, + fastTierDescription: _staleFastTierDescription, + ...modelWithoutServiceTier + } = model; // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow @@ -671,6 +677,9 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), + ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined + ? { fastTierDescription: fastPolicy.fastTierDescription } + : {}), ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}), // Default-on for openai-chat providers (explicit false opts out); other adapters // advertise only on explicit opt-in. @@ -1845,8 +1854,11 @@ async function gatherRoutedModelsUncached( ? nativeDefaultReasoningEffort(cm.modelId) : undefined; const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); - const supportsServiceTier = effectiveProvider - ? serviceTierSupportForModel(effectiveProvider, cm.modelId, cm.provider) + const fastPolicy = effectiveProvider + ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider) + : undefined; + const supportsServiceTier = fastPolicy + ? serviceTierSupportFromPolicy(fastPolicy) : undefined; const base: CatalogModel = { id: cm.modelId, @@ -1883,6 +1895,9 @@ async function gatherRoutedModelsUncached( ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), + ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined + ? { fastTierDescription: fastPolicy.fastTierDescription } + : {}), ...(cm.codexToolMode !== undefined ? { codexToolMode: cm.codexToolMode } : effectiveProvider?.codexToolMode !== undefined diff --git a/src/codex/coordinator-doctor.ts b/src/codex/coordinator-doctor.ts new file mode 100644 index 0000000000..1c238bd922 --- /dev/null +++ b/src/codex/coordinator-doctor.ts @@ -0,0 +1,332 @@ +/** + * Observe and explicitly quarantine non-authoritative native-write coordinators. + * + * Default doctor runs use immutable SQLite reads so diagnostics cannot create + * WAL/SHM sidecars. Recovery is deliberately opt-in and moves, never deletes, + * only a file that is still the same private regular file observed beforehand. + */ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + realpathSync, + renameSync, + type Stats, +} from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { Database, constants as sqliteConstants } from "bun:sqlite"; + +import { resolveCodexHomeDir } from "./home"; +import { + CodexUserIdentityRefusal, + probeCodexCoordinatorNamespace, + resolveEffectiveUserIdentity, + samePathIdentity, +} from "./user-identity"; +import { + CODEX_COORDINATOR_SCHEMA_VERSION, + readCodexCoordinatorState, +} from "./transition-state"; + +const IMMUTABLE_READONLY_FLAGS = + sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI; + +export type FileIdentity = Pick; + +export interface CodexCoordinatorDiagnosticEvidence { + sizeBytes: number; + schemaVersion: number; + tables: readonly string[]; + transitionRows: number | null; + singletonRows: number | null; +} + +export type CodexCoordinatorDiagnostic = + | { kind: "absent"; path: string | null } + | { kind: "zero-byte"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unversioned-empty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unversioned-nonempty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "rowless"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "ready"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unsupported"; path: string; identity: FileIdentity; version: number; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "changed"; path: string } + | { kind: "unsafe"; path: string | null; reason: string } + | { kind: "unreadable"; path: string; reason: string; evidence?: CodexCoordinatorDiagnosticEvidence }; + +export type CodexCoordinatorRecoveryResult = + | { ok: true; backupPath: string } + | { ok: false; reason: string }; + +function errorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; +} + +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +function sameNodeAndSize(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size; +} + +function coordinatorPathWithoutCreation(): { kind: "absent"; path: string | null } | { kind: "path"; path: string } { + const identity = resolveEffectiveUserIdentity(); + const canonicalCodexHome = realpathSync.native(resolveCodexHomeDir()); + const namespace = probeCodexCoordinatorNamespace(identity); + if (namespace.status === "missing") return { kind: "absent", path: null }; + + const locks = join(namespace.root, "native-write-locks"); + let locksEntry: Stats; + try { + locksEntry = lstatSync(locks); + } catch (cause) { + if (errorCode(cause) === "ENOENT") { + const digest = createHash("sha256").update(canonicalCodexHome).digest("hex"); + return { kind: "absent", path: join(locks, `${digest}.sqlite`) }; + } + throw new CodexUserIdentityRefusal("The coordinator lock directory cannot be inspected.", { cause }); + } + if (locksEntry.isSymbolicLink() || !locksEntry.isDirectory()) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace is not a real directory."); + } + if (identity.platform === "posix") { + if (locksEntry.uid !== identity.uid || (locksEntry.mode & 0o777) !== 0o700) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace has unsafe ownership or permissions."); + } + } else if (!samePathIdentity(realpathSync.native(locks), locks, "win32")) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace is redirected by a junction or reparse point."); + } + + const digest = createHash("sha256").update(canonicalCodexHome).digest("hex"); + return { kind: "path", path: join(locks, `${digest}.sqlite`) }; +} + +function inspectTarget( + path: string, + options: { allowSqliteSidecars?: boolean } = {}, +): { kind: "absent" } | { kind: "file"; identity: FileIdentity } | { kind: "unsafe"; reason: string } { + let entry: Stats; + try { + entry = lstatSync(path); + } catch (cause) { + if (errorCode(cause) === "ENOENT") return { kind: "absent" }; + return { kind: "unsafe", reason: "the coordinator file cannot be inspected" }; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + return { kind: "unsafe", reason: "the coordinator path is not a real file" }; + } + try { + if (!samePathIdentity(realpathSync.native(path), path)) { + return { kind: "unsafe", reason: "the coordinator path is redirected" }; + } + } catch { + return { kind: "unsafe", reason: "the coordinator path cannot be resolved" }; + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + if (uid === undefined || entry.uid !== uid || (entry.mode & 0o777) !== 0o600) { + return { kind: "unsafe", reason: "the coordinator file has unsafe ownership or permissions" }; + } + } + if (!options.allowSqliteSidecars) { + for (const suffix of ["-journal", "-wal", "-shm"]) { + if (existsSync(`${path}${suffix}`)) { + return { kind: "unsafe", reason: `the coordinator has an active SQLite ${suffix.slice(1)} sidecar` }; + } + } + } + return { kind: "file", identity: entry }; +} + +function classifyOpenedDatabase( + database: Database, + path: string, + identity: FileIdentity, +): CodexCoordinatorDiagnostic { + const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0; + const tables = database.query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ).all().map(row => row.name); + const baseEvidence = { + sizeBytes: identity.size, + schemaVersion: version, + tables, + transitionRows: null, + singletonRows: null, + } satisfies CodexCoordinatorDiagnosticEvidence; + if (version === 0) { + const evidence = tables.length === 0 + ? { ...baseEvidence, transitionRows: 0, singletonRows: 0 } + : baseEvidence; + return tables.length === 0 + ? { kind: "unversioned-empty", path, identity, evidence } + : { kind: "unversioned-nonempty", path, identity, evidence }; + } + if (version !== CODEX_COORDINATOR_SCHEMA_VERSION) { + return { kind: "unsupported", path, identity, version, evidence: baseEvidence }; + } + if (tables.length !== 1 || tables[0] !== "codex_transition_state") { + return tables.length === 0 + ? { kind: "rowless", path, identity, evidence: baseEvidence } + : { kind: "unreadable", path, reason: "the coordinator contains unexpected tables", evidence: baseEvidence }; + } + let rowCounts: { total: number; singleton: number } | null; + try { + rowCounts = database.query<{ total: number; singleton: number }, []>( + "SELECT count(*) AS total, sum(CASE WHEN singleton = 1 THEN 1 ELSE 0 END) AS singleton FROM codex_transition_state", + ).get() ?? null; + } catch { + return { + kind: "unreadable", + path, + reason: "the transition table schema is not recognized", + evidence: baseEvidence, + }; + } + const evidence = { + ...baseEvidence, + transitionRows: rowCounts?.total ?? null, + singletonRows: rowCounts?.singleton ?? null, + }; + if (!rowCounts || rowCounts.total === 0) return { kind: "rowless", path, identity, evidence }; + if (rowCounts.total !== 1 || rowCounts.singleton !== 1) { + return { + kind: "unreadable", + path, + reason: "the coordinator does not contain exactly one singleton row", + evidence, + }; + } + try { + readCodexCoordinatorState(database); + } catch { + return { + kind: "unreadable", + path, + reason: "the authoritative transition row is malformed", + evidence, + }; + } + return { kind: "ready", path, identity, evidence }; +} + +export function inspectCodexCoordinator(): CodexCoordinatorDiagnostic { + let resolved: ReturnType; + try { + resolved = coordinatorPathWithoutCreation(); + } catch (cause) { + return { + kind: "unsafe", + path: null, + reason: cause instanceof Error ? cause.message : String(cause), + }; + } + if (resolved.kind === "absent") return resolved; + return inspectCodexCoordinatorPath(resolved.path); +} + +/** Inspect one already-resolved coordinator path without creating SQLite state. */ +export function inspectCodexCoordinatorPath(path: string): CodexCoordinatorDiagnostic { + const target = inspectTarget(path); + if (target.kind === "absent") return { kind: "absent", path }; + if (target.kind === "unsafe") return { kind: "unsafe", path, reason: target.reason }; + + let database: Database | undefined; + try { + const uri = `${pathToFileURL(path).href}?immutable=1`; + database = new Database(uri, IMMUTABLE_READONLY_FLAGS); + const result = classifyOpenedDatabase(database, path, target.identity); + const after = inspectTarget(path); + if (after.kind !== "file" || !sameIdentity(target.identity, after.identity)) { + return { kind: "changed", path }; + } + // Size alone is not evidence that this is a non-authoritative remnant. + // Query the immutable snapshot too, so the recovery label means all three + // facts were observed together: zero bytes, schema version zero, no tables. + if (target.identity.size === 0 && result.kind === "unversioned-empty") { + return { kind: "zero-byte", path, identity: target.identity, evidence: result.evidence }; + } + return result; + } catch (cause) { + return { kind: "unreadable", path, reason: cause instanceof Error ? cause.message : String(cause) }; + } finally { + try { database?.close(); } catch { /* diagnostics already completed */ } + } +} + +function recoverable(diagnostic: CodexCoordinatorDiagnostic): diagnostic is Extract< + CodexCoordinatorDiagnostic, + { kind: "zero-byte" } +> { + return diagnostic.kind === "zero-byte"; +} + +function backupTimestamp(now: Date): string { + return now.toISOString().replace(/[-:.]/g, ""); +} + +export function recoverZeroByteCodexCoordinator(now = new Date()): CodexCoordinatorRecoveryResult { + const observed = inspectCodexCoordinator(); + if (!recoverable(observed)) { + if (observed.kind === "unsafe" || observed.kind === "unreadable") { + return { ok: false, reason: `coordinator state is ${observed.kind}: ${observed.reason}` }; + } + return { ok: false, reason: `coordinator state is ${observed.kind}, not a recoverable zero-byte remnant` }; + } + + let database: Database | undefined; + let transactionOpen = false; + try { + database = new Database(observed.path, { readwrite: true, create: false }); + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + const lockedEntry = inspectTarget(observed.path, { allowSqliteSidecars: true }); + // SQLite may update file timestamps merely by opening a zero-byte database + // for BEGIN IMMEDIATE. Device/inode/size are the stable identity here; the + // transaction excludes content writers while we reclassify the database. + if (lockedEntry.kind !== "file" || !sameNodeAndSize(observed.identity, lockedEntry.identity)) { + return { ok: false, reason: "the coordinator changed before recovery acquired its SQLite lock" }; + } + if (lockedEntry.identity.size !== 0) { + return { ok: false, reason: "the coordinator stopped being zero-byte before recovery" }; + } + database.exec("ROLLBACK"); + transactionOpen = false; + database.close(); + database = undefined; + + const finalEntry = inspectTarget(observed.path); + if (finalEntry.kind !== "file" || !sameIdentity(lockedEntry.identity, finalEntry.identity)) { + return { ok: false, reason: "the coordinator changed before the backup move" }; + } + const backupPath = `${observed.path}.zero-byte-backup-${backupTimestamp(now)}`; + if (existsSync(backupPath)) return { ok: false, reason: "the same-directory backup path already exists" }; + renameSync(observed.path, backupPath); + const backupEntry = inspectTarget(backupPath); + // The rename itself can advance ctime, so post-move verification uses the + // stable filesystem object and byte size. The full timestamp identity was + // already revalidated immediately before rename while the source existed. + if (backupEntry.kind !== "file" || !sameNodeAndSize(finalEntry.identity, backupEntry.identity) || existsSync(observed.path)) { + return { ok: false, reason: "the coordinator backup move could not be verified" }; + } + return { ok: true, backupPath }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + const busy = errorCode(cause) === "SQLITE_BUSY" || errorCode(cause) === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); + return { ok: false, reason: busy ? "the coordinator is busy; stop active sync/service writers and retry" : message }; + } finally { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases the lock */ } + } + try { database?.close(); } catch { /* recovery already completed */ } + } +} diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 91f9374bc6..a8b1c28858 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -5,10 +5,11 @@ * sequence it is, rather than doubling in length around the lock. */ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { atomicWriteFile } from "../config"; import type { CodexWriteLockResult } from "./codex-write-lock"; +import { inspectCodexCoordinatorPath } from "./coordinator-doctor"; import { JOURNAL_PATH } from "./journal"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; import { @@ -43,21 +44,51 @@ export type CodexWriteCoordinationEligibility = | { kind: "legacy-uncoordinated"; reason: string } | { kind: "refused"; reason: string }; +/** + * A live SQLite creator exposes a zero-byte pathname before BEGIN IMMEDIATE. + * Requiring a settled filesystem age makes that scheduling window remain on + * the coordinated path while old crash remnants can use the legacy boundary. + */ +export const STABLE_ZERO_BYTE_COORDINATOR_AGE_MS = 1_000; + export function codexWriteCoordinationEligibility(deps: { coordinatorPath: () => string; residue: () => { kind: string }; integrationRecord: () => { kind: string }; + nowMs?: () => number; }): CodexWriteCoordinationEligibility { let coordinatorExists: boolean; + let coordinatorIsStableZeroByte = false; try { - coordinatorExists = existsSync(deps.coordinatorPath()); + const path = deps.coordinatorPath(); + coordinatorExists = existsSync(path); + if (coordinatorExists) { + const entry = lstatSync(path); + if (entry.isFile() && !entry.isSymbolicLink() && entry.size === 0) { + const diagnostic = inspectCodexCoordinatorPath(path); + if (diagnostic.kind === "zero-byte") { + const lastIdentityChange = Math.max(diagnostic.identity.mtimeMs, diagnostic.identity.ctimeMs); + coordinatorIsStableZeroByte = (deps.nowMs?.() ?? Date.now()) - lastIdentityChange + >= STABLE_ZERO_BYTE_COORDINATOR_AGE_MS; + } + } + } } catch (error) { return { kind: "refused", reason: `the coordinator path could not be resolved: ${String(error)}` }; } - // An existing coordinator is authoritative, and the lock owns validating it — - // including the unversioned and rowless cases it must refuse rather than adopt. - if (coordinatorExists) return { kind: "coordinated" }; + // Every existing coordinator remains authoritative unless it is proven to be + // an old, immutable SQLite-empty remnant. The age gate is part of that proof: + // a live creator exposes the same zero-byte pathname briefly before taking N, + // and sending that fresh file down the legacy path would bypass its lock. + // Non-empty, fresh, unsafe, changed, unversioned, and rowless files therefore + // stay coordinated and are validated/refused by the transaction owner. + // + // We do NOT initialize or adopt it here. Clean homes still enter the + // coordinated path, whose SQLite transaction safely initializes it. Routed + // or indeterminate legacy homes keep the same uncoordinated compatibility + // boundary they would have had if the remnant pathname were absent. + if (coordinatorExists && !coordinatorIsStableZeroByte) return { kind: "coordinated" }; const record = deps.integrationRecord(); if (record.kind === "invalid") { @@ -83,7 +114,9 @@ export function codexWriteCoordinationEligibility(deps: { */ return { kind: "legacy-uncoordinated", - reason: residue.kind === "residue" + reason: coordinatorIsStableZeroByte + ? "the coordinator is a zero-byte non-authoritative remnant and this routed home has not been adopted yet" + : residue.kind === "residue" ? "this home was routed before write coordination existed and has not been adopted yet" : "the existing native Codex state could not be classified, so it cannot seed a coordinator row", }; diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts index 27ce605530..ed00fca09f 100644 --- a/src/codex/transition-state.ts +++ b/src/codex/transition-state.ts @@ -37,7 +37,7 @@ import { samePathIdentity, } from "./user-identity"; -const COORDINATOR_SCHEMA_VERSION = 1; +export const CODEX_COORDINATOR_SCHEMA_VERSION = 1; const DURABLE_HISTORY_STATUSES = new Set(["converged", "pending", "running", "blocked", "unknown"]); const DURABLE_HISTORY_REASONS = new Set([ "db-busy", @@ -241,7 +241,7 @@ function rowToState(row: TransitionRow | null): CodexTransitionState { }; } -function readState(database: Database): CodexTransitionState { +export function readCodexCoordinatorState(database: Database): CodexTransitionState { const row = database.query(SELECT_TRANSITION_ROW).get(); return rowToState(row); } @@ -282,7 +282,7 @@ function assertInitialStateCanBeCreated(): void { function initialize(database: Database, databaseWasAbsent: boolean): void { const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version; - if (version !== 0 && version !== COORDINATOR_SCHEMA_VERSION) { + if (version !== 0 && version !== CODEX_COORDINATOR_SCHEMA_VERSION) { throw new CodexCoordinatorTransactionError("The coordinator database schema version is unsupported."); } if (!databaseWasAbsent && version === 0) { @@ -301,8 +301,8 @@ function initialize(database: Database, databaseWasAbsent: boolean): void { assertInitialStateCanBeCreated(); database.query(INITIALIZE_TRANSITION_ROW).run(new Date().toISOString()); } - if (version === 0) database.exec(`PRAGMA user_version = ${COORDINATOR_SCHEMA_VERSION}`); - readState(database); + if (version === 0) database.exec(`PRAGMA user_version = ${CODEX_COORDINATOR_SCHEMA_VERSION}`); + readCodexCoordinatorState(database); } function createCapability( @@ -336,7 +336,7 @@ function createCapability( expected.nativeGeneration, expected.currentTxId, ); - const state = readState(database); + const state = readCodexCoordinatorState(database); const update: TransitionStateUpdate = result.changes === 1 ? { kind: "updated", state } : { kind: "conflict", current: state }; @@ -451,7 +451,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code capability, expectation() { requireOpen(); - const state = readState(db); + const state = readCodexCoordinatorState(db); return { nativeBefore: state.nativeGeneration, nativeAfter: state.nativeGeneration + 1, @@ -460,7 +460,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code }, version() { requireOpen(); - const state = readState(db); + const state = readCodexCoordinatorState(db); return { nativeGeneration: state.nativeGeneration, currentTxId: state.currentTxId }; }, assertPublished(expectation) { @@ -468,7 +468,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code if (lastResult?.kind !== "updated") { throw new CodexCoordinatorTransactionError("The coordinator transition was not published."); } - const state = readState(db); + const state = readCodexCoordinatorState(db); if (state.nativeGeneration !== expectation.nativeAfter || state.currentTxId !== expectation.txId) { throw new CodexCoordinatorTransactionError("The coordinator published a different transition."); } @@ -540,7 +540,7 @@ function readCommittedState(): TransitionStateRead { try { database = new Database(path, { readonly: true }); database.exec("PRAGMA busy_timeout = 0"); - return { kind: "ready", state: readState(database) }; + return { kind: "ready", state: readCodexCoordinatorState(database) }; } catch (error) { return mapUnavailable(error); } finally { @@ -577,7 +577,7 @@ export const updateCodexHistoryTransition: UpdateCodexHistoryTransition = (expec database = new Database(currentCoordinatorDatabasePath(), { readwrite: true, create: false }); database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); transactionOpen = true; - const current = readState(database); + const current = readCodexCoordinatorState(database); if (current.nativeGeneration > 0 && current.historySchedule === null) { throw new CodexCoordinatorTransactionError("A positive transition cannot lose its direction."); } @@ -594,7 +594,7 @@ export const updateCodexHistoryTransition: UpdateCodexHistoryTransition = (expec expected.currentTxId, expected.currentTxId, ); - const state = readState(database); + const state = readCodexCoordinatorState(database); database.exec("COMMIT"); transactionOpen = false; return result.changes === 1 diff --git a/src/lib/errors.ts b/src/lib/errors.ts index c5523712dc..a7bbdb71e9 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -162,11 +162,16 @@ export function classifyError(status: number, type: string, message: string): Oc return { message, type: "invalid_request_error", code: "context_length_exceeded" }; } // "Cursor resource limit exceeded" is emitted only for explicit request-size overflow - // details (isCursorRequestTooLargeDetail in cursor-errors.ts); quota-style resource - // exhaustion arrives as "Cursor rate limit exceeded" and falls through to 429 below. + // details (isCursorRequestTooLargeDetail in cursor-errors.ts); "Cursor context limit + // exceeded" is the bare payload-overflow shape (isCursorZeroTokenResourceExhausted); + // quota-style resource exhaustion arrives as "Cursor rate limit exceeded" and falls + // through to 429 below. if (text.includes("cursor resource limit exceeded")) { return { message, type: "invalid_request_error", code: "tool_catalog_too_large" }; } + if (text.includes("cursor context limit exceeded")) { + return { message, type: "invalid_request_error", code: "context_length_exceeded" }; + } // The Cursor adapter's classified rate-limit prefix is authoritative: its DETAIL may echo // quota wording ("... quota exhausted") that would otherwise hit the insufficient_quota // branch below and break the planned retry-with-backoff contract (WP3 review blocker 1). @@ -306,6 +311,7 @@ export function inferHttpStatusFromAdapterMessage(message: string): number { // See classifyError: this prefix now only means explicit request-size overflow (400); // quota-style Cursor resource exhaustion carries the rate-limit prefix and maps to 429. if (lower.includes("cursor resource limit exceeded")) return 400; + if (lower.includes("cursor context limit exceeded")) return 400; if ( lower.includes("resource_exhausted") || lower.includes("resource exhausted") || diff --git a/src/oauth/cursor.ts b/src/oauth/cursor.ts index d7607cef83..d30bc33b87 100644 --- a/src/oauth/cursor.ts +++ b/src/oauth/cursor.ts @@ -101,6 +101,19 @@ function sleep(ms: number, signal?: AbortSignal): Promise { }); } +/** Terminal poll statuses (T07, senpi PR #905): the login is denied/expired — retrying cannot succeed. */ +const POLL_TERMINAL_STATUSES = new Set([400, 401, 403, 410]); + +export class CursorAuthTerminalError extends Error { + readonly status: number; + + constructor(status: number) { + super(`Cursor login rejected by the auth server (HTTP ${status}); start a new login`); + this.name = "CursorAuthTerminalError"; + this.status = status; + } +} + /** * Poll cursor.com for login completion. 404 = still pending (back off), 200 = tokens. * `baseDelayMs` is injectable so tests can avoid the real 1s cadence; production uses the default. @@ -135,9 +148,17 @@ export async function pollCursorAuth( return { accessToken: data.accessToken, refreshToken: data.refreshToken }; } + // T07: a terminal auth status means the login attempt itself is dead (denied, + // expired, revoked). Fail on the FIRST such response instead of burning the + // 3-strike retry budget and masking the reason behind a generic error. + if (POLL_TERMINAL_STATUSES.has(response.status)) { + throw new CursorAuthTerminalError(response.status); + } + throw new Error(`Cursor auth poll failed: ${response.status}`); } catch (err) { if (signal?.aborted) throw err instanceof Error ? err : new Error("Cursor login cancelled"); + if (err instanceof CursorAuthTerminalError) throw err; consecutiveErrors++; if (consecutiveErrors >= 3) { throw new Error("Too many consecutive errors during Cursor auth polling"); diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts new file mode 100644 index 0000000000..a83c93d216 --- /dev/null +++ b/src/providers/cursor-pool.ts @@ -0,0 +1,72 @@ +/** + * Weighted credential routing for Cursor accounts. + * + * Transfer from yelixir-dev/cursor-ai-proxy-bridge credentials.ts: + * weighted round-robin selection with per-credential auth-failure cooldown + * and one-retry failover on a different account before surfacing the error. + * + * OpenCodex already has JWT-based multi-account identification (src/oauth/cursor.ts) + * and Anthropic-specific 429 rotation; this module adds Cursor-aware weighted + * routing on top of those primitives. + */ + +export interface CursorCredential { + readonly id: string; + weight: number; +} + +interface CredentialState { + readonly credential: CursorCredential; + currentWeight: number; + disabledUntil: number; +} + +export class NoAvailableCursorCredentialError extends Error { + constructor(message = "No available Cursor credentials") { super(message); } +} + +export class CursorCredentialRouter { + private states: CredentialState[] = []; + private readonly cooldownMs: number; + + constructor(credentials: ReadonlyArray, cooldownMs = 300_000) { + this.cooldownMs = cooldownMs; + this.replace(credentials); + } + + replace(credentials: ReadonlyArray): void { + this.states = credentials.map(c => ({ + credential: { ...c, weight: Math.max(1, c.weight || 1) }, + currentWeight: 0, + disabledUntil: 0, + })); + } + + pick(excludeIds: ReadonlySet = new Set()): CursorCredential { + const now = Date.now(); + const candidates = this.states.filter(s => + !excludeIds.has(s.credential.id) && s.disabledUntil <= now, + ); + if (candidates.length === 0) throw new NoAvailableCursorCredentialError(); + let selected: CredentialState | undefined; + let totalWeight = 0; + for (const state of candidates) { + state.currentWeight += state.credential.weight; + totalWeight += state.credential.weight; + if (!selected || state.currentWeight > selected.currentWeight) selected = state; + } + if (!selected) throw new NoAvailableCursorCredentialError(); + selected.currentWeight -= totalWeight; + return { ...selected.credential }; + } + + disable(id: string): void { + const state = this.states.find(s => s.credential.id === id); + if (state) state.disabledUntil = Date.now() + this.cooldownMs; + } + + get snapshot(): ReadonlyArray<{ id: string; disabled: boolean }> { + const now = Date.now(); + return this.states.map(s => ({ id: s.credential.id, disabled: s.disabledUntil > now })); + } +} diff --git a/src/providers/derive.ts b/src/providers/derive.ts index c00df10bee..63cd1c9388 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -483,6 +483,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.supportsOpenAiWebSearchToolFields === undefined && entry.supportsOpenAiWebSearchToolFields !== undefined) { prov.supportsOpenAiWebSearchToolFields = entry.supportsOpenAiWebSearchToolFields; } + if (prov.supportsResponsesCustomTools === undefined && entry.supportsResponsesCustomTools !== undefined) { + prov.supportsResponsesCustomTools = entry.supportsResponsesCustomTools; + } if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov)); diff --git a/src/providers/fastwire.ts b/src/providers/fastwire.ts index 63cd642532..9098bd9df9 100644 --- a/src/providers/fastwire.ts +++ b/src/providers/fastwire.ts @@ -33,6 +33,7 @@ export interface FastPolicyAuthority { readonly providerAdapter: string; readonly providerAuthMode?: ProviderAuthKind; readonly fastWireDeclaration: FastWire | null | undefined; + readonly fastTierDescription?: string; readonly modelWireOverrideAllowed: boolean; readonly authTransport: FastPolicyAuthTransport; readonly capability: { @@ -55,6 +56,7 @@ export interface ResolvedFastPolicy { | "pin-unavailable"; readonly adapter: string; readonly fastWire: FastWire | null; + readonly fastTierDescription?: string; readonly forwardCallerTier: boolean; } @@ -222,7 +224,16 @@ export function resolveFastPolicy( else if (capability === undefined) eligibility = "unclassified"; else eligibility = "eligible"; - return { capability, eligibility, adapter, fastWire, forwardCallerTier }; + return { + capability, + eligibility, + adapter, + fastWire, + ...(authority.fastTierDescription !== undefined + ? { fastTierDescription: authority.fastTierDescription } + : {}), + forwardCallerTier, + }; } export function canonicalFastTierMarker(callerTier: string | undefined): "priority" | undefined { diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index e1ffc397fb..892b08462b 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -169,6 +169,7 @@ export async function resolveFirstUsableOpenAiSidecar( authContext.accountId, outcome, { + threadId: authContext.affinityKey, probeLeaseId: authContext.probeLeaseId, writerGeneration: authContext.writerGeneration, }, diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 4e61bb1e68..3fa208456f 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -225,8 +225,22 @@ export interface ProviderRegistryEntry { supportsServiceTier?: boolean; /** Registry default for OpenAI extended hosted web_search field support. */ supportsOpenAiWebSearchToolFields?: boolean; + /** Registry default for native Responses custom-tool support. */ + supportsResponsesCustomTools?: boolean; /** Registry default for exact model service-tier capability; explicit config keys win. */ modelSupportsServiceTier?: Record; + /** + * Registry-only service-tier defaults for an OAuth preset's explicit API-key transport. + * Applied only when `allowKeyAuthOverride` is true and the captured effective auth transport + * is key-based. Explicit provider config still wins field-by-field, including `false`. + */ + keyAuthServiceTier?: { + supportsServiceTier?: boolean; + modelSupportsServiceTier?: Record; + chatServiceTier?: boolean; + }; + /** Provider-specific copy for the Codex catalog's Fast tier. */ + fastTierDescription?: string; /** * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence * without changing provider ownership, routing, authentication, or config validation. @@ -1023,10 +1037,21 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ baseUrl: "https://api.x.ai/v1", authKind: "oauth", allowKeyAuthOverride: true, + // Priority Processing is documented for xAI's public API-key Chat Completions and + // Responses endpoints. OAuth is a separate Grok CLI subscription gateway and remains + // unclassified; do not turn this into a provider-wide supportsServiceTier declaration. + keyAuthServiceTier: { + supportsServiceTier: true, + chatServiceTier: true, + }, + fastTierDescription: "Priority processing, 2x token price", featured: true, oauthId: "xai", jawcodeBundle: "xai", supportsOpenAiWebSearchToolFields: false, + // Live A/B on 2026-08-20: xAI rejects native custom/custom_tool_call shapes while accepting + // the otherwise-identical request after the custom tool is lowered to a function. + supportsResponsesCustomTools: false, note: "Log in with your Grok account", // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index 2a09530d23..de71cb3f9e 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -70,11 +70,22 @@ function buildFastPolicyAuthority( capabilityProvider: ServiceTierCapabilityProvider = provider, ): FastPolicyAuthority { const registry = registryTransportMatch ? getProviderRegistryEntry(providerName) : undefined; + const authTransport = resolveProviderAuthTransport( + provider.adapter, + provider.authMode ?? registry?.authKind ?? "key", + provider.apiKeyTransport, + ); + const keyAuthDefaults = registry?.allowKeyAuthOverride === true + && (authTransport === "authorization_bearer" || authTransport === "x_api_key") + ? registry.keyAuthServiceTier + : undefined; const registryModelCapabilities = registry && registryModelServiceTierCapabilityApplies(registry, capabilityProvider) ? registry.modelSupportsServiceTier : undefined; - const providerCapability = capabilityProvider.supportsServiceTier ?? registry?.supportsServiceTier; + const providerCapability = capabilityProvider.supportsServiceTier + ?? keyAuthDefaults?.supportsServiceTier + ?? registry?.supportsServiceTier; const authority: FastPolicyAuthority = Object.freeze({ providerAdapter: provider.adapter, providerAuthMode: provider.authMode ?? registry?.authKind ?? "key", @@ -82,19 +93,23 @@ function buildFastPolicyAuthority( provider.fastWire !== undefined ? provider.fastWire : registry?.fastWire, { freeze: true }, ), + ...(registry?.fastTierDescription !== undefined + ? { fastTierDescription: registry.fastTierDescription } + : {}), modelWireOverrideAllowed: !isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig), - authTransport: resolveProviderAuthTransport( - provider.adapter, - provider.authMode ?? registry?.authKind ?? "key", - provider.apiKeyTransport, - ), + authTransport, capability: Object.freeze({ ...(providerCapability !== undefined ? { provider: providerCapability } : {}), models: Object.freeze({ ...(registryModelCapabilities ?? {}), + ...(keyAuthDefaults?.modelSupportsServiceTier ?? {}), ...(capabilityProvider.modelSupportsServiceTier ?? {}), }), - ...(provider.chatServiceTier !== undefined ? { chatServiceTier: provider.chatServiceTier } : {}), + ...(provider.chatServiceTier !== undefined + ? { chatServiceTier: provider.chatServiceTier } + : keyAuthDefaults?.chatServiceTier !== undefined + ? { chatServiceTier: keyAuthDefaults.chatServiceTier } + : {}), }), modelAdapters: Object.freeze({ ...(provider.modelAdapters ?? {}) }), hardPins: captureWireAdapterHardPins(providerName), diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index e7db3c32a6..d5d4e93b30 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -4,6 +4,13 @@ import { collectResponsesToolGroups } from "./tool-groups"; const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; +function routedCustomToolPassesThrough( + name: string, + supportsResponsesCustomTools: boolean | undefined, +): boolean { + return supportsResponsesCustomTools !== false && ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(name); +} + function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -34,7 +41,10 @@ export function routedCustomToolWireName(value: unknown): string | undefined { * Names of converted custom declarations after namespace lowering. Restoration uses these exact * wire identities so same-named function and custom children in different namespaces stay distinct. */ -function collectRoutedCustomToolWireNames(body: unknown): Set { +function collectRoutedCustomToolWireNames( + body: unknown, + supportsResponsesCustomTools?: boolean, +): Set { const names = new Set(); const groups = collectResponsesToolGroups(body); const bareWireNames = new Set(); @@ -54,7 +64,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set { if ( tool.type === "custom" && typeof tool.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(tool.name) + && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) ) { names.add(tool.name); continue; @@ -67,7 +77,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set { isPlainObject(child) && child.type === "custom" && typeof child.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(child.name) + && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) ) names.add(customToolWireName(tool.name, child.name)); } @@ -81,7 +91,10 @@ export function customToolItemId(id: unknown): unknown { return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; } -export function collectRoutedCustomToolNames(body: unknown): Set { +export function collectRoutedCustomToolNames( + body: unknown, + supportsResponsesCustomTools?: boolean, +): Set { const names = new Set(); const visit = (value: unknown): void => { if (Array.isArray(value)) { @@ -92,7 +105,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set { if ( value.type === "custom" && typeof value.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name) + && !routedCustomToolPassesThrough(value.name, supportsResponsesCustomTools) ) { names.add(value.name); } @@ -184,12 +197,15 @@ function rewriteForUpstream( return changed ? next : value; } -export function rewriteRoutedCustomToolsForUpstream(body: unknown): { +export function rewriteRoutedCustomToolsForUpstream( + body: unknown, + supportsResponsesCustomTools?: boolean, +): { body: unknown; names: Set; } { - const conversionNames = collectRoutedCustomToolNames(body); - const names = collectRoutedCustomToolWireNames(body); + const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools); + const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools); if (conversionNames.size === 0) return { body, names }; const callIds = new Set(); collectConvertedCallIds(body, conversionNames, callIds); diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 3f6cd42ea2..cbc90db605 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -268,9 +268,8 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { const groups = collectResponsesToolGroups(body); const plan = buildRewritePlan(groups); - // Deliberately not gated on the plan being non-empty: a turn whose catalog is gone still replays - // call items carrying a private `namespace`, and the routed compaction turn strips the whole tool - // surface before this runs. + // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still + // replay call items carrying a private `namespace`. const emitted = new Set(); const tools = Array.isArray(body.tools) ? rewriteToolList(body.tools, plan, emitted) : body.tools; diff --git a/src/router.ts b/src/router.ts index 35e34d75ca..47a604d77c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -366,6 +366,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider && registryEntry.supportsOpenAiWebSearchToolFields !== undefined ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields } : {}), + ...(provider.supportsResponsesCustomTools === undefined && registryEntry.supportsResponsesCustomTools !== undefined + ? { supportsResponsesCustomTools: registryEntry.supportsResponsesCustomTools } + : {}), ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index a8e160a206..325c4ffd08 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -258,6 +258,10 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", + // Terminal vision-describe marker (roadmap 180): the bridge rebuilds + // headers from the FORWARD_HEADERS allowlist, which would drop the raw + // header — so the fact is detected here and carried as an option flag. + ...(req.headers.get("x-opencodex-vision-describe") === "1" ? { visionDescribeTerminal: true } : {}), translatorBudget, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index b27f29c962..e00caea282 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -11,6 +11,7 @@ import type { AdmissionLease } from "../lib/admission"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; +import { isModelTextOnly } from "../vision"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, @@ -61,6 +62,12 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo if (rawBody.store === true || rawBody.background === true) return false; if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return false; if (rawBody.compaction_trigger !== undefined) return false; + // Vision sidecar coverage (roadmap 180): a text-only routed model with an + // image-bearing body must go through the Responses pipeline, whose plan + // site describes or strips the image. The native fast path has no vision + // handling, so letting it keep such a request forwards raw pixels to a + // model the operator declared blind. + if (isModelTextOnly(provider, route.modelId) && chatBodyCarriesImage(rawBody)) return false; if (Array.isArray(rawBody.tools)) { for (const tool of rawBody.tools) { if (!isRec(tool)) continue; @@ -72,6 +79,19 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo return true; } +/** Any messages[].content[] part of type image_url. */ +function chatBodyCarriesImage(rawBody: Rec): boolean { + const messages = rawBody.messages; + if (!Array.isArray(messages)) return false; + for (const message of messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (isRec(part) && part.type === "image_url") return true; + } + } + return false; +} + function chatCompletionJson(value: unknown): Rec | null { if (!isRec(value) || !Array.isArray(value.choices) || value.choices.length === 0) return null; return value; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 2fbee7d5a9..28e326fda9 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1073,13 +1073,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const section = body[field]; if (section === undefined || section === null) continue; if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400); - // The widened union applies to the WEB-SEARCH override only (roadmap 060). - // Vision keeps its two-backend contract — accepting a wider id there would - // persist a backend the vision resolver reads as unset, silently activating - // a backend the operator never chose (review F1). + // Both overrides now speak their full unions (roadmap 060 web, 170 + // vision revised). Vision's third arm is "routed" (loopback through the + // proxy's own router), never exa: exa is not an LLM, and accepting an + // unknown literal would persist a backend the vision resolver reads as + // unset (review F1's failure mode). const allowedBackends = field === "webSearchSidecar" ? ["openai", "anthropic", "xai", "gemini", "exa"] - : ["openai", "anthropic"]; + : ["openai", "anthropic", "routed"]; if (section.backend !== undefined && section.backend !== null && !allowedBackends.includes(section.backend as string)) { return jsonResponse({ error: `${field}.backend must be ${allowedBackends.join(", ")}, or null` }, 400); @@ -1094,8 +1095,18 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const requested = section.model; const candidates = await visionCandidateRows(config); const hint = section.backend === "anthropic" || section.backend === "openai" + || section.backend === "routed" ? section.backend : config.claudeCode?.visionSidecar?.backend; + // Same coherence rule as /api/sidecar-settings (roadmap 170 r2). + const effectiveBackend = hint ?? "openai"; + const namespaced = requested.includes("/"); + if (namespaced && effectiveBackend !== "routed") { + return jsonResponse({ error: `visionSidecar.model "${requested}" is provider-namespaced; it requires backend "routed"` }, 400); + } + if (!namespaced && effectiveBackend === "routed") { + return jsonResponse({ error: `visionSidecar.backend "routed" requires a provider-namespaced model ("provider/model"); got "${requested}"` }, 400); + } if (visionDescriberIsProvablyBlind(config, requested, candidates, hint)) { return jsonResponse(visionDescriberRejection("visionSidecar.model", requested, config, candidates), 400); } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 1e5e1ad2c6..0e7a0c8db6 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -113,8 +113,14 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{ // Match the runtime's one selected Anthropic executor for both backend fallback // and catalog reachability; resolving it once prevents the two projections drifting. const anthropicSidecar = findAnthropicVisionProvider(config); - const backend = resolveVisionBackend(vs.backend, anthropicSidecar); - const model = resolveEffectiveVisionModel(config, backend); + // The routed backend reports its own namespaced model verbatim: it is the + // dispatched value, and collapsing it through the legacy resolver would + // display a describer the runtime is not using (roadmap 190). + const routedActive = vs.backend === "routed" && !!vs.model && vs.model.includes("/"); + const backend = routedActive ? "routed" as const : resolveVisionBackend(vs.backend, anthropicSidecar); + const model = routedActive && vs.model + ? vs.model + : resolveEffectiveVisionModel(config, backend === "routed" ? resolveVisionBackend(undefined, anthropicSidecar) : backend); const reasoning = normalizeVisionReasoningForModel(model, vs.reasoning) ?? "low"; const models = await visionModelOptionsFor(config, anthropicSidecar); // Display-only grandfather: a persisted id stays selectable, but the write gate @@ -592,8 +598,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0) backends.push("openai"); - if (anthropicSidecar) backends.push("anthropic"); - // Neither side resolvable (fresh install, no login): fall back to both so the - // picker is populated rather than empty, matching the permissive-unknown rule. - return backends.length > 0 ? backends : ["openai", "anthropic"]; + const auth = resolveSidecarAuth(config); + // Preserve the caller's resolution for the anthropic side: the descriptor + // reads the shared auth module, but a caller that already resolved "no + // executor" must not see anthropic options it cannot dispatch. The filter + // applies to the ACTIVE set only — the fresh-install fallback below stays + // both universal sides, exactly the pre-widening behavior (test 6 pins it). + const active = VISION_BACKENDS + .filter(descriptor => descriptor.isActive(auth, config)) + .map(descriptor => descriptor.backend) + .filter(backend => backend !== "anthropic" || anthropicSidecar !== undefined); + // "routed" is active by construction, so the fresh-install fallback keys on + // the UNIVERSAL sides: when neither resolves, both are offered so the picker + // stays populated (permissive-unknown rule; test 6 pins it). + if (!active.includes("openai") && !active.includes("anthropic")) { + return ["openai", "anthropic", ...active]; + } + return active; } /** @@ -93,10 +108,16 @@ export async function visionModelOptionsFor( * When no catalog row matches, the caller's `backend` is only a HINT, never the * authority. Trusting it let a client launder a known-blind OpenAI model past the * gate by claiming `backend: "anthropic"`, since the id is absent from the - * Anthropic table and absence reads as "unknown". Both families are therefore - * consulted and any positive text-only verdict wins. That is safe precisely - * because the two vendor tables share no bare model id, so they can never - * disagree about one. + * Anthropic table and absence reads as "unknown". + * + * A NAMESPACED id ("provider/model", the routed-backend option shape) names + * its provider outright, so that provider's config row and metadata family + * are probed directly. A BARE id probes ALL configured provider families and + * any positive text-only verdict wins (roadmap 170: a bare `grok-4` is + * provably text-only in the xai vendor table and must not slip through a + * two-family probe). That is safe precisely because the vendor tables share + * no bare model id (collision scan in roadmap 160: openai 48, anthropic 26, + * xai 32, google 43, zero overlaps), so they can never disagree about one. */ export function visionDescriberIsProvablyBlind( config: OcxConfig, @@ -109,11 +130,25 @@ export function visionDescriberIsProvablyBlind( if (candidates.some(candidate => candidate.id === requested && modelAcceptsImageInput(config, candidate) === false)) return true; - const hinted: VisionSidecarBackend = backendHint === "anthropic" ? "anthropic" : "openai"; - const probed: VisionSidecarBackend[] = hinted === "anthropic" - ? ["anthropic", "openai"] - : ["openai", "anthropic"]; - return probed.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false); + // Namespaced routed id: the provider is named, probe it directly (config + // row enrichment + its metadata family both flow through the predicate). + const sep = requested.indexOf("/"); + if (sep > 0) { + const provider = requested.slice(0, sep); + const id = requested.slice(sep + 1); + if (modelAcceptsImageInput(config, { provider, id }) === false) return true; + // A namespaced candidate row (value shape) may also carry the proof. + return candidates.some(candidate => candidate.provider === provider && candidate.id === id + && modelAcceptsImageInput(config, candidate) === false); + } + + // Bare id: probe the base vendor families plus every configured provider — + // a positive text-only verdict from any source wins. + const families = new Set(["openai", "anthropic", "xai", "google-antigravity", ...Object.keys(config.providers ?? {})]); + const ordered = backendHint === "anthropic" + ? ["anthropic", ...[...families].filter(family => family !== "anthropic")] + : ["openai", ...[...families].filter(family => family !== "openai")]; + return ordered.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false); } /** The 400 body both routes return, so the two errors cannot diverge either. */ diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index adc9415ec6..4273dae40b 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -461,7 +461,6 @@ export async function handleResponsesCompact( } compactHostAdmissionLease = null; }; - const compactThreadId = req.headers.get("x-codex-parent-thread-id"); const connectMs = config.connectTimeoutMs ?? 200_000; // Takes its context explicitly: the alternate-account flow below records a rejection // against A while promoting B, then records B's own outcome. A closure over a single @@ -478,7 +477,7 @@ export async function handleResponsesCompact( if (!usesCodexForwardPoolAuth(ctx, route.provider)) return; recordCodexUpstreamOutcome(config, ctx.accountId, outcome, { ...meta, - threadId: compactThreadId, + threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined, fixedAccount: ctx.fixedAccount, modelId: selectedModelId, probeLeaseId: codexProbeLeaseId(ctx), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 22bf3c18c3..b1bb6fadeb 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -111,6 +111,7 @@ import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenA import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; import { applyCodexAuthContextToProvider, + codexPoolAffinityKey, CodexAccountCooldownError, codexMainProfileDrainingResponse, cooldownErrorResponse, @@ -139,6 +140,7 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-m import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug"; import { computeQuotaCooldown, + codexQuotaScopeForModel, formatCodexProviderForLog, previewCodexAccountForRequest, recordCodexUpstreamOutcome, @@ -328,11 +330,10 @@ export function adapterNeedsForcedContinuation(name: string): boolean { export function sidecarOutcomeRecorder( config: OcxConfig, authCtx: CodexAuthContext, - threadId?: string | null, ): ((outcome: CodexUpstreamOutcome) => void) | undefined { return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId, + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, probeLeaseId: authCtx.probeLeaseId, probeQuotaScope: authCtx.probeQuotaScope, @@ -946,7 +947,7 @@ async function retryCodexPoolOnAlternateAccount( const recordFirstOutcome = (): void => { recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, { ...quotaMeta, - threadId: req.headers.get("x-codex-parent-thread-id"), + threadId: firstAuthCtx.affinityKey, modelId: route.modelId, probeLeaseId: codexProbeLeaseId(firstAuthCtx), probeQuotaScope: codexProbeQuotaScope(firstAuthCtx), @@ -1081,7 +1082,6 @@ export function codexForwardTerminalOutcomeRecorder( provider: OcxProviderConfig, modelId?: string, logCtx?: RequestLogContext, - threadId?: string | null, ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined { if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined; return (status, httpStatusOverride) => { @@ -1090,7 +1090,7 @@ export function codexForwardTerminalOutcomeRecorder( // request. Don't penalize account health; record success to clear any // prior soft-avoid so a healthy account isn't stuck avoided. recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { - threadId, + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, modelId, probeLeaseId: codexProbeLeaseId(authCtx), @@ -1112,7 +1112,7 @@ export function codexForwardTerminalOutcomeRecorder( ? 200 : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId, + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, modelId, probeLeaseId: codexProbeLeaseId(authCtx), @@ -1227,6 +1227,15 @@ export interface HandleResponsesOptions { onConsumedComboFailure?: (failure: ConsumedComboFailure) => void; /** Caller-owned for Chat/Claude replay; omitted only at genuine Responses ingress. */ translatorBudget?: TranslatorBudget; + /** + * Terminal vision-describe marker (roadmap 180): true when the inbound + * request IS the vision sidecar's own loopback describe call. The plan site + * then STRIPS images instead of planning another describe — a depth cap of 1 + * that holds under predicate drift and combo re-resolution. The Chat surface + * detects the raw `x-opencodex-vision-describe` header before its bridge + * rebuilds headers and carries the fact through this flag. + */ + visionDescribeTerminal?: boolean; } @@ -2177,6 +2186,27 @@ async function handleResponsesInner( if (inboundClientThreadId) { parsed._clientThreadId = inboundClientThreadId; parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId }; + } else if ( + options.inboundWire === "anthropic" + && options.promptCacheKeyIsSharedCohort !== true + && typeof parsed.options.promptCacheKey === "string" + && parsed.options.promptCacheKey.trim().length > 0 + ) { + // Claude Code has no Codex parent-thread header, but its metadata.user_id is + // translated into a stable per-session prompt_cache_key. Use it as the replay + // thread identity so Gemini thought signatures are remembered by call_id for + // Anthropic Messages clients too (#1735/#1926). Keep `_clientThreadId` unset so + // existing provider session-id derivation (first-user-text fallback) is unchanged. + // Normalize through anthropicSessionKeyFromParts so overlong keys are hashed and + // trimming matches the affinity/session-key path exactly (no raw >128-char ids). + const normalizedCacheKey = anthropicSessionKeyFromParts({ + promptCacheKey: parsed.options.promptCacheKey, + // The enclosing branch already proves this is not the shared cohort. + promptCacheKeyIsSharedCohort: false, + }); + if (normalizedCacheKey) { + parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey }; + } } } catch (err) { if (isTranslatorBudgetExceededError(err)) { @@ -2278,6 +2308,7 @@ async function handleResponsesInner( let subagentFallbackPreviewAccountId: string | null | undefined; let subagentQuotaFailureModel = parsed.modelId; const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null; + const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null; try { if ( @@ -2293,12 +2324,15 @@ async function handleResponsesInner( // Preview the preferred Codex account without acquiring a probe lease or refreshing // tokens — auth is resolved only after the final route is selected. if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) { - const threadId = req.headers.get("x-codex-parent-thread-id"); + // The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId), + // so the preview must read the same scope slot — an undefined scope would map to the + // "legacy" affinity bucket and never find a binding made under "shared" or a native + // model scope, making the preview diverge from the account that actually authenticates. const previewAccountId = previewCodexAccountForRequest( - threadId, + poolAffinityKey, config, Date.now(), - undefined, + codexQuotaScopeForModel(route.modelId), previewSelectionOptions, ); subagentFallbackPreviewAccountId = previewAccountId; @@ -2725,7 +2759,15 @@ async function handleResponsesInner( // Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each // attached image through the selected sidecar backend and replace it with text BEFORE the main // call, so the text-only model can reason about it. - const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar); + // Terminal describe fence (roadmap 180): the sidecar's OWN loopback describe + // call must never plan another describe. The flag arrives from the Chat + // surface (whose bridge rebuilds headers) or as the raw header for native + // Responses callers. Marked + text-only routed model → strip, depth cap 1. + const visionDescribeTerminal = options.visionDescribeTerminal === true + || req.headers.get("x-opencodex-vision-describe") === "1"; + const visionPlan = visionDescribeTerminal + ? undefined + : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar); const recordSidecarOutcome = openAiSidecar?.recordOutcome; if (visionPlan) { await describeImagesInPlace( @@ -3005,7 +3047,7 @@ async function handleResponsesInner( } if (usesCodexForwardPoolAuth(authCtx, route.provider)) { recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId: req.headers.get("x-codex-parent-thread-id"), + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, modelId: route.modelId, probeLeaseId: codexProbeLeaseId(authCtx), @@ -3388,7 +3430,6 @@ async function handleResponsesInner( route.provider, route.modelId, logCtx, - req.headers.get("x-codex-parent-thread-id"), ); const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream; // Capture quota from upstream response for multi-account tracking @@ -3429,7 +3470,7 @@ async function handleResponsesInner( )) { recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, { ...quotaMeta, - threadId: req.headers.get("x-codex-parent-thread-id"), + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, modelId: route.modelId, probeLeaseId: codexProbeLeaseId(authCtx), diff --git a/src/service.ts b/src/service.ts index a00e616ba1..cf61a657fb 100644 --- a/src/service.ts +++ b/src/service.ts @@ -3274,6 +3274,7 @@ export async function serviceStatusReport( } export function normalizeServiceSubcommand(sub?: string): string { + if (sub === "restart") return "repair"; return sub ?? "install"; } @@ -3283,6 +3284,119 @@ export interface ParsedServiceArgs { invalid: string[]; } +export type ServiceInstallationState = "installed" | "absent" | "unknown"; + +export interface ServiceInstallationProbe { + state: ServiceInstallationState; + detail?: string; +} + +export interface ServiceInstallationProbeHooks { + platform?: NodeJS.Platform; + exists?: (path: string) => boolean; + probeWindowsTask?: () => WindowsSchedulerTaskProbe; + nativeStatus?: () => WinswStatus; +} + +/** + * Read only enough registration state to choose between install and repair. + * Windows must keep query failure distinct from proven absence: treating an + * unreadable scheduler/SCM as absent would send a bare command into the + * elevated registration path and recreate the original #2287 failure. + */ +export function probeServiceInstallation( + hooks: ServiceInstallationProbeHooks = {}, +): ServiceInstallationProbe { + const platform = hooks.platform ?? process.platform; + const exists = hooks.exists ?? existsSync; + if (platform === "darwin") { + return { state: exists(plistPath()) ? "installed" : "absent" }; + } + if (platform === "linux") { + return { state: exists(unitPath()) ? "installed" : "absent" }; + } + if (platform !== "win32") return { state: "absent" }; + + let scheduler: WindowsSchedulerTaskProbe; + try { + scheduler = (hooks.probeWindowsTask ?? probeWindowsSchedulerTask)(); + } catch (cause) { + scheduler = { status: "unknown", detail: schtasksErrorDetail(cause) }; + } + let native: WinswStatus; + try { + native = (hooks.nativeStatus ?? statusWinswRaw)(); + } catch { + native = "unknown"; + } + + if (scheduler.status === "present" || native === "started" || native === "stopped") { + return { state: "installed" }; + } + if (scheduler.status === "unknown" || native === "unknown") { + const parts = [ + scheduler.status === "unknown" ? `Task Scheduler: ${scheduler.detail}` : null, + native === "unknown" ? "WinSW status could not be determined" : null, + ].filter((part): part is string => Boolean(part)); + return { state: "unknown", detail: parts.join("; ") }; + } + return { state: "absent" }; +} + +/** + * A bare invocation is an idempotent "make the installed service current" + * operation. First-time setup still installs, but an existing registration must + * use the repair path so Windows does not re-run the elevated `schtasks /create`. + * Backend flags remain an explicit install request because they select which + * registration mechanism to create. + */ +export function selectServiceSubcommand( + parsed: ParsedServiceArgs, + options: { hasExplicitSubcommand: boolean; installed: boolean }, +): string { + if (!options.hasExplicitSubcommand && parsed.backend === null && options.installed) return "repair"; + return parsed.sub; +} + +export type ServiceCommandPlan = + | { ok: true; parsed: ParsedServiceArgs; command: string } + | { ok: false; message: string }; + +export function planServiceCommand( + args: string[], + options: { platform?: NodeJS.Platform; probeInstallation?: () => ServiceInstallationProbe } = {}, +): ServiceCommandPlan { + const parsed = parseServiceArgs(args); + if (parsed.invalid.length > 0) { + return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` }; + } + if (parsed.backend && parsed.sub !== "install") { + return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." }; + } + if (parsed.backend === "native" && (options.platform ?? process.platform) !== "win32") { + return { ok: false, message: "--native (WinSW) is Windows-only." }; + } + + const hasExplicitSubcommand = args.some(arg => !arg.startsWith("--")); + let installed = false; + if (!hasExplicitSubcommand && parsed.backend === null) { + const probe = (options.probeInstallation ?? probeServiceInstallation)(); + if (probe.state === "unknown") { + const suffix = probe.detail ? ` (${probe.detail})` : ""; + return { + ok: false, + message: `Could not safely determine whether the service is installed${suffix}. Run 'ocx service status' and retry; use explicit 'ocx service install' only after confirming it is absent.`, + }; + } + installed = probe.state === "installed"; + } + return { + ok: true, + parsed, + command: selectServiceSubcommand(parsed, { hasExplicitSubcommand, installed }), + }; +} + /** * `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the * subcommand; backend flags are only meaningful for `install` (validated by the caller). @@ -3308,20 +3422,13 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs { } export async function serviceCommand(...args: (string | undefined)[]): Promise { - const parsed = parseServiceArgs(args.filter((a): a is string => Boolean(a))); - const command = parsed.sub; - if (parsed.invalid.length > 0) { - console.error(`Unknown service option: ${parsed.invalid.join(" ")}`); - process.exit(1); - } - if (parsed.backend && command !== "install") { - console.error("--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend."); - process.exit(1); - } - if (parsed.backend === "native" && process.platform !== "win32") { - console.error("--native (WinSW) is Windows-only."); + const filteredArgs = args.filter((a): a is string => Boolean(a)); + const plan = planServiceCommand(filteredArgs); + if (!plan.ok) { + console.error(plan.message); process.exit(1); } + const { parsed, command } = plan; if (command === "repair") { assertServiceEnvironmentMatchesInstall(); assertServiceAuthEnvironment(); @@ -3458,9 +3565,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise requestedServiceTier @@ -405,9 +413,9 @@ function isConfirmedFast(tier?: ServiceTierInput): boolean { * normalized billable input — normalization subtracts cache read/write, so a * cache-heavy long prompt would fall below the boundary and under-bill. * - * Skipped entirely for a response-confirmed Fast request: OpenAI does not serve - * long context in Fast mode, so the two are mutually exclusive regimes rather - * than composable multipliers. + * A provider's declaration decides how a response-confirmed priority tier relates to this band. + * OpenAI declares the bands exclusive. xAI publishes neither a combined rate nor an exclusion, + * so its long-context rate remains the known lower bound instead of inventing a stacked multiplier. */ function applyContextTier( cost4: Cost4, @@ -415,25 +423,27 @@ function applyContextTier( modelId: string, rawInputTokens: number | undefined, tier?: ServiceTierInput, -): [Cost4, ContextTierName | undefined] { - if (rawInputTokens === undefined) return [cost4, undefined]; - if (isConfirmedFast(tier)) return [cost4, undefined]; +): [Cost4, ContextTierName | undefined, boolean] { + if (rawInputTokens === undefined) return [cost4, undefined, false]; const rule = findContextTier(baseProviderLabel(provider), modelId); - if (!rule || !isLongContext(rule, rawInputTokens)) return [cost4, undefined]; + if (!rule || !isLongContext(rule, rawInputTokens)) return [cost4, undefined, false]; + const confirmedFast = isConfirmedFast(tier); + if (confirmedFast && rule.confirmedPriorityRelation === "exclusive") { + return [cost4, undefined, false]; + } return [{ input: cost4.input * rule.multiplier.input, output: cost4.output * rule.multiplier.output, cacheRead: cost4.cacheRead * rule.multiplier.cacheRead, cacheWrite: cost4.cacheWrite * rule.multiplier.cacheWrite, - }, "long"]; + }, "long", confirmedFast && rule.confirmedPriorityRelation === "lower-bound"]; } /** - * Apply the OpenAI priority-tier multiplier to a Cost4 when applicable. + * Apply a declared provider/model priority-tier multiplier to a Cost4 when applicable. * Returns [effectiveCost4, multiplier]. Multiplier is 1 (no-op) when: * - serviceTier is not "priority" - * - provider is not a canonical OpenAI forward provider - * - model is not in PRIORITY_MULTIPLIERS + * - no exact provider/model rule exists */ function applyPriorityMultiplier( cost4: Cost4, @@ -443,8 +453,9 @@ function applyPriorityMultiplier( ): [Cost4, number] { if (tierScalar(serviceTier) !== "priority") return [cost4, 1]; const base = baseProviderLabel(provider); - if (!OPENAI_TIER_PROVIDER_IDS.has(base)) return [cost4, 1]; - const multiplier = resolvePriorityMultiplier(modelId); + const rule = findPriorityPricingRule(base, modelId); + if (rule?.requiresResponseConfirmation && !isConfirmedFast(serviceTier)) return [cost4, 1]; + const multiplier = rule?.multiplier ?? 1; if (multiplier === 1) return [cost4, 1]; return [{ input: cost4.input * multiplier, @@ -495,16 +506,17 @@ export function estimateAttemptCost( const attemptServiceTier = attempt.tierOutcome ? serviceTierContextFromOutcome(attempt.tierOutcome) : serviceTier; - const [tieredCost4, contextTier] = applyContextTier( + const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier( price.cost4, attempt.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier, ); - // Exclusive both ways: if the long rate applied, the request was NOT served as - // Fast (Fast does not support long context), so the Fast multiplier must not - // also apply — otherwise a downgraded request bills at both rates. + // A published long-context row owns the numeric estimate. OpenAI declares that band + // exclusive with Fast; xAI's confirmed combination is deliberately left unmultiplied + // and marked as a lower bound because no combined price has been published. const [effectiveCost4, multiplier] = contextTier ? [tieredCost4, 1] as const : applyPriorityMultiplier(tieredCost4, attempt.provider, attempt.model, attemptServiceTier); - const priorityLowerBound = isOpenRouterPriorityLowerBound(attempt.provider, attempt.tierOutcome); + const priorityLowerBound = contextPriorityLowerBound + || isOpenRouterPriorityLowerBound(attempt.provider, attempt.tierOutcome); return { ordinal: attempt.ordinal, provider: attempt.provider, @@ -514,8 +526,8 @@ export function estimateAttemptCost( cost: calculateCost(tokens, effectiveCost4), estimated: isEstimated(attempt.usage, attempt.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), - ...(priorityLowerBound ? { priorityLowerBound: true } : {}), ...(contextTier ? { contextTier } : {}), + ...(priorityLowerBound ? { priorityLowerBound: true } : {}), }; } @@ -557,8 +569,10 @@ export function estimateComboCost( ...(estimates.some(est => est.priorityMultiplier && est.priorityMultiplier !== 1) ? { priorityMultiplier: estimates.find(est => est.priorityMultiplier)?.priorityMultiplier } : {}), - ...(estimates.some(est => est.priorityLowerBound) ? { priorityLowerBound: true } : {}), ...(estimates.some(est => est.contextTier) ? { contextTier: "long" as const } : {}), + ...(estimates.every(est => est.priorityLowerBound === true) + ? { priorityLowerBound: true as const } + : {}), }; } @@ -579,13 +593,13 @@ export function estimateRequestCost( if (!tokens) return null; const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays); if (!price) return null; - const [tieredCost4, contextTier] = applyContextTier( + const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier( price.cost4, input.provider, input.model, input.usage.inputTokens, input.serviceTier, ); const [effectiveCost4, multiplier] = contextTier ? [tieredCost4, 1] as const : applyPriorityMultiplier(tieredCost4, input.provider, input.model, input.serviceTier); - const priorityLowerBound = isOpenRouterPriorityLowerBound( + const priorityLowerBound = contextPriorityLowerBound || isOpenRouterPriorityLowerBound( input.provider, typeof input.serviceTier === "object" ? input.serviceTier.tierOutcome : undefined, ); @@ -595,8 +609,8 @@ export function estimateRequestCost( cost: calculateCost(tokens, effectiveCost4), estimated: isEstimated(input.usage, input.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), - ...(priorityLowerBound ? { priorityLowerBound: true } : {}), ...(contextTier ? { contextTier } : {}), + ...(priorityLowerBound ? { priorityLowerBound: true } : {}), }; } diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 64a42e87e7..af94b8b262 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -181,6 +181,30 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ { provider: "cursor", modelId: "auto", cost4: { input: 1.25, output: 6, cacheRead: 0.25, cacheWrite: 1.25 }, source: "https://docs.cursor.com/account/pricing + https://cursor.com/blog/aug-2025-pricing", verifiedAt: "2026-07-20", status: "verified" }, ]; +/** + * Exact official corrections for stale nonzero catalog rows. These are intentionally separate + * from fallback overlays: they win over the bundled row only for the declared provider/model and + * therefore cannot reprice routed resellers that reuse the same model slug. + */ +export const VERIFIED_PRICE_OVERRIDES: readonly ExpectedPriceOverlay[] = [ + { + provider: "xai", + modelId: "grok-4.6", + cost4: { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, + source: "https://docs.x.ai/developers/pricing", + verifiedAt: "2026-08-18", + status: "verified", + }, +]; + +export function findVerifiedPriceOverride( + provider: string, + modelId: string, + overrides: readonly ExpectedPriceOverlay[] = VERIFIED_PRICE_OVERRIDES, +): ExpectedPriceOverlay | undefined { + return overrides.find(row => row.provider === provider && row.modelId === modelId); +} + /** * Exact-key overlay lookup. Returns verified first, then verified-derived. * NEVER returns "unverified" rows — fail-closed is enforced in code, not just docs. @@ -196,12 +220,7 @@ export function findExpectedPriceOverlay( ?? exact.find(row => row.status === "verified-derived"); } -/** - * OpenAI Fast mode (`service_tier=priority`) price multipliers by model slug. - * Source: https://openai.com/api-fast-mode/ (2026-07-31). - * Fast pricing applies uniformly to all token types (input, output, cache). - * Models not listed here fall back to 1× (no multiplier). - */ +/** OpenAI Fast price multipliers retained as a compatibility export. */ export const PRIORITY_MULTIPLIERS: Readonly> = { "gpt-5.6-sol": 2, // Post-price-cut Fast tables (https://openai.com/api-fast-mode/, 2026-08-05): @@ -219,6 +238,52 @@ export function resolvePriorityMultiplier(modelId: string): number { return PRIORITY_MULTIPLIERS[modelId] ?? 1; } +export interface PriorityPricingRule { + provider: string; + modelId: string; + multiplier: number; + /** Apply the premium only after the upstream response confirms this tier. */ + requiresResponseConfirmation?: true; + source: string; + verifiedAt: string; +} + +const OPENAI_FAST_PRICING = "https://openai.com/api-fast-mode/"; +const XAI_PRIORITY_PRICING = "https://docs.x.ai/developers/advanced-api-usage/priority-processing"; + +/** + * Exact provider/model priority premiums. Routed resellers never inherit a vendor rule merely + * because they reuse its model slug. Multipliers apply uniformly after cache discounts. + */ +export const PRIORITY_PRICING_RULES: readonly PriorityPricingRule[] = [ + ...["openai", "openai-apikey"].flatMap(provider => + Object.entries(PRIORITY_MULTIPLIERS).map(([modelId, multiplier]): PriorityPricingRule => ({ + provider, + modelId, + multiplier, + source: OPENAI_FAST_PRICING, + verifiedAt: "2026-08-05", + })), + ), + ...["grok-4.5", "grok-4.6"].map((modelId): PriorityPricingRule => ({ + provider: "xai", + modelId, + multiplier: 2, + requiresResponseConfirmation: true, + source: XAI_PRIORITY_PRICING, + verifiedAt: "2026-08-18", + })), +]; + +/** Exact provider/model priority-pricing lookup. */ +export function findPriorityPricingRule( + provider: string, + modelId: string, + rules: readonly PriorityPricingRule[] = PRIORITY_PRICING_RULES, +): PriorityPricingRule | undefined { + return rules.find(rule => rule.provider === provider && rule.modelId === modelId); +} + /** * Long-context pricing tiers (#908). Several vendors reprice the ENTIRE request * once the prompt crosses a published input-token threshold, so a flat Cost4 @@ -244,6 +309,8 @@ export interface ContextTier { inclusive: boolean; /** Per-field factor from the short rate to the published long rate. */ multiplier: Cost4; + /** Published relationship between confirmed priority and long-context bands. */ + confirmedPriorityRelation?: "exclusive" | "lower-bound"; source: string; verifiedAt: string; } @@ -277,6 +344,7 @@ export const CONTEXT_TIERS: readonly ContextTier[] = [ thresholdInputTokens: 272_000, inclusive: false, multiplier: OPENAI_LONG_CONTEXT, + confirmedPriorityRelation: "exclusive", source: OPENAI_PRICING_DOC, verifiedAt: "2026-08-03", })), @@ -287,19 +355,21 @@ export const CONTEXT_TIERS: readonly ContextTier[] = [ thresholdInputTokens: 200_000, inclusive: true, multiplier: UNIFORM_DOUBLE, + confirmedPriorityRelation: "lower-bound", source: "https://docs.x.ai/developers/pricing", verifiedAt: "2026-08-03", }, { - // 260813: grok-4.6 long-context tier mirrored from grok-4.5; the official pricing row - // was not yet published when the model page went up, so treat as provisional. + // xAI publishes the whole-request >=200k band for grok-4.6. Its combination with + // Priority Processing is not published, so confirmed priority uses this row as a lower bound. provider: "xai", modelId: "grok-4.6", thresholdInputTokens: 200_000, inclusive: true, multiplier: UNIFORM_DOUBLE, + confirmedPriorityRelation: "lower-bound", source: "https://docs.x.ai/developers/pricing", - verifiedAt: "2026-08-13", + verifiedAt: "2026-08-18", }, { // daybreak-blue-latest aliases gpt-5.6-sol, which publishes the full long-context row diff --git a/src/vision/backends.ts b/src/vision/backends.ts new file mode 100644 index 0000000000..b9f42b8408 --- /dev/null +++ b/src/vision/backends.ts @@ -0,0 +1,97 @@ +/** + * Which backends may DESCRIBE images for the vision sidecar, and which + * candidate rows each can describe through (#2188 vision rules; roadmap 170 + * REVISED: the "routed" backend). + * + * A SIBLING of WEB_SEARCH_BACKENDS, not a shared table: vision has no + * per-model probe (rule 2 is "− provably text-only", enforced by + * modelAcceptsImageInput, not here), carries per-side baseline models, and + * excludes non-LLM backends like exa. + * + * Three backends, not one per provider: "openai" and "anthropic" carry auth + * semantics loopback routing cannot replicate (forwarded ChatGPT headers, + * OAuth beta fences) and their defaults must not drift. Every OTHER + * picker-visible provider row reaches the describer through "routed" — a + * loopback self-fetch of the proxy's own /v1/chat/completions, where the + * router and adapters already speak each provider's wire. That is what makes + * this table closed under provider growth: a new provider needs no new + * describe executor. + */ +import type { OcxConfig } from "../types"; +import type { SidecarAuthState } from "../sidecar/auth"; +import { listOpenAiForwardSidecarCandidates } from "../providers/openai-sidecar"; +import type { VisionCandidateModel, VisionSidecarBackend } from "./eligibility"; + +export interface VisionBackendDescriptor { + backend: VisionSidecarBackend; + /** Liveness signal for this backend. */ + isActive(auth: SidecarAuthState, config: OcxConfig): boolean; + /** Which candidate rows this backend's describe executor can actually run. */ + candidateMatch(candidate: VisionCandidateModel, auth: SidecarAuthState): boolean; + /** + * Default entry for this side: cheap, image-capable, present in every + * deployment. Only the two universal sides carry one — "routed" has no + * universal model to name. + */ + baseline?: string; + /** Stable option ordering (baselines first within a side). */ + rank: number; +} + +export const VISION_BACKENDS: readonly VisionBackendDescriptor[] = [ + { + backend: "openai", + // The OpenAI describer needs a CANONICAL ChatGPT forward provider, not + // merely a provider keyed "openai" — same predicate the runtime sidecar + // resolver uses. Deliberately NOT auth.isCodexAuth: tightening to a live + // credential here would change which options a fresh install sees, and + // the options list is a suggestion surface, not the write gate. + isActive: (_auth, config) => listOpenAiForwardSidecarCandidates(config).length > 0, + candidateMatch: candidate => candidate.native === true || candidate.provider === "openai", + baseline: "gpt-5.6-luna", + rank: 0, + }, + { + backend: "anthropic", + isActive: auth => auth.isAnthropicAuth, + // The runtime dispatches through exactly ONE Anthropic provider — the + // resolved OAuth row. Same-adapter keyed rows are unreachable (see + // visionBackendForCandidate's original stance). + candidateMatch: (candidate, auth) => candidate.provider === auth.anthropicProviderName, + baseline: "claude-haiku-4-5", + rank: 1, + }, + { + backend: "routed", + // Always offered: options only materialize when a matching picker row + // exists, and the row's own provider config is the liveness signal — the + // loopback request fails closed through ordinary routing errors. + isActive: () => true, + // Any row the other two executors do NOT own. Auth-slot rows are + // entitlements of the openai/anthropic sides and never route here. + candidateMatch: (candidate, auth) => + candidate.native !== true + && candidate.provider !== "openai" + && candidate.provider !== auth.anthropicProviderName, + rank: 2, + }, +]; + +export function visionBackendDescriptor(backend: VisionSidecarBackend): VisionBackendDescriptor { + const descriptor = VISION_BACKENDS.find(entry => entry.backend === backend); + if (!descriptor) throw new Error(`unknown vision backend "${backend}"`); + return descriptor; +} + +/** + * The active backend set for option generation. Falls back to the two + * UNIVERSAL sides when neither is active (fresh install: picker stays + * populated, permissive-unknown rule); "routed" is active by construction. + */ +export function activeVisionBackends(auth: SidecarAuthState, config: OcxConfig): VisionSidecarBackend[] { + const active = VISION_BACKENDS.filter(entry => entry.isActive(auth, config)).map(entry => entry.backend); + return active.includes("openai") || active.includes("anthropic") + ? active + : ["openai", "anthropic", ...active.filter(backend => backend === "routed")]; +} + diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 7737f67844..06a4ecce02 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -26,15 +26,27 @@ import { nativeInputModalities } from "../codex/catalog/metadata"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import { enrichProviderFromRegistry } from "../providers/derive"; -/** The two wire protocols `planVisionSidecar` can actually dispatch to. */ -export type VisionSidecarBackend = "openai" | "anthropic"; +/** + * The wire protocols `planVisionSidecar` can dispatch to (#2188 roadmap 170 + * REVISED). "routed" describes through the proxy's OWN router via loopback — + * one executor for every non-forward, non-OAuth-Anthropic provider row. + */ +export type VisionSidecarBackend = "openai" | "anthropic" | "routed"; + +/** The two sides every deployment has; also the empty-auth fallback set. */ +export type UniversalVisionBackend = "openai" | "anthropic"; /** * Default entry per backend: cheap, image-capable, and present in every deployment. Offered * whenever its side is enabled, and withheld only when that provider explicitly lists it as a * model the sidecar describes FOR — never merely because a metadata table stayed silent. + * + * Keyed by the UNIVERSAL subset on purpose (roadmap 170, audit blocker A): + * xai/gemini are auth-gated sides whose catalogs are present whenever the side + * is, so they carry no baseline, and a narrow-key total record documents that + * without sprinkling non-null assertions at the consumers. */ -export const BASELINE_VISION_MODELS: Record = { +export const BASELINE_VISION_MODELS: Record = { openai: "gpt-5.6-luna", anthropic: "claude-haiku-4-5", }; @@ -146,7 +158,7 @@ function isVisionEligibleModelWithCache( return modelAcceptsImageInputWithCache(config, candidate, cache) !== false; } -/** Which executor can describe through this row, or undefined when neither can. */ +/** Which executor can describe through this row. */ export function visionBackendForCandidate( config: Pick, candidate: VisionCandidateModel, @@ -158,17 +170,17 @@ export function visionBackendForCandidate( // Messages wire is not enough: a key-auth row of the same adapter is unreachable, and an // option that cannot be dispatched is worse than a missing one, because selecting it fails // at describe time rather than at pick time. - // - // So the executor's name is REQUIRED for an Anthropic suggestion. When the caller has no - // executor to name, no catalog row qualifies; the side's baseline is added separately and - // keeps the picker populated. Narrowing here never widens the write gate, which is a - // different predicate (`modelAcceptsImageInput`) and still treats unknown as allowed. - if (anthropicProviderName === undefined) return undefined; - return candidate.provider === anthropicProviderName ? "anthropic" : undefined; + if (anthropicProviderName !== undefined && candidate.provider === anthropicProviderName) { + return "anthropic"; + } + // EVERY other provider row describes through the proxy's own router + // (roadmap 170 revised): the loopback executor covers all provider wires, + // so no row is left without an executor. + return "routed"; } function baselineCandidate( - backend: VisionSidecarBackend, + backend: UniversalVisionBackend, anthropicProviderName: string | undefined, ): VisionCandidateModel { return { @@ -181,12 +193,13 @@ function baselineCandidate( } /** - * The picker's option list: every eligible row reachable by one of the two - * executors, plus each enabled side's baseline unless that baseline is explicitly - * excluded, de-duplicated and stably ordered (openai side first, baselines first - * within a side). Anthropic rows must belong to the OAuth provider that would - * actually execute them, so `anthropicProviderName` is what makes that side's - * catalog rows eligible at all. + * The picker's option list: every eligible row reachable by an enabled + * executor, plus each enabled universal side's baseline unless that baseline + * is explicitly excluded, de-duplicated and stably ordered (side rank order, + * baselines first within a side). Anthropic rows must belong to the OAuth + * provider that would actually execute them, so `anthropicProviderName` is + * what makes that side's catalog rows eligible at all; xai/gemini rows map by + * provider identity and appear only when the caller enabled those backends. * * This is the SUGGESTION list (narrow): it emits only rows an executor can reach * and some source has heard of. It is deliberately NOT the same set as the write @@ -208,7 +221,7 @@ export function visionEligibleModelOptions( const byValue = new Map(); const enrichedProviders: EnrichedProviderCache = new Map(); - for (const backend of ["openai", "anthropic"] as const) { + for (const backend of Object.keys(BASELINE_VISION_MODELS) as UniversalVisionBackend[]) { if (!enabled.has(backend)) continue; const candidate = baselineCandidate(backend, anthropicProviderName); if (!isVisionEligibleModelWithCache(config, candidate, enrichedProviders)) continue; @@ -219,11 +232,19 @@ export function visionEligibleModelOptions( const backend = visionBackendForCandidate(config, candidate, anthropicProviderName); if (!backend || !enabled.has(backend)) continue; if (!isVisionEligibleModelWithCache(config, candidate, enrichedProviders)) continue; - if (byValue.has(candidate.id)) continue; - byValue.set(candidate.id, { value: candidate.id, label: candidate.id, backend }); + // Routed rows carry NAMESPACED values ("provider/model") so the loopback + // dispatch is unambiguous under routeModel; the legacy sides keep bare ids + // (GUI current-value compatibility, and the forward/OAuth executors POST + // the string verbatim). De-dup stays keyed by the emitted value. + const value = backend === "routed" ? `${candidate.provider}/${candidate.id}` : candidate.id; + if (byValue.has(value)) continue; + byValue.set(value, { value, label: value, backend }); } + // Two slots per side (baseline first), ranked openai < anthropic < routed + // so widening the union appends rather than interleaves (roadmap 170). + const sideRank: Record = { openai: 0, anthropic: 2, routed: 4 }; const order = (option: VisionModelOption) => - (option.backend === "openai" ? 0 : 2) + (option.baseline ? 0 : 1); + sideRank[option.backend] + (option.baseline ? 0 : 1); return [...byValue.values()].sort((a, b) => order(a) - order(b) || a.value.localeCompare(b.value)); } diff --git a/src/vision/index.ts b/src/vision/index.ts index 792e5db1b1..b945620ca6 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -5,6 +5,8 @@ import { modelRecordValue } from "../reasoning-effort"; import type { VisionReasoningEffort } from "../reasoning-effort"; import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe"; import { describeImageAnthropic } from "./anthropic-describe"; +import { describeImageRouted } from "./routed-describe"; +import { modelAcceptsImageInput } from "./eligibility"; import { normalizeVisionReasoningForModel } from "./reasoning"; import type { CodexAuthContext } from "../codex/auth-context"; import { resolveSidecarAuth } from "../sidecar/auth"; @@ -226,16 +228,23 @@ export function findAnthropicVisionProvider(config: OcxConfig): AnthropicVisionP } export function resolveVisionBackend( - explicit: "openai" | "anthropic" | undefined, + explicit: "openai" | "anthropic" | "routed" | undefined, anthropicSidecar: AnthropicVisionProvider | undefined, ): "openai" | "anthropic" { if (explicit === "openai" || explicit === "anthropic") return explicit; + // "routed" collapses to the legacy default order until its describe executor + // lands (roadmap 170 → 180 revised): a persisted routed backend without a + // dispatchable arm degrades exactly like unset rather than crashing. wp3 + // replaces this collapse with the real routed arm in planVisionSidecar. return anthropicSidecar ? "anthropic" : "openai"; } /** Native model used by the OpenAI vision helper, including its bounded default. */ export function resolveOpenAiVisionModel(config: Pick): string { - return config.visionSidecar?.model || DEFAULT_VISION_MODEL; + const configured = config.visionSidecar?.model; + // Namespaced routed ids never reach the forward executor (see + // resolveEffectiveVisionModel). + return configured && !configured.includes("/") ? configured : DEFAULT_VISION_MODEL; } /** Effective describer model for the backend `planVisionSidecar` selected. */ @@ -243,9 +252,15 @@ export function resolveEffectiveVisionModel( config: Pick, backend: "openai" | "anthropic", ): string { + const configured = config.visionSidecar?.model; + // A namespaced "provider/model" id belongs to the routed backend only; the + // forward/OAuth executors POST the model string verbatim, so it falls back + // to the side's default here (PUT coherence rejects new writes of this + // shape, but a legacy or hand-edited config must not break the executor). + const usable = configured && !configured.includes("/") ? configured : undefined; return backend === "anthropic" - ? config.visionSidecar?.model || DEFAULT_ANTHROPIC_VISION_MODEL - : resolveOpenAiVisionModel(config); + ? usable || DEFAULT_ANTHROPIC_VISION_MODEL + : usable || DEFAULT_VISION_MODEL; } /** A user/developer/toolResult message can carry images (toolResult: e.g. Codex view_image output). */ @@ -271,9 +286,13 @@ export function shouldResolveOpenAiVisionSidecar( } export interface VisionPlan { - backend: "openai" | "anthropic"; + backend: "openai" | "anthropic" | "routed"; forwardSidecar?: ResolvedOpenAiForwardSidecar; anthropicSidecar?: AnthropicVisionProvider; + /** Namespaced "provider/model" describer for the routed backend (roadmap 180). */ + routedModel?: string; + /** Loopback dispatch inputs for the routed backend. */ + routedConfig?: Pick; settings: VisionSettings; maxDescriptionsPerTurn: number; } @@ -295,8 +314,45 @@ export function planVisionSidecar( if (!messagesHaveImage(parsed)) return undefined; const cfg = config.visionSidecar ?? {}; if (cfg.enabled === false) return undefined; + + // Routed arm (roadmap 180 revised): explicit backend + NAMESPACED explicit + // model only — never inferred from credential availability. Plan-time + // fence: the target must not be provably blind, and must not itself be a + // model this planner would re-enter for (belt; the terminal marker on the + // loopback request is the braces). + if (cfg.backend === "routed") { + const routedModel = cfg.model; + const sep = routedModel ? routedModel.indexOf("/") : -1; + if (routedModel && sep > 0) { + const targetProvider = routedModel.slice(0, sep); + const targetId = routedModel.slice(sep + 1); + const targetProviderConfig = config.providers?.[targetProvider]; + const targetVisible = modelAcceptsImageInput(config, { provider: targetProvider, id: targetId }) !== false + && !(targetProviderConfig && isModelTextOnly(targetProviderConfig, targetId)); + if (targetVisible) { + return { + backend: "routed", + routedModel, + routedConfig: { port: config.port, ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}) }, + settings: { + model: routedModel, + reasoning: DEFAULT_REASONING, + timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs), + }, + maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn), + }; + } + } + // Misconfigured routed backend (bare id, unknown provider, or provably + // blind target): fall through to the legacy default order below rather + // than dispatching a describe that cannot work. + } + const anthropicSidecar = findAnthropicVisionProvider(config); const backend = resolveVisionBackend(cfg.backend, anthropicSidecar); + // A namespaced routed model must never reach the forward/OAuth executors + // (they POST the string verbatim); the effective-model resolver falls back + // to each side's default in that case. const model = resolveEffectiveVisionModel(config, backend); const maxDescriptionsPerTurn = resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn); @@ -447,6 +503,18 @@ async function executeDescription( abortSignal?: AbortSignal, recordSidecarOutcome?: SidecarOutcomeRecorder, ): Promise { + if (plan.backend === "routed") { + if (!plan.routedModel || !plan.routedConfig) return { text: "", error: "routed vision sidecar is unavailable" }; + return describeImageRouted( + job.imageUrl, + job.detail, + job.contextText, + plan.routedModel, + plan.routedConfig, + plan.settings, + abortSignal, + ); + } if (plan.backend === "anthropic") { const sidecar = plan.anthropicSidecar; if (!sidecar) return { text: "", error: "anthropic vision sidecar is unavailable" }; diff --git a/src/vision/routed-describe.ts b/src/vision/routed-describe.ts new file mode 100644 index 0000000000..fdd1575606 --- /dev/null +++ b/src/vision/routed-describe.ts @@ -0,0 +1,175 @@ +/** + * Describe ONE image via a ROUTED model through the proxy's own + * /v1/chat/completions on loopback (#2188 roadmap 180 revised). + * + * One executor for every provider the router can reach: the chat inbound + * translates image_url parts and each adapter compiles its own wire + * (Anthropic blocks, Antigravity inlineData, xai Responses input_image, plain + * openai-chat), so provider coverage is the router's job, not this file's. + * + * Recursion fence: the request carries `x-opencodex-vision-describe: 1`. + * The Chat surface detects the raw header before its bridge rebuilds headers + * and carries it into handleResponses as `visionDescribeTerminal`; a marked + * request STRIPS images instead of planning another describe (depth cap 1, + * holds under predicate drift and combo re-resolution — audit rounds 2-4). + * + * Admission ladder (audit round 3): configuredApiAuthToken() (env token) || + * service token file || first config.apiKeys entry, sent as + * `x-opencodex-api-key` — never Authorization (gateway-cache.ts rule: an + * admission secret in a forwardable header is a forwarding hazard). Loopback + * binds require no token at all (resolveApiAuth admits loopback). + * + * Known limitation (recorded in roadmap 170): a bindHost where 127.0.0.1 + * does not answer cannot reach its own loopback — same latent limitation + * gateway-cache has. + */ +import type { OcxConfig } from "../types"; +import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; +import { redactSecretString } from "../lib/redact"; +import { sidecarEnter } from "../lib/sidecar-tracker"; +import { configuredApiAuthToken, configuredPort } from "../server/auth-cors"; +import { loadServiceTokenFromFile } from "../lib/service-secrets"; +import type { DescribeOutcome, VisionSettings } from "./describe"; + +export const VISION_DESCRIBE_TERMINAL_HEADER = "x-opencodex-vision-describe"; + +const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]); +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +/** Bound the loopback JSON response; descriptions are clamped to ~2k chars by the caller anyway. */ +const MAX_ROUTED_RESPONSE_BYTES = 4 * 1024 * 1024; + +const DESCRIBE_INSTRUCTION = + "You are a vision describer for a text-only model that cannot see the image. Describe the image " + + "thoroughly and factually so that model can fully reason about it: transcribe any visible text " + + "verbatim, and note UI/layout, colors, branding/logos, charts, and notable details. Focus on " + + "what's relevant to the user's request. Output only the description."; + +function validateImageUrl(url: string): string | null { + if (url.startsWith("data:")) { + const match = /^data:([^;,]+?)(;base64)?,(.*)$/s.exec(url); + if (!match) return "malformed data URL"; + const mime = match[1].toLowerCase(); + if (!ALLOWED_IMAGE_MIME.has(mime)) return `unsupported image type "${mime}"`; + if (match[2]) { + const bytes = Math.floor((match[3].length * 3) / 4); + if (bytes > MAX_IMAGE_BYTES) return `image too large (~${Math.round(bytes / 1024 / 1024)}MB)`; + } + return null; + } + if (url.startsWith("https://")) return null; + return "unsupported image URL scheme (expected data: or https:)"; +} + +/** The admission ladder: env token, service token file, first configured API key. */ +export function routedDescribeAdmissionToken(config: Pick): string | undefined { + const envToken = configuredApiAuthToken(); + if (envToken) return envToken; + const fileToken = loadServiceTokenFromFile(process.env); + if (fileToken) return fileToken; + const first = config.apiKeys?.[0]?.key?.trim(); + return first || undefined; +} + +/** Base URL seam for tests; production always self-fetches loopback. */ +export function routedDescribeBaseUrl(config: Pick): string { + // config.port can be 0 (ephemeral bind, tests) or stale after a live port + // override; the server records its ACTUAL bound port via setCorsOrigin at + // startup, so prefer that when config carries no positive port. + const port = config.port && config.port > 0 ? String(config.port) : configuredPort(); + return `http://127.0.0.1:${port}`; +} + +export async function describeImageRouted( + imageUrl: string, + _detail: string | undefined, + contextText: string, + routedModel: string, + config: Pick, + settings: VisionSettings, + abortSignal?: AbortSignal, + baseUrlOverride?: string, +): Promise { + const invalid = validateImageUrl(imageUrl); + if (invalid) return { text: "", error: invalid }; + + const headers: Record = { + "Content-Type": "application/json", + [VISION_DESCRIBE_TERMINAL_HEADER]: "1", + }; + const admission = routedDescribeAdmissionToken(config); + if (admission) headers["x-opencodex-api-key"] = admission; + + const requestBody = { + model: routedModel, + stream: false, + messages: [ + { role: "system", content: DESCRIBE_INSTRUCTION }, + { + role: "user", + content: [ + ...(contextText ? [{ type: "text", text: `User's request context: ${contextText}` }] : []), + { type: "image_url", image_url: { url: imageUrl } }, + ], + }, + ], + }; + + const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); + const sidecarExit = sidecarEnter("vision"); + const t0 = Date.now(); + try { + const res = await fetch(`${baseUrlOverride ?? routedDescribeBaseUrl(config)}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: linkedSignal.signal, + redirect: "manual", + }); + const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); + try { + const raw = await res.text(); + if (raw.length > MAX_ROUTED_RESPONSE_BYTES) { + return { text: "", error: "routed describe response exceeded byte bound" }; + } + if (!res.ok) { + return { text: "", error: `routed describe HTTP ${res.status}: ${redactSecretString(raw.slice(0, 200))}` }; + } + let payload: unknown; + try { payload = JSON.parse(raw); } catch { + return { text: "", error: "routed describe returned non-JSON" }; + } + const content = extractChatContent(payload); + if (!content) return { text: "", error: "routed describe returned no text" }; + return { text: content }; + } finally { + detachBodyGuard(); + } + } catch (e) { + const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + console.warn(`[vision] routed describe ${kind} (${Date.now() - t0}ms)`); + return { text: "", error: redactSecretString(e instanceof Error ? e.message : String(e)) }; + } finally { + sidecarExit(); + linkedSignal.cleanup(); + } +} + +function extractChatContent(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") return undefined; + const choices = (payload as { choices?: unknown }).choices; + if (!Array.isArray(choices) || choices.length === 0) return undefined; + const message = (choices[0] as { message?: unknown })?.message; + if (!message || typeof message !== "object") return undefined; + const content = (message as { content?: unknown }).content; + if (typeof content === "string" && content.trim().length > 0) return content; + // Some adapters emit content parts; join text parts. + if (Array.isArray(content)) { + const joined = content + .map(part => (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string" + ? (part as { text: string }).text + : "")) + .join(""); + if (joined.trim().length > 0) return joined; + } + return undefined; +} diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 6054c211d4..26a279f592 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -80,6 +80,27 @@ on proven absence, never on an unreadable path. - 다른 대안 대신 이 방식을 선택한 이유: Physical credential ownership remains cross-process safe, while an inert optional subsystem can no longer create the reported lock/recovery catch-22. - 장점, 단점 및 영향: Fresh installs avoid the SQLite profile lock; any present or uncertain stage state retains the existing locked fail-closed cleanup and recovery behavior. +The native-write coordinator is keyed by the canonical `CODEX_HOME` in the effective-user runtime +namespace. A pathname alone is not authority: SQLite can expose a zero-byte file before its first +schema write, and a terminated process can leave that remnant behind. Eligibility treats the file +as non-authoritative only after an immutable SQLite read proves version zero with no tables, the +filesystem identity remains unchanged, and the file has been settled for at least one second; a +fresh zero-byte creator stays on the coordinated path so its lock cannot be bypassed. `ocx doctor` inspects the +coordinator with immutable read-only SQLite flags so diagnosis never creates WAL/SHM sidecars. It +distinguishes absent, zero-byte, unversioned, rowless, valid, unsupported, changed, unsafe, and +unreadable states and prints the exact path. Explicit recovery is available only after the proxy is +stopped and only for a proven zero-byte state. The command revalidates the same private +regular-file identity under a non-blocking SQLite write lock and moves it to a same-directory +backup; it never deletes or auto-adopts legacy routed residue. + +[Decision Log] +- 목적과 의도: Recover a crashed zero-byte coordinator without mistaking SQLite's normal creation window for stale authority. +- 기존 구현 및 제약 조건: Eligibility treated every existing pathname as coordinated, while initialization correctly refused a missing row over routed residue; catalog sync could therefore succeed before config injection failed permanently. +- 검토한 주요 대안: Delete zero-byte files automatically, initialize a new row over residue, require a manual filesystem command, or add observe-only classification plus explicit guarded quarantine. +- 선택한 방식: Treat only a settled, identity-stable, immutably verified zero-byte database like the existing legacy-uncoordinated boundary; keep fresh creators coordinated, diagnose all other database states immutably, and expose an opt-in zero-byte-only same-directory backup move with identity, ownership, sidecar, liveness, and SQLite-lock checks. +- 다른 대안 대신 이 방식을 선택한 이유: Automatic deletion or adoption can race a live creator or erase transition evidence; a guarded backup preserves evidence and makes the operator action reproducible. +- 장점, 단점 및 영향: A stale zero-byte file no longer wedges sync, valid/unrecognized databases remain fail-closed, and recovery requires the proxy to be stopped before `ocx sync` retries injection. + OpenCodex never overrides an explicit `CODEX_HOME`. On Windows, `ocx doctor` and `ocx status` nevertheless diagnose the high-confidence Orca dual-home case: both `CODEX_HOME` and `ORCA_CODEX_HOME` select Orca's `orca/codex-runtime-home/home`, while the ChatGPT/Codex app uses the diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 75410d38b3..927a8f2fd5 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1,5 +1,22 @@ # Transports And Sidecars SOT +## Background service command selection + +A bare `ocx service` is an idempotent install-or-repair command. Argument validation happens before +any platform status probe. macOS and Linux choose from the registration file's proven presence; +Windows combines the Task Scheduler and WinSW probes into `installed`, `absent`, or `unknown`. +Only proven absence enters registration. A query failure refuses the bare command with status +guidance, because treating `unknown` as absent can rerun elevated `schtasks /create` against an +existing task. Explicit `ocx service install` remains the operator-owned registration request. + +[Decision Log] +- 목적과 의도: Make a bare service refresh safe and idempotent without converting a localized or transient Windows status failure into an elevated re-registration. +- 기존 구현 및 제약 조건: The command defaulted to install and later used a boolean diagnostic whose scheduler query fallback could collapse unknown into absent; repair must preserve the existing Windows launcher and Bun stability workarounds. +- 검토한 주요 대안: Always repair; keep a boolean installed check; infer presence from saved state alone; use a tri-state live registration probe. +- 선택한 방식: Validate arguments first, then use a narrow tri-state platform probe only for a bare backend-neutral invocation; route installed to repair, absent to install, and unknown to a refusal. +- 다른 대안 대신 이 방식을 선택한 이유: Saved state can be stale and unconditional repair breaks first install, while a boolean cannot represent the exact uncertainty that must fail closed. +- 장점, 단점 및 영향: Existing services avoid UAC and registration churn, invalid input performs no status I/O, and uncertain Windows hosts require one explicit status/installation decision instead of risking a destructive guess. + ## Provider diagnostic outbound safety Provider connection tests and live model discovery share the GET-only provider outbound wrapper. @@ -27,7 +44,7 @@ Responses-compatible streaming output. - 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result. - 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge. - 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item. -- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` and tools replaced by hosted-provider policy stay in their upstream function-call form. +- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` stays in its upstream function-call form unless the destination explicitly denies Responses custom tools; tools replaced by hosted-provider policy also stay in their upstream function-call form. - 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. - 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. @@ -52,10 +69,11 @@ Two coordinates that lower to the same wire name are treated as one tool when th a `functions` child of the same name are the duplicate the parser already tolerates — and the one `promoteClientLoadedTools` produces. The declaration is emitted once instead of failing the request. -Replayed call items are lowered whether or not this turn declares the group they name. A routed -compaction turn strips the whole tool surface before the boundary runs, and a catalog can change -mid-session, but the client is still replaying items this layer's own response restoration stamped -with a private `namespace`. Only `tool_choice` resolves a bare name through the catalog: a history +Replayed call items are lowered whether or not this turn declares the group they name. A catalog can +be absent or change mid-session, but the client is still replaying items this layer's own response +restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing +the tool surface so request-local aliases remain available for response restoration. Only +`tool_choice` resolves a bare name through the catalog: a history item records which tool actually ran, so re-pointing it at a same-named namespace child would rewrite that record on a coincidence rather than translate it. @@ -101,6 +119,14 @@ alone never opt a gateway in. and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. +[Decision Log] +- 목적과 의도: Complete Cursor turns at the protocol terminal instead of waiting for a separate HTTP-body EOF that may never arrive. +- 기존 구현 및 제약 조건: Cursor can send turnEnded followed by a clean Connect END_STREAM envelope while RunSSE remains open or later closes through an abort-shaped transport error. The adapter logged the clean envelope but did not settle its terminal owner, so a completed-looking turn could remain open until the Responses stall watchdog. +- 검토한 주요 대안: Shorten the global stall timeout; treat every later abort as success; settle only when the HTTP stream emits end; make the clean Connect envelope authoritative. +- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, run any already-armed drained client-tool finalizer before protocol cleanup clears its grace timer only while the call set is still drained, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. +- 다른 대안 대신 이 방식을 선택한 이유: The protocol envelope is upstream's explicit terminal signal. Timeout changes only hide the race, and globally swallowing aborts would mask genuine mid-turn cancellation. +- 장점, 단점 및 영향: Completed Cursor responses no longer wait for the 300-second watchdog when the HTTP body stays open; incomplete tool calls still emit their existing truncation error, and error-bearing Connect terminals remain failures. + A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent base64, so they always lower to plain user messages. A native blob is relayed only when there is no @@ -162,6 +188,14 @@ not a separate tier policy. One write sets or clears the Grok 4.5 and 4.6 entrie preserving unrelated overrides; a pre-existing one-entry state is reported as mixed until the next switch write normalizes both. +[Decision Log] +- 목적과 의도: Keep Codex hosted web search usable on xAI's public Responses endpoint without forwarding private OpenAI-only fields that xAI rejects. +- 기존 구현 및 제약 조건: Codex emits `external_web_access`, `search_context_size`, `search_content_types`, and `user_location`; xAI documents a live-only `web_search` tool with domain filters and image flags, while Codex cached mode explicitly forbids external access. +- 검토한 주요 대안: Strip only the first rejected field; pass every hosted-search field unchanged; disable web search for all xAI turns; normalize only the exact official xAI API destination. +- 선택한 방식: On `https://api.x.ai` Responses traffic, lower live search to xAI's public shape, map image content requests to `enable_image_search`, remove unsupported OpenAI-private fields, and omit cached/index-only search plus stale selectors because xAI has no non-live equivalent. +- 다른 대안 대신 이 방식을 선택한 이유: One-field stripping exposes the next schema mismatch and turning `external_web_access:false` into xAI live search widens the caller's network policy; destination scoping leaves custom gateways and canonical OpenAI byte-shape native. +- 장점, 단점 및 영향: Grok 4.5/4.6 no longer fail every default Codex turn with an unsupported-argument 400; live search remains available when explicitly enabled, while cached search degrades to no hosted search on xAI rather than silently going live. + OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and keeps the provider-wide `openai-chat` default for other non-pinned models. This endpoint correction diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 21cef4e954..0fd70b3b58 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,34 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +Pool affinity preserves the existing `x-codex-parent-thread-id` supplied by ordinary Codex clients. +The parent id is trimmed and bounded under the same 512-byte component limit as the Desktop +fallback. When Codex Desktop omits it or sends an unusable value, the complete bounded `session-id` +plus `thread-id` pair is mapped to an opaque HMAC under a random process-local key. Missing or +oversized components remain unbound, raw identifiers and durable hashes are never stored, and +account-qualified selectors skip both lookup and mutation. Selection, subagent fallback preview, +and terminal outcome accounting carry the same key so route planning cannot preview one account +and authenticate another, and a transient failure clears the binding that actually selected the +account. + +[Decision Log] +- 목적과 의도: Keep Desktop reconnects on the account selected for the App task without persisting + or exposing its session and thread identifiers. +- 기존 구현 및 제약 조건: Pool affinity used only `x-codex-parent-thread-id`; Desktop requests can + omit it while stable `session-id` and `thread-id` headers remain available. Exact account + selectors must stay outside automatic Pool affinity. +- 검토한 주요 대안: Leave reconnects unbound, persist a plain hash, bind from either header alone, + delete App turn metadata, or derive one process-local key from the complete pair. +- 선택한 방식: Preserve the parent-thread key when present; otherwise HMAC the two bounded headers + under a random per-process key and carry that opaque value through selection, subagent preview, + and outcome handling. +- 다른 대안 대신 이 방식을 선택한 이유: A complete pair avoids weak partial identities, a + process-local HMAC prevents durable correlation or dictionary recovery, and no upstream metadata + needs to be mutated before the first-403 cause is proven. +- 장점, 단점 및 영향: Reconnects stop rotating among Pool accounts and failure accounting clears + the correct binding. Affinity intentionally resets on process restart, and requests missing either + component retain the prior unbound behavior. + An explicit `Retry-After` or an unclassified quota 429 is account-wide. A reset-derived native-model 429 is advisory and remains within its confirmed quota group: `gpt-5.3-codex-spark` is separate from the shared native group (including GPT-5.6 Terra/Luna). This allows a same-account combo to test an diff --git a/tests/claude-code-thought-signature-scope.test.ts b/tests/claude-code-thought-signature-scope.test.ts new file mode 100644 index 0000000000..d6a3de65f6 --- /dev/null +++ b/tests/claude-code-thought-signature-scope.test.ts @@ -0,0 +1,125 @@ +/** + * Regression coverage for the Claude Code thought-signature replay scope: + * + * Claude Code speaks Anthropic Messages and does not send Codex's + * `x-codex-parent-thread-id`. The server must still create a reasoning-replay + * scope for a real per-session `prompt_cache_key` (derived from + * `metadata.user_id`) so Gemini/Antigravity thought signatures can be remembered + * by call_id. The shared Desktop `prompt_cache_key` cohort must NOT get a scope. + */ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import type { ProviderAdapter } from "../src/adapters/base"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const actualResolver = await import("../src/server/adapter-resolve"); + +let adapterFactory: ((provider: OcxProviderConfig) => ProviderAdapter) | undefined; + +mock.module("../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { + return adapterFactory?.(provider) ?? actualResolver.resolveAdapter(provider, cacheRetention); + }, +})); + +const { handleResponses } = await import("../src/server/responses"); + +afterEach(() => { + adapterFactory = undefined; +}); + +function captureAdapter(captured: OcxParsedRequest[]): ProviderAdapter { + return { + name: "capture-replay-scope", + buildRequest: () => ({ url: "https://capture.test", method: "POST", headers: {}, body: "{}" }), + async *parseStream(): AsyncGenerator { + yield { type: "done" }; + }, + async runTurn(parsed: OcxParsedRequest, _incoming, emit) { + captured.push(parsed); + emit({ type: "done" }); + }, + }; +} + +function testConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://capture.test", + authMode: "key", + apiKey: "capture-key", + models: ["m1"], + }, + }, + } as OcxConfig; +} + +async function drive(options: { + promptCacheKey?: string; + promptCacheKeyIsSharedCohort?: boolean; +}): Promise { + const captured: OcxParsedRequest[] = []; + adapterFactory = () => captureAdapter(captured); + const body: Record = { + model: "m1", + stream: true, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }], + }; + if (options.promptCacheKey !== undefined) body.prompt_cache_key = options.promptCacheKey; + + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + testConfig(), + { model: "", provider: "" }, + { + inboundWire: "anthropic", + ...(options.promptCacheKeyIsSharedCohort === undefined + ? {} + : { promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort }), + }, + ); + await response.text(); + expect(captured.length).toBe(1); + return captured[0]!; +} + +describe("Claude Code Anthropic inbound reasoning-replay scope", () => { + test("a real per-session prompt_cache_key creates a call_id replay scope", async () => { + const parsed = await drive({ promptCacheKey: "session-key-123", promptCacheKeyIsSharedCohort: false }); + expect(parsed._clientThreadId).toBeUndefined(); + expect(parsed._reasoningReplayScope?.clientThreadId).toBe("session-key-123"); + }); + + test("the shared Desktop prompt_cache_key cohort does not create a scope", async () => { + const parsed = await drive({ promptCacheKey: "shared-cohort-key", promptCacheKeyIsSharedCohort: true }); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); + + test("an Anthropic replay without prompt_cache_key does not create a scope", async () => { + const parsed = await drive({}); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); + + test("an overlong prompt_cache_key is hashed, not stored raw", async () => { + const overlong = "k".repeat(200); + const parsed = await drive({ promptCacheKey: overlong, promptCacheKeyIsSharedCohort: false }); + const scope = parsed._reasoningReplayScope?.clientThreadId; + expect(scope).toBeDefined(); + expect(scope).not.toBe(overlong); + expect(scope!.length).toBeLessThanOrEqual(128); + }); + + test("a whitespace-only prompt_cache_key does not create a scope", async () => { + const parsed = await drive({ promptCacheKey: " ", promptCacheKeyIsSharedCohort: false }); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); +}); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index ea1e49718b..8998d644a3 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -293,7 +293,7 @@ describe("CLI subcommand help", () => { test("invalid service and codex-shim usage include remove alias", () => { const cases = [ - { args: ["service", "nope"], expected: "Usage: ocx service [install|repair|start|stop|status|uninstall|remove]" }, + { args: ["service", "nope"], expected: "Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove]" }, { args: ["codex-shim", "nope"], expected: "Usage: ocx codex-shim " }, ]; diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index b4e9f77d86..5a3372c137 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -497,6 +497,187 @@ describe("Codex auth context", () => { .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); + test("Desktop session and thread headers derive one opaque reconnect affinity", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + const headers = new Headers({ + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(first).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(first.kind).toBe("pool"); + if (first.kind !== "pool") throw new Error("expected pool context"); + expect(first.affinityKey?.startsWith("app:")).toBe(true); + expect(first.affinityKey?.includes("desktop-session-private")).toBe(false); + expect(first.affinityKey?.includes("desktop-thread-private")).toBe(false); + + cfg.activeCodexAccountId = "pool-b"; + const reconnect = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(reconnect).toMatchObject({ + kind: "pool", + accountId: "pool-a", + affinityKey: first.affinityKey, + }); + }); + + test("the canonical parent-thread affinity stays authoritative over Desktop fallback headers", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + const headers = new Headers({ + "x-codex-parent-thread-id": " canonical-parent-thread ", + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const resolved = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(resolved).toMatchObject({ + kind: "pool", + accountId: "pool-a", + affinityKey: "canonical-parent-thread", + }); + }); + + test("an oversized parent-thread id falls back to the bounded Desktop pair", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + const headers = new Headers({ + "x-codex-parent-thread-id": "p".repeat(513), + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const resolved = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(resolved).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(resolved.kind).toBe("pool"); + if (resolved.kind !== "pool") throw new Error("expected pool context"); + expect(resolved.affinityKey?.startsWith("app:")).toBe(true); + expect(resolved.affinityKey).not.toContain("desktop-session-private"); + expect(resolved.affinityKey).not.toContain("desktop-thread-private"); + }); + + test("incomplete or oversized Desktop affinity headers remain unbound", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + + for (const headers of [ + new Headers({ "session-id": "session-only" }), + new Headers({ "thread-id": "thread-only" }), + new Headers({ "session-id": "s".repeat(513), "thread-id": "bounded-thread" }), + ]) { + clearThreadAccountMap(); + cfg.activeCodexAccountId = "pool-a"; + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(first).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(first.kind === "pool" ? first.affinityKey : undefined).toBeUndefined(); + + cfg.activeCodexAccountId = "pool-b"; + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + } + }); + + test("exact account selection does not create Desktop Pool affinity", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.activeCodexAccountId = "pool-b"; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + const headers = new Headers({ + "session-id": "exact-desktop-session", + "thread-id": "exact-desktop-thread", + }); + + const exact = await resolveCodexAuthContext(headers, cfg, "pool", { accountId: "pool-a" }); + expect(exact).toMatchObject({ kind: "pool", accountId: "pool-a", fixedAccount: true }); + expect(exact.kind === "pool" ? exact.affinityKey : undefined).toBeUndefined(); + + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + + test("late transient failure cannot delete a newer Desktop affinity binding", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.upstreamFailoverThreshold = 3; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + const headers = new Headers({ + "session-id": "failure-desktop-session", + "thread-id": "failure-desktop-thread", + }); + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + if (first.kind !== "pool") throw new Error("expected pool context"); + expect(first.accountId).toBe("pool-a"); + + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(cfg, "pool-a", 500, { + now: 1_800_000_000_000 + attempt, + threadId: first.affinityKey, + }); + } + const rebound = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(rebound).toMatchObject({ kind: "pool", accountId: "pool-b" }); + + recordCodexUpstreamOutcome(cfg, "pool-a", 500, { + now: 1_800_000_000_100, + threadId: first.affinityKey, + }); + clearCodexUpstreamHealth(); + cfg.activeCodexAccountId = "pool-a"; + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + test("selection order never bypasses an exact account selector", async () => { // Regression: `codexAccountPriorities` narrows the pool to the highest tier, but it // is an ordering boundary over the pool path only. A request that names an account diff --git a/tests/codex-coordinator-doctor.test.ts b/tests/codex-coordinator-doctor.test.ts new file mode 100644 index 0000000000..49e9be23ce --- /dev/null +++ b/tests/codex-coordinator-doctor.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + inspectCodexCoordinator, + recoverZeroByteCodexCoordinator, +} from "../src/codex/coordinator-doctor"; +import { + codexWriteCoordinationEligibility, + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS, +} from "../src/codex/inject-coordination"; +import { + openCodexCoordinatorTransaction, +} from "../src/codex/transition-state"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { formatCoordinatorDoctorLines } from "../src/cli/doctor"; + +let codexHome = ""; +let opencodexHome = ""; +let coordinatorPath = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + codexHome = mkdtempSync(join(tmpdir(), "ocx-coordinator-doctor-codex-")); + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-coordinator-doctor-ocx-")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${coordinatorPath}${suffix}`, { force: true }); + } + rmSync(codexHome, { recursive: true, force: true }); + rmSync(opencodexHome, { recursive: true, force: true }); +}); + +function privateFile(path: string, bytes = ""): void { + writeFileSync(path, bytes); + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +test("doctor classifies and explicitly backs up a stable zero-byte coordinator", () => { + privateFile(coordinatorPath); + const diagnostic = inspectCodexCoordinator(); + expect(diagnostic.kind).toBe("zero-byte"); + if (diagnostic.kind !== "zero-byte") return; + expect(formatCoordinatorDoctorLines(diagnostic).join("\n")).toContain( + "ocx doctor --recover-zero-byte-coordinator --yes", + ); + expect(formatCoordinatorDoctorLines(diagnostic).join("\n")).toContain( + "size: 0 bytes; user_version: 0", + ); + + const recovered = recoverZeroByteCodexCoordinator(new Date("2026-08-21T12:00:00.000Z")); + expect(recovered.ok).toBe(true); + if (!recovered.ok) return; + expect(recovered.backupPath).toEndWith(".zero-byte-backup-20260821T120000000Z"); + expect(existsSync(coordinatorPath)).toBe(false); + expect(existsSync(recovered.backupPath)).toBe(true); + rmSync(recovered.backupPath, { force: true }); +}); + +test("doctor distinguishes unversioned, rowless, and authoritative coordinators", () => { + let database = new Database(coordinatorPath, { create: true }); + database.exec("CREATE TABLE temporary_probe (id INTEGER); DROP TABLE temporary_probe"); + database.close(); + if (process.platform !== "win32") chmodSync(coordinatorPath, 0o600); + expect(inspectCodexCoordinator().kind).toBe("unversioned-empty"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is unversioned-empty, not a recoverable zero-byte remnant", + }); + + database = new Database(coordinatorPath, { readwrite: true, create: false }); + database.exec("PRAGMA user_version = 1; CREATE TABLE codex_transition_state (singleton INTEGER PRIMARY KEY)"); + database.close(); + expect(inspectCodexCoordinator().kind).toBe("rowless"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is rowless, not a recoverable zero-byte remnant", + }); + + database = new Database(coordinatorPath, { readwrite: true, create: false }); + database.exec("INSERT INTO codex_transition_state (singleton) VALUES (1)"); + database.close(); + const malformed = inspectCodexCoordinator(); + expect(malformed.kind).toBe("unreadable"); + expect(formatCoordinatorDoctorLines(malformed).join("\n")).toContain("user_version: 1"); + expect(formatCoordinatorDoctorLines(malformed).join("\n")).toContain("transition rows: 1"); + + rmSync(coordinatorPath, { force: true }); + const transaction = openCodexCoordinatorTransaction(coordinatorPath); + transaction.commit(); + transaction.close(); + expect(inspectCodexCoordinator().kind).toBe("ready"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is ready, not a recoverable zero-byte remnant", + }); +}); + +test("doctor inspection is immutable and refuses sidecars, unsafe modes, and symlinks", () => { + privateFile(coordinatorPath); + expect(inspectCodexCoordinator().kind).toBe("zero-byte"); + for (const suffix of ["-journal", "-wal", "-shm"]) { + expect(existsSync(`${coordinatorPath}${suffix}`)).toBe(false); + } + + privateFile(`${coordinatorPath}-wal`, "active"); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + rmSync(`${coordinatorPath}-wal`, { force: true }); + + if (process.platform !== "win32") { + chmodSync(coordinatorPath, 0o644); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + chmodSync(coordinatorPath, 0o600); + + const target = `${coordinatorPath}.target`; + privateFile(target); + rmSync(coordinatorPath, { force: true }); + symlinkSync(target, coordinatorPath); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + rmSync(coordinatorPath, { force: true }); + rmSync(target, { force: true }); + } +}); + +test("recovery refuses a zero-byte coordinator with an active SQLite writer sidecar", () => { + privateFile(coordinatorPath); + const holder = new Database(coordinatorPath, { readwrite: true, create: false }); + holder.exec("PRAGMA journal_mode = OFF; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + expect(recoverZeroByteCodexCoordinator()).toMatchObject({ + ok: false, + reason: expect.stringContaining("active SQLite journal sidecar"), + }); + expect(existsSync(coordinatorPath)).toBe(true); + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } +}); + +test("zero-byte residue uses the legacy boundary while clean homes still initialize", () => { + privateFile(coordinatorPath); + const afterStableAge = () => Date.now() + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 1; + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: afterStableAge, + })).toEqual({ + kind: "legacy-uncoordinated", + reason: "the coordinator is a zero-byte non-authoritative remnant and this routed home has not been adopted yet", + }); + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "clean" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: afterStableAge, + })).toEqual({ kind: "coordinated" }); + + privateFile(coordinatorPath, "not-empty"); + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + })).toEqual({ kind: "coordinated" }); +}); + +test("a fresh zero-byte coordinator stays on the locked path until it is stable", () => { + privateFile(coordinatorPath); + const fresh = codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: () => Date.now(), + }); + expect(fresh).toEqual({ kind: "coordinated" }); + + const settled = codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: () => Date.now() + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 1, + }); + expect(settled.kind).toBe("legacy-uncoordinated"); +}); diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index 5603137bd2..9054d5bacf 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -8,9 +8,14 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { STABLE_ZERO_BYTE_COORDINATOR_AGE_MS } from "../src/codex/inject-coordination"; const repoRoot = join(import.meta.dir, ".."); const CHILD = join(repoRoot, "tests", "helpers", "codex-inject-race-child.ts"); @@ -20,6 +25,7 @@ let root = ""; let codexHome = ""; let opencodexHome = ""; const cleanup: string[] = []; +const coordinatorCleanup: string[] = []; function seedNative(): void { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); @@ -50,6 +56,12 @@ beforeEach(() => { }); afterEach(() => { + while (coordinatorCleanup.length) { + const path = coordinatorCleanup.pop()!; + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${path}${suffix}`, { force: true }); + } + } while (cleanup.length) { const dir = cleanup.pop()!; // `force` covers a missing path, not a locked one: a child that is still exiting @@ -184,6 +196,35 @@ describe("homes the coordinator cannot adopt keep working", () => { expect(result.success).toBeTrue(); expect(readFileSync(join(codexHome, "config.toml"), "utf-8")).toContain("openai_base_url"); }); + + test("a zero-byte coordinator remnant does not wedge a pre-substrate routed home", () => { + writeFileSync(join(codexHome, "config.toml"), [ + 'model_provider = "opencodex"', + 'model = "gpt-5.5"', + "", + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'wire_api = "responses"', + "", + ].join("\n")); + const coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); + coordinatorCleanup.push(coordinatorPath); + writeFileSync(coordinatorPath, ""); + if (process.platform !== "win32") chmodSync(coordinatorPath, 0o600); + // Fresh zero-byte files remain on the coordinated path because they may + // belong to a live SQLite creator. This fixture represents an old remnant. + Bun.sleepSync(STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 100); + + const result = runInject(10100); + + expect(result.success).toBeTrue(); + expect(readFileSync(join(codexHome, "config.toml"), "utf-8")).toContain("openai_base_url"); + expect(readFileSync(coordinatorPath)).toHaveLength(0); + }); }); describe("the transition is resolved, not left pending", () => { diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 1139ea79fb..cf37282c80 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -913,6 +913,47 @@ describe("Cursor blob handshake", () => { const run = msg.message.case === "runRequest" ? msg.message.value : undefined; expect(run?.action?.action.case).toBe("resumeAction"); + const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; + const serialized = JSON.stringify(roots); + expect(serialized).toContain("read a file"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + }); + + test("native Auto Intelligence omits assistant-role [Tool Result] root replay", () => { + const bytes = encodeCursorRunRequest({ + modelId: "auto-intelligence", + conversationId: "c-auto-intel", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto-intelligence", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + expect(run?.action?.action.case).toBe("resumeAction"); + const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; + const serialized = JSON.stringify(roots); + expect(roots.some(root => root.role === "assistant")).toBe(false); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).toContain("read a file"); + const turnIds = run?.conversationState?.turns ?? []; + expect(turnIds).toHaveLength(1); + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnIds[0]!)); + expect(turn.turn.case).toBe("agentConversationTurn"); + const steps = turn.turn.case === "agentConversationTurn" ? turn.turn.value.steps : []; + expect(steps).toHaveLength(1); + const step = fromBinary(ConversationStepSchema, blobData(steps[0]!)); + expect(step.message.case).toBe("toolCall"); }); test("drives composer-2.5 tool-result continuations as userMessageAction", () => { diff --git a/tests/cursor-eof-terminal.test.ts b/tests/cursor-eof-terminal.test.ts index a2a59b2262..18d4da50fa 100644 --- a/tests/cursor-eof-terminal.test.ts +++ b/tests/cursor-eof-terminal.test.ts @@ -3,6 +3,7 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { describe, expect, test } from "bun:test"; import { AgentServerMessageSchema, + ExecServerMessageSchema, InteractionUpdateSchema, McpArgsSchema, McpToolCallSchema, @@ -63,6 +64,29 @@ function toolCallStartedFrame(callId: string, toolName: string): Uint8Array { return encodeConnectFrame(toBinary(AgentServerMessageSchema, message)); } +function clientToolArgsFrame(callId: string, toolName: string, argText: string): Uint8Array { + const message = create(AgentServerMessageSchema, { + message: { + case: "execServerMessage", + value: create(ExecServerMessageSchema, { + id: 1, + execId: `exec-${callId}`, + message: { + case: "mcpArgs", + value: create(McpArgsSchema, { + name: toolName, + toolName, + toolCallId: callId, + providerIdentifier: PROVIDER, + args: { text: new TextEncoder().encode(JSON.stringify(argText)) }, + }), + }, + }), + }, + }); + return encodeConnectFrame(toBinary(AgentServerMessageSchema, message)); +} + function turnEndedFrame(): Uint8Array { const message = create(AgentServerMessageSchema, { message: { @@ -79,6 +103,10 @@ function emptyFrame(): Uint8Array { return encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, {}))); } +function cleanConnectEndFrame(): Uint8Array { + return encodeConnectFrame(new TextEncoder().encode("{}"), { endStream: true }); +} + function runRequest(tools?: CursorRunRequest["tools"]): CursorRunRequest { return { modelId: "composer-2", @@ -96,6 +124,17 @@ const APPLY_PATCH_TOOL = [{ freeform: true, }] as unknown as CursorRunRequest["tools"]; +const ECHO_TOOL = [{ + name: "echo_a", + description: "echo text", + parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, +}] as unknown as CursorRunRequest["tools"]; + +const ECHO_AND_APPLY_PATCH_TOOLS = [ + ...(ECHO_TOOL ?? []), + ...(APPLY_PATCH_TOOL ?? []), +] as CursorRunRequest["tools"]; + async function drain(baseUrl: string, request: CursorRunRequest): Promise<{ messages: CursorServerMessage[]; failure?: Error; @@ -153,6 +192,80 @@ describe("Cursor clean-EOF terminal gate", () => { }); }); + test("clean Connect END_STREAM finishes before a held-open HTTP body (#2300)", async () => { + let fallback: ReturnType | undefined; + const startedAt = Date.now(); + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(turnEndedFrame())); + stream.write(Buffer.from(cleanConnectEndFrame())); + // Model Cursor's observed shape: the protocol has ended, but the HTTP body has not. The + // fallback keeps the pre-fix test bounded; correct code returns well before it fires. + fallback = setTimeout(() => { + try { stream.end(); } catch { /* transport already closed */ } + }, 500); + }, async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest()); + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + }); + if (fallback) clearTimeout(fallback); + expect(Date.now() - startedAt).toBeLessThan(450); + }); + + test("clean Connect END_STREAM wins over an immediate abort-shaped body teardown (#2300)", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(turnEndedFrame())); + stream.write(Buffer.from(cleanConnectEndFrame())); + setImmediate(() => { + const abort = new Error("The operation was aborted"); + abort.name = "AbortError"; + stream.destroy(abort); + }); + }, async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest()); + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + expect(messages.some(message => message.type === "error")).toBe(false); + }); + }); + + test("clean Connect END_STREAM preserves a drained client-tool terminal before its grace timer", async () => { + await withH2Server(respondWith([ + toolCallStartedFrame("call_client_1", "echo_a"), + clientToolArgsFrame("call_client_1", "echo_a", "A"), + cleanConnectEndFrame(), + ]), async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest(ECHO_TOOL)); + + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "tool_call_end")).toHaveLength(1); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + expect(messages.some(message => message.type === "error")).toBe(false); + }); + }); + + test("clean Connect END_STREAM keeps a later open sibling fail-closed after a client-tool drain", async () => { + await withH2Server(respondWith([ + toolCallStartedFrame("call_client_2", "echo_a"), + clientToolArgsFrame("call_client_2", "echo_a", "A"), + toolCallStartedFrame("call_open_2", "apply_patch"), + cleanConnectEndFrame(), + ]), async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest(ECHO_AND_APPLY_PATCH_TOOLS)); + + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "tool_call_end")).toHaveLength(1); + const terminal = messages.at(-1); + expect(terminal?.type).toBe("error"); + expect((terminal as { message?: string }).message).toContain("call_open_2"); + expect(messages.some(message => message.type === "done")).toBe(false); + }); + }); + test("EOF with no open tool call keeps its existing graceful finish", async () => { await withH2Server(respondWith([emptyFrame()]), async baseUrl => { const { failure } = await drain(baseUrl, runRequest()); diff --git a/tests/cursor-errors.test.ts b/tests/cursor-errors.test.ts index 80e5310f81..59a7b95399 100644 --- a/tests/cursor-errors.test.ts +++ b/tests/cursor-errors.test.ts @@ -12,9 +12,7 @@ describe("classifyCursorError", () => { expect(classifyCursorError("rate limit exceeded for model")).toBe("Cursor rate limit exceeded"); }); - test("generic resource_exhausted is quota-style rate limiting, not a too-large request", () => { - // The live retry-storm shape: no detail beyond "Error" — must map to 429 so Codex backs off. - expect(classifyCursorError("Cursor Connect error resource_exhausted: Error")).toBe("Cursor rate limit exceeded"); + test("explicit quota-cue resource_exhausted is rate limiting; bare overflow is context limit (T01)", () => { expect(classifyCursorError("resource_exhausted: too many requests")).toBe("Cursor rate limit exceeded"); expect(classifyCursorError("resource_exhausted while loading tool catalog: quota exhausted")).toBe("Cursor rate limit exceeded"); // Concurrency limits are quota shapes, not request-size overflow (a bare "limit" @@ -32,6 +30,20 @@ describe("classifyCursorError", () => { expect(classifyCursorError("resource_exhausted: request size exceeds maximum allowed limit")).toBe("Cursor resource limit exceeded"); }); + test("bare resource_exhausted with no quota cue and no size phrase is payload overflow (T01)", () => { + // senpi #1009 / #1036: a huge session hits the context window and Cursor returns a bare + // gRPC resource_exhausted end-stream with no detail. Classifying it as 429 makes Codex + // back off instead of compacting, which burns retries on an unfixable-by-retry failure. + expect(classifyCursorError("Cursor Connect error resource_exhausted: Error")).toBe("Cursor context limit exceeded"); + expect(classifyCursorError("resource_exhausted")).toBe("Cursor context limit exceeded"); + expect(classifyCursorError("resource exhausted")).toBe("Cursor context limit exceeded"); + }); + + test("explicit quota wording still maps to rate limit even without a size phrase", () => { + expect(classifyCursorError("resource_exhausted: too many requests for this model")).toBe("Cursor rate limit exceeded"); + expect(classifyCursorError("resource_exhausted while loading tool catalog: quota exhausted")).toBe("Cursor rate limit exceeded"); + }); + test("authentication / permission denied", () => { expect(classifyCursorError("unauthenticated: invalid bearer token")).toBe("Cursor authentication failed"); expect(classifyCursorError("permission_denied: account suspended")).toBe("Cursor authentication failed"); @@ -102,9 +114,10 @@ describe("safeCursorErrorMessage", () => { expect(msg).not.toContain("rate limit"); }); - test("end-to-end: quota-style resource exhaustion carries the rate-limit prefix", () => { + test("end-to-end: bare resource_exhausted carries the overflow prefix; explicit quota carries the rate-limit prefix", () => { + // Bare resource_exhausted is payload overflow (T01): the 400-class prefix lets Codex compact. expect(safeCursorErrorMessage("Cursor Connect error resource_exhausted: Error")) - .toContain("Cursor rate limit exceeded"); + .toContain("Cursor context limit exceeded"); expect(safeCursorErrorMessage("resource_exhausted: too many requests")) .toContain("Cursor rate limit exceeded"); expect(safeCursorErrorMessage("resource_exhausted while loading tool catalog: quota exhausted")) @@ -123,3 +136,34 @@ describe("isCursorInvalidArgumentError", () => { expect(isCursorInvalidArgumentError(new Error("Cursor connection failed"))).toBe(false); }); }); + +describe("bare resource_exhausted size prior (devlog 260)", () => { + const BARE = "Cursor Connect error resource_exhausted: Error"; + + test("a provably small request keeps the 429 class (plan-gated model, live probe 210)", () => { + expect(classifyCursorError(BARE, { estimatedInputTokens: 20, contextWindow: 200_000 })) + .toBe("Cursor rate limit exceeded"); + }); + + test("a plausibly large request still classifies as context overflow", () => { + expect(classifyCursorError(BARE, { estimatedInputTokens: 150_000, contextWindow: 200_000 })) + .toBe("Cursor context limit exceeded"); + }); + + test("unknown estimate or window keeps today's overflow mapping (prior only removes provable false overflows)", () => { + expect(classifyCursorError(BARE)).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, {})).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, { estimatedInputTokens: 20 })).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, { contextWindow: 200_000 })).toBe("Cursor context limit exceeded"); + }); + + test("explicit quota cues stay 429 regardless of size context", () => { + expect(classifyCursorError("resource_exhausted: quota exhausted", { estimatedInputTokens: 150_000, contextWindow: 200_000 })) + .toBe("Cursor rate limit exceeded"); + }); + + test("explicit size phrases stay resource-limit regardless of size context", () => { + expect(classifyCursorError("resource_exhausted: request body exceeds maximum allowed size", { estimatedInputTokens: 20, contextWindow: 200_000 })) + .toBe("Cursor resource limit exceeded"); + }); +}); diff --git a/tests/cursor-h2-pool-shutdown.test.ts b/tests/cursor-h2-pool-shutdown.test.ts new file mode 100644 index 0000000000..f832206f80 --- /dev/null +++ b/tests/cursor-h2-pool-shutdown.test.ts @@ -0,0 +1,62 @@ +import http2 from "node:http2"; +import { afterEach, describe, expect, test } from "bun:test"; +import { CursorH2SessionPool } from "../src/adapters/cursor/h2-pool"; +import { + resetOptionalShutdownHooksForTests, + runOptionalShutdownHooks, +} from "../src/lib/optional-shutdown-hooks"; + +afterEach(() => { + resetOptionalShutdownHooksForTests(); +}); + +async function withH2Server(run: (baseUrl: string) => Promise): Promise { + const server = http2.createServer(); + server.on("stream", stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200 }); + // hold the stream open; shutdown must not depend on server cooperation + }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("HTTP/2 fixture did not bind a TCP port"); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } +} + +describe("CursorH2SessionPool shutdown hook (devlog 120b)", () => { + test("first request() registers a shutdown hook that closes pooled sessions", async () => { + await withH2Server(async baseUrl => { + const pool = new CursorH2SessionPool(); + const stream = pool.request(baseUrl, { ":method": "POST", ":path": "/x" }); + expect(pool.size).toBe(1); + runOptionalShutdownHooks(); + // shutdown() is fire-and-forget in the sync seam; give it a beat to settle. + await new Promise(resolve => setTimeout(resolve, 100)); + expect(pool.size).toBe(0); + expect(() => pool.request(baseUrl, { ":method": "POST", ":path": "/x" })).toThrow(/closed/); + stream.destroy(); + }); + }); + + test("running the hooks twice is safe (idempotent shutdown)", async () => { + await withH2Server(async baseUrl => { + const pool = new CursorH2SessionPool(); + pool.request(baseUrl, { ":method": "POST", ":path": "/x" }).destroy(); + runOptionalShutdownHooks(); + runOptionalShutdownHooks(); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(pool.size).toBe(0); + }); + }); +}); diff --git a/tests/cursor-native-exec.test.ts b/tests/cursor-native-exec.test.ts index e3365ce040..be1ac99c47 100644 --- a/tests/cursor-native-exec.test.ts +++ b/tests/cursor-native-exec.test.ts @@ -253,12 +253,39 @@ describe("Cursor native exec bridge", () => { } }); - test("unknown exec cases return empty reply instead of throwing (#116 hardening)", async () => { + test("unknown exec cases reply with ExecClientThrow + streamClose instead of silence (T05)", async () => { const result = await handleCursorNativeExec(execMessage({ case: undefined, value: undefined, })); - expect(result).toEqual([]); + // T05 (senpi contract): a frame that cannot be answered gets a typed in-band error + // + stream-close so the server unblocks with a known failure. #116 was about an + // unhandled throw propagating to failAndClear and killing the whole gRPC connection; + // a typed ExecClientThrow does not do that. + expect(result).toHaveLength(2); + + // Control messages use a different top-level case; decode them directly from the wire. + const throwMsg = fromBinary(AgentClientMessageSchema, result[0]); + const closeMsg = fromBinary(AgentClientMessageSchema, result[1]); + expect(throwMsg.message.case).toBe("execClientControlMessage"); + if (throwMsg.message.case === "execClientControlMessage") { + expect(throwMsg.message.value.message.case).toBe("throw"); + if (throwMsg.message.value.message.case === "throw") { + expect(throwMsg.message.value.message.value.error).toContain("Unknown exec message variant"); + } + } + expect(closeMsg.message.case).toBe("execClientControlMessage"); + if (closeMsg.message.case === "execClientControlMessage") { + expect(closeMsg.message.value.message.case).toBe("streamClose"); + } + }); + + test("unknown exec cases do NOT kill the gRPC connection (#116 hardening preserved)", async () => { + // The T05 typed reply must not propagate into failAndClear. The transport-level + // contract is that handleCursorNativeExec returns bytes (not throws), which is + // what live-transport writes back. This test pins that boundary. + const replies = await handleCursorNativeExec(execMessage({ case: undefined, value: undefined })); + expect(replies.length).toBeGreaterThan(0); }); test("rejects native write and delete when apply_patch is available", async () => { diff --git a/tests/cursor-oauth.test.ts b/tests/cursor-oauth.test.ts index 19abe77e4f..c4f3b2651b 100644 --- a/tests/cursor-oauth.test.ts +++ b/tests/cursor-oauth.test.ts @@ -57,6 +57,32 @@ describe("Cursor OAuth core flow", () => { await expect(pollCursorAuth("uuid", "ver", ctrl.signal, 1)).rejects.toThrow(/cancel/i); }); + test("pollCursorAuth fails on the FIRST terminal status without retrying (T07)", async () => { + for (const status of [400, 401, 403, 410]) { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response("", { status }); + }) as typeof fetch; + const err = await pollCursorAuth("uuid", "ver", undefined, 1).catch((e: unknown) => e as Error); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain(String(status)); + expect((err as Error).message).toMatch(/new login/i); + expect(calls).toBe(1); + } + }); + + test("pollCursorAuth keeps the 3-strike retry for server errors (500)", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response("", { status: 500 }); + }) as typeof fetch; + const err = await pollCursorAuth("uuid", "ver", undefined, 1).catch((e: unknown) => e as Error); + expect((err as Error).message).toMatch(/consecutive errors/i); + expect(calls).toBe(3); + }); + test("refreshCursorToken posts the refresh token as a Bearer and returns new creds", async () => { let seenAuth = ""; globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { diff --git a/tests/cursor-pool.test.ts b/tests/cursor-pool.test.ts new file mode 100644 index 0000000000..25881e8c48 --- /dev/null +++ b/tests/cursor-pool.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { CursorCredentialRouter, NoAvailableCursorCredentialError } from "../src/providers/cursor-pool"; + +describe("CursorCredentialRouter", () => { + test("weighted round-robin distributes picks proportionally", () => { + const router = new CursorCredentialRouter([ + { id: "a", weight: 3 }, + { id: "b", weight: 1 }, + ]); + const picks: Record = { a: 0, b: 0 }; + for (let i = 0; i < 40; i++) { + const cred = router.pick(); + picks[cred.id] = (picks[cred.id] ?? 0) + 1; + } + // 3:1 ratio should be roughly 30:10 + expect(picks.a).toBeGreaterThan(picks.b * 2); + }); + + test("disable + cooldown excludes the credential", () => { + const router = new CursorCredentialRouter([{ id: "a", weight: 1 }]); + router.disable("a"); + expect(() => router.pick()).toThrow(NoAvailableCursorCredentialError); + }); + + test("failover picks a different credential when one is disabled", () => { + const router = new CursorCredentialRouter([ + { id: "a", weight: 1 }, + { id: "b", weight: 1 }, + ]); + router.disable("a"); + const cred = router.pick(); + expect(cred.id).toBe("b"); + }); +}); diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 1f8e1bfbec..d03d7724d1 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -8,6 +8,7 @@ import { McpArgsSchema, McpToolCallSchema, PartialToolCallUpdateSchema, + TextDeltaUpdateSchema, TokenDeltaUpdateSchema, ToolCallCompletedUpdateSchema, ToolCallSchema, @@ -1102,3 +1103,39 @@ describe("request-local input estimate (#373)", () => { expect(usage?.inputTokens).toBe(0); }); }); + +describe("textual pseudo tool-call marker normalization (#2305)", () => { + function textDelta(text: string) { + return interaction({ case: "textDelta", value: create(TextDeltaUpdateSchema, { text }) }); + } + + test("display alias inside [TOOL_CALL]...[ARGS] markers folds to the wire name", () => { + const state = createCursorProtobufEventState(); + const events = mapCursorProtobufServerMessage( + textDelta('[TOOL_CALL]mcp_opencodex-responses_grep[ARGS]{"pattern":"OpenCodex"}'), + state, + ); + expect(events).toEqual([{ type: "text", text: '[TOOL_CALL]grep[ARGS]{"pattern":"OpenCodex"}' }]); + }); + + test("prose mentioning the display alias without markers stays untouched", () => { + const state = createCursorProtobufEventState(); + const prose = "You could call mcp_opencodex-responses_grep here."; + const events = mapCursorProtobufServerMessage(textDelta(prose), state); + expect(events).toEqual([{ type: "text", text: prose }]); + }); + + test("markers with a non-opencodex provider prefix are not rewritten", () => { + const state = createCursorProtobufEventState(); + const other = "[TOOL_CALL]mcp_other-provider_grep[ARGS]{}"; + const events = mapCursorProtobufServerMessage(textDelta(other), state); + expect(events).toEqual([{ type: "text", text: other }]); + }); + + test("already-short names inside markers pass through unchanged", () => { + const state = createCursorProtobufEventState(); + const short = "[TOOL_CALL]grep[ARGS]{}"; + const events = mapCursorProtobufServerMessage(textDelta(short), state); + expect(events).toEqual([{ type: "text", text: short }]); + }); +}); diff --git a/tests/cursor-static-catalog.test.ts b/tests/cursor-static-catalog.test.ts index fa8be304f3..1775989bf5 100644 --- a/tests/cursor-static-catalog.test.ts +++ b/tests/cursor-static-catalog.test.ts @@ -120,3 +120,34 @@ describe("Cursor static Codex catalog", () => { } }); }); + +describe("Opus Fast catalog families (devlog 300, live-verified 260822)", () => { + test("all three -fast families are present with tier pickers", async () => { + const { CURSOR_STATIC_MODELS } = await import("../src/adapters/cursor/discovery"); + for (const id of ["claude-opus-4-7-fast", "claude-opus-4-8-fast", "claude-opus-5-fast"]) { + const entry = CURSOR_STATIC_MODELS.find(model => model.id === id); + expect(entry, `${id} missing from static catalog`).toBeDefined(); + expect(entry?.supportsReasoningEffort, `${id} must carry a tier picker — the bare wire id is not_found`).toBe(true); + } + }); + + test("tier ladders match the 260822 GetUsableModels dump", async () => { + const { cursorModelEffortLadder } = await import("../src/adapters/cursor/effort-map"); + expect(cursorModelEffortLadder("claude-opus-4-7-fast")).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(cursorModelEffortLadder("claude-opus-4-8-fast")).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(cursorModelEffortLadder("claude-opus-5-fast")).toEqual(["low", "medium", "high"]); + }); + + test("wire-id derivation produces the live-verified suffixed forms and never a bare -fast id", async () => { + const { cursorWireModelIdWithEffort, cursorEffortSuffix } = await import("../src/adapters/cursor/effort-map"); + expect(cursorWireModelIdWithEffort("claude-opus-4-8-fast", "high")).toBe("claude-opus-4-8-high-fast"); + expect(cursorWireModelIdWithEffort("claude-opus-5-fast", "medium")).toBe("claude-opus-5-medium-fast"); + expect(cursorWireModelIdWithEffort("claude-opus-4-7-fast", "max")).toBe("claude-opus-4-7-max-fast"); + // No-effort requests must still resolve to a suffix (bare id is not_found on the wire). + for (const id of ["claude-opus-4-7-fast", "claude-opus-4-8-fast", "claude-opus-5-fast"]) { + expect(cursorEffortSuffix(id, undefined), `${id} must never send bare`).toBeTruthy(); + } + // Out-of-ladder effort clamps within the family ladder (opus-5-fast has no xhigh). + expect(cursorEffortSuffix("claude-opus-5-fast", "xhigh")).toBe("high"); + }); +}); diff --git a/tests/cursor-stream-health.test.ts b/tests/cursor-stream-health.test.ts new file mode 100644 index 0000000000..d59c1147ae --- /dev/null +++ b/tests/cursor-stream-health.test.ts @@ -0,0 +1,210 @@ +import http2 from "node:http2"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { describe, expect, test } from "bun:test"; +import { + AgentServerMessageSchema, + ConversationStateStructureSchema, + HeartbeatUpdateSchema, + InteractionUpdateSchema, + TextDeltaUpdateSchema, + TurnEndedUpdateSchema, +} from "../src/adapters/cursor/gen/agent_pb"; +import { encodeConnectFrame } from "../src/adapters/cursor/framing"; +import { createLiveCursorTransport } from "../src/adapters/cursor/live-transport"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import type { CursorRunRequest, CursorServerMessage } from "../src/adapters/cursor/types"; + +/** + * T04 (devlog 260822_senpi_cursor_transfer/110): inbound stream-health watchdog. + * A turn that received its first frame but then goes silent (or heartbeat-only) + * must fail at the transport with a typed stall error instead of waiting for the + * 300s bridge stall watchdog (issue #2210 class). + */ + +function agentFrame(message: Parameters>[1]): Uint8Array { + return encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, message))); +} + +function textDeltaFrame(textValue: string): Uint8Array { + return agentFrame({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "textDelta", value: create(TextDeltaUpdateSchema, { text: textValue }) }, + }), + }, + }); +} + +function heartbeatFrame(): Uint8Array { + return agentFrame({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "heartbeat", value: create(HeartbeatUpdateSchema, {}) }, + }), + }, + }); +} + +function checkpointFrame(): Uint8Array { + return agentFrame({ + message: { + case: "conversationCheckpointUpdate", + value: create(ConversationStateStructureSchema, {}), + }, + }); +} + +function turnEndedFrame(): Uint8Array { + return agentFrame({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: create(TurnEndedUpdateSchema, {}) }, + }), + }, + }); +} + +async function withH2Server( + handler: (stream: http2.ServerHttp2Stream) => void, + run: (baseUrl: string) => Promise, +): Promise { + const server = http2.createServer(); + server.on("stream", handler); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("HTTP/2 fixture did not bind a TCP port"); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } +} + +function runRequest(): CursorRunRequest { + return { + modelId: "composer-2", + conversationId: "cursor_stream_health_test", + system: [], + messages: [{ role: "user", content: "hello" }], + } as CursorRunRequest; +} + +async function drain(baseUrl: string, knobs: { streamSilenceFailMs?: number; streamHeartbeatOnlyFailMs?: number }): Promise<{ + messages: CursorServerMessage[]; + failure?: Error; +}> { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl, apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + firstFrameTimeoutMs: 2_000, + ...knobs, + }); + const messages: CursorServerMessage[] = []; + let failure: Error | undefined; + try { + for await (const message of transport.run(runRequest())) messages.push(message); + } catch (err) { + failure = err instanceof Error ? err : new Error(String(err)); + } finally { + await transport.close?.(); + } + return { messages, failure }; +} + +describe("Cursor inbound stream-health watchdog (T04)", () => { + test("silence after the first frame fails the turn with the stall error", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(textDeltaFrame("hi"))); + // then: silence — never end the stream + }, async baseUrl => { + const { failure } = await drain(baseUrl, { streamSilenceFailMs: 300, streamHeartbeatOnlyFailMs: 10_000 }); + expect(failure).toBeDefined(); + expect(failure!.message).toContain("no inbound frames"); + }); + }, 15_000); + + test("heartbeat-only traffic survives the silence threshold but fails at the heartbeat-only threshold", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(textDeltaFrame("hi"))); + const ping = setInterval(() => { + try { + stream.write(Buffer.from(heartbeatFrame())); + stream.write(Buffer.from(checkpointFrame())); + } catch { clearInterval(ping); } + }, 100); + stream.on("close", () => clearInterval(ping)); + }, async baseUrl => { + const { failure } = await drain(baseUrl, { streamSilenceFailMs: 400, streamHeartbeatOnlyFailMs: 900 }); + expect(failure).toBeDefined(); + expect(failure!.message).toContain("heartbeat-only"); + }); + }, 15_000); + + test("meaningful frames keep resetting both clocks; turnEnded finishes cleanly", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + let count = 0; + const tick = setInterval(() => { + count += 1; + try { + if (count < 6) { + stream.write(Buffer.from(textDeltaFrame(`part-${count}`))); + } else { + stream.write(Buffer.from(turnEndedFrame())); + stream.end(); + clearInterval(tick); + } + } catch { clearInterval(tick); } + }, 150); + stream.on("close", () => clearInterval(tick)); + }, async baseUrl => { + // Each 150ms text delta must reset the 400ms silence clock: six ticks ≈ 900ms total, + // far past a NON-resetting 400ms deadline. + const { messages, failure } = await drain(baseUrl, { streamSilenceFailMs: 400, streamHeartbeatOnlyFailMs: 10_000 }); + expect(failure).toBeUndefined(); + expect(messages.some(message => message.type === "text")).toBe(true); + expect(messages.some(message => message.type === "done")).toBe(true); + }); + }, 15_000); + + test("turnEnded disarms the watchdog even when the server holds the stream open", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(textDeltaFrame("hi"))); + stream.write(Buffer.from(turnEndedFrame())); + // hold open: the T03 turnEnded close owns this case; the watchdog must not fire first + }, async baseUrl => { + const { messages, failure } = await drain(baseUrl, { streamSilenceFailMs: 300, streamHeartbeatOnlyFailMs: 10_000 }); + expect(failure).toBeUndefined(); + expect(messages.some(message => message.type === "done")).toBe(true); + }); + }, 15_000); + + test("no watchdog before the first frame: the first-frame timeout still owns dial silence", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + // no frames at all + }, async baseUrl => { + const { failure } = await drain(baseUrl, { streamSilenceFailMs: 60_000, streamHeartbeatOnlyFailMs: 60_000 }); + expect(failure).toBeDefined(); + expect(failure!.message).toContain("before first response"); + }); + }, 15_000); +}); diff --git a/tests/cursor-tool-continuation.test.ts b/tests/cursor-tool-continuation.test.ts index 6880d741cb..2772245035 100644 --- a/tests/cursor-tool-continuation.test.ts +++ b/tests/cursor-tool-continuation.test.ts @@ -39,7 +39,7 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = { role: "toolResult", toolCallId: "call_1", toolName: "read_file", toolNamespace: "mcp__fs", content: "FILE CONTENTS HERE", isError: false, timestamp: 3 }, ]; - test("tool result text is present in rootPromptMessagesJson, not only in turns[]", () => { + test("external-continuation tool result text is present in rootPromptMessagesJson, not only in turns[]", () => { const bytes = encodeCursorRunRequest({ modelId: "composer-2.5", conversationId: "c1", @@ -49,14 +49,29 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = }); const roots = decodeRoots(bytes); const serialized = JSON.stringify(roots); - // The model prompt (rootPromptMessagesJson) MUST carry the tool result, or ResumeAction has - // nothing model-visible to resume from. Reference: danger-pi buildRootPromptMessagesJson. + // composer-2.5 still continues as userMessageAction, so the model prompt must carry the + // tool result. Reference: danger-pi buildRootPromptMessagesJson. expect(serialized).toContain("FILE CONTENTS HERE"); expect(serialized).toContain("call_1"); // The prior user turn must also be replayed (not system-only). expect(serialized).toContain("read a file"); }); + test("native resume models keep tool results on turns[], not as assistant-role root text", () => { + const bytes = encodeCursorRunRequest({ + modelId: "auto-intelligence", + conversationId: "c-auto", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nFILE CONTENTS HERE" }], + rawMessages, + }); + const serialized = JSON.stringify(decodeRoots(bytes)); + expect(serialized).toContain("read a file"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).not.toContain("FILE CONTENTS HERE"); + }); + test("rootPromptMessagesJson still leads with the system prompt blob", () => { const bytes = encodeCursorRunRequest({ modelId: "composer-2.5", @@ -82,11 +97,25 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = // "[Tool Call]" text. The model few-shot-mimics that marker and emits later parallel/mixed tool // calls as inert text instead of real tool frames (halting multi-tool continuations). expect(serialized).not.toContain("[Tool Call]"); - // ...but the tool's model-visible continuation context (call id + output) must still survive via - // the paired tool RESULT echo, so the model can continue from it. + // composer-2.5 still needs the paired tool RESULT echo in the model-visible prompt. expect(serialized).toContain("FILE CONTENTS HERE"); expect(serialized).toContain("call_1"); }); + + test("native resume models do not few-shot [Tool Result] as assistant chat", () => { + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5-fast", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nFILE CONTENTS HERE" }], + rawMessages, + }); + const serialized = JSON.stringify(decodeRoots(bytes)); + expect(serialized).not.toContain("[Tool Call]"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).toContain("read a file"); + }); }); import { create as createPb } from "@bufbuild/protobuf"; diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index 4d2a500857..d04535581d 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -14,6 +14,67 @@ function convertedInputDescription(name: string): string | undefined { } describe("routed custom-tool compatibility", () => { + test.each([ + ["absent", undefined], + ["true", true], + ] as const)("keeps apply_patch byte-identical when custom-tool support is %s", (_label, support) => { + const raw = { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + const before = JSON.stringify(raw); + + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, support); + + expect(rewritten.body).toBe(raw); + expect(JSON.stringify(rewritten.body)).toBe(before); + expect(rewritten.names).toEqual(new Set()); + }); + + test("lowers apply_patch declarations and replay items on an explicit capability denial", () => { + const raw = { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, false); + const body = rewritten.body as typeof raw; + + expect(rewritten.names).toEqual(new Set(["apply_patch"])); + expect(body.tools[0]).toMatchObject({ + type: "function", + name: "apply_patch", + parameters: { required: ["input"] }, + }); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(body.input[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch", + output: "done", + }); + }); + + test.each([undefined, true, false])("keeps lowering other custom tools when support is %p", support => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [{ type: "custom", name: "review_patch", description: "Review", format: { type: "text" } }], + }, support); + const body = rewritten.body as { tools: Array> }; + + expect(body.tools[0]).toMatchObject({ type: "function", name: "review_patch" }); + expect(rewritten.names).toEqual(new Set(["review_patch"])); + }); + test("converted exec preserves the JavaScript input contract", () => { const description = convertedInputDescription("exec"); expect(description).toContain("JavaScript"); diff --git a/tests/fastwire-policy.test.ts b/tests/fastwire-policy.test.ts index 412d0524a4..a29debcb34 100644 --- a/tests/fastwire-policy.test.ts +++ b/tests/fastwire-policy.test.ts @@ -288,6 +288,7 @@ describe("resolveFastPolicy matrix", () => { settledCallerTier: undefined, }, { + // B2: key-auth Chat Completions is a documented Priority Processing transport. name: "xAI API-key default", providerName: "xai", modelIds: ["grok-4.6", "grok-4.5"], @@ -297,7 +298,7 @@ describe("resolveFastPolicy matrix", () => { authMode: "key" as const, }, adapter: "openai-chat", - forwardCallerTier: false, + forwardCallerTier: true, callerTier: undefined, settledCallerTier: undefined, }, diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index c914825bee..571ce27c1a 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -118,6 +118,20 @@ describe("#1735 thought signature survives history replay", () => { .toBe(SIGNATURE); }); + test("a functionCall part with nested extra_content.google.thought_signature is read", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(firstTurn()); + const events = await adapter.parseResponse!(new Response(JSON.stringify(googleBody([ + { + functionCall: { name: "shell_command", args: { command: "pwd" } }, + extra_content: { google: { thought_signature: SIGNATURE } }, + }, + ])))); + const start = events.find((e: AdapterEvent) => e.type === "tool_call_start"); + expect(start && "providerMetadata" in start ? start.providerMetadata?.google?.thoughtSignature : undefined) + .toBe(SIGNATURE); + }); + test("parallel calls each keep their own signature", async () => { const adapter = createGoogleAdapter(provider); await adapter.buildRequest(firstTurn()); diff --git a/tests/management-api-logs-metrics.test.ts b/tests/management-api-logs-metrics.test.ts index f7077a8951..0255005e2f 100644 --- a/tests/management-api-logs-metrics.test.ts +++ b/tests/management-api-logs-metrics.test.ts @@ -95,6 +95,31 @@ describe("GET /api/logs display metrics", () => { expect(dto!.displayMetrics.cost.estimateReasons).toContain("cache_detail_missing"); }); + test("confirmed xAI priority plus long context is exposed as a cost lower bound", async () => { + addRequestLog(baseEntry({ + provider: "xai", + model: "grok-4.6", + usage: { + inputTokens: 200_000, + outputTokens: 10_000, + cacheReadInputTokens: 50_000, + }, + tierOutcome: { + canonical: "priority", + wireKind: "service-tier", + wireValue: "priority", + fastOutcome: "applied", + confirmation: "confirmed", + responseServiceTier: "priority", + }, + })); + const [dto] = await readLogs(); + expect(dto!.displayMetrics.cost.kind).toBe("value"); + expect(dto!.displayMetrics.cost.estimate.priorityLowerBound).toBe(true); + expect(dto!.displayMetrics.cost.estimate.cost.total).toBeCloseTo(0.77, 9); + expect(dto!.displayMetrics.cost.estimateReasons).toContain("priority_lower_bound"); + }); + test("unmatched price is unavailable instead of zero", async () => { addRequestLog(baseEntry({ provider: "no-such-provider", diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts index 45a4157808..83367a8ed8 100644 --- a/tests/namespace-tool-compat.test.ts +++ b/tests/namespace-tool-compat.test.ts @@ -198,9 +198,8 @@ describe("Responses namespace tool compatibility", () => { expect(flatten([functionsGroup], [bare])).toEqual([bare]); }); - // The routed compaction turn strips the whole tool surface before this runs, and a catalog can - // change mid-session — but the client is still replaying items this layer's own restoration - // stamped with a private `namespace`. + // A catalog can be absent or change mid-session, but the client can still replay items this + // layer's own restoration stamped with a private `namespace`. test("lowers replayed calls even when this turn declares no namespace", () => { const body = rewriteRoutedNamespaceToolsForUpstream({ input: [ diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 3da353dac0..27254db95f 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -3,6 +3,7 @@ import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterP import { openaiResponsesUrl } from "../src/adapters/openai-responses-url"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; +import { routeModel } from "../src/router"; import { handleResponses, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { encodeCompactionSummary, @@ -248,6 +249,369 @@ describe("DeepSeek Responses endpoint contract", () => { }); }); +describe("Responses custom-tool destination capability", () => { + test("xAI explicitly denies native custom tools and registry enrichment preserves an override", () => { + const entry = getProviderRegistryEntry("xai")!; + expect(entry.supportsResponsesCustomTools).toBe(false); + + const inherited = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + } as Parameters[1]; + enrichProviderFromRegistry("xai", inherited); + expect(inherited.supportsResponsesCustomTools).toBe(false); + + const explicit = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + supportsResponsesCustomTools: true, + } as Parameters[1]; + enrichProviderFromRegistry("xai", explicit); + expect(explicit.supportsResponsesCustomTools).toBe(true); + + const routed = routeModel({ + port: 0, + defaultProvider: "xai", + providers: { + xai: { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" }, + }, + } as OcxConfig, "xai/grok-4.6"); + expect(routed.provider.supportsResponsesCustomTools).toBe(false); + }); + + test("noncanonical forward destinations that deny custom tools lower apply_patch", () => { + const rawBody = { + model: "routed-model", + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "c1", name: "apply_patch", input: "noop" }, + ], + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "routed-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }).buildRequest(parsed, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + }; + + expect(request.headers.authorization).toBe("Bearer provider-static"); + expect(body.tools[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "c1", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect([...(request.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); + }); + + test("the canonical Codex forward surface never lowers custom tools, even with an explicit denial", () => { + const rawBody = { + model: "gpt-5.6-sol", + stream: true, + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "c1", name: "apply_patch", input: "noop" }, + ], + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + // Exact canonical Codex forward base URL: isCanonicalOpenAiForwardProvider is true, + // so the lowering gate must be unreachable regardless of the capability flag. + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }).buildRequest(parsed, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + }; + + expect(body.tools[0]).toMatchObject({ type: "custom", name: "apply_patch" }); + expect(body.input[0]).toMatchObject({ type: "custom_tool_call", call_id: "c1", name: "apply_patch" }); + expect(request.convertedRoutedCustomToolNames ?? []).toEqual([]); + }); +}); + +describe("routed compaction lowering order", () => { + const baseInput = [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA" }, + ], + }, + { type: "custom_tool_call", call_id: "c1", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "c1", output: "ok" }, + { + type: "tool_search_call", + call_id: "c2", + execution: "client", + arguments: { query: "database" }, + }, + { + type: "tool_search_output", + call_id: "c2", + execution: "client", + status: "completed", + tools: [{ + type: "function", + name: "loaded_tool", + defer_loading: true, + parameters: { type: "object" }, + }], + }, + { + type: "function_call", + call_id: "c3", + namespace: "collaboration", + name: "spawn_agent", + arguments: "{}", + }, + { type: "function_call_output", call_id: "c3", output: "done" }, + { + type: "additional_tools", + role: "developer", + tools: [{ + type: "function", + name: "extra", + defer_loading: true, + parameters: { type: "object" }, + }], + }, + ]; + const rawBody = (compaction: boolean) => ({ + model: "routed-model", + stream: false, + input: [ + ...baseInput, + ...(compaction ? [{ type: "compaction_trigger" }] : []), + ], + tools: [ + { + type: "custom", + name: "apply_patch", + description: "Apply patch", + format: { type: "text" }, + }, + { + type: "function", + name: "tool_search", + description: "Ordinary collision", + parameters: { type: "object" }, + }, + { + type: "tool_search", + execution: "client", + description: "Find tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", parameters: { type: "object" } }], + }, + ], + tool_choice: "auto", + parallel_tool_calls: true, + text: { format: { type: "json_object" } }, + }); + const loweredReplay = [ + { + type: "function_call", + call_id: "c1", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + { + type: "function_call", + call_id: "c2", + name: "opencodex_tool_search", + arguments: JSON.stringify({ query: "database" }), + }, + { + type: "function_call_output", + call_id: "c2", + output: JSON.stringify({ + tools: [{ + type: "function", + name: "loaded_tool", + defer_loading: true, + parameters: { type: "object" }, + }], + status: "completed", + }), + }, + { + type: "function_call", + call_id: "c3", + name: "collaboration__spawn_agent", + arguments: "{}", + }, + { type: "function_call_output", call_id: "c3", output: "done" }, + ]; + const loweredTools = [ + { + type: "function", + name: "apply_patch", + description: "Apply patch", + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: "Raw input for this client-executed custom tool.", + }, + }, + required: ["input"], + additionalProperties: false, + }, + }, + { + type: "function", + name: "tool_search", + description: "Ordinary collision", + parameters: { type: "object" }, + }, + { + type: "function", + description: "Find tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + name: "opencodex_tool_search", + }, + { + type: "function", + name: "collaboration__spawn_agent", + parameters: { type: "object" }, + }, + { type: "function", name: "loaded_tool", parameters: { type: "object" } }, + ]; + + function build(compaction: boolean) { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "test-key", + supportsResponsesCustomTools: false, + }); + return adapter.buildRequest({ + modelId: "routed-model", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: rawBody(compaction), + ...(compaction ? { _compactionRequest: true } : {}), + }, { headers: new Headers() }); + } + + test("lowers replayed calls before removing the compaction tool surface", () => { + const built = build(true); + const body = JSON.parse(built.body) as Record & { + input: Array>; + }; + + expect(body.input.slice(0, -1)).toEqual([ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_text", text: "[image omitted for compaction]" }, + ], + }, + ...loweredReplay, + ]); + expect(body.input.at(-1)).toEqual({ + type: "message", + role: "user", + content: [{ + type: "input_text", + text: expect.stringContaining("CONTEXT CHECKPOINT COMPACTION"), + }], + }); + + expect(body).not.toHaveProperty("tools"); + expect(body).not.toHaveProperty("tool_choice"); + expect(body).not.toHaveProperty("parallel_tool_calls"); + expect(body).not.toHaveProperty("text"); + expect(body.input.some(item => item.type === "compaction_trigger")).toBe(false); + expect(body.input.some(item => item.type === "additional_tools")).toBe(false); + expect(JSON.stringify(body)).not.toContain("input_image"); + expect(JSON.stringify(body)).not.toContain("data:image/png"); + expect(body.input.find(item => item.call_id === "c3")).not.toHaveProperty("namespace"); + + expect([...(built.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); + expect([...(built.convertedRoutedToolSearchNames ?? [])]).toEqual(["opencodex_tool_search"]); + expect([...(built.convertedRoutedNamespaceToolAliases ?? new Map()).entries()]).toEqual([ + ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }], + ]); + }); + + test("leaves the non-compaction serialized body byte-identical", () => { + const built = build(false); + expect(built.body).toBe(JSON.stringify({ + model: "routed-model", + stream: false, + input: [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA" }, + ], + }, + ...loweredReplay, + { + type: "additional_tools", + role: "developer", + tools: [{ type: "function", name: "extra", parameters: { type: "object" } }], + }, + ], + tools: loweredTools, + tool_choice: "auto", + parallel_tool_calls: true, + text: { format: { type: "json_object" } }, + })); + }); +}); + describe("OpenAI Responses passthrough sanitization", () => { const deferredToolBody = { model: "routed-model", @@ -976,7 +1340,7 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toMatchObject({ type: "image_generation" }); }); - test("drops ChatGPT's external_web_access hint but keeps routed web search", () => { + test("normalizes xAI top-level and additional web search without stale tool choice", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", @@ -998,21 +1362,18 @@ describe("OpenAI Responses passthrough sanitization", () => { tools: [{ type: "web_search", external_web_access: true, search_context_size: "medium" }], }], tools: [{ type: "web_search", external_web_access: false, filters: { allowed_domains: ["example.com"] } }], + tool_choice: { type: "web_search" }, }, }, { headers: new Headers() }); const body = JSON.parse(request.body) as { - tools: Record[]; + tools?: Record[]; input: Array<{ type: string; tools: Record[] }>; + tool_choice: Record; }; - expect(body.tools).toEqual([{ - type: "web_search", - filters: { allowed_domains: ["example.com"] }, - }]); - expect(body.input[0]?.tools).toEqual([{ - type: "web_search", - search_context_size: "medium", - }]); + expect(body.tools).toBeUndefined(); + expect(body.input[0]?.tools).toEqual([{ type: "web_search" }]); + expect(body.tool_choice).toEqual({ type: "web_search" }); }); test("preserves external_web_access on the canonical OpenAI forward route", () => { @@ -1070,7 +1431,7 @@ describe("OpenAI Responses passthrough sanitization", () => { input: Array<{ tools: Record[] }>; }; - expect(body.tools[0]).toEqual({ type: "web_search_preview" }); + expect(body.tools[0]).toEqual({ type: "web_search" }); expect(body.tools[1]).toMatchObject({ type: "function", name: "workspace__read" }); expect(body.tools[1]).not.toHaveProperty("defer_loading"); expect(body.input[0].tools[0]).not.toHaveProperty("defer_loading"); diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index a659e4ba97..d5ce3fa614 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -37,6 +37,10 @@ interface ReleaseScenario { originUrl?: string; } +interface SshInvocation { + args: string[]; +} + function writeExecutable(path: string, contents: string): void { writeFileSync(path, contents, "utf8"); chmodSync(path, 0o755); @@ -264,6 +268,51 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { return { calls, result }; } +/** + * Run the exact command string emitted by the release helper through real Git and a fake SSH. + * + * The release shim proves which string was placed in the environment, but Git owns the parsing + * contract for `GIT_SSH_COMMAND`. Exercising a real Git process here catches quoting that looks + * correct in text yet splits, substitutes, or reinterprets the private-key path before SSH sees it. + */ +function executeGitSshCommand(gitSshCommand: string): { calls: SshInvocation[]; result: ReturnType } { + const shimDir = mkdtempSync(join(tmpdir(), "ocx-release-ssh-")); + const logPath = join(shimDir, "ssh-log.jsonl"); + const jsPath = join(shimDir, "ssh.js"); + writeFileSync(logPath, "", "utf8"); + writeFileSync(jsPath, `import { appendFileSync } from "node:fs"; +appendFileSync(process.env.FAKE_SSH_LOG, JSON.stringify({ args: process.argv.slice(2) }) + "\\n"); +process.exit(0); +`, "utf8"); + + // Use a native executable directly on every platform. A Windows `.cmd` shim that forwards `%*` + // reparses quoting and can make a broken GIT_SSH_COMMAND look correct after the damage, turning + // this regression into a false green. Only replace the executable token; Git still parses the + // exact emitted `-i` argument and hostile key path. + expect(gitSshCommand.startsWith("ssh ")).toBe(true); + const quote = (value: string) => `"${value.replace(/(["\\`$])/g, "\\$1")}"`; + const nativeFakeCommand = `${quote(process.execPath)} ${quote(jsPath)}${gitSshCommand.slice(3)}`; + + const inheritedEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== "GIT_SSH" && key !== "GIT_SSH_COMMAND"), + ); + const result = spawnSync("git", ["ls-remote", "ssh://example.invalid/owner/repository.git"], { + cwd: repoRoot, + env: { + ...inheritedEnv, + FAKE_SSH_LOG: logPath, + GIT_SSH_COMMAND: nativeFakeCommand, + }, + encoding: "utf8", + }); + const raw = readFileSync(logPath, "utf8").trim(); + const calls = raw + ? raw.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line) as SshInvocation) + : []; + rmSync(shimDir, { recursive: true, force: true }); + return { calls, result }; +} + describe("release helper", () => { test("preflight runs the shared audit, typecheck, test suite, and privacy scan before version bump", () => { const { calls, result } = runRelease("9.9.9"); @@ -391,6 +440,25 @@ describe("release helper", () => { expect(push?.gitSshCommand).toBe('ssh -i "C:\\\\Users\\\\Jun Kim\\\\.ssh\\\\ocx release key" -o IdentitiesOnly=yes'); }); + test("Git passes the emitted deploy-key path to SSH as one literal argument", () => { + const keyPath = 'C:\\Users\\Jun Kim\\.ssh\\ocx "quoted" $HOME $(not-run) `not-run`; key'; + const { calls: releaseCalls } = runRelease("9.9.9", { + releaseSshKey: keyPath, + releaseSshRepo: sshTarget, + pendingBump: true, + }); + const push = releaseCalls.find(call => call.name === "git" && call.args[0] === "push"); + expect(push?.gitSshCommand).toBeDefined(); + + const { calls } = executeGitSshCommand(push?.gitSshCommand ?? ""); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + const identityIndex = call.args.indexOf("-i"); + expect(identityIndex).toBeGreaterThanOrEqual(0); + expect(call.args[identityIndex + 1]).toBe(keyPath); + } + }); + /** * The SSH target is derived from `origin` rather than hardcoded, so a fork's release pushes to * the fork instead of silently targeting upstream. @@ -436,6 +504,46 @@ describe("release helper", () => { expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); }); + test("credential-bearing SSH targets are rejected without logging the credential", () => { + for (const scenario of [ + { releaseSshRepo: "ssh://git:SECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "ssh://SECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "ssh://git%3ASECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "git@SECRET@example.test:owner/repository.git" }, + { releaseSshRepo: "ssh://git:@example.test/owner/repository.git" }, + { releaseSshRepo: "git@example.test:owner/repository.git?token=SECRET" }, + { originUrl: "ssh://git:SECRET@example.test/owner/repository.git" }, + { originUrl: "git:SECRET@example.test:owner/repository.git" }, + ]) { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + pendingBump: true, + ...scenario, + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + expect(result.status).not.toBe(0); + expect(output).not.toContain("SECRET"); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); + } + }); + + test("credential-free ssh URL and scp-like release targets remain accepted", () => { + for (const releaseSshRepo of [ + "ssh://git@example.test/owner/repository.git", + "ssh://example.test/owner/repository.git", + "git@example.test:owner/repository.git", + ]) { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + releaseSshRepo, + pendingBump: true, + }); + expect(result.status).toBe(0); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")?.args[1]) + .toBe(releaseSshRepo); + } + }); + test("an ssh origin is reused verbatim rather than rewritten", () => { const { calls } = runRelease("9.9.9", { releaseSshKey: "/tmp/k", diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index a5fdafabee..923d52af44 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -528,6 +528,210 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses lowers and restores apply_patch when the destination denies custom tools", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const upstreamItem = { + type: "function_call", + id: "fc_patch_next", + call_id: "call_patch_next", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: upstreamItem.id, + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + supportsResponsesCustomTools: false, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [ + { + type: "custom_tool_call", + id: "ctc_patch_prior", + call_id: "call_patch_prior", + name: "apply_patch", + input: "noop", + }, + { type: "custom_tool_call_output", call_id: "call_patch_prior", output: "done" }, + ], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + const outboundInput = outboundBody?.input as Array> | undefined; + + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(outboundInput?.[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch_prior", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(outboundInput?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch_prior", + output: "done", + }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"id":"ctc_patch_next"'); + expect(clientSse).toContain('"call_id":"call_patch_next"'); + expect(clientSse).toContain('"name":"apply_patch"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain("data: [DONE]"); + expect(clientSse).not.toContain('"type":"function_call"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses lowers apply_patch for a noncanonical forward destination that denies custom tools", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + let outboundAuthorization: string | null = null; + let outboundUrl = ""; + const upstreamItem = { + type: "function_call", + id: "fc_patch_next", + call_id: "call_patch_next", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: upstreamItem.id, + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (input, init) => { + outboundUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + outboundBody = JSON.parse(String(init?.body)) as Record; + outboundAuthorization = new Headers(init?.headers).get("authorization"); + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer caller-secret" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [ + { + type: "custom_tool_call", + id: "ctc_patch_prior", + call_id: "call_patch_prior", + name: "apply_patch", + input: "noop", + }, + { type: "custom_tool_call_output", call_id: "call_patch_prior", output: "done" }, + ], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + const outboundInput = outboundBody?.input as Array> | undefined; + + expect(outboundUrl).toBe("https://provider.example/v1/responses"); + expect(outboundAuthorization).toBe("Bearer provider-static"); + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(outboundInput?.[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch_prior", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(outboundInput?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch_prior", + output: "done", + }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"id":"ctc_patch_next"'); + expect(clientSse).toContain('"call_id":"call_patch_next"'); + expect(clientSse).toContain('"name":"apply_patch"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain("data: [DONE]"); + expect(clientSse).not.toContain('"type":"function_call"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses continuation rewrites custom_tool_call_output and keeps call_id ordered", async () => { const savedFetch = globalThis.fetch; const outboundBodies: Array> = []; diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts index f6dc65ac27..3f67df88f4 100644 --- a/tests/responses-routed-web-search-fields.test.ts +++ b/tests/responses-routed-web-search-fields.test.ts @@ -50,6 +50,32 @@ describe("stripOpenAiOnlyWebSearchFields", () => { const clean = { model: "m", tools: [{ type: "web_search" }] }; expect(stripOpenAiOnlyWebSearchFields(clean)).toBe(clean); }); + + test("strips a nested cached declaration even when no top-level tools exist", () => { + const body = { + model: "m", + input: [{ + type: "additional_tools", + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "low", + filters: { allowed_domains: ["example.com"] }, + }], + }], + }; + + expect(stripOpenAiOnlyWebSearchFields(body)).toEqual({ + model: "m", + input: [{ + type: "additional_tools", + tools: [{ + type: "web_search", + filters: { allowed_domains: ["example.com"] }, + }], + }], + }); + }); }); describe("Responses buildRequest web_search capability", () => { @@ -69,17 +95,69 @@ describe("Responses buildRequest web_search capability", () => { }]); }); - test("registry xAI traffic strips fields its Responses API rejects", () => { + test("registry xAI traffic normalizes Codex search fields for its public Responses API", () => { const entry = getProviderRegistryEntry("xai"); if (!entry) throw new Error("xAI registry entry missing"); const provider = { ...providerConfigSeed(entry), adapter: "openai-responses" }; enrichProviderFromRegistry("xai", provider); const body = buildWebSearchBody(provider); + expect(body.tools).toEqual([{ type: "web_search" }]); + }); + + test("non-xAI classified gateways use generic field stripping, not xAI cached-search policy", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://responses.example.com/v1", + authMode: "key", + apiKey: "test-gateway-key", + supportsOpenAiWebSearchToolFields: false, + }; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: [{ + type: "additional_tools", + role: "developer", + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "low", + user_location: { type: "approximate", country: "KR" }, + filters: { excluded_domains: ["blocked.example"] }, + }], + }], + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "medium", + user_location: { type: "approximate" }, + filters: { allowed_domains: ["example.com"] }, + }], + tool_choice: { type: "web_search" }, + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as Record; + expect(body.tools).toEqual([{ type: "web_search", user_location: { type: "approximate" }, + filters: { allowed_domains: ["example.com"] }, + }]); + expect(body.input).toEqual([{ + type: "additional_tools", + role: "developer", + tools: [{ + type: "web_search", + user_location: { type: "approximate", country: "KR" }, + filters: { excluded_domains: ["blocked.example"] }, + }], }]); + expect(body.tool_choice).toEqual({ type: "web_search" }); }); }); @@ -106,7 +184,7 @@ describe("routedProviderConfig web_search capability backfill", () => { expect(routed.supportsOpenAiWebSearchToolFields).toBe(false); }); - test("the routed row actually strips the fatal fields at the adapter", () => { + test("the routed row actually normalizes the search tool at the adapter", () => { const routed = routedProviderConfig("xai", { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", @@ -115,10 +193,7 @@ describe("routedProviderConfig web_search capability backfill", () => { }); const body = buildWebSearchBody({ ...routed, adapter: "openai-responses" }); - expect(body.tools).toEqual([{ - type: "web_search", - user_location: { type: "approximate" }, - }]); + expect(body.tools).toEqual([{ type: "web_search" }]); }); test("an explicit saved value still overrides the registry default", () => { diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index 49c051b8dc..fd1a418602 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -7,15 +7,22 @@ * ever receiving an injection (PR #860 family). */ import { afterEach, describe, expect, test } from "bun:test"; -import { applyProviderConfigHints } from "../src/codex/catalog"; +import { applyProviderConfigHints, buildCatalogEntries, gatherRoutedModels } from "../src/codex/catalog"; import { applyCatalogModelMetadata } from "../src/codex/catalog/effort"; import type { RawEntry } from "../src/codex/catalog/parsing"; import { providerConfigSeed, enrichProviderFromRegistry } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; +import { decideTier } from "../src/providers/fastwire"; import type { RequestLogContext } from "../src/server/request-log"; import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; -import { canForwardServiceTierForModel, serviceTierSupportForModel, supportsServiceTierForModel } from "../src/providers/service-tier"; -import { serviceTierAdapterForModel } from "../src/providers/service-tier"; +import { + canForwardServiceTierForModel, + fastPolicyForModel, + serviceTierAdapterForModel, + serviceTierSupportForModel, + serviceTierSupportFromPolicy, + supportsServiceTierForModel, +} from "../src/providers/service-tier"; import { candidateCapabilityEvidence } from "../src/routing/capability"; import { resolveProductionBehaviorValues } from "../src/routing/compatibility/behavior"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; @@ -74,6 +81,92 @@ describe("registry capability reaches saved configs without overriding them", () }); }); +describe("xAI Fast capability follows the captured authentication transport", () => { + function xaiProvider( + authMode: "key" | "oauth", + overrides: Partial = {}, + ): OcxProviderConfig { + return { + ...providerConfigSeed(getProviderRegistryEntry("xai")!), + authMode, + apiKey: authMode === "key" ? "xai-test-key" : "oauth-test-token", + liveModels: false, + models: ["grok-4.6"], + ...overrides, + }; + } + + async function catalogEntry(provider: OcxProviderConfig) { + const models = await gatherRoutedModels({ + providers: { xai: provider }, + } as unknown as OcxConfig); + return buildCatalogEntries(null, [], models) + .find(entry => entry.slug === "xai/grok-4.6"); + } + + test("registry declares a key-auth overlay without classifying OAuth", () => { + const entry = getProviderRegistryEntry("xai")!; + expect(entry.keyAuthServiceTier).toEqual({ + supportsServiceTier: true, + chatServiceTier: true, + }); + expect(entry.supportsServiceTier).toBeUndefined(); + expect(entry.chatServiceTier).toBeUndefined(); + + const keyPolicy = fastPolicyForModel(xaiProvider("key"), "grok-4.6", "xai"); + expect(keyPolicy).toMatchObject({ + capability: true, + eligibility: "eligible", + forwardCallerTier: true, + fastTierDescription: "Priority processing, 2x token price", + }); + + const oauthPolicy = fastPolicyForModel(xaiProvider("oauth"), "grok-4.6", "xai"); + expect(oauthPolicy.capability).toBeUndefined(); + expect(oauthPolicy.eligibility).toBe("unclassified"); + expect(oauthPolicy.forwardCallerTier).toBe(false); + }); + + test("catalog and runtime publish the same key/OAuth conclusion", async () => { + const keyProvider = xaiProvider("key"); + const keyPolicy = fastPolicyForModel(keyProvider, "grok-4.6", "xai"); + const keyCatalog = await catalogEntry(keyProvider); + expect(serviceTierSupportFromPolicy(keyPolicy)).toBe(true); + expect(keyCatalog?.service_tiers).toEqual([{ + id: "priority", + name: "Fast", + description: "Priority processing, 2x token price", + }]); + expect(keyCatalog?.additional_speed_tiers).toEqual(["fast"]); + expect(decideTier(keyPolicy, true, undefined)).toEqual({ kind: "set", value: "priority" }); + + const oauthProvider = xaiProvider("oauth"); + const oauthPolicy = fastPolicyForModel(oauthProvider, "grok-4.6", "xai"); + const oauthCatalog = await catalogEntry(oauthProvider); + expect(serviceTierSupportFromPolicy(oauthPolicy)).toBe(false); + expect(oauthCatalog).not.toHaveProperty("service_tiers"); + expect(oauthCatalog).not.toHaveProperty("additional_speed_tiers"); + expect(decideTier(oauthPolicy, true, undefined)).toEqual({ kind: "drop" }); + }); + + test("explicit supportsServiceTier=false wins in policy and catalog for both transports", async () => { + for (const authMode of ["key", "oauth"] as const) { + const provider = xaiProvider(authMode, { supportsServiceTier: false }); + const policy = fastPolicyForModel( + provider, + "grok-4.6", + "xai", + ); + expect(policy.capability).toBe(false); + expect(policy.eligibility).toBe("capability-unsupported"); + expect(decideTier(policy, true, undefined)).toEqual({ kind: "drop" }); + const catalog = await catalogEntry(provider); + expect(catalog).not.toHaveProperty("service_tiers"); + expect(catalog).not.toHaveProperty("additional_speed_tiers"); + } + }); +}); + describe("service-tier capability is exact-model and provider-scoped", () => { test("an exact model entry overrides the provider fallback in both directions", () => { const provider: OcxProviderConfig = { @@ -264,6 +357,16 @@ describe("the gate fires on the live handleResponses path", () => { ({ ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }); const openAiKeyProvider = (): OcxProviderConfig => ({ ...providerConfigSeed(getProviderRegistryEntry("openai-apikey")!), apiKey: "sk-test" }); + const xaiKeyProvider = (): OcxProviderConfig => ({ + ...providerConfigSeed(getProviderRegistryEntry("xai")!), + authMode: "key", + apiKey: "xai-test-key", + }); + const xaiOAuthProvider = (): OcxProviderConfig => ({ + ...providerConfigSeed(getProviderRegistryEntry("xai")!), + authMode: "oauth", + apiKey: "xai-oauth-test-token", + }); const openRouterProvider = (overrides: Partial = {}): OcxProviderConfig => { const provider: OcxProviderConfig = { ...providerConfigSeed(getProviderRegistryEntry("openrouter")!), @@ -321,6 +424,23 @@ describe("the gate fires on the live handleResponses path", () => { expect(body.service_tier).toBe("flex"); }); + test("xAI API-key runtime injects priority while OAuth does not", async () => { + const keyBody = await drive("xai", xaiKeyProvider(), "grok-4.6", {}, true); + expect(keyBody.service_tier).toBe("priority"); + const oauthBody = await drive("xai", xaiOAuthProvider(), "grok-4.6", {}, true); + expect(oauthBody).not.toHaveProperty("service_tier"); + for (const provider of [xaiKeyProvider(), xaiOAuthProvider()]) { + const optedOut = await drive( + "xai", + { ...provider, supportsServiceTier: false }, + "grok-4.6", + {}, + true, + ); + expect(optedOut).not.toHaveProperty("service_tier"); + } + }); + test("an unclassified custom Responses provider keeps caller values; only explicit false strips", async () => { const custom = (): OcxProviderConfig => ({ adapter: "openai-responses", baseUrl: "https://gateway.example.com/v1", apiKey: "sk-test" }); const preserved = await drive("custom-gw", custom(), "some-model", { service_tier: "priority" }); diff --git a/tests/service.test.ts b/tests/service.test.ts index 8ee4cd243b..69ef8209db 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -5,7 +5,7 @@ import { isAbsolute, join, posix, win32 } from "node:path"; import * as serviceModule from "../src/service"; import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, prepareServiceInstall, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; @@ -89,19 +89,94 @@ describe("service listen-port bake", () => { }); describe("systemd service unit", () => { - test("bare service command defaults to the install/update/start path", async () => { + test("bare service installs only when absent and otherwise selects no-admin repair", async () => { expect(normalizeServiceSubcommand()).toBe("install"); + expect(normalizeServiceSubcommand("restart")).toBe("repair"); expect(normalizeServiceSubcommand("start")).toBe("start"); expect(normalizeServiceSubcommand("nope")).toBe("nope"); + const bare = parseServiceArgs([]); + expect(selectServiceSubcommand(bare, { hasExplicitSubcommand: false, installed: false })).toBe("install"); + expect(selectServiceSubcommand(bare, { hasExplicitSubcommand: false, installed: true })).toBe("repair"); + expect(selectServiceSubcommand(parseServiceArgs(["install"]), { + hasExplicitSubcommand: true, + installed: true, + })).toBe("install"); + expect(selectServiceSubcommand(parseServiceArgs(["--native"]), { + hasExplicitSubcommand: false, + installed: true, + })).toBe("install"); + + let probes = 0; + const installed = planServiceCommand([], { + probeInstallation: () => { probes += 1; return { state: "installed" }; }, + }); + expect(installed).toMatchObject({ ok: true, command: "repair" }); + expect(probes).toBe(1); + + const absent = planServiceCommand([], { + probeInstallation: () => ({ state: "absent" }), + }); + expect(absent).toMatchObject({ ok: true, command: "install" }); + + const unknown = planServiceCommand([], { + probeInstallation: () => ({ state: "unknown", detail: "query failed" }), + }); + expect(unknown).toMatchObject({ ok: false }); + if (!unknown.ok) expect(unknown.message).toContain("Could not safely determine"); + + probes = 0; + const invalid = planServiceCommand(["--bogus"], { + probeInstallation: () => { probes += 1; return { state: "installed" }; }, + }); + expect(invalid).toMatchObject({ ok: false, message: "Unknown service option: --bogus" }); + expect(probes).toBe(0); + + const explicitInstall = planServiceCommand(["install"], { + probeInstallation: () => { probes += 1; return { state: "unknown" }; }, + }); + expect(explicitInstall).toMatchObject({ ok: true, command: "install" }); + expect(probes).toBe(0); + const service = await readText("src/service.ts"); const serviceCommand = service.slice(service.indexOf("export async function serviceCommand")); - // Args flow through parseServiceArgs (which applies the install default) into the switch. - expect(serviceCommand).toContain("const parsed = parseServiceArgs("); - expect(serviceCommand).toContain("const command = parsed.sub;"); + expect(serviceCommand).toContain("const plan = planServiceCommand(filteredArgs);"); + expect(serviceCommand).toContain("const { parsed, command } = plan;"); expect(serviceCommand).toContain("switch (command)"); }); + test("Windows install presence distinguishes unknown queries from proven absence", () => { + const present = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "present" }), + nativeStatus: () => "unknown", + }); + expect(present.state).toBe("installed"); + + const absent = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "absent" }), + nativeStatus: () => "nonexistent", + }); + expect(absent.state).toBe("absent"); + + const schedulerUnknown = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "unknown", detail: "localized query failure" }), + nativeStatus: () => "nonexistent", + }); + expect(schedulerUnknown).toMatchObject({ state: "unknown" }); + expect(schedulerUnknown.detail).toContain("localized query failure"); + + const nativeUnknown = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "absent" }), + nativeStatus: () => "unknown", + }); + expect(nativeUnknown).toMatchObject({ state: "unknown" }); + expect(nativeUnknown.detail).toContain("WinSW status"); + }); + test("uses unquoted append targets for service logs", () => { const unit = buildUnit(); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 79744f588a..937b0aadeb 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -27,7 +27,7 @@ import { resetSubagentModelFallbackStateForTests, setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; -import type { CodexAuthContext } from "../src/codex/auth-context"; +import { resolveCodexAuthContext, type CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; import { isEagerRelaySseResponse } from "../src/server/relay"; import type { OcxConfig } from "../src/types"; @@ -670,6 +670,111 @@ describe("subagent fallback final-route normalization", () => { }); describe("native fallback account preview", () => { + test("Desktop fallback affinity drives the subagent preview and final native account", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + defaultProvider: "xai", + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + subagentModelFallback: ["gpt-5.6-terra"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const desktopHeaders = { + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }; + const bound = await resolveCodexAuthContext(new Headers(desktopHeaders), cfg, "pool", { + modelId: "gpt-5.6-sol", + }); + expect(bound).toMatchObject({ kind: "pool", accountId: "pool-a" }); + if (bound.kind !== "pool") throw new Error("expected pool context"); + cfg.activeCodexAccountId = "pool-b"; + // The binding above was made under codexQuotaScopeForModel("gpt-5.6-sol") === "shared". + // The preview inside handleResponses must derive the SAME scope from the route model — + // an undefined scope reads the "legacy" slot and would miss the binding entirely. + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, "shared")).toBe("pool-a"); + // With no binding in the legacy slot the preview falls through to rotation/active selection, + // so it returns a DIFFERENT account than the affinity-bound one — that divergence is exactly + // what the route-model scope derivation inside handleResponses prevents. + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, undefined)).not.toBe("pool-a"); + + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("xai/grok-4.5", "429", cfg); + noteSubagentModelFailure("grok-4.5", "429", cfg); + + let finalAuth: CodexAuthContext | undefined; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "xai/grok-4.5", input: readableAgentInput(), stream: false }, + { onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; } }, + { model: "", provider: "" }, + desktopHeaders, + ); + + expect(response.status).toBe(200); + expect(capture.urls.some((url) => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); + expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(capture.auths.some((auth) => auth?.includes("pool-a_token"))).toBe(true); + }); + + test("subagent preview reads the route-model quota scope, not the legacy slot", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + defaultProvider: "xai", + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + subagentModelFallback: ["gpt-5.6-terra"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const desktopHeaders = { + "session-id": "scope-session-private", + "thread-id": "scope-thread-private", + }; + const bound = await resolveCodexAuthContext(new Headers(desktopHeaders), cfg, "pool", { + modelId: "gpt-5.6-sol", + }); + expect(bound).toMatchObject({ kind: "pool", accountId: "pool-a" }); + cfg.activeCodexAccountId = "pool-b"; + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("xai/grok-4.5", "429", cfg); + noteSubagentModelFailure("grok-4.5", "429", cfg); + + let finalAuth: CodexAuthContext | undefined; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "xai/grok-4.5", input: readableAgentInput(), stream: false }, + { onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; } }, + { model: "", provider: "" }, + desktopHeaders, + ); + + // The preview inside handleResponses derives its quota scope from the route model, so the + // affinity binding made under "shared" is found and the fallback authenticates pool-a — + // the same account that bound the thread — even though the active account is now pool-b. + expect(response.status).toBe(200); + expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-a" }); + }); + test("uses healthier pool account B when active A is above threshold", async () => { const now = 1_800_000_000_000; Date.now = () => now; diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 11fa3d77fc..362868f4f2 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { createAdapterTierMetadata } from "../src/providers/fastwire"; import { calculateCost, estimateAttemptCost, @@ -12,8 +13,10 @@ import { import { EXPECTED_PRICE_OVERLAYS, PRIORITY_MULTIPLIERS, + PRIORITY_PRICING_RULES, CONTEXT_TIERS, findExpectedPriceOverlay, + findPriorityPricingRule, resolvePriorityMultiplier, type ExpectedPriceOverlay, } from "../src/usage/expected-prices"; @@ -564,6 +567,169 @@ describe("priority (Fast) service tier multiplier", () => { }); }); +describe("xAI Priority Processing pricing", () => { + const usage = { + inputTokens: 100_000, + outputTokens: 10_000, + cacheReadInputTokens: 20_000, + }; + + function outcome(responseServiceTier?: string) { + const tracker = createAdapterTierMetadata( + { + capability: true, + eligibility: "eligible", + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority" }, + foreignCallerTiers: "verbatim", + }, + demandDecision: "force-fast", + }, + { kind: "set", value: "priority" }, + "service-tier", + "priority", + )!; + if (responseServiceTier !== undefined) tracker.observeResponseServiceTier(responseServiceTier); + return tracker.outcome; + } + + function estimate(tierOutcome: ReturnType, requestUsage = usage) { + return estimateAttemptCost({ + ordinal: 1, + provider: "xai", + model: "grok-4.6", + usageStatus: "reported", + usage: requestUsage, + tierOutcome, + })!; + } + + test("xAI rules declare exact 2x premiums with official provenance", () => { + const xaiRules = PRIORITY_PRICING_RULES.filter(rule => rule.provider === "xai"); + expect(xaiRules.map(rule => rule.modelId)).toEqual(["grok-4.5", "grok-4.6"]); + expect(xaiRules.every(rule => rule.multiplier === 2)).toBe(true); + expect(xaiRules.every(rule => rule.requiresResponseConfirmation === true)).toBe(true); + expect(xaiRules.every(rule => rule.source === "https://docs.x.ai/developers/advanced-api-usage/priority-processing")).toBe(true); + expect(findPriorityPricingRule("xai", "grok-4.6")?.multiplier).toBe(2); + expect(findPriorityPricingRule("openrouter", "grok-4.6")).toBeUndefined(); + expect(resolveMatchedPrice("openrouter", "grok-4.6")?.cost4).toEqual({ + input: 2, + output: 6, + cacheRead: 0.3, + cacheWrite: 0, + }); + expect(resolveMatchedPrice("cursor", "grok-4.6")?.cost4).toEqual({ + input: 2, + output: 6, + cacheRead: 0.3, + cacheWrite: 0, + }); + }); + + test("grok-4.6 standard and confirmed priority prices include the official cache rate", () => { + expect(resolveMatchedPrice("xai", "grok-4.6")?.cost4).toEqual({ + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }); + const confirmedOutcome = outcome("priority"); + const confirmed = estimate(confirmedOutcome); + expect(confirmedOutcome).toMatchObject({ + canonical: "priority", + fastOutcome: "applied", + confirmation: "confirmed", + }); + expect(confirmed.cost.total).toBeCloseTo(0.46, 9); + expect(confirmed.cost.cacheRead).toBeCloseTo(0.02, 9); + expect(confirmed.priorityMultiplier).toBe(2); + }); + + test("an assumed priority outcome stays at the standard price", () => { + const assumedOutcome = outcome(); + const assumed = estimate(assumedOutcome); + expect(assumedOutcome).toMatchObject({ + canonical: "priority", + fastOutcome: "applied", + confirmation: "assumed", + }); + expect(assumed.cost.total).toBeCloseTo(0.23, 9); + expect(assumed.priorityMultiplier).toBeUndefined(); + }); + + test("missing provenance and a requested tier do not prove the xAI premium", () => { + for (const serviceTier of [ + "priority", + { requestedServiceTier: "priority" }, + { configuredServiceTier: "priority" }, + ] as const) { + const unconfirmed = estimateRequestCost({ + provider: "xai", + model: "grok-4.6", + usageStatus: "reported", + usage, + serviceTier, + })!; + expect(unconfirmed.cost.total).toBeCloseTo(0.23, 9); + expect(unconfirmed.priorityMultiplier).toBeUndefined(); + } + }); + + test("an echoed default records a downgrade and bills the standard price", () => { + const downgradedOutcome = outcome("default"); + const downgraded = estimate(downgradedOutcome); + expect(downgradedOutcome).toMatchObject({ + fastOutcome: "downgraded", + fastDowngradeReason: "response-declined", + confirmation: "downgraded", + responseServiceTier: "default", + }); + expect(downgradedOutcome).not.toHaveProperty("canonical"); + expect(downgraded.cost.total).toBeCloseTo(0.23, 9); + expect(downgraded.priorityMultiplier).toBeUndefined(); + }); + + test("confirmed priority at 200k uses the long-context price as a marked lower bound", () => { + const long = estimate(outcome("priority"), { + inputTokens: 200_000, + outputTokens: 10_000, + cacheReadInputTokens: 50_000, + }); + expect(long.contextTier).toBe("long"); + expect(long.priorityMultiplier).toBeUndefined(); + expect(long.priorityLowerBound).toBe(true); + expect(long.cost).toMatchObject({ + input: 0.6, + cacheRead: 0.05, + output: 0.12, + }); + expect(long.cost.total).toBeCloseTo(0.77, 9); + }); + + test("a combo is a lower bound only when every priced attempt is a lower bound", () => { + const confirmed = outcome("priority"); + const lowerBoundAttempt = { + ordinal: 1, + provider: "xai", + model: "grok-4.6", + usageStatus: "reported" as const, + usage: { inputTokens: 200_000, outputTokens: 10_000 }, + tierOutcome: confirmed, + }; + const ordinaryAttempt = { + ordinal: 2, + provider: "xai", + model: "grok-4.6", + usageStatus: "reported" as const, + usage, + }; + + expect(estimateComboCost([lowerBoundAttempt, { ...lowerBoundAttempt, ordinal: 2 }])?.priorityLowerBound).toBe(true); + expect(estimateComboCost([lowerBoundAttempt, ordinaryAttempt])?.priorityLowerBound).toBeUndefined(); + }); +}); + describe("long-context pricing tiers (#908)", () => { const SOL: ExpectedPriceOverlay[] = [ { provider: "openai", modelId: "gpt-5.6-sol", cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, diff --git a/tests/vision-backend-union.test.ts b/tests/vision-backend-union.test.ts new file mode 100644 index 0000000000..f55b46abb3 --- /dev/null +++ b/tests/vision-backend-union.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import * as storeModule from "../src/oauth/store"; +import * as usabilityModule from "../src/codex/account-usability"; +import * as modelRowsModule from "../src/server/management/model-rows"; + +let accountSets: Record; activeAccountId?: string }> = {}; +let usableCodexAccounts: Set = new Set(); +let managementRows: Array> = []; + +mock.module("../src/oauth/store", () => ({ + ...storeModule, + getAccountSet: (provider: string) => accountSets[provider] ?? null, +})); +mock.module("../src/codex/account-usability", () => ({ + ...usabilityModule, + isCodexAccountUsable: (_config: unknown, accountId: string) => usableCodexAccounts.has(accountId), +})); +mock.module("../src/server/management/model-rows", () => ({ + ...modelRowsModule, + listManagementModelRows: async () => managementRows, +})); + +import { handleManagementAPI } from "../src/server/management-api"; +import { ManagementRequest as Request } from "./helpers/management-auth"; +import { + enabledVisionBackends, + visionCandidateRows, + visionDescriberIsProvablyBlind, + visionModelOptionsFrom, +} from "../src/server/management/vision-sidecar-options"; +import { activeVisionBackends } from "../src/vision/backends"; +import { visionBackendForCandidate } from "../src/vision/eligibility"; +import { resolveSidecarAuth } from "../src/sidecar/auth"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const forward: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }; +const xaiOAuth: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "oauth" }; +const antigravityOAuth: OcxProviderConfig = { adapter: "google-antigravity", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }; +const volc: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://ark.volces.test/v1", apiKey: "k" }; + +function config(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + providers: { openai: forward, xai: xaiOAuth, "google-antigravity": antigravityOAuth, volcengine: volc }, + ...overrides, + }; +} + +afterEach(() => { + accountSets = {}; + usableCodexAccounts = new Set(); + managementRows = []; +}); + +describe("routed vision backend (#2188 roadmap 170 revised)", () => { + test("any non-forward, non-OAuth-anthropic picker row maps to routed", () => { + const cfg = config(); + expect(visionBackendForCandidate(cfg, { provider: "xai", id: "grok-4.3" })).toBe("routed"); + expect(visionBackendForCandidate(cfg, { provider: "google-antigravity", id: "gemini-3.7-flash" })).toBe("routed"); + expect(visionBackendForCandidate(cfg, { provider: "volcengine", id: "doubao-1.8-vision" })).toBe("routed"); + expect(visionBackendForCandidate(cfg, { provider: "openai", id: "gpt-5.6-luna" })).toBe("openai"); + expect(visionBackendForCandidate(cfg, { provider: "claude", id: "claude-haiku-4-5" }, "claude")).toBe("anthropic"); + }); + + test("routed is always active; universal fallback still fires without any auth side", () => { + const cfg = config(); + const active = activeVisionBackends(resolveSidecarAuth(cfg), cfg); + expect(active).toContain("openai"); + expect(active).toContain("routed"); + expect(enabledVisionBackends(cfg, undefined)).toContain("routed"); + }); + + test("options: routed rows are NAMESPACED and image-filtered (rule 2)", async () => { + const cfg = config(); + managementRows = [ + { provider: "xai", id: "grok-4.3" }, + { provider: "xai", id: "grok-4" }, + { provider: "google-antigravity", id: "gemini-3.7-flash" }, + { provider: "volcengine", id: "doubao-1.8-vision", inputModalities: ["text", "image"] }, + { provider: "volcengine", id: "doubao-text-only", inputModalities: ["text"] }, + ]; + const candidates = await visionCandidateRows(cfg); + const options = visionModelOptionsFrom(cfg, candidates, undefined); + const values = options.map(option => option.value); + expect(values).toContain("xai/grok-4.3"); + expect(values).toContain("google-antigravity/gemini-3.7-flash"); + expect(values).toContain("volcengine/doubao-1.8-vision"); + // rule 2: provably text-only rows drop — vendor table (grok-4) and row modalities. + expect(values).not.toContain("xai/grok-4"); + expect(values).not.toContain("volcengine/doubao-text-only"); + const routedRows = options.filter(option => option.backend === "routed"); + expect(routedRows.every(option => option.value.includes("/"))).toBe(true); + }); + + test("provably-blind gate: namespaced probes its provider; bare probes all families", () => { + const cfg = config(); + expect(visionDescriberIsProvablyBlind(cfg, "xai/grok-4", [], "routed")).toBe(true); + expect(visionDescriberIsProvablyBlind(cfg, "xai/grok-4.3", [], "routed")).toBe(false); + // bare text-only grok-4 still caught without any hint (blocker B). + expect(visionDescriberIsProvablyBlind(cfg, "grok-4", [], undefined)).toBe(true); + expect(visionDescriberIsProvablyBlind(cfg, "grok-4.3", [], undefined)).toBe(false); + }); +}); + +describe("management routes: routed union + coherence", () => { + async function putVision(cfg: OcxConfig, vision: Record): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI( + new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ vision }) }), + url, cfg, + ); + if (!response) throw new Error("route did not handle PUT"); + return response; + } + + test("backend routed accepted; xai/gemini/exa literals rejected 400", async () => { + const cfg = config(); + expect((await putVision(cfg, { backend: "routed" })).status).toBe(200); + expect(cfg.visionSidecar?.backend).toBe("routed"); + for (const bad of ["xai", "gemini", "exa", "zen"]) { + expect((await putVision(cfg, { backend: bad })).status).toBe(400); + } + expect(cfg.visionSidecar?.backend).toBe("routed"); + }); + + test("coherence: namespaced model requires routed; routed requires namespaced", async () => { + const cfg = config(); + expect((await putVision(cfg, { backend: "openai", model: "xai/grok-4.3" })).status).toBe(400); + expect((await putVision(cfg, { backend: "routed", model: "grok-4.3" })).status).toBe(400); + const ok = await putVision(cfg, { backend: "routed", model: "xai/grok-4.3" }); + expect(ok.status).toBe(200); + expect(cfg.visionSidecar?.model).toBe("xai/grok-4.3"); + }); + + test("routed model provably blind via its namespaced provider → 400", async () => { + const cfg = config(); + expect((await putVision(cfg, { backend: "routed", model: "xai/grok-4" })).status).toBe(400); + }); + test("GET reports a routed backend's namespaced model verbatim (live-found regression)", async () => { + const cfg = config({ visionSidecar: { backend: "routed", model: "xai/grok-4.6" } }); + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI(new Request(url, { method: "GET" }), url, cfg); + if (!response) throw new Error("route did not handle GET"); + const body = await response.json() as { vision: { model: string; backend?: string }; visionModels: Array<{ value: string; backend: string }> }; + expect(body.vision.backend).toBe("routed"); + expect(body.vision.model).toBe("xai/grok-4.6"); + // display grandfather: the persisted pair stays selectable even when no + // matching option row exists in this fixture. + expect(body.visionModels.some(option => option.value === "xai/grok-4.6" && option.backend === "routed")).toBe(true); + }); + + test("claude-code vision override admits routed with coherence", async () => { + const cfg = config(); + const url = new URL("http://localhost/api/claude-code"); + async function putOverride(body: Record): Promise { + const response = await handleManagementAPI( + new Request(url, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ visionSidecar: body }), + }), + url, cfg, + ); + if (!response) throw new Error("route did not handle PUT"); + return response; + } + expect((await putOverride({ backend: "routed", model: "volcengine/doubao-1.8-vision" })).status).toBe(200); + expect((await putOverride({ backend: "xai" })).status).toBe(400); + expect((await putOverride({ backend: "routed", model: "bare-id" })).status).toBe(400); + expect((await putOverride({ backend: "openai", model: "volcengine/doubao-1.8-vision" })).status).toBe(400); + }); +}); + diff --git a/tests/vision-eligibility.test.ts b/tests/vision-eligibility.test.ts index 5e77cda24a..6469b6cb0a 100644 --- a/tests/vision-eligibility.test.ts +++ b/tests/vision-eligibility.test.ts @@ -245,9 +245,10 @@ describe("vision eligibility core", () => { expect(matches[0]?.baseline).toBe(true); }); - test("8. backend routing excludes image-capable rows with no executor", () => { - // cursor has no vision sidecar executor — backend is undefined and the row is absent - // from the options list even when it is image-capable. + test("8. non-forward rows map to routed; absent unless routed is enabled", () => { + // cursor has no DEDICATED describe executor — the row now belongs to the + // "routed" loopback executor (#2188 roadmap 170 revised) and appears only + // when the caller enables that backend, as a NAMESPACED value. const config = configWithProviders({ cursor: { adapter: "openai-chat", @@ -259,7 +260,7 @@ describe("vision eligibility core", () => { id: "cursor-vision-capable", inputModalities: ["text", "image"], }; - expect(visionBackendForCandidate(config, candidate)).toBeUndefined(); + expect(visionBackendForCandidate(config, candidate)).toBe("routed"); expect(isVisionEligibleModel(config, candidate)).toBe(true); const options = visionEligibleModelOptions(config, [candidate], ["openai", "anthropic"]); expect(options.some((o) => o.value === candidate.id)).toBe(false); @@ -268,5 +269,8 @@ describe("vision eligibility core", () => { BASELINE_VISION_MODELS.openai, BASELINE_VISION_MODELS.anthropic, ]); + // enabling routed surfaces the row, namespaced. + const withRouted = visionEligibleModelOptions(config, [candidate], ["openai", "anthropic", "routed"]); + expect(withRouted.some((o) => o.value === "cursor/cursor-vision-capable" && o.backend === "routed")).toBe(true); }); }); diff --git a/tests/vision-routed.test.ts b/tests/vision-routed.test.ts new file mode 100644 index 0000000000..c0f680f606 --- /dev/null +++ b/tests/vision-routed.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { resetVisionDescriptionCache } from "../src/vision"; +import { + describeImageRouted, + routedDescribeAdmissionToken, + VISION_DESCRIBE_TERMINAL_HEADER, +} from "../src/vision/routed-describe"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +// Roadmap 180 (revised): the routed describer loops back through the proxy's +// own chat surface, and its terminal marker is the depth-cap-1 recursion +// fence. The fence test drives the FULL chat-surface path (audit round 3-4: +// a predicate-only test would stay green with the marker broken). + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let upstream: ReturnType | null = null; +const originalEnvToken = process.env.OPENCODEX_API_AUTH_TOKEN; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-vision-routed-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-vision-routed-")); + process.env.OPENCODEX_HOME = testDir; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + resetVisionDescriptionCache(); +}); + +afterEach(() => { + upstream?.stop(true); + upstream = null; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (originalEnvToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = originalEnvToken; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +const PNG_DATA_URL = "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM="; +const CAPTION = "A dashboard screenshot with a vision sidecar dropdown."; +const SETTINGS = { model: "vlm/qwen-vl", reasoning: "low" as const, timeoutMs: 10_000 }; + +describe("describeImageRouted unit", () => { + test("POSTs chat wire with terminal marker and returns the caption", async () => { + let seen: { url: string; marker: string | null; auth: string | null; apiKey: string | null; body: Record } | null = null; + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + seen = { + url: new URL(req.url).pathname, + marker: req.headers.get(VISION_DESCRIBE_TERMINAL_HEADER), + auth: req.headers.get("authorization"), + apiKey: req.headers.get("x-opencodex-api-key"), + body: await req.json() as Record, + }; + return Response.json({ choices: [{ message: { content: CAPTION } }] }); + }, + }); + try { + const out = await describeImageRouted( + PNG_DATA_URL, undefined, "what is this", "vlm/qwen-vl", + { port: server.port }, SETTINGS, undefined, `http://127.0.0.1:${server.port}`, + ); + expect(out.error).toBeUndefined(); + expect(out.text).toBe(CAPTION); + expect(seen!.url).toBe("/v1/chat/completions"); + expect(seen!.marker).toBe("1"); + expect(seen!.auth).toBeNull(); + expect(seen!.apiKey).toBeNull(); + expect(seen!.body.model).toBe("vlm/qwen-vl"); + expect(seen!.body.stream).toBe(false); + const messages = seen!.body.messages as Array<{ role: string; content: unknown }>; + expect(messages[0].role).toBe("system"); + const userParts = messages[1].content as Array<{ type: string }>; + expect(userParts.some(part => part.type === "image_url")).toBe(true); + } finally { + server.stop(true); + } + }); + + test("admission ladder: env token first, then first apiKeys entry, as x-opencodex-api-key", () => { + expect(routedDescribeAdmissionToken({})).toBeUndefined(); + expect(routedDescribeAdmissionToken({ + apiKeys: [{ id: "a", name: "a", key: "key-1", createdAt: "" }], + })).toBe("key-1"); + process.env.OPENCODEX_API_AUTH_TOKEN = "env-token"; + expect(routedDescribeAdmissionToken({ + apiKeys: [{ id: "a", name: "a", key: "key-1", createdAt: "" }], + })).toBe("env-token"); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + }); + + test("error taxonomy: HTTP error is redacted and never throws; invalid image rejected locally", async () => { + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch: () => new Response("upstream exploded sk-secret-123", { status: 502 }), + }); + try { + const out = await describeImageRouted( + PNG_DATA_URL, undefined, "", "vlm/qwen-vl", + { port: server.port }, SETTINGS, undefined, `http://127.0.0.1:${server.port}`, + ); + expect(out.text).toBe(""); + expect(out.error).toContain("routed describe HTTP 502"); + const bad = await describeImageRouted( + "data:application/pdf;base64,QUJD", undefined, "", "vlm/qwen-vl", + { port: server.port }, SETTINGS, undefined, `http://127.0.0.1:${server.port}`, + ); + expect(bad.error).toContain("unsupported image type"); + } finally { + server.stop(true); + } + }); +}); + +describe("chat-surface recursion fence (full path)", () => { + function textOnlyUpstream(record: (body: string) => void) { + return Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + const body = await req.text(); + record(body); + // The pipeline may re-emit upstream as a chat STREAM; serve SSE when + // asked, JSON otherwise. + if (body.includes('"stream":true')) { + const chunk = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 0, model: "text-only", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] }; + const done = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 0, model: "text-only", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; + const sse = [`data: ${JSON.stringify(chunk)}`, "", `data: ${JSON.stringify(done)}`, "", "data: [DONE]", "", ""].join("\n"); + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ + id: "chatcmpl-1", object: "chat.completion", created: 0, model: "text-only", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + }, + }); + } + + test("marked POST strips images (no describe); unmarked plans/strips per legacy path", async () => { + const forwarded: string[] = []; + upstream = textOnlyUpstream(body => forwarded.push(body)); + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "routed", + providers: { + routed: { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + allowPrivateNetwork: true, + apiKey: "k", + noVisionModels: ["text-only"], + }, + }, + } as OcxConfig; + saveConfig(config); + const server = startServer(0); + try { + const chatBody = { + model: "routed/text-only", + stream: false, + messages: [{ + role: "user", + content: [ + { type: "text", text: "look" }, + { type: "image_url", image_url: { url: PNG_DATA_URL } }, + ], + }], + }; + const marked = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json", [VISION_DESCRIBE_TERMINAL_HEADER]: "1" }, + body: JSON.stringify(chatBody), + }); + expect(marked.status).toBe(200); + expect(forwarded.length).toBe(1); + // The marked request must reach the upstream with the image STRIPPED — + // and, critically, without any inner describe loopback having fired + // (forwarded.length would be 2 if a describe re-entered). + expect(forwarded[0]).not.toContain(PNG_DATA_URL.slice(30, 60)); + + const unmarked = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(chatBody), + }); + expect(unmarked.status).toBe(200); + // No sidecar auth in this fixture: the legacy path fail-closes by + // stripping too, but WITHOUT the marker the vision planner ran (same + // upstream count increment, no recursion either way). + expect(forwarded.length).toBe(2); + } finally { + server.stop(true); + } + }); + + test("routed describer end-to-end: image described via loopback before the text-only main call", async () => { + const mainBodies: string[] = []; + const describerBodies: string[] = []; + upstream = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + const body = await req.text(); + const url = new URL(req.url); + if (url.port === String(upstream!.port)) { + // both providers share this fake upstream; disambiguate by model. + } + if (body.includes('"model":"vlm"')) { + describerBodies.push(body); + return Response.json({ + id: "chatcmpl-vlm", object: "chat.completion", created: 0, model: "vlm", + choices: [{ index: 0, message: { role: "assistant", content: CAPTION }, finish_reason: "stop" }], + }); + } + mainBodies.push(body); + if (body.includes('"stream":true')) { + const chunk = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 0, model: "text-only", choices: [{ index: 0, delta: { role: "assistant", content: "done" }, finish_reason: null }] }; + const done = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 0, model: "text-only", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; + const sse = [`data: ${JSON.stringify(chunk)}`, "", `data: ${JSON.stringify(done)}`, "", "data: [DONE]", "", ""].join("\n"); + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ + id: "chatcmpl-1", object: "chat.completion", created: 0, model: "text-only", + choices: [{ index: 0, message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + }); + }, + }); + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "routed", + visionSidecar: { backend: "routed", model: "vision/vlm" }, + providers: { + routed: { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + allowPrivateNetwork: true, + apiKey: "k", + noVisionModels: ["text-only"], + }, + vision: { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + allowPrivateNetwork: true, + apiKey: "k", + modelInputModalities: { vlm: ["text", "image"] }, + }, + }, + } as OcxConfig; + saveConfig(config); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "routed/text-only", + stream: false, + messages: [{ + role: "user", + content: [ + { type: "text", text: "what does the dashboard show" }, + { type: "image_url", image_url: { url: PNG_DATA_URL } }, + ], + }], + }), + }); + expect(res.status).toBe(200); + // The describer ran exactly once, through the loopback chat surface. + expect(describerBodies.length).toBe(1); + expect(describerBodies[0]).toContain("image_url"); + // The main call got the CAPTION text, not the raw image bytes. + expect(mainBodies.length).toBe(1); + expect(mainBodies[0]).toContain("described by a vision model"); + expect(mainBodies[0]).toContain(CAPTION.slice(0, 20)); + expect(mainBodies[0]).not.toContain("aGVsbG8taW1hZ2UtYnl0ZXM="); + } finally { + server.stop(true); + } + }); +}); diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index f3f5cce06f..f89a9af2f6 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -234,6 +234,10 @@ describe("service backend CLI parsing", () => { expect(parseServiceArgs([])).toEqual({ sub: "install", backend: null, invalid: [] }); }); + test("restart aliases the existing no-admin repair path", () => { + expect(parseServiceArgs(["restart"])).toEqual({ sub: "repair", backend: null, invalid: [] }); + }); + test("--scheduler and unknown flags are recognized separately", () => { expect(parseServiceArgs(["install", "--scheduler"]).backend).toBe("scheduler"); expect(parseServiceArgs(["install", "--bogus"]).invalid).toEqual(["--bogus"]); diff --git a/tests/xai-web-search-compat.test.ts b/tests/xai-web-search-compat.test.ts new file mode 100644 index 0000000000..f8d2afc29c --- /dev/null +++ b/tests/xai-web-search-compat.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createProductionAdapter } from "../src/adapters/openai-responses"; +import { normalizeXaiResponsesWebSearch } from "../src/adapters/xai-web-search"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +function createXaiAdapter() { + return withTestTranslatorBudget(createProductionAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "forward", + headers: { authorization: "Bearer xai-oauth" }, + })); +} + +function buildBody(rawBody: Record): Record { + const request = createXaiAdapter().buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }); + return JSON.parse(request.body) as Record; +} + +describe("xAI Responses web-search compatibility", () => { + test("lowers Codex live-search fields to xAI's documented tool schema", () => { + const body = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search", + external_web_access: true, + filters: { allowed_domains: ["x.ai"] }, + user_location: { type: "approximate", country: "KR" }, + search_context_size: "high", + search_content_types: ["text", "image"], + }], + tool_choice: { type: "web_search" }, + }); + + expect(body.tools).toEqual([{ + type: "web_search", + filters: { allowed_domains: ["x.ai"] }, + enable_image_search: true, + }]); + expect(body.tool_choice).toEqual({ type: "web_search" }); + expect(JSON.stringify(body)).not.toContain("external_web_access"); + expect(JSON.stringify(body)).not.toContain("search_context_size"); + expect(JSON.stringify(body)).not.toContain("search_content_types"); + expect(JSON.stringify(body)).not.toContain("user_location"); + }); + + test("omits cached-only search instead of silently widening it to xAI live search", () => { + const body = buildBody({ + model: "grok-4.6", + tools: [{ type: "web_search", external_web_access: false }], + input: [ + { + type: "additional_tools", + role: "developer", + tools: [{ type: "web_search", external_web_access: false }], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search" }], + }, + }); + + expect(body.tools).toBeUndefined(); + expect(body.input).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + ]); + expect(body.tool_choice).toBe("none"); + }); + + test("keeps public xAI search declarations live when the private access flag is absent", () => { + const body = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search", + filters: { excluded_domains: ["example.com"] }, + enable_image_understanding: true, + }], + }); + + expect(body.tools).toEqual([{ + type: "web_search", + filters: { excluded_domains: ["example.com"] }, + enable_image_understanding: true, + }]); + }); + + test("normalizes the supported preview alias in declarations and selectors", () => { + const direct = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search_preview", + external_web_access: true, + search_context_size: "medium", + }], + tool_choice: { type: "web_search_preview" }, + }); + + expect(direct.tools).toEqual([{ type: "web_search" }]); + expect(direct.tool_choice).toEqual({ type: "web_search" }); + + const allowed = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ type: "web_search_preview" }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search_preview" }], + }, + }); + + expect(allowed.tools).toEqual([{ type: "web_search" }]); + expect(allowed.tool_choice).toEqual({ + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search" }], + }); + }); + + test("does not rewrite OpenAI, lookalike, or nonstandard-port providers", () => { + const original = { + model: "gpt-5.6-sol", + tools: [{ type: "web_search", external_web_access: false }], + }; + for (const baseUrl of [ + "https://chatgpt.com/backend-api/codex", + "https://api.x.ai.example/v1", + "https://api.x.ai:8443/v1", + "http://api.x.ai/v1", + ]) { + expect(normalizeXaiResponsesWebSearch(original, { baseUrl })).toBe(original); + } + }); +});