diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c109a53..080c63a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -8,5 +8,6 @@ "name": "multi-core", "description": "Shared Multi gateway, setup, status and reversible Claude startup integration.", "skills": "./plugins/multi-core/skills", - "hooks": "./plugins/multi-core/hooks/hooks.json" + "hooks": "./plugins/multi-core/hooks/hooks.json", + "types": "./plugins/multi-core/types/multi-core.d.ts" } diff --git a/.gitignore b/.gitignore index d551fc3..02935d7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ tmp_rp.txt /docs/ACP_TRANSPORT_BLUEPRINT.md # --- Tooling --- -node_modules/ +node_modules # Agent scratch area: plans, specs, reverse-engineering notes. Local-only. /.agent/ diff --git a/AGENTS.md b/AGENTS.md index 767eb1e..fba5734 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,17 +17,19 @@ Claude Code function-hook UI and extensibility rules live in | --- | --- | | `plugins/multi-core/src/launcher.ts` | Launches the gateway and registers models and workers. | | `plugins/multi-core/src/gateway/server.ts` | Routes requests and manages sessions. | -| `plugins/multi-core/src/gateway/messages.ts`, `fetch.ts`, `tools.ts` | Shared protocol, outbound fetch, and tool aliases. | +| `plugins/multi-core/src/gateway/messages.ts`, `fetch.ts`, `tools.ts`, `responses.ts`, `bounded.ts`, `record.ts` | Shared protocol, outbound fetch, tool aliases, Responses translation and SSE reader, bounded maps, and the `isRecord` guard. | +| `plugins/multi-core/src/gateway/provider.ts`, `provider-request.ts` | Model-prefix provider table, `nativeSpelling`, and the contract through which OpenAI and Zen modules build their own requests. | | `plugins/multi-core/src/gateway/executable.ts`, `process-tree.ts` | Resolves executables and manages child processes. | | `plugins/multi-core/src/gateway/atomic-write.ts`, `state-lock.ts` | Protects files and serializes native state. | -| `plugins/multi-core/src/gateway/cursor-settings.ts`, `mode-hook.ts`, `agent-definitions.ts`, `worker-catalog.ts` | Admits settings, maps prompt and worker permissions, and resolves the Agent tool's `model` against each provider's worker catalog. | +| `plugins/multi-core/src/gateway/harness-settings.ts`, `mode-hook.ts`, `agent-definitions.ts`, `worker-catalog.ts` | Admits harness settings (`checkHarnessSettings`, `mergePermissions`), maps prompt and worker permissions, and resolves the Agent tool's `model` against each provider's worker catalog. | | `plugins/multi-core/src/gateway/approval.ts`, `permission-hook.ts` | Approval protocol and capability checks. | -| `plugins/multi-core/src/gateway/mod-*.ts`, `display-rows.ts`, `tool-observer.ts` | Claude Mods control-plane routes, compaction, policy, native action display rows, and tool observation. | -| `plugins/multi-core/src/gateway/harness-*.ts` | Session store, exchange registry, response builder, completion, and notices shared by Cursor, Antigravity, and Grok; CLI process runner and text prompt preparation shared by Antigravity and Grok. | +| `plugins/multi-core/src/gateway/mod-*.ts`, `display-rows.ts`, `tool-observer.ts`, `nested-env.ts` | Claude Mods control-plane routes, per-session mod key (`mod-keys.ts`), compaction, policy, native action display rows, tool observation, and the nested-`claude` environment restore. | +| `plugins/multi-core/hooks/` | Claude Mods function hooks; `gateway.ts` is the single gateway client, state lives in `$.state`, timing in `$.clock`. | +| `plugins/multi-core/src/gateway/harness-*.ts` | Session store, exchange registry, response builder, completion, notices, and failure classifier (`harness-failure.ts`) shared by Cursor, Antigravity, and Grok; the CLI harness base (`harness-cli.ts`), process runner, and text prompt preparation shared by Antigravity and Grok. | | `plugins/multi-core/src/account.ts`, `setup.ts`, `install/` | Accounts, bootstrap, plugin discovery, and installation. | -| `plugins/multi-openai/src/` | Codex authentication, models, Responses translation, instructions, and reviewer. | +| `plugins/multi-openai/src/` | Codex authentication, models, request construction (`gateway-request.ts`), instructions, and reviewer. | | `plugins/multi-cursor/src/` | Cursor SDK harness, permissions, progress, requests, models, and workspaces. | -| `plugins/multi-zen/src/` | Zen API-key authentication, catalogs, requests, and translations. | +| `plugins/multi-zen/src/` | Zen API-key authentication, catalogs, request construction (`gateway-request.ts`), and translations. | | `plugins/multi-antigravity/src/` | `agy` CLI harness, models, hooks, requests, and permissions. | | `plugins/multi-grok/src/` | Grok Build CLI harness, models, requests, permissions, and login state. | | `test/unit/` | Offline unit tests for gateway and provider behavior. | @@ -71,9 +73,9 @@ type checking, and offline tests. `npm test` runs `tsc --noEmit` and the unit test suite. Use Node 24.12 or newer and avoid `DEP0190` warnings. For Claude Mods hook or surface changes, also run `npm run test:mod`. It type-checks -the hooks against `.claude/types` (write them with `/plugin-types` first; they are -gitignored) and runs `claude plugin test` with the installed Claude executable, -without provider inference. It is separate from `npm run check` and the current CI matrix. +the hooks against `.claude-plugin/types/` (Claude Code writes them when it loads the +plugin; they are gitignored) and runs `claude plugin test` with the installed Claude +executable and the Mods rollout flag forced on, without provider inference. It is separate from `npm run check` and the current CI matrix. | Command | Check | Login needed | | --- | --- | --- | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 22d8e25..017f66b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,7 +13,13 @@ session. The launcher registers provider models and one Agent-tool worker type per provider. The Node gateway routes requests, preserves Claude passthrough, and coordinates sessions. Claude Mods provide the in-engine control plane for model rows, worker rows, -permission state, progress, and compaction. [The local Mods reference](docs/claude-mods.md) +permission state, progress, and compaction. The hooks (`plugins/multi-core/hooks/`) +reach the gateway through one client (`hooks/gateway.ts`) and keep state in +`$.state` so it survives a hot reload. The gateway mints a per-session mod key +(`mod-keys.ts`) on the mod's first request and, once the mod echoes it, refuses +`/multi/mod/*` requests carrying only the environment token, so a shell command +cannot loosen a session's permissions. Session state is dropped on `session.end`, +not when a client detaches. [The local Mods reference](docs/claude-mods.md) is required reading for changes to Claude Code UI or extensibility. Provider adapters own their model catalogs, authentication, execution, and review boundaries. @@ -32,8 +38,19 @@ Claude Code session (/model, workers, prompts) ## Request flow Claude Code sends Anthropic Messages traffic and provider requests to the gateway. -The gateway passes Anthropic traffic through and translates direct-provider -requests. `/model` exposes provider model and effort rows. Each connected +The gateway passes Anthropic traffic through untouched (every path other than +Messages and `/multi/*` is forwarded as raw bytes, and a failed upstream +connection is dropped so Claude Code's own retry runs) and translates +direct-provider requests. It honors `HTTPS_PROXY` and forwards to a caller-set +`ANTHROPIC_BASE_URL`. A `claude` started by a command inside the session gets +the session-start environment back (`nested-env.ts`, a PATH shim and the +bootstrap), so it never routes through the session gateway. Hosted-API +providers build their own requests: `gateway/provider-request.ts` defines the +contract, `multi-openai/src/gateway-request.ts` and +`multi-zen/src/gateway-request.ts` implement it, and the gateway owns keepalive, +events, and failure mapping. `gateway/provider.ts` is the one reader of a model +ID's provider prefix. The Responses translation and SSE reader live in +`gateway/responses.ts`. `/model` exposes provider model and effort rows. Each connected provider's Agent-tool worker type runs that provider's picker rows; the Agent tool's `model` parameter picks which. Each run reports a visible lifecycle: row, elapsed time, streamed progress, completion, failure, and cancellation. @@ -78,19 +95,26 @@ action becomes a display row in the harness's own reply: a tool_use block named the native tool (`mcp__multi-core__run_command`) with the native parameters and a gateway-issued token, answered by the Multi mod with the native output, so the row sits in the transcript of the `/model` session or worker that ran it. Display tools -are never model tools: the gateway strips them from every provider's tools and -history, the mod defers them and refuses any call without an issued token, and the +are never model tools: they register in the engine lazily, only when a harness +prompt, worker, or step needs them, and the gateway strips them (and their +`tool_reference` blocks) from every provider's tools and history. The mod defers +them and refuses any call without an issued token, and the gateway answers the engine's follow-up request with the reply's remaining text without a native run. Nothing is replayed or re-executed. Cursor supports Auto, Plan, and Bypass. Antigravity uses its native CLI with Claude policy enforcement at the prompt boundary. Grok carries the same policy in its own run arguments, and each announced toolset is checked against -it because an unknown removal is accepted and ignored by that CLI. +it because an unknown removal is accepted and ignored by that CLI. Antigravity +runs an allowlist built from the Claude grant: unmapped tools are denied in +every mode, scheduling, messaging, task, and knowledge-deletion tools are always +denied, and write tools cannot touch agy's own hook or config files. All three harnesses share their session store, in-flight exchange registry, -response builder, durable completion, notices, and the transcript action summary from -`plugins/multi-core/src/gateway/harness-*.ts`. Grok and Antigravity additionally -share native process execution, text prompt preparation, and the native action -tracker (Cursor uses it too) that turns native actions into display rows; Cursor retains its +response builder, durable completion, notices, failure classifier, and the +transcript action summary from `plugins/multi-core/src/gateway/harness-*.ts`. +Grok and Antigravity additionally share a CLI harness base (`harness-cli.ts`: +the request flow around one native run), native process execution, text prompt +preparation, and the native action tracker (Cursor uses it too) that turns +native actions into display rows; Cursor retains its SDK and image-aware prompt format. Each provider still owns its own event grammar, CLI argument construction, usage accounting, and (for Cursor) SDK agent lifecycle. The shared layer owns turn leases, lock lifetime, @@ -102,7 +126,9 @@ than queued against a native conversation that has moved on. ## Permissions Claude's permission mode controls each provider at prompt boundaries through the -UserPromptSubmit and SubagentStart hooks. The gateway intersects worker rules, +UserPromptSubmit and SubagentStart hooks. Worker tool calls are attributed +through the Mods `tool.call` hook, with the session's permission mode. +Harness settings admission is `gateway/harness-settings.ts`. The gateway intersects worker rules, provider capabilities, project settings, and platform policy. Unsupported modes, unknown workers, untranslatable policies, and unavailable required reviewers fail explicitly. Plan denies shell and edit capabilities. Bypass disables Cursor native @@ -118,6 +144,10 @@ rule, and its announced toolset is verified because that CLI accepts an unknown removal silently; its MCP tools stay visible to the model and are documented as such. Explicit native workspace selection is required. +Auto-mode classifier requests for Claude's own actions go to Anthropic unless a +provider-owned pending action positively matches; only a request routed to +provider review keeps the session mismatch check. + Claude's launcher enables on-demand tool discovery for the local gateway. Direct provider adapters omit deferred tool schemas until Claude discovers or uses them; tool references and loaded declarations survive later turns and provider diff --git a/CHANGELOG.md b/CHANGELOG.md index 8234871..a655d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,132 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and ## Unreleased +- **Run GPT-6.1 Sol on OpenAI.** Codex now serves `gpt-6.1-sol` on ChatGPT + plans, so the OpenAI picker row and `model: gpt-6.1-sol` on a `multi-openai` + worker run it in place of the previous-generation `gpt-6-sol`. The default + OpenAI model stays `gpt-6-astra`. +- **Deliver native worker reports in Auto mode.** Cursor, Antigravity, and Grok + workers now send their final report through Claude Code's `SubagentHandback` + tool when it is offered. Display rows keep their follow-up answer, and resumed + workers continue without forwarding handback results or enforcement prompts to + the native harness. +- **Start several `claude-multi` sessions at once.** Every launch rewrites the + Antigravity permission hook under a file lock, and a second launch that met a + live holder failed with "State file is locked by another gateway". The hook + install now waits up to 5 s for the other launch, and a lock released between + the check and the read is retried instead of reported as missing metadata. +- **Close review-round gaps.** The gateway refuses any request whose `Host` is + not its own loopback name and port, so a DNS-rebinding page cannot use the + raw Anthropic passthrough as a relay. A `multi/` model with an unknown + provider prefix is reviewed like other external models, except Zen. A `multi` + launch inside a Multi session forwards to the user's own upstream and headers. + On Windows the nested `claude` shim also exists as an extensionless sh script + for Git Bash. A `claude "a & b"` through a non-npm `.cmd` launcher names the + refused argument. With a proxy set, `NO_PROXY` gains the loopback names. A + provider failure after the 30 s keepalive is now an assistant message stating + the failure rather than an error body under a 200. +- **Leave Claude sessions alone until a harness runs.** Multi registered about + 70 hidden display tools (named after Cursor, Antigravity, and Grok tools, such + as `web_search`) at every session start, so ToolSearch could surface them to + Claude and the gateway rewrote every native request to hide them. They now + register only when a harness prompt, worker, or step needs them, + acknowledgement is per session, and display `tool_reference` blocks are + stripped from forwarded history. The Agent tool's provider-model paragraph + appears only while a provider worker is offered, and Claude's recorded Agent + input is no longer rewritten. +- **Stop rejecting Claude's own auto-mode checks in mixed sessions.** A + classifier request for a Claude action that the gateway had not observed + returned "Missing or ambiguous pending review action" once the session had run + a provider. It now goes to Anthropic unless a provider-owned action positively + matches; a request routed to provider review keeps the session mismatch check. + A classifier request the gateway cannot read still fails closed while a + provider action in that session awaits review. +- **Fix provider workers in Claude sessions.** A custom agent pinned to a + `multi/openai` or `multi/zen` model, or a worker inheriting a provider model, + was treated as a native Claude subagent, skipped gateway registration, and had + every tool call denied in auto mode. Worker tool calls are now attributed + through the `tool.call` hook (the classic `PreToolUse` event carries no agent + id), with the session's permission mode. +- **Forget session state when the session ends, not when a client detaches.** + Multi treated `session.detach`, which fires when any client such as the phone + or desktop app leaves, as the end of the session and dropped its policy, + worker models, and held follow-up text mid-run. Cleanup now runs on + `session.end`. +- **Use the engine's state and clock in the Mod.** Hook state lives in + `$.state`, so it survives a hot reload; timeouts, the status poll, and the + policy wait use `$.clock` instead of timers the hooks environment does not + provide; one gateway client replaces eight request helpers; the policy wait is + one long-poll request instead of a sleep loop inside the hook's budget. The + usage pane shows a toast when the terminal cannot place it. `npm run test:mod` + forces the Mods rollout flag that `claude plugin test` follows, and Mods are + no longer described as opt-in. +- **Cut gateway round trips on Claude turns.** Unchanged Claude prompt + snapshots, Claude step telemetry, and offers of built-in agents no longer call + the gateway. +- **Keep slow non-streamed provider replies alive.** A non-streamed harness + request sent nothing until the run finished, so Claude Code could time out and + re-run native actions. After 30 s the gateway sends headers and JSON whitespace + keepalives. +- **Honor proxies, custom base URLs, and every Anthropic path.** The gateway + respects `HTTPS_PROXY` (the bootstrap starts Node with + `NODE_USE_ENV_PROXY=1`), forwards to a caller-set `ANTHROPIC_BASE_URL` + instead of refusing to launch, forwards every path other than `/multi/*` and + Messages raw, keeps bodyless requests bodyless, and passes the client's + compression preference upstream. +- **Keep nested `claude` runs native.** A `claude` started by a Bash command or + script inherited the gateway URL and token and broke when the parent exited. A + PATH shim and the bootstrap now restore the session-start environment for + nested plain runs. +- **Bind the Mod control plane to the Mod.** The gateway mints a per-session key + on the Mod's first request and, once the Mod echoes it, refuses requests that + carry only the environment token, so a shell command cannot loosen a session's + permissions. +- **Launch more robustly.** A failing `claude plugin list` no longer aborts + launch, provider discovery runs in parallel, and a Claude that exits early ends + the launch with its own exit code instead of a 30-second wait and a misleading + version error. Agent view is disabled through one setting instead of a setting + and an environment variable. +- **Scope compaction to the main loop.** Arming a main-session compaction turned + every worker resolved meanwhile into a toolless compaction turn, and a failed + compaction stayed armed. Workers are now unaffected and failure or cancel + clears it. Mod session and permission tables evict idle entries instead of + asking for a gateway restart, and unused policy jobs expire. +- **Enforce Antigravity tool grants in every mode.** Outside plan mode only + mapped tools were denied, so a Read-only worker could still drive the browser, + schedule tasks, or send messages. agy now runs an allowlist built from the + Claude grant; scheduling, messaging, inbox, task, resource, image, and + knowledge-deletion tools are always denied; write tools cannot touch agy's own + hook or config files; and `multi uninstall` removes the global agy hook. +- **Fail closed on Grok tools and stops.** Grok's toolset check now refuses any + announced tool that is neither granted nor MCP-namespaced, and a cancelled, + refused, or truncated turn is an error instead of a cached success. Grok and + Antigravity keep their interruption notice after a failed run so a retry does + not silently repeat side effects, and a missing CLI is a 400 on both. +- **Recover provider state across restarts and long sessions.** Cursor follow-up + rows survive a gateway restart, idle Antigravity and Grok session records are + evicted, reply archives are pruned, unsupported-version session records are + kept aside instead of overwritten, and native action summaries report actions + past the 512-row cap. Long Grok and Antigravity runs are no longer killed at + 8 MiB of total output. +- **Keep provider bookkeeping out of the model's context.** Grok and Antigravity + no longer append timing and billing lines to model-visible text; receipts and + the status line already show them. Zen Chat emits its content blocks in order + and joins text blocks with newlines, and OpenAI replies that end on a stop + sequence record their billed usage. +- **Harden Windows process handling and state files.** Arguments containing + cmd.exe metacharacters are refused for non-shim `.cmd`/`.bat` launchers, and + npm's shim for a native binary, such as an npm-installed Claude Code's + `claude.exe`, now runs that binary directly instead of through cmd.exe, + cancellation asks the process tree to close before forcing it and never kills + a reused PID, policy probes run `reg`, `powershell`, and `defaults` from system + paths with a timeout, state locks survive hostname changes and recheck racing + takeovers, and atomic writes are fsynced. +- **Windows `.cmd` launchers no longer receive unsafe prompts.** Grok and + Antigravity deliver prompts containing `"`, `%`, `&` and similar through a + file or stdin when the CLI is a non-shim `.cmd`/`.bat`, and both use the + shared failure classifier for request-versus-retryable status. The Codex + app-server flag no longer embeds quotes. + - **Stop the gateway from altering native Claude requests.** Claude requests now reach Anthropic as Claude Code sent them, and Anthropic's answer or failure reaches Claude Code as it would natively. A tool call cut off by `max_tokens` diff --git a/docs/antigravity.md b/docs/antigravity.md index 6f54bf9..ef7a5ae 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -68,8 +68,12 @@ Claude's window governs when the session compacts. It is separate from the nativ Claude's permission mode and tool rules take precedence. `agy` runs with native permissions skipped. The namespaced global hook reads `MULTI_ANTIGRAVITY_DENY` -and denies Claude-excluded native tools. Native children and MCP tools are always -denied. Each finished tool step is a row under agy's own tool name (`view_file`, +and `MULTI_ANTIGRAVITY_ALLOW`. Every mode is an allowlist: only the native tools Claude's +rules grant, plus the `finish` and `wait` control tools, run, so a tool `agy` announces +that no Claude tool maps to (for example `schedule`, `send_message`, `generate_image`) +stays denied. Browser tools follow the `WebFetch` grant. Native children, MCP-like +resource tools, scheduling and messaging are always denied, and write tools can never +target `agy`'s own config tree (`~/.gemini`), where the hook lives. Each finished tool step is a row under agy's own tool name (`view_file`, `run_command`, ...) with its native parameters and `tool_info.output`, in the `/model` Antigravity session or inside the Antigravity worker's transcript; the names come from agy's `init` toolset. Rows are never replayed as executable Claude @@ -77,7 +81,8 @@ tools. `agy` does not distinguish a failed tool step: a failing command such as `cat /nonexistent/file` ends `DONE` with the error only in its output and no exit code, so the row shows as done and its output carries the error; Multi does not guess failure from the text. The transcript keeps streamed text, run diagnostics, -and one closing summary of action counts, changed files, and failed steps. Claude and OpenAI parents can spawn Antigravity +and one closing summary of action counts, changed files, and failed steps; timing and +billing are shown by receipts, not written into the text. Claude and OpenAI parents can spawn Antigravity workers. Every run selects its native workspace explicitly with `--add-dir`; subprocess @@ -137,7 +142,9 @@ refuse the same way. | Windows | `%LOCALAPPDATA%\gemini\config\hooks.json` (or `%APPDATA%`) | `%LOCALAPPDATA%\gemini\antigravity-cli\settings.json` (or `%APPDATA%`) | The installed hook is named `multi-cli-antigravity`. Setup preserves other hook -entries. Run setup again after moving the checkout or changing the Node install. +entries. Every launch with Antigravity enabled rewrites the entry, so it follows a plugin +update; run setup again after moving the checkout or changing the Node install. +`multi uninstall` removes the entry. ## Limits diff --git a/docs/claude-mods.md b/docs/claude-mods.md index 692c99f..e7a7b6a 100644 --- a/docs/claude-mods.md +++ b/docs/claude-mods.md @@ -11,8 +11,10 @@ The authoritative starting point is Anthropic's [Mods proposal and September 9 update](https://github.com/anthropics/claude-code/issues/91870). Anthropic calls the product feature **Claude Mods** and the implementation primitive **function hooks**: a Mod is a plugin whose behavior is implemented with function hooks. -The update says the interface was still early access and rapidly iterating, but -that core semantics had settled enough to publish built-in source. +Mods are generally available and on by default as of Claude Code 2.1.287; no +environment variable turns them on for a person. The engine's own declarations +(`.claude-plugin/types/claude-code/index.d.ts`, written beside a mod it loads from a +folder) are the API authority for this repository. The issue's [Function Hooks: Core Architecture PDF](https://github.com/user-attachments/files/31802150/EXTERNAL.Function.Hooks.Core.Architecture.pdf) @@ -39,8 +41,8 @@ PDF](https://github.com/user-attachments/files/31802150/EXTERNAL.Function.Hooks. Anthropic's [built-in Mods source listings](https://github.com/anthropics/claude-code/tree/main/mods) are the best executable reference. The published README describes a hooks module as one -`register(on, options)` entry, typing against declarations written by -`/plugin-types`, and tests run with `claude plugin test`. As of the source tree +`register(on, options)` entry, typed against the declarations the engine writes, with +tests run by `claude plugin test`. As of the source tree inspected September 22, 2026 (latest `mods/` commit `7974a707` dated September 20), it contains `diff`, `sec-default`, `telemetry`, and `agents-md`: @@ -56,14 +58,34 @@ inspected September 22, 2026 (latest `mods/` commit `7974a707` dated September demonstrates instruction loading through engine hooks rather than prompt or filesystem side channels. -The issue's September 9 cheat-sheet image covers v267/v268-era affordances and -documents the opt-in command -`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude`. Treat it as dated version evidence, -not a permanent API promise. Multi's registration code records that Claude Code -2.1.272 loads exactly one `hooks.json` module, so all Multi hook registrars are -composed by `hooks/register.ts`. Regenerate current local declarations with -`/plugin-types` and test the repository's supported Claude Code build when an -event or result shape changes. +The issue's September 9 cheat-sheet image covers v267/v268-era affordances; treat +it as dated version evidence, not a permanent API promise. Multi's registration code +records that Claude Code 2.1.272 loads exactly one `hooks.json` module, so all Multi +hook registrars are composed by `hooks/register.ts`. The engine rewrites +`.claude-plugin/types/` each time it loads the mod from this checkout (and +`/plugin-types` writes the same declarations to a directory you name); test the +repository's supported Claude Code build when an event or result shape changes. + +### Static rules the engine enforces + +`claude plugin validate .claude-plugin/plugin.json` reads the hooks source the way the +engine will and refuses a module that breaks these rules, which shape the code: + +- `$` is passed only into functions declared in the same file, never across an import. + Each hooks file therefore builds its own small `wire($)` (four closures: the two + `$.env.get` names, `$.http.fetch`, `$.clock.sleep`) and hands that to the shared + gateway client in `gateway.ts`; cross-file helpers (`syncDisplayTools`, the policy + client) take such closures, never `$`. +- `$.env.get` takes a literal name, and `$.noun.event(...)` is spelled at every call + site; `$.env` and the like are never passed as values. +- A module hooks each event once without a matcher, so one `tool.call` hook (in + `register.ts`) answers display rows and attributes every other call. +- State values are named where they are read: each file declares its own + `atom({ plugin: 'multi-core', key: ... } as const, initial)` for the keys it uses; the + value types come from `types/multi-core.d.ts`. +- A hooks module has no timers. Every wait is `$.clock` (`sleep`, `after`, `every`), + which is also the only wait that counts against a hook's 10 s own-time budget; time + inside any other `$` call (a gateway fetch) does not count. ## Community field report @@ -77,7 +99,8 @@ function-hook UI and reports these primitives and constraints: `Client` surface module. - `turn.complete` and `tool.call` let a Mod react to Claude lifecycle and tool activity without spending model tokens. -- `$.store` persists Mod-owned state. +- `$.store` persists Mod-owned state between sessions; `$.state` holds session state + that survives a hot reload and redraws any drawing that read it (Multi uses `$.state`). - A `Client` module path must be a string literal so the engine can discover it. - A JSX surface module must not bind a local variable named `h`, because JSX is compiled into calls to that identifier. @@ -87,22 +110,24 @@ function-hook UI and reports these primitives and constraints: The linked project's [README, especially **How it works**, **Limits**, and **Develop**](https://github.com/sezaakgun/cc-arcade#develop), is the practical -upstream reference behind those findings. Function hooks are early access, so -validate behavior against the Claude Code version supported by this repository +upstream reference behind those findings. It was written before general +availability, so validate behavior against the Claude Code version supported by this repository instead of treating the example as a stable public specification. ## API map used by Multi | Concern | Function-hook API used here | Multi behavior | | --- | --- | --- | -| Startup and registration | `session.start`, `$.command.register`, `$.tool.register` | Registers `/multi-usage`, the initial authenticated Mod session, and the display tools for native harness actions (see below). Multi registers no model-callable tools. | -| Model and permission boundary | `classic.SessionStart`, `classic.UserPromptSubmit`, `$.session.model/cwd/id` | Records the effective Claude permission snapshot at prompt boundaries; it does not redesign provider permissions. | -| Workers | `tool.call` (Agent), `tool.describe` (Agent), `agent.offer`, `agent.spawn`, `classic.SubagentStart`, `ui.render` (ToolUse, UserMessage) | Takes the Agent tool's `model` out before Claude's Agent schema check, resolves it against the provider worker catalog at spawn (labelling the task's description, which the running-agents list and its notification show), admits harness children against the current policy generation, and labels the Agent row with the resolved provider and model. The engine's compact line for parallel Agent calls reads the stored call, whose provider model its schema rejects, and draws those calls as a bare `Agent`; ctrl+o shows the labelled rows. | -| Native action rows | `$.tool.register`, `turn.step`, `tool.describe`, `tool.check`, `tool.call`, `ui.render` for `ToolUse` and `ToolResult` | Registers a display tool per native tool name the gateway offers (again before a harness step when a harness announced new ones), defers them behind ToolSearch, allows only calls carrying a gateway-issued token, answers those with the native output from `POST /multi/mod/display`, and draws each row as Claude Code draws the built-in the native tool mirrors (Read, Bash, Grep, Glob, LS, Edit, Write), under the native tool's name. `ToolGroup` folding stays the engine's. | -| Usage UI | `command.run`, `$.ui.open`, `$.ui.invalidate`, `ui.render` for `Pane`, `ui.message` | Opens the interactive usage pane, renders its literal `Client` module, and handles bounded refresh/receipt/toggle messages. | -| Lifecycle | `turn.step`, `turn.complete`, `session.detach`, `$.ui.status` | Updates telemetry, the status line and receipts, completes/cancels runs, and forgets session-scoped state. | +| Startup and registration | `session.start`, `$.command.register`, `$.state`, `$.tool.register` | Registers `/multi-usage` and greets the gateway with the session (which also tells the launcher the Mod is live). The first prompt after a `/clear`, which fires no `session.start`, greets again. Multi registers no model-callable tools, and no display tool at start (see below). | +| Model and permission boundary | `classic.SessionStart`, `classic.UserPromptSubmit`, `$.session.model/cwd/id`, `$.state` | Records the effective Claude permission snapshot at prompt boundaries; it does not redesign provider permissions. A harness prompt always admits its settings policy; any other model's snapshot is posted only when the mode, workspace or model changed, and always kept in `$.state` so a harness worker spawned later admits against it. | +| Workers | `tool.call` (Agent), `tool.describe` (Agent), `agent.offer`, `agent.spawn`, `classic.SubagentStart`, `$.ui.invalidate('tool.describe')`, `ui.render` (ToolUse, UserMessage) | Takes the Agent tool's `model` out before Claude's Agent schema check, resolves it against the provider worker catalog at spawn (labelling the task's description, which the running-agents list and its notification show), admits harness children against the current policy generation, and labels the Agent row with the resolved provider and model, read from `$.state` so a resolved spawn redraws it. The Agent description names provider models only while a provider worker type is offered. An agent type skips gateway registration only when it is natively Claude: a built-in on a Claude session is classified locally, any other type by the gateway's offer (its model must not be a Multi model, so a definition pinned to `multi/openai/...` keeps registration), and `classic.SubagentStart` keeps the record while any loop of the session runs a Multi model. | +| Native action rows | `$.tool.register`, `turn.step`, `tool.describe`, `tool.check`, `tool.call`, `ui.render` for `ToolUse` and `ToolResult` | Registers a display tool per native tool name the gateway offers, lazily: at a harness prompt, before a harness worker spawns, and before a harness step when the catalog changed. A session that never runs a harness lists none. It defers them behind ToolSearch, allows only calls carrying a gateway-issued token, answers those with the native output from `POST /multi/mod/display`, and draws each row as Claude Code draws the built-in the native tool mirrors (Read, Bash, Grep, Glob, LS, Edit, Write), under the native tool's name. `ToolGroup` folding stays the engine's. | +| Usage UI | `command.run`, `$.ui.open` (`isPlaced`), `$.ui.toast`, `$.state`, `ui.render` for `Pane`, `ui.message` | Opens the interactive usage pane, renders its literal `Client` module from `$.state` (a write redraws it; nothing invalidates), and handles bounded refresh/receipt/toggle messages. A pane that waits undrawn is announced with a toast and its numbers are returned as text. | +| Lifecycle | `turn.step`, `turn.complete`, `session.end`, `$.clock.every`, `$.ui.status` | Records each loop's model, sends telemetry for a Multi step only, polls a harness run's status on a `$.clock` timer cancelled at `turn.complete` and `session.end`, completes/cancels runs, and forgets session-scoped state when the session ends (exit, `/clear` and resume alike). `session.detach` fires when any client leaves the roster, a session still running, so it is not hooked. | +| Reviewer attribution | `tool.call` | A provider loop's tool call (its `agentId` names the worker) is attributed to the gateway's reviewer with the session's snapshot mode; a Claude loop's call costs the gateway nothing. `classic.PreToolUse` carries neither the agent id nor a mode. | | Compaction | `session.compact` | Keeps native-harness compaction provider-owned and generation-bound. | -| Local control plane | `$.http.fetch`, `$.env.get` | Calls authenticated loopback `/multi/mod/*` routes with bounded bodies and timeouts. | +| Local control plane | `$.http.fetch`, `$.env.get`, `$.clock.sleep` | `gateway.ts` calls authenticated loopback `/multi/mod/*` routes with an explicit method, a body bounded in bytes, and a `$.clock` deadline (1.5 s, 8.5 s for usage). Policy readiness is one request the gateway holds until discovery ends (bounded at 8 s), so no hook loops. | +| Hook state | `$.state` (`atom`, `read`, `update`), `types/multi-core.d.ts` | The policy snapshot, loop and spawn models, offer classification, display tools and usage panes live in `$.state`, declared by the `types` file `.claude-plugin/plugin.json` names, so a hot reload keeps them and a drawing that reads one is redrawn when it changes. Only per-call memos (the Agent model between `tool.call` and `agent.spawn`, a row's input between draws) and timers stay in module memory. | The `classic.*` events are compatibility bridges for Claude Code's classic hook payloads. New UI and extensibility still belongs in Mods; classic events do not @@ -114,7 +139,8 @@ The implementation is deliberately split: | Layer | Responsibility | | --- | --- | -| `plugins/multi-core/hooks/*.ts` | Register function hooks, react to Claude events, open/invalidate surfaces, and exchange UI messages. | +| `plugins/multi-core/hooks/*.ts` | Register function hooks, react to Claude events, open surfaces, and exchange UI messages. `gateway.ts` is the one gateway client, `state.ts` the bounded-record helpers, `policy.ts` the prompt-boundary admission. | +| `plugins/multi-core/types/multi-core.d.ts` | The `$.state` values the hooks keep (`PluginState`), named by `.claude-plugin/plugin.json` `types`. | | `plugins/multi-core/hooks/*-view.ts` | Render `Client` surfaces (the usage pane); module paths at call sites stay literal. | | `plugins/multi-core/hooks/rows.ts` | Register, gate, answer and draw the display rows of native harness actions. | | `plugins/multi-core/hooks/rows-view.ts` | Draw a display row as the Claude Code built-in it mirrors. | @@ -158,10 +184,15 @@ harness reports it: Cursor's SDK tool-call type (`shell`, `read`, `edit`, `grep` `mcp__multi-core__`; that is engine naming, not an MCP server. The gateway offers the static sets of the harnesses it runs (`GET /multi/mod/display-tools`), adds any name a harness announces at run time, and bounds them (160 names, -`[A-Za-z0-9_-]`, 47 characters). The mod registers them at `session.start` and -again before a harness `turn.step` when the catalog changed, then acknowledges the -registered set (`POST /multi/mod/display-tools`); the gateway emits rows only for -acknowledged names, so a session without the mod gets the closing summary alone. +`[A-Za-z0-9_-]`, 47 characters). The mod registers them lazily, so a +pure Claude session lists none (ToolSearch could otherwise surface them): at a harness +prompt, before a harness worker spawns, and before a harness `turn.step` when the +catalog changed (a registration takes effect from the next prompt, so the prompt hook +is what lets the first harness run's rows anchor). It then acknowledges the registered +set per session (`POST /multi/mod/display-tools`); the gateway emits rows only for names +the requesting session acknowledged, so a session without the mod gets the closing +summary alone. A session's end clears what it registered, and the next session +registers and acknowledges again. Each harness worker definition lists `mcp__multi-core` in its tools, because the engine dispatches a subagent's tool_use only for a tool its definition admits; the Cursor, Antigravity, and Grok permission mappers drop that entry, so it grants no @@ -233,7 +264,8 @@ would be folded, and is counted in no parent's line. **No fake tools for any model.** The gateway removes every display tool from each forwarded `tools` list, every display tool_use and its tool_result from each forwarded history (closing up the turns), and the display names from Claude Code's -ToolSearch catalogue and worker tool lists, for every provider, Anthropic included. +ToolSearch catalogue, ToolSearch `tool_reference` results and worker tool lists, for +every provider, Anthropic included. `tool.describe` returns `isDeferred: true`, so no prompt lists them. `tool.check` allows a call only when `POST /multi/mod/display` confirms its token for that session and tool_use id, and `tool.call` refuses the rest, so a model-originated call @@ -248,7 +280,7 @@ whose last turn holds only the rows' results, with it, without a native run. The harness's record of its reply includes that text, so the next turn's history still matches. The held text is bound to the session, worker and provider whose reply wrote the rows, and only that scope's request naming that reply's rows receives it; -any other is refused, and a session's held texts are dropped when it detaches. +any other is refused, and a session's held texts are dropped when it ends. After a restart or an eviction the gateway reads it back from the harness's session record (`recordedResponse`); when no record holds it, the request fails explicitly. An action whose completion never arrives is settled when the run ends as @@ -271,6 +303,36 @@ permission snapshots, model and worker selection, progress/lifecycle, compaction, session cleanup, and quota advice through the same authenticated Mods control plane. +The gateway token is in the environment of every Bash command, so a token alone does +not admit a session's `/multi/mod/*` POSTs. The gateway mints a per-session key on the +first POST it sees for a `sessionId` and returns it in an `x-multi-mod-key` response +header. `gateway.ts` captures it from any reply, keeps it by session id in the host-held +`$.state` value `multi-core.modKeys` (so a hot reload keeps it; a key held only in module +memory would lock the Mod out, since the gateway never re-issues one after the first echo), +and sends it as `x-multi-mod-key` on every later request that names that session. After the +first echo the gateway refuses token-only POSTs for that session with 403. The key is +never put in the environment, a log, or a Client surface payload; other plugins could read +the state value, but they already run in the engine with the same environment. `session.end` +sends `/multi/mod/detach` with the key, then forgets it. + +The gateway bounds what the Mod can make it hold: a session's snapshot and policy +tables evict the least recently used idle session instead of refusing a new one, an +unconsumed policy job is reused for a minute at most, and a policy request may carry +`wait: true` to be held until discovery ends. + +## Testing the hooks + +`npm run test:mod` type-checks the hooks and their tests against the engine's own +declarations (`.claude-plugin/types/`, written when the mod loads from this checkout; +`/plugin-types` writes the same files elsewhere) and runs `claude plugin test` with the +installed Claude executable, without provider inference. Mods are on by default in +Claude Code 2.1.287, but `claude plugin test` follows the server rollout flag, which is +served off for some accounts; the script therefore sets +`CLAUDE_INTERNAL_FC_OVERRIDES={"tengu_plugin_hooks_modules":true}` for that process +only (no `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` is involved). A test that reaches the +gateway client installs `mock.clock(on)`, because each request is bounded by +`$.clock`; a test of a timer advances that clock. + ## Rules for changes Any Claude Code harness UI or extensibility change must use function-hook Mods, @@ -290,6 +352,7 @@ When adding a surface: as a literal string. 3. Avoid a local `h` binding in JSX surface modules. 4. Preserve `next(event)` behavior for events the Mod does not own. -5. Bound retained state, payload size, refresh rate, and gateway latency. +5. Bound retained state (keep it in `$.state`, not module memory), payload size, + refresh rate, and gateway latency (wait on `$.clock`, never a timer). 6. Add hook tests and gateway route tests for session isolation, stale state, authentication, and failure fallback. diff --git a/docs/installation.md b/docs/installation.md index 6550b77..a79952a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,7 +5,12 @@ - Node 24.12 or newer from a persistent installation. Setup records its executable path. - Claude Code 2.1.272 or newer with function hooks. `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` would block the local gateway, so the launcher replaces it for its session with - `DISABLE_AUTOUPDATER`, `DISABLE_TELEMETRY`, `DISABLE_ERROR_REPORTING` and `DISABLE_BUG_COMMAND`. + `DISABLE_AUTOUPDATER`, `DISABLE_TELEMETRY`, `DISABLE_ERROR_REPORTING`, `DISABLE_BUG_COMMAND` and + `DISABLE_FEEDBACK_COMMAND`. +- A proxy (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`) and a caller-set `ANTHROPIC_BASE_URL` are + honored for Claude's own traffic: the gateway forwards it through the proxy to that base URL. + Node reads the proxy variables at startup only, so the installed command starts the launcher + with `NODE_USE_ENV_PROXY=1`; a launcher started by hand needs it set. - OpenAI: the official Codex CLI (`codex`) and a ChatGPT login. - Cursor: the official Cursor SDK login. No separate Cursor CLI is required. - OpenCode Zen: a Zen API key in OpenCode's auth store or `OPENCODE_API_KEY`. diff --git a/docs/openai.md b/docs/openai.md index 3d16b77..50d6591 100644 --- a/docs/openai.md +++ b/docs/openai.md @@ -18,7 +18,7 @@ Use `/model multi/openai/` or choose a row in `/model`. Use `/effort < | Picker row | Model | | --- | --- | | OpenAI · `gpt-6-astra` | `gpt-6-astra` | -| OpenAI · `gpt-6-sol` | `gpt-6-sol` | +| OpenAI · `gpt-6.1-sol` | `gpt-6.1-sol` | | OpenAI · `gpt-5.6-terra` | `gpt-5.6-terra` | | OpenAI · `gpt-6-luna` | `gpt-6-luna` | diff --git a/docs/platform-support.md b/docs/platform-support.md index 8c39cd7..26ed54e 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -99,10 +99,18 @@ registered with the interactive logon type. 1. Install Node 24.12+, Claude Code, PowerShell or cmd, and the Codex, `agy`, or Grok Build CLIs as needed. `npm ci` installs the Cursor SDK; Zen needs an API key, not the OpenCode executable. Sign in to the providers you will test. 2. In PowerShell or cmd, run the same commands listed for macOS. Use `npm.cmd` when the shell requires it. +A native CLI that resolves to a `.cmd` or `.bat` file that is not a standard npm +shim is started through `cmd.exe`, which expands `%VAR%` and has no safe escape +for quotes. Such a launcher is refused any argument containing `"`, `%`, `!`, +`^`, `&`, `|`, `<`, `>` or a line break; providers send prompt text on stdin +instead (`promptArgvSafe`). Cancelling a Windows run first asks the process tree +to close (`taskkill /T`) and forces it (`/F`) only after the grace period, and +nothing is killed by PID after the child has exited. + Managed Claude policy comes from `/etc/claude-code` on Linux and WSL, `/Library/Application Support/ClaudeCode` and macOS preferences on macOS, and `C:\Program Files\ClaudeCode` and the Windows policy keys on Windows. Windows -policy keys are read with `reg query`; when `reg.exe` reports a localized +policy keys are read with `reg query` (run from `System32` with a 10 s timeout, as are `powershell.exe` and `/usr/bin/defaults`); when `reg.exe` reports a localized failure, Windows PowerShell classifies the key as absent or failed. macOS preferences are read with `defaults read`; a missing domain is recognized from both wordings `defaults` uses for it, `does not exist` and the macOS 27 diff --git a/docs/usage.md b/docs/usage.md index 424990d..7cee39e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -42,7 +42,7 @@ The advisory uses the same cached account lookups as the menu. Unavailable quota is labeled unknown, and lookup failures do not stop the agent. Worker receipts are not included in the advisory. Disabling the toggle stops future advisories; earlier context already received by the agent is not removed. The preference is -session-only and is cleared when the session detaches. +session-only and is cleared when the session ends. Runtime totals cover requests observed by this gateway process; restarting the launcher starts a new in-memory view. Claude subscription passthrough and reviewer diff --git a/package.json b/package.json index b600b62..ede0c9e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "tsc --noEmit", "test": "npm run typecheck && node --test --test-timeout=120000 \"test/unit/**/*.test.ts\"", "test:e2e": "node --test --test-timeout=120000 \"test/e2e/**/*.test.ts\"", - "test:mod": "npm run typecheck:mod && node --input-type=module -e \"import { spawn } from 'node:child_process'; const child = spawn('claude', ['plugin', 'test', 'plugins/multi-core'], { stdio: 'inherit', env: { ...process.env, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: '1' } }); child.on('exit', (code) => { process.exitCode = code ?? 1; });\"", + "test:mod": "npm run typecheck:mod && node --input-type=module -e \"import { spawn } from 'node:child_process'; const child = spawn('claude', ['plugin', 'test', 'plugins/multi-core'], { stdio: 'inherit', env: { ...process.env, CLAUDE_INTERNAL_FC_OVERRIDES: JSON.stringify({ tengu_plugin_hooks_modules: true }) } }); child.on('exit', (code) => { process.exitCode = code ?? 1; });\"", "test:live:compaction": "node test/live/native-compaction.ts", "test:live:zen": "node test/live/native-zen.ts", "test:live:cursor": "node test/live/native-cursor-harness.ts", diff --git a/plugins/multi-antigravity/src/cli.ts b/plugins/multi-antigravity/src/cli.ts index 15fe4df..05ce50a 100644 --- a/plugins/multi-antigravity/src/cli.ts +++ b/plugins/multi-antigravity/src/cli.ts @@ -3,9 +3,10 @@ import { NativeCliError, nativeEnvironment, promptArgumentLimitBytes, + promptArgvSafe, runNativeCli, } from '../../multi-core/src/gateway/harness-process.ts'; -import { isRecord } from '../../multi-core/src/gateway/harness-session.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; type AntigravityStatus = | 'SUCCESS' @@ -194,8 +195,15 @@ export async function runAntigravity( function spawnAntigravity(options: AntigravityRunOptions): Promise { const platform = options.platform ?? process.platform; - const promptOnStdin = Buffer.byteLength(options.prompt) >= promptArgumentLimitBytes(platform); const environment = antigravityEnvironment(options.env); + const promptOnStdin = + Buffer.byteLength(options.prompt) >= promptArgumentLimitBytes(platform) || + !promptArgvSafe(options.prompt, { + executable: 'agy', + configuredPath: options.executable, + platform, + env: environment, + }); return runNativeCli({ name: 'Antigravity', executable: 'agy', diff --git a/plugins/multi-antigravity/src/harness.ts b/plugins/multi-antigravity/src/harness.ts index 28d4be8..093f8a4 100644 --- a/plugins/multi-antigravity/src/harness.ts +++ b/plugins/multi-antigravity/src/harness.ts @@ -1,16 +1,13 @@ -import { createHash } from 'node:crypto'; -import { realpath } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { - archiveHarnessReply, - commitHarnessResponse, -} from '../../multi-core/src/gateway/harness-completion.ts'; -import { - ExchangeRegistry, - type HarnessExchange, - replayPersisted, -} from '../../multi-core/src/gateway/harness-exchange.ts'; + CliHarness, + type CliTurn, + digest, + harnessFailure, +} from '../../multi-core/src/gateway/harness-cli.ts'; +import { commitHarnessResponse } from '../../multi-core/src/gateway/harness-completion.ts'; +import type { HarnessExchange } from '../../multi-core/src/gateway/harness-exchange.ts'; import { continuation, historyRewound, @@ -20,21 +17,15 @@ import { terminalSuffix, writeNotices, } from '../../multi-core/src/gateway/harness-notices.ts'; -import { - NativeActionTracker, - type NativeProgressObserver, -} from '../../multi-core/src/gateway/harness-progress.ts'; +import { NativeActionTracker } from '../../multi-core/src/gateway/harness-progress.ts'; import { HarnessModelCalls, HarnessResponse, type HarnessUsageFields, } from '../../multi-core/src/gateway/harness-response.ts'; -import { - HarnessBusyError, - type HarnessSession, - type HarnessSessionBase, - HarnessSessionStore, - isRecord, +import type { + HarnessSession, + HarnessSessionBase, } from '../../multi-core/src/gateway/harness-session.ts'; import type { Emit, @@ -42,7 +33,9 @@ import type { MessagesResponse, } from '../../multi-core/src/gateway/messages.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; -import { abortGraceMs, settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; +import { nativeSpelling } from '../../multi-core/src/gateway/provider.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; +import { settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import type { AntigravityResult, AntigravityRunOptions, @@ -53,7 +46,7 @@ import type { import { runAntigravity } from './cli.ts'; import { antigravitySettingsFile } from './hooks.ts'; import type { AntigravityModel } from './models.ts'; -import { nativeSpelling, selectAntigravityModel } from './models.ts'; +import { selectAntigravityModel } from './models.ts'; import { type AntigravityPolicy, antigravityCompactionDenyList } from './permissions.ts'; import { observeAntigravityCall, @@ -80,19 +73,6 @@ type Saved = HarnessSessionBase & { }; type Session = HarnessSession; -/** Everything one native turn is addressed by, fixed before the exchange starts. */ -type Turn = { - body: MessagesRequest; - context: PermissionContext; - model: AntigravityModel; - cwd: string; - identity: string; - key: string; - observe?: NativeProgressObserver; -}; - -const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); - function modelEffort(model: AntigravityModel): AntigravityRunOptions['effort'] { if (model.effort) { return model.effort; @@ -125,16 +105,10 @@ function noticeForRun(policy: AntigravityPolicy, context: PermissionContext) { : 'Compaction summary; native tools disabled.'; } -export class AntigravityHarness { +export class AntigravityHarness extends CliHarness { private readonly models: readonly AntigravityModel[]; - private readonly defaultCwd: string; - private readonly stateDirectory: string; private readonly run: AntigravityRunner; private readonly checkPermissions: CheckAntigravityPermissions; - private readonly platform: NodeJS.Platform; - private readonly store: HarnessSessionStore; - private readonly exchanges = new ExchangeRegistry({ provider: TAG, createMeta: () => ({}) }); - private closed = false; private unindexedSteps = 0; constructor( @@ -156,19 +130,13 @@ export class AntigravityHarness { platform?: NodeJS.Platform; } = {}, ) { - this.models = models; - this.defaultCwd = cwd; - this.stateDirectory = stateDirectory; - this.platform = platform; - this.run = run; - this.checkPermissions = checkPermissions ?? missingPermissions; - this.store = new HarnessSessionStore({ - provider: PROVIDER, + super({ tag: TAG, + storeProvider: PROVIDER, stateDirectory, + defaultCwd: cwd, platform, version: SESSION_VERSION, - runtime: () => ({}), fresh: (identity) => ({ version: SESSION_VERSION, provider: PROVIDER, @@ -177,9 +145,12 @@ export class AntigravityHarness { }), validate: (saved) => validUsage(saved.usage), }); + this.models = models; + this.run = run; + this.checkPermissions = checkPermissions ?? missingPermissions; } - private selection(body: MessagesRequest) { + protected selection(body: MessagesRequest) { return selectAntigravityModel(this.models, body.model, body.output_config?.effort); } @@ -188,99 +159,25 @@ export class AntigravityHarness { return prepareAntigravityRequest(body, model.id).inputTokens; } - /** The scope's last reply from its session record, located as `handle` locates it. */ - async recordedResponse(scope: string, context?: PermissionContext) { - const cwd = await realpath(context?.cwd ?? this.defaultCwd); - return this.store.recordedResponse(`${cwd}\0${scope}`); - } - - async handle( - body: MessagesRequest, - scope: string, - signal: AbortSignal, - emit?: Emit, - context?: PermissionContext, - observe?: NativeProgressObserver, - ): Promise { - if (this.closed) { - throw new Error('Antigravity harness is closed'); - } - signal.throwIfAborted(); - if (!context) { - throw new Error('Antigravity requires an explicit Claude permission context'); - } - const model = this.selection(body); - this.validate(body); - const cwd = await realpath(context.cwd ?? this.defaultCwd); - const identity = `${cwd}\0${scope}`; - const key = digest([ - PROVIDER, - identity, - { - ...body, - // Both spellings of a model name the same native request. Keying on the tagged one - // would let the plain spelling miss a completed exchange and dispatch it a second - // time, which is exactly what happens across a MULTI_DISABLE_1M_CONTEXT change. - model: nativeSpelling(body.model), - stream: undefined, - messages: antigravityHistoryHash(body.messages), - system: undefined, - }, - { - permissionMode: context.permissionMode, - tools: context.tools, - disallowedTools: context.disallowedTools, - nativePermissionError: context.nativePermissionError, - compaction: context.compaction, - }, - ]); - const turn: Turn = { body, context, model, cwd, identity, key, observe }; - let exchange = this.exchanges.get(key); - if (!exchange) { - if (this.exchanges.size >= 256) { - throw new Error('Too many concurrent Antigravity requests'); - } - exchange = this.exchanges.start(key, (started, forward) => - this.cachedExecute(turn, started, forward), - ); - } - return this.exchanges.observe(exchange, signal, emit); + protected requestKey(body: MessagesRequest) { + return { + ...body, + // Both spellings of a model name the same native request. Keying on the tagged one + // would let the plain spelling miss a completed exchange and dispatch it a second + // time, which is exactly what happens across a MULTI_DISABLE_1M_CONTEXT change. + model: nativeSpelling(body.model), + stream: undefined, + messages: antigravityHistoryHash(body.messages), + system: undefined, + }; } - private async cachedExecute(turn: Turn, exchange: HarnessExchange, emit: Emit) { - let lease: Awaited['acquireLease']>>; - try { - // A first-load race is a deterministic conflict, so it must leave here as - // this provider's own failure: a bare busy error reads as a retryable 502. - lease = await this.store.acquireLease(turn.identity); - } catch (error) { - throw new AntigravityProviderError(error); - } - const session = lease.session; - try { - const replayed = await replayPersisted({ - stateDirectory: this.stateDirectory, - key: turn.key, - saved: session.saved, - emit, - provider: TAG, - }); - if (replayed) { - return replayed; - } - await archiveHarnessReply({ - session, - stateDirectory: this.stateDirectory, - platform: this.platform, - }); - return await this.execute(turn, session, exchange, emit); - } finally { - await lease.release(); - } + protected providerError(error: unknown) { + return new AntigravityProviderError(error); } - private async execute( - turn: Turn, + protected async execute( + turn: CliTurn, session: Session, exchange: HarnessExchange, emit: Emit, @@ -307,12 +204,13 @@ export class AntigravityHarness { } private async runTurn( - turn: Turn, + turn: CliTurn, acquired: { session: Session; messages: MessagesRequest['messages']; rewound: boolean }, exchange: HarnessExchange, emit: Emit, ): Promise { - const { body, context, model, cwd, key } = turn; + const { body, context, cwd, key } = turn; + const model = turn.selection; const { session, messages, rewound } = acquired; const signal = exchange.controller.signal; let initConversationId: string | undefined; @@ -348,7 +246,6 @@ export class AntigravityHarness { signal.throwIfAborted(); let streamed = ''; let initSave: Promise | undefined; - const startedAt = performance.now(); // Each finished tool step becomes a display row in this reply, named after // agy's own tool; the terse summary is written when the run commits. const actions = new NativeActionTracker(TAG, turn.observe, (block) => @@ -393,10 +290,13 @@ export class AntigravityHarness { ); await initSave; const result = outcome.result; - appendDiagnostics(response, result, outcome.stderr, performance.now() - startedAt); + appendDiagnostics(response, result, outcome.stderr); if (result.status !== 'SUCCESS') { session.saved.conversationId = result.conversation_id; - session.saved.interrupted = false; + // A failed run may still have executed steps. Keep the interruption flag so a retry + // tells the native conversation what may already have happened. + session.saved.interrupted = + initConversationId !== undefined || streamed.length > 0 || calls.count > 0; await this.store.save(session); throw new AntigravityProviderError(result.error ?? `Antigravity run ${result.status}`); } @@ -487,30 +387,9 @@ export class AntigravityHarness { } observeAntigravityStep(update, actions, () => `tool-${++this.unindexedSteps}`); } - - async close() { - this.closed = true; - const running: Promise[] = []; - for (const exchange of this.exchanges.all()) { - if (!exchange.settled) { - exchange.controller.abort(new Error('Antigravity gateway closed')); - running.push(exchange.result); - } - } - await bounded(Promise.allSettled(running)); - await this.store.closeAll(); - } } -function appendDiagnostics( - response: HarnessResponse, - result: AntigravityResult, - stderr: string, - elapsedMs: number, -) { - if (Number.isFinite(elapsedMs)) { - response.text(`[${TAG}] completed in ${(Math.max(0, elapsedMs) / 1000).toFixed(1)}s\n`); - } +function appendDiagnostics(response: HarnessResponse, result: AntigravityResult, stderr: string) { const denied = result.denied_actions; if (Array.isArray(denied) && denied.length) { const detail = denied @@ -577,20 +456,6 @@ function validUsage(usage: AntigravityUsage | undefined) { ); } -async function bounded(operation: Promise) { - let timer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - operation, - new Promise((resolve) => { - timer = setTimeout(resolve, abortGraceMs); - }), - ]); - } finally { - clearTimeout(timer); - } -} - const missingPermissions: CheckAntigravityPermissions = async () => { throw new Error('Antigravity native permission policy is not configured'); }; @@ -598,15 +463,11 @@ const missingPermissions: CheckAntigravityPermissions = async () => { export class AntigravityProviderError extends Error { readonly failure: { status: number; message: string }; constructor(error: unknown) { - const detail = error && typeof error === 'object' && 'message' in error ? error.message : error; - const message = String(detail ?? 'unknown Antigravity failure') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 500); - super(message, { cause: error }); + // Deterministic refusals are request errors: a retryable status turned one + // into ten paid attempts. + const failure = harnessFailure(error, 'unknown Antigravity failure'); + super(failure.message, { cause: error }); this.name = 'AntigravityProviderError'; - // A busy agent is a deterministic conflict, not a transient fault: a - // retryable status turned one such refusal into ten paid attempts. - this.failure = { status: error instanceof HarnessBusyError ? 400 : 502, message }; + this.failure = failure; } } diff --git a/plugins/multi-antigravity/src/hooks.ts b/plugins/multi-antigravity/src/hooks.ts index 26606af..3b0632e 100644 --- a/plugins/multi-antigravity/src/hooks.ts +++ b/plugins/multi-antigravity/src/hooks.ts @@ -6,6 +6,8 @@ import { hookCommand } from '../../multi-core/src/gateway/permission-hook.ts'; import { lockStateFile } from '../../multi-core/src/gateway/state-lock.ts'; const namespace = 'multi-cli-antigravity'; +/** Long enough for a concurrent launch's read and atomic rewrite of the hook file. */ +const hookLockWaitMs = 5000; export interface AntigravityPathOptions { platform?: NodeJS.Platform; @@ -85,7 +87,8 @@ export async function installAntigravityHook( options: AntigravityPathOptions = {}, ): Promise { await mkdir(path.dirname(file), { recursive: true }); - const unlock = await lockStateFile(`${file}.multi-lock`); + // Every launch reinstalls the hook, so a concurrent launch holds this briefly. + const unlock = await lockStateFile(`${file}.multi-lock`, { waitMs: hookLockWaitMs }); try { const hooks = await readHooks(file); const platform = options.platform ?? process.platform; diff --git a/plugins/multi-antigravity/src/models.ts b/plugins/multi-antigravity/src/models.ts index 3528bdf..ef49e97 100644 --- a/plugins/multi-antigravity/src/models.ts +++ b/plugins/multi-antigravity/src/models.ts @@ -4,6 +4,7 @@ import { executableInvocation, resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; +import { nativeSpelling } from '../../multi-core/src/gateway/provider.ts'; import { antigravityEnvironment } from './cli.ts'; export interface AntigravityModel { @@ -101,14 +102,24 @@ export function antigravityDefaultWorkerModel(ids: readonly string[]): string | if (!match) { continue; } - const rank = [Number(match[1]), Number(match[2] ?? 0), id.includes('-pro') ? 1 : 0]; - if (!best || compareRank(rank, best.rank) > 0) { + // Pro before Flash, Flash before Flash-Lite; equal ranks fall to the greater id so the + // pick never depends on the order `agy models` lists. + const rank = [Number(match[1]), Number(match[2] ?? 0), modelTier(id)]; + const order = best ? compareRank(rank, best.rank) : 1; + if (!best || order > 0 || (order === 0 && id > best.id)) { best = { id, rank }; } } return best?.id; } +function modelTier(id: string): number { + if (id.includes('-pro')) { + return 2; + } + return id.includes('-lite') ? 0 : 1; +} + function compareRank(left: readonly number[], right: readonly number[]): number { for (let index = 0; index < left.length; index++) { const difference = (left[index] ?? 0) - (right[index] ?? 0); @@ -129,11 +140,6 @@ function defaultVariant(models: readonly AntigravityModel[], base: string) { throw new Error('Antigravity base route has no advertised native variant.'); } -/** Claude's 1M-context picker tag; it is display metadata and never a native model ID. */ -export function nativeSpelling(model: string | undefined): string | undefined { - return model?.replace(/\[1m\]$/i, ''); -} - /** Resolve advertised variants; let agy validate effort for models without suffixes. */ export function selectAntigravityModel( models: readonly AntigravityModel[], diff --git a/plugins/multi-antigravity/src/permissions.ts b/plugins/multi-antigravity/src/permissions.ts index 7d3d3ec..ae1bb63 100644 --- a/plugins/multi-antigravity/src/permissions.ts +++ b/plugins/multi-antigravity/src/permissions.ts @@ -1,7 +1,24 @@ +import os from 'node:os'; +import path from 'node:path'; import { nativeToolRules } from '../../multi-core/src/gateway/display-rows.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; /** Native names reachable from each mapped Claude tool. */ +/** The agy config roots that hold hooks and settings; see antigravityProtectedRoots. */ +export function antigravityProtectedRoots( + options: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; homedir?: string } = {}, +): string[] { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.homedir ?? os.homedir(); + const join = platform === 'win32' ? path.win32.join : path.posix.join; + const roots = [join(home, '.gemini')]; + if (platform === 'win32') { + roots.push(join(env.LOCALAPPDATA ?? env.APPDATA ?? join(home, 'AppData', 'Local'), 'gemini')); + } + return roots; +} + const TOOL_MAP = { Read: ['view_file', 'list_dir'], Grep: ['grep_search'], @@ -9,7 +26,31 @@ const TOOL_MAP = { Bash: ['run_command', 'command_status', 'send_command_input'], Write: ['write_to_file'], Edit: ['replace_file_content', 'multi_replace_file_content', 'sed_file'], - WebFetch: ['read_url_content'], + WebFetch: [ + 'read_url_content', + 'open_browser_url', + 'read_browser_page', + 'list_browser_pages', + 'execute_browser_javascript', + 'capture_browser_console_logs', + 'capture_browser_screenshot', + 'click_browser_pixel', + 'browser_click_element', + 'browser_drag_pixel_to_pixel', + 'browser_get_dom', + 'browser_get_network_request', + 'browser_input', + 'browser_list_network_requests', + 'browser_mouse_down', + 'browser_mouse_up', + 'browser_move_mouse', + 'browser_press_key', + 'browser_refresh_page', + 'browser_resize_window', + 'browser_scroll', + 'browser_scroll_dom', + 'browser_select_option', + ], WebSearch: ['search_web'], NotebookEdit: ['notebook_edit'], } as const; @@ -25,10 +66,21 @@ const PLAN_READ_ONLY = [ ...TOOL_MAP.Read, ...TOOL_MAP.Grep, ...TOOL_MAP.Glob, - ...TOOL_MAP.WebFetch, + // Plan reads pages as text; driving a browser can submit forms and run scripts. + 'read_url_content', ...TOOL_MAP.WebSearch, ]; +/** Control tools agy needs to finish a turn; they act on nothing outside the conversation. */ +const CONTROL_TOOLS = ['finish', 'wait', 'wait_5_seconds']; + +/** Native tools whose parameters name files they write. */ +const WRITING_TOOLS = new Set([ + ...TOOL_MAP.Write, + ...TOOL_MAP.Edit, + ...TOOL_MAP.NotebookEdit, +]); + /** Native child-agent and MCP tools; agy runs with permissions skipped, so these are always denied. */ const ALWAYS_DENIED = [ 'invoke_subagent', @@ -37,6 +89,16 @@ const ALWAYS_DENIED = [ 'browser_subagent', 'call_mcp_tool', 'notebook_execution', + // Messaging, scheduling, task and resource tools are child-agent or MCP-like surfaces. + 'schedule', + 'manage_task', + 'send_message', + 'manage_inbox', + 'list_resources', + 'read_resource', + // No Claude tool maps to these side effects. + 'generate_image', + 'delete_knowledge', ]; export interface AntigravityPolicy { @@ -72,9 +134,11 @@ export function antigravityPermissionPolicy(context: PermissionContext): Antigra } } } + // Always an allowlist: a tool agy announces later, or one no Claude tool maps to, stays denied. + const mapped = plan ? PLAN_READ_ONLY : Object.values(TOOL_MAP).flat(); return { denied: [...denied], - ...(plan ? { allowed: PLAN_READ_ONLY.filter((tool) => !denied.has(tool)) } : {}), + allowed: [...mapped.filter((tool) => !denied.has(tool)), ...CONTROL_TOOLS], plan, notice: policyNotice(context.permissionMode), }; @@ -125,11 +189,51 @@ function toolList(serialized: string): string[] { return tools; } +function stringValues(value: unknown, depth = 0): string[] { + if (typeof value === 'string') { + return [value]; + } + if (depth > 4 || !value || typeof value !== 'object') { + return []; + } + return Object.values(value).flatMap((item) => stringValues(item, depth + 1)); +} + +function inside(parent: string, child: string, platform: NodeJS.Platform): boolean { + const lib = platform === 'win32' ? path.win32 : path.posix; + const fold = (value: string) => (platform === 'win32' ? value.toLowerCase() : value); + const relative = lib.relative(fold(parent), fold(child)); + return relative === '' || (!relative.startsWith('..') && !lib.isAbsolute(relative)); +} + +/** + * The permission hook lives in agy's user-writable config tree and is read once per turn, so a + * granted write tool must never target that tree. Shell commands are not path-checked here; + * Bash is a separate grant. + */ +function targetsProtectedPath( + call: { name: string; parameters?: unknown }, + options: { protectedRoots: string[]; platform?: NodeJS.Platform; cwd?: string }, +): boolean { + if (!WRITING_TOOLS.has(call.name)) { + return false; + } + const platform = options.platform ?? process.platform; + const lib = platform === 'win32' ? path.win32 : path.posix; + const cwd = options.cwd ?? process.cwd(); + return stringValues(call.parameters).some((value) => { + const expanded = value.startsWith('~') ? `${os.homedir()}${value.slice(1)}` : value; + const resolved = lib.resolve(cwd, expanded); + return options.protectedRoots.some((root) => inside(lib.resolve(root), resolved, platform)); + }); +} + /** No policy means an ordinary native CLI session, outside this gateway. */ export function antigravityToolDecision( input: unknown, serializedPolicy?: string, serializedAllowed?: string, + options: { protectedRoots?: string[] } = {}, ) { if (serializedPolicy === undefined) { return undefined; @@ -144,7 +248,15 @@ export function antigravityToolDecision( if (!call || typeof call !== 'object' || !('name' in call) || typeof call.name !== 'string') { throw new Error('Invalid native tool call'); } - if (denied.includes(call.name) || (allowed && !allowed.includes(call.name))) { + const parameters = 'parameters' in call ? call.parameters : undefined; + if ( + denied.includes(call.name) || + (allowed && !allowed.includes(call.name)) || + targetsProtectedPath( + { name: call.name, parameters }, + { protectedRoots: options.protectedRoots ?? antigravityProtectedRoots() }, + ) + ) { return { decision: 'deny', reason: 'Claude session policy excludes this native Antigravity tool.', diff --git a/plugins/multi-antigravity/src/progress.ts b/plugins/multi-antigravity/src/progress.ts index 1710854..157b952 100644 --- a/plugins/multi-antigravity/src/progress.ts +++ b/plugins/multi-antigravity/src/progress.ts @@ -3,6 +3,7 @@ import type { NativeActionTracker, } from '../../multi-core/src/gateway/harness-progress.ts'; import type { HarnessModelCalls } from '../../multi-core/src/gateway/harness-response.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { type AntigravityStreamEvent, parseAntigravityUsage } from './cli.ts'; type StepUpdate = Extract['step_update']; @@ -82,10 +83,6 @@ const targetKey = /path|file|command|query|pattern|url/i; const doneState = /done|complete|success|finish/i; const failedState = /error|fail|cancel|denied|reject|block/i; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function kindOf(tool: string): NativeActionKind { return kinds.find(([pattern]) => pattern.test(tool))?.[1] ?? 'other'; } diff --git a/plugins/multi-antigravity/src/quota.ts b/plugins/multi-antigravity/src/quota.ts index 0de4195..aec9de9 100644 --- a/plugins/multi-antigravity/src/quota.ts +++ b/plugins/multi-antigravity/src/quota.ts @@ -4,6 +4,7 @@ import { resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; import { terminateProcessTree } from '../../multi-core/src/gateway/process-tree.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; interface AntigravityQuotaBucket { id: string; @@ -222,7 +223,3 @@ function runCommand( }); }); } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/plugins/multi-core/hooks/compact.ts b/plugins/multi-core/hooks/compact.ts index d23ede3..ab74298 100644 --- a/plugins/multi-core/hooks/compact.ts +++ b/plugins/multi-core/hooks/compact.ts @@ -1,35 +1,42 @@ import type { EngineInterface, Register } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import { accepted, getJson, isActive, postJson, type Wire } from './gateway.ts'; import { isHarnessModel } from './provider.ts'; -const maxBody = 32000; +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); -type GatewayResponse = { - accepted?: boolean; - error?: string; - generation?: number; - precomputeId?: string; - allow?: boolean; - messages?: readonly import('claude-code').SessionMessage[]; -}; +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, +}); -export const register = ( - on: Parameters[0], - _options: Parameters[1], - agentModels: ReadonlyMap = new Map(), -) => { +export const register = (on: Parameters[0], _options: Parameters[1]) => { on('session.compact', async ($, event, next) => { // session.model() describes only the main loop. A Claude child must never // inherit its external parent's compaction policy (or the reverse). - const model = await compactionModel($, event.agentId, agentModels); + const model = await compactionModel($, event.agentId); if (!isHarnessModel(model)) { return next(event); } - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } const sessionId = await $.session.id(); - const mode = await request($, {}, `/multi/mod/mode?sessionId=${encodeURIComponent(sessionId)}`); - if (mode?.generation === undefined) { + const mode = accepted(await getJson(wire($), '/multi/mod/mode', { sessionId })); + if (typeof mode?.generation !== 'number') { return { skip: 'Multi compaction policy generation is unavailable.' }; } const payload = { @@ -44,7 +51,7 @@ export const register = ( await precompute($, payload); return { skip: 'Multi summary preparation runs outside the hook budget.' }; } - const result = await request($, payload, '/multi/mod/compact/authorize'); + const result = accepted(await postJson(wire($), '/multi/mod/compact/authorize', payload)); if (result?.messages) { return { messages: result.messages }; } @@ -52,14 +59,12 @@ export const register = ( return next(event); } // Oversized transcripts still require a separate, small tool-free authorization. - const fallback = await request( - $, - { + const fallback = accepted( + await postJson(wire($), '/multi/mod/compact/authorize', { sessionId, agentId: event.agentId, generation: mode.generation, - }, - '/multi/mod/compact/authorize', + }), ); if (!fallback?.allow) { return { skip: 'Multi tool-free compaction authorization was not acknowledged.' }; @@ -69,67 +74,22 @@ export const register = ( }; async function precompute($: EngineInterface, payload: Record) { - const prepared = await request($, payload, '/multi/mod/compact/precompute'); + const prepared = accepted(await postJson(wire($), '/multi/mod/compact/precompute', payload)); if (prepared?.accepted && prepared.precomputeId) { - void request( - $, - { - sessionId: payload.sessionId, - agentId: payload.agentId, - generation: payload.generation, - precomputeId: prepared.precomputeId, - }, - '/multi/mod/compact/run', - ); - } -} - -async function active($: EngineInterface): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - return Boolean(base && token); -} - -async function request( - $: EngineInterface, - payload: Record, - route = '/multi/mod/session', -) { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - const body = JSON.stringify(payload); - if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const response = $.http.fetch(`${base}${route}`, { - method: route.includes('?') ? 'GET' : 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(route.includes('?') ? {} : { body }), - }); - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500); + // The gateway starts the summary and answers at once, so the hook can await it. + await postJson(wire($), '/multi/mod/compact/run', { + sessionId: payload.sessionId, + agentId: payload.agentId, + generation: payload.generation, + precomputeId: prepared.precomputeId, }); - const result = await Promise.race([response, timeout]); - return result.ok ? (JSON.parse(result.text) as GatewayResponse) : undefined; - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } } } /** The main model is never evidence of a child's provider. */ -export async function compactionModel( +async function compactionModel( $: EngineInterface, agentId: string | undefined, - agentModels: ReadonlyMap, ): Promise { - return agentId ? agentModels.get(agentId) : $.session.model(); + return agentId ? (await read($, agentModels))[agentId] : $.session.model(); } diff --git a/plugins/multi-core/hooks/gateway.ts b/plugins/multi-core/hooks/gateway.ts new file mode 100644 index 0000000..9f73f55 --- /dev/null +++ b/plugins/multi-core/hooks/gateway.ts @@ -0,0 +1,212 @@ +import type { HttpInit, HttpResponse, SessionMessage } from 'claude-code'; + +/** + * The one client for the authenticated loopback `/multi/mod/*` control plane. + * + * Every hook reaches the gateway through `getJson` or `postJson`: the method is + * explicit, a body is bounded in bytes, and the wait is bounded by `$.clock` (a hooks + * module has no timers). `undefined` means the gateway is dormant, unreachable, slow or + * answered something that is not JSON; a refused reply (any non-2xx status) comes back + * with `refused` set and the gateway's reason, never with its body, so a caller that + * checks only `accepted` still fails closed. + */ +const maxBody = 32000; +const defaultTimeoutMs = 1500; + +export type GatewayResponse = { + refused?: true; + httpStatus?: number; + error?: string; + accepted?: boolean; + generation?: number | string; + status?: string; + stale?: boolean; + isOffered?: boolean; + execution?: 'claude' | 'harness'; + known?: boolean; + model?: string; + precomputeId?: string; + allow?: boolean; + messages?: readonly SessionMessage[]; + [field: string]: unknown; +}; + +export type GatewayOptions = { + /** How long to wait; 0 waits on the gateway's own bound (a held long poll). */ + timeoutMs?: number; + /** Ends the wait early, as `next.signal` ends a hook's with its dispatch. */ + signal?: AbortSignal; +}; + +/** + * What the client needs of the engine, as four calls and the session keys. The engine follows `$` only into + * functions of the file that hooks, never across an import, and wants each `$.env.get` + * name and `$.noun.event(...)` spelled at its call site, so each hooks file builds this + * from its own `$` and hands it here: + * + * const wire = ($: EngineInterface): Wire => ({ + * url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + * token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + * fetch: (url, init) => $.http.fetch(url, init), + * sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + * keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, + * }); + */ +export type Wire = { + /** + * The per-session keys the gateway issued, held in `$.state` so a hot reload keeps them. The + * gateway refuses a session's token-only POSTs once its key has been echoed, so a lost key + * would lock the mod out of that session. + */ + keys: ModKeys; + url: () => Promise; + token: () => Promise; + fetch: (url: string, init: HttpInit) => Promise; + sleep: (ms: number, signal: AbortSignal) => Promise; +}; + +export type ModKeys = { + read: () => Promise>>; + save: (change: (held: Record) => Record) => Promise; +}; + +/** Sent on every request that names a session once the gateway has issued that session's key. */ +const keyHeader = 'x-multi-mod-key'; +const maxKeys = 256; + +/** Forgets the key of an ended session. */ +export async function forgetKey(wire: Wire, sessionId: string): Promise { + await wire.keys.save((held) => { + const { [sessionId]: _ended, ...others } = held; + return others; + }); +} + +async function rememberKey(wire: Wire, sessionId: string, key: string): Promise { + await wire.keys.save((held) => { + const { [sessionId]: _replaced, ...others } = held; + return Object.fromEntries([...Object.entries(others).slice(-(maxKeys - 1)), [sessionId, key]]); + }); +} + +type Environment = { base: string; token: string }; + +async function environment(wire: Wire): Promise { + const base = await wire.url(); + const token = await wire.token(); + return base && token ? { base, token } : undefined; +} + +/** Whether this session was launched with the gateway's Mod control plane. */ +export async function isActive(wire: Wire): Promise { + return (await environment(wire)) !== undefined; +} + +export function getJson( + wire: Wire, + route: string, + query: Record = {}, + options: GatewayOptions = {}, +): Promise { + const search = new URLSearchParams(query).toString(); + return send( + wire, + 'GET', + search ? `${route}?${search}` : route, + undefined, + options, + query.sessionId, + ); +} + +export function postJson( + wire: Wire, + route: string, + payload: object, + options: GatewayOptions = {}, +): Promise { + const { sessionId } = payload as { sessionId?: unknown }; + return send( + wire, + 'POST', + route, + JSON.stringify(payload), + options, + typeof sessionId === 'string' ? sessionId : undefined, + ); +} + +/** The reply only when the gateway accepted the request. */ +export function accepted(reply: T | undefined): T | undefined { + return reply && !reply.refused ? reply : undefined; +} + +async function send( + wire: Wire, + method: 'GET' | 'POST', + route: string, + body: string | undefined, + options: GatewayOptions, + sessionId: string | undefined, +): Promise { + const env = await environment(wire); + if (!env || (body !== undefined && new TextEncoder().encode(body).length > maxBody)) { + return undefined; + } + const waiting = new AbortController(); + const cancel = () => waiting.abort(); + options.signal?.addEventListener('abort', cancel, { once: true }); + try { + const key = sessionId ? (await wire.keys.read())[sessionId] : undefined; + const fetched = wire.fetch(`${env.base}${route}`, { + method, + headers: { + 'content-type': 'application/json', + 'x-multi-gateway-token': env.token, + ...(key ? { [keyHeader]: key } : {}), + }, + ...(body === undefined ? {} : { body }), + }); + // A late failure of a fetch that lost the race to its deadline is not reported. + fetched.catch(() => undefined); + const timeoutMs = options.timeoutMs ?? defaultTimeoutMs; + const result = + timeoutMs > 0 + ? await Promise.race([fetched, deadline(wire, timeoutMs, waiting.signal)]) + : await fetched; + const issued = result.headers?.[keyHeader]; + if (sessionId && issued && issued !== key) { + await rememberKey(wire, sessionId, issued); + } + return result.ok ? (JSON.parse(result.text) as T) : (refusal(result) as T); + } catch { + return undefined; + } finally { + options.signal?.removeEventListener('abort', cancel); + waiting.abort(); + } +} + +async function deadline(wire: Wire, ms: number, signal: AbortSignal): Promise { + await wire.sleep(ms, signal); + throw new Error('gateway request timeout'); +} + +function refusal(result: { text: string; status: number }): GatewayResponse { + let reason: string | undefined; + try { + const parsed: unknown = JSON.parse(result.text); + if (parsed && typeof parsed === 'object') { + const value = (parsed as { error?: unknown }).error; + reason = typeof value === 'string' && value ? value : undefined; + } + } catch { + // A non-JSON body still names the status below. + } + const detail = result.text.trim().slice(0, 200); + return { + refused: true, + httpStatus: result.status, + error: reason ?? (detail ? `gateway ${result.status}: ${detail}` : `gateway ${result.status}`), + }; +} diff --git a/plugins/multi-core/hooks/lifecycle.ts b/plugins/multi-core/hooks/lifecycle.ts index 445ded6..832144c 100644 --- a/plugins/multi-core/hooks/lifecycle.ts +++ b/plugins/multi-core/hooks/lifecycle.ts @@ -1,8 +1,64 @@ -import type { EngineInterface, Register, TurnStepInputChunk } from 'claude-code'; -import { isHarnessModel } from './provider.ts'; +import type { EngineInterface, Register, Timer } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { + MultiCoreDisplayTools, + MultiCorePolicy, + MultiCoreUsagePane, +} from '../types/multi-core.d.ts'; +import { forgetKey, getJson, isActive, postJson, type Wire } from './gateway.ts'; +import { isHarnessModel, isMultiModel } from './provider.ts'; import { type RowsClient, syncDisplayTools } from './rows.ts'; -import { forgetUsageSession } from './usage.ts'; -import { appendLabel, providerName, workerLabel } from './worker-rows.ts'; +import { withBounded } from './state.ts'; + +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy); +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); +const spawnModels = atom( + { plugin: 'multi-core', key: 'spawnModels' } as const, + {} as Record, +); +const claudeTypes = atom({ plugin: 'multi-core', key: 'claudeTypes' } as const, [] as string[]); +const offeredProviders = atom( + { plugin: 'multi-core', key: 'offeredProviders' } as const, + [] as string[], +); +const displayTools = atom( + { plugin: 'multi-core', key: 'displayTools' } as const, + { registered: [] } as MultiCoreDisplayTools, +); +const usagePanes = atom( + { plugin: 'multi-core', key: 'usagePanes' } as const, + {} as Record, +); +const advisorySessions = atom( + { plugin: 'multi-core', key: 'advisorySessions' } as const, + [] as string[], +); + +const rowsClient = ($: EngineInterface): RowsClient => ({ + wire: wire($), + sessionId: () => $.session.id(), + held: () => read($, displayTools), + save: (change) => update($, displayTools, change), + register: (tool) => $.tool.register(tool), +}); + +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, +}); type Status = { model?: string; @@ -11,272 +67,155 @@ type Status = { error?: string; elapsedMs?: number; startedAt?: number; + [field: string]: unknown; }; -function rememberBounded(entries: Map, key: string, value: T, limit = 256) { - entries.delete(key); - const oldest = entries.keys().next(); - if (entries.size >= limit && !oldest.done) { - entries.delete(oldest.value); +/** A harness loop's status poll: a timer on `$.clock`, so it ends with its environment. */ +type Poll = { timer: Timer; since: number; failures: number; isBusy: boolean }; + +const pollMs = 500; +const maximumPolls = 128; +const maximumFailures = 5; +/** The polls of this module instance; a hot reload cancels the timers with it. */ +const polls = new Map(); + +function stopPoll(key: string): boolean { + const poll = polls.get(key); + poll?.timer.cancel(); + return polls.delete(key); +} + +async function startPoll($: EngineInterface, key: string, agentId: string | undefined) { + if (polls.has(key) || polls.size >= maximumPolls) { + return; } - entries.set(key, value); + const poll: Poll = { + since: await $.clock.now(), + failures: 0, + isBusy: false, + timer: $.clock.every(pollMs, () => { + void tick($, key, agentId); + }), + }; + polls.set(key, poll); } -async function resolveWorkerModel( - $: EngineInterface, - subagentType: string, - model: string | undefined, - parentModel: string, - agentId?: string, -): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; +/** One status read; a tick still in flight when the next is due is skipped. */ +async function tick($: EngineInterface, key: string, agentId: string | undefined) { + const poll = polls.get(key); + if (!poll || poll.isBusy) { + return; } - let timer: ReturnType | undefined; + poll.isBusy = true; try { - const payload = { + const status = await getJson(wire($), '/multi/mod/lifecycle', { sessionId: await $.session.id(), - parentAgentId: agentId, - subagentType, - cwd: await $.session.cwd(), - model, - parentModel, - }; - const response = await Promise.race([ - $.http.fetch(`${base}/multi/mod/worker-model`, { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - body: JSON.stringify(payload), - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('gateway timeout')), 1500); - }), - ]); - if (!response.ok) { - return undefined; + agentId: agentId ?? 'main', + }); + if (polls.get(key) !== poll) { + return; } - const parsed: unknown = JSON.parse(response.text); - if (!parsed || typeof parsed !== 'object' || (parsed as { refused?: boolean }).refused) { - return undefined; + poll.failures = status && !status.refused ? 0 : poll.failures + 1; + if (status?.state && (status.startedAt ?? 0) >= poll.since) { + $.ui.status(statusText(status, agentId)); + if (status.state !== 'running') { + stopPoll(key); + } } - const resolved = (parsed as { model?: unknown }).model; - if (typeof resolved === 'string') { - return resolved; + if (poll.failures >= maximumFailures) { + stopPoll(key); } - return undefined; - } catch { - return undefined; } finally { - if (timer) { - clearTimeout(timer); - } + poll.isBusy = false; } } -async function rewriteAgentInput( - $: EngineInterface, - chunk: TurnStepInputChunk, - parentModel: string, - agentId: string | undefined, - toolUseId: string | undefined, - spawnModels: Map, -): Promise { - let parsed: Record | undefined; - try { - const value: unknown = JSON.parse(chunk.json); - if (value && typeof value === 'object' && !Array.isArray(value)) { - parsed = value as Record; - } - } catch { - return chunk; - } - if ( - !parsed || - typeof parsed.subagent_type !== 'string' || - !parsed.subagent_type.startsWith('multi-') - ) { - return chunk; - } - const explicitModel = typeof parsed.model === 'string' ? parsed.model : undefined; - const resolvedModel = await resolveWorkerModel( - $, - parsed.subagent_type, - explicitModel, - parentModel, - agentId, - ); - if (resolvedModel && toolUseId) { - rememberBounded(spawnModels, toolUseId, resolvedModel); - } - const label = - (resolvedModel ? workerLabel(resolvedModel) : undefined) ?? providerName(parsed.subagent_type); - if (!label || typeof parsed.description !== 'string') { - return chunk; - } - const description = appendLabel(parsed.description, label); - return { - ...chunk, - json: JSON.stringify({ ...parsed, description }), - }; -} - async function prepareStep( $: EngineInterface, - event: { agentId?: string; model: string }, - models: Map, - running: Map, + event: { agentId?: string; model: string; effort?: unknown }, ) { const key = event.agentId ?? 'main'; - models.set(key, event.model); - const native = isHarnessModel(event.model); - if (native && !running.has(key) && running.size < 128) { - const token = {}; - running.set(key, token); - void poll($, event.agentId, () => running.get(key) === token); + const known = (await read($, agentModels))[key]; + if (known !== event.model) { + await update($, agentModels, (held) => withBounded(held, key, event.model)); } - void postStep($, event); - if (native) { + if (isMultiModel(event.model)) { + // The gateway reads a Multi request's model and effort from this record, so it is + // sent before the request; a Claude step costs the gateway nothing. + await postJson(wire($), '/multi/mod/telemetry', { + ...event, + sessionId: await $.session.id(), + }); + } + if (isHarnessModel(event.model)) { + await startPoll($, key, event.agentId); // A harness may announce a tool the mod has not registered; register it // before the step's request, so the rows of later runs can anchor. await syncDisplayTools(rowsClient($)); } } -export const register = ( - on: Parameters[0], - _options: Parameters[1], - onDetach: () => void = () => {}, - models: Map = new Map(), - spawnModels: Map = new Map(), -) => { - const running = new Map(); +export const register = (on: Parameters[0], _options: Parameters[1]) => { on('turn.step', async function* ($, event, next) { - await prepareStep($, event, models, running); - const agentCalls = new Map(); - for await (const chunk of next(event)) { - if (chunk.kind === 'tool') { - if (chunk.name === 'Agent') { - agentCalls.set(chunk.index, chunk.id); - } - yield chunk; - continue; - } - if (chunk.kind === 'input' && agentCalls.has(chunk.index)) { - const rewritten = await rewriteAgentInput( - $, - chunk, - event.model, - event.agentId, - agentCalls.get(chunk.index), - spawnModels, - ); - yield rewritten; - continue; - } - yield chunk; - } + await prepareStep($, event); + return yield* next(event); }); on('turn.complete', async ($, event, next) => { const key = event.agentId ?? 'main'; - const model = models.get(key); - if (model?.startsWith('multi/')) { - await request($, '/multi/mod/usage/complete', { + const model = (await read($, agentModels))[key]; + if (isMultiModel(model)) { + await postJson(wire($), '/multi/mod/usage/complete', { sessionId: await $.session.id(), agentId: event.agentId, turnId: event.turnId, outcome: event.reason, }); } - const wasRunning = running.delete(key); + const wasRunning = stopPoll(key); // Retain a child's identity between turns: compaction can precede its next step. if (event.isAborted && isHarnessModel(model)) { - void cancelCompaction($, event.agentId); + await postJson(wire($), '/multi/mod/compact/cancel', { + sessionId: await $.session.id(), + agentId: event.agentId, + }); } - if (wasRunning && running.size === 0) { - void $.ui.status(undefined); + if (wasRunning && polls.size === 0) { + $.ui.status(undefined); } return next(event); }); - on('session.detach', async ($, event, next) => { - onDetach(); - forgetUsageSession(await $.session.id()); - running.clear(); - models.clear(); - void detach($); + // The session is over for good: a `/clear` or a resume ends one and starts another in + // the same process (`session.start` fires for neither), and the gateway forgets the + // ended one. `session.detach` fires when any client leaves the roster, a session still + // running, so it is not a place to forget anything. + on('session.end', async ($, event, next) => { + for (const key of [...polls.keys()]) { + stopPoll(key); + } + await forgetSession($, event.sessionId, next.signal); return next(event); }); }; -function rowsClient($: EngineInterface): RowsClient { - return { - sessionId: () => $.session.id(), - catalog: () => request($, '/multi/mod/display-tools'), - register: (tool) => $.tool.register(tool), - acknowledge: (payload) => request($, '/multi/mod/display-tools', payload), - }; -} - -async function postStep($: EngineInterface, event: object) { - await request($, '/multi/mod/telemetry', { ...event, sessionId: await $.session.id() }); -} - -async function detach($: EngineInterface) { - await request($, '/multi/mod/detach', { sessionId: await $.session.id() }); -} - -async function poll($: EngineInterface, agentId: string | undefined, active: () => boolean) { - const since = Date.now(); - const sessionId = await $.session.id(); - const query = `sessionId=${encodeURIComponent(sessionId)}&agentId=${encodeURIComponent(agentId ?? 'main')}`; - let failures = 0; - while (active() && failures < 5) { - const status = await request($, `/multi/mod/lifecycle?${query}`); - if (!active()) { - return; - } - failures = status ? 0 : failures + 1; - if (status?.state && (status.startedAt ?? 0) >= since) { - await $.ui.status(statusText(status, agentId)); - if (status.state !== 'running') { - return; - } - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } -} - -async function request( - $: EngineInterface, - route: string, - payload?: object, -): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const response = await Promise.race([ - $.http.fetch(`${base}${route}`, { - method: payload ? 'POST' : 'GET', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(payload ? { body: JSON.stringify(payload) } : {}), - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Gateway timeout')), 1500); - }), - ]); - return response.ok ? (JSON.parse(response.text) as Status) : undefined; - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } +/** Forgets everything the hooks held for the session that ended, here and in the gateway. */ +async function forgetSession($: EngineInterface, sessionId: string, signal: AbortSignal) { + await update($, policy, () => ({})); + await update($, agentModels, () => ({})); + await update($, spawnModels, () => ({})); + await update($, claudeTypes, () => []); + await update($, offeredProviders, () => []); + await update($, displayTools, () => ({ registered: [] })); + await update($, advisorySessions, (held) => held.filter((id) => id !== sessionId)); + await update($, usagePanes, (held) => { + const { [sessionId]: _closed, ...others } = held; + return others; + }); + $.ui.status(undefined); + if (await isActive(wire($))) { + await postJson(wire($), '/multi/mod/detach', { sessionId }, { timeoutMs: 1000, signal }); } + // After the detach, which the gateway admits only with the key. + await forgetKey(wire($), sessionId); } function statusText(status: Status, agentId: string | undefined) { @@ -284,6 +223,3 @@ function statusText(status: Status, agentId: string | undefined) { // A failed or refused run names its reason, so the line says why, not only that. return `${status.model} · ${agentId ?? 'main'} · ${status.state} · ${elapsed}s ${status.error ?? status.detail ?? ''}`; } -async function cancelCompaction($: EngineInterface, agentId?: string) { - await request($, '/multi/mod/compact/cancel', { sessionId: await $.session.id(), agentId }); -} diff --git a/plugins/multi-core/hooks/policy.ts b/plugins/multi-core/hooks/policy.ts index 3a22e96..cd44820 100644 --- a/plugins/multi-core/hooks/policy.ts +++ b/plugins/multi-core/hooks/policy.ts @@ -1,3 +1,5 @@ +import type { MultiCorePolicy, MultiCorePrompt } from '../types/multi-core.d.ts'; +import { type GatewayOptions, getJson, postJson, type Wire } from './gateway.ts'; import { isHarnessModel } from './provider.ts'; export type PolicyResponse = { @@ -8,32 +10,58 @@ export type PolicyResponse = { status?: string; accepted?: boolean; }; +/** The policy's gateway for one session model; `wire` is the calling file's own. */ +export function policyClient(wire: Wire, model: string): PolicyClient { + return { + model, + post: (route, payload, options) => postJson(wire, route, payload, options), + get: (route, query) => getJson(wire, route, query), + }; +} + +/** The gateway as the policy needs it: explicit methods, as `gateway.ts` offers them. */ export type PolicyClient = { model: string; - request: (route: string, payload: Record) => Promise; -}; -export type PromptSnapshot = { sessionId: string; cwd: string; permissionMode: unknown }; -export type PolicyState = { - generation?: number; - prompt?: PromptSnapshot; - harnessReady?: boolean; - preparing?: Promise; + post: ( + route: string, + payload: Record, + options?: GatewayOptions, + ) => Promise; + get: (route: string, query: Record) => Promise; }; +export type PromptSnapshot = MultiCorePrompt; +export type PolicyState = MultiCorePolicy; -/** Share one admission across helpers spawned from the same prompt. */ +/** The admission each prompt is running, shared by every helper spawned from that prompt. */ +const preparing = new Map>(); + +/** A prompt's identity as the gateway holds it: what, with the model, decides a repost. */ +export function snapshotKey(snapshot: PromptSnapshot, model: string): string { + return JSON.stringify([snapshot.sessionId, snapshot.cwd, snapshot.permissionMode ?? null, model]); +} + +/** + * Share one admission across helpers spawned from the same prompt. It updates `state`, + * a copy the caller persists; only a state still holding the admitted prompt is changed. + */ export async function ensureHarnessPolicy(client: PolicyClient, state: PolicyState) { const snapshot = state.prompt; if (!snapshot || state.harnessReady) { return; } - const preparing = state.preparing ?? admitPrompt(client, snapshot, state.generation); - state.preparing = preparing; - const generation = await preparing; - if (state.prompt === snapshot) { - state.generation = generation; - state.harnessReady = generation !== undefined; - state.preparing = undefined; + const key = snapshotKey(snapshot, client.model); + const admission = preparing.get(key) ?? admitPrompt(client, snapshot, state.generation); + preparing.set(key, admission); + let generation: number | undefined; + try { + generation = await admission; + } finally { + if (preparing.get(key) === admission) { + preparing.delete(key); + } } + state.generation = generation; + state.harnessReady = generation !== undefined; } /** Prepare translated settings only for a harness prompt or a requested harness worker. */ @@ -46,7 +74,7 @@ export async function admitPrompt( if (!prepared) { return undefined; } - const response = await client.request('/multi/mod/session', { + const response = await client.post('/multi/mod/session', { policyGeneration: prepared.policyGeneration, sessionId: snapshot.sessionId, cwd: snapshot.cwd, @@ -71,7 +99,7 @@ export async function recordPrompt( if (isHarnessModel(model)) { return admitPrompt(client, snapshot, generation); } - const response = await client.request('/multi/mod/session', { + const response = await client.post('/multi/mod/session', { ...snapshot, model, event: 'prompt', @@ -91,7 +119,7 @@ export async function preparePolicy( sourceGeneration: number | undefined, ): Promise { let generation = sourceGeneration; - let started = await client.request('/multi/mod/policy', { sessionId, cwd, sourceGeneration }); + let started = await client.post('/multi/mod/policy', { sessionId, cwd, sourceGeneration }); if (started?.refused) { // A reloaded hooks module keeps a mode generation the gateway no longer agrees // with, and `/clear` detaches the session so the gateway holds none at all; @@ -102,7 +130,7 @@ export async function preparePolicy( return undefined; } generation = resynced.generation; - started = await client.request('/multi/mod/policy', { + started = await client.post('/multi/mod/policy', { sessionId, cwd, sourceGeneration: generation, @@ -115,29 +143,23 @@ export async function preparePolicy( return policyGeneration === undefined ? undefined : { policyGeneration, generation }; } +/** + * One request: the gateway holds the reply until discovery ends (`claude plugin list` + * and settings admission take seconds on a cold Windows start) or its own bound passes. + * The wait is the gateway's, so it needs no polling loop in the hook's budget. + */ async function awaitPolicy(client: PolicyClient, sessionId: string, generation: string) { - // Policy discovery runs `claude plugin list` and settings admission; on a - // cold Windows start that takes several seconds. Stay under the 10 s hook budget. - const deadline = Date.now() + 8000; - while (Date.now() < deadline) { - const result = await client.request('/multi/mod/policy', { sessionId, generation }); - if (result?.status === 'ready') { - return generation; - } - if (!result || result.refused || result.status === 'failed') { - return undefined; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return undefined; + const result = await client.post( + '/multi/mod/policy', + { sessionId, generation, wait: true }, + { timeoutMs: 0 }, + ); + return result?.status === 'ready' && !result.refused ? generation : undefined; } /** The gateway's own mode generation, or `{ generation: undefined }` when it holds none. */ async function modeGeneration(client: PolicyClient, sessionId: string) { - const mode = await client.request( - `/multi/mod/mode?sessionId=${encodeURIComponent(sessionId)}`, - {}, - ); + const mode = await client.get('/multi/mod/mode', { sessionId }); if (typeof mode?.generation === 'number') { return { generation: mode.generation }; } diff --git a/plugins/multi-core/hooks/provider.ts b/plugins/multi-core/hooks/provider.ts index 7dd8a81..4d3bd75 100644 --- a/plugins/multi-core/hooks/provider.ts +++ b/plugins/multi-core/hooks/provider.ts @@ -6,3 +6,8 @@ export function isHarnessModel(model: string | undefined): boolean { model?.startsWith('multi/grok/'), ); } + +/** Any Multi model: a harness, or a provider whose tool loop Claude Code runs. */ +export function isMultiModel(model: string | undefined): boolean { + return Boolean(model?.startsWith('multi/')); +} diff --git a/plugins/multi-core/hooks/register.ts b/plugins/multi-core/hooks/register.ts index fa747ed..cb2e213 100644 --- a/plugins/multi-core/hooks/register.ts +++ b/plugins/multi-core/hooks/register.ts @@ -1,49 +1,76 @@ import type { EngineInterface, Register } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { MultiCoreDisplayTools, MultiCorePolicy } from '../types/multi-core.d.ts'; import { register as registerCompaction } from './compact.ts'; +import { isActive, postJson, type Wire } from './gateway.ts'; import { register as registerLifecycle } from './lifecycle.ts'; -import { type PolicyClient, type PolicyState, recordPrompt } from './policy.ts'; -import { isHarnessModel } from './provider.ts'; -import { type RowsClient, register as registerRows, syncDisplayTools } from './rows.ts'; +import { type PromptSnapshot, policyClient, recordPrompt, snapshotKey } from './policy.ts'; +import { isHarnessModel, isMultiModel } from './provider.ts'; +import { + callDisplayRow, + isDisplayTool, + type RowsClient, + register as registerRows, + syncDisplayTools, +} from './rows.ts'; +import { defined } from './state.ts'; import { register as registerUsage } from './usage.ts'; import { register as registerWorkers } from './workers.ts'; -const maxBody = 32000; +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); -type GatewayResponse = { - refused?: true; - httpStatus?: number; - error?: string; - accepted?: boolean; - generation?: number | string; - status?: string; - stale?: boolean; -}; +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, +}); + +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy); +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); +const displayTools = atom( + { plugin: 'multi-core', key: 'displayTools' } as const, + { registered: [] } as MultiCoreDisplayTools, +); + +const rowsClient = ($: EngineInterface): RowsClient => ({ + wire: wire($), + sessionId: () => $.session.id(), + held: () => read($, displayTools), + save: (change) => update($, displayTools, change), + register: (tool) => $.tool.register(tool), +}); + +type PromptEvent = { session_id: string; cwd: string; permission_mode?: string }; // Claude Code 2.1.272 loads exactly one entry from hooks.json `modules`; compose here. +// What the hooks keep lives in `$.state` (`state.ts`), so a hot reload loses none of it. export const register: Register = (on, options) => { - const policyState: PolicyState = {}; - // Detaching makes the gateway forget this session's mode; keeping its generation - // here would leave every later prompt stale against a gateway holding nothing. - const agentModels = new Map(); - const spawnModels = new Map(); - registerUsage(on, options, agentModels); - registerLifecycle( - on, - options, - () => { - policyState.generation = undefined; - policyState.prompt = undefined; - policyState.harnessReady = false; - policyState.preparing = undefined; - }, - agentModels, - spawnModels, - ); - registerCompaction(on, options, agentModels); - registerWorkers(on, options, agentModels, policyState, spawnModels); + registerUsage(on, options); + registerLifecycle(on, options); + registerCompaction(on, options); + registerWorkers(on, options); registerRows(on); + // One hook for every tool call (a module hooks an event once without a matcher): a + // display row is answered here, any other call is attributed to its provider's reviewer. + on('tool.call', async ($, event, next) => { + if (isDisplayTool(event.tool)) { + return callDisplayRow(rowsClient($), event); + } + await attributeToolCall($, event); + return next(event); + }); on('session.start', async ($, event, next) => { - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } await $.command.register({ @@ -51,122 +78,123 @@ export const register: Register = (on, options) => { description: 'Open provider quotas, spend, and session receipts.', immediate: true, }); - const response = await request($, '/multi/mod/session', { - sessionId: await $.session.id(), - cwd: await $.session.cwd(), - model: await $.session.model(), - event: 'start', - }); - policyState.generation = - typeof response?.generation === 'number' ? response.generation : undefined; - // Display rows for native harness actions; awaited so the first run's rows anchor. - await syncDisplayTools(rowsClient($)); + await greet($, await $.session.id(), await $.session.cwd()); return next(event); }); on('classic.UserPromptSubmit', async ($, event, next) => { - if (await active($)) { - policyState.prompt = snapshotOf(event); - policyState.harnessReady = false; - policyState.preparing = undefined; - policyState.generation = await recordPrompt( - await policyClient($), - policyState.prompt, - policyState.generation, - ); - policyState.harnessReady = - policyState.generation !== undefined && isHarnessModel(await $.session.model()); - } + await recordSnapshot($, event); return next(event); }); on('classic.SessionStart', async ($, event, next) => { - if ((await active($)) && typeof event.permission_mode === 'string') { - policyState.prompt = snapshotOf(event); - policyState.harnessReady = false; - policyState.preparing = undefined; - policyState.generation = await recordPrompt( - await policyClient($), - policyState.prompt, - policyState.generation, - ); - policyState.harnessReady = - policyState.generation !== undefined && isHarnessModel(await $.session.model()); + if (typeof event.permission_mode === 'string') { + await recordSnapshot($, event); } return next(event); }); }; -async function active($: EngineInterface): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - return Boolean(base && token); +/** + * The gateway's first record of a session (which also tells the launcher the mod is + * live). `/clear` ends a session and starts another in the process without a + * `session.start`, and `session.end` made the gateway forget the first, so the next + * prompt greets it again before recording its snapshot. + */ +async function greet($: EngineInterface, sessionId: string, cwd: string) { + const response = await postJson(wire($), '/multi/mod/session', { + sessionId, + cwd, + model: await $.session.model(), + event: 'start', + }); + await update($, policy, (held) => + defined({ + ...held, + generation: typeof response?.generation === 'number' ? response.generation : undefined, + handshake: sessionId, + }), + ); } -async function request($: EngineInterface, route: string, payload: Record) { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; +/** + * Records the prompt-boundary snapshot: the permission mode and workspace every + * provider reads. A harness prompt waits for its translated settings policy; any other + * model's snapshot is posted only when it differs from the one the gateway holds, so + * ordinary Claude turns cost no round trip. The snapshot is always kept here, because a + * harness worker spawned later admits against it. + */ +async function recordSnapshot($: EngineInterface, event: PromptEvent) { + if (!(await isActive(wire($)))) { + return; } - const body = JSON.stringify(payload); - if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) { - return undefined; + const held = await read($, policy); + if (held.handshake !== event.session_id) { + await greet($, event.session_id, event.cwd); } - const isGet = route.includes('?'); - let timer: ReturnType | undefined; - try { - const response = $.http.fetch(`${base}${route}`, { - method: isGet ? 'GET' : 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(isGet ? {} : { body }), - }); - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500); - }); - const result = await Promise.race([response, timeout]); - return result.ok ? (JSON.parse(result.text) as GatewayResponse) : refusal(result); - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } + const prompt = snapshotOf(event); + const model = await $.session.model(); + const current = await read($, policy); + const harness = isHarnessModel(model); + const key = snapshotKey(prompt, model); + let generation = current.generation; + let posted: string | undefined; + if (harness || current.posted !== key || generation === undefined) { + generation = await recordPrompt(policyClient(wire($), model), prompt, generation); + posted = harness || generation === undefined ? undefined : key; + } else { + posted = key; + } + await update($, policy, (latest) => + defined({ + ...latest, + prompt, + generation, + posted, + harnessReady: harness && generation !== undefined, + }), + ); + if (harness) { + // The display rows of this prompt's native run anchor only once their tools exist. + await syncDisplayTools(rowsClient($)); } } -// A refused reply keeps the gateway's reason; `refused` stops polling callers. -function refusal(result: { text: string; status: number }): GatewayResponse { - let error: string | undefined; +function snapshotOf(event: PromptEvent): PromptSnapshot { + return defined({ + sessionId: event.session_id, + cwd: event.cwd, + permissionMode: event.permission_mode, + }); +} + +/** + * Reviewer attribution for a provider's tool call: the gateway learns the session, call, + * and workspace it needs to match the reviewer request to it. A Claude loop's tool call + * is Claude's own and costs no gateway call. The reply never decides the call. It runs + * from `tool.call`, which carries the calling loop's agentId (`classic.PreToolUse` does + * not, so it could not tell a provider worker's call from the session's own). + */ +async function attributeToolCall( + $: EngineInterface, + event: { tool: string; tool_use_id?: string; agentId?: string }, +) { try { - const parsed: unknown = JSON.parse(result.text); - if (parsed && typeof parsed === 'object') { - error = (parsed as { error?: unknown }).error as string | undefined; + const model = + event.agentId === undefined + ? await $.session.model() + : (await read($, agentModels))[event.agentId]; + if (!isMultiModel(model) || !(await isActive(wire($)))) { + return; } + // The engine hands a tool call no permission mode: the prompt-boundary snapshot has it. + const mode = (await read($, policy)).prompt?.permissionMode; + await postJson(wire($), '/multi/permission', { + session_id: await $.session.id(), + tool_use_id: event.tool_use_id, + tool_name: event.tool, + cwd: await $.session.cwd(), + ...(mode === undefined ? {} : { permission_mode: mode }), + }); } catch { - // A non-JSON body still names the status below. + // Attribution is best effort; Claude's own checks decide the call. } - return { - refused: true, - httpStatus: result.status, - error: error ?? `gateway ${result.status}`, - }; -} - -function snapshotOf(event: { session_id: string; cwd: string; permission_mode?: string }) { - return { sessionId: event.session_id, cwd: event.cwd, permissionMode: event.permission_mode }; -} - -function rowsClient($: EngineInterface): RowsClient { - return { - sessionId: () => $.session.id(), - catalog: () => request($, '/multi/mod/display-tools?', {}), - register: (tool) => $.tool.register(tool), - acknowledge: (payload) => request($, '/multi/mod/display-tools', payload), - }; -} - -async function policyClient($: EngineInterface): Promise { - return { - model: await $.session.model(), - request: (route, payload) => request($, route, payload), - }; } diff --git a/plugins/multi-core/hooks/rows.ts b/plugins/multi-core/hooks/rows.ts index 31f27e2..f98023f 100644 --- a/plugins/multi-core/hooks/rows.ts +++ b/plugins/multi-core/hooks/rows.ts @@ -1,4 +1,7 @@ import type { EngineInterface, Register, ToolSpec } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { MultiCoreDisplayTools } from '../types/multi-core.d.ts'; +import { accepted, getJson, postJson, type Wire } from './gateway.ts'; import { errorBody, headerSummary, @@ -8,6 +11,7 @@ import { toolHeader, unconfirmedBody, } from './rows-view.ts'; +import { rememberBounded } from './state.ts'; /** * Native harness actions as Claude Code tool rows. @@ -21,14 +25,15 @@ import { * the gateway did not originate, answers the gateway's own with the native output, * and draws the row as Claude Code draws the built-in the action mirrors * (`rows-view.ts`), under the native tool's name. - * `register.ts` syncs the names at session start and `lifecycle.ts` before each - * harness step (a module hooks each event once), both through a `RowsClient`. + * The names are registered lazily, so a session that never runs a harness model lists + * none: `register.ts` syncs them at a harness prompt, `workers.ts` before a harness + * worker spawns, and `lifecycle.ts` before each harness step (a module hooks each + * event once). What is registered, and the catalog revision it answers, is `$.state`. */ const prefix = 'mcp__multi-core__'; const tokenKey = 'multi_row'; const namePattern = /^[A-Za-z0-9_-]{1,47}$/; const maximumTools = 160; -const maximumBody = 32000; const maximumRemembered = 512; const reminder = /[\s\S]*?<\/system-reminder>/g; const description = @@ -41,10 +46,45 @@ const refusal = type On = Parameters[0]; type Row = { output: string; isError: boolean }; -const registered = new Set(); -/** Row inputs by tool_use id: a `ToolResult` carries no input, and its drawing needs it. */ +// Named where it is read: the engine's scan reads an atom's plugin and key from this file. +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + +const rowGateway = ($: EngineInterface): RowGateway => ({ + wire: { + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, + }, + sessionId: () => $.session.id(), +}); + +/** What answering a display row needs: the gateway and this session's id. */ +export type RowGateway = { wire: Wire; sessionId: () => Promise }; + +/** + * What syncing needs of the engine. The engine follows `$` only into functions of the + * file that hooks, so each caller (`register.ts`, `lifecycle.ts`, `workers.ts`) builds + * this from its own `$`: the state calls are `read`/`update` on `displayTools`. + */ +export type RowsClient = RowGateway & { + held: () => Promise; + save: ( + change: (held: MultiCoreDisplayTools) => MultiCoreDisplayTools, + ) => Promise; + register: (tool: ToolSpec) => Promise; +}; + +/** + * Row inputs by tool_use id: a `ToolResult` carries no input, and its drawing needs it. + * A render memo (every draw of a row's `ToolUse` refills it), not state a reload loses, + * and never written from a render hook, which `$.state` refuses. + */ const inputs = new Map>(); -let revision: number | undefined; export function isDisplayTool(tool: unknown): tool is string { return typeof tool === 'string' && tool.startsWith(prefix); @@ -62,24 +102,11 @@ export const register = (on: On) => { if (!isDisplayTool(event.tool)) { return next(event); } - const row = await issuedRow($, event.input, event.tool_use_id); + const row = await issuedRow(rowGateway($), event.input, event.tool_use_id); return row ? { decision: 'allow' as const, reason: 'A row the Multi gateway issued for this call.' } : { decision: 'deny' as const, reason: refusal }; }); - on('tool.call', async ($, event, next) => { - if (!isDisplayTool(event.tool)) { - return next(event); - } - const row = await issuedRow($, event, event.tool_use_id); - if (!row) { - return { deny: refusal }; - } - remember(event.tool_use_id, event); - return row.isError - ? { isError: true as const, result: row.output } - : { result: [{ type: 'text', text: row.output }] }; - }); on('ui.render', { component: 'ToolUse' }, async ($, event, next) => { if (!isDisplayTool(event.props.tool)) { return next(event); @@ -116,12 +143,24 @@ export const register = (on: On) => { }); }; -function remember(toolUseId: string, input: Record) { - inputs.delete(toolUseId); - inputs.set(toolUseId, input); - while (inputs.size > maximumRemembered) { - inputs.delete(inputs.keys().next().value as string); +/** + * A display row's call, answered with the native output when the gateway issued its + * token for this call; any other call to a display name is refused. `register.ts` + * routes display tools here from the module's one `tool.call` hook. + */ +export async function callDisplayRow(client: RowGateway, event: { tool_use_id?: string }) { + const row = await issuedRow(client, event, event.tool_use_id); + if (!row) { + return { deny: refusal }; } + remember(String(event.tool_use_id), event as Record); + return row.isError + ? { isError: true as const, result: row.output } + : { result: [{ type: 'text', text: row.output }] }; +} + +function remember(toolUseId: string, input: Record) { + rememberBounded(inputs, toolUseId, input, maximumRemembered); } async function sessionCwd($: EngineInterface) { @@ -132,33 +171,20 @@ async function sessionCwd($: EngineInterface) { } } -/** - * What syncing needs from the engine and the gateway. The engine passes `$` only - * into functions of the module that hooks, so each caller builds this from its - * own `$` (as `policy.ts` takes a `PolicyClient`). - */ -export type RowsClient = { - sessionId: () => Promise; - /** `GET /multi/mod/display-tools`: the gateway's offered names and their revision. */ - catalog: () => Promise; - register: (tool: ToolSpec) => Promise; - /** `POST /multi/mod/display-tools`: the names now registered. */ - acknowledge: (payload: Record) => Promise; -}; - /** * Registers every display tool the gateway offers that is not registered yet, * then tells the gateway which are, since it only emits rows for those. */ export async function syncDisplayTools(client: RowsClient) { - const catalog = record(await client.catalog()); - if (!catalog || typeof catalog.revision !== 'number' || catalog.revision === revision) { + const catalog = record(accepted(await getJson(client.wire, '/multi/mod/display-tools'))); + const known = await client.held(); + if (!catalog || typeof catalog.revision !== 'number' || catalog.revision === known.revision) { return; } const names = (Array.isArray(catalog.names) ? catalog.names : []) .filter((name): name is string => typeof name === 'string' && namePattern.test(name)) .slice(0, maximumTools) - .filter((name) => !registered.has(name)); + .filter((name) => !known.registered.includes(name)); const results = await Promise.allSettled( names.map((name) => client.register({ @@ -168,50 +194,52 @@ export async function syncDisplayTools(client: RowsClient) { }), ), ); - let complete = true; - for (const [index, result] of results.entries()) { - if (result.status === 'fulfilled') { - registered.add(names[index] as string); - } else { - // A toolless session cannot register tools; its gateway emits no rows. - complete = false; - } - } + // A toolless session cannot register tools; its gateway emits no rows. + const complete = results.every((result) => result.status === 'fulfilled'); + const registered = [ + ...known.registered, + ...names.filter((_name, index) => results[index]?.status === 'fulfilled'), + ]; // The revision advances only once the gateway confirmed the registered set; a // timeout or an HTTP failure (which the client returns as no reply) is retried // on the next sync, with the names registered so far kept. - const confirmed = await acknowledged(client); - if (complete && confirmed) { - revision = catalog.revision; - } + const confirmed = await acknowledged(client, registered); + const revision = complete && confirmed ? (catalog.revision as number) : undefined; + await client.save((latest) => + revision === undefined ? { ...latest, registered } : { registered, revision }, + ); } /** Tells the gateway which names are registered; true only for its validated reply. */ -async function acknowledged(client: RowsClient): Promise { - if (!registered.size) { +async function acknowledged(client: RowsClient, registered: readonly string[]): Promise { + if (!registered.length) { return true; } const reply = record( - await client.acknowledge({ - sessionId: await client.sessionId(), - registered: [...registered], - }), + accepted( + await postJson(client.wire, '/multi/mod/display-tools', { + sessionId: await client.sessionId(), + registered, + }), + ), ); return typeof reply?.registered === 'number'; } /** The native output of a row, only when the gateway issued its token for this call. */ -async function issuedRow($: EngineInterface, input: unknown, toolUseId: unknown) { +async function issuedRow(client: RowGateway, input: unknown, toolUseId: unknown) { const token = record(input)?.[tokenKey]; if (typeof token !== 'string' || typeof toolUseId !== 'string') { return undefined; } const reply = record( - await gateway($, '/multi/mod/display', { - sessionId: await $.session.id(), - token, - toolUseId, - }), + accepted( + await postJson(client.wire, '/multi/mod/display', { + sessionId: await client.sessionId(), + token, + toolUseId, + }), + ), ); if (!reply || typeof reply.output !== 'string') { return undefined; @@ -246,35 +274,3 @@ export function outputText(output: unknown): string { } return text.replaceAll(reminder, '').replaceAll('\r', '').trimEnd(); } - -async function gateway($: EngineInterface, route: string, payload?: Record) { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - const body = payload ? JSON.stringify(payload) : undefined; - if (body && body.length > maximumBody) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const response = await Promise.race([ - $.http.fetch(`${base}${route}`, { - method: body ? 'POST' : 'GET', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(body ? { body } : {}), - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Gateway timeout')), 1500); - }), - ]); - return response.ok ? (JSON.parse(response.text) as unknown) : undefined; - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } - } -} diff --git a/plugins/multi-core/hooks/state.ts b/plugins/multi-core/hooks/state.ts new file mode 100644 index 0000000..554a420 --- /dev/null +++ b/plugins/multi-core/hooks/state.ts @@ -0,0 +1,45 @@ +/** + * What the hooks keep across a hot reload of this module is `$.state` (declared in + * `types/multi-core.d.ts`): read with `read($, atom)`, changed with `update($, atom, change)`. + * Each hooks file names its atoms itself, since the engine's scan reads a state value's + * plugin and key from the file that uses it. Never mutate a value read: copy it. A + * `ui.render` hook that reads one is drawn again when it changes. + */ +const defaultLimit = 256; + +/** A copy of `entries` with `key` newest, the oldest dropped past `limit`. */ +export function withBounded( + entries: Readonly>, + key: string, + value: T, + limit = defaultLimit, +): Record { + const { [key]: _replaced, ...others } = entries; + const kept = Object.entries(others).slice(-(limit - 1)); + return Object.fromEntries([...kept, [key, value]]); +} + +/** A copy of `items` with `item` newest and at most `limit` kept. */ +export function withItem(items: readonly string[], item: string, limit = defaultLimit): string[] { + return [...items.filter((held) => held !== item), item].slice(-limit); +} + +/** Bounded like the other per-call records: the oldest entry makes room. */ +export function rememberBounded( + entries: Map, + key: string, + value: T, + limit = defaultLimit, +) { + entries.delete(key); + const oldest = entries.keys().next(); + if (entries.size >= limit && !oldest.done) { + entries.delete(oldest.value); + } + entries.set(key, value); +} + +/** An object without its undefined fields, which the state's JSON data does not hold. */ +export function defined(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as T; +} diff --git a/plugins/multi-core/hooks/tests/compact.test.ts b/plugins/multi-core/hooks/tests/compact.test.ts index d5170c5..72cd3ea 100644 --- a/plugins/multi-core/hooks/tests/compact.test.ts +++ b/plugins/multi-core/hooks/tests/compact.test.ts @@ -1,11 +1,12 @@ -import { expect, test } from 'claude-code/testing'; -import { compactionModel, register } from '../compact.ts'; +import { expect, mock, test } from 'claude-code/testing'; +import { register } from '../compact.ts'; test('registers the compaction boundary hook', () => { expect(typeof register).toBe('function'); }); test('compaction with an unknown generation skips without calling core', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.model', () => ({ value: 'multi/cursor/auto' })); on('session.id', () => ({ value: 's' })); @@ -15,12 +16,13 @@ test('compaction with an unknown generation skips without calling core', async ( core = true; return { skip: 'core' }; }); - const result = await $.session.compact({}); + const result = await $.session.compact({} as never); expect(result.skip).toBe('Multi compaction policy generation is unavailable.'); expect(core).toBe(false); }); test('a stale digest falls through to core compaction', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.model', () => ({ value: 'multi/cursor/auto' })); on('session.id', () => ({ value: 's' })); @@ -33,11 +35,12 @@ test('a stale digest falls through to core compaction', async ($, on) => { }, })); on('session.compact', () => ({ skip: 'core fallback' })); - const result = await $.session.compact({}); + const result = await $.session.compact({} as never); expect(result.skip).toBe('core fallback'); }); test('failed fallback authorization skips instead of running core with ordinary tools', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.model', () => ({ value: 'multi/cursor/auto' })); on('session.id', () => ({ value: 's' })); @@ -49,12 +52,13 @@ test('failed fallback authorization skips instead of running core with ordinary core = true; return { skip: 'core' }; }); - const result = await $.session.compact({}); + const result = await $.session.compact({} as never); expect(result.skip).toBe('Multi tool-free compaction authorization was not acknowledged.'); expect(core).toBe(false); }); test('ready authenticated summary replaces the transcript without calling core', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.model', () => ({ value: 'multi/cursor/auto' })); on('session.id', () => ({ value: 's' })); @@ -73,8 +77,8 @@ test('ready authenticated summary replaces the transcript without calling core', core = true; return { skip: 'core' }; }); - const result = await $.session.compact({}); - expect(result.messages?.[0].text).toBe('summary'); + const result = await $.session.compact({} as never); + expect(result.messages?.[0]?.text).toBe('summary'); expect(core).toBe(false); }); @@ -87,6 +91,7 @@ for (const model of [ 'multi/zen/glm-5', ]) { test(`native ${model} compacts without consulting the gateway`, async ($, on) => { + mock.clock(on); on('session.model', () => ({ value: model })); on('env.get', () => ({ value: 'configured' })); let fetches = 0; @@ -95,19 +100,74 @@ for (const model of [ return { value: { ok: false, status: 409, headers: {}, text: '{}' } }; }); on('session.compact', () => ({ skip: 'native compaction reached' })); - const result = await $.session.compact({}); + const result = await $.session.compact({} as never); expect(result.skip).toBe('native compaction reached'); expect(fetches).toBe(0); }); } test('compaction uses the child model without borrowing its parent model', async ($, on) => { + mock.clock(on); + mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000' }); on('session.model', () => ({ value: 'multi/cursor/auto' })); - const models = new Map([ + on('session.id', () => ({ value: 's' })); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'ok' }; + return { + turnId: event.turnId, + index: 0, + answer: 'ok', + toolUses: [], + stopReason: 'end_turn', + usage: null, + }; + }); + on('session.compact', () => ({ skip: 'native compaction reached' })); + on('http.fetch', () => ({ value: { ok: false, status: 409, headers: {}, text: '{}' } })); + // The hooks record each loop's model as it steps; that record is what compaction reads. + for (const [agentId, model] of [ ['claude-child', 'claude-sonnet-5'], ['external-child', 'multi/cursor/auto'], - ]); - expect(await compactionModel($, 'claude-child', models)).toBe('claude-sonnet-5'); - expect(await compactionModel($, 'external-child', models)).toBe('multi/cursor/auto'); - expect(await compactionModel($, 'unknown-child', models)).toBe(undefined); + ] as const) { + for await (const _chunk of $.turn.step({ + turnId: agentId, + agentId, + index: 0, + model, + messageCount: 1, + })) { + // Observe the step, as the engine does. + } + } + const transcript = [{ role: 'user' as const, text: 'hello', toolUses: [] }]; + // A Claude child compacts natively, whatever the main loop runs on. + expect( + ( + await $.session.compact({ + agentId: 'claude-child', + trigger: 'auto', + messages: transcript, + } as never) + ).skip, + ).toBe('native compaction reached'); + // A harness child is held to the gateway's generation, which this gateway does not have. + expect( + ( + await $.session.compact({ + agentId: 'external-child', + trigger: 'auto', + messages: transcript, + } as never) + ).skip, + ).toBe('Multi compaction policy generation is unavailable.'); + // An unrecorded child's model is unknown, never its parent's. + expect( + ( + await $.session.compact({ + agentId: 'unknown-child', + trigger: 'auto', + messages: transcript, + } as never) + ).skip, + ).toBe('native compaction reached'); }); diff --git a/plugins/multi-core/hooks/tests/gateway-key.test.ts b/plugins/multi-core/hooks/tests/gateway-key.test.ts new file mode 100644 index 0000000..0c7d3ba --- /dev/null +++ b/plugins/multi-core/hooks/tests/gateway-key.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from 'claude-code/testing'; +import { forgetKey, getJson, postJson, type Wire } from '../gateway.ts'; + +// A stub gateway with the admission rule of the gateway's mod-keys.ts: it mints a key for a +// session's first POST and refuses every token-only POST of that session once the key is echoed. +function stubGateway() { + const entries = new Map(); + const refusal = { + ok: false, + status: 403, + headers: {}, + text: '{"error":"Mod session key required"}', + }; + return async (url: string, init: { headers?: Record; body?: string }) => { + const sessionId = (JSON.parse(init.body ?? '{}') as { sessionId?: string }).sessionId; + const presented = init.headers?.['x-multi-mod-key']; + const headers: Record = {}; + if (sessionId) { + const entry = entries.get(sessionId) ?? { key: `key-${entries.size}`, confirmed: false }; + entries.set(sessionId, entry); + if (presented === undefined ? entry.confirmed : presented !== entry.key) { + return refusal; + } + if (presented === undefined) { + headers['x-multi-mod-key'] = entry.key; + } else { + entry.confirmed = true; + } + } + return { ok: true, status: 200, headers, text: JSON.stringify({ accepted: true, url }) }; + }; +} + +function wireOver(store: { held: Record }, fetch: Wire['fetch']): Wire { + return { + url: async () => 'http://127.0.0.1:4000', + token: async () => 'token', + fetch, + sleep: () => new Promise(() => undefined), + keys: { + read: async () => store.held, + save: async (change) => { + store.held = change(store.held); + }, + }, + }; +} + +test('the key is captured from the first reply and echoed on later requests', async () => { + const store = { held: {} as Record }; + const wire = wireOver(store, stubGateway()); + expect(await postJson(wire, '/multi/mod/session', { sessionId: 's' })).toMatchObject({ + accepted: true, + }); + expect(Object.keys(store.held)).toEqual(['s']); + expect(await postJson(wire, '/multi/mod/session', { sessionId: 's' })).toMatchObject({ + accepted: true, + }); + expect(await getJson(wire, '/multi/mod/mode', { sessionId: 's' })).toMatchObject({ + accepted: true, + }); +}); + +test('a request without the key is refused once the mod has echoed it', async () => { + const fetch = stubGateway(); + const wire = wireOver({ held: {} }, fetch); + await postJson(wire, '/multi/mod/session', { sessionId: 's' }); + await postJson(wire, '/multi/mod/session', { sessionId: 's' }); + // An approved curl carries the token but not the key. + const forged = await fetch('http://x/multi/mod/session', { + headers: { 'x-multi-gateway-token': 'token' }, + body: JSON.stringify({ sessionId: 's', permissionMode: 'bypassPermissions' }), + }); + expect(forged.status).toBe(403); + // A mod holding no key for the session is refused the same way. + const other = wireOver({ held: {} }, fetch); + expect(await postJson(other, '/multi/mod/session', { sessionId: 's' })).toMatchObject({ + refused: true, + httpStatus: 403, + }); +}); + +test('keys survive a reload of the module and are forgotten at session end', async () => { + const fetch = stubGateway(); + const store = { held: {} as Record }; + await postJson(wireOver(store, fetch), '/multi/mod/session', { sessionId: 's' }); + // A reload builds a new wire over the same host-held state. + const reloaded = wireOver(store, fetch); + expect(await postJson(reloaded, '/multi/mod/session', { sessionId: 's' })).toMatchObject({ + accepted: true, + }); + await forgetKey(reloaded, 's'); + expect(store.held).toEqual({}); +}); diff --git a/plugins/multi-core/hooks/tests/lifecycle.test.ts b/plugins/multi-core/hooks/tests/lifecycle.test.ts index 897d773..50d885c 100644 --- a/plugins/multi-core/hooks/tests/lifecycle.test.ts +++ b/plugins/multi-core/hooks/tests/lifecycle.test.ts @@ -1,6 +1,7 @@ import { expect, mock, test } from 'claude-code/testing'; test('turn.step telemetry preserves core model, effort and streamed chunks', async ($, on) => { + mock.clock(on); mock.env(on, {}); on('session.id', () => ({ value: 's' })); on('turn.step', async function* (_$, event) { @@ -32,6 +33,7 @@ test('turn.step telemetry preserves core model, effort and streamed chunks', asy }); test('Claude completion does not wait on Multi accounting or clear another provider status', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', @@ -82,6 +84,7 @@ test('Claude completion does not wait on Multi accounting or clear another provi }); test('a completed harness child retains its provider for compaction between turns', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000' }); on('session.id', () => ({ value: 's' })); on('session.model', () => ({ value: 'claude-sonnet-5' })); @@ -115,9 +118,219 @@ test('a completed harness child retains its provider for compaction between turn answer: 'done', durationMs: 1, isAborted: false, - reason: 'end_turn', + reason: 'answer', }); - expect((await $.session.compact({ agentId: 'child' })).skip).toBe( + expect((await $.session.compact({ agentId: 'child' } as never)).skip).toBe( 'Multi compaction policy generation is unavailable.', ); }); + +const gatewayEnv = { + MULTI_GATEWAY_TOKEN: 'secret', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', +}; +const stepResult = (event: { turnId: string }) => ({ + turnId: event.turnId, + index: 0, + answer: 'done', + toolUses: [], + stopReason: 'end_turn' as const, + usage: null, +}); + +test('a harness step polls the run status on the clock until its turn completes', async ($, on) => { + const clock = mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 's' })); + const lifecycleReads: string[] = []; + on('http.fetch', (_$, event) => { + const reply = (value: unknown) => ({ + value: { ok: true, status: 200, headers: {}, text: JSON.stringify(value) }, + }); + if (event.url.includes('/multi/mod/lifecycle')) { + lifecycleReads.push(event.url); + return reply({ + model: 'multi/cursor/auto', + state: 'running', + detail: 'reading', + elapsedMs: 3000, + startedAt: 10, + }); + } + return reply({ revision: 1, names: [], accepted: true }); + }); + const lines: Array = []; + on('ui.status', (_$, event) => { + lines.push(event.text); + return { value: undefined }; + }); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + on('turn.complete', (_$, event) => ({ text: event.answer })); + for await (const _chunk of $.turn.step({ + turnId: 't', + index: 0, + model: 'multi/cursor/auto', + messageCount: 1, + })) { + // The run is under way once its step began. + } + // Nothing polls outside the clock: the first read comes when the clock reaches it. + expect(lifecycleReads.length).toBe(0); + await clock.advance(500); + expect(lifecycleReads.length).toBe(1); + expect(lifecycleReads[0]).toContain('sessionId=s&agentId=main'); + expect(lines.at(-1)).toBe('multi/cursor/auto · main · running · 3s reading'); + await clock.advance(1000); + expect(lifecycleReads.length).toBe(3); + await $.turn.complete({ + turnId: 't', + answer: 'done', + durationMs: 1, + isAborted: false, + reason: 'answer', + }); + expect(lines.at(-1)).toBe(undefined); + await clock.advance(5000); + expect(lifecycleReads.length).toBe(3); +}); + +test('a Claude step costs the gateway no telemetry and starts no poll', async ($, on) => { + const clock = mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 's' })); + const urls: string[] = []; + on('http.fetch', (_$, event) => { + urls.push(event.url); + return { value: { ok: true, status: 200, headers: {}, text: '{}' } }; + }); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + for await (const _chunk of $.turn.step({ + turnId: 't', + index: 0, + model: 'claude-sonnet-5', + messageCount: 1, + })) { + // A native step. + } + await clock.advance(5000); + expect(urls).toEqual([]); + // A provider-loop (not harness) step reports its model and effort, and polls nothing. + for await (const _chunk of $.turn.step({ + turnId: 't2', + index: 0, + model: 'multi/openai/gpt-6-astra', + effort: 'high', + messageCount: 1, + })) { + // The gateway reads this step's model and effort before its request. + } + await clock.advance(5000); + expect(urls).toEqual(['http://127.0.0.1:4000/multi/mod/telemetry']); +}); + +test('a request that outlasts its bound gives up on the clock, not on a timer', async ($, on) => { + const clock = mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 's' })); + on('http.fetch', () => new Promise(() => undefined)); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + let stepped = false; + const step = (async () => { + for await (const _chunk of $.turn.step({ + turnId: 't', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // The step waits on its telemetry post. + } + stepped = true; + })(); + await clock.advance(1499); + expect(stepped).toBe(false); + await clock.advance(1); + await step; + expect(stepped).toBe(true); +}); + +test('session end forgets the gateway session, while a client leaving does not', async ($, on) => { + mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 'ending' })); + on('session.end', (_$, event) => ({ sessionId: event.sessionId })); + on('ui.status', () => ({ value: undefined })); + const posts: Array<{ url: string; body: Record }> = []; + on('http.fetch', (_$, event) => { + posts.push({ + url: event.url, + body: event.init?.body ? JSON.parse(String(event.init.body)) : {}, + }); + return { value: { ok: true, status: 200, headers: {}, text: '{"accepted":true}' } }; + }); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + for await (const _chunk of $.turn.step({ + turnId: 't', + agentId: 'worker', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // A provider worker's model is recorded. + } + posts.length = 0; + await $.session.end({ reason: 'clear', sessionId: 'ending', resume: { id: 'ending' } } as never); + expect(posts).toEqual([ + { url: 'http://127.0.0.1:4000/multi/mod/detach', body: { sessionId: 'ending' } }, + ]); +}); + +test('the gateway session key is captured, echoed, and cleared at session end', async ($, on) => { + mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 'keyed' })); + on('session.end', (_$, event) => ({ sessionId: event.sessionId })); + on('ui.status', () => ({ value: undefined })); + const sent: Array<{ route: string; key: string | undefined }> = []; + on('http.fetch', (_$, event) => { + const headers = event.init?.headers as Record | undefined; + sent.push({ route: new URL(event.url).pathname, key: headers?.['x-multi-mod-key'] }); + const issued: Record = headers?.['x-multi-mod-key'] + ? {} + : { 'x-multi-mod-key': 'issued-key' }; + return { value: { ok: true, status: 200, headers: issued, text: '{"accepted":true}' } }; + }); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + const step = async (turnId: string) => { + for await (const _chunk of $.turn.step({ + turnId, + agentId: 'worker', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // Each step posts telemetry for the session. + } + }; + await step('t1'); + await step('t2'); + expect(sent.map((request) => request.key)).toEqual([undefined, 'issued-key']); + await $.session.end({ reason: 'clear', sessionId: 'keyed', resume: { id: 'keyed' } } as never); + expect(sent.at(-1)).toEqual({ route: '/multi/mod/detach', key: 'issued-key' }); + await step('t3'); + expect(sent.at(-1)?.key).toBe(undefined); +}); diff --git a/plugins/multi-core/hooks/tests/policy-resync.test.ts b/plugins/multi-core/hooks/tests/policy-resync.test.ts index 73aa099..518e33c 100644 --- a/plugins/multi-core/hooks/tests/policy-resync.test.ts +++ b/plugins/multi-core/hooks/tests/policy-resync.test.ts @@ -14,15 +14,18 @@ type Call = { url: string; body: Record }; function gateway(held: number | undefined, calls: Call[]): PolicyClient { return { model: 'multi/cursor/auto', - request: async (route, body) => { + get: async (route, query) => { + calls.push({ url: `${route}?${new URLSearchParams(query)}`, body: {} }); + return held === undefined ? { refused: true, httpStatus: 409 } : { generation: held }; + }, + post: async (route, body) => { calls.push({ url: route, body }); - if (route.startsWith('/multi/mod/mode?')) { - return held === undefined ? { refused: true, httpStatus: 409 } : { generation: held }; - } if (route === '/multi/mod/session') { return { accepted: true, generation: 11 }; } if (typeof body.generation === 'string') { + // One held request: the gateway answers once discovery has ended. + expect(body.wait).toBe(true); return { generation: body.generation, status: 'ready' }; } if (body.sourceGeneration !== held) { @@ -33,7 +36,11 @@ function gateway(held: number | undefined, calls: Call[]): PolicyClient { }; } function unreachable(): PolicyClient { - return { model: 'multi/cursor/auto', request: async () => undefined }; + return { + model: 'multi/cursor/auto', + get: async () => undefined, + post: async () => undefined, + }; } test('concurrent harness helpers share admission and later helpers reuse it', async () => { @@ -100,7 +107,15 @@ for (const model of ['claude-sonnet-5', 'multi/openai/gpt-6-astra', 'multi/zen/g const snapshot = { sessionId: 'session', cwd: '/workspace', permissionMode: 'plan' }; expect(await recordPrompt(client, snapshot, 7)).toBe(11); expect(calls.map((call) => call.url)).toEqual(['/multi/mod/session']); - expect(calls[0].body.policyGeneration).toBe(undefined); - expect(calls[0].body.permissionMode).toBe('plan'); + expect(calls[0]?.body.policyGeneration).toBe(undefined); + expect(calls[0]?.body.permissionMode).toBe('plan'); }); } + +test('policy readiness is one held request rather than a polling loop', async () => { + const calls: Call[] = []; + await preparePolicy(gateway(undefined, calls), 'session', '/workspace', undefined); + const waits = calls.filter((call) => call.body.wait === true); + expect(waits.length).toBe(1); + expect(waits[0]?.body.generation).toBe('policy-1'); +}); diff --git a/plugins/multi-core/hooks/tests/register.test.ts b/plugins/multi-core/hooks/tests/register.test.ts index 5ad575f..3695a51 100644 --- a/plugins/multi-core/hooks/tests/register.test.ts +++ b/plugins/multi-core/hooks/tests/register.test.ts @@ -1,6 +1,7 @@ import { expect, mock, test } from 'claude-code/testing'; test('posts a session snapshot and registers no model-callable tools', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'test-token', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', @@ -26,13 +27,14 @@ test('posts a session snapshot and registers no model-callable tools', async ($, value: { status: 200, ok: true, headers: {}, text: JSON.stringify({ accepted: true }) }, }; }); - await $.session.start({ cwd: '/tmp', model: 'multi/cursor/auto' }); + await $.session.start({ cwd: '/tmp', model: 'multi/cursor/auto' } as never); expect(requests).toContain('http://127.0.0.1:4000/multi/mod/session'); expect(commands).toEqual(['multi-usage']); expect(tools).toEqual([]); }); test('mod is dormant without launcher environment', async ($, on) => { + mock.clock(on); mock.env(on, {}); on('session.start', () => ({ cwd: '/tmp' })); on('command.register', (_$, event) => ({ value: { command: event.name } })); @@ -44,6 +46,68 @@ test('mod is dormant without launcher environment', async ($, on) => { fetches += 1; return { value: { status: 200, ok: true, headers: {}, text: '{}' } }; }); - await $.session.start({ cwd: '/tmp', model: 'claude-sonnet' }); + await $.session.start({ cwd: '/tmp', model: 'claude-sonnet' } as never); expect(fetches).toBe(0); }); + +test('an unchanged Claude prompt posts nothing, and a changed mode posts again', async ($, on) => { + mock.clock(on); + mock.env(on, { + MULTI_GATEWAY_TOKEN: 'test-token', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', + }); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + on('classic.UserPromptSubmit', () => ({})); + const posts: Array> = []; + on('http.fetch', (_$, event) => { + posts.push(event.init?.body ? JSON.parse(String(event.init.body)) : {}); + return { + value: { + ok: true, + status: 200, + headers: {}, + text: JSON.stringify({ accepted: true, generation: posts.length }), + }, + }; + }); + const prompt = (mode: string) => + $.classic.UserPromptSubmit({ + prompt: 'go', + permission_mode: mode, + session_id: 's', + cwd: '/workspace', + }); + await prompt('default'); + // The first prompt greets the gateway (`start`) and records its snapshot. + expect(posts.map((post) => post.event)).toEqual(['start', 'prompt']); + await prompt('default'); + await prompt('default'); + expect(posts.length).toBe(2); + await prompt('plan'); + expect(posts.map((post) => post.permissionMode)).toEqual([undefined, 'default', 'plan']); +}); + +test('a harness prompt always admits its policy, even when nothing changed', async ($, on) => { + mock.clock(on); + mock.env(on, { + MULTI_GATEWAY_TOKEN: 'test-token', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', + }); + on('session.model', () => ({ value: 'multi/cursor/auto' })); + on('classic.UserPromptSubmit', () => ({})); + const routes: string[] = []; + on('http.fetch', (_$, event) => { + routes.push(event.url.replace(/^.*\/multi/, '/multi')); + return { value: { ok: true, status: 200, headers: {}, text: '{"revision":0,"names":[]}' } }; + }); + const send = () => + $.classic.UserPromptSubmit({ + prompt: 'go', + permission_mode: 'default', + session_id: 's', + cwd: '/workspace', + }); + await send(); + await send(); + expect(routes.filter((route) => route === '/multi/mod/policy').length).toBeGreaterThanOrEqual(2); +}); diff --git a/plugins/multi-core/hooks/tests/rows.test.ts b/plugins/multi-core/hooks/tests/rows.test.ts index c850a89..3fb60c3 100644 --- a/plugins/multi-core/hooks/tests/rows.test.ts +++ b/plugins/multi-core/hooks/tests/rows.test.ts @@ -1,5 +1,8 @@ +import type { On as EngineOn } from 'claude-code'; +import type { Engine as TestEngine } from 'claude-code/testing'; import { expect, mock, test } from 'claude-code/testing'; -import { syncDisplayTools } from '../rows.ts'; +import type { MultiCoreDisplayTools } from '../../types/multi-core.d.ts'; +import { type RowsClient, syncDisplayTools } from '../rows.ts'; const gatewayEnv = { MULTI_GATEWAY_TOKEN: 'test-token', @@ -11,12 +14,18 @@ const tool = 'mcp__multi-core__run_command'; type Fetched = { url: string; body: Record | undefined }; /** A gateway that offers two native tools and answers only the token it issued. */ -function gateway(on: Parameters[1]>[1], rows: Record) { +function gateway( + on: EngineOn, + rows: Record, + model = 'multi/antigravity/gemini-3.8-flash', +) { const fetched: Fetched[] = []; mock.env(on, gatewayEnv); on('session.id', () => ({ value: 'rows-session' })); on('session.cwd', () => ({ value: '/workspace' })); - on('session.model', () => ({ value: 'multi/antigravity/gemini-3.8-flash' })); + on('session.model', () => ({ value: model })); + on('classic.UserPromptSubmit', () => ({})); + on('session.end', (_$, event) => ({ sessionId: event.sessionId })); on('command.register', (_$, event) => ({ value: { command: event.name } })); on('http.fetch', (_$, event) => { const sent = event.init?.body; @@ -39,15 +48,47 @@ function gateway(on: Parameters[1]>[1], rows: Record { - const fetched = gateway(on, {}); +function registrations(on: EngineOn) { const registered: string[] = []; on('tool.register', (_$, event) => { registered.push(event.name); return { value: { tool: `mcp__multi-core__${event.name}` } }; }); + return registered; +} + +test('a session registers no display tools until a harness could run one', async ($, on) => { + mock.clock(on); + gateway(on, {}); + const registered = registrations(on); on('session.start', () => ({ cwd: '/workspace' })); - await $.session.start({ cwd: '/workspace' }); + await $.session.start({ cwd: '/workspace' } as never); + expect(registered).toEqual([]); +}); + +test('a Claude prompt registers no display tools either', async ($, on) => { + mock.clock(on); + gateway(on, {}, 'claude-sonnet-5'); + const registered = registrations(on); + await $.classic.UserPromptSubmit({ + prompt: 'hello', + permission_mode: 'default', + session_id: 'rows-session', + cwd: '/workspace', + }); + expect(registered).toEqual([]); +}); + +test('a harness prompt registers the native tools the gateway offers and acknowledges them', async ($, on) => { + mock.clock(on); + const fetched = gateway(on, {}); + const registered = registrations(on); + await $.classic.UserPromptSubmit({ + prompt: 'hello', + permission_mode: 'default', + session_id: 'rows-session', + cwd: '/workspace', + }); expect(registered.sort()).toEqual(['run_command', 'view_file']); const acknowledgement = fetched.find( (item) => item.url.endsWith('/multi/mod/display-tools') && item.body, @@ -59,9 +100,40 @@ test('session start registers the native tools the gateway offers and acknowledg sessionId: 'rows-session', registered: ['run_command', 'view_file'], }); + // The unchanged catalog registers nothing again. + registered.length = 0; + await $.classic.UserPromptSubmit({ + prompt: 'again', + permission_mode: 'default', + session_id: 'rows-session', + cwd: '/workspace', + }); + expect(registered).toEqual([]); +}); + +test('a session end forgets what was registered, so the next session registers again', async ($, on) => { + mock.clock(on); + gateway(on, {}); + const registered = registrations(on); + const prompt = { + prompt: 'hello', + permission_mode: 'default', + session_id: 'rows-session', + cwd: '/workspace', + }; + await $.classic.UserPromptSubmit(prompt); + expect(registered.length).toBe(2); + await $.session.end({ + reason: 'clear', + sessionId: 'rows-session', + resume: { id: 'rows-session' }, + } as never); + await $.classic.UserPromptSubmit({ ...prompt, session_id: 'rows-session-2' }); + expect(registered.length).toBe(4); }); test('tool.describe keeps display rows behind ToolSearch and leaves other tools alone', async ($, on) => { + mock.clock(on); gateway(on, {}); on('tool.describe', (_$, event) => ({ description: event.description })); const provider = { plugin: 'multi-core', tier: 'user' as const }; @@ -76,6 +148,7 @@ test('tool.describe keeps display rows behind ToolSearch and leaves other tools }); test('a model-originated call to a display row is denied at check and at call', async ($, on) => { + mock.clock(on); gateway(on, { toolu_row: { output: 'hi', isError: false } }); on('tool.check', () => ({ decision: 'allow' as const })); on('tool.call', () => ({ result: 'core ran it' })); @@ -100,6 +173,7 @@ test('a model-originated call to a display row is denied at check and at call', }); test('a gateway-originated row is allowed and answered with the native output', async ($, on) => { + mock.clock(on); gateway(on, { toolu_row: { output: 'hi\n', isError: false } }); const check = await $.tool.check({ tool, @@ -110,6 +184,7 @@ test('a gateway-originated row is allowed and answered with the native output', }); test('a gateway-originated row answers its native output, and a failed action as an error', async ($, on) => { + mock.clock(on); gateway(on, { '*': { output: 'cat: missing: No such file', isError: true } }); const called = await $.tool.call({ tool, @@ -177,7 +252,7 @@ function relabelled(tree: unknown, native: string, builtin: string): unknown { return JSON.parse(JSON.stringify(tree).replaceAll(`"${native}"`, `"${builtin}"`)); } -type Mount = Parameters[1]>[0]; +type Mount = TestEngine; async function header( $: Mount, @@ -229,7 +304,7 @@ async function result( } /** The engine beneath the mod: what it was asked to draw, and a stand-in for its drawing. */ -function engine(on: Parameters[1]>[1]) { +function engine(on: EngineOn) { const drawn: Array> = []; on('ui.render', (_$, event) => { drawn.push(event.props as Record); @@ -239,6 +314,7 @@ function engine(on: Parameters[1]>[1]) { } test('an agy view_file row draws exactly as Claude draws Read, but for its name', async ($, on) => { + mock.clock(on); gateway(on, {}); const input = { kind: 'Read', @@ -259,6 +335,7 @@ test('an agy view_file row draws exactly as Claude draws Read, but for its name' }); test('a shell row draws as Bash and leaves its output to the engine, compact and under ctrl+o', async ($, on) => { + mock.clock(on); gateway(on, {}); const asked = engine(on); for (const surface of surfaces) { @@ -279,6 +356,7 @@ test('a shell row draws as Bash and leaves its output to the engine, compact and }); test('a search row draws as Grep: its pattern and path, then what it found', async ($, on) => { + mock.clock(on); gateway(on, {}); for (const surface of surfaces) { const drawn = await header($, surface, 'grep_search', { @@ -297,6 +375,7 @@ test('a search row draws as Grep: its pattern and path, then what it found', asy }); test('a failed action draws as a failed built-in: error dot, `Error:` in the error colour', async ($, on) => { + mock.clock(on); gateway(on, {}); const text = (value: string) => ({ type: 'Text', props: { color: 'error' }, children: [value] }); for (const surface of surfaces) { @@ -335,6 +414,7 @@ test('a failed action draws as a failed built-in: error dot, `Error:` in the err }); test('writes and edits draw as Write and Update do: counts, numbered preview, diff', async ($, on) => { + mock.clock(on); gateway(on, {}); for (const surface of surfaces) { await header($, surface, 'write_to_file', { @@ -395,6 +475,7 @@ test('writes and edits draw as Write and Update do: counts, numbered preview, di }); test('Cursor edits draw from their result: a created file as Write, a diff or counts as Update', async ($, on) => { + mock.clock(on); gateway(on, {}); // The output a Cursor edit row carries is its `EditSuccess.diffString`, as a real run reported it. const createdDiff = @@ -456,6 +537,7 @@ test('Cursor edits draw from their result: a created file as Write, a diff or co }); test('a native tool with no built-in keeps its own argument and output', async ($, on) => { + mock.clock(on); gateway(on, {}); const asked = engine(on); for (const surface of surfaces) { @@ -469,6 +551,7 @@ test('a native tool with no built-in keeps its own argument and output', async ( }); test('an action the run never confirmed draws neither green nor red, with an unconfirmed outcome', async ($, on) => { + mock.clock(on); gateway(on, {}); for (const surface of surfaces) { const drawn = await header($, surface, 'write_to_file', { @@ -505,19 +588,39 @@ test('an action the run never confirmed draws neither green nor red, with an unc test('a failed display tool acknowledgement is retried while the catalog is unchanged', async () => { const acknowledged: unknown[] = []; let failures = 1; - const client = { + let held: MultiCoreDisplayTools = { registered: [] }; + const reply = (status: number, value: unknown) => ({ + ok: status === 200, + status, + headers: {}, + text: JSON.stringify(value), + }); + const client: RowsClient = { + wire: { + keys: { read: async () => ({}), save: async () => undefined }, + url: async () => 'http://127.0.0.1:4000', + token: async () => 'token', + sleep: () => new Promise(() => undefined), + fetch: async (_url, init) => { + if (!init.body) { + return reply(200, { revision: 41, names: ['ack_probe'] }); + } + acknowledged.push(JSON.parse(init.body).registered); + if (failures > 0) { + failures--; + // The gateway client turns an HTTP failure into a refusal, not an acknowledgement. + return reply(503, {}); + } + return reply(200, { registered: 1 }); + }, + }, sessionId: async () => 'rows-session', - catalog: async () => ({ revision: 41, names: ['ack_probe'] }), - register: async () => ({ tool: 'mcp__multi-core__ack_probe' }), - // The gateway client turns a timeout or an HTTP failure into `undefined`. - acknowledge: async (payload: Record) => { - acknowledged.push(payload.registered); - if (failures > 0) { - failures--; - return undefined; - } - return { registered: 1 }; + held: async () => held, + save: async (change) => { + held = change(held); + return held; }, + register: async () => ({ tool: 'mcp__multi-core__ack_probe' }), }; await syncDisplayTools(client); await syncDisplayTools(client); diff --git a/plugins/multi-core/hooks/tests/usage.test.ts b/plugins/multi-core/hooks/tests/usage.test.ts index b058a71..70c0582 100644 --- a/plugins/multi-core/hooks/tests/usage.test.ts +++ b/plugins/multi-core/hooks/tests/usage.test.ts @@ -1,12 +1,13 @@ import { expect, mock, test } from 'claude-code/testing'; test('usage command reads only the current session without model dispatch', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', }); on('session.id', () => ({ value: 'session/one' })); - on('ui.open', () => ({ value: undefined })); + on('ui.open', () => ({ value: { isPlaced: true as const } })); on('ui.invalidate', () => ({ value: undefined })); on('http.fetch', (_$, event) => { expect(event.url).toBe( @@ -49,19 +50,20 @@ test('usage command reads only the current session without model dispatch', asyn }, }; }); - const result = await $.command.run({ command: 'multi-usage', args: '' }); + const result = await $.command.run({ command: 'multi-usage', args: '' } as never); expect(result.text).toBeUndefined(); const rendered = await $.ui.render({ surface: 'terminal', component: 'Pane', requestId: 'multi-usage', - props: { title: 'Multi usage', isFocused: true, bodyColumns: 80 }, + props: { title: 'Multi usage', isFocused: true, bodyColumns: 80 } as never, }); expect(JSON.stringify(rendered)).toContain('usage-view.ts'); expect(JSON.stringify(rendered)).toContain('OpenAI'); }); test('worker completion awaits accounting and preserves the engine answer', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', @@ -119,26 +121,32 @@ test('worker completion awaits accounting and preserves the engine answer', asyn }); test('usage without a gateway reports unavailable without opening a pane', async ($, on) => { + mock.clock(on); mock.env(on, {}); on('session.id', () => ({ value: 'session' })); - const result = await $.command.run({ command: 'multi-usage', args: '' }); + const result = await $.command.run({ command: 'multi-usage', args: '' } as never); expect(result.text).toContain('unavailable'); }); test('ordinary prompt submission no longer runs the quota advisory', async ($, on) => { + mock.clock(on); on('prompt.submit', (_$, event) => ({ text: event.text, context: event.context })); - const result = await $.prompt.submit({ text: 'Choose a worker', context: ['Existing guidance'] }); + const result = await $.prompt.submit({ + text: 'Choose a worker', + context: ['Existing guidance'], + } as never); expect(result.text).toBe('Choose a worker'); expect(result.context).toEqual(['Existing guidance']); }); test('receipts show a worker context beside what it consumed', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', }); on('session.id', () => ({ value: 'session' })); - on('ui.open', () => ({ value: undefined })); + on('ui.open', () => ({ value: { isPlaced: true as const } })); on('ui.invalidate', () => ({ value: undefined })); on('http.fetch', (_$, event) => { const body = event.url.includes('/multi/mod/receipts') @@ -168,13 +176,13 @@ test('receipts show a worker context beside what it consumed', async ($, on) => : { updatedAt: '2026-09-22T12:00:00Z', providers: [] }; return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify(body) } }; }); - await $.command.run({ command: 'multi-usage', args: '' }); + await $.command.run({ command: 'multi-usage', args: '' } as never); const pane = await $.ui.mount({ plugin: 'multi-core', surface: 'terminal', component: 'Pane', requestId: 'multi-usage', - props: { title: 'Multi usage', isFocused: true, bodyColumns: 120 }, + props: { title: 'Multi usage', isFocused: true, bodyColumns: 120 } as never, }); await pane.resize({ columns: 120, rows: 20, in: 'usage' }); await pane.press({ key: 'receipts' }); @@ -186,3 +194,143 @@ test('receipts show a worker context beside what it consumed', async ($, on) => ); await pane.unmount(); }); + +test('a pane that waits undrawn is announced and its numbers are shown as text', async ($, on) => { + mock.clock(on); + mock.env(on, { + MULTI_GATEWAY_TOKEN: 'secret', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', + }); + on('session.id', () => ({ value: 'session' })); + on('ui.open', () => ({ value: { isPlaced: false as const, reason: 'no surface places panes' } })); + const toasts: string[] = []; + on('ui.toast', (_$, event) => { + toasts.push(event.text); + return { value: undefined }; + }); + on('http.fetch', () => ({ + value: { + ok: true, + status: 200, + headers: {}, + text: JSON.stringify({ + updatedAt: '2026-10-01T00:00:00Z', + providers: [ + { id: 'openai', name: 'OpenAI', status: 'ready', summary: '10 tokens', details: [] }, + ], + }), + }, + })); + const result = await $.command.run({ command: 'multi-usage', args: '' } as never); + expect(result.text).toBe('OpenAI: 10 tokens'); + expect(toasts).toEqual(['Multi usage: no surface places panes']); +}); + +const dashboard = { + updatedAt: 'today', + providers: [ + { + id: 'cursor', + name: 'Cursor', + status: 'ready', + summary: '$0.00 charged', + details: ['native spend'], + }, + ], +}; + +/** A gateway serving the dashboard, and receipts for one worker, recording every URL read. */ +function usageGateway(on: Parameters[0], reads: string[]) { + mock.clock(on); + mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000' }); + on('session.id', () => ({ value: 'session/one' })); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + on('ui.open', () => ({ value: { isPlaced: true as const } })); + on('ui.render', () => ({ type: 'engine', ref: 0 }) as never); + on('http.fetch', (_$, event) => { + reads.push(event.url.replace(/^.*\/multi/, '/multi')); + const body = event.url.includes('/receipts') + ? { + receipts: [ + { + agentId: 'worker', + outcome: 'completed', + time: 'today', + requests: 1, + usage: { input_tokens: 2, output_tokens: 3 }, + }, + ], + } + : dashboard; + return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify(body) } }; + }); +} + +test('refresh and receipts keep the providers, and another pane is not answered', async ($, on) => { + const reads: string[] = []; + usageGateway(on, reads); + await $.command.run({ command: 'multi-usage', args: '' } as never); + const pane = await $.ui.mount({ + plugin: 'multi-core', + surface: 'terminal', + component: 'Pane', + requestId: 'multi-usage', + props: { title: 'Multi usage', isFocused: true, bodyColumns: 100 } as never, + }); + await pane.resize({ columns: 100, rows: 20, in: 'usage' }); + await pane.press({ key: 'refresh' }); + expect(reads.at(-1)).toBe('/multi/mod/usage?sessionId=session%2Fone&view=providers&refresh=true'); + expect(await pane.find({ type: 'Text', text: /native spend|Cursor/, in: 'usage' })).toBeDefined(); + await pane.press({ key: 'receipts' }); + expect(reads.at(-1)).toBe('/multi/mod/receipts?sessionId=session%2Fone'); + expect((await pane.find({ type: 'Text', text: /^worker/, in: 'usage' }))?.text).toContain( + 'completed', + ); + // The providers survive both messages. + await pane.press({ key: 'cursor' }); + expect(await pane.find({ type: 'Text', text: /native spend/, in: 'usage' })).toBeDefined(); + await pane.unmount(); +}); + +test('quota advice is opt-in, session scoped and ends with the session', async ($, on) => { + const reads: string[] = []; + usageGateway(on, reads); + on('tool.call', () => ({ result: 'launched' })); + on('session.end', (_$, event) => ({ sessionId: event.sessionId })); + on('ui.status', () => ({ value: undefined })); + const agentCall = { + tool: 'Agent', + tool_use_id: 'call-1', + prompt: 'work', + subagent_type: 'cursor', + }; + const advice = async () => { + reads.length = 0; + const result = await $.tool.call(agentCall as never); + return { reads: reads.filter((url) => url.startsWith('/multi/mod/usage')), result }; + }; + // Off by default: delegating reads no quota. + expect((await advice()).reads).toEqual([]); + await $.command.run({ command: 'multi-usage', args: '' } as never); + const pane = await $.ui.mount({ + plugin: 'multi-core', + surface: 'terminal', + component: 'Pane', + requestId: 'multi-usage', + props: { title: 'Multi usage', isFocused: true, bodyColumns: 100 } as never, + }); + await pane.press({ key: 'quota-advice' }); + expect((await advice()).reads.length).toBe(1); + // Only an Agent call is advised. + reads.length = 0; + await $.tool.call({ tool: 'Read', tool_use_id: 'call-2', file_path: '/a' } as never); + expect(reads.filter((url) => url.startsWith('/multi/mod/usage'))).toEqual([]); + await pane.press({ key: 'quota-advice' }); + expect((await advice()).reads).toEqual([]); + await pane.press({ key: 'quota-advice' }); + expect((await advice()).reads.length).toBe(1); + // The session's end forgets the opt-in. + await $.session.end({ reason: 'clear', sessionId: 'session/one', resume: { id: 'x' } } as never); + expect((await advice()).reads).toEqual([]); + await pane.unmount(); +}); diff --git a/plugins/multi-core/hooks/tests/worker-rows.test.ts b/plugins/multi-core/hooks/tests/worker-rows.test.ts index 5e58c1a..9383f46 100644 --- a/plugins/multi-core/hooks/tests/worker-rows.test.ts +++ b/plugins/multi-core/hooks/tests/worker-rows.test.ts @@ -1,7 +1,9 @@ -import { expect, test } from 'claude-code/testing'; +import type { On as EngineOn } from 'claude-code'; +import type { Engine as TestEngine } from 'claude-code/testing'; +import { expect, mock, test } from 'claude-code/testing'; -type On = Parameters[1]>[1]; -type Engine = Parameters[1]>[0]; +type On = EngineOn; +type Engine = TestEngine; const surfaces = ['terminal', 'desktop'] as const; @@ -92,6 +94,7 @@ const input = { }; test('a running provider worker row shows its provider and resolved model', async ($, on) => { + mock.clock(on); gateway(on); const drawn = engine(on); await $.agent.spawn({ @@ -113,6 +116,7 @@ test('a running provider worker row shows its provider and resolved model', asyn }); test('a completed worker row reads the model from its result after a reload', async ($, on) => { + mock.clock(on); gateway(on); const drawn = engine(on); for (const surface of surfaces) { @@ -127,6 +131,7 @@ test('a completed worker row reads the model from its result after a reload', as }); test('Claude agents and other tools keep their rows unlabelled', async ($, on) => { + mock.clock(on); gateway(on); const drawn = engine(on); await agentRow($, 'terminal', { @@ -146,6 +151,7 @@ test('Claude agents and other tools keep their rows unlabelled', async ($, on) = }); test('a worker notification shows its provider and model, and ctrl+o keeps the full row', async ($, on) => { + mock.clock(on); gateway(on); const drawn = engine(on); await $.agent.spawn({ @@ -174,118 +180,20 @@ test('a worker notification shows its provider and model, and ctrl+o keeps the f } }); -test('turn.step rewrites complete Agent tool inputs with provider and model while preserving other chunks', async ($, on) => { +test("turn.step leaves an Agent call's streamed input exactly as the model wrote it", async ($, on) => { + mock.clock(on); gateway(on); - on('turn.step', async function* (_$, event) { - yield { kind: 'text', index: 0, text: 'launching workers' }; - yield { kind: 'tool', index: 1, id: 'toolu_cursor', name: 'Agent' }; - yield { - kind: 'input', - index: 1, - json: JSON.stringify({ - description: 'Fix tests', - prompt: 'task 1', - subagent_type: 'multi-cursor', - model: 'composer-2.5', - }), - }; - yield { kind: 'tool', index: 2, id: 'toolu_agy', name: 'Agent' }; - yield { - kind: 'input', - index: 2, - json: JSON.stringify({ - description: 'Review code', - prompt: 'task 2', - subagent_type: 'multi-antigravity', - model: 'gemini-3.8-flash[1m]', - }), - }; - yield { kind: 'tool', index: 3, id: 'toolu_builtin', name: 'Agent' }; - yield { - kind: 'input', - index: 3, - json: JSON.stringify({ - description: 'Search repo', - prompt: 'search', - subagent_type: 'Explore', - }), - }; - yield { kind: 'tool', index: 4, id: 'toolu_read', name: 'Read' }; - yield { - kind: 'input', - index: 4, - json: JSON.stringify({ - file_path: '/workspace/src/index.ts', - }), - }; - yield { kind: 'input', index: 1, json: '{"partial' }; - return { - turnId: event.turnId, - index: event.index, - answer: 'launching workers', - toolUses: [], - stopReason: 'end_turn', - usage: null, - }; + const written = JSON.stringify({ + description: 'Fix tests', + prompt: 'task 1', + subagent_type: 'multi-cursor', + model: 'composer-2.5', }); - - const chunks: Array> = []; - for await (const chunk of $.turn.step({ - turnId: 't1', - index: 0, - model: 'claude-sonnet-5', - messageCount: 1, - })) { - chunks.push(chunk as Record); - } - - expect(chunks[0]).toEqual({ kind: 'text', index: 0, text: 'launching workers' }); - expect(chunks[1]).toEqual({ kind: 'tool', index: 1, id: 'toolu_cursor', name: 'Agent' }); - - const cursorInput = JSON.parse(String(chunks[2]?.json)) as Record; - expect(cursorInput.description).toBe('Fix tests · Cursor · composer-2.5'); - expect(cursorInput.prompt).toBe('task 1'); - expect(cursorInput.subagent_type).toBe('multi-cursor'); - expect(cursorInput.model).toBe('composer-2.5'); - - expect(chunks[3]).toEqual({ kind: 'tool', index: 2, id: 'toolu_agy', name: 'Agent' }); - - const agyInput = JSON.parse(String(chunks[4]?.json)) as Record; - expect(agyInput.description).toBe('Review code · Antigravity · gemini-3.8-flash'); - expect(agyInput.prompt).toBe('task 2'); - expect(agyInput.subagent_type).toBe('multi-antigravity'); - - expect(chunks[5]).toEqual({ kind: 'tool', index: 3, id: 'toolu_builtin', name: 'Agent' }); - const exploreInput = JSON.parse(String(chunks[6]?.json)) as Record; - expect(exploreInput.description).toBe('Search repo'); - expect(exploreInput.prompt).toBe('search'); - - expect(chunks[7]).toEqual({ kind: 'tool', index: 4, id: 'toolu_read', name: 'Read' }); - expect(JSON.parse(String(chunks[8]?.json))).toEqual({ file_path: '/workspace/src/index.ts' }); - - expect(chunks[9]?.json).toBe('{"partial'); -}); - -test('turn.step uses provider name only when the worker model is unresolvable', async ($, on) => { - on('env.get', (_$, event) => ({ - value: event.name === 'MULTI_GATEWAY_TOKEN' ? 'token' : 'http://127.0.0.1:4000', - })); - on('session.id', () => ({ value: 's' })); - on('session.cwd', () => ({ value: '/workspace' })); - on('http.fetch', () => ({ - value: { ok: false, status: 503, headers: {}, text: 'Gateway unavailable' }, - })); on('turn.step', async function* (_$, event) { - yield { kind: 'tool', index: 0, id: 'toolu_grok', name: 'Agent' }; - yield { - kind: 'input', - index: 0, - json: JSON.stringify({ - description: 'Audit codebase', - prompt: 'audit', - subagent_type: 'multi-grok', - }), - }; + yield { kind: 'tool', index: 1, id: 'toolu_cursor', name: 'Agent' }; + // Partial JSON, as a stream delivers it: nothing parses or rewrites it. + yield { kind: 'input', index: 1, json: written.slice(0, 20) }; + yield { kind: 'input', index: 1, json: written.slice(20) }; return { turnId: event.turnId, index: event.index, @@ -295,18 +203,16 @@ test('turn.step uses provider name only when the worker model is unresolvable', usage: null, }; }); - - const chunks: Array> = []; + const json: string[] = []; for await (const chunk of $.turn.step({ - turnId: 't2', + turnId: 't1', index: 0, model: 'claude-sonnet-5', messageCount: 1, })) { - chunks.push(chunk as Record); + if (chunk.kind === 'input') { + json.push(chunk.json); + } } - - const grokInput = JSON.parse(String(chunks[1]?.json)) as Record; - expect(grokInput.description).toBe('Audit codebase · Grok'); - expect(grokInput.prompt).toBe('audit'); + expect(json.join('')).toBe(written); }); diff --git a/plugins/multi-core/hooks/tests/workers.test.ts b/plugins/multi-core/hooks/tests/workers.test.ts index d62dff5..25f136d 100644 --- a/plugins/multi-core/hooks/tests/workers.test.ts +++ b/plugins/multi-core/hooks/tests/workers.test.ts @@ -1,4 +1,6 @@ -import { expect, test } from 'claude-code/testing'; +import type { On as EngineOn } from 'claude-code'; +import type { Engine as TestEngine } from 'claude-code/testing'; +import { expect, mock, test } from 'claude-code/testing'; import { register } from '../workers.ts'; test('registers the worker admission hook', () => { @@ -6,6 +8,7 @@ test('registers the worker admission hook', () => { }); test('agent.offer hides an unsupported worker before dispatch', async ($, on) => { + mock.clock(on); on('env.get', (_$, event) => ({ value: event.name === 'MULTI_GATEWAY_TOKEN' ? 'token' : 'http://127.0.0.1:4000', })); @@ -31,6 +34,7 @@ test('agent.offer hides an unsupported worker before dispatch', async ($, on) => }); test('agent.offer preserves a known catalog worker', async ($, on) => { + mock.clock(on); on('env.get', (_$, event) => ({ value: event.name === 'MULTI_GATEWAY_TOKEN' ? 'token' : 'http://127.0.0.1:4000', })); @@ -51,6 +55,7 @@ test('agent.offer preserves a known catalog worker', async ($, on) => { }); test('worker spawn is denied when gateway admission is unavailable', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -64,12 +69,13 @@ test('worker spawn is denied when gateway admission is unavailable', async ($, o prompt: 'task', subagentType: 'cursor', model: 'multi/cursor/auto', - }); + } as never); expect(typeof result.deny).toBe('string'); expect(started).toBe(false); }); test('harness spawn remains dormant when gateway is not configured', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: undefined })); let started = false; on('agent.spawn', () => { @@ -80,35 +86,38 @@ test('harness spawn remains dormant when gateway is not configured', async ($, o prompt: 'task', subagentType: 'cursor-auto', model: 'multi/cursor/auto', - }); + } as never); expect(result.agentId).toBe('worker'); expect(started).toBe(true); }); test('Claude-loop spawn proceeds when gateway is not configured', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: undefined })); on('agent.spawn', () => ({ model: 'multi/openai/gpt-6-luna', agentId: 'worker' })); const result = await $.agent.spawn({ prompt: 'task', subagentType: 'openai-luna', model: 'multi/openai/gpt-6-luna', - }); + } as never); expect(result.agentId).toBe('worker'); }); for (const model of ['multi/openai/gpt-6-luna', 'multi/zen/gpt-6-luna']) { test(`${model} spawn survives an active gateway outage`, async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); on('http.fetch', () => ({ value: { ok: false, status: 503, headers: {}, text: '' } })); on('agent.spawn', () => ({ model, agentId: 'worker' })); - const result = await $.agent.spawn({ prompt: 'task', subagentType: 'direct', model }); + const result = await $.agent.spawn({ prompt: 'task', subagentType: 'direct', model } as never); expect(result.agentId).toBe('worker'); }); } test('known catalog harness with omitted event model is admitted through worker-model', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -129,11 +138,12 @@ test('known catalog harness with omitted event model is admitted through worker- return { value: { ok: true, status: 200, headers: {}, text: '{"accepted":true}' } }; }); on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'worker' })); - const result = await $.agent.spawn({ prompt: 'task', subagentType: 'cursor-auto' }); + const result = await $.agent.spawn({ prompt: 'task', subagentType: 'cursor-auto' } as never); expect(result.agentId).toBe('worker'); }); test('a refused spawn shows the gateway reason instead of the generic denial', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -156,12 +166,13 @@ test('a refused spawn shows the gateway reason instead of the generic denial', a prompt: 'task', subagentType: 'cursor', model: 'multi/cursor/auto', - }); + } as never); expect(result.deny).toContain('Claude permission mode is unavailable; submit a new prompt'); expect(started).toBe(false); }); test('a non-JSON gateway refusal still names the status in the denial', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -173,11 +184,12 @@ test('a non-JSON gateway refusal still names the status in the denial', async ($ prompt: 'task', subagentType: 'cursor', model: 'multi/cursor/auto', - }); + } as never); expect(result.deny).toContain('gateway 502: upstream failure'); }); test('a refused reply cannot acknowledge a spawn through its body', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -201,13 +213,14 @@ test('a refused reply cannot acknowledge a spawn through its body', async ($, on prompt: 'task', subagentType: 'cursor', model: 'multi/cursor/auto', - }); + } as never); expect(result.deny).toContain('policy refused'); expect(result.deny).toContain('issues/new?template=bug_report.yml'); expect(started).toBe(false); }); test('an unclassified refused offer remains available for the engine to decide', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -225,7 +238,7 @@ test('an unclassified refused offer remains available for the engine to decide', expect(result.isOffered).toBe(true); }); -type On = Parameters[1]>[1]; +type On = EngineOn; /** A gateway whose catalog runs Cursor's default and Composer 2.5, and refuses the rest. */ function catalogGateway(on: On) { @@ -263,6 +276,7 @@ function catalogGateway(on: On) { } test('an Agent call names the model: tool.call takes it out before the schema check', async ($, on) => { + mock.clock(on); catalogGateway(on); const reached: Array> = []; on('tool.call', (_$, event) => { @@ -293,6 +307,7 @@ test('an Agent call names the model: tool.call takes it out before the schema ch }); test('agent.spawn resolves a provider worker model and runs the rewrite', async ($, on) => { + mock.clock(on); const sent = catalogGateway(on); const spawned: Array = []; const descriptions: string[] = []; @@ -306,7 +321,7 @@ test('agent.spawn resolves a provider worker model and runs the rewrite', async description: 'Fix tests', subagentType: 'multi-cursor', model: 'composer-2.5', - }); + } as never); expect(named.agentId).toBe('agent-1'); expect(spawned[0]).toBe('multi/cursor/composer-2.5'); // The running-agents list and the notification show the task's description. @@ -317,11 +332,12 @@ test('agent.spawn resolves a provider worker model and runs the rewrite', async const admission = sent.filter((item) => item.url.endsWith('/multi/mod/worker')).at(-1); expect(admission?.body?.model).toBe('multi/cursor/composer-2.5'); // No model: the provider default. - await $.agent.spawn({ prompt: 'task', subagentType: 'multi-cursor' }); + await $.agent.spawn({ prompt: 'task', subagentType: 'multi-cursor' } as never); expect(spawned[1]).toBe('multi/cursor/default'); }); test('agent.spawn refuses an unknown provider model with the provider models named', async ($, on) => { + mock.clock(on); catalogGateway(on); let started = false; on('agent.spawn', () => { @@ -332,7 +348,7 @@ test('agent.spawn refuses an unknown provider model with the provider models nam prompt: 'task', subagentType: 'multi-cursor', model: 'kimi-k3', - }); + } as never); expect(result.deny).toBe( 'multi-cursor has no model "kimi-k3". Cursor models: default, composer-2.5. Omit model for the default, default.', ); @@ -340,6 +356,7 @@ test('agent.spawn refuses an unknown provider model with the provider models nam }); test('a provider worker is refused when the gateway cannot resolve its model', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -355,29 +372,42 @@ test('a provider worker is refused when the gateway cannot resolve its model', a prompt: 'task', subagentType: 'multi-zen', model: 'kimi-k3', - }); + } as never); expect(result.deny).toContain('The Multi gateway did not resolve the multi-zen model.'); expect(started).toBe(false); }); -test('the Agent tool description tells every model how to pick a provider model', async ($, on) => { +const provider = { plugin: 'engine', tier: 'core' } as const; + +test('the Agent tool description names provider models only while a provider worker is offered', async ($, on) => { + mock.clock(on); catalogGateway(on); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + on('ui.invalidate', () => ({ value: undefined })); on('tool.describe', (_$, event) => ({ description: event.description })); - const described = await $.tool.describe({ - tool: 'Agent', - description: 'Launch a new agent.', - provider: { plugin: 'engine', tier: 'core' }, - }); - expect(described.description).toContain('For a multi-* agent type'); - expect(described.description.startsWith('Launch a new agent.')).toBe(true); + let offered = true; + on('agent.offer', () => ({ isOffered: offered })); + const describe = async () => + (await $.tool.describe({ tool: 'Agent', description: 'Launch a new agent.', provider })) + .description; + const offer = (agent: string) => + $.agent.offer({ agent, description: agent, source: 'plugin', provider }); + // No provider type offered: nothing to explain, so the description is the engine's own. + expect(await describe()).toBe('Launch a new agent.'); + await offer('Explore'); + expect(await describe()).toBe('Launch a new agent.'); + await offer('multi-cursor'); + const described = await describe(); + expect(described).toContain('For a multi-* agent type'); + expect(described.startsWith('Launch a new agent.')).toBe(true); + // The offer is withdrawn (settings the harness cannot honour): the paragraph goes too. + offered = false; + await offer('multi-cursor'); + expect(await describe()).toBe('Launch a new agent.'); }); /** The gateway classifies the type at offer, as it does before the model can name it. */ -async function offerClaudeType( - $: Parameters[1]>[0], - on: On, - agent: string, -) { +async function offerClaudeType($: TestEngine, on: On, agent: string) { on('agent.offer', () => ({ isOffered: true })); on('session.model', () => ({ value: 'claude-sonnet-5' })); await $.agent.offer({ @@ -411,6 +441,7 @@ function claudeGateway(on: On) { } test('a native Claude subagent spawns without any gateway call', async ($, on) => { + mock.clock(on); const sent = claudeGateway(on); on('agent.spawn', () => ({ model: 'haiku', agentId: 'native' })); await offerClaudeType($, on, 'Explore'); @@ -420,12 +451,13 @@ test('a native Claude subagent spawns without any gateway call', async ($, on) = subagentType: 'Explore', model: 'haiku', parentModel: 'claude-sonnet-5', - }); + } as never); expect(result.agentId).toBe('native'); expect(sent.length).toBe(before); }); test('a subagent of a Multi model session still reaches the gateway', async ($, on) => { + mock.clock(on); const sent = claudeGateway(on); on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'inherited' })); await offerClaudeType($, on, 'Explore'); @@ -434,18 +466,24 @@ test('a subagent of a Multi model session still reaches the gateway', async ($, prompt: 'task', subagentType: 'Explore', parentModel: 'multi/cursor/auto', - }); + } as never); expect(sent.length).toBeGreaterThan(before); }); test('a type not offered as Claude-loop keeps gateway admission', async ($, on) => { + mock.clock(on); const sent = catalogGateway(on); on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'worker' })); - await $.agent.spawn({ prompt: 'task', subagentType: 'cursor-auto', parentModel: 'claude' }); + await $.agent.spawn({ + prompt: 'task', + subagentType: 'cursor-auto', + parentModel: 'claude', + } as never); expect(sent.some((item) => item.url.endsWith('/multi/mod/worker-model'))).toBe(true); }); test('a Claude tool call posts nothing to the gateway', async ($, on) => { + mock.clock(on); const sent = catalogGateway(on); on('session.model', () => ({ value: 'claude-sonnet-5' })); on('tool.call', () => ({ result: 'ran' })); @@ -454,6 +492,7 @@ test('a Claude tool call posts nothing to the gateway', async ($, on) => { }); test('a provider model tool call is attributed to the gateway reviewer', async ($, on) => { + mock.clock(on); const sent = catalogGateway(on); on('session.model', () => ({ value: 'multi/openai/gpt-6-astra' })); on('tool.call', () => ({ result: 'ran' })); @@ -466,3 +505,184 @@ test('a provider model tool call is attributed to the gateway reviewer', async ( cwd: '/workspace', }); }); + +/** A gateway whose offer answers name the model each agent type runs on. */ +function pinnedGateway( + on: On, + models: Record, + bodies: Array> = [], +) { + const sent: string[] = []; + on('env.get', (_$, event) => ({ + value: event.name === 'MULTI_GATEWAY_TOKEN' ? 'token' : 'http://127.0.0.1:4000', + })); + on('session.id', () => ({ value: 's' })); + on('session.cwd', () => ({ value: '/workspace' })); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + on('ui.invalidate', () => ({ value: undefined })); + on('http.fetch', (_$, event) => { + sent.push(event.url); + const body = event.init?.body ? JSON.parse(String(event.init.body)) : {}; + bodies.push(body); + const model = models[String(body.agent ?? body.subagentType)] ?? 'claude-sonnet-5'; + return { + value: { + ok: true, + status: 200, + headers: {}, + text: JSON.stringify({ + execution: 'claude', + known: true, + isOffered: true, + accepted: true, + model, + }), + }, + }; + }); + return sent; +} + +test('a custom agent pinned to a provider model keeps gateway registration', async ($, on) => { + mock.clock(on); + const sent = pinnedGateway(on, { reviewer: 'multi/openai/gpt-6-astra' }); + on('agent.offer', () => ({ isOffered: true })); + on('agent.spawn', () => ({ model: 'multi/openai/gpt-6-astra', agentId: 'pinned' })); + on('classic.SubagentStart', () => ({})); + await $.agent.offer({ agent: 'reviewer', description: 'r', source: 'projectSettings', provider }); + await $.agent.offer({ agent: 'scout', description: 's', source: 'projectSettings', provider }); + // A definition on a Claude model stays native. + const native = sent.length; + await $.classic.SubagentStart({ + agent_id: 'plain', + agent_type: 'scout', + session_id: 's', + cwd: '/workspace', + }); + expect(sent.length).toBe(native); + const before = sent.length; + // The spawn event carries no model for the definition's pin: the offer classified it. + await $.agent.spawn({ + prompt: 'task', + subagentType: 'reviewer', + parentModel: 'claude-sonnet-5', + } as never); + expect(sent.slice(before).some((url) => url.endsWith('/multi/mod/worker'))).toBe(true); + const registered = sent.length; + await $.classic.SubagentStart({ + agent_id: 'pinned', + agent_type: 'reviewer', + session_id: 's', + cwd: '/workspace', + }); + expect(sent.length).toBeGreaterThan(registered); +}); + +test('a built-in agent on a Claude session is classified without a gateway call', async ($, on) => { + mock.clock(on); + const sent = pinnedGateway(on, {}); + on('agent.offer', () => ({ isOffered: true })); + on('agent.spawn', () => ({ model: 'haiku', agentId: 'native' })); + await $.agent.offer({ agent: 'Explore', description: 'e', source: 'built-in', provider }); + expect(sent).toEqual([]); + await $.agent.spawn({ + prompt: 'task', + subagentType: 'Explore', + parentModel: 'claude-sonnet-5', + } as never); + expect(sent).toEqual([]); +}); + +test('a subagent registers with the gateway while any loop of the session runs a Multi model', async ($, on) => { + mock.clock(on); + const sent = pinnedGateway(on, {}); + on('agent.offer', () => ({ isOffered: true })); + on('classic.SubagentStart', () => ({})); + on('ui.status', () => ({ value: undefined })); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'ok' }; + return { + turnId: event.turnId, + index: 0, + answer: 'ok', + toolUses: [], + stopReason: 'end_turn', + usage: null, + }; + }); + await $.agent.offer({ agent: 'Explore', description: 'e', source: 'built-in', provider }); + const start = { + agent_id: 'child', + agent_type: 'Explore', + session_id: 's', + cwd: '/workspace', + }; + const before = sent.length; + await $.classic.SubagentStart(start); + expect(sent.length).toBe(before); + // A provider worker is stepping: a nested subagent may inherit its model. + for await (const _chunk of $.turn.step({ + turnId: 't', + agentId: 'provider-worker', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // Observe the step. + } + const afterStep = sent.length; + await $.classic.SubagentStart(start); + expect(sent.slice(afterStep).some((url) => url.endsWith('/multi/mod/worker'))).toBe(true); +}); + +test('a provider worker tool call is attributed to the reviewer with its agent id', async ($, on) => { + mock.clock(on); + const bodies: Array> = []; + const sent = pinnedGateway(on, {}, bodies); + on('tool.call', () => ({ result: 'ran' })); + on('ui.status', () => ({ value: undefined })); + on('classic.UserPromptSubmit', () => ({})); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'ok' }; + return { + turnId: event.turnId, + index: 0, + answer: 'ok', + toolUses: [], + stopReason: 'end_turn', + usage: null, + }; + }); + await $.classic.UserPromptSubmit({ + prompt: 'go', + permission_mode: 'auto', + session_id: 's', + cwd: '/workspace', + }); + for await (const _chunk of $.turn.step({ + turnId: 't', + agentId: 'provider-worker', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // The hooks learn the worker's model from its step. + } + sent.length = 0; + bodies.length = 0; + await $.tool.call({ tool: 'Bash', tool_use_id: 'toolu_w', agentId: 'provider-worker' } as never); + const index = sent.findIndex((url) => url.endsWith('/multi/permission')); + expect(index).toBeGreaterThanOrEqual(0); + // The Claude session's own mode (the engine hands a tool call none) comes from its snapshot. + expect(bodies[index]).toEqual({ + session_id: 's', + tool_use_id: 'toolu_w', + tool_name: 'Bash', + cwd: '/workspace', + permission_mode: 'auto', + }); + // The main loop is on Claude: its own call costs the gateway nothing. + sent.length = 0; + await $.tool.call({ tool: 'Bash', tool_use_id: 'toolu_main' } as never); + expect(sent).toEqual([]); +}); diff --git a/plugins/multi-core/hooks/usage.ts b/plugins/multi-core/hooks/usage.ts index 3d50248..861bd66 100644 --- a/plugins/multi-core/hooks/usage.ts +++ b/plugins/multi-core/hooks/usage.ts @@ -1,40 +1,54 @@ import type { EngineInterface, Register } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { MultiCoreUsagePane } from '../types/multi-core.d.ts'; +import { accepted, getJson, type Wire } from './gateway.ts'; import { quotaAdvice } from './quota-advice.ts'; +import { withBounded } from './state.ts'; import type { UsagePaneProps } from './usage-view.ts'; -const panes = new Map(); -const advisorySessions = new Set(); +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const advisorySessions = atom( + { plugin: 'multi-core', key: 'advisorySessions' } as const, + [] as string[], +); +const usagePanes = atom( + { plugin: 'multi-core', key: 'usagePanes' } as const, + {} as Record, +); -export function forgetUsageSession(session: string) { - advisorySessions.delete(session); - panes.delete(session); -} +/** A provider dashboard can take a few seconds; the command's own budget is 10 s. */ +const usageTimeoutMs = 8500; +const maximumPanes = 16; + +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); -export const register = ( - on: Parameters[0], - _options?: Parameters[1], - agentModels: ReadonlyMap = new Map(), -) => { +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, +}); + +export const register = (on: Parameters[0], _options?: Parameters[1]) => { on('classic.PreToolUse', async ($, event, next) => { - await attributeToolCall($, event, agentModels); - // `Task` is the Agent tool's legacy name; a worker's own spawn carries its agentId. + // `Task` is the Agent tool's legacy name. const tool: string = event.tool; - if ( - (tool !== 'Agent' && tool !== 'Task') || - (event as { agentId?: string }).agentId !== undefined - ) { + if (tool !== 'Agent' && tool !== 'Task') { return next(event); } const session = await $.session.id(); - if (!advisorySessions.has(session)) { + if (!(await read($, advisorySessions)).includes(session)) { return next(event); } - const snapshot = dashboard( - await request($, `/multi/mod/usage?sessionId=${encodeURIComponent(session)}&view=providers`), - ); + const snapshot = dashboard(accepted(await fetchUsage($, session))); const result = await next(event); // Preserve all permission decisions and tool arguments, including on lookup failure. - if (!advisorySessions.has(session)) { + if (!(await read($, advisorySessions)).includes(session)) { return result; } return { @@ -47,39 +61,46 @@ export const register = ( return { text: 'Use /multi-usage without arguments.' }; } const session = await $.session.id(); - const response = dashboard( - await request($, `/multi/mod/usage?sessionId=${encodeURIComponent(session)}&view=providers`), - ); + const response = dashboard(accepted(await fetchUsage($, session))); if (!response) { return { text: 'Multi usage is unavailable. Launch this session with claude-multi.' }; } - if (panes.size >= 16) { - panes.clear(); - } - panes.set(session, { ...response, quotaAdviceEnabled: advisorySessions.has(session) }); + const quotaAdviceEnabled = (await read($, advisorySessions)).includes(session); + // The pane draws from this state: writing it redraws an open pane without an invalidate. + await update($, usagePanes, (held) => + withBounded( + held, + session, + { ...response, quotaAdviceEnabled }, + maximumPanes, + ), + ); + const summary = response.providers + .map((provider) => `${provider.name}: ${provider.summary}`) + .join('\n'); try { - await $.ui.open({ + const opened = await $.ui.open({ id: 'multi-usage', title: 'Multi usage', focus: true, closeOnEscape: true, rows: 20, }); - $.ui.invalidate('ui.render'); - return {}; + if (opened.isPlaced) { + return {}; + } + // The pane waits undrawn (a surface that places no panes): say so, and show the numbers. + $.ui.toast(`Multi usage: ${opened.reason}`); + return { text: summary }; } catch { - return { - text: response.providers - .map((provider) => `${provider.name}: ${provider.summary}`) - .join('\n'), - }; + return { text: summary }; } }); on('ui.render', { component: 'Pane' }, async ($, event, next) => { if (event.requestId !== 'multi-usage' || event.surface !== 'terminal') { return next(event); } - const props = panes.get(await $.session.id()); + const props = (await read($, usagePanes))[await $.session.id()]; if (!props) { return next(event); } @@ -95,32 +116,46 @@ export const register = ( return next(event); } const session = await $.session.id(); - const previous = panes.get(session); + const previous = (await read($, usagePanes))[session]; if (!previous) { return next(event); } if (action === 'toggle-quota-advice') { - const enabled = !advisorySessions.has(session); - if (enabled) { - advisorySessions.add(session); - } else { - advisorySessions.delete(session); - } + const enabled = !(await read($, advisorySessions)).includes(session); + await update($, advisorySessions, (held) => + enabled ? [...held, session] : held.filter((id) => id !== session), + ); const props = { ...previous, quotaAdviceEnabled: enabled }; - panes.set(session, props); + await update($, usagePanes, (held) => + withBounded(held, session, props, maximumPanes), + ); return { props }; } - const route = + const response = action === 'refresh' - ? `/multi/mod/usage?view=providers&refresh=true&sessionId=${encodeURIComponent(session)}` - : `/multi/mod/receipts?sessionId=${encodeURIComponent(session)}`; - const response = await request($, route); - const props = updatePane(previous, action, response); - panes.set(session, props); + ? await fetchUsage($, session, true) + : await getJson( + wire($), + '/multi/mod/receipts', + { sessionId: session }, + { timeoutMs: usageTimeoutMs }, + ); + const props = updatePane(previous, action, accepted(response)); + await update($, usagePanes, (held) => + withBounded(held, session, props, maximumPanes), + ); return { props }; }); }; +function fetchUsage($: EngineInterface, session: string, refresh = false) { + const query: Record = { sessionId: session, view: 'providers' }; + if (refresh) { + query.refresh = 'true'; + } + return getJson(wire($), '/multi/mod/usage', query, { timeoutMs: usageTimeoutMs }); +} + function record(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -204,71 +239,3 @@ function receiptEntry(value: unknown): string[] { ` ${String(value.provider)} · ${String(value.model ?? 'model unreported')} · effort ${String(value.effort ?? 'unreported')} · ${String(value.endpoint ?? 'endpoint unreported')} · ${String(value.source)}`, ]; } - -async function request($: EngineInterface, route: string): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const result = await Promise.race([ - $.http.fetch(`${base}${route}`, { - method: 'GET', - headers: { 'x-multi-gateway-token': token }, - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Usage timeout')), 8500); - }), - ]); - return result.ok ? JSON.parse(result.text) : undefined; - } catch { - return undefined; - } finally { - clearTimeout(timer); - } -} - -/** - * Reviewer attribution for a provider's tool call: the gateway learns the session, call, - * and workspace it needs to match the reviewer request to it. A Claude loop's tool call - * is Claude's own and costs no gateway call. The reply never decides the call. - */ -async function attributeToolCall( - $: EngineInterface, - event: { tool: string; tool_use_id: string }, - agentModels: ReadonlyMap, -) { - const agentId = (event as { agentId?: string }).agentId; - const model = agentId === undefined ? await $.session.model() : agentModels.get(agentId); - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!model?.startsWith('multi/') || !base || !token) { - return; - } - const mode = (event as { permission_mode?: unknown }).permission_mode; - let timer: ReturnType | undefined; - try { - await Promise.race([ - $.http.fetch(`${base}/multi/permission`, { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - body: JSON.stringify({ - session_id: await $.session.id(), - tool_use_id: event.tool_use_id, - tool_name: event.tool, - cwd: await $.session.cwd(), - ...(typeof mode === 'string' ? { permission_mode: mode } : {}), - }), - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Attribution timeout')), 1500); - }), - ]); - } catch { - // Attribution is best effort; Claude's own checks decide the call. - } finally { - clearTimeout(timer); - } -} diff --git a/plugins/multi-core/hooks/worker-rows.ts b/plugins/multi-core/hooks/worker-rows.ts index 3a966d9..78205b3 100644 --- a/plugins/multi-core/hooks/worker-rows.ts +++ b/plugins/multi-core/hooks/worker-rows.ts @@ -1,4 +1,16 @@ import type { Register } from 'claude-code'; +import { atom, read } from 'claude-code'; + +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); +const spawnModels = atom( + { plugin: 'multi-core', key: 'spawnModels' } as const, + {} as Record, +); /** * The provider worker types. Each runs one provider's models, and its Agent call's @@ -87,18 +99,15 @@ export function labelled(description: string, model: string): string { * completion) and a background agent's notification gain ` · ` * beside their description; the stored input and message stay as they were. */ -export const register = ( - on: Parameters[0], - agentModels: ReadonlyMap, - spawnModels: ReadonlyMap, -) => { - on('ui.render', { component: 'ToolUse' }, async (_$, event, next) => { +export const register = (on: Parameters[0]) => { + on('ui.render', { component: 'ToolUse' }, async ($, event, next) => { const input = record(event.props.input); if (event.props.tool !== 'Agent' || !input || !isProviderWorker(input.subagent_type)) { return next(event); } + // Read from state while drawing: a spawn that resolves its model redraws the row. const label = workerLabel( - spawnModels.get(event.props.tool_use_id) ?? resultModel(event.props.output), + (await read($, spawnModels))[event.props.tool_use_id] ?? resultModel(event.props.output), ); // The call's provider model is not one of the Agent schema's Claude aliases, and the // engine draws an input its schema rejects as a bare `Agent`: the label carries it. @@ -112,14 +121,16 @@ export const register = ( on( 'ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, - async (_$, event, next) => { + async ($, event, next) => { if (event.component !== 'UserMessage' || event.props.isExpanded) { return next(event); } const task = event.props.task; + const agents = await read($, agentModels); + const spawns = await read($, spawnModels); const label = workerLabel( - (task?.id ? agentModels.get(task.id) : undefined) ?? - (task?.toolUseId ? spawnModels.get(task.toolUseId) : undefined), + (task?.id ? agents[task.id] : undefined) ?? + (task?.toolUseId ? spawns[task.toolUseId] : undefined), ); // A spawn's labelled description already names it in the notification's summary. if (!label || event.props.text.includes(label)) { diff --git a/plugins/multi-core/hooks/workers.ts b/plugins/multi-core/hooks/workers.ts index 5e3e7ba..5d7054f 100644 --- a/plugins/multi-core/hooks/workers.ts +++ b/plugins/multi-core/hooks/workers.ts @@ -1,14 +1,62 @@ import type { AgentSpawnInput, EngineInterface, Register } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { MultiCoreDisplayTools, MultiCorePolicy } from '../types/multi-core.d.ts'; import { - ensureHarnessPolicy, - type PolicyClient, - type PolicyResponse, - type PolicyState, -} from './policy.ts'; -import { isHarnessModel } from './provider.ts'; + accepted, + type GatewayResponse, + getJson, + isActive, + postJson, + type Wire, +} from './gateway.ts'; +import { ensureHarnessPolicy, policyClient } from './policy.ts'; +import { isHarnessModel, isMultiModel } from './provider.ts'; +import { type RowsClient, syncDisplayTools } from './rows.ts'; +import { defined, rememberBounded, withBounded, withItem } from './state.ts'; import { isProviderWorker, labelled, register as registerWorkerRows } from './worker-rows.ts'; -const maxBody = 32000; +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy); +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); +const spawnModels = atom( + { plugin: 'multi-core', key: 'spawnModels' } as const, + {} as Record, +); +const claudeTypes = atom({ plugin: 'multi-core', key: 'claudeTypes' } as const, [] as string[]); +const offeredProviders = atom( + { plugin: 'multi-core', key: 'offeredProviders' } as const, + [] as string[], +); +const displayTools = atom( + { plugin: 'multi-core', key: 'displayTools' } as const, + { registered: [] } as MultiCoreDisplayTools, +); + +const rowsClient = ($: EngineInterface): RowsClient => ({ + wire: wire($), + sessionId: () => $.session.id(), + held: () => read($, displayTools), + save: (change) => update($, displayTools, change), + register: (tool) => $.tool.register(tool), +}); + +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, +}); + const issues = 'https://github.com/greenpolo/cc-multi-cli-plugin/issues/new?template=bug_report.yml'; @@ -23,84 +71,34 @@ function reportable(reason: string) { return `${reason}\n\nIf this reads like a defect in the multi-cli plugin rather than a permission the user chose, tell them so and offer to open an issue at ${issues}, quoting the reason above.`; } -type GatewayResponse = PolicyResponse & { - accepted?: boolean; - error?: string; - isOffered?: boolean; - execution?: 'claude' | 'harness'; - known?: boolean; - model?: string; -}; - type SpawnEvent = AgentSpawnInput; /** * The model an Agent call named for a provider worker, by tool_use_id. The Agent tool's * schema only admits Claude aliases, so `tool.call` (which runs before the engine checks - * the input) takes the value out and `agent.spawn` resolves it against the catalog. + * the input) takes the value out and `agent.spawn` resolves it against the catalog. It + * lives from that `tool.call` to its `agent.spawn`, inside one call, so it is not state. */ const requestedModels = new Map(); -/** Bounded like the other per-call records: the oldest entry makes room. */ -export function rememberBounded(entries: Map, key: string, value: T, limit = 256) { - entries.delete(key); - const oldest = entries.keys().next(); - if (entries.size >= limit && !oldest.done) { - entries.delete(oldest.value); - } - entries.set(key, value); -} - -// A refused reply keeps only the reason: a non-2xx status never carries an -// acknowledgement, so every caller still fails closed on the HTTP status alone. -function refusal(text: string, status: number): GatewayResponse { - let reason: string | undefined; - try { - const parsed: unknown = JSON.parse(text); - if (parsed && typeof parsed === 'object') { - const value = (parsed as { error?: unknown }).error; - reason = typeof value === 'string' && value ? value : undefined; - } - } catch { - // A non-JSON body still names the status below. - } - const detail = text.trim().slice(0, 200); - return { - refused: true, - httpStatus: status, - error: reason ?? (detail ? `gateway ${status}: ${detail}` : `gateway ${status}`), - }; -} +const providerGuidance = + "\n\nFor a multi-* agent type (multi-cursor, multi-openai, ...), `model` names one of that provider's models from the type's description, not a Claude alias; omit it for the provider's default."; -export const register = ( - on: Parameters[0], - _options: Parameters[1], - agentModels: Map = new Map(), - policyState: PolicyState = {}, - spawnModels: Map = new Map(), -) => { - /** The resolved model of each provider-worker spawn, by its Agent call's tool_use_id. */ - registerWorkerRows(on, agentModels, spawnModels); - /** - * Agent types the gateway classified as Claude-loop at offer. A definition may name a - * provider model the spawn event does not carry, so only such a type skips admission. - */ - const claudeTypes = new Set(); +export const register = (on: Parameters[0], _options: Parameters[1]) => { + registerWorkerRows(on); on('tool.describe', { tool: 'Agent' }, async ($, event, next) => { const described = await next(event); - if (!(await active($))) { + // The paragraph is for the provider types the model is offered; none, no paragraph. + if (!(await isActive(wire($))) || !(await read($, offeredProviders)).length) { return described; } - return { - ...described, - description: `${described.description}\n\nFor a multi-* agent type (multi-cursor, multi-openai, ...), \`model\` names one of that provider's models from the type's description, not a Claude alias; omit it for the provider's default.`, - }; + return { ...described, description: `${described.description}${providerGuidance}` }; }); on('tool.call', { tool: 'Agent' }, async ($, event, next) => { if (event.tool !== 'Agent' || event.model === undefined) { return next(event); } - if (!isProviderWorker(event.subagent_type) || !(await active($))) { + if (!isProviderWorker(event.subagent_type) || !(await isActive(wire($)))) { return next(event); } rememberBounded(requestedModels, event.tool_use_id, String(event.model)); @@ -112,61 +110,68 @@ export const register = ( } }); on('agent.offer', async ($, event, next) => { - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } - const response = await request( - $, - { - sessionId: await $.session.id(), - cwd: await $.session.cwd(), - agent: event.agent, - parentModel: await $.session.model(), - }, - '/multi/mod/offer', - ); - if (response?.execution === 'claude') { - claudeTypes.add(event.agent); - } else { - claudeTypes.delete(event.agent); + const parentModel = await $.session.model(); + // A built-in agent on a Claude session is Claude's own: no gateway call, ever. + if ( + event.source === 'built-in' && + !isMultiModel(parentModel) && + !isProviderWorker(event.agent) + ) { + await classify($, event.agent, true); + return next(event); } + const response = await postJson(wire($), '/multi/mod/offer', { + sessionId: await $.session.id(), + cwd: await $.session.cwd(), + agent: event.agent, + parentModel, + }); + await classify($, event.agent, nativeClaude(response)); // Claude owns its own catalog. Only a positively identified harness worker // is subject to Multi's settings-translation compatibility filter. - return response?.execution === 'harness' && response.isOffered === false - ? { isOffered: false } - : next(event); + const hidden = response?.execution === 'harness' && response.isOffered === false; + const result = hidden ? { isOffered: false } : await next(event); + await markOffered($, event.agent, result.isOffered); + return result; }); on('classic.SubagentStart', async ($, event, next) => { - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } - // A native Claude subagent in a Claude session needs no gateway record. + // A native Claude subagent in a Claude session needs no gateway record. The + // subagent's own model and its parent's are not on this event, so any loop known to + // run a Multi model (an inheriting child of a provider worker) keeps the record. if ( - claudeTypes.has(event.agent_type) && + (await read($, claudeTypes)).includes(event.agent_type) && !isProviderWorker(event.agent_type) && - !isMultiModel(await $.session.model()) + !isMultiModel(await $.session.model()) && + !Object.values(await read($, agentModels)).some(isMultiModel) ) { return next(event); } - const response = await request($, { + const response = await postJson(wire($), '/multi/mod/worker', { sessionId: event.session_id, agentId: event.agent_id, subagentType: event.agent_type, cwd: event.cwd, }); - if (response?.accepted && response.model) { - agentModels.set(event.agent_id, response.model); + const model = response?.accepted ? response.model : undefined; + if (model) { + await update($, agentModels, (held) => withBounded(held, event.agent_id, model)); } // Registration is observational for Claude-loop workers. An unregistered // harness worker still cannot dispatch: resolveHarness rejects its scope. return next(event); }); on('agent.spawn', async ($, event, next) => { - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } // A native Claude subagent runs on Claude Code's own path: no gateway call. - if (nativeClaudeSpawn(event, claudeTypes)) { + if (nativeClaudeSpawn(event, await read($, claudeTypes))) { return next(event); } const resolved = await providerSpawn($, event); @@ -174,22 +179,50 @@ export const register = ( return { deny: resolved.deny }; } const spawn = resolved.event; - if (spawn.model && spawn !== event) { - rememberBounded(spawnModels, event.tool_use_id, spawn.model); - $.ui.invalidate('ui.render'); + const resolvedModel = spawn.model; + if (resolvedModel && spawn !== event) { + // Written to state, so the Agent row and the task notification draw the model. + await update($, spawnModels, (held) => withBounded(held, event.tool_use_id, resolvedModel)); } - const denial = await admit($, spawn, resolved.selection, policyState); + const denial = await admit($, spawn, resolved.selection); if (denial) { return { deny: denial }; } const result = await next(spawn); - if (result.agentId && result.model) { - agentModels.set(result.agentId, result.model); + const { agentId, model } = result; + if (agentId && model) { + await update($, agentModels, (held) => withBounded(held, agentId, model)); } return result; }); }; +/** Truly native: the gateway classifies the type as Claude-loop and its model is not a Multi one. */ +function nativeClaude(response: GatewayResponse | undefined): boolean { + return response?.execution === 'claude' && !isMultiModel(response.model); +} + +/** Remembers whether an agent type is a native Claude subagent, changing state only on a change. */ +async function classify($: EngineInterface, agent: string, isNative: boolean) { + if ((await read($, claudeTypes)).includes(agent) === isNative) { + return; + } + await update($, claudeTypes, (held) => + isNative ? withItem(held, agent) : held.filter((type) => type !== agent), + ); +} + +/** Tracks the provider worker types the model is offered, for the Agent tool's description. */ +async function markOffered($: EngineInterface, agent: string, isOffered: boolean) { + if (!isProviderWorker(agent) || (await read($, offeredProviders)).includes(agent) === isOffered) { + return; + } + await update($, offeredProviders, (held) => + isOffered ? withItem(held, agent) : held.filter((type) => type !== agent), + ); + $.ui.invalidate('tool.describe'); +} + async function spawnPayload($: EngineInterface, event: SpawnEvent) { return { sessionId: await $.session.id(), @@ -216,7 +249,7 @@ async function providerSpawn( const provider = isProviderWorker(event.subagentType) && !event.fork; const named = provider ? (requestedModels.get(event.tool_use_id) ?? event.model) : event.model; const payload = { ...(await spawnPayload($, event)), model: named }; - const selection = await request($, payload, '/multi/mod/worker-model'); + const selection = await postJson(wire($), '/multi/mod/worker-model', payload); if (!provider) { return { event, selection }; } @@ -238,38 +271,37 @@ async function admit( $: EngineInterface, event: SpawnEvent, selection: GatewayResponse | undefined, - policyState: PolicyState, ): Promise { const payload = await spawnPayload($, event); if (!harnessSpawn(event, selection)) { // Keep context for a possible later harness child, but never veto the // engine's native worker because Multi could not reconstruct its policy. - await request($, payload); + await postJson(wire($), '/multi/mod/worker', payload); return undefined; } - await prepareHarness($, policyState); - const mode = await request( - $, - {}, - `/multi/mod/mode?sessionId=${encodeURIComponent(payload.sessionId)}`, + await prepareHarness($); + // The worker's rows anchor in its own transcript only once their tools exist. + await syncDisplayTools(rowsClient($)); + const mode = accepted( + await getJson(wire($), '/multi/mod/mode', { sessionId: payload.sessionId }), ); - const response = await request($, { ...payload, generation: mode?.generation }); + const response = await postJson(wire($), '/multi/mod/worker', { + ...payload, + generation: mode?.generation, + }); return response?.accepted ? undefined : reportable(response?.error ?? 'Multi harness worker policy was not acknowledged.'); } -function isMultiModel(model: string | undefined): boolean { - return Boolean(model?.startsWith('multi/')); -} - /** - * A Claude subagent: not a `multi-*` worker type, offered as Claude-loop, and neither its - * model nor its parent's (which a fork or an inheriting subagent runs on) is a Multi model. + * A Claude subagent: not a `multi-*` worker type, classified as native Claude (a Claude + * model, not a provider model its definition pins), and neither its model nor its + * parent's (which a fork or an inheriting subagent runs on) is a Multi model. */ -function nativeClaudeSpawn(event: SpawnEvent, claudeTypes: ReadonlySet): boolean { +function nativeClaudeSpawn(event: SpawnEvent, claudeTypeNames: readonly string[]): boolean { return ( - claudeTypes.has(event.subagentType) && + claudeTypeNames.includes(event.subagentType) && !isProviderWorker(event.subagentType) && !isMultiModel(event.model) && !isMultiModel(event.parentModel) @@ -284,58 +316,21 @@ function harnessSpawn( return selection?.execution === 'harness' || (!selection?.known && isHarnessModel(inferred)); } -async function active($: EngineInterface): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - return Boolean(base && token); -} - -async function request( - $: EngineInterface, - payload: Record, - route = '/multi/mod/worker', -) { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - const body = JSON.stringify(payload); - if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const response = $.http.fetch(`${base}${route}`, { - method: route.includes('?') ? 'GET' : 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(route.includes('?') ? {} : { body }), - }); - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500); - }); - const result = await Promise.race([response, timeout]); - return result.ok - ? (JSON.parse(result.text) as GatewayResponse) - : refusal(result.text, result.status); - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -async function policyClient($: EngineInterface): Promise { - return { - model: await $.session.model(), - request: (route, payload) => request($, payload, route), - }; -} - -async function prepareHarness($: EngineInterface, state: PolicyState) { - if (state.prompt && !state.harnessReady) { - await ensureHarnessPolicy(await policyClient($), state); +/** + * Admits the prompt's harness policy once for every helper it spawns. The snapshot is + * state, so the admission outlives a reload; concurrent helpers share one admission in + * `policy.ts`. + */ +async function prepareHarness($: EngineInterface) { + const held = await read($, policy); + if (!held.prompt || held.harnessReady) { + return; } + const admitted: MultiCorePolicy = { ...held }; + await ensureHarnessPolicy(policyClient(wire($), await $.session.model()), admitted); + await update($, policy, (latest) => + JSON.stringify(latest.prompt) === JSON.stringify(held.prompt) + ? defined({ ...latest, generation: admitted.generation, harnessReady: admitted.harnessReady }) + : latest, + ); } diff --git a/plugins/multi-core/src/account.ts b/plugins/multi-core/src/account.ts index 7791404..a8ed603 100644 --- a/plugins/multi-core/src/account.ts +++ b/plugins/multi-core/src/account.ts @@ -76,7 +76,7 @@ async function main() { ); } if (provider === 'openai' && args.every((arg) => arg === '--device-auth')) { - return run('codex', ['-c', 'cli_auth_credentials_store="file"', 'login', ...args]); + return run('codex', ['-c', 'cli_auth_credentials_store=file', 'login', ...args]); } if (args.length) { throw new Error('Unsupported login arguments'); diff --git a/plugins/multi-core/src/gateway/agent-catalog.ts b/plugins/multi-core/src/gateway/agent-catalog.ts deleted file mode 100644 index 6ad8893..0000000 --- a/plugins/multi-core/src/gateway/agent-catalog.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { MessagesRequest } from './messages.ts'; - -interface Worker { - model: string; - description: string; - tools: string[]; -} - -const record = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -/** Registration is unchanged: only exact Multi-owned announcement rows are hidden. */ -export class AgentCatalog { - private hidden = new Set(); - - constructor(workers: Record, models: readonly string[]) { - const remaining = new Set(models); - for (const [name, worker] of Object.entries(workers)) { - if (remaining.delete(worker.model)) { - continue; - } - this.hidden.add(`- ${name}: ${worker.description} (Tools: ${worker.tools.join(', ')})`); - } - } - - compact(body: MessagesRequest): MessagesRequest { - if (!Array.isArray(body.messages)) { - return body; - } - let changed = false; - const messages = body.messages.map((message) => { - if (!record(message) || !['user', 'system'].includes(message.role)) { - return message; - } - const content = this.content(message.content, message.role === 'system'); - if (content === message.content) { - return message; - } - changed = true; - return { ...message, content }; - }); - return changed ? { ...body, messages } : body; - } - - private content(content: string | NonNullable, system: boolean) { - if (typeof content === 'string') { - return this.text(content, system); - } - if (!Array.isArray(content)) { - return content; - } - let changed = false; - const blocks = content.map((block) => { - if (!record(block) || block.type !== 'text' || typeof block.text !== 'string') { - return block; - } - const text = this.text(block.text, system); - if (text === block.text) { - return block; - } - changed = true; - return { ...block, text }; - }); - return changed ? blocks : content; - } - - private text(text: string, system: boolean): string { - // Native CLI announcements may be bare system messages or wrapped user reminders. - if (system) { - return this.section(text); - } - return text.replace(/[\s\S]*?<\/system-reminder>/g, (reminder) => - this.section(reminder), - ); - } - - private section(text: string): string { - return text.replace( - /^(Available agent types for the Agent tool:|New agent types are now available for the Agent tool:)\n((?:- [^\n]*(?:\n|$))+)/gm, - (section: string, heading: string, rows: string) => { - const kept = rows - .split('\n') - .filter((line) => !this.hidden.has(line)) - .join('\n'); - return kept === rows ? section : `${heading}\n${kept}`; - }, - ); - } -} diff --git a/plugins/multi-core/src/gateway/approval.ts b/plugins/multi-core/src/gateway/approval.ts index 5fb0300..06ccec7 100644 --- a/plugins/multi-core/src/gateway/approval.ts +++ b/plugins/multi-core/src/gateway/approval.ts @@ -1,5 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; +import { setBounded } from './bounded.ts'; import type { MessagesRequest } from './messages.ts'; +import { isRecord } from './record.ts'; /** Normalize a workspace path for the classifier's omitted-cd comparison. */ export function approvalCwdForComparison( @@ -42,20 +44,17 @@ export interface ApprovalVerdict { outcome: 'allow' | 'deny'; } -const record = (value: unknown): value is Record => - Boolean(value) && typeof value === 'object' && !Array.isArray(value); - /** Claude can retry classification using the working model's ID. */ export function isApprovalRequest(body: unknown): boolean { - if (!record(body) || !Array.isArray(body.messages)) { + if (!isRecord(body) || !Array.isArray(body.messages)) { return false; } return body.messages.some( (message) => - record(message) && + isRecord(message) && Array.isArray(message.content) && message.content.some( - (block) => record(block) && block.type === 'text' && block.text === '\n', + (block) => isRecord(block) && block.type === 'text' && block.text === '\n', ), ); } @@ -63,7 +62,7 @@ export function isApprovalRequest(body: unknown): boolean { /** Strict, version-sensitive Claude 2.1.263 classifier envelope. Unknown formats fail closed. */ function approvalEnvelope(body: unknown) { if ( - !record(body) || + !isRecord(body) || body.stream || (body.tools !== undefined && (!Array.isArray(body.tools) || body.tools.length)) || !Array.isArray(body.messages) || @@ -72,17 +71,17 @@ function approvalEnvelope(body: unknown) { throw new Error('Unsupported approval request'); } const message = body.messages[0]; - if (!record(message) || message.role !== 'user' || !Array.isArray(message.content)) { + if (!isRecord(message) || message.role !== 'user' || !Array.isArray(message.content)) { throw new Error('Unsupported approval message'); } const text = message.content.map((block) => { - if (!record(block) || block.type !== 'text' || typeof block.text !== 'string') { + if (!isRecord(block) || block.type !== 'text' || typeof block.text !== 'string') { throw new Error('Unsupported approval content'); } return block.text; }); if ( - !record(body.metadata) || + !isRecord(body.metadata) || typeof body.metadata.user_id !== 'string' || !body.metadata.user_id ) { @@ -109,7 +108,7 @@ function approvalPolicy(value: unknown): string | undefined { !Array.isArray(value) || value.some( (block) => - !record(block) || + !isRecord(block) || block.type !== 'text' || typeof block.text !== 'string' || block.text.length > 131072, @@ -151,7 +150,7 @@ export function parseApprovalRequest(body: unknown): ApprovalAction & { key: str .filter(Boolean) .map((line) => { const entry: unknown = JSON.parse(line); - if (!record(entry)) { + if (!isRecord(entry)) { throw new Error('Unsupported approval transcript entry'); } return entry; @@ -263,10 +262,12 @@ export class NativeApprovalBridge { return; } // Only denials cross stages; the oldest one is evicted at the fixed cache limit. - const oldest = this.denied.keys().next(); - if (this.denied.size >= 64 && !oldest.done) { - this.denied.delete(oldest.value); - } - this.denied.set(key, { verdict: { ...verdict }, expires: Date.now() + 60000 }); + setBounded( + this.denied, + key, + { verdict: { ...verdict }, expires: Date.now() + 60000 }, + 64, + 'insertion', + ); } } diff --git a/plugins/multi-core/src/gateway/atomic-write.ts b/plugins/multi-core/src/gateway/atomic-write.ts index 7de0a21..ad0d9f4 100644 --- a/plugins/multi-core/src/gateway/atomic-write.ts +++ b/plugins/multi-core/src/gateway/atomic-write.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { rename, rm, writeFile } from 'node:fs/promises'; +import { open, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; export async function atomicWriteFile( file: string, @@ -10,6 +11,7 @@ export async function atomicWriteFile( retries?: number; rename?: typeof rename; rm?: typeof rm; + open?: typeof open; } = {}, ): Promise { const temporary = `${file}.${randomUUID()}.tmp`; @@ -19,14 +21,16 @@ export async function atomicWriteFile( if (!Number.isSafeInteger(retries) || retries < 0) { throw new RangeError('Atomic write retries must be a non-negative integer'); } - await writeFile(temporary, data, { mode: options.mode }); + const openFile = options.open ?? open; const attempts = options.platform === 'win32' ? retries : 0; const renameFile = options.rename ?? rename; const removeFile = options.rm ?? rm; try { + await writeDurably(openFile, temporary, data, options.mode); for (let attempt = 0; ; attempt += 1) { try { await renameFile(temporary, file); + await syncDirectory(openFile, path.dirname(file), options.platform); return; } catch (error) { const retryable = isWindowsRenameRetryable(error, options.platform); @@ -49,3 +53,43 @@ function isWindowsRenameRetryable(error: unknown, platform: NodeJS.Platform | un (error.code === 'EPERM' || error.code === 'EBUSY') ); } + +/** Flush the bytes before the rename can make them visible under the final name. */ +async function writeDurably( + openFile: typeof open, + file: string, + data: string | Uint8Array, + mode: number | undefined, +): Promise { + const handle = await openFile(file, 'w', mode); + try { + await handle.writeFile(data); + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** + * Persist the rename itself. Windows cannot open a directory for syncing, and + * some POSIX filesystems refuse it, so this step is best effort by design. + */ +async function syncDirectory( + openFile: typeof open, + directory: string, + platform: NodeJS.Platform | undefined, +): Promise { + if ((platform ?? process.platform) === 'win32') { + return; + } + try { + const handle = await openFile(directory, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } catch { + // The data is already durable; only rename durability is unavailable here. + } +} diff --git a/plugins/multi-core/src/gateway/bounded.ts b/plugins/multi-core/src/gateway/bounded.ts new file mode 100644 index 0000000..bccf92f --- /dev/null +++ b/plugins/multi-core/src/gateway/bounded.ts @@ -0,0 +1,47 @@ +// Capacity-bounded Map and Set writes. A Map iterates in insertion order, so the first key +// is the oldest. Callers choose the recency rule explicitly; none of these refuses a write. + +/** + * `lru`: a write makes the key the newest, and the least recently written entry makes room. + * `insertion`: only a new key can evict, and an existing key keeps its place in the order. + */ +export type EvictionOrder = 'lru' | 'insertion'; + +function evictOldest(map: Map, capacity: number) { + while (map.size >= capacity) { + const oldest = map.keys().next(); + if (oldest.done) { + return; + } + map.delete(oldest.value); + } +} + +/** Stores `value` and keeps the map at or below `capacity` entries. */ +export function setBounded( + map: Map, + key: K, + value: V, + capacity: number, + order: EvictionOrder, +): void { + if (order === 'lru') { + map.delete(key); + } + if (!map.has(key)) { + evictOldest(map, capacity); + } + map.set(key, value); +} + +/** Adds `key`, evicting the oldest members once the set exceeds `capacity`. */ +export function addBounded(set: Set, key: K, capacity: number): void { + set.add(key); + while (set.size > capacity) { + const oldest = set.values().next(); + if (oldest.done) { + return; + } + set.delete(oldest.value); + } +} diff --git a/plugins/multi-core/src/gateway/conversation.ts b/plugins/multi-core/src/gateway/conversation.ts index 1b3b93a..ab7e8e9 100644 --- a/plugins/multi-core/src/gateway/conversation.ts +++ b/plugins/multi-core/src/gateway/conversation.ts @@ -1,4 +1,5 @@ import type { ContentBlock, RequestMessage } from './messages.ts'; +import { isRecord } from './record.ts'; import { callId, toolName } from './tools.ts'; const IMAGE_MEDIA_TYPES: readonly unknown[] = [ @@ -21,10 +22,6 @@ export type NormalizedConversationItem = export type AssistantReasoningDecoder = (block: ContentBlock) => R | undefined; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function contentBlocks(value: unknown): ContentBlock[] { if (typeof value === 'string') { return [{ type: 'text', text: value }]; diff --git a/plugins/multi-core/src/gateway/display-rows.ts b/plugins/multi-core/src/gateway/display-rows.ts index 6049c19..e947d6c 100644 --- a/plugins/multi-core/src/gateway/display-rows.ts +++ b/plugins/multi-core/src/gateway/display-rows.ts @@ -1,10 +1,12 @@ import { randomBytes, randomUUID } from 'node:crypto'; +import { setBounded } from './bounded.ts'; import type { ContentBlock, MessagesRequest, MessagesResponse, RequestMessage, } from './messages.ts'; +import { isRecord } from './record.ts'; /** * Native harness actions drawn as Claude Code tool rows. @@ -34,6 +36,7 @@ export const ROW_TOKEN = 'multi_row'; const maximumName = 64 - DISPLAY_TOOL_PREFIX.length; const maximumTools = 160; const maximumRows = 2048; +const maximumSessions = 128; const maximumFollowUps = 2048; const maximumOutput = 16 * 1024; const maximumInputValue = 2000; @@ -340,14 +343,6 @@ function boundedInput(input: unknown): Record { return output; } -function remember(map: Map, key: string, value: T, capacity: number) { - map.delete(key); - map.set(key, value); - while (map.size > capacity) { - map.delete(map.keys().next().value as string); - } -} - /** * The gateway's side of display rows: the native tool names offered to the mod, * the names it acknowledged registering, the tokens it issued, and the text each @@ -355,7 +350,8 @@ function remember(map: Map, key: string, value: T, capacity: numbe */ export class DisplayRows { private readonly known = new Set(); - private readonly registered = new Set(); + /** The names each session's mod acknowledged: another session registered nothing of these. */ + private readonly registered = new Map>(); private readonly rows = new Map(); /** The pending reply of each scope, and which scope wrote each of its rows. */ private readonly followUps = new Map(); @@ -377,17 +373,19 @@ export class DisplayRows { return { revision: this.revision, names: [...this.known] }; } - /** Records names the mod registered; only those are ever emitted as rows. */ - acknowledge(names: unknown) { + /** Records names a session's mod registered; only those are ever emitted as its rows. */ + acknowledge(session: string, names: unknown) { if (!Array.isArray(names)) { throw new Error('Invalid display tool acknowledgement'); } + const own = new Set(); for (const name of names) { if (typeof name === 'string' && this.known.has(name)) { - this.registered.add(name); + own.add(name); } } - return { registered: this.registered.size }; + setBounded(this.registered, session, own, maximumSessions, 'lru'); + return { registered: own.size }; } /** @@ -397,17 +395,18 @@ export class DisplayRows { */ issue(scope: string, row: NativeRow): DisplayToolUse | undefined { const name = displayName(row.tool); - if (!name || !this.registered.has(name)) { + const session = sessionOf(scope); + if (!name || !this.registered.get(session)?.has(name)) { return undefined; } const id = `toolu_multi_${randomUUID().replaceAll('-', '')}`; const token = randomBytes(16).toString('hex'); - const session = String(JSON.parse(scope)[0]); - remember( + setBounded( this.rows, token, { session, id, output: bounded(row.output, maximumOutput), isError: row.error }, maximumRows, + 'lru', ); return { type: 'tool_use', @@ -494,6 +493,7 @@ export class DisplayRows { } forgetSession(session: string) { + this.registered.delete(session); for (const [token, row] of this.rows) { if (row.session === session) { this.rows.delete(token); @@ -540,27 +540,23 @@ export function recordedFollowUp( return { text: response.multi_followup, usage: response.usage }; } -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** * Only a well-formed request is rewritten or answered here; anything else passes * unchanged to the validation that refuses it with the provider's own error. */ -function wellFormed(body: MessagesRequest): boolean { +export function wellFormed(body: MessagesRequest): boolean { const tools: unknown = body.tools; const messages: unknown = body.messages; return ( - (tools === undefined || (Array.isArray(tools) && tools.every(isObject))) && + (tools === undefined || (Array.isArray(tools) && tools.every(isRecord))) && (messages === undefined || (Array.isArray(messages) && messages.every( (message) => - isObject(message) && + isRecord(message) && typeof message.role === 'string' && (typeof message.content === 'string' || - (Array.isArray(message.content) && message.content.every(isObject))), + (Array.isArray(message.content) && message.content.every(isRecord))), ))) ); } @@ -628,6 +624,9 @@ export function displayFollowUp(body: MessagesRequest): string[] | undefined { * ToolSearch catalogue line naming one, and the grant in a worker's tool list. */ function scrubbed(block: ContentBlock): ContentBlock { + if (block.type === 'tool_result') { + return withoutReferences(block); + } if ( block.type !== 'text' || typeof block.text !== 'string' || @@ -643,6 +642,26 @@ function scrubbed(block: ContentBlock): ContentBlock { return { ...block, text }; } +/** + * A ToolSearch result whose `tool_reference` blocks name display tools: the + * references are dropped, so no provider is told those tools exist. + */ +function withoutReferences(block: ContentBlock): ContentBlock { + const content = block.content; + if (!Array.isArray(content) || !content.some((item) => isReferenceToDisplay(item))) { + return block; + } + const kept = content.filter((item) => !isReferenceToDisplay(item)); + return { + ...block, + content: kept.length ? kept : [{ type: 'text', text: 'No matching deferred tools found.' }], + }; +} + +function isReferenceToDisplay(item: unknown): boolean { + return isRecord(item) && item.type === 'tool_reference' && isDisplayTool(item.tool_name); +} + function withoutRows(message: RequestMessage, rows: ReadonlySet): RequestMessage { const content = blocks(message); if (!content) { diff --git a/plugins/multi-core/src/gateway/executable.ts b/plugins/multi-core/src/gateway/executable.ts index 3e7c4bb..159d3b2 100644 --- a/plugins/multi-core/src/gateway/executable.ts +++ b/plugins/multi-core/src/gateway/executable.ts @@ -71,6 +71,41 @@ function missingExecutable(message: string): NodeJS.ErrnoException { return error; } +const CMD_UNSAFE = /["%!^&|<>\r\n\0]/; + +/** True when text can pass through `cmd.exe /d /s /c` as one literal argument. */ +export function isCmdSafeArgument(value: string): boolean { + return !CMD_UNSAFE.test(value); +} + +export class UnsafeCommandArgumentError extends Error { + readonly code = 'EUNSAFEARG'; + readonly argument: string; + constructor(argument = '') { + super( + 'Refusing to pass an argument containing cmd.exe metacharacters through a .cmd/.bat launcher; deliver it on stdin instead', + ); + this.name = 'UnsafeCommandArgumentError'; + this.argument = argument; + } +} + +/** + * Whether `value` may travel as a command-line argument to this executable on + * this platform. Only a non-shim `.cmd`/`.bat` launcher restricts arguments. + */ +export function argvSafe( + executable: string, + value: string, + platform = process.platform, + shimOptions: ExecutableInvocationOptions = {}, +): boolean { + if (platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(executable)) { + return true; + } + return resolveNpmShimTarget(executable, shimOptions) !== undefined || isCmdSafeArgument(value); +} + /** Invoke Windows command shims without shell:true, preserving argument boundaries. */ export function executableInvocation( executable: string, @@ -83,6 +118,11 @@ export function executableInvocation( return { command: executable, args: [...args], viaComSpec: false }; } const shimTarget = resolveNpmShimTarget(executable, shimOptions); + // npm links a package's native binary (Claude Code's bin/claude.exe) through the same + // shim, so that target runs directly instead of through cmd.exe. + if (shimTarget && /\.exe$/i.test(shimTarget)) { + return { command: shimTarget, args: [...args], viaComSpec: false }; + } if (shimTarget) { return { command: process.execPath, @@ -90,6 +130,14 @@ export function executableInvocation( viaComSpec: false, }; } + // cmd.exe re-parses the line: it expands %VAR% and does not honor the + // backslash-quote escape the child's runtime uses. No quoting is provably + // safe for such text, so refuse it and let callers use stdin or a file. + for (const value of [executable, ...args]) { + if (!isCmdSafeArgument(value)) { + throw new UnsafeCommandArgumentError(value); + } + } const command = environmentValue(env, 'ComSpec') ?? process.env.ComSpec ?? 'cmd.exe'; const commandLine = [quoteWindows(executable), ...args.map(quoteWindows)].join(' '); return { @@ -101,7 +149,7 @@ export function executableInvocation( } const MAX_SHIM_SIZE = 32 * 1024; -const SHIM_TARGET = /"%dp0%\\([^"\r\n]+\.(?:js|cjs|mjs))"[ \t]+%\*/gi; +const SHIM_TARGET = /"%dp0%\\([^"\r\n]+\.(?:js|cjs|mjs|exe))"[ \t]+%\*/gi; function resolveNpmShimTarget( executable: string, diff --git a/plugins/multi-core/src/gateway/harness-cli.ts b/plugins/multi-core/src/gateway/harness-cli.ts new file mode 100644 index 0000000..ba1d394 --- /dev/null +++ b/plugins/multi-core/src/gateway/harness-cli.ts @@ -0,0 +1,227 @@ +import { createHash } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; +import { archiveHarnessReply } from './harness-completion.ts'; +import { ExchangeRegistry, type HarnessExchange, replayPersisted } from './harness-exchange.ts'; +import { classifyHarnessFailure } from './harness-failure.ts'; +import type { NativeProgressObserver } from './harness-progress.ts'; +import { + type HarnessSession, + type HarnessSessionBase, + HarnessSessionStore, +} from './harness-session.ts'; +import type { Emit, MessagesRequest, MessagesResponse } from './messages.ts'; +import type { PermissionContext } from './mode-hook.ts'; +import { abortGraceMs } from './settle.ts'; + +const MAX_CONCURRENT_EXCHANGES = 256; + +export const digest = (value: unknown) => + createHash('sha256').update(JSON.stringify(value)).digest('hex'); + +/** Waits for `operation`, but never longer than `timeoutMs`; it never rejects on timeout. */ +export async function boundedWait(operation: Promise, timeoutMs = abortGraceMs) { + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + operation, + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** + * The failure a CLI harness reports: a one-line message capped at 500 characters, and + * the status the shared policy gives it. `advise` may append provider guidance. + */ +export function harnessFailure( + error: unknown, + fallback: string, + advise?: (reported: string) => string | undefined, +): { status: number; message: string } { + const detail = error && typeof error === 'object' && 'message' in error ? error.message : error; + const reported = String(detail ?? fallback); + const advice = advise?.(reported); + const message = `${reported}${advice ? ` ${advice}` : ''}` + .replace(/\s+/g, ' ') + .trim() + .slice(0, 500); + return { status: classifyHarnessFailure(error).status, message }; +} + +/** Everything one native turn is addressed by, fixed before the exchange starts. */ +export type CliTurn = { + body: MessagesRequest; + context: PermissionContext; + selection: Selection; + cwd: string; + identity: string; + key: string; + observe?: NativeProgressObserver; +}; + +export type CliHarnessOptions = { + /** Display name used in every message the caller reads. */ + tag: string; + /** The persisted session discriminator and the leading element of the exchange key. */ + storeProvider: string; + stateDirectory: string; + defaultCwd: string; + platform: NodeJS.Platform; + version: number; + fresh: (identity: string) => Saved; + validate: (saved: Partial) => boolean; +}; + +/** + * The request, exchange, lease and shutdown flow shared by the CLI-backed harnesses + * (Antigravity, Grok). A provider supplies its model selection, the request part of + * the exchange key, its error type, and the turn itself. + */ +export abstract class CliHarness { + protected readonly tag: string; + protected readonly stateDirectory: string; + protected readonly platform: NodeJS.Platform; + protected readonly store: HarnessSessionStore; + private readonly storeProvider: string; + private readonly defaultCwd: string; + private readonly exchanges: ExchangeRegistry; + private closed = false; + + constructor(options: CliHarnessOptions) { + this.tag = options.tag; + this.storeProvider = options.storeProvider; + this.stateDirectory = options.stateDirectory; + this.defaultCwd = options.defaultCwd; + this.platform = options.platform; + this.exchanges = new ExchangeRegistry({ provider: options.tag, createMeta: () => ({}) }); + this.store = new HarnessSessionStore({ + provider: options.storeProvider, + tag: options.tag, + stateDirectory: options.stateDirectory, + platform: options.platform, + version: options.version, + runtime: () => ({}), + fresh: options.fresh, + validate: options.validate, + }); + } + + protected abstract selection(body: MessagesRequest): Selection; + + /** The part of the exchange key that names the request itself, before permissions. */ + protected abstract requestKey(body: MessagesRequest): unknown; + + /** This provider's error for a failed turn; its `failure.status` follows the shared policy. */ + protected abstract providerError(error: unknown): Error; + + /** Runs one turn on a held session. Failures leave as this provider's own error. */ + protected abstract execute( + turn: CliTurn, + session: HarnessSession, + exchange: HarnessExchange, + emit: Emit, + ): Promise; + + abstract validate(body: MessagesRequest): number; + + /** The scope's last reply from its session record, located as `handle` locates it. */ + async recordedResponse(scope: string, context?: PermissionContext) { + const cwd = await realpath(context?.cwd ?? this.defaultCwd); + return this.store.recordedResponse(`${cwd}\0${scope}`); + } + + async handle( + body: MessagesRequest, + scope: string, + signal: AbortSignal, + emit?: Emit, + context?: PermissionContext, + observe?: NativeProgressObserver, + ): Promise { + if (this.closed) { + throw new Error(`${this.tag} harness is closed`); + } + signal.throwIfAborted(); + if (!context) { + throw new Error(`${this.tag} requires an explicit Claude permission context`); + } + const selection = this.selection(body); + this.validate(body); + const cwd = await realpath(context.cwd ?? this.defaultCwd); + const identity = `${cwd}\0${scope}`; + const key = digest([ + this.storeProvider, + identity, + this.requestKey(body), + { + permissionMode: context.permissionMode, + tools: context.tools, + disallowedTools: context.disallowedTools, + nativePermissionError: context.nativePermissionError, + compaction: context.compaction, + }, + ]); + const turn: CliTurn = { body, context, selection, cwd, identity, key, observe }; + let exchange = this.exchanges.get(key); + if (!exchange) { + if (this.exchanges.size >= MAX_CONCURRENT_EXCHANGES) { + throw new Error(`Too many concurrent ${this.tag} requests`); + } + exchange = this.exchanges.start(key, (started, forward) => + this.serve(turn, started, forward), + ); + } + return this.exchanges.observe(exchange, signal, emit); + } + + private async serve(turn: CliTurn, exchange: HarnessExchange, emit: Emit) { + let lease: Awaited['acquireLease']>>; + try { + // A first-load race is a deterministic conflict, so it must leave here as + // this provider's own failure: a bare busy error reads as a retryable 502. + lease = await this.store.acquireLease(turn.identity); + } catch (error) { + throw this.providerError(error); + } + const session = lease.session; + try { + const replayed = await replayPersisted({ + stateDirectory: this.stateDirectory, + key: turn.key, + saved: session.saved, + emit, + provider: this.tag, + }); + if (replayed !== undefined) { + return replayed; + } + await archiveHarnessReply({ + session, + stateDirectory: this.stateDirectory, + platform: this.platform, + }); + return await this.execute(turn, session, exchange, emit); + } finally { + await lease.release(); + // The durable record holds everything; an idle attachment only pins its lock and replay events. + await this.store.evictIdle(session); + } + } + + async close() { + this.closed = true; + const running: Promise[] = []; + for (const exchange of this.exchanges.all()) { + if (!exchange.settled) { + exchange.controller.abort(new Error(`${this.tag} gateway closed`)); + running.push(exchange.result); + } + } + await boundedWait(Promise.allSettled(running)); + await this.store.closeAll(); + } +} diff --git a/plugins/multi-core/src/gateway/harness-completion.ts b/plugins/multi-core/src/gateway/harness-completion.ts index 28243c5..18c21fd 100644 --- a/plugins/multi-core/src/gateway/harness-completion.ts +++ b/plugins/multi-core/src/gateway/harness-completion.ts @@ -1,3 +1,4 @@ +import { readdir, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import type { HarnessExchange } from './harness-exchange.ts'; import type { HarnessResponse } from './harness-response.ts'; @@ -65,6 +66,10 @@ export async function archiveHarnessReply; stateDirectory: string; platform: NodeJS.Platform; + /** Archives kept beyond the one just written; defaults to `archiveKeepCount`. */ + keep?: number; + maxAgeMs?: number; + now?: number; }): Promise { const { replay, response } = args.session.saved; if (!replay || !response) { @@ -75,4 +80,43 @@ export async function archiveHarnessReply { + try { + const names = (await readdir(directory)).filter((name) => name.endsWith('.response.json')); + const dated = await Promise.all( + names.map(async (name) => { + const file = path.join(directory, name); + const info = await stat(file).catch(() => undefined); + return info ? { file, time: info.mtimeMs } : undefined; + }), + ); + const present = dated + .filter((entry): entry is { file: string; time: number } => entry !== undefined) + .sort((left, right) => right.time - left.time); + const doomed = present.filter( + (entry, index) => index >= limits.keep || limits.now - entry.time > limits.maxAgeMs, + ); + await Promise.all(doomed.map((entry) => rm(entry.file, { force: true }).catch(() => {}))); + } catch { + // Pruning is housekeeping only. + } } diff --git a/plugins/multi-core/src/gateway/harness-failure.ts b/plugins/multi-core/src/gateway/harness-failure.ts new file mode 100644 index 0000000..6784152 --- /dev/null +++ b/plugins/multi-core/src/gateway/harness-failure.ts @@ -0,0 +1,52 @@ +import { NativeCliError } from './harness-process.ts'; +import { HarnessBusyError } from './harness-session.ts'; + +/** + * A machine momentarily out of processes, file handles or memory starts the CLI + * on the next attempt; a missing binary or a denied path never does. + */ +const TRANSIENT_SPAWN = new Set(['EAGAIN', 'EMFILE', 'ENFILE', 'ENOMEM', 'ETXTBSY']); + +/** Launch failures that arrive as plain errors, before a native run object exists. */ +const PERMANENT_LAUNCH = new Set(['ENOENT', 'EACCES', 'EPERM', 'ENOTDIR', 'EUNSAFEARG']); + +export type HarnessFailureClass = { + /** The same request fails the same way again: retrying only repeats a paid attempt. */ + deterministic: boolean; + /** 400 for deterministic and configuration failures, 502 for uncertain or transient ones. */ + status: 400 | 502; +}; + +function systemCodeOf(error: unknown): string | undefined { + if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') { + return error.code; + } + return undefined; +} + +/** + * The one status policy for native CLI harnesses (Antigravity, Grok). A busy + * agent, an unapplied permission policy, a missing or unrunnable binary and an + * argument the platform cannot carry fail identically on every attempt, so they + * are request errors. Everything else (a stream that ended without a result, a + * crash, an output limit, a transient resource shortage) leaves the native run in + * an uncertain state and stays 502 so the caller sees an interrupted turn. + */ +export function classifyHarnessFailure(error: unknown): HarnessFailureClass { + if (error instanceof HarnessBusyError) { + return { deterministic: true, status: 400 }; + } + if (error instanceof NativeCliError && error.code === 'policy') { + return { deterministic: true, status: 400 }; + } + if (error instanceof NativeCliError) { + const transient = TRANSIENT_SPAWN.has(error.systemCode ?? ''); + return error.code === 'spawn' && !transient + ? { deterministic: true, status: 400 } + : { deterministic: false, status: 502 }; + } + if (PERMANENT_LAUNCH.has(systemCodeOf(error) ?? '')) { + return { deterministic: true, status: 400 }; + } + return { deterministic: false, status: 502 }; +} diff --git a/plugins/multi-core/src/gateway/harness-handback.ts b/plugins/multi-core/src/gateway/harness-handback.ts new file mode 100644 index 0000000..9209678 --- /dev/null +++ b/plugins/multi-core/src/gateway/harness-handback.ts @@ -0,0 +1,209 @@ +import { randomUUID } from 'node:crypto'; +import { wellFormed } from './display-rows.ts'; +import type { + ContentBlock, + Emit, + MessagesRequest, + MessagesResponse, + RequestMessage, +} from './messages.ts'; +import { safeguardResults } from './safeguards.ts'; + +const HANDBACK_TOOL = 'SubagentHandback'; +const enforcement = /^\[handback-send-enforce\]/; +const fallback = 'Native run finished.'; + +export function handbackOffered(body: MessagesRequest): boolean { + if (!wellFormed(body)) { + return false; + } + return body.tools?.some((tool) => tool.name === HANDBACK_TOOL) ?? false; +} + +function blocks(message: RequestMessage | undefined): ContentBlock[] { + if (!message) { + return []; + } + return typeof message.content === 'string' + ? [{ type: 'text', text: message.content }] + : message.content; +} + +/** These are Claude's delivery protocol, never native-harness input. */ +export function handbackRequestKind(body: MessagesRequest): 'result' | 'enforce' | undefined { + if (!wellFormed(body)) { + return undefined; + } + const messages = body.messages ?? []; + const last = messages.at(-1); + if (last?.role !== 'user') { + return undefined; + } + const content = blocks(last); + if ( + content.some( + (block) => + block.type === 'text' && + typeof block.text === 'string' && + enforcement.test(block.text.trimStart()), + ) + ) { + return 'enforce'; + } + const ids = new Set( + messages.flatMap((message) => + message.role === 'assistant' + ? blocks(message).flatMap((block) => + block.type === 'tool_use' && block.name === HANDBACK_TOOL && block.id ? [block.id] : [], + ) + : [], + ), + ); + if ( + content.length > 0 && + content.every( + (block) => + block.type === 'tool_result' && + block.tool_use_id !== undefined && + ids.has(block.tool_use_id), + ) + ) { + return 'result'; + } + return undefined; +} + +function joined(messages: RequestMessage[]): RequestMessage[] { + const result: RequestMessage[] = []; + for (const message of messages) { + const content = blocks(message); + if (!content.length) { + continue; + } + const previous = result.at(-1); + if (previous?.role === message.role) { + result[result.length - 1] = { + role: message.role, + content: [...blocks(previous), ...content], + }; + } else { + result.push({ role: message.role, content }); + } + } + return result; +} + +/** Strip gateway handbacks and their results before continuation or rewind checks. */ +export function withoutHarnessHandback(body: MessagesRequest): MessagesRequest { + if (!wellFormed(body)) { + return body; + } + const messages = body.messages ?? []; + const tools = body.tools?.filter((tool) => tool.name !== HANDBACK_TOOL); + const ids = new Set( + messages.flatMap((message) => + blocks(message).flatMap((block) => + block.type === 'tool_use' && block.name === HANDBACK_TOOL && block.id ? [block.id] : [], + ), + ), + ); + if (!ids.size) { + return tools?.length === body.tools?.length ? body : { ...body, tools }; + } + const cleaned = messages.map((message) => ({ + ...message, + content: cleanHandbackMessage(message, ids), + })); + return { ...body, ...(body.tools ? { tools } : {}), messages: joined(cleaned) }; +} + +function cleanHandbackMessage(message: RequestMessage, ids: ReadonlySet): ContentBlock[] { + const original = blocks(message); + const content = original.filter( + (block) => + !(block.type === 'tool_use' && block.name === HANDBACK_TOOL) && + !( + block.type === 'tool_result' && + block.tool_use_id !== undefined && + ids.has(block.tool_use_id) + ), + ); + // An empty native answer still has a delivered report. Keep an assistant + // anchor so the next user message is a continuation, not a fresh prompt. + if ( + message.role === 'assistant' && + !content.length && + original.some((block) => block.type === 'tool_use' && block.name === HANDBACK_TOOL) + ) { + return [{ type: 'text', text: fallback }]; + } + return content; +} + +export function recordedReport(response: MessagesResponse | undefined): string | undefined { + if (!response) { + return undefined; + } + // With rows, only the deferred follow-up is the worker's final answer. + const text = + response.multi_followup ?? + response.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join(''); + return text || fallback; +} + +export function withHandback( + response: MessagesResponse, + report: string, + safeguards: unknown, +): MessagesResponse { + // This call delivers the gateway's own reply; it is not a replay of a native tool event. + const block = { + type: 'tool_use' as const, + id: `toolu_multi_handback_${randomUUID().replaceAll('-', '')}`, + name: HANDBACK_TOOL, + input: { message: report || fallback }, + }; + const content = [...response.content, block]; + const existing = response.safeguard_results ?? safeguardResults(safeguards, content, 'native'); + const safeguard_results = existing + ? ([ + { + type: 'dangerous_tool_use' as const, + status: { + type: 'available' as const, + tool_uses: { + ...existing[0].status.tool_uses, + // Nothing reviewed the report, so auto mode's own classifier does. + [block.id]: { type: 'unavailable' as const, reason: 'error' as const }, + }, + }, + }, + ] as const) + : undefined; + return { + ...response, + content, + stop_reason: 'tool_use', + ...(safeguard_results + ? { safeguard_results: [...safeguard_results] as MessagesResponse['safeguard_results'] } + : {}), + }; +} + +/** Append the delivery block after streamed text, before the terminal event. */ +export function emitHandback(emit: Emit, response: MessagesResponse): void { + const block = response.content.at(-1); + if (block?.type !== 'tool_use' || block.name !== HANDBACK_TOOL) { + return; + } + const index = response.content.length - 1; + emit('content_block_start', { index, content_block: { ...block, input: {} } }); + emit('content_block_delta', { + index, + delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) }, + }); + emit('content_block_stop', { index }); +} diff --git a/plugins/multi-core/src/gateway/harness-notices.ts b/plugins/multi-core/src/gateway/harness-notices.ts index 852791f..ca545cd 100644 --- a/plugins/multi-core/src/gateway/harness-notices.ts +++ b/plugins/multi-core/src/gateway/harness-notices.ts @@ -98,6 +98,8 @@ function historyContent(response: MessagesResponse): ContentBlock[] { ); if (response.multi_followup !== undefined) { content.push({ type: 'text', text: response.multi_followup || followUpFallback }); + } else if (!content.length) { + content.push({ type: 'text', text: followUpFallback }); } return content; } diff --git a/plugins/multi-core/src/gateway/harness-process.ts b/plugins/multi-core/src/gateway/harness-process.ts index f5429b7..baea130 100644 --- a/plugins/multi-core/src/gateway/harness-process.ts +++ b/plugins/multi-core/src/gateway/harness-process.ts @@ -1,8 +1,9 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; -import { executableInvocation, resolveExecutable } from './executable.ts'; +import { argvSafe, executableInvocation, resolveExecutable } from './executable.ts'; import { terminateProcessTree } from './process-tree.ts'; +/** Per-line and retained-stderr bound; the stream itself is not capped in total. */ export const defaultMaxOutputBytes = 8 * 1024 * 1024; export const interruptGraceMs = 1500; export const terminateGraceMs = 1500; @@ -17,6 +18,36 @@ export function promptArgumentLimitBytes(platform: NodeJS.Platform): number { return platform === 'win32' ? windowsPromptArgumentLimitBytes : posixPromptArgumentLimitBytes; } +/** + * Whether prompt text may ride in argv to the resolved CLI. A non-shim + * `.cmd`/`.bat` launcher is parsed by cmd.exe, which expands `%VAR%` and has no + * safe escape for quotes, so such prompts must go through stdin. An unresolved + * binary reports true; the spawn itself then fails with a clear ENOENT. + */ +export function promptArgvSafe( + prompt: string, + options: { + executable: string; + configuredPath?: string; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + }, +): boolean { + if (options.platform !== 'win32') { + return true; + } + try { + const resolved = resolveExecutable(options.executable, { + platform: options.platform, + env: options.env, + configuredPath: options.configuredPath, + }); + return argvSafe(resolved, prompt, options.platform); + } catch { + return true; + } +} + /** * Credentials of other providers never reach a native CLI. A `drop` entry ending * in `_` removes a whole prefix; any other entry removes exactly that name. @@ -146,11 +177,11 @@ type RunContext

= { decoder: StringDecoder; limit: number; buffer: string; - stdoutBytes: number; - stderrBytes: number; stderr: string; aborted: boolean; settled: boolean; + /** The child's own `close` arrived: its PID may already belong to someone else. */ + closed: boolean; interruptTimer?: NodeJS.Timeout; terminateTimer?: NodeJS.Timeout; cancellationTimer?: NodeJS.Timeout; @@ -185,11 +216,10 @@ export function runNativeCli

( decoder: new StringDecoder('utf8'), limit: spec.maxOutputBytes ?? defaultMaxOutputBytes, buffer: '', - stdoutBytes: 0, - stderrBytes: 0, stderr: '', aborted: false, settled: false, + closed: false, onAbort: () => {}, resolve, reject, @@ -236,7 +266,10 @@ function attach

(context: RunContext): voi child.stdin?.on('error', (error: Error) => { fail(context, new NativeCliError(`${spec.executable} stdin failed: ${error.message}`, 'spawn')); }); - child.once('close', (exitCode, signal) => finishRun(context, exitCode, signal)); + child.once('close', (exitCode, signal) => { + context.closed = true; + finishRun(context, exitCode, signal); + }); spec.signal.addEventListener('abort', context.onAbort, { once: true }); if (spec.signal.aborted) { context.onAbort(); @@ -250,9 +283,19 @@ function kill

( context: RunContext, signal: NodeJS.Signals, ): void { - if (context.child.pid) { - terminateProcessTree(context.child.pid, { platform: context.spec.platform, signal }); + const { pid } = context.child; + if (!pid) { + return; + } + if (context.closed) { + // The child is gone. taskkill by PID could hit a recycled PID, and on POSIX + // only the group (which outlives its leader while members remain) is safe. + if (context.spec.platform !== 'win32') { + terminateProcessTree(pid, { platform: context.spec.platform, signal, groupOnly: true }); + } + return; } + terminateProcessTree(pid, { platform: context.spec.platform, signal }); } /** Interrupt first, then terminate, then kill: a CLI may save native state. */ @@ -298,33 +341,35 @@ function consumeStdout

( if (context.spec.parser.failure) { return; } - context.stdoutBytes += chunk.byteLength; - if (context.stdoutBytes > context.limit) { - fail( - context, - new NativeCliError(`${context.spec.name} stdout exceeded its safety limit`, 'output_limit'), - ); - return; - } context.buffer += context.decoder.write(chunk); let newline = context.buffer.indexOf('\n'); while (newline >= 0) { const line = context.buffer.slice(0, newline).replace(/\r$/, ''); context.buffer = context.buffer.slice(newline + 1); + if (line.length > context.limit) { + failLineLimit(context); + return; + } parse(context, line); newline = context.buffer.indexOf('\n'); } + // The stream is JSONL and consumed incrementally, so only one unfinished + // line is ever retained; a long healthy run is bounded per line, not in total. if (context.buffer.length > context.limit) { - fail( - context, - new NativeCliError( - `${context.spec.name} stdout line exceeded its safety limit`, - 'output_limit', - ), - ); + failLineLimit(context); } } +function failLineLimit

(context: RunContext): void { + fail( + context, + new NativeCliError( + `${context.spec.name} stdout line exceeded its safety limit`, + 'output_limit', + ), + ); +} + function parse

(context: RunContext, line: string): void { context.spec.parseLine( line, @@ -341,15 +386,11 @@ function consumeStderr

( if (context.spec.parser.failure) { return; } - context.stderrBytes += chunk.byteLength; - if (context.stderrBytes > context.limit) { - fail( - context, - new NativeCliError(`${context.spec.name} stderr exceeded its safety limit`, 'output_limit'), - ); - return; - } + // Only the tail is diagnostic; a chatty CLI must not fail an otherwise healthy run. context.stderr += chunk.toString('utf8'); + if (context.stderr.length > context.limit) { + context.stderr = context.stderr.slice(-context.limit); + } } function abortRun

(context: RunContext): void { diff --git a/plugins/multi-core/src/gateway/harness-progress.ts b/plugins/multi-core/src/gateway/harness-progress.ts index 39935d3..af9efab 100644 --- a/plugins/multi-core/src/gateway/harness-progress.ts +++ b/plugins/multi-core/src/gateway/harness-progress.ts @@ -35,7 +35,7 @@ export type NativeAction = { }; const maximumListed = 8; -const maximumTracked = 512; +export const maximumTracked = 512; const unsettledOutput = "The native run ended without reporting this action's completion."; /** @@ -48,12 +48,18 @@ export class NativeActionSummary { private readonly changed = new Set(); private readonly problems: string[] = []; private unconfirmed = 0; + private untracked = 0; private readonly tag: string; constructor(tag: string) { this.tag = tag; } + /** Counts an action past the tracking cap: seen, but with no row, outcome or change entry. */ + recordUntracked() { + this.untracked++; + } + /** * Counts one finished action; `changed` names what a successful edit touched. * An unconfirmed action is counted but never listed as a change. @@ -78,28 +84,40 @@ export class NativeActionSummary { } } - /** `modelCalls` is how many model calls the run made, when the harness reported them. */ - text(modelCalls = 0): string { - const total = [...this.counts.values()].reduce((sum, count) => sum + count, 0); - if (!total && modelCalls < 2) { - return ''; - } - const calls = modelCalls ? `${modelCalls} model call${modelCalls === 1 ? '' : 's'}` : ''; - const parts = [...this.counts].map(([kind, count]) => `${count} ${kind}`); - const actions = total - ? `${total} native action${total === 1 ? '' : 's'}: ${parts.join(', ')}` - : ''; - const lines = [`[${this.tag}] ${[actions, calls].filter(Boolean).join('; ')}.`]; + private notices(): string[] { + const lines: string[] = []; if (this.changed.size) { lines.push(`[${this.tag}] Changed: ${[...this.changed].join(', ')}.`); } if (this.problems.length) { lines.push(`[${this.tag}] Not completed: ${this.problems.join('; ')}.`); } + if (this.untracked) { + lines.push( + `[${this.tag}] Truncated: ${this.untracked} further action${this.untracked === 1 ? '' : 's'} beyond the first ${maximumTracked} were not tracked, so the counts above and any changes among them are incomplete.`, + ); + } if (this.unconfirmed) { const count = `${this.unconfirmed} action${this.unconfirmed === 1 ? '' : 's'}`; lines.push(`[${this.tag}] Unconfirmed: ${count} ended without a reported outcome.`); } + return lines; + } + + /** `modelCalls` is how many model calls the run made, when the harness reported them. */ + text(modelCalls = 0): string { + const total = [...this.counts.values()].reduce((sum, count) => sum + count, 0); + if (!total && !this.untracked && modelCalls < 2) { + return ''; + } + const calls = modelCalls ? `${modelCalls} model call${modelCalls === 1 ? '' : 's'}` : ''; + const parts = [...this.counts].map(([kind, count]) => `${count} ${kind}`); + const actions = total + ? `${total} native action${total === 1 ? '' : 's'}: ${parts.join(', ')}` + : ''; + const headline = [actions, calls].filter(Boolean).join('; '); + const lines = headline ? [`[${this.tag}] ${headline}.`] : []; + lines.push(...this.notices()); return `\n\n${lines.join('\n')}\n`; } } @@ -139,7 +157,12 @@ export class NativeActionTracker { /** Reports a new action once; a repeated start for the same id is ignored. */ start(id: string, action: NativeAction) { - if (this.actions.has(id) || this.actions.size >= maximumTracked) { + if (this.actions.has(id)) { + return; + } + if (this.actions.size >= maximumTracked) { + // Counted so the summary can say the record stopped; ids are not retained. + this.summary.recordUntracked(); return; } const description = displayLine(action.description) || action.tool || action.kind; diff --git a/plugins/multi-core/src/gateway/harness-session.ts b/plugins/multi-core/src/gateway/harness-session.ts index 6f262aa..aaf3135 100644 --- a/plugins/multi-core/src/gateway/harness-session.ts +++ b/plugins/multi-core/src/gateway/harness-session.ts @@ -1,10 +1,12 @@ import { createHash } from 'node:crypto'; -import { mkdir, readFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { copyFile, mkdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { atomicWriteFile } from './atomic-write.ts'; import { isDisplayTool } from './display-rows.ts'; import type { HarnessEvent } from './harness-exchange.ts'; import type { MessagesResponse } from './messages.ts'; +import { isRecord } from './record.ts'; import { lockStateFile } from './state-lock.ts'; /** @@ -253,6 +255,9 @@ export class HarnessSessionStore = { @@ -289,7 +294,8 @@ export class HarnessSessionStore { + const version = isRecord(saved) ? String(saved.version ?? 'unversioned') : 'unknown'; + const label = version.replaceAll(/[^A-Za-z0-9._-]/g, '_').slice(0, 32); + try { + await copyFile(file, `${file}.unsupported-v${label}`, constants.COPYFILE_EXCL); + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; + if (code !== 'EEXIST' && code !== 'ENOENT') { + throw error; + } + } +} + function resolveStateFile(directory: string, file: string): string { return path.isAbsolute(file) ? file : path.join(directory, file); } @@ -363,10 +387,6 @@ export function isHash(value: unknown): value is string { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); } -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - export function optionalCount(value: unknown): boolean { return value === undefined || (Number.isSafeInteger(value) && Number(value) >= 0); } diff --git a/plugins/multi-core/src/gateway/cursor-settings.ts b/plugins/multi-core/src/gateway/harness-settings.ts similarity index 85% rename from plugins/multi-core/src/gateway/cursor-settings.ts rename to plugins/multi-core/src/gateway/harness-settings.ts index 1860bcd..321efea 100644 --- a/plugins/multi-core/src/gateway/cursor-settings.ts +++ b/plugins/multi-core/src/gateway/harness-settings.ts @@ -5,12 +5,11 @@ import path from 'node:path'; import { assertCursorClaudeSettings, cursorPermissionPolicy, - mergeCursorPermissions, } from '../../../multi-cursor/src/permissions.ts'; import { pluginPermissions, type WorkerPermissions } from './agent-definitions.ts'; import type { PermissionContext } from './mode-hook.ts'; -export interface CursorSettingsOptions { +export interface HarnessSettingsOptions { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; osRelease?: string; @@ -29,30 +28,74 @@ export interface CursorSettingsOptions { } /** Every discovery option resolved to a value; not part of the public surface. */ -type SettingsDiscovery = Required; +type SettingsDiscovery = Required; + +export const policyCommandTimeoutMs = 10_000; + +/** + * Policy probes run from fixed system directories: a bare name would let a + * Windows working directory (searched first) or a user PATH entry answer for + * the managed policy. + */ +export function systemCommandPath( + command: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string { + if (platform === 'win32') { + const root = env.SystemRoot ?? env.SYSTEMROOT ?? env.windir ?? 'C:\\Windows'; + const system32 = path.win32.join(root, 'System32'); + if (command === 'powershell.exe') { + return path.win32.join(system32, 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + } + return path.win32.join(system32, command.endsWith('.exe') ? command : `${command}.exe`); + } + return command === 'defaults' ? '/usr/bin/defaults' : command; +} const defaultRunCommand = (command: string, args: readonly string[]): Promise => new Promise((resolve, reject) => { - childProcess.execFile(command, [...args], { encoding: 'utf8' }, (error, stdout, stderr) => { - if (error) { - Object.assign(error, { stderr }); - reject(error); - return; - } - resolve(stdout); - }); + childProcess.execFile( + systemCommandPath(command), + [...args], + { encoding: 'utf8', timeout: policyCommandTimeoutMs, windowsHide: true, cwd: os.tmpdir() }, + (error, stdout, stderr) => { + if (error) { + Object.assign(error, { stderr }); + reject(error); + return; + } + resolve(stdout); + }, + ); }); +/** Restriction layers intersect grants and accumulate denials; none can widen another. */ +export function mergePermissions( + context: PermissionContext, + rules: WorkerPermissions = {}, +): PermissionContext { + let tools = context.tools; + if (rules.tools !== undefined) { + tools = tools === undefined ? rules.tools : tools.filter((tool) => rules.tools?.includes(tool)); + } + return { + ...context, + tools, + disallowedTools: [...(context.disallowedTools ?? []), ...(rules.disallowedTools ?? [])], + }; +} + /** Re-read on each native dispatch; Claude-side rules cannot constrain SDK tools. */ -export async function checkCursorSettings( +export async function checkHarnessSettings( cwd: string, args: readonly string[], inlineSettings: Record, - options: CursorSettingsOptions = {}, + options: HarnessSettingsOptions = {}, ): Promise { const { sources, restrictions } = settingSources(args); await pluginPermissions(cwd, [...args, '--settings', JSON.stringify(inlineSettings)]); - let context = mergeCursorPermissions({ permissionMode: 'auto' }, restrictions); + let context = mergePermissions({ permissionMode: 'auto' }, restrictions); const { validate = cursorPermissionPolicy, cursorToolRules = options.validate === undefined } = options; const settingsOptions: SettingsDiscovery = { @@ -66,12 +109,12 @@ export async function checkCursorSettings( cursorToolRules, }; context = mergePolicies(context, await managedSettings(settingsOptions)); - context = mergeCursorPermissions( + context = mergePermissions( context, assertCursorClaudeSettings(inlineSettings, { cursorToolRules }), ); if (sources.has('user')) { - context = mergeCursorPermissions( + context = mergePermissions( context, await checkFile( path.join( @@ -85,7 +128,7 @@ export async function checkCursorSettings( } for (let directory = path.resolve(cwd); ; directory = path.dirname(directory)) { if (sources.has('project')) { - context = mergeCursorPermissions( + context = mergePermissions( context, await checkFile( path.join(directory, '.claude', 'settings.json'), @@ -95,7 +138,7 @@ export async function checkCursorSettings( ); } if (sources.has('local')) { - context = mergeCursorPermissions( + context = mergePermissions( context, await checkFile( path.join(directory, '.claude', 'settings.local.json'), @@ -118,7 +161,7 @@ function mergePolicies( ): PermissionContext { let context = initial; for (const policy of policies) { - context = mergeCursorPermissions(context, policy); + context = mergePermissions(context, policy); } return context; } @@ -149,7 +192,7 @@ function settingSources(args: readonly string[]): { } else { const [list, lastIndex] = toolArguments(value, args, index); index = lastIndex; - context = mergeCursorPermissions(context, toolRestriction(name, list)); + context = mergePermissions(context, toolRestriction(name, list)); } } return { sources: selectedSources(sources), restrictions: context }; diff --git a/plugins/multi-core/src/gateway/mod-bridge.ts b/plugins/multi-core/src/gateway/mod-bridge.ts index 0b0919a..f299f6c 100644 --- a/plugins/multi-core/src/gateway/mod-bridge.ts +++ b/plugins/multi-core/src/gateway/mod-bridge.ts @@ -1,3 +1,4 @@ +import { setBounded } from './bounded.ts'; import type { NativeObservation } from './harness-progress.ts'; const MAX_LINE = 160; @@ -55,15 +56,28 @@ export class ModBridge { private readonly lifecycle = new Map(); private readonly telemetry = new Map(); - recordSession(key: string, value: { effective: Effective; cwd?: string; generation?: number }) { - return this.record(key, value); + mode(key: string) { + const snapshot = this.snapshots.get(key); + if (snapshot) { + // A read counts as use: the map's order is the recency eviction reads. + this.snapshots.delete(key); + this.snapshots.set(key, snapshot); + } + return snapshot; } - mode(key: string) { - return this.snapshots.get(key); + /** Drops the least recently used snapshot whose scope has no running native run. */ + private evictSnapshot() { + const idle = [...this.snapshots.keys()].find( + (key) => this.lifecycle.get(key)?.state !== 'running', + ); + const victim = idle ?? this.snapshots.keys().next().value; + if (victim !== undefined) { + this.snapshots.delete(victim); + } } - private record(key: string, value: { effective: Effective; cwd?: string; generation?: number }) { + recordSession(key: string, value: { effective: Effective; cwd?: string; generation?: number }) { if ( value.generation !== undefined && value.generation !== this.snapshots.get(key)?.generation @@ -71,7 +85,7 @@ export class ModBridge { return undefined; } if (!this.snapshots.has(key) && this.snapshots.size >= MAX_KEYS) { - throw new Error('Mod session capacity reached; restart the gateway'); + this.evictSnapshot(); } const snapshot = { generation: ++this.generation, @@ -82,6 +96,7 @@ export class ModBridge { }, cwd: value.cwd, }; + this.snapshots.delete(key); this.snapshots.set(key, snapshot); return snapshot; } @@ -179,10 +194,7 @@ export class ModBridge { } observeStep(key: string, value: { model: string; effort?: string | number }) { - if (!this.telemetry.has(key) && this.telemetry.size >= MAX_KEYS) { - this.telemetry.delete(this.telemetry.keys().next().value as string); - } - this.telemetry.set(key, value); + setBounded(this.telemetry, key, value, MAX_KEYS, 'insertion'); } /** diff --git a/plugins/multi-core/src/gateway/mod-keys.ts b/plugins/multi-core/src/gateway/mod-keys.ts new file mode 100644 index 0000000..7f73298 --- /dev/null +++ b/plugins/multi-core/src/gateway/mod-keys.ts @@ -0,0 +1,83 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; + +// --------------------------------------------------------------------------- +// The gateway token is in the environment of every command Claude runs, so an +// approved `curl` can call the mod control plane with it, including routes that +// set the permission snapshot native harnesses obey. The mod therefore also +// holds a per-session key the gateway mints on the first mod request it sees +// for that session and returns in a response header. The mod keeps it in +// memory, never in the environment, and presents it on every later request. +// +// A session starts enforcing once the mod has presented its key, which any +// mod build that follows the header protocol does with its next request; a +// build that does not never presents one and is never refused. Before that +// first echo a session's requests are admitted, which is the milliseconds +// between the mod's own first request and its second: no tool has run yet. +// --------------------------------------------------------------------------- + +export const MOD_KEY_HEADER = 'x-multi-mod-key'; +const MAX_SESSIONS = 1024; + +interface Entry { + key: string; + confirmed: boolean; +} + +export type ModKeyVerdict = { refused: true } | { refused: false; issue?: string }; + +export class ModSessionKeys { + private readonly entries = new Map(); + + /** Admit or refuse one mod request for `session`; `issue` is a key to return to the mod. */ + check(session: string, presented: string | undefined): ModKeyVerdict { + const entry = this.entries.get(session) ?? this.mint(session); + if (presented === undefined) { + return entry.confirmed ? { refused: true } : { refused: false, issue: entry.key }; + } + if (!matches(presented, entry.key)) { + return { refused: true }; + } + entry.confirmed = true; + return { refused: false }; + } + + /** The mod forgot this session (detach), so a later start under the same ID mints afresh. */ + forget(session: string): void { + this.entries.delete(session); + } + + /** + * Makes room for a new session. An unconfirmed entry goes first: anyone holding the + * gateway token can mint entries for sessions that do not exist, and they must not be + * able to push a confirmed session's key out. A confirmed entry goes only when every + * entry is confirmed. + */ + private evict(): void { + let victim: string | undefined; + for (const [session, entry] of this.entries) { + victim ??= session; + if (!entry.confirmed) { + victim = session; + break; + } + } + if (victim !== undefined) { + this.entries.delete(victim); + } + } + + private mint(session: string): Entry { + if (this.entries.size >= MAX_SESSIONS) { + this.evict(); + } + const entry = { key: randomBytes(24).toString('hex'), confirmed: false }; + this.entries.set(session, entry); + return entry; + } +} + +function matches(actual: string, expected: string): boolean { + const a = Buffer.from(actual); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} diff --git a/plugins/multi-core/src/gateway/mod-policy.ts b/plugins/multi-core/src/gateway/mod-policy.ts index c632ada..c91455f 100644 --- a/plugins/multi-core/src/gateway/mod-policy.ts +++ b/plugins/multi-core/src/gateway/mod-policy.ts @@ -8,6 +8,9 @@ export type PreparedPolicy = { }; type Job = { cwd: string; + createdAt: number; + /** Resolves when discovery ends, ready or failed, so a waiting hook is released at once. */ + settled: Promise; consumed: boolean; generation: string; status: 'pending' | 'ready' | 'failed'; @@ -15,28 +18,55 @@ type Job = { error?: string; }; +/** An unconsumed job is reused for this long; settings read after that are read again. */ +const JOB_TTL_MS = 60_000; +const MAX_SESSIONS = 128; +/** The longest a hook's long poll holds its reply; the hook sends one request, not a loop. */ +const POLICY_WAIT_MS = 8000; + /** File discovery runs detached from the bounded hook requests. */ export class ModPolicies { private inFlight = 0; private readonly jobs = new Map(); private readonly load: (cwd: string) => Promise; + private readonly now: () => number; - constructor(load: (cwd: string) => Promise) { + constructor(load: (cwd: string) => Promise, now: () => number = Date.now) { this.load = load; + this.now = now; } begin(session: string, cwd: string) { const existing = this.jobs.get(session); - if (existing && existing.cwd === cwd && !existing.consumed && existing.status !== 'failed') { + if ( + existing && + existing.cwd === cwd && + !existing.consumed && + existing.status !== 'failed' && + this.now() - existing.createdAt < JOB_TTL_MS + ) { return { generation: existing.generation, status: existing.status }; } if (this.inFlight >= 64) { throw new Error('Policy discovery capacity reached'); } - if (!this.jobs.has(session) && this.jobs.size >= 128) { - throw new Error('Policy session capacity reached'); + if (!this.jobs.has(session) && this.jobs.size >= MAX_SESSIONS) { + this.evictIdle(); } - const job: Job = { cwd, consumed: false, generation: randomUUID(), status: 'pending' }; + let settle = () => {}; + const settled = new Promise((resolve) => { + settle = resolve; + }); + const job: Job = { + cwd, + createdAt: this.now(), + settled, + consumed: false, + generation: randomUUID(), + status: 'pending', + }; + // Re-inserted last: the map's order is the recency eviction reads. + this.jobs.delete(session); this.jobs.set(session, job); this.inFlight++; void this.load(cwd).then( @@ -46,16 +76,49 @@ export class ModPolicies { job.policy = structuredClone(policy); job.status = 'ready'; } + settle(); }, (error: unknown) => { this.inFlight--; job.status = 'failed'; job.error = error instanceof Error ? error.message : 'Policy discovery failed'; + settle(); }, ); return { generation: job.generation, status: job.status }; } + /** Makes room by dropping the least recently begun session whose discovery has ended. */ + private evictIdle() { + for (const [session, job] of this.jobs) { + if (job.status !== 'pending') { + this.jobs.delete(session); + return; + } + } + throw new Error('Policy session capacity reached; every session is still discovering'); + } + + /** + * The status once discovery has ended, or still pending after `timeoutMs`: a hook asks + * once and the gateway holds the reply, so the wait costs the hook no polling loop. + */ + async wait(session: string, generation: string, timeoutMs = POLICY_WAIT_MS) { + const job = this.jobs.get(session); + if (job?.generation === generation && job.status === 'pending') { + let timer: ReturnType | undefined; + const expired = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }); + try { + await Promise.race([job.settled, expired]); + } finally { + clearTimeout(timer); + } + } + return this.status(session, generation); + } + status(session: string, generation: string) { const job = this.jobs.get(session); if (!job || job.generation !== generation) { diff --git a/plugins/multi-core/src/gateway/mod-routes.ts b/plugins/multi-core/src/gateway/mod-routes.ts index 0eb967f..90d67f4 100644 --- a/plugins/multi-core/src/gateway/mod-routes.ts +++ b/plugins/multi-core/src/gateway/mod-routes.ts @@ -2,14 +2,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { DisplayRows } from './display-rows.ts'; import type { ModBridge } from './mod-bridge.ts'; import type { ModCompactions } from './mod-compaction.ts'; +import type { ModSessionKeys } from './mod-keys.ts'; import { usageRoute } from './mod-usage.ts'; import type { PermissionContext, PermissionModes } from './mode-hook.ts'; import type { ProviderUsageDashboard } from './provider-usage.ts'; import type { ReceiptLedger } from './receipts.ts'; +import { isRecord } from './record.ts'; -function record(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} function text(value: unknown, name: string) { if (typeof value !== 'string' || value.length === 0 || value.length > 512) { throw new Error(`Invalid ${name}`); @@ -32,19 +31,27 @@ function method(req: IncomingMessage, expected: string) { } } +/** The gateway state the mod control plane reads and acts on. */ +export interface ModRouteContext { + bridge: ModBridge; + permissionModes?: PermissionModes; + compactions?: ModCompactions; + receipts?: ReceiptLedger; + billedUsage?: (session: string) => Promise; + dashboard?: ProviderUsageDashboard; + rows?: DisplayRows; + /** The per-session keys; a detached session's key is dropped with the session. */ + keys?: ModSessionKeys; +} + export async function handleModRoute( req: IncomingMessage, res: ServerResponse, url: URL, parsed: unknown, - bridge: ModBridge, - permissionModes?: PermissionModes, - compactions?: ModCompactions, - receipts?: ReceiptLedger, - billedUsage?: (session: string) => Promise, - dashboard?: ProviderUsageDashboard, - rows?: DisplayRows, + context: ModRouteContext, ) { + const { bridge, receipts, billedUsage, dashboard, rows } = context; try { if ( ['/multi/mod/usage', '/multi/mod/receipts', '/multi/mod/usage/complete'].includes( @@ -59,16 +66,7 @@ export async function handleModRoute( switch (url.pathname) { case '/multi/mod/telemetry': if (req.method === 'POST') { - return handlePostRoute( - req, - res, - url.pathname, - parsed, - bridge, - permissionModes, - compactions, - rows, - ); + return handlePostRoute(req, res, url.pathname, parsed, context); } method(req, 'GET'); return reply( @@ -107,16 +105,7 @@ export async function handleModRoute( bridge, ); default: - return await handlePostRoute( - req, - res, - url.pathname, - parsed, - bridge, - permissionModes, - compactions, - rows, - ); + return await handlePostRoute(req, res, url.pathname, parsed, context); } } catch (error) { return reply( @@ -132,42 +121,27 @@ async function handlePostRoute( res: ServerResponse, route: string, parsed: unknown, - bridge: ModBridge, - permissionModes?: PermissionModes, - compactions?: ModCompactions, - rows?: DisplayRows, + context: ModRouteContext, ) { + const { bridge, permissionModes, compactions, rows, keys } = context; method(req, 'POST'); - if (!record(parsed)) { + if (!isRecord(parsed)) { throw new Error('Expected an object'); } const sessionId = text(parsed.sessionId, 'sessionId'); const key = sessionKey(sessionId, parsed.agentId); if (route.startsWith('/multi/mod/compact/')) { - return compactRoute(res, route, parsed, bridge, permissionModes, compactions); + return compactRoute(res, route, parsed, context); } switch (route) { case '/multi/mod/policy': - if (parsed.generation === undefined) { - if (parsed.sourceGeneration !== bridge.mode(key)?.generation) { - throw new Error('Policy source generation is stale'); - } - compactions?.cancel(sessionId); - } - if (!permissionModes) { - throw new Error('Policy admission is unavailable'); - } - return reply( - res, - parsed.generation === undefined - ? permissionModes.beginPolicy(sessionId, text(parsed.cwd, 'cwd')) - : permissionModes.policies.status(sessionId, text(parsed.generation, 'generation')), - ); + return await policyRoute(res, parsed, key, bridge, permissionModes, compactions); case '/multi/mod/detach': compactions?.cancel(sessionId); bridge.forgetSession(sessionId); rows?.forgetSession(sessionId); permissionModes?.forgetSession(sessionId); + keys?.forget(sessionId); return reply(res, { accepted: true }); case '/multi/mod/telemetry': bridge.observeStep(key, { @@ -195,6 +169,40 @@ async function handlePostRoute( } } +/** + * Starts a policy discovery for a prompt, or reports one: with `wait` the reply is held + * until discovery ends (bounded), so a hook asks once instead of polling. + */ +async function policyRoute( + res: ServerResponse, + parsed: Record, + key: string, + bridge: ModBridge, + permissionModes?: PermissionModes, + compactions?: ModCompactions, +) { + const sessionId = text(parsed.sessionId, 'sessionId'); + if (parsed.generation === undefined) { + if (parsed.sourceGeneration !== bridge.mode(key)?.generation) { + throw new Error('Policy source generation is stale'); + } + compactions?.cancel(sessionId); + } + if (!permissionModes) { + throw new Error('Policy admission is unavailable'); + } + if (parsed.generation === undefined) { + return reply(res, permissionModes.beginPolicy(sessionId, text(parsed.cwd, 'cwd'))); + } + const generation = text(parsed.generation, 'generation'); + return reply( + res, + parsed.wait === true + ? await permissionModes.policies.wait(sessionId, generation) + : permissionModes.policies.status(sessionId, generation), + ); +} + /** * Display rows: the mod acknowledges the display tools it registered, and * answers a row's call with the native output only for a token this gateway @@ -208,7 +216,7 @@ function displayRoute( rows: DisplayRows | undefined, ) { method(req, 'POST'); - if (!record(parsed)) { + if (!isRecord(parsed)) { throw new Error('Expected an object'); } const sessionId = text(parsed.sessionId, 'sessionId'); @@ -216,7 +224,7 @@ function displayRoute( throw new Error('Display rows are unavailable'); } if (route === '/multi/mod/display-tools') { - return reply(res, rows.acknowledge(parsed.registered)); + return reply(res, rows.acknowledge(sessionId, parsed.registered)); } const row = rows.verify(sessionId, parsed.token, parsed.toolUseId); if (!row) { @@ -367,16 +375,14 @@ function compactRoute( res: ServerResponse, route: string, value: Record, - bridge: ModBridge, - modes?: PermissionModes, - compactions?: ModCompactions, + { bridge, permissionModes: modes, compactions }: ModRouteContext, ) { const session = text(value.sessionId, 'sessionId'); if (route === '/multi/mod/compact/cancel') { - compactions?.cancelScope( - session, - value.agentId === undefined ? undefined : text(value.agentId, 'agentId'), - ); + const agentId = value.agentId === undefined ? undefined : text(value.agentId, 'agentId'); + compactions?.cancelScope(session, agentId); + // A cancelled compaction also disarms its permission boundary, the main loop's included. + modes?.cancelModCompaction(session, agentId); return reply(res, { accepted: true }); } const generation = optionalGeneration(value); @@ -425,7 +431,7 @@ function transcript(value: unknown): Record[] { !Array.isArray(value) || value.length === 0 || value.length > 256 || - value.some((item) => !record(item)) + value.some((item) => !isRecord(item)) ) { throw new Error('Invalid bounded compaction transcript'); } diff --git a/plugins/multi-core/src/gateway/mode-hook.ts b/plugins/multi-core/src/gateway/mode-hook.ts index d016dcf..780ec8a 100644 --- a/plugins/multi-core/src/gateway/mode-hook.ts +++ b/plugins/multi-core/src/gateway/mode-hook.ts @@ -1,14 +1,16 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; -import { nativeSpelling } from '../../../multi-antigravity/src/models.ts'; -import { mergeCursorPermissions } from '../../../multi-cursor/src/permissions.ts'; import type { WorkerPermissions } from './agent-definitions.ts'; +import { setBounded } from './bounded.ts'; +import { mergePermissions } from './harness-settings.ts'; import { ModPolicies } from './mod-policy.ts'; +import { harnessProvider, nativeSpelling } from './provider.ts'; import { type ResolvedWorker, resolveWorker, type WorkerCatalog } from './worker-catalog.ts'; const MODES = ['default', 'acceptEdits', 'auto', 'dontAsk', 'bypassPermissions', 'plan'] as const; type PermissionMode = (typeof MODES)[number]; type WorkerExecution = 'claude' | 'harness'; +const MAX_ENTRIES = 4096; export interface PermissionContext extends WorkerPermissions { permissionMode: PermissionMode; cwd?: string; @@ -31,20 +33,18 @@ function requiredString(value: unknown, name: string): string { } function executionForModel(model: unknown): WorkerExecution { - if (typeof model !== 'string') { - return 'claude'; - } - return model.startsWith('multi/cursor/') || - model.startsWith('multi/antigravity/') || - model.startsWith('multi/grok/') - ? 'harness' - : 'claude'; + return harnessProvider(model) ? 'harness' : 'claude'; } /** Prompt-time snapshots: the existing selector takes effect at the next prompt. */ export class PermissionModes { readonly policies: ModPolicies; - private readonly compactions = new Map(); + /** + * Armed compaction boundaries, keyed by exactly one [session, 'main' | worker]. The + * main loop's boundary is applied by `resolve` and never written into its snapshot, + * so no worker inherits it; a worker's `previous` is what it ran under before. + */ + private readonly compactions = new Map(); private readonly parents = new Map(); private readonly hostOnly = new Set(); /** The last admitted settings restrictions per session, retained across host snapshots. */ @@ -82,9 +82,9 @@ export class PermissionModes { beginPolicy(session: string, cwd: string) { this.parents.delete(session); - for (const key of this.compactions.keys()) { + for (const key of [...this.compactions.keys()]) { if (JSON.parse(key)[0] === session) { - this.compactions.delete(key); + this.clearCompaction(key); } } for (const key of this.pendingWorkers.keys()) { @@ -99,7 +99,7 @@ export class PermissionModes { const policy = this.policies.consume(session, generation, requiredString(context.cwd, 'cwd')); remember(this.catalogs, policy.cwd, policy.workers); this.recordModSession(session, { - ...mergeCursorPermissions(context, policy.restrictions), + ...mergePermissions(context, policy.restrictions), nativePermissionError: policy.restrictions.nativePermissionError, }); remember(this.admitted, session, structuredClone(policy.restrictions)); @@ -160,7 +160,7 @@ export class PermissionModes { private withAdmittedPolicy(session: string, context: PermissionContext): PermissionContext { const restrictions = this.admitted.get(session) ?? {}; return { - ...mergeCursorPermissions(context, restrictions), + ...mergePermissions(context, restrictions), nativePermissionError: context.nativePermissionError ?? restrictions.nativePermissionError, }; } @@ -207,7 +207,7 @@ export class PermissionModes { } this.prunePendingWorkers(); const token = randomUUID(); - const inherited = input.parentAgentId ? mergeCursorPermissions(parent, definition) : definition; + const inherited = input.parentAgentId ? mergePermissions(parent, definition) : definition; remember(this.pendingWorkers, JSON.stringify([session, token]), { ...inherited, cwd, @@ -344,14 +344,14 @@ export class PermissionModes { authorizeModCompaction(session: string, agent?: string): void { const current = this.resolveHarness(session, agent); const key = JSON.stringify([session, agent ?? 'main']); - const previous = this.compactions.get(key)?.previous ?? current; - const context = { ...current, tools: [], compaction: randomUUID() }; - remember(this.compactions, key, { id: context.compaction, previous }); - if (agent) { - this.recordModWorker(session, agent, context); - } else { - this.recordModSession(session, context); + const id = randomUUID(); + if (!agent) { + remember(this.compactions, key, { id }); + return; } + const previous = this.compactions.get(key)?.previous ?? current; + remember(this.compactions, key, { id, previous }); + this.recordModWorker(session, agent, { ...current, tools: [], compaction: id }); } /** Arm a tool-free boundary when SessionStart restored no prompt snapshot. */ @@ -364,58 +364,30 @@ export class PermissionModes { this.authorizeModCompaction(session); } + /** The compaction turn ended, successfully or not: the scope runs under its own policy again. */ finishModCompaction(session: string, agent: string | undefined, id: string | undefined): void { const key = JSON.stringify([session, agent ?? 'main']); - const saved = this.compactions.get(key); - if (!id || saved?.id !== id) { + if (!id || this.compactions.get(key)?.id !== id) { return; } - this.compactions.delete(key); - if (this.resolve(session, agent).compaction !== id) { - return; - } - if (agent) { - this.recordModWorker(session, agent, saved.previous); - } else { - this.recordModSession(session, saved.previous); - } + this.clearCompaction(key); + } + + /** Disarm one scope's boundary without a result, for a cancelled or abandoned compaction. */ + cancelModCompaction(session: string, agent?: string): void { + this.clearCompaction(JSON.stringify([session, agent ?? 'main'])); } - async record(input: Record): Promise { - const session = requiredString(input.session_id, 'session_id'); - if (input.hook_event_name === 'UserPromptSubmit') { - // Clear first: a malformed new snapshot must not retain earlier permissions. - this.parents.delete(session); - remember(this.parents, session, { permissionMode: permissionMode(input.permission_mode) }); + private clearCompaction(key: string): void { + const saved = this.compactions.get(key); + if (!saved) { return; } - if (input.hook_event_name !== 'SubagentStart') { - throw new Error('Unsupported mode hook event'); - } - const agent = requiredString(input.agent_id, 'agent_id'); - const key = JSON.stringify([session, agent]); - this.workers.delete(key); - const type = requiredString(input.agent_type, 'agent_type'); - const cwd = requiredString(input.cwd, 'cwd'); - const definitions = await this.definitions(cwd); - const definition = Object.hasOwn(definitions, type) ? definitions[type] : undefined; - if (!definition) { - throw new Error(`Cannot resolve permissions for Claude worker ${type}`); + this.compactions.delete(key); + const [session, agent] = JSON.parse(key) as [string, string]; + if (agent !== 'main' && saved.previous && this.workers.has(key)) { + this.recordModWorker(session, agent, saved.previous); } - remember(this.workers, key, { - cwd, - model: - definition.model === 'inherit' - ? this.parents.get(session)?.model - : (definition.model ?? this.parents.get(session)?.model), - // A definition without a mode inherits the parent's at resolve time. - permissionMode: definition.permissionMode, - tools: definition.tools?.slice(), - disallowedTools: definition.disallowedTools?.slice(), - ...(definition.nativePermissionError - ? { nativePermissionError: definition.nativePermissionError } - : {}), - }); } forgetSession(session: string): void { @@ -443,7 +415,9 @@ export class PermissionModes { throw new Error('Claude permission mode is unavailable; submit a new prompt'); } if (!agent) { - return structuredClone(parent); + const armed = this.compactions.get(JSON.stringify([session, 'main'])); + const context = structuredClone(parent); + return armed ? { ...context, tools: [], compaction: armed.id } : context; } const worker = this.workers.get(JSON.stringify([session, agent])); if (!worker) { @@ -454,11 +428,10 @@ export class PermissionModes { const inherited = ['auto', 'acceptEdits', 'bypassPermissions', 'plan'].includes( parent.permissionMode, ); - return mergeCursorPermissions( + return mergePermissions( { ...worker, nativePermissionError: worker.nativePermissionError ?? parent.nativePermissionError, - ...(parent.compaction ? { compaction: parent.compaction } : {}), permissionMode: inherited ? parent.permissionMode : permissionMode(worker.permissionMode ?? parent.permissionMode), @@ -538,10 +511,10 @@ function validateWorkerRequest( } } +/** + * A write refreshes the entry; at the cap the least recently written one makes room, so a + * long-lived gateway never refuses new work for the sake of identities long idle. + */ function remember(entries: Map, key: string, value: T): void { - // ponytail: cap lifetime identities; add lifecycle cleanup if long sessions reach this limit. - if (!entries.has(key) && entries.size >= 4096) { - throw new Error('Claude permission context limit reached; restart the gateway'); - } - entries.set(key, value); + setBounded(entries, key, value, MAX_ENTRIES, 'lru'); } diff --git a/plugins/multi-core/src/gateway/nested-env.ts b/plugins/multi-core/src/gateway/nested-env.ts new file mode 100644 index 0000000..b23da3a --- /dev/null +++ b/plugins/multi-core/src/gateway/nested-env.ts @@ -0,0 +1,55 @@ +// --------------------------------------------------------------------------- +// A Multi session points Claude Code at its own gateway through environment +// variables. Every process Claude starts inherits them, including a `claude -p` +// run by an agent's Bash tool, an SDK run, or a script. Those runs are not part +// of the session; once the session exits the gateway is gone, and until then +// they would route through (and inherit the credentials of) a gateway that was +// not started for them. A nested run gets the environment the session started +// with instead (`withoutGateway`, in install/process.ts: the installed bootstrap +// has to carry it without the rest of the gateway). +// --------------------------------------------------------------------------- + +export interface NestedShimFile { + file: string; + content: string; +} + +/** + * The `claude` launchers a session puts first on PATH for the processes it starts. On + * Windows that is `claude.cmd` for cmd.exe and PowerShell, plus an extensionless sh + * script: Claude Code's Bash tool there is Git Bash, which does not apply PATHEXT and + * would otherwise find npm's own extensionless `claude`. + */ +export function nestedClaudeShim(options: { + platform?: NodeJS.Platform; + node: string; + script: string; + claude: string; +}): NestedShimFile & { files: NestedShimFile[] } { + const { node, script, claude } = options; + const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + const sh: NestedShimFile = { + file: 'claude', + content: `#!/bin/sh\nexec ${quote(node)} ${quote(script)} ${quote(claude)} "$@"\n`, + }; + if ((options.platform ?? process.platform) === 'win32') { + const cmd: NestedShimFile = { + file: 'claude.cmd', + content: `@echo off\r\n"${node}" "${script}" "${claude}" %*\r\n`, + }; + return { ...cmd, files: [cmd, sh] }; + } + return { ...sh, files: [sh] }; +} + +/** `directory` first on PATH, whatever case the platform spells the variable in. */ +export function withPathPrefix( + env: NodeJS.ProcessEnv, + directory: string, + platform: NodeJS.Platform = process.platform, +): NodeJS.ProcessEnv { + const name = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'; + const delimiter = platform === 'win32' ? ';' : ':'; + const existing = env[name]; + return { ...env, [name]: existing ? `${directory}${delimiter}${existing}` : directory }; +} diff --git a/plugins/multi-core/src/gateway/process-tree.ts b/plugins/multi-core/src/gateway/process-tree.ts index fc4aa7d..89483dc 100644 --- a/plugins/multi-core/src/gateway/process-tree.ts +++ b/plugins/multi-core/src/gateway/process-tree.ts @@ -4,18 +4,24 @@ export interface ProcessTreeOptions { platform?: NodeJS.Platform; signal?: NodeJS.Signals; kill?: (pid: number, signal?: NodeJS.Signals | number) => void; - taskkill?: (pid: number) => void; + /** Windows only: `force` adds `/F`; without it the tree is asked to close. */ + taskkill?: (pid: number, force: boolean) => void; + /** POSIX only: signal the group but never the bare PID (it may be reused). */ + groupOnly?: boolean; } /** * Stop a CLI and its descendants. POSIX group membership is best effort: a * child can leave the group, so the direct-PID fallback is always attempted. * Windows has no portable POSIX group signal, therefore taskkill owns the tree. + * Only SIGKILL (or an unspecified signal, the historical behavior) forces; any + * other signal asks politely so a CLI can still save state. */ export function terminateProcessTree(pid: number, options: ProcessTreeOptions = {}): void { const platform = options.platform ?? process.platform; if (platform === 'win32') { - (options.taskkill ?? defaultTaskkill)(pid); + const force = options.signal === undefined || options.signal === 'SIGKILL'; + (options.taskkill ?? defaultTaskkill)(pid, force); return; } const kill = options.kill ?? process.kill; @@ -25,6 +31,9 @@ export function terminateProcessTree(pid: number, options: ProcessTreeOptions = } catch { // The process may have exited, or the group may not exist. } + if (options.groupOnly) { + return; + } try { kill(pid, signal); } catch { @@ -32,10 +41,10 @@ export function terminateProcessTree(pid: number, options: ProcessTreeOptions = } } -function defaultTaskkill(pid: number): void { +function defaultTaskkill(pid: number, force: boolean): void { const child = spawn( process.env.ComSpec ?? 'cmd.exe', - ['/d', '/c', 'taskkill', '/T', '/F', '/PID', String(pid)], + ['/d', '/c', 'taskkill', '/T', ...(force ? ['/F'] : []), '/PID', String(pid)], { stdio: 'ignore', windowsHide: true, diff --git a/plugins/multi-core/src/gateway/provider-request.ts b/plugins/multi-core/src/gateway/provider-request.ts new file mode 100644 index 0000000..9af1896 --- /dev/null +++ b/plugins/multi-core/src/gateway/provider-request.ts @@ -0,0 +1,61 @@ +// The boundary between the gateway's routing and a hosted-API provider (OpenAI, Zen). +// A provider module turns one Claude request into a `PreparedRequest`; the gateway owns +// keepalive, events, failure mapping and the reply, and never builds a provider request. + +import type { GatewayFetch } from './fetch.ts'; +import type { Emit, MessagesRequest, MessagesResponse } from './messages.ts'; + +/** The provider rejected or could not renew the credentials; answered as HTTP 401. */ +export class ProviderAuthError extends Error {} + +/** A non-2xx upstream reply, relayed with its status and `retry-after`. */ +export class UpstreamFailure extends Error { + status: number; + retryAfter: string | null; + constructor(status: number, retryAfter: string | null, provider: string, authHelp: string) { + super(`${provider} returned HTTP ${status}.${status === 401 ? authHelp : ''}`); + this.status = status; + this.retryAfter = retryAfter; + } +} + +/** Routing facts a provider reports for one request; never credentials or bodies. */ +interface ProviderEvent { + route: 'openai' | 'openai-request' | 'zen' | 'zen-request'; + agentId?: string | null; + model?: string; + effort?: string; + status?: number; +} + +/** What the gateway knows about the request before any provider sees it. */ +export interface ProviderRequestInput { + method?: string; + pathname: string; + body: MessagesRequest; + /** The Claude session the request identifies; empty when the client named none. */ + session: string; + /** The gateway's own session, for a client that identified none. */ + fallbackSession: string; + /** The session header exactly as the client sent it. */ + clientSession?: string; + agentId?: string; +} + +export interface PreparedRequest { + /** How failures name the provider. */ + label: string; + /** What a 401 adds to the failure text. */ + authHelp: string; + /** The API the request is billed through, reported on completion. */ + endpoint: string; + route: 'openai' | 'zen'; + /** The local input estimate; computed on first use, since a streamed reply needs it late. */ + inputTokens(): number; + /** Model and effort the completion reports in place of the client's request, if known. */ + completion?: { model: string; effort?: string }; + requestEvent(agentId?: string): ProviderEvent; + failureEvent(status: number, agentId?: string): ProviderEvent; + send(signal: AbortSignal, fetchImpl: GatewayFetch): Promise; + translate(stream: ReadableStream, emit: Emit | undefined): Promise; +} diff --git a/plugins/multi-core/src/gateway/provider.ts b/plugins/multi-core/src/gateway/provider.ts new file mode 100644 index 0000000..6623078 --- /dev/null +++ b/plugins/multi-core/src/gateway/provider.ts @@ -0,0 +1,58 @@ +// The one place that reads a model ID's provider prefix. Every routing, review, +// signal and execution decision derives from `providerOf`. + +const HARNESS_PROVIDERS = ['cursor', 'antigravity', 'grok'] as const; +export type HarnessProvider = (typeof HARNESS_PROVIDERS)[number]; +type ProviderName = HarnessProvider | 'openai' | 'zen'; +export type ProviderRoute = ProviderName | 'anthropic'; + +const PROVIDERS: readonly ProviderName[] = [...HARNESS_PROVIDERS, 'openai', 'zen']; + +/** The provider whose `multi//` prefix the model carries, if any. */ +function providerOf(model: unknown): ProviderName | undefined { + if (typeof model !== 'string') { + return undefined; + } + return PROVIDERS.find((provider) => model.startsWith(`multi/${provider}/`)); +} + +export function isHarnessProvider(provider: string | undefined): provider is HarnessProvider { + return HARNESS_PROVIDERS.some((name) => name === provider); +} + +/** The native harness that runs the model's actions itself. */ +export function harnessProvider(model: unknown): HarnessProvider | undefined { + const provider = providerOf(model); + return isHarnessProvider(provider) ? provider : undefined; +} + +/** An external model with an unrecognized prefix still routes to OpenAI; no model is Claude's. */ +export function providerRoute(model: string | null | undefined): ProviderRoute { + return providerOf(model) ?? (model ? 'openai' : 'anthropic'); +} + +/** + * Whose reviewer judges the model's actions: a provider's own, never Zen (whose auto mode + * is unreviewed) or Claude. An unrecognized `multi/` prefix routes to OpenAI, so it is + * reviewed like any other external model. + */ +export function providerOwnedReview(model: string): boolean { + return model.startsWith('multi/') && providerOf(model) !== 'zen'; +} + +/** Claude's context tag is display metadata, never part of a native model ID. */ +export function nativeSpelling(model: string | undefined): string | undefined { + return model?.replace(/\[1m\]$/i, ''); +} + +/** The upstream a harness bills through, reported on its completion events. */ +export function harnessEndpoint(provider: HarnessProvider): string { + return { cursor: '@cursor/sdk', antigravity: 'agy', grok: 'grok' }[provider]; +} + +/** What a request for a harness the gateway does not run is told. */ +export const harnessUnavailable: Record = { + cursor: 'Cursor SDK is not signed in. Run the launcher with --cursor-login first.', + antigravity: 'Antigravity is unavailable. Install and connect the multi-antigravity plugin.', + grok: 'Grok is unavailable. Install Grok Build, run grok login, and relaunch.', +}; diff --git a/plugins/multi-core/src/gateway/receipts.ts b/plugins/multi-core/src/gateway/receipts.ts index ce454fb..46819ff 100644 --- a/plugins/multi-core/src/gateway/receipts.ts +++ b/plugins/multi-core/src/gateway/receipts.ts @@ -1,8 +1,10 @@ import { randomUUID } from 'node:crypto'; import { appendFile } from 'node:fs/promises'; +import { addBounded } from './bounded.ts'; import type { GatewayEvent } from './server.ts'; const RECEIPT_SCHEMA_VERSION = 1; +const MAX_REMEMBERED = 8192; type Usage = NonNullable & { reasoning_tokens?: number; @@ -297,7 +299,7 @@ export class ReceiptLedger { return; } if (event.requestId) { - retain(this.seenRequests, event.requestId); + addBounded(this.seenRequests, event.requestId, MAX_REMEMBERED); } this.start(event); const pending = this.pending.get(keyFor(event)); @@ -338,7 +340,7 @@ export class ReceiptLedger { } this.pending.delete(keyFor(ref)); if (ref.invocationId) { - retain(this.completedKeys, dedup); + addBounded(this.completedKeys, dedup, MAX_REMEMBERED); } const snapshot = copySnapshot(pending.snapshot); const receipt: WorkerUsageReceipt = { @@ -394,13 +396,3 @@ export class ReceiptLedger { return this.queue; } } - -function retain(set: Set, key: string) { - set.add(key); - if (set.size > 8192) { - const oldest = set.values().next().value; - if (oldest !== undefined) { - set.delete(oldest); - } - } -} diff --git a/plugins/multi-core/src/gateway/record.ts b/plugins/multi-core/src/gateway/record.ts new file mode 100644 index 0000000..54808fb --- /dev/null +++ b/plugins/multi-core/src/gateway/record.ts @@ -0,0 +1,4 @@ +/** A plain JSON object: not null and not an array. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/plugins/multi-openai/src/responses.ts b/plugins/multi-core/src/gateway/responses.ts similarity index 91% rename from plugins/multi-openai/src/responses.ts rename to plugins/multi-core/src/gateway/responses.ts index 4de82b3..6ad909f 100644 --- a/plugins/multi-openai/src/responses.ts +++ b/plugins/multi-core/src/gateway/responses.ts @@ -1,25 +1,20 @@ import { isDeepStrictEqual } from 'node:util'; -import { - type NormalizedContent, - normalizeConversation, - textContent, -} from '../../multi-core/src/gateway/conversation.ts'; -import { isDirectToolAvailable } from '../../multi-core/src/gateway/direct-tools.ts'; +import { type NormalizedContent, normalizeConversation, textContent } from './conversation.ts'; +import { isDirectToolAvailable } from './direct-tools.ts'; import type { Emit, MessagesRequest, MessagesResponse, ResponseContentBlock, StopReason, -} from '../../multi-core/src/gateway/messages.ts'; -import { - type SafeguardProvider, - safeguardResults, -} from '../../multi-core/src/gateway/safeguards.ts'; -import { callId, toolName } from '../../multi-core/src/gateway/tools.ts'; +} from './messages.ts'; +import { isRecord } from './record.ts'; +import { type SafeguardProvider, safeguardResults } from './safeguards.ts'; +import { callId, toolName } from './tools.ts'; // Anthropic Messages <-> OpenAI Responses, for native Claude Code workers. -const SIGNATURE_PREFIX = 'multi-openai:'; +/** Signature prefix OpenAI reasoning state carries; other providers pass their own. */ +export const OPENAI_SIGNATURE_PREFIX = 'multi-openai:'; const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; export type Effort = (typeof EFFORTS)[number]; @@ -115,10 +110,6 @@ interface ReasoningState { } // Boundary guards: JSON.parse and the provider stream hand us `unknown`. -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function isOutputItem(value: unknown, done: boolean): value is ResponsesOutputItem { if ( !isRecord(value) || @@ -251,8 +242,12 @@ function isEffort(value: string): value is Effort { return (EFFORTS as readonly string[]).includes(value); } -// Only rewrite Claude-bound history when it contains our provider's opaque state. -export function forAnthropic(body: MessagesRequest): MessagesRequest { +// Only rewrite Claude-bound history when it contains a provider's opaque state; +// the caller supplies every provider signature prefix to strip. +export function forAnthropic( + body: MessagesRequest, + foreignPrefixes: readonly string[], +): MessagesRequest { let changed = false; const messages = body.messages ?.map((message) => { @@ -262,9 +257,7 @@ export function forAnthropic(body: MessagesRequest): MessagesRequest { const content = message.content.filter((block) => { const foreign = block.type === 'thinking' && - [SIGNATURE_PREFIX, 'multi-zen-responses:', 'multi-zen-chat:'].some((prefix) => - block.signature?.startsWith(prefix), - ); + foreignPrefixes.some((prefix) => block.signature?.startsWith(prefix)); changed ||= Boolean(foreign); return !foreign; }); @@ -426,7 +419,7 @@ function decodeReasoning(signaturePrefix: string) { export function toResponses( body: MessagesRequest, model: string, - signaturePrefix = SIGNATURE_PREFIX, + signaturePrefix = OPENAI_SIGNATURE_PREFIX, ): ResponsesRequest { validateRequestOptions(body); const format = outputFormat(body); @@ -598,6 +591,8 @@ class ResponseStream { private message?: MessagesResponse; private cursor = 0; private stopped: string | null = null; + /** A stop sequence ended the content; the terminal event is awaited only for usage. */ + stopPending = false; completed = false; private model: string; private emit: Emit; @@ -633,14 +628,24 @@ class ResponseStream { if (!this.message) { throw new Error('OpenAI stream omitted response.created'); } + if (this.stopPending) { + return; + } this.update(event); this.drain(); if (this.stopped) { - this.finish('stop_sequence'); + this.stopPending = true; } } } + /** The terminal event never came in time; the answer is complete but its usage is unknown. */ + finishWithoutUsage() { + if (this.stopPending && !this.completed) { + this.finish('stop_sequence'); + } + } + private start(response: ResponsesResponse) { if (this.message) { return; @@ -706,6 +711,11 @@ class ResponseStream { { type: 'response.completed' | 'response.incomplete' | 'response.done' } >, ) { + if (this.stopPending) { + this.start(event.response); + this.finish('stop_sequence', event.response.usage); + return; + } if ( this.options.requireUsage && (event.response.usage?.input_tokens === undefined || @@ -714,16 +724,7 @@ class ResponseStream { throw new Error('Provider completed without token usage; cost accounting is unavailable'); } this.start(event.response); - if (event.response.status && !['completed', 'incomplete'].includes(event.response.status)) { - throw new Error(`OpenAI terminal response has status ${event.response.status}`); - } - const incomplete = - event.type === 'response.incomplete' || event.response.status === 'incomplete'; - if (incomplete && event.response.incomplete_details?.reason !== 'max_output_tokens') { - throw new Error( - `OpenAI response incomplete: ${event.response.incomplete_details?.reason ?? 'unknown'}`, - ); - } + const incomplete = this.terminalIncomplete(event); this.reconcile(event.response.output); if ([...this.slots.values()].some((slot) => !slot.done)) { throw new Error('OpenAI completed with an unfinished content block'); @@ -745,6 +746,26 @@ class ResponseStream { this.finish(stopReason, event.response.usage); } + /** Whether the terminal event is a max-output cut-off; any other failed state throws. */ + private terminalIncomplete( + event: Extract< + ResponseStreamEvent, + { type: 'response.completed' | 'response.incomplete' | 'response.done' } + >, + ): boolean { + if (event.response.status && !['completed', 'incomplete'].includes(event.response.status)) { + throw new Error(`OpenAI terminal response has status ${event.response.status}`); + } + const incomplete = + event.type === 'response.incomplete' || event.response.status === 'incomplete'; + if (incomplete && event.response.incomplete_details?.reason !== 'max_output_tokens') { + throw new Error( + `OpenAI response incomplete: ${event.response.incomplete_details?.reason ?? 'unknown'}`, + ); + } + return incomplete; + } + private reconcile(output?: ResponsesOutputItem[]) { // Some streams omit the redundant output array or send []; their finished // incremental items remain authoritative. A populated array must agree. @@ -922,7 +943,7 @@ class ResponseStream { throw new Error('OpenAI omitted encrypted reasoning state'); } block.signature = - (this.options.signaturePrefix ?? SIGNATURE_PREFIX) + + (this.options.signaturePrefix ?? OPENAI_SIGNATURE_PREFIX) + Buffer.from(JSON.stringify(slot.item)).toString('base64url'); this.emit('content_block_delta', { index, @@ -965,6 +986,9 @@ export function prefixSafeLength(text: string, stops: readonly string[]): number return limit; } +/** How long a stream that already hit a stop sequence may take to report its usage. */ +const STOP_USAGE_WAIT_MS = 5000; + export async function fromResponses( stream: AsyncIterable, model: string, @@ -972,14 +996,36 @@ export async function fromResponses( options: ResponseOptions = {}, ): Promise { const response = new ResponseStream(model, emit, options); - for await (const event of readSse(stream)) { - if (!isStreamEvent(event)) { - continue; - } - response.accept(event); - if (response.completed) { - break; + const events = readSse(stream)[Symbol.asyncIterator](); + let deadline: Promise<'late'> | undefined; + let timer: NodeJS.Timeout | undefined; + try { + while (!response.completed) { + const next = events.next(); + const step = deadline ? await Promise.race([next, deadline]) : await next; + if (step === 'late') { + response.finishWithoutUsage(); + break; + } + if (step.done) { + break; + } + if (!isStreamEvent(step.value)) { + continue; + } + response.accept(step.value); + if (response.stopPending && !deadline) { + // The text is final, but the provider bills the whole response: keep reading to its usage. + deadline = new Promise<'late'>((resolve) => { + timer = setTimeout(() => resolve('late'), STOP_USAGE_WAIT_MS); + }); + } } + } finally { + clearTimeout(timer); + // A stream abandoned early is released without waiting on its next chunk. + events.return?.(undefined).catch(() => undefined); } + response.finishWithoutUsage(); return response.result(); } diff --git a/plugins/multi-core/src/gateway/safeguards.ts b/plugins/multi-core/src/gateway/safeguards.ts index 3382458..3ac6bd5 100644 --- a/plugins/multi-core/src/gateway/safeguards.ts +++ b/plugins/multi-core/src/gateway/safeguards.ts @@ -1,4 +1,5 @@ import type { ResponseContentBlock } from './messages.ts'; +import { isRecord } from './record.ts'; type ToolVerdict = | { type: 'unavailable'; reason: 'error' } @@ -22,13 +23,9 @@ const zenUnreviewedModes = new Set([ 'bypassPermissions', ]); -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** A malformed request cannot authorize a synthetic verdict. */ export function dangerousToolMode(safeguards: unknown): string | undefined { - if (!Array.isArray(safeguards) || !safeguards.every(record)) { + if (!Array.isArray(safeguards) || !safeguards.every(isRecord)) { return undefined; } const entry = safeguards.find((item) => item.type === 'dangerous_tool_use'); @@ -36,7 +33,7 @@ export function dangerousToolMode(safeguards: unknown): string | undefined { return undefined; } const context = entry.classifier_context; - return record(context) && typeof context.permission_mode === 'string' + return isRecord(context) && typeof context.permission_mode === 'string' ? context.permission_mode : ''; } diff --git a/plugins/multi-core/src/gateway/server.ts b/plugins/multi-core/src/gateway/server.ts index bd7cb91..68f9e4d 100644 --- a/plugins/multi-core/src/gateway/server.ts +++ b/plugins/multi-core/src/gateway/server.ts @@ -1,20 +1,22 @@ -import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import type { Server } from 'node:http'; import http from 'node:http'; import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { CodexAuthError, codexRequest } from '../../../multi-openai/src/auth.ts'; -import { openaiInstructions } from '../../../multi-openai/src/instructions.ts'; -import { MODELS } from '../../../multi-openai/src/models.ts'; -import type { ResponsesRequest } from '../../../multi-openai/src/responses.ts'; -import { forAnthropic, fromResponses, toResponses } from '../../../multi-openai/src/responses.ts'; +import { + openaiSignaturePrefixes, + prepareOpenAIRequest, +} from '../../../multi-openai/src/gateway-request.ts'; import { validateZenKey } from '../../../multi-zen/src/auth.ts'; -import { fromChat } from '../../../multi-zen/src/chat.ts'; -import { zenRequest } from '../../../multi-zen/src/request.ts'; -import type { AgentCatalog } from './agent-catalog.ts'; +import { + prepareZenRequest, + zenSignaturePrefixes, + zenUnavailable, +} from '../../../multi-zen/src/gateway-request.ts'; import type { ApprovalContext, NativeApprovalBridge } from './approval.ts'; import { approvalCwdForComparison, isApprovalRequest, parseApprovalRequest } from './approval.ts'; +import { setBounded } from './bounded.ts'; import { DisplayRows, displayFollowUp, @@ -27,23 +29,44 @@ import { withoutDisplayTools, } from './display-rows.ts'; import type { GatewayFetch } from './fetch.ts'; +import { + emitHandback, + handbackOffered, + handbackRequestKind, + recordedReport, + withHandback, + withoutHarnessHandback, +} from './harness-handback.ts'; import type { NativeObservation } from './harness-progress.ts'; import type { Emit, MessagesRequest, MessagesResponse, StopReason } from './messages.ts'; import { ModBridge } from './mod-bridge.ts'; import { ModCompactions } from './mod-compaction.ts'; +import { MOD_KEY_HEADER, ModSessionKeys } from './mod-keys.ts'; import { handleModRoute } from './mod-routes.ts'; import type { PermissionContext, PermissionModes } from './mode-hook.ts'; import { type NativeHarness, nativeHarnessErrorStatus } from './native-harness.ts'; import type { PendingApprovalTool } from './permission-hook.ts'; +import { + type HarnessProvider, + harnessEndpoint, + harnessProvider, + harnessUnavailable, + isHarnessProvider, + providerOwnedReview, + providerRoute, +} from './provider.ts'; +import type { PreparedRequest } from './provider-request.ts'; +import { ProviderAuthError, UpstreamFailure } from './provider-request.ts'; import { ProviderUsageDashboard, type ProviderUsageReader } from './provider-usage.ts'; import { ReceiptLedger } from './receipts.ts'; +import { isRecord } from './record.ts'; +import { forAnthropic } from './responses.ts'; import { dangerousToolMode, safeguardResults } from './safeguards.ts'; -import { estimateInputTokens } from './tokens.ts'; import { forwardObservedTools, ToolObserver } from './tool-observer.ts'; -import { originalToolNames } from './tools.ts'; -const OPENAI_URL = 'https://chatgpt.com/backend-api/codex/responses'; -const ANTHROPIC_URL = 'https://api.anthropic.com'; +/** Other providers' reasoning signatures mean nothing to Anthropic and are stripped. */ +const FOREIGN_SIGNATURE_PREFIXES = [...openaiSignaturePrefixes, ...zenSignaturePrefixes]; +const DEFAULT_ANTHROPIC_URL = 'https://api.anthropic.com'; // Anthropic's own Messages API limit is 32 MB; the gateway buffers up to it and leaves // the verdict to the provider, so a request Claude Code could send natively still goes. const MAX_BODY = 32 * 1024 * 1024; @@ -98,6 +121,8 @@ export interface GatewayEvent { endpoint?: string; /** Claude session that owns the request, when the client identified one. */ session?: string; + /** Why a request failed inside the gateway; never a credential or a body. */ + diagnostic?: string; } export interface GatewayOptions { @@ -108,6 +133,10 @@ export interface GatewayOptions { Record<'openai' | 'cursor' | 'zen' | 'antigravity' | 'grok', ProviderUsageReader> >; token: string; + /** How long a non-streamed provider reply may stay silent before its headers go out. */ + jsonKeepAliveMs?: number; + /** Where Claude's own traffic goes: Anthropic by default, or the caller's own base URL. */ + anthropicBaseUrl?: string; enabledProviders?: readonly string[]; authFile: string; fetchImpl?: GatewayFetch; @@ -119,12 +148,10 @@ export interface GatewayOptions { zen?: { apiKey: string }; /** OpenAI review for GPT-originated actions, independent of Claude authentication. */ approvalBridge?: Pick; - approvalProviders?: readonly ('openai' | 'cursor')[]; /** No Anthropic credentials: also block passthrough if no reviewer is available. */ blockAnthropic?: boolean; guardAuto?: boolean; permissionModes?: PermissionModes; - agentCatalog?: AgentCatalog; modBridge?: ModBridge; /** Display rows for native harness actions; the native tool names each harness is known to have. */ displayRows?: DisplayRows; @@ -133,32 +160,9 @@ export interface GatewayOptions { /** Rejected before any provider call; answered as HTTP 400 rather than 502. */ class BadRequest extends Error {} -class UpstreamFailure extends Error { - status: number; - retryAfter: string | null; - constructor(status: number, retryAfter: string | null, provider = 'OpenAI') { - const authHelp = provider === 'OpenAI' ? ' Renew the Codex login.' : ' Check the Zen API key.'; - super(`${provider} returned HTTP ${status}.${status === 401 ? authHelp : ''}`); - this.status = status; - this.retryAfter = retryAfter; - } -} - +class ForbiddenHost extends Error {} const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function providerOwnedReview(model: string): boolean { - return ( - model.startsWith('multi/openai/') || - model.startsWith('multi/cursor/') || - model.startsWith('multi/antigravity/') || - model.startsWith('multi/grok/') - ); -} - function authenticated(actual: string | string[] | undefined, expected: string): boolean { const a = Buffer.from(String(actual ?? '')); const b = Buffer.from(expected); @@ -184,10 +188,14 @@ interface ProviderRequest { emit: Emit; remember: (tool: { id: string; name: string; input: unknown }) => void; startStream: () => void; + /** Arms the non-streamed keepalive before a slow upstream wait; a stream needs none. */ + keepAlive: () => void; } export function createNativeGateway({ token, + anthropicBaseUrl = DEFAULT_ANTHROPIC_URL, + jsonKeepAliveMs = 30000, enabledProviders, authFile, fetchImpl = fetch, @@ -198,11 +206,9 @@ export function createNativeGateway({ grok, zen, approvalBridge, - approvalProviders: _approvalProviders = approvalBridge ? ['openai'] : [], blockAnthropic, guardAuto, permissionModes, - agentCatalog, modBridge = new ModBridge(), displayRows = new DisplayRows(), displayTools = {}, @@ -243,10 +249,20 @@ export function createNativeGateway({ if (zen) { validateZenKey(zen.apiKey); } + const anthropicOrigin = parseAnthropicBase(anthropicBaseUrl); + const anthropicUrl = (url: URL) => anthropicOrigin + url.pathname + url.search; + const modKeys = new ModSessionKeys(); const fallbackSession = randomUUID(); + const harnesses: Record = { + cursor, + antigravity, + grok, + }; + const harnessFor = (provider: string | undefined) => + isHarnessProvider(provider) ? harnesses[provider] : undefined; // Offer the mod rows only for the harnesses this gateway runs. for (const [provider, names] of Object.entries(displayTools)) { - if ({ cursor, antigravity, grok }[provider as keyof typeof displayTools] && names) { + if (harnessFor(provider) && names) { displayRows.announce(names); } } @@ -256,23 +272,6 @@ export function createNativeGateway({ string, { tool: PendingApprovalTool; context: ApprovalContext } >(); - function nativeReviewSession(session: unknown) { - if (blockAnthropic || typeof session !== 'string' || !session) { - return false; - } - const contexts = [...approvalContexts.values()].filter((context) => { - try { - return JSON.parse(context.scope)[0] === session; - } catch { - return false; - } - }); - // A native classifier may pass through only when this session has no - // provider-owned review context. Per-action routing below handles mixed - // sessions; this fallback covers native Claude's classifier retries that - // carry no correlatable tool action. - return contexts.length > 0 && contexts.every((context) => !providerOwnedReview(context.model)); - } const matchesBashAction = ( candidate: { tool: PendingApprovalTool; context: ApprovalContext }, action: unknown, @@ -310,11 +309,13 @@ export function createNativeGateway({ ) { const context = approvalContexts.get(pending.scope); if (context?.model === pending.model) { - evictOldest(reviewCandidates, 512); - reviewCandidates.set(id, { - tool: pending, - context: { ...context, cwd: parsed.cwd, ...reportedMode(parsed) }, - }); + setBounded( + reviewCandidates, + id, + { tool: pending, context: { ...context, cwd: parsed.cwd, ...reportedMode(parsed) } }, + 512, + 'insertion', + ); } } // Permission hooks only enrich attribution. Provider review is enforced @@ -336,17 +337,22 @@ export function createNativeGateway({ } // Each worker retains its own current request; provider switches replace it. approvalContexts.delete(approvalScope); - evictOldest(approvalContexts, 128); - approvalContexts.set(approvalScope, { - model: external, - request: body, - scope: approvalScope, - worker: Boolean(agentId), - rootRequest: agentId - ? approvalContexts.get(JSON.stringify([identity, 'main']))?.request - : undefined, - requestPermissionMode: dangerousToolMode(body.safeguards), - }); + setBounded( + approvalContexts, + approvalScope, + { + model: external, + request: body, + scope: approvalScope, + worker: Boolean(agentId), + rootRequest: agentId + ? approvalContexts.get(JSON.stringify([identity, 'main']))?.request + : undefined, + requestPermissionMode: dangerousToolMode(body.safeguards), + }, + 128, + 'insertion', + ); } function reviewCandidatesFor(parsed: Record, sourceSession: string) { const { action } = parseApprovalRequest(parsed); @@ -358,6 +364,12 @@ export function createNativeGateway({ (name !== 'Bash' || matchesBashAction(candidate, action[name])), ); } + function providerActionPending(sourceSession: string) { + return [...reviewCandidates.values()].some( + (candidate) => + candidate.tool.session === sourceSession && providerOwnedReview(candidate.context.model), + ); + } function pendingReview(parsed: Record, sourceSession: string) { const candidates = reviewCandidatesFor(parsed, sourceSession); if (candidates.length !== 1) { @@ -367,16 +379,14 @@ export function createNativeGateway({ } const compactions = new ModCompactions(async (request) => { const model = request.context.model; - const provider = model?.split('/')[1]; - const harness = provider === 'cursor' ? cursor : grokOrAntigravity(provider); - const native = harness; - if (!native || !model) { + const harness = harnessFor(harnessProvider(model)); + if (!harness || !model) { throw new Error('Precomputed summaries require a native harness model'); } assertProviderEnabled(model, enabledProviders); // Use a separate native record. A speculative summary never advances or rewinds // the originating run and never receives native tool capabilities. - const result = await native.handle( + const result = await harness.handle( { model, max_tokens: 3000, @@ -398,13 +408,6 @@ export function createNativeGateway({ .join('\n'); }); - function grokOrAntigravity(provider: string | undefined) { - if (provider === 'antigravity') { - return antigravity; - } - return provider === 'grok' ? grok : undefined; - } - function harnessScope({ identity, agentId }: ProviderRequest) { return identity.scope ?? JSON.stringify([fallbackSession, agentId ?? 'main']); } @@ -418,23 +421,13 @@ export function createNativeGateway({ } return error; } - async function handleHarness( - exchange: ProviderRequest, - provider: 'cursor' | 'antigravity' | 'grok', - ) { + async function handleHarness(exchange: ProviderRequest, provider: HarnessProvider) { const { res, body, url, signal, agentId, emit, identity } = exchange; - const bridge = { cursor, antigravity, grok }[provider]; + const bridge = harnesses[provider]; if (!bridge) { - const unavailable = { - cursor: 'Cursor SDK is not signed in. Run the launcher with --cursor-login first.', - antigravity: - 'Antigravity is unavailable. Install and connect the multi-antigravity plugin.', - grok: 'Grok is unavailable. Install Grok Build, run grok login, and relaunch.', - }; - throw refuseHarness(exchange, provider, new BadRequest(unavailable[provider])); + throw refuseHarness(exchange, provider, new BadRequest(harnessUnavailable[provider])); } const scope = harnessScope(exchange); - const nativeBody = exchange.body; let inputTokens: number; try { inputTokens = validateHarness(exchange, provider, bridge); @@ -462,19 +455,28 @@ export function createNativeGateway({ ? displayRows.issue(scope, observation.row) : undefined; }; - const result = await bridge + const deliverHandback = handbackOffered(exchange.parsed as MessagesRequest); + const streamed = harnessStreamEmitter(emit, deliverHandback); + const nativeResult = await bridge .handle( - nativeBody, + body, scope, signal, - body.stream ? emit : undefined, + body.stream ? streamed : undefined, exchange.permissionContext, observe, ) .catch((error: unknown) => { modBridge.complete(scope, signal.aborted ? 'cancelled' : 'failed', run, reason(error)); + // A failed compaction turn must not leave its scope tool-free. + permissionModes?.finishModCompaction( + identity.session, + agentId, + exchange.permissionContext?.compaction, + ); throw error; }); + const result = finishHarnessDelivery(nativeResult, deliverHandback, body, emit); permissionModes?.finishModCompaction( identity.session, agentId, @@ -507,6 +509,7 @@ export function createNativeGateway({ exchange: ProviderRequest, ids: readonly string[], external: string | null, + deliverHandback: boolean, ) { const { body, emit } = exchange; const followUp = await pendingFollowUp(exchange, ids, providerRoute(external)); @@ -515,7 +518,7 @@ export function createNativeGateway({ // The reply's context carries over, so the window's fill does not read as empty // after a turn with rows; the reply already reported its output and its spend. const context = followUp.usage; - const result: MessagesResponse = { + const plain: MessagesResponse = { id: `msg_${randomUUID()}`, type: 'message', role: 'assistant', @@ -525,7 +528,9 @@ export function createNativeGateway({ stop_sequence: null, usage: context ? { ...context, output_tokens: 0 } : { input_tokens: 0, output_tokens: 0 }, }; - const safeguard_results = safeguardResults(body.safeguards, result.content, 'native'); + const result = deliverHandback ? withHandback(plain, text, body.safeguards) : plain; + const safeguard_results = + result.safeguard_results ?? safeguardResults(body.safeguards, result.content, 'native'); if (safeguard_results) { result.safeguard_results = safeguard_results; } @@ -535,18 +540,79 @@ export function createNativeGateway({ emit('content_block_start', { index: 0, content_block: { type: 'text', text: '' } }); emit('content_block_delta', { index: 0, delta: { type: 'text_delta', text } }); emit('content_block_stop', { index: 0 }); - emit('message_delta', { - delta: { - stop_reason: 'end_turn', - stop_sequence: null, - ...(safeguard_results === undefined ? {} : { safeguard_results }), - }, - usage: result.usage, + if (deliverHandback) { + emitHandback(emit, result); + } + emitTerminal(emit, result); + } + sendResult(exchange, result); + } + async function answerHandbackOnly(exchange: ProviderRequest, external: string | null) { + const harness = harnessFor(providerRoute(external)); + const recorded = await harness?.recordedResponse?.( + harnessScope(exchange), + followUpContext(exchange), + ); + const report = recordedReport(recorded); + if (report === undefined) { + throw new BadRequest('No recorded harness report is available for SubagentHandback'); + } + const plain: MessagesResponse = { + id: `msg_${randomUUID()}`, + type: 'message', + role: 'assistant', + model: exchange.body.model ?? '', + content: [{ type: 'text', text: report }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: recorded?.usage ?? { input_tokens: 0, output_tokens: 0 }, + }; + const result = withHandback(plain, report, exchange.body.safeguards); + if (exchange.body.stream) { + exchange.startStream(); + exchange.emit('message_start', { message: { ...result, content: [], stop_reason: null } }); + exchange.emit('content_block_start', { index: 0, content_block: { type: 'text', text: '' } }); + exchange.emit('content_block_delta', { + index: 0, + delta: { type: 'text_delta', text: report }, }); - emit('message_stop', {}); + exchange.emit('content_block_stop', { index: 0 }); + emitHandback(exchange.emit, result); + emitTerminal(exchange.emit, result); } sendResult(exchange, result); } + async function answerHandbackRequest( + exchange: ProviderRequest, + kind: ReturnType, + deliverHandback: boolean, + external: string | null, + ): Promise { + if (!kind || !harnessProvider(external)) { + return false; + } + if (!deliverHandback) { + throw new BadRequest('SubagentHandback is required for this harness continuation'); + } + await answerHandbackOnly(exchange, external); + return true; + } + async function dispatchGatewayMessage( + exchange: ProviderRequest, + followUp: string[] | undefined, + external: string | null, + deliverHandback: boolean, + handbackKind: ReturnType, + ): Promise { + if (await answerHandbackRequest(exchange, handbackKind, deliverHandback, external)) { + return; + } + if (followUp) { + await answerFollowUp(exchange, followUp, external, deliverHandback); + return; + } + await dispatch(exchange, exchange.identity, external); + } /** * The deferred answer the rows' own reply holds, for the scope and provider that * wrote them only: from this gateway, or after a restart or an eviction from the @@ -562,7 +628,7 @@ export function createNativeGateway({ if (held) { return held; } - const harness = provider === 'cursor' ? cursor : grokOrAntigravity(provider); + const harness = harnessFor(provider); const recorded = await harness ?.recordedResponse?.(scope, followUpContext(exchange)) .catch(() => undefined); @@ -580,148 +646,81 @@ export function createNativeGateway({ return undefined; } } - async function handleOpenAI(exchange: ProviderRequest, externalModel: string) { - const { req, res, body, url, signal, abort, agentId, emit } = exchange; - const request = openaiRequest(exchange, externalModel); - request.prompt_cache_key = createHash('sha256') - .update( - JSON.stringify([ - 'openai', - exchange.identity.session || fallbackSession, - agentId ?? 'main', - request.model, - ]), - ) - .digest('hex'); + /** + * A hosted-API provider (OpenAI, Zen): the provider module prepares the request, + * this owns keepalive, events, failure mapping and the reply. + */ + async function handleHosted(exchange: ProviderRequest, route: 'openai' | 'zen') { + const { res, body, url, signal, agentId, emit } = exchange; + const prepared = prepareHosted(exchange, route); if (url.pathname === '/v1/messages/count_tokens') { res.writeHead(200, { 'content-type': 'application/json', 'x-multi-token-count': 'estimate', }); - return res.end(JSON.stringify({ input_tokens: estimateInputTokens(request) })); + return res.end(JSON.stringify({ input_tokens: prepared.inputTokens() })); } - const toolNames = originalToolNames(body); - onEvent({ - route: 'openai-request', - agentId, - model: request.model, - effort: request.reasoning.effort, - }); - const headers = { - 'content-type': 'application/json', - accept: 'text/event-stream', - originator: 'cc_multi_native', - session_id: String( - agentId ?? header(req.headers['x-claude-code-session-id']) ?? fallbackSession, - ), - }; - const upstream = await codexRequest(authFile, signal, (auth) => - fetchImpl(OPENAI_URL, { - method: 'POST', - headers: { ...headers, ...auth }, - body: JSON.stringify(request), - signal, - redirect: 'error', - }), - ); + onEvent(prepared.requestEvent(agentId)); + exchange.keepAlive(); + const upstream = await prepared.send(signal, fetchImpl); if (!upstream.ok) { // Do not print upstream bodies or credentials in gateway diagnostics. - onEvent({ route: 'openai', status: upstream.status }); + onEvent(prepared.failureEvent(upstream.status, agentId)); await upstream.body?.cancel(); - throw new UpstreamFailure(upstream.status, upstream.headers.get('retry-after')); + throw new UpstreamFailure( + upstream.status, + upstream.headers.get('retry-after'), + prepared.label, + prepared.authHelp, + ); } if (!upstream.body) { - throw new Error('OpenAI returned no response stream.'); + throw new Error(`${prepared.label} returned no response stream.`); } exchange.startStream(); - const result = await fromResponses( - upstream.body, - externalModel, - body.stream ? emit : undefined, - { - toolNames, - stopSequences: body.stop_sequences, - inputTokens: estimateInputTokens(request), - safeguards: body.safeguards, - }, - ); + const result = await prepared.translate(upstream.body, body.stream ? emit : undefined); rememberResult(exchange, result); if (result.stop_reason === 'stop_sequence') { - abort.abort(); + exchange.abort.abort(); } onEvent({ - ...completionEvent(exchange, result, 'openai', 'responses'), - model: request.model, - effort: request.reasoning.effort, + ...completionEvent(exchange, result, prepared.route, prepared.endpoint), + ...prepared.completion, }); sendResult(exchange, result); } - async function handleZen(exchange: ProviderRequest) { - const { res, body, url, signal, agentId, emit } = exchange; - if (!zen?.apiKey) { - throw new BadRequest('Zen is not configured. Set OPENCODE_API_KEY or connect OpenCode Zen.'); - } - const prepared = prepareZenRequest(exchange, fallbackSession); - if (url.pathname === '/v1/messages/count_tokens') { - res.writeHead(200, { 'content-type': 'application/json', 'x-multi-token-count': 'estimate' }); - return res.end(JSON.stringify({ input_tokens: prepared.inputTokens })); - } - onEvent({ route: 'zen-request', agentId, model: body.model }); - const upstream = await fetchImpl(`https://opencode.ai/zen/v1/${prepared.endpoint}`, { - method: 'POST', - headers: { - authorization: `Bearer ${zen.apiKey}`, - 'content-type': 'application/json', - accept: 'text/event-stream', - 'x-opencode-session': prepared.cacheKey, - 'x-opencode-client': 'cc-multi-cli-plugin', - }, - body: JSON.stringify(prepared.body), - signal, - redirect: 'error', - }); - if (!upstream.ok) { - onEvent({ route: 'zen', agentId, status: upstream.status }); - await upstream.body?.cancel(); - throw new UpstreamFailure(upstream.status, upstream.headers.get('retry-after'), 'Zen'); - } - if (!upstream.body) { - throw new Error('Zen returned no response stream.'); - } - exchange.startStream(); - const options = { - toolNames: originalToolNames(body), - stopSequences: body.stop_sequences, - signaturePrefix: prepared.signaturePrefix, - requireUsage: true, - inputTokens: prepared.inputTokens, - safeguards: body.safeguards, - safeguardProvider: 'zen' as const, + function prepareHosted(exchange: ProviderRequest, route: 'openai' | 'zen'): PreparedRequest { + const { req, url, body, identity, agentId } = exchange; + if (route === 'zen' && !zen?.apiKey) { + throw new BadRequest(zenUnavailable); + } + const input = { + method: req.method, + pathname: url.pathname, + body, + session: identity.session, + fallbackSession, + clientSession: header(req.headers['x-claude-code-session-id']), + agentId, }; - const translate = prepared.endpoint === 'responses' ? fromResponses : fromChat; - const result = await translate( - upstream.body, - String(body.model), - body.stream ? emit : undefined, - options, - ); - rememberResult(exchange, result); - if (result.stop_reason === 'stop_sequence') { - exchange.abort.abort(); + try { + return zen?.apiKey && route === 'zen' + ? prepareZenRequest(input, zen.apiKey) + : prepareOpenAIRequest(input, String(body.model), authFile); + } catch (error) { + throw new BadRequest(reason(error)); } - onEvent(completionEvent(exchange, result, 'zen', prepared.endpoint)); - sendResult(exchange, result); } async function handleAnthropic(exchange: ProviderRequest) { const { req, res, body, url, raw, signal } = exchange; const headers = anthropicHeaders(req); - const cleaned = forAnthropic(body); + const cleaned = forAnthropic(body, FOREIGN_SIGNATURE_PREFIXES); let forwarded: Buffer | undefined; if (req.method === 'POST') { forwarded = cleaned === body ? raw : Buffer.from(JSON.stringify(cleaned)); } try { - const upstream = await fetchImpl(ANTHROPIC_URL + url.pathname + url.search, { + const upstream = await fetchImpl(anthropicUrl(url), { method: req.method ?? 'GET', headers, body: forwarded, @@ -734,9 +733,12 @@ export function createNativeGateway({ res, signal, body.tools?.length ? exchange.remember : undefined, + body.model, ); - } catch { - // Anthropic failures are not rebranded: Claude Code sees a dropped connection. + } catch (error) { + // Anthropic failures are not rebranded: Claude Code sees a dropped connection. The + // reason still reaches the event stream, so a gateway bug is not silent. + onEvent({ route: 'anthropic', model: body.model, diagnostic: reason(error) }); res.destroy(); } } @@ -745,6 +747,7 @@ export function createNativeGateway({ res: http.ServerResponse, signal: AbortSignal, remember?: (tool: { id: string; name: string; input: unknown }) => void, + model?: unknown, ) { const responseHeaders = Object.fromEntries(upstream.headers); for (const name of STRIPPED_RESPONSE_HEADERS) { @@ -752,7 +755,9 @@ export function createNativeGateway({ } res.writeHead(upstream.status, responseHeaders); if (guardAuto && upstream.ok && remember) { - await forwardObservedTools(upstream, res, remember, signal); + await forwardObservedTools(upstream, res, remember, signal, (diagnostic) => + onEvent({ route: 'anthropic', model: String(model ?? ''), diagnostic }), + ); } else if (upstream.body) { await pipeline(Readable.fromWeb(upstream.body), res); } else { @@ -775,13 +780,13 @@ export function createNativeGateway({ throw new BadRequest('Anthropic is not signed in. Select an external model.'); } const method = req.method ?? 'GET'; - const hasBody = method !== 'GET' && method !== 'HEAD'; + const hasBody = requestHasBody(req); const headers = anthropicHeaders(req); - if (req.headers['content-length'] !== undefined) { + if (hasBody && req.headers['content-length'] !== undefined) { headers['content-length'] = String(req.headers['content-length']); } try { - const upstream = await fetchImpl(ANTHROPIC_URL + url.pathname + url.search, { + const upstream = await fetchImpl(anthropicUrl(url), { method, headers, body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined, @@ -791,7 +796,8 @@ export function createNativeGateway({ }); onEvent({ route: 'anthropic', status: upstream.status, model: undefined }); await relayAnthropic(upstream, res, abort.signal); - } catch { + } catch (error) { + onEvent({ route: 'anthropic', path: url.pathname, diagnostic: reason(error) }); res.destroy(); } } @@ -854,34 +860,61 @@ export function createNativeGateway({ res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(decision)); } - async function dispatchNativeClassification( + /** + * Claude's own classifier (auto mode, for Claude's own actions) goes to Anthropic. It + * leaves for provider review only when a provider-owned action observed in this session + * positively matches: observing Claude's stream is best effort, so a missing match + * proves nothing about who proposed the action. + */ + function classifyUnobserved( exchange: ProviderRequest, metadata: ReturnType, ) { - let candidates: ReturnType; + // Without action observation there are no candidates to match: only a session whose + // retained context is Claude's own may pass to Anthropic. + const context = metadata.scope ? approvalContexts.get(metadata.scope) : undefined; + if (context && !providerOwnedReview(context.model) && !blockAnthropic) { + return handleAnthropic(exchange); + } + return dispatchReview(exchange, metadata, null); + } + /** The matching review candidates, or undefined when the request is Anthropic's own. */ + function readableCandidates(parsed: Record, sourceSession: string) { try { - candidates = reviewCandidatesFor(exchange.parsed, metadata.session); + return reviewCandidatesFor(parsed, sourceSession); } catch (error) { - if (nativeReviewSession(metadata.session)) { - return handleAnthropic(exchange); + // An action the gateway cannot read cannot be matched, so it may be the provider + // action waiting for review: it passes to Anthropic only when none is waiting. + if (blockAnthropic) { + throw error; } - throw error; + if (providerActionPending(sourceSession)) { + throw new BadRequest('Unreadable review request while a provider action awaits review'); + } + return undefined; } - if (candidates.length === 1) { - const context = candidates[0].context; - if (providerOwnedReview(context.model)) { + } + async function dispatchNativeClassification( + exchange: ProviderRequest, + metadata: ReturnType, + ) { + if (!guardAuto) { + return classifyUnobserved(exchange, metadata); + } + const candidates = readableCandidates(exchange.parsed, metadata.session); + if (!candidates) { + return handleAnthropic(exchange); + } + if (!candidates.some(({ context }) => providerOwnedReview(context.model))) { + if (blockAnthropic) { return dispatchReview(exchange, metadata, null); } return handleAnthropic(exchange); } - if ( - candidates.length > 1 && - candidates.some(({ context }) => context.model.startsWith('multi/openai/')) - ) { - return dispatchReview(exchange, metadata, null); - } - if (nativeReviewSession(metadata.session)) { - return handleAnthropic(exchange); + // metadata.session picks the review candidates, so a header that disagrees with it + // could steer a provider action's review to another session. + if (metadata.mismatch) { + throw new BadRequest('Session header and metadata disagree'); } return dispatchReview(exchange, metadata, null); } @@ -966,11 +999,7 @@ export function createNativeGateway({ const route = providerRoute(external); beginUsage(exchange, external); let permissionContext: PermissionContext | undefined; - if ( - permissionModes && - ['cursor', 'antigravity', 'grok'].includes(route) && - url.pathname === '/v1/messages' - ) { + if (permissionModes && isHarnessProvider(route) && url.pathname === '/v1/messages') { try { permissionContext = permissionModes.resolveHarness( exchange.identity.session, @@ -992,13 +1021,30 @@ export function createNativeGateway({ if (!external) { return handleAnthropic(exchange); } - if (route === 'cursor' || route === 'antigravity' || route === 'grok') { + if (isHarnessProvider(route)) { return handleHarness(exchange, route); } - if (external.startsWith('multi/zen/')) { - return handleZen(exchange); + return handleHosted(exchange, route === 'zen' ? 'zen' : 'openai'); + } + function handleMod( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + parsed: Record, + ) { + if (!admitModRequest(req, res, parsed, modKeys)) { + return undefined; } - return handleOpenAI(exchange, external); + return handleModRoute(req, res, url, parsed, { + bridge: modBridge, + permissionModes, + compactions, + receipts, + billedUsage, + dashboard, + rows: displayRows, + keys: modKeys, + }); } return http.createServer(async (req, res) => { const abort = new AbortController(); @@ -1007,7 +1053,6 @@ export function createNativeGateway({ abort.abort(); } }); - let heartbeat: NodeJS.Timeout | undefined; let sourceModel = ''; let sourceSession = ''; let sourceScope: string | undefined; @@ -1016,31 +1061,22 @@ export function createNativeGateway({ if (!guardAuto || !sourceSession) { return; } - evictOldest(pendingTools, 512); - pendingTools.set(tool.id, { + const pending: PendingApprovalTool = { model: sourceModel, session: sourceSession, name: tool.name, input: tool.input, scope: sourceScope, - }); + }; + setBounded(pendingTools, tool.id, pending, 512, 'insertion'); if (sourceScope) { const context = approvalContexts.get(sourceScope); if (context?.model === sourceModel) { - evictOldest(reviewCandidates, 512); - reviewCandidates.set(tool.id, { - tool: { - model: sourceModel, - session: sourceSession, - name: tool.name, - input: tool.input, - scope: sourceScope, - }, - context, - }); + setBounded(reviewCandidates, tool.id, { tool: pending, context }, 512, 'insertion'); } } }; + const replies = new ReplyKeepAlive(res, (type) => emit(type, {}), jsonKeepAliveMs); const emit: Emit = (type, value) => { if (guardAuto) { observer.event({ type, ...value }); @@ -1048,6 +1084,7 @@ export function createNativeGateway({ return res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...value })}\n\n`); }; try { + assertLoopbackHost(req); const url = new URL(req.url ?? '', 'http://localhost'); if (isRawAnthropicPath(url.pathname)) { return await handleRawAnthropic(req, res, url, abort); @@ -1055,24 +1092,15 @@ export function createNativeGateway({ if (!authorizeRequest(req, res, token, guardAuto)) { return; } - const request = await readRequest(req, agentCatalog); + const request = await readRequest(req); const { parsed } = request; if (url.pathname.startsWith('/multi/mod/')) { - return handleModRoute( - req, - res, - url, - parsed, - modBridge, - permissionModes, - compactions, - receipts, - billedUsage, - dashboard, - displayRows, - ); + return handleMod(req, res, url, parsed); } - const { body, raw, followUp } = displayRowsRemoved(url, request); + const { body, raw, followUp, deliverHandback, handbackKind } = displayRowsRemoved( + url, + request, + ); const external = externalModel(body.model); assertProviderEnabled(external, enabledProviders); const signal = providerSignal(abort.signal, external, timeoutMs); @@ -1099,40 +1127,146 @@ export function createNativeGateway({ agentId, emit, remember, - startStream: () => { - if (!body.stream) { - return; - } - res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); - res.flushHeaders(); - heartbeat = setInterval(() => emit('ping', {}), 15000); - }, + startStream: () => replies.startStream(body.stream), + keepAlive: () => replies.keepAlive(body.stream), }; if (url.pathname === '/multi/permission') { return sendPermissionDecision(exchange); } - if (followUp) { - return await answerFollowUp(exchange, followUp, external); - } - await dispatch(exchange, metadata, external); + await dispatchGatewayMessage(exchange, followUp, external, deliverHandback, handbackKind); } catch (error) { abort.abort(); - failResponse(res, emit, error); + failResponse(res, emit, error, replies.jsonStarted, sourceModel); } finally { - clearInterval(heartbeat); + replies.stop(); } }); } class RequestTooLarge extends Error {} -/** Everything under /v1 except the Messages API the gateway parses and may route. */ +/** + * What keeps a provider reply's connection alive while it works. A stream sends its + * headers at once and a `ping` event every 15 s. Claude Code's request timeout only + * bounds the wait for response headers, and a timed-out request is retried: a run that + * is still working would then be started again. A non-streamed reply that is still + * pending after a while therefore sends its headers and keeps the connection alive with + * leading JSON whitespace, which any JSON parser skips before the message. Replies and + * failures that come sooner keep their own status; after that point a failure can only + * be an error body. + */ +class ReplyKeepAlive { + jsonStarted = false; + private timer: NodeJS.Timeout | undefined; + private readonly res: http.ServerResponse; + private readonly ping: (type: 'ping') => void; + private readonly delayMs: number; + + constructor(res: http.ServerResponse, ping: (type: 'ping') => void, delayMs: number) { + this.res = res; + this.ping = ping; + this.delayMs = delayMs; + } + + startStream(streamed: boolean | undefined) { + if (this.timer) { + return; + } + if (!streamed) { + this.keepAlive(streamed); + return; + } + this.res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + this.res.flushHeaders(); + this.timer = setInterval(() => this.ping('ping'), 15000); + } + + keepAlive(streamed: boolean | undefined) { + if (this.timer || streamed) { + return; + } + this.timer = setTimeout(() => { + this.jsonStarted = true; + this.res.writeHead(200, { 'content-type': 'application/json' }); + this.res.flushHeaders(); + this.timer = setInterval(() => this.res.write(' '), Math.min(15000, this.delayMs)); + }, this.delayMs); + } + + stop() { + clearInterval(this.timer); + } +} + +/** + * The gateway token alone does not authorize a mod request that changes a session: the + * token is in the environment of Claude's own tools. See mod-keys.ts. + */ +function admitModRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + parsed: Record, + keys: ModSessionKeys, +) { + if (req.method !== 'POST' || typeof parsed.sessionId !== 'string' || !parsed.sessionId) { + return true; + } + const verdict = keys.check(parsed.sessionId, header(req.headers[MOD_KEY_HEADER])); + if (verdict.refused) { + res.writeHead(403, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'Mod session key required' })); + return false; + } + if (verdict.issue) { + res.setHeader(MOD_KEY_HEADER, verdict.issue); + } + return true; +} + +/** + * Everything except the Messages API the gateway parses and may route, and its own + * /multi/* control routes, goes to Anthropic as raw bytes with the caller's headers. + */ function isRawAnthropicPath(pathName: string) { return ( - pathName.startsWith('/v1/') && !['/v1/messages', '/v1/messages/count_tokens'].includes(pathName) + !pathName.startsWith('/multi/') && + !['/v1/messages', '/v1/messages/count_tokens'].includes(pathName) + ); +} + +/** + * Only the gateway's own loopback name and port are accepted as the `Host`. A web page + * that rebinds its DNS name to 127.0.0.1 reaches the port with its own name in `Host`, + * which is what keeps it from using the unauthenticated raw passthrough as a relay. + */ +function assertLoopbackHost(req: http.IncomingMessage) { + const host = req.headers.host?.toLowerCase(); + const port = req.socket.localPort; + const allowed = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]; + if (!host || !allowed.includes(host)) { + throw new ForbiddenHost('Forbidden host'); + } +} + +/** A request body is present when the client declared one, whatever the method. */ +function requestHasBody(req: http.IncomingMessage) { + if (req.method === 'GET' || req.method === 'HEAD') { + return false; + } + return ( + req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length'] ?? 0) > 0 ); } +/** The origin and path prefix Claude's traffic is forwarded to, without a trailing slash. */ +function parseAnthropicBase(value: string) { + const parsed = new URL(value); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('The Anthropic base URL must be http or https'); + } + return parsed.origin + parsed.pathname.replace(/\/+$/, ''); +} + /** * Display rows are Claude Code UI: no provider sees them as tools or history. A * request that only answers a reply's rows is marked as that reply's follow-up. @@ -1142,21 +1276,74 @@ function displayRowsRemoved( request: { raw: Buffer; parsed: Record; body: MessagesRequest }, ) { if (isApprovalRequest(request.parsed)) { - return { body: request.body, raw: request.raw, followUp: undefined }; + return { + body: request.body, + raw: request.raw, + followUp: undefined, + deliverHandback: false, + handbackKind: undefined, + }; } + const harness = harnessProvider(externalModel(request.body.model)); + const deliverHandback = Boolean(harness && handbackOffered(request.body)); + const handbackKind = harness ? handbackRequestKind(request.body) : undefined; const followUp = url.pathname === '/v1/messages' ? displayFollowUp(request.body) : undefined; - const body = withoutDisplayTools(request.body); + const withoutRows = withoutDisplayTools(request.body); + const body = harness ? withoutHarnessHandback(withoutRows) : withoutRows; const raw = body === request.body ? request.raw : Buffer.from(JSON.stringify(body)); - return { body, raw, followUp }; + return { body, raw, followUp, deliverHandback, handbackKind }; +} + +function emitTerminal(emit: Emit, response: MessagesResponse): void { + emit('message_delta', { + delta: { + stop_reason: response.stop_reason, + stop_sequence: response.stop_sequence, + ...(response.safeguard_results === undefined + ? {} + : { safeguard_results: response.safeguard_results }), + }, + usage: response.usage, + }); + emit('message_stop', {}); +} + +function harnessStreamEmitter(emit: Emit, deliverHandback: boolean): Emit { + return (type, value) => { + // The native reply is durable before its terminal event. Hold that event + // until the gateway has appended Claude's delivery call to the final message. + if (deliverHandback && (type === 'message_delta' || type === 'message_stop')) { + return; + } + emit(type, value); + }; +} + +function finishHarnessDelivery( + nativeResult: MessagesResponse, + deliverHandback: boolean, + body: MessagesRequest, + emit: Emit, +): MessagesResponse { + if (!deliverHandback) { + return nativeResult; + } + const result = + nativeResult.multi_followup === undefined + ? withHandback(nativeResult, recordedReport(nativeResult) ?? '', body.safeguards) + : nativeResult; + if (body.stream) { + if (result !== nativeResult) { + emitHandback(emit, result); + } + emitTerminal(emit, result); + } + return result; } function providerSignal(disconnected: AbortSignal, model: string | null, timeoutMs?: number) { // Native harness runs follow the client connection rather than an HTTP deadline. - if ( - model?.startsWith('multi/cursor/') || - model?.startsWith('multi/antigravity/') || - model?.startsWith('multi/grok/') - ) { + if (harnessProvider(model)) { return disconnected; } // Streamed OpenAI, Zen and Claude responses (including server-side advisor calls) @@ -1167,7 +1354,7 @@ function providerSignal(disconnected: AbortSignal, model: string | null, timeout return AbortSignal.any([disconnected, AbortSignal.timeout(timeoutMs)]); } -async function readRequest(req: http.IncomingMessage, catalog?: AgentCatalog) { +async function readRequest(req: http.IncomingMessage) { const chunks: Buffer[] = []; let size = 0; for await (const chunk of req) { @@ -1187,41 +1374,17 @@ async function readRequest(req: http.IncomingMessage, catalog?: AgentCatalog) { if (!isRecord(parsed) || (parsed.model !== undefined && typeof parsed.model !== 'string')) { throw new BadRequest('Expected an object with a string model'); } - const body: MessagesRequest = isApprovalRequest(parsed) - ? parsed - : (catalog?.compact(parsed) ?? parsed); - return { raw: body === parsed ? raw : Buffer.from(JSON.stringify(body)), parsed, body }; -} - -function providerRoute( - model: string | null, -): 'cursor' | 'antigravity' | 'grok' | 'openai' | 'anthropic' | 'zen' { - if (model?.startsWith('multi/cursor/')) { - return 'cursor'; - } - if (model?.startsWith('multi/antigravity/')) { - return 'antigravity'; - } - if (model?.startsWith('multi/grok/')) { - return 'grok'; - } - if (model?.startsWith('multi/zen/')) { - return 'zen'; - } - return model ? 'openai' : 'anthropic'; -} -function harnessEndpoint(provider: 'cursor' | 'antigravity' | 'grok'): string { - return { cursor: '@cursor/sdk', antigravity: 'agy', grok: 'grok' }[provider]; + return { raw, parsed, body: parsed as MessagesRequest }; } function errorStatus(error: unknown): number { - if (error instanceof CodexAuthError) { + if (error instanceof ProviderAuthError) { return 401; } if (error instanceof BadRequest) { return 400; } - if (error instanceof FollowUpRefused) { + if (error instanceof FollowUpRefused || error instanceof ForbiddenHost) { return 403; } if (error instanceof FollowUpUnavailable) { @@ -1248,19 +1411,12 @@ function rememberResult(exchange: ProviderRequest, result: MessagesResponse) { } } function sendResult(exchange: ProviderRequest, result: MessagesResponse) { - if (!exchange.body.stream) { + if (!exchange.body.stream && !exchange.res.headersSent) { exchange.res.writeHead(200, { 'content-type': 'application/json' }); } exchange.res.end(exchange.body.stream ? undefined : JSON.stringify(result)); } -function evictOldest(cache: Map, capacity: number) { - const oldest = cache.keys().next(); - if (cache.size >= capacity && !oldest.done) { - cache.delete(oldest.value); - } -} - /** Claude's PreToolUse input carries its mode at the moment the action was proposed. */ function reportedMode(parsed: Record): { permissionMode?: string } { return typeof parsed.permission_mode === 'string' @@ -1289,9 +1445,11 @@ function requestIdentity( /* Unknown identity cannot grant auto capability. */ } } - // Session identity matters to provider routes. A Claude passthrough request is - // never refused for it: Anthropic owns that request. - if (provider && session && sessionHeader && session !== sessionHeader) { + // Session identity matters to provider routes and to classifier requests that go to + // provider review (checked at dispatch). A Claude passthrough request is never refused + // for it: Anthropic owns that request. + const mismatch = Boolean(session && sessionHeader && session !== sessionHeader); + if (provider && mismatch) { throw new BadRequest('Session header and metadata disagree'); } session = session || sessionHeader || ''; @@ -1299,57 +1457,11 @@ function requestIdentity( return { identity, session, + mismatch, scope: identity ? JSON.stringify([identity, agentId ?? 'main']) : undefined, }; } -function prepareZenRequest(exchange: ProviderRequest, fallbackSession: string) { - const { req, body, url, identity, agentId } = exchange; - try { - if ( - req.method !== 'POST' || - !['/v1/messages', '/v1/messages/count_tokens'].includes(url.pathname) - ) { - throw new Error('Zen requires POST /v1/messages or /v1/messages/count_tokens'); - } - // Zen uses this for sticky upstream routing. Claude identity survives restarts; - // no per-request nonce is inserted into the prompt or cache key. - const cacheKey = createHash('sha256') - .update( - JSON.stringify([ - identity.session || fallbackSession, - agentId ?? 'main', - body.model, - process.cwd(), - ]), - ) - .digest('hex'); - return { ...zenRequest(body, cacheKey), cacheKey }; - } catch (error) { - throw new BadRequest(reason(error)); - } -} - -function openaiRequest(exchange: ProviderRequest, externalModel: string): ResponsesRequest { - const { req, body, url } = exchange; - try { - const model = Object.values(MODELS).find((model) => externalModel === `multi/openai/${model}`); - if (!model) { - throw new Error('Unknown native OpenAI model'); - } - if ( - !['/v1/messages', '/v1/messages/count_tokens'].includes(url.pathname) || - req.method !== 'POST' - ) { - throw new Error('External models require POST /v1/messages or /v1/messages/count_tokens'); - } - const request = toResponses(body, model); - return { ...request, instructions: openaiInstructions(request.instructions) }; - } catch (error) { - throw new BadRequest(reason(error)); - } -} - function anthropicHeaders(req: http.IncomingMessage) { const headers: Record = {}; for (const [name, value] of Object.entries(req.headers)) { @@ -1358,10 +1470,24 @@ function anthropicHeaders(req: http.IncomingMessage) { headers[name] = single; } } - headers['accept-encoding'] = 'identity'; + headers['accept-encoding'] = decodableEncodings(req.headers['accept-encoding']); return headers; } +/** + * Node's fetch decodes gzip, deflate and br before the gateway sees a byte, so the + * observer and the relay always handle decoded bytes (the relay drops content-encoding + * and content-length). Keep the client's own preference for those, so Anthropic + * compresses as it would for Claude Code directly; anything else asks for identity. + */ +function decodableEncodings(value: string | string[] | undefined) { + const wanted = (header(value) ?? '') + .split(',') + .map((entry) => entry.trim().split(';')[0].toLowerCase()) + .filter((entry) => ['gzip', 'deflate', 'br'].includes(entry)); + return wanted.length ? wanted.join(', ') : 'identity'; +} + function authorizeRequest( req: http.IncomingMessage, res: http.ServerResponse, @@ -1379,7 +1505,6 @@ function authorizeRequest( ![ '/v1/messages', '/v1/messages/count_tokens', - '/api/hello', '/multi/mod/session', '/multi/mod/worker', '/multi/mod/worker-model', @@ -1409,7 +1534,13 @@ function authorizeRequest( return true; } -function failResponse(res: http.ServerResponse, emit: Emit, error: unknown) { +function failResponse( + res: http.ServerResponse, + emit: Emit, + error: unknown, + jsonKeepAlive: boolean, + model = '', +) { if (res.destroyed) { return; } @@ -1446,7 +1577,11 @@ function failResponse(res: http.ServerResponse, emit: Emit, error: unknown) { if (error instanceof UpstreamFailure && error.retryAfter && !res.headersSent) { res.setHeader('retry-after', error.retryAfter); } - if (res.headersSent) { + if (res.headersSent && jsonKeepAlive) { + // The 200 and its headers are already out, so the failure has to be a valid message + // for Claude Code to show; an error body under a 200 is read as a malformed reply. + res.end(JSON.stringify(failureMessage(error, model))); + } else if (res.headersSent) { emit('error', { error: failure.error }); res.end(); } else { @@ -1455,6 +1590,22 @@ function failResponse(res: http.ServerResponse, emit: Emit, error: unknown) { } } +/** The failure as an ordinary assistant message: it states the failure and claims no work. */ +export function failureMessage(error: unknown, model: string): MessagesResponse { + const provider = providerRoute(model); + const detail = reason(error).replace(/\s+/g, ' ').trim().slice(0, 500); + return { + id: `msg_${randomUUID()}`, + type: 'message', + role: 'assistant', + model, + content: [{ type: 'text', text: `Multi: ${provider} failed: ${detail}` }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }; +} + function assertProviderEnabled(model: string | null, enabled: readonly string[] | undefined) { if (model && enabled && !enabled.includes(model.split('/')[1])) { throw new BadRequest('This provider plugin is not enabled for this session.'); diff --git a/plugins/multi-core/src/gateway/state-lock.ts b/plugins/multi-core/src/gateway/state-lock.ts index 03440e9..f6da467 100644 --- a/plugins/multi-core/src/gateway/state-lock.ts +++ b/plugins/multi-core/src/gateway/state-lock.ts @@ -1,10 +1,22 @@ +import { execFileSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { lstat, open, readFile, rename, unlink } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { link, lstat, open, readFile, rename, unlink } from 'node:fs/promises'; import { hostname } from 'node:os'; export interface LockStateFileOptions { + /** Exclusive re-creation of a marker that was moved aside by mistake. */ + link?: typeof link; + /** Stable machine identity; defaults to the OS-provided id when one exists. */ + machineId?: string | undefined; + hostname?: string; platform?: NodeJS.Platform; maxAttempts?: number; + /** + * How long to wait for a live owner to release before failing. The default 0 + * fails at once: a session file held by another gateway is owned, not busy. + */ + waitMs?: number; rename?: typeof rename; unlink?: typeof unlink; open?: typeof open; @@ -18,6 +30,63 @@ interface LockOwner { pid: number; hostname: string; token: string; + /** OS machine identity that survives a hostname change (absent in older markers). */ + machine?: string; +} + +let cachedMachineId: { value: string | undefined } | undefined; + +/** + * A hostname follows the network (macOS flips between `.local`, `.lan` and + * conflict suffixes), so identity prefers an OS machine id when one is readable. + */ +export function machineIdentity(platform: NodeJS.Platform = process.platform): string | undefined { + if (cachedMachineId) { + return cachedMachineId.value; + } + let value: string | undefined; + try { + if (platform === 'linux') { + value = readFileSync('/etc/machine-id', 'utf8').trim() || undefined; + } else if (platform === 'darwin') { + const output = execFileSync('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { + encoding: 'utf8', + timeout: 2000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + value = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(output)?.[1]; + } + } catch { + value = undefined; + } + cachedMachineId = { value }; + return value; +} + +/** Lower-cased first label: `Mac.local`, `mac.lan` and `MAC` are one machine name. */ +export function normalizeHostname(name: string): string { + return name.toLowerCase().split('.')[0] ?? ''; +} + +/** Whether a marker was written by this machine, tolerating hostname drift. */ +export function sameMachine( + recorded: LockOwner, + self: { hostname: string; machine?: string | undefined }, +): boolean { + if (recorded.machine !== undefined && self.machine !== undefined) { + return recorded.machine === self.machine; + } + return normalizeHostname(recorded.hostname) === normalizeHostname(self.hostname); +} + +function newOwner(options: LockStateFileOptions, platform: NodeJS.Platform): LockOwner { + const machine = 'machineId' in options ? options.machineId : machineIdentity(platform); + return { + pid: process.pid, + hostname: options.hostname ?? hostname(), + token: randomUUID(), + ...(machine === undefined ? {} : { machine }), + }; } /** @@ -35,23 +104,25 @@ export async function lockStateFile( options: LockStateFileOptions = {}, ): Promise<() => Promise> { const platform = options.platform ?? process.platform; - const owner: LockOwner = { pid: process.pid, hostname: hostname(), token: randomUUID() }; + const owner = newOwner(options, platform); const maxAttempts = options.maxAttempts ?? 100; - const renameFile = options.rename ?? rename; - const unlinkFile = options.unlink ?? unlink; const openFile = options.open ?? open; - const lstatFile = options.lstat ?? lstat; - const readFileContents = options.readFile ?? readFile; + const operations = lockOperations(options); + const { unlinkFile, readFileContents } = operations; if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) { throw new RangeError('State file lock maxAttempts must be a positive integer'); } + const deadline = Date.now() + (options.waitMs ?? 0); - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + for (let attempt = 0; attempt < maxAttempts; ) { const release = await tryAcquire(file, owner, platform, unlinkFile, openFile, readFileContents); if (release) { return release; } - await takeOverStaleLock(file, platform, renameFile, unlinkFile, lstatFile, readFileContents); + if (await ownerStillHolds(file, owner, platform, operations, deadline)) { + continue; + } + attempt += 1; if (platform === 'win32') { await delay(20); } @@ -59,6 +130,43 @@ export async function lockStateFile( throw new Error(`State file lock acquisition exceeded ${maxAttempts} attempts`); } +function lockOperations(options: LockStateFileOptions): LockOperations { + return { + renameFile: options.rename ?? rename, + unlinkFile: options.unlink ?? unlink, + lstatFile: options.lstat ?? lstat, + readFileContents: options.readFile ?? readFile, + linkFile: options.link ?? link, + }; +} + +class LockHeld extends Error {} + +const ownerPollMs = 50; + +/** + * Takes over a stale marker; a live owner's marker is waited on until `deadline`, + * then refused. True when the caller should try again without spending an attempt. + */ +async function ownerStillHolds( + file: string, + owner: LockOwner, + platform: NodeJS.Platform, + operations: LockOperations, + deadline: number, +): Promise { + try { + await takeOverStaleLock(file, owner, platform, operations); + return false; + } catch (error) { + if (!(error instanceof LockHeld) || Date.now() >= deadline) { + throw error; + } + await delay(Math.min(ownerPollMs, Math.max(1, deadline - Date.now()))); + return true; + } +} + async function tryAcquire( file: string, owner: LockOwner, @@ -93,14 +201,21 @@ async function openLock(file: string, openFile: typeof open) { } } +interface LockOperations { + renameFile: typeof rename; + unlinkFile: typeof unlink; + lstatFile: typeof lstat; + readFileContents: typeof readFile; + linkFile: typeof link; +} + async function takeOverStaleLock( file: string, + self: LockOwner, platform: NodeJS.Platform, - renameFile: typeof rename, - unlinkFile: typeof unlink, - lstatFile: typeof lstat, - readFileContents: typeof readFile, + operations: LockOperations, ): Promise { + const { renameFile, unlinkFile, lstatFile, readFileContents, linkFile } = operations; const info = await lstatFile(file).catch((error: unknown) => { if (isCode(error, 'ENOENT')) { return undefined; @@ -115,13 +230,14 @@ async function takeOverStaleLock( } const current = await readOwner(file, platform, readFileContents); if (!current) { - throw new Error('State file is locked by another gateway (owner metadata is unavailable)'); + // The owner released between the lstat and the read: the caller acquires again. + return; } if ( current !== legacyEmptyMarker && - (current.hostname !== hostname() || isProcessAlive(current.pid)) + (!sameMachine(current, self) || isProcessAlive(current.pid)) ) { - throw new Error('State file is locked by another gateway'); + throw new LockHeld('State file is locked by another gateway'); } const stale = `${file}.stale-${randomUUID()}`; try { @@ -134,6 +250,13 @@ async function takeOverStaleLock( cause: error, }); } + // Between our read and the rename another gateway may have taken over and + // acquired; then the marker we moved is a live lock and must go back. + const moved = await readOwner(stale, platform, readFileContents); + if (!sameMarker(current, moved)) { + await restoreMovedMarker(file, stale, platform, { linkFile, unlinkFile }); + return; + } await retryLockOperation(() => unlinkFile(stale), platform, 'stale-lock cleanup').catch( (error: unknown) => { if (!isCode(error, 'ENOENT')) { @@ -143,6 +266,34 @@ async function takeOverStaleLock( ); } +function sameMarker( + expected: LockOwner | typeof legacyEmptyMarker, + actual: LockOwner | typeof legacyEmptyMarker | undefined, +): boolean { + if (expected === legacyEmptyMarker || actual === legacyEmptyMarker) { + return expected === actual; + } + return actual !== undefined && actual.token === expected.token; +} + +/** `link` creates the marker only if none exists, so a newer owner is never clobbered. */ +async function restoreMovedMarker( + file: string, + stale: string, + platform: NodeJS.Platform, + operations: Pick, +): Promise { + try { + await retryLockOperation(() => operations.linkFile(stale, file), platform, 'lock restore'); + } catch (error) { + // Another gateway already re-acquired; the contested marker is stale-by-token. + if (!(error instanceof Error && isCode(error.cause, 'EEXIST'))) { + throw error; + } + } + await operations.unlinkFile(stale).catch(() => {}); +} + async function releaseLock( file: string, owner: LockOwner, @@ -155,7 +306,7 @@ async function releaseLock( !current || current === legacyEmptyMarker || current.token !== owner.token || - current.hostname !== owner.hostname + !sameMachine(current, owner) ) { return; } diff --git a/plugins/multi-core/src/gateway/tokens.ts b/plugins/multi-core/src/gateway/tokens.ts index 1fa651a..18c8f58 100644 --- a/plugins/multi-core/src/gateway/tokens.ts +++ b/plugins/multi-core/src/gateway/tokens.ts @@ -1,8 +1,5 @@ import { getEncoding } from 'js-tiktoken'; -import type { - ResponsesInputContent, - ResponsesRequest, -} from '../../../multi-openai/src/responses.ts'; +import type { ResponsesInputContent, ResponsesRequest } from './responses.ts'; let encoding: ReturnType | undefined; /** Shared local text estimate; providers may use different tokenizers. */ diff --git a/plugins/multi-core/src/gateway/tool-observer.ts b/plugins/multi-core/src/gateway/tool-observer.ts index ee84865..0577f00 100644 --- a/plugins/multi-core/src/gateway/tool-observer.ts +++ b/plugins/multi-core/src/gateway/tool-observer.ts @@ -1,5 +1,6 @@ import { once } from 'node:events'; import type { ServerResponse } from 'node:http'; +import { isRecord } from './record.ts'; interface Tool { id: string; @@ -8,8 +9,6 @@ interface Tool { } const MAX_BYTES = 8 * 1024 * 1024; -const record = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); /** Observe provider output for attribution, never for permission grants. */ export class ToolObserver { @@ -21,7 +20,7 @@ export class ToolObserver { } response(value: unknown) { - if (record(value) && Array.isArray(value.content)) { + if (isRecord(value) && Array.isArray(value.content)) { for (const block of value.content) { const tool = this.tool(block); if (tool) { @@ -32,7 +31,7 @@ export class ToolObserver { } event(value: unknown) { - if (!record(value) || typeof value.index !== 'number') { + if (!isRecord(value) || typeof value.index !== 'number') { return; } if (value.type === 'content_block_start') { @@ -46,7 +45,7 @@ export class ToolObserver { if (!tool) { return; } - if (value.type === 'content_block_delta' && record(value.delta)) { + if (value.type === 'content_block_delta' && isRecord(value.delta)) { this.append(tool, value.delta.partial_json); } if (value.type === 'content_block_stop') { @@ -66,7 +65,7 @@ export class ToolObserver { private tool(value: unknown): Tool | undefined { if ( - record(value) && + isRecord(value) && value.type === 'tool_use' && typeof value.id === 'string' && typeof value.name === 'string' @@ -82,6 +81,7 @@ class SseObserver { private decoder = new TextDecoder(); private pending = ''; private data: string[] = []; + private dataBytes = 0; private observer: ToolObserver; constructor(observer: ToolObserver) { @@ -90,73 +90,122 @@ class SseObserver { push(chunk: Uint8Array) { this.pending += this.decoder.decode(chunk, { stream: true }); - if (this.pending.length > MAX_BYTES) { - throw new Error('Observed SSE buffer exceeds 8 MiB'); - } + // Split first: a chunk may hold many complete events, and only an incomplete line or + // event that outgrows the bound is a reason to stop observing. for (let index = this.pending.indexOf('\n'); index !== -1; index = this.pending.indexOf('\n')) { const line = this.pending.slice(0, index).replace(/\r$/, ''); this.pending = this.pending.slice(index + 1); this.line(line); } + if (this.pending.length > MAX_BYTES) { + throw new Error('Observed SSE line exceeds 8 MiB'); + } } private line(line: string) { if (line.startsWith('data:')) { - this.data.push(line.slice(5).replace(/^ /, '')); + const value = line.slice(5).replace(/^ /, ''); + this.dataBytes += value.length; + if (this.dataBytes > MAX_BYTES) { + throw new Error('Observed SSE event exceeds 8 MiB'); + } + this.data.push(value); } else if (!line && this.data.length) { const value = this.data.join('\n'); this.data = []; + this.dataBytes = 0; this.observer.event(JSON.parse(value)); } } } +/** Observation of one response body; once it cannot keep up it stops and says why. */ +class BestEffortObservation { + private readonly observer: ToolObserver; + private readonly streamed: boolean; + private readonly stopped: (reason: string) => void; + private readonly sse: SseObserver; + private chunks: Uint8Array[] = []; + private bytes = 0; + private observing = true; + + constructor( + remember: (tool: Tool) => void, + streamed: boolean, + stopped: (reason: string) => void, + ) { + this.observer = new ToolObserver(remember); + this.sse = new SseObserver(this.observer); + this.streamed = streamed; + this.stopped = stopped; + } + + push(chunk: Uint8Array) { + if (!this.observing) { + return; + } + try { + this.accept(chunk); + } catch (error) { + this.stop(error); + } + } + + finish() { + if (!this.observing || this.streamed) { + return; + } + try { + this.observer.response(JSON.parse(Buffer.concat(this.chunks).toString('utf8'))); + } catch (error) { + this.stop(error); + } + } + + private accept(chunk: Uint8Array) { + if (this.streamed) { + this.sse.push(chunk); + return; + } + this.bytes += chunk.length; + if (this.bytes > MAX_BYTES) { + throw new Error('Observed response exceeds 8 MiB'); + } + this.chunks.push(chunk); + } + + private stop(error: unknown) { + this.observing = false; + this.chunks = []; + this.stopped(error instanceof Error ? error.message : String(error)); + } +} + /** * Pass the upstream bytes through unchanged and observe them on the side. An * observation failure (partial tool JSON, oversized input) only stops observing; - * it never reaches Claude Code. The passthrough itself has no size cap. + * it never reaches Claude Code, and `stopped` records why. The passthrough itself + * has no size cap. */ export async function forwardObservedTools( upstream: Response, res: ServerResponse, remember: (tool: Tool) => void, signal: AbortSignal, + stopped: (reason: string) => void = () => {}, ) { if (!upstream.body) { res.end(); return; } - const observer = new ToolObserver(remember); - const streamed = upstream.headers.get('content-type')?.includes('text/event-stream'); - const sse = new SseObserver(observer); - const chunks: Uint8Array[] = []; - let bytes = 0; - let observing = true; + const streamed = Boolean(upstream.headers.get('content-type')?.includes('text/event-stream')); + const observation = new BestEffortObservation(remember, streamed, stopped); for await (const chunk of upstream.body) { if (!res.write(chunk)) { await once(res, 'drain', { signal }); } - if (!observing) { - continue; - } - try { - if (streamed) { - sse.push(chunk); - } else { - bytes += chunk.length; - observing = bytes <= MAX_BYTES; - chunks.push(chunk); - } - } catch { - observing = false; - } - } - if (observing && !streamed) { - try { - observer.response(JSON.parse(Buffer.concat(chunks).toString('utf8'))); - } catch { - // Observation is best effort. - } + observation.push(chunk); } + observation.finish(); res.end(); } diff --git a/plugins/multi-core/src/install/bootstrap.ts b/plugins/multi-core/src/install/bootstrap.ts index 2e98f90..5bab34f 100644 --- a/plugins/multi-core/src/install/bootstrap.ts +++ b/plugins/multi-core/src/install/bootstrap.ts @@ -3,7 +3,13 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { type Installation, readInstallation, uninstall } from './installation.ts'; import { installedPlugins, settingsArguments } from './plugins.ts'; -import { run } from './process.ts'; +import { + describeRunError, + needsEnvProxy, + run, + withLoopbackNoProxy, + withoutGateway, +} from './process.ts'; async function dispatch(state: Installation, args: string[], management: boolean) { const { root, providers } = await installedPlugins(state.claude, settingsArguments(args)); @@ -12,13 +18,14 @@ async function dispatch(state: Installation, args: string[], management: boolean return 0; } if (!management && (!root || providers.length === 0)) { - return run(state.claude, args); + return run(state.claude, args, { env: withoutGateway(process.env) }); } if (!management && state.command === 'claude' && process.env.MULTI_GATEWAY_TOKEN) { // A launch command named `claude` also catches Claude's own nested runs (agents // calling `claude -p`, SDK spawns, hooks). Inside a Multi session those must - // reach the real executable rather than start a second gateway. - return run(state.claude, args); + // reach the real executable rather than start a second gateway, and without this + // session's gateway URL and credentials: that gateway is not theirs. + return run(state.claude, args, { env: withoutGateway(process.env) }); } if (!root) { throw new Error('Enable multi-core at user scope before using Multi commands.'); @@ -29,15 +36,17 @@ async function dispatch(state: Installation, args: string[], management: boolean if (manifest.name !== 'multi-core') { throw new Error('Installed core manifest does not identify multi-core'); } - const env = { + const env = withLoopbackNoProxy({ ...process.env, ...(state.models !== undefined && process.env.MULTI_MODELS === undefined ? { MULTI_MODELS: state.models } : {}), + // Node reads this at startup only, so the launcher process has to be started with it. + ...(needsEnvProxy(process.env) ? { NODE_USE_ENV_PROXY: '1' } : {}), MULTI_REAL_CLAUDE: state.claude, MULTI_ENABLED_PROVIDERS: providers.join(','), MULTI_ANTIGRAVITY: providers.includes('antigravity') ? '1' : '0', - }; + }); const entry = management ? 'account.ts' : 'launcher.ts'; return run(state.node, [path.join(root, 'plugins', 'multi-core', 'src', entry), ...args], { env, @@ -64,7 +73,7 @@ void main().then( process.exitCode = code; }, (error: unknown) => { - console.error(`Multi: ${error instanceof Error ? error.message : String(error)}`); + console.error(`Multi: ${describeRunError(error)}`); process.exitCode = 1; }, ); diff --git a/plugins/multi-core/src/install/installation.ts b/plugins/multi-core/src/install/installation.ts index ee833ef..764e434 100644 --- a/plugins/multi-core/src/install/installation.ts +++ b/plugins/multi-core/src/install/installation.ts @@ -52,6 +52,7 @@ type DeferDeletion = (command: string, args: string[], options: SpawnOptions) => export interface UninstallOptions { platform?: Platform; + homedir?: string; env?: NodeJS.ProcessEnv; deferDeletion?: DeferDeletion; } @@ -144,7 +145,7 @@ function normalizeModels(value: string | undefined, previous?: string): string | // /model and pastes here. Accept it and store the untagged ID, which is the stable one: // it still names the row whether or not the tag is switched on later. The strip is // spelled out rather than imported because this file is installed on its own, with no - // sibling plugin sources beside it; `nativeSpelling()` in multi-antigravity is the + // sibling plugin sources beside it; `nativeSpelling()` in gateway/provider.ts is the // canonical definition. const persisted = models.map((model) => model.replace(/\[1m\]$/i, '')); for (const model of persisted) { @@ -396,6 +397,46 @@ export async function setup( return state; } +/** + * Remove the `multi-cli-antigravity` entry from agy's global hooks file. This file is copied + * beside the installed shims and must run after the plugin is gone, so it cannot import the + * plugin's own path helpers; the location logic mirrors `antigravityHookFile`. + */ +export async function removeAntigravityHook( + options: { platform?: Platform; env?: NodeJS.ProcessEnv; homedir?: string } = {}, +): Promise { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.homedir ?? os.homedir(); + const lib = platform === 'win32' ? path.win32 : path.posix; + const root = + platform === 'win32' + ? lib.join(env.LOCALAPPDATA ?? env.APPDATA ?? lib.join(home, 'AppData', 'Local'), 'gemini') + : lib.join(home, '.gemini'); + const file = lib.join(root, 'config', 'hooks.json'); + let hooks: unknown; + try { + hooks = JSON.parse(await readFile(file, 'utf8')); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false; + } + throw error; + } + if ( + !hooks || + typeof hooks !== 'object' || + Array.isArray(hooks) || + !('multi-cli-antigravity' in hooks) + ) { + return false; + } + const remaining = { ...hooks } as Record; + delete remaining['multi-cli-antigravity']; + await writeFile(file, `${JSON.stringify(remaining, null, 2)}\n`, { mode: 0o600 }); + return true; +} + export async function uninstall( directory = installationDirectory(), options: UninstallOptions = {}, @@ -404,6 +445,12 @@ export async function uninstall( const state = await readInstallation(directory); const source = await readFile(state.shellFile, 'utf8'); await writeFile(state.shellFile, removeBlock(source, state.block)); + try { + // The Antigravity permission hook is global to the machine and points into this plugin. + await removeAntigravityHook({ platform, env: options.env, homedir: options.homedir }); + } catch (error) { + console.warn(`Could not remove the Antigravity hook: ${String(error)}`); + } const shimFiles = state.shims ?? [DEFAULT_COMMAND, MANAGEMENT_COMMAND]; const shimPaths = shimFiles.map((file) => path.join(directory, 'bin', file)); const deferred = platform === 'win32' ? shimPaths.filter((file) => /\.cmd$/i.test(file)) : []; diff --git a/plugins/multi-core/src/install/nested-claude.ts b/plugins/multi-core/src/install/nested-claude.ts new file mode 100644 index 0000000..362f7c2 --- /dev/null +++ b/plugins/multi-core/src/install/nested-claude.ts @@ -0,0 +1,20 @@ +// Runs the real Claude Code without the enclosing Multi session's gateway +// environment. The launcher puts a `claude` shim that calls this first on the +// session's PATH, so a `claude` started by Claude's own tools is an ordinary run. +import { describeRunError, run, withoutGateway } from './process.ts'; + +const [claude, ...args] = process.argv.slice(2); +if (!claude) { + console.error('Multi: nested claude shim needs the real executable path.'); + process.exitCode = 1; +} else { + void run(claude, args, { env: withoutGateway(process.env) }).then( + (code) => { + process.exitCode = code; + }, + (error: unknown) => { + console.error(`Multi: ${describeRunError(error)}`); + process.exitCode = 1; + }, + ); +} diff --git a/plugins/multi-core/src/install/process.ts b/plugins/multi-core/src/install/process.ts index e3f62f6..f2f2bb2 100644 --- a/plugins/multi-core/src/install/process.ts +++ b/plugins/multi-core/src/install/process.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { executableInvocation } from '../gateway/executable.ts'; +import { executableInvocation, UnsafeCommandArgumentError } from '../gateway/executable.ts'; import { terminateProcessTree } from '../gateway/process-tree.ts'; export interface RunOptions { @@ -40,3 +40,93 @@ export async function run( process.off('SIGINT', interrupt); } } + +/** What the session started with, kept so a nested run can be given it back. */ +const RESTORABLE = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_CUSTOM_HEADERS'] as const; +const ORIGINAL_PREFIX = 'MULTI_ORIG_'; + +/** Remember the caller's own values before the launcher replaces them. */ +export function rememberOriginalEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const remembered: NodeJS.ProcessEnv = {}; + for (const name of RESTORABLE) { + const value = env[name]; + if (value !== undefined) { + remembered[`${ORIGINAL_PREFIX}${name}`] = value; + } + } + return remembered; +} + +/** + * The environment for a Claude run that is not the Multi session: no gateway URL, no + * gateway token, no gateway credential, and the caller's own base URL and headers + * back. An environment that carries no gateway token is returned unchanged. + */ +export function withoutGateway(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const token = env.MULTI_GATEWAY_TOKEN; + if (token === undefined) { + return env; + } + const result: NodeJS.ProcessEnv = { ...env }; + delete result.MULTI_GATEWAY_TOKEN; + delete result.MULTI_MOD_GATEWAY_URL; + if (result.ANTHROPIC_AUTH_TOKEN === token) { + delete result.ANTHROPIC_AUTH_TOKEN; + } + for (const name of RESTORABLE) { + const original = env[`${ORIGINAL_PREFIX}${name}`]; + delete result[`${ORIGINAL_PREFIX}${name}`]; + if (original === undefined) { + delete result[name]; + } else { + result[name] = original; + } + } + return result; +} + +/** + * Node's fetch ignores HTTP_PROXY and HTTPS_PROXY unless NODE_USE_ENV_PROXY=1 is set + * when the process starts; setting it later has no effect. A user who needs a proxy to + * reach Anthropic or a provider has it for Claude Code itself, so the gateway has to + * honor it too. True when the process is configured for a proxy but not yet using it. + */ +export function needsEnvProxy(env: NodeJS.ProcessEnv): boolean { + return ( + env.NODE_USE_ENV_PROXY === undefined && + ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((name) => Boolean(env[name])) + ); +} + +/** The message a failed `run` is reported with; a refused argument is named and explained. */ +export function describeRunError(error: unknown): string { + if (error instanceof UnsafeCommandArgumentError) { + const shown = error.argument.length > 80 ? `${error.argument.slice(0, 80)}...` : error.argument; + return `Claude is launched through a .cmd/.bat file, and cmd.exe cannot pass the argument ${JSON.stringify(shown)} safely (it contains one of " % ! ^ & | < > or a line break). Run Claude through the npm-installed claude (npm install -g @anthropic-ai/claude-code) or the native installer, or avoid those characters in the argument.`; + } + return error instanceof Error ? error.message : String(error); +} + +const PROXY_VARIABLES = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']; +const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1']; + +/** + * With a proxy configured and NODE_USE_ENV_PROXY=1, the gateway's and hooks' own + * requests to 127.0.0.1 would go through the proxy too. Append the loopback names to + * NO_PROXY and no_proxy, keeping the user's entries and adding none twice. + */ +export function withLoopbackNoProxy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (!PROXY_VARIABLES.some((name) => Boolean(env[name]))) { + return env; + } + const result: NodeJS.ProcessEnv = { ...env }; + for (const name of ['NO_PROXY', 'no_proxy']) { + const entries = (env[name] ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + const missing = LOOPBACK_HOSTS.filter((host) => !entries.includes(host)); + result[name] = [...entries, ...missing].join(','); + } + return result; +} diff --git a/plugins/multi-core/src/launcher.ts b/plugins/multi-core/src/launcher.ts index 6e62598..21f5a3e 100644 --- a/plugins/multi-core/src/launcher.ts +++ b/plugins/multi-core/src/launcher.ts @@ -2,7 +2,7 @@ import { execFile, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { realpathSync } from 'node:fs'; -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -17,11 +17,11 @@ import { antigravityDefaultWorkerModel, antigravityPickerOptions, discoverAntigravityModels, - nativeSpelling, } from '../../multi-antigravity/src/models.ts'; import { antigravityPermissionPolicy } from '../../multi-antigravity/src/permissions.ts'; import { ANTIGRAVITY_TOOLS } from '../../multi-antigravity/src/progress.ts'; import { antigravityUsageReader } from '../../multi-antigravity/src/usage-adapter.ts'; +import type { Effort } from '../../multi-core/src/gateway/responses.ts'; import { CursorHarness } from '../../multi-cursor/src/harness.ts'; import type { CursorModelOption } from '../../multi-cursor/src/models.ts'; import { @@ -29,10 +29,7 @@ import { cursorModelOptions, cursorPickerOptions, } from '../../multi-cursor/src/models.ts'; -import { - cursorPermissionPolicy, - mergeCursorPermissions, -} from '../../multi-cursor/src/permissions.ts'; +import { cursorPermissionPolicy } from '../../multi-cursor/src/permissions.ts'; import { CURSOR_TOOLS } from '../../multi-cursor/src/progress.ts'; import { cursorUsageReader } from '../../multi-cursor/src/usage-adapter.ts'; import { CursorWorkspaces } from '../../multi-cursor/src/workspaces.ts'; @@ -52,7 +49,6 @@ import { OPENAI_DEFAULT_WORKER_MODEL, OPENAI_WORKER_EFFORT, } from '../../multi-openai/src/models.ts'; -import type { Effort } from '../../multi-openai/src/responses.ts'; import { openAIUsageReader } from '../../multi-openai/src/usage-adapter.ts'; import { readZenKey } from '../../multi-zen/src/auth.ts'; import { @@ -63,17 +59,22 @@ import { zenPickerOptions, } from '../../multi-zen/src/models.ts'; import { zenUsageReader } from '../../multi-zen/src/usage-adapter.ts'; -import { AgentCatalog } from './gateway/agent-catalog.ts'; import { loadWorkerPermissions, type PluginPermissionInventory, pluginPermissions, } from './gateway/agent-definitions.ts'; -import { type CursorSettingsOptions, checkCursorSettings } from './gateway/cursor-settings.ts'; import { DISPLAY_TOOL_SERVER } from './gateway/display-rows.ts'; import { executableInvocation, resolveExecutable } from './gateway/executable.ts'; +import { + checkHarnessSettings, + type HarnessSettingsOptions, + mergePermissions, +} from './gateway/harness-settings.ts'; import { ModBridge } from './gateway/mod-bridge.ts'; import { PermissionModes } from './gateway/mode-hook.ts'; +import { nestedClaudeShim, withPathPrefix } from './gateway/nested-env.ts'; +import { nativeSpelling } from './gateway/provider.ts'; import type { ProviderUsageReader } from './gateway/provider-usage.ts'; import { ReceiptLedger } from './gateway/receipts.ts'; import type { GatewayEvent } from './gateway/server.ts'; @@ -86,6 +87,7 @@ import { } from './gateway/worker-catalog.ts'; import { providerSelection } from './install/plugins.ts'; +import { needsEnvProxy, rememberOriginalEnvironment, withoutGateway } from './install/process.ts'; const enabledProviders = providerSelection(process.env.MULTI_ENABLED_PROVIDERS); const providerEnabled = (provider: string) => @@ -134,8 +136,8 @@ interface LaunchSettings { async function main() { const args = process.argv.slice(2); await handleCommand(args[0]); - const { cursorModels, cursorSignedIn } = await discoverCursor(args[0] === '--cursor-models'); if (args[0] === '--cursor-models') { + const { cursorModels, cursorSignedIn } = await discoverCursor(true); printCursorModels(cursorModels, cursorSignedIn); return; } @@ -143,20 +145,33 @@ async function main() { args.shift(); } validateSessionLaunch(args); - const pluginInventory = await pluginPermissions(process.cwd(), args); + warnIfProxyUnused(); const pluginRoot = await findPluginRoot(fileURLToPath(import.meta.url)); await assertFunctionHooksSupported(); - const anthropic = await anthropicSignedIn(); const fullCatalog = process.env.MULTI_MODELS !== undefined; - const cursorPicker = selectedCursorOptions(cursorModels, fullCatalog); const authFile = path.join( process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'auth.json', ); - const { codexSignedIn, openaiReview } = await discoverOpenAI(authFile); - const zenKey = providerEnabled('zen') ? await readZenKey() : undefined; - const antigravityModels = await discoverAntigravity(); - const grokModels = await discoverGrok(); + // Independent probes run together: Cursor's model listing alone can take 15 s. + const [ + pluginInventory, + anthropic, + { cursorModels }, + { codexSignedIn, openaiReview }, + zenKey, + antigravityModels, + grokModels, + ] = await Promise.all([ + pluginInventoryOrEmpty(process.cwd(), args), + anthropicSignedIn(), + discoverCursor(false), + discoverOpenAI(authFile), + providerEnabled('zen') ? readZenKey() : Promise.resolve(undefined), + discoverAntigravity(), + discoverGrok(), + ]); + const cursorPicker = selectedCursorOptions(cursorModels, fullCatalog); const token = randomBytes(32).toString('hex'); const defaultModels = fullCatalog ? pickerSettings( @@ -176,9 +191,12 @@ async function main() { fullCatalog, ); await mergeSettings(args, settings); - // The supervisor does not transfer --agents or our session-local gateway env, - // and can outlive the child whose exit releases settingsDir and the gateway. - // Keep ordinary background subagent tasks available within this owned session. + // Agent view and background sessions are off for this session only, through the + // session's own settings: nested Claude runs do not read them, unlike an environment + // variable. The background supervisor does not carry --agents or this session's gateway + // environment, and outlives the child whose exit releases settingsDir and the gateway, + // so a session handed to it would lose its workers and its gateway. Ordinary background + // subagent tasks stay available inside this session. settings.disableAgentView = true; filterPicker(settings, process.env.MULTI_MODELS, defaultModels); const callerSettings = structuredClone(settings); @@ -236,13 +254,10 @@ async function main() { usageReaders, permissionModes, approvalBridge, - approvalProviders, + // A launch inside another Multi session forwards to the user's own upstream, not the outer gateway. + anthropicBaseUrl: withoutGateway(process.env).ANTHROPIC_BASE_URL || undefined, blockAnthropic: !anthropic, guardAuto: true, - agentCatalog: new AgentCatalog( - agents, - settings.modelPicker.options.map((option) => option.model), - ), onEvent: traceEvent, }); await new Promise((resolve, reject) => { @@ -261,11 +276,12 @@ async function main() { configureApproval(settings, approvalProviders, selectedModel, { antigravity, grok }, anthropic); await writeFile(settingsFile, JSON.stringify(settings), { mode: 0o600 }); const definitions = JSON.stringify(agents); - const childEnvironment = gatewayEnvironment(address.port, token, anthropic); + const baseEnvironment = gatewayEnvironment(address.port, token, anthropic); const claudePath = resolveExecutable('claude', { configuredPath: claudeExecutable, - env: childEnvironment, + env: baseEnvironment, }); + const childEnvironment = await withNestedClaudeShim(baseEnvironment, settingsDir, claudePath); const childArguments = launcherArguments( args, settingsFile, @@ -289,7 +305,7 @@ async function main() { await rm(settingsDir, { recursive: true, force: true }); throw error; } - const ready = awaitModSessionStart(); + const modStart = awaitModSessionStart(); const child = spawn(childInvocation.command, childInvocation.args, { stdio: 'inherit', env: childEnvironment, @@ -304,20 +320,19 @@ async function main() { await receipts.drain(); await rm(settingsDir, { recursive: true, force: true }); }; - try { - await ready; - } catch (error) { - child.kill(); - await shutdown(); - throw error; - } + // Attached before anything is awaited: a Claude that cannot start, or that exits at + // once (--version, an argument error), must end the launcher with its own exit code + // rather than after the mod's acknowledgement times out. + const exited = new Promise((resolve) => { + child.once('exit', (code) => resolve(code ?? 1)); + }); child.once('error', (error) => { + modStart.cancel(); console.error(error.message); void shutdown().finally(() => process.exit(1)); }); - child.once('exit', (code) => { - void shutdown().finally(() => process.exit(code ?? 1)); - }); + await awaitStartOrExit(modStart, exited, child, shutdown); + void exited.then((code) => shutdown().finally(() => process.exit(code))); if (process.platform === 'win32') { // Windows console control events do not provide POSIX process groups. Claude's // child owns Ctrl+C handling; forward termination explicitly to its process tree. @@ -363,15 +378,11 @@ function validateSessionLaunch(args: string[]) { args.some((arg) => arg === '--bg' || arg === '--background') || ['attach', 'respawn'].includes(args[0] ?? '') ) { + // The background supervisor does not carry the gateway environment or --agents. throw new Error( 'Multi sessions must stay attached to their launcher. Exit and use --resume to continue with a fresh gateway; whole-session background handoff is unsupported.', ); } - if (process.env.ANTHROPIC_BASE_URL) { - throw new Error( - 'Start without ANTHROPIC_BASE_URL; this launcher supplies the central gateway.', - ); - } if (args.some((arg) => arg === '--agents' || arg.startsWith('--agents='))) { throw new Error('This launcher supplies --agents; use agent files for additional agents.'); } @@ -400,7 +411,7 @@ function nativeSettingsCheck( return {}; } try { - return await checkCursorSettings(cwd, args, callerSettings, sharedAdmission(harnesses)); + return await checkHarnessSettings(cwd, args, callerSettings, sharedAdmission(harnesses)); } catch (error) { return { nativePermissionError: String(error) }; } @@ -419,7 +430,7 @@ function nativeHarnesses( (cwd) => new CursorHarness(cursorModels, { cwd, - checkPermissions: () => checkCursorSettings(cwd, args, callerSettings), + checkPermissions: () => checkHarnessSettings(cwd, args, callerSettings), }), ) : undefined; @@ -427,20 +438,20 @@ function nativeHarnesses( ? new AntigravityHarness(antigravityModels, { checkPermissions: async (cwd, context) => { await checkAntigravityHooks(); - const restrictions = await checkCursorSettings(cwd, args, callerSettings, { + const restrictions = await checkHarnessSettings(cwd, args, callerSettings, { validate: antigravityPermissionPolicy, }); - return antigravityPermissionPolicy(mergeCursorPermissions(context, restrictions)); + return antigravityPermissionPolicy(mergePermissions(context, restrictions)); }, }) : undefined; const grok = grokModels.length ? new GrokHarness(grokModels, { checkPermissions: async (cwd, context) => { - const restrictions = await checkCursorSettings(cwd, args, callerSettings, { + const restrictions = await checkHarnessSettings(cwd, args, callerSettings, { validate: grokPermissionPolicy, }); - return grokPermissionPolicy(mergeCursorPermissions(context, restrictions)); + return grokPermissionPolicy(mergePermissions(context, restrictions)); }, }) : undefined; @@ -588,22 +599,54 @@ function atLeastVersion(actual: number[], required: number[]): boolean { */ const modSessionStartTimeoutMs = Number(process.env.MULTI_MOD_START_TIMEOUT_MS ?? 30000); -function awaitModSessionStart(): Promise { - return new Promise((resolve, reject) => { +/** + * Wait for the mod's acknowledgement, or for a Claude that exits first: that ends the + * launch with Claude's own exit code. A timeout is the launcher's error. + */ +async function awaitStartOrExit( + modStart: { ready: Promise; cancel: () => void }, + exited: Promise, + child: { kill: () => boolean }, + shutdown: () => Promise, +) { + try { + const earlyExit = await Promise.race([modStart.ready.then(() => undefined), exited]); + if (earlyExit !== undefined) { + modStart.cancel(); + await shutdown(); + process.exit(earlyExit); + } + } catch (error) { + child.kill(); + await shutdown(); + throw error; + } +} + +function awaitModSessionStart(): { ready: Promise; cancel: () => void } { + let cancel = () => {}; + const ready = new Promise((resolve, reject) => { const timer = setTimeout(() => { - process.off('multi-mod-session-start', ready); + process.off('multi-mod-session-start', acknowledged); reject( new Error( 'Claude Code 2.1.272 or newer with loaded function hooks is required; the Multi mod did not acknowledge session.start.', ), ); }, modSessionStartTimeoutMs); - const ready = () => { + const acknowledged = () => { clearTimeout(timer); resolve(); }; - process.once('multi-mod-session-start', ready); + cancel = () => { + clearTimeout(timer); + process.off('multi-mod-session-start', acknowledged); + }; + process.once('multi-mod-session-start', acknowledged); }); + // A cancelled wait never settles; a timed-out one is handled by whoever awaits it. + ready.catch(() => {}); + return { ready, cancel }; } async function anthropicSignedIn(): Promise { @@ -660,7 +703,7 @@ export function sharedAdmission(harnesses: { cursor?: unknown; antigravity?: unknown; grok?: unknown; -}): CursorSettingsOptions { +}): HarnessSettingsOptions { if (harnesses.antigravity) { return { validate: antigravityPermissionPolicy, cursorToolRules: false }; } @@ -1139,10 +1182,15 @@ function pickerSettings( } /** - * CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC also blocks the plugin worker's - * loopback call to this gateway, which the Mods control plane requires. Keep - * the user's intent (no updater, telemetry or error reports) with the narrower - * flags instead of silently running without the mod. + * CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC also blocks the plugin worker's loopback call + * to this gateway, which the Mods control plane requires (an end-to-end check on + * Claude Code 2.1.283 showed the broad flag blocks the function-hook fetch while the + * narrow flags do not). Keep the user's intent (no updater, telemetry, error reports or + * feedback command) with the narrower flags instead of silently running without the mod. + * + * This is not about GrowthBook. The broad flag and DISABLE_TELEMETRY both turn it off, + * and on Claude Code 2.1.287 the Mods rollout flag `tengu_plugin_hooks_modules` defaults + * to on without it, so the replacement flags leave the mod loading. */ function translateTrafficPolicy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC === undefined) { @@ -1150,7 +1198,7 @@ function translateTrafficPolicy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { } const { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: _flag, ...rest } = env; process.stderr.write( - 'Multi: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC would block the local gateway; using DISABLE_AUTOUPDATER, DISABLE_TELEMETRY, DISABLE_ERROR_REPORTING and DISABLE_BUG_COMMAND instead.\n', + 'Multi: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC would block the local gateway; using DISABLE_AUTOUPDATER, DISABLE_TELEMETRY, DISABLE_ERROR_REPORTING, DISABLE_BUG_COMMAND and DISABLE_FEEDBACK_COMMAND instead.\n', ); return { ...rest, @@ -1158,33 +1206,81 @@ function translateTrafficPolicy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1', DISABLE_BUG_COMMAND: '1', + DISABLE_FEEDBACK_COMMAND: '1', }; } function gatewayEnvironment(port: number, token: string, anthropic: boolean) { - const env = translateTrafficPolicy({ ...process.env }); + // A launch inside another Multi session starts from the user's own environment. + const origin = withoutGateway(process.env); + const env = translateTrafficPolicy({ ...origin }); delete env.OPENCODE_API_KEY; return { ...env, - CLAUDE_CODE_DISABLE_AGENT_VIEW: '1', + // What nested Claude runs get back in place of the gateway (see nested-env.ts). + ...rememberOriginalEnvironment(origin), ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, - // A custom base URL disables Claude's on-demand tool loading unless opted in. - // We forward Claude tool references, so match the direct default of always - // deferring; 'auto' loads every tool once a 1M window makes them under 10%. - ENABLE_TOOL_SEARCH: process.env.ENABLE_TOOL_SEARCH ?? 'true', + // A custom base URL makes Claude Code skip on-demand tool loading unless it is told + // otherwise, and a gateway base URL is always custom. Claude's own default against + // Anthropic is to defer tools always, which is what 'true' says; the user's own + // setting is kept. 'auto' would instead load every tool once a 1M window makes them + // under 10%. + ENABLE_TOOL_SEARCH: origin.ENABLE_TOOL_SEARCH ?? 'true', MULTI_GATEWAY_TOKEN: token, + // Claude Code 2.1.287 and newer has Mods on by default; 2.1.272 to 2.1.286 need this. CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: '1', MULTI_MOD_GATEWAY_URL: `http://127.0.0.1:${port}`, ...(!anthropic ? { ANTHROPIC_AUTH_TOKEN: token } : {}), - ANTHROPIC_CUSTOM_HEADERS: [ - process.env.ANTHROPIC_CUSTOM_HEADERS, - `x-multi-gateway-token: ${token}`, - ] + ANTHROPIC_CUSTOM_HEADERS: [origin.ANTHROPIC_CUSTOM_HEADERS, `x-multi-gateway-token: ${token}`] .filter(Boolean) .join('\n'), }; } +/** + * A `claude` run by Claude's own tools (Bash `claude -p`, scripts) inherits the session's + * gateway environment and would route through a gateway that is not its own. Put a + * `claude` on the session's PATH that runs the real executable without it. + */ +async function withNestedClaudeShim( + env: NodeJS.ProcessEnv, + settingsDir: string, + claude: string, +): Promise { + const directory = path.join(settingsDir, 'bin'); + const shim = nestedClaudeShim({ + node: process.execPath, + script: fileURLToPath(new URL('./install/nested-claude.ts', import.meta.url)), + claude, + }); + await mkdir(directory, { recursive: true }); + for (const file of shim.files) { + await writeFile(path.join(directory, file.file), file.content, { mode: 0o755 }); + } + return withPathPrefix(env, directory); +} + +/** A proxy the user has configured is honored only if Node was started to use it. */ +function warnIfProxyUnused() { + if (needsEnvProxy(process.env)) { + process.stderr.write( + 'Multi: HTTP_PROXY/HTTPS_PROXY is set but Node ignores it for the gateway; start the launcher with NODE_USE_ENV_PROXY=1 (the installed command does).\n', + ); + } +} + +/** A plugin inventory that cannot be read means no plugin workers, not no session. */ +async function pluginInventoryOrEmpty(cwd: string, args: readonly string[]) { + try { + return await pluginPermissions(cwd, args); + } catch (error) { + process.stderr.write( + `Multi: could not list Claude plugins (${error instanceof Error ? error.message.split('\n', 1)[0] : String(error)}); plugin-provided workers are unavailable.\n`, + ); + return { permissions: {}, multiCoreEnabled: false } satisfies PluginPermissionInventory; + } +} + function traceEvent(event: GatewayEvent) { if (process.env.MULTI_NATIVE_TRACE === '1') { process.stderr.write(`[native] ${JSON.stringify(event)}\n`); diff --git a/plugins/multi-core/tsconfig.json b/plugins/multi-core/tsconfig.json index 45d05be..0cd864b 100644 --- a/plugins/multi-core/tsconfig.json +++ b/plugins/multi-core/tsconfig.json @@ -11,6 +11,5 @@ "noEmit": true, "skipLibCheck": true }, - "include": ["../../.claude/types", "hooks", "types"], - "exclude": ["hooks/tests"] + "include": ["../../.claude-plugin/types/claude-code", "hooks", "types"] } diff --git a/plugins/multi-core/types/multi-core.d.ts b/plugins/multi-core/types/multi-core.d.ts new file mode 100644 index 0000000..6d297b5 --- /dev/null +++ b/plugins/multi-core/types/multi-core.d.ts @@ -0,0 +1,55 @@ +// The values multi-core's hooks keep in `$.state`: held by the host for the session, +// so they survive a hot reload of the hooks module, and a drawing that reads one is +// drawn again when it is written. Plain JSON data; a field without a value is left out. +export type MultiCorePrompt = { sessionId: string; cwd: string; permissionMode?: string }; + +/** The prompt-boundary snapshot the gateway holds this session's mode generation for. */ +export type MultiCorePolicy = { + generation?: number; + prompt?: MultiCorePrompt; + harnessReady?: boolean; + /** The snapshot last posted for a prompt that needs no harness policy, so an unchanged one posts nothing. */ + posted?: string; + /** The session the gateway was greeted for (`session.start`, or the first prompt after a `/clear`). */ + handshake?: string; +}; + +export type MultiCoreDisplayTools = { registered: string[]; revision?: number }; + +export type MultiCoreUsagePane = { + updatedAt: string; + providers: Array<{ + id: string; + name: string; + status: string; + summary: string; + details: string[]; + url?: string; + }>; + receiptLines?: string[]; + error?: string; + quotaAdviceEnabled?: boolean; +}; + +declare module 'claude-code' { + interface PluginState { + 'multi-core': { + policy: MultiCorePolicy; + /** The model each loop last stepped on or was registered with, by agent id (`main` for the main loop). */ + agentModels: Record; + /** The model a provider worker resolved to, by its Agent call's tool_use_id. */ + spawnModels: Record; + /** Agent types positively classified as Claude-loop on a Claude model. */ + claudeTypes: string[]; + /** Provider (multi-*) worker types the model is offered. */ + offeredProviders: string[]; + displayTools: MultiCoreDisplayTools; + /** The usage pane's props, by session id. */ + usagePanes: Record; + /** The gateway's per-session mod key, by session id. Never logged or drawn. */ + modKeys: Record; + /** Sessions whose Agent calls carry the quota advice. */ + advisorySessions: string[]; + }; + } +} diff --git a/plugins/multi-core/types/timers.d.ts b/plugins/multi-core/types/timers.d.ts deleted file mode 100644 index 6cf3fb4..0000000 --- a/plugins/multi-core/types/timers.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// The hooks environment provides timers, which the generated engine declarations -// (`.claude/types/claude-code.d.ts`) do not list among its globals. -declare function setTimeout(callback: (...args: never[]) => void, ms?: number): number; -declare function clearTimeout(id: number | undefined): void; diff --git a/plugins/multi-cursor/src/harness.ts b/plugins/multi-cursor/src/harness.ts index ba97097..7cca7b6 100644 --- a/plugins/multi-cursor/src/harness.ts +++ b/plugins/multi-cursor/src/harness.ts @@ -11,6 +11,7 @@ import type { TokenUsage, } from '@cursor/sdk'; import type { WorkerPermissions } from '../../multi-core/src/gateway/agent-definitions.ts'; +import { boundedWait } from '../../multi-core/src/gateway/harness-cli.ts'; import { archiveHarnessReply, commitHarnessResponse, @@ -45,25 +46,22 @@ import { HarnessSessionStore, type HarnessTurnLease, isHash, - isRecord, readJson, textContentBlock, } from '../../multi-core/src/gateway/harness-session.ts'; +import { mergePermissions } from '../../multi-core/src/gateway/harness-settings.ts'; import type { Emit, MessagesRequest, MessagesResponse, } from '../../multi-core/src/gateway/messages.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { dangerousToolMode } from '../../multi-core/src/gateway/safeguards.ts'; import { settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import { CursorProviderError, cursorRunError } from './errors.ts'; import { type CursorModelOption, cursorSelection } from './models.ts'; -import { - cursorNativePermissions, - cursorPermissionPolicy, - mergeCursorPermissions, -} from './permissions.ts'; +import { cursorNativePermissions, cursorPermissionPolicy } from './permissions.ts'; import { cursorContextNotice, observeCursorUpdate } from './progress.ts'; import { prepareCursorRequest } from './request.ts'; import { legacyCursorSessionFile, restoreCursorSession } from './session-record.ts'; @@ -396,7 +394,7 @@ export class CursorHarness { if (!context) { throw new Error('Cursor requires an explicit Claude permission context'); } - const permissions = mergeCursorPermissions(context, await this.checkPermissions()); + const permissions = mergePermissions(context, await this.checkPermissions()); this.validate(body, permissions); this.cwd = await realpath(this.cwd); const key = hash([ @@ -864,7 +862,10 @@ export class CursorHarness { exchange.controller.abort(new Error('Cursor gateway closed')); } } - await bounded(Promise.allSettled(this.registry.all().map((exchange) => exchange.result))); + await boundedWait( + Promise.allSettled(this.registry.all().map((exchange) => exchange.result)), + 1000, + ); for (const session of [...this.store.sessions()]) { this.closeAgent(session.runtime.agent); } @@ -898,17 +899,3 @@ function savedAgentId(saved: unknown): string | undefined { } return saved.agentId; } - -async function bounded(operation: Promise) { - let timer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - operation, - new Promise((resolve) => { - timer = setTimeout(resolve, 1000); - }), - ]); - } finally { - clearTimeout(timer); - } -} diff --git a/plugins/multi-cursor/src/permissions.ts b/plugins/multi-cursor/src/permissions.ts index 5b09ed4..0d5dece 100644 --- a/plugins/multi-cursor/src/permissions.ts +++ b/plugins/multi-cursor/src/permissions.ts @@ -144,22 +144,6 @@ export function assertCursorClaudeSettings( return { disallowedTools: denied }; } -/** Restriction layers intersect grants and accumulate denials; none can widen another. */ -export function mergeCursorPermissions( - context: PermissionContext, - rules: WorkerPermissions = {}, -): PermissionContext { - let tools = context.tools; - if (rules.tools !== undefined) { - tools = tools === undefined ? rules.tools : tools.filter((tool) => rules.tools?.includes(tool)); - } - return { - ...context, - tools, - disallowedTools: [...(context.disallowedTools ?? []), ...(rules.disallowedTools ?? [])], - }; -} - function settingsRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Invalid Claude settings for native Cursor execution'); diff --git a/plugins/multi-cursor/src/quota.ts b/plugins/multi-cursor/src/quota.ts index 0b15d97..6ade07e 100644 --- a/plugins/multi-cursor/src/quota.ts +++ b/plugins/multi-cursor/src/quota.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import { getDefaultSdkAuthPath } from '@cursor/sdk'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; export interface CursorQuotaUsage { billingCycleStart?: string; @@ -204,10 +205,6 @@ function formatDate(value: string | undefined): string { return value; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function unavailable(): Error { return new Error('Cursor quota is unavailable'); } diff --git a/plugins/multi-cursor/src/request.ts b/plugins/multi-cursor/src/request.ts index 50f467a..0c7425b 100644 --- a/plugins/multi-cursor/src/request.ts +++ b/plugins/multi-cursor/src/request.ts @@ -2,6 +2,7 @@ import type { SDKUserMessage } from '@cursor/sdk'; import { normalizeConversation, textContent } from '../../multi-core/src/gateway/conversation.ts'; import { isDirectToolAvailable } from '../../multi-core/src/gateway/direct-tools.ts'; import type { MessagesRequest } from '../../multi-core/src/gateway/messages.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { estimateTextTokens } from '../../multi-core/src/gateway/tokens.ts'; function validateControls(body: MessagesRequest) { @@ -82,10 +83,6 @@ function validateThinking(thinking: MessagesRequest['thinking']) { } } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - export function prepareCursorRequest(body: MessagesRequest) { validateControls(body); // AgentOptions has no Messages max-token or sampling controls. Keep Claude's diff --git a/plugins/multi-cursor/src/session-record.ts b/plugins/multi-cursor/src/session-record.ts index 759c9bc..1ca9ac3 100644 --- a/plugins/multi-cursor/src/session-record.ts +++ b/plugins/multi-cursor/src/session-record.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; -import { isRecord } from '../../multi-core/src/gateway/harness-session.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; /** The v2 gateway hashed the serialized [workspace, scope] directly. */ export function legacyCursorSessionFile(directory: string, identity: string): string { diff --git a/plugins/multi-cursor/src/workspaces.ts b/plugins/multi-cursor/src/workspaces.ts index f8d2ccd..8d13bb0 100644 --- a/plugins/multi-cursor/src/workspaces.ts +++ b/plugins/multi-cursor/src/workspaces.ts @@ -25,6 +25,12 @@ export class CursorWorkspaces { return this.harness(cwd).handle(...args); } + /** The scope's last reply, read from the workspace that `handle` would route it to. */ + async recordedResponse(scope: string, context?: PermissionContext) { + const cwd = await realpath(context?.cwd ?? this.cwd); + return this.harness(cwd).recordedResponse(scope); + } + async billedUsageForSession(sessionId: string) { const batches = await Promise.all( [...this.harnesses.values()].map((harness) => harness.billedUsageForSession(sessionId)), diff --git a/plugins/multi-grok/src/cli.ts b/plugins/multi-grok/src/cli.ts index 5dd7635..da4ce61 100644 --- a/plugins/multi-grok/src/cli.ts +++ b/plugins/multi-grok/src/cli.ts @@ -6,8 +6,10 @@ import { NativeCliError, nativeEnvironment, promptArgumentLimitBytes, + promptArgvSafe, runNativeCli, } from '../../multi-core/src/gateway/harness-process.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; /** * Grok Build headless contract, captured from `grok -p --output-format streaming-json` @@ -202,7 +204,16 @@ export async function runGrok(options: GrokRunOptions): Promise { throw new GrokCliError('Grok resumes a session or creates one, never both', 'spawn'); } const platform = options.platform ?? process.platform; - const oversized = Buffer.byteLength(options.prompt) >= promptArgumentLimitBytes(platform); + // A prompt too long for argv, or one a cmd.exe launcher cannot carry safely, + // travels in a private file instead. + const oversized = + Buffer.byteLength(options.prompt) >= promptArgumentLimitBytes(platform) || + !promptArgvSafe(options.prompt, { + executable: 'grok', + configuredPath: options.executable, + platform, + env: grokEnvironment(options.env), + }); const directory = oversized ? await mkdtemp(path.join(os.tmpdir(), 'multi-grok-')) : undefined; const promptFile = directory ? path.join(directory, 'prompt.txt') : undefined; if (promptFile) { @@ -222,6 +233,8 @@ export async function runGrok(options: GrokRunOptions): Promise { type ParserState = { response: string; forbidden?: readonly string[]; + /** The allowlist this run requested; an announced tool outside it that is not MCP is a breach. */ + granted?: readonly string[]; tools?: readonly string[]; terminal?: GrokResult; failure?: NativeCliError; @@ -235,7 +248,11 @@ async function spawnGrok( platform: NodeJS.Platform, promptFile: string | undefined, ): Promise { - const parser: ParserState = { response: '', forbidden: options.forbiddenTools }; + const parser: ParserState = { + response: '', + forbidden: options.forbiddenTools, + granted: options.tools, + }; const run = await runNativeCli({ name: 'Grok', executable: 'grok', @@ -342,6 +359,14 @@ function parseEvent( } } +/** + * MCP tools are announced as `server__tool`, and `search_tool`/`use_tool` are the CLI's own + * broker for them. MCP execution is denied by rule, so their exposure is tolerated. + */ +function isMcpTool(name: string): boolean { + return name.includes('__') || name === 'search_tool' || name === 'use_tool'; +} + /** * The announced toolset is the only evidence that a removal took effect: the CLI * accepts unknown `--disallowed-tools` names and runs the tool anyway. Every @@ -354,7 +379,11 @@ function parseTools(value: Record, parser: ParserState, emit: E return; } const forbidden = new Set(parser.forbidden ?? []); - const breach = tools.filter((tool) => forbidden.has(tool)); + const granted = parser.granted === undefined ? undefined : new Set(parser.granted); + // Fail closed: a name this build adds later is neither granted nor MCP, so it is refused. + const breach = tools.filter( + (tool) => forbidden.has(tool) || (granted && !granted.has(tool) && !isMcpTool(tool)), + ); if (breach.length) { fail( new GrokCliError( @@ -444,10 +473,6 @@ function parseTerminal( emit({ event: 'result', result }); } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function optionalString(value: unknown): string | undefined { return typeof value === 'string' && value ? value : undefined; } diff --git a/plugins/multi-grok/src/harness.ts b/plugins/multi-grok/src/harness.ts index bd0a478..d1ec42e 100644 --- a/plugins/multi-grok/src/harness.ts +++ b/plugins/multi-grok/src/harness.ts @@ -1,16 +1,14 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { realpath } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import { - archiveHarnessReply, - commitHarnessResponse, -} from '../../multi-core/src/gateway/harness-completion.ts'; + CliHarness, + type CliTurn, + digest, + harnessFailure, +} from '../../multi-core/src/gateway/harness-cli.ts'; +import { commitHarnessResponse } from '../../multi-core/src/gateway/harness-completion.ts'; import type { HarnessExchange } from '../../multi-core/src/gateway/harness-exchange.ts'; -import { - ExchangeRegistry, - replayPersisted, -} from '../../multi-core/src/gateway/harness-exchange.ts'; import { continuation, historyRewound, @@ -18,7 +16,6 @@ import { stderrDiagnostics, writeNotices, } from '../../multi-core/src/gateway/harness-notices.ts'; -import type { NativeProgressObserver } from '../../multi-core/src/gateway/harness-progress.ts'; import { NativeActionTracker } from '../../multi-core/src/gateway/harness-progress.ts'; import type { HarnessUsageFields } from '../../multi-core/src/gateway/harness-response.ts'; import { @@ -29,19 +26,15 @@ import type { HarnessSession, HarnessSessionBase, } from '../../multi-core/src/gateway/harness-session.ts'; -import { - HarnessBusyError, - HarnessSessionStore, -} from '../../multi-core/src/gateway/harness-session.ts'; import type { Emit, MessagesRequest, MessagesResponse, } from '../../multi-core/src/gateway/messages.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; -import { abortGraceMs, settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; +import { settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import type { GrokRunOptions, GrokRunResult, GrokStreamEvent, GrokUsage } from './cli.ts'; -import { GrokCliError, runGrok } from './cli.ts'; +import { runGrok } from './cli.ts'; import { grokFailureAdvice } from './errors.ts'; import type { GrokModel } from './models.ts'; import { selectGrokModel } from './models.ts'; @@ -67,21 +60,12 @@ type GrokSaved = HarnessSessionBase & { }; type GrokSession = HarnessSession; -const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); +type GrokSelection = ReturnType; -export class GrokHarness { +export class GrokHarness extends CliHarness { private readonly models: readonly GrokModel[]; - private readonly defaultCwd: string; - private readonly stateDirectory: string; - private readonly platform: NodeJS.Platform; private readonly run: GrokRunner; private readonly checkPermissions: CheckGrokPermissions; - private readonly store: HarnessSessionStore; - private readonly exchanges = new ExchangeRegistry({ - provider: PROVIDER, - createMeta: () => ({}), - }); - private closed = false; constructor( models: readonly GrokModel[], @@ -99,27 +83,24 @@ export class GrokHarness { platform?: NodeJS.Platform; } = {}, ) { - this.models = models; - this.defaultCwd = cwd; - this.stateDirectory = stateDirectory; - this.platform = platform; - this.run = run; - this.checkPermissions = checkPermissions ?? missingPermissions; - this.store = new HarnessSessionStore({ - provider: STORE_PROVIDER, + super({ tag: PROVIDER, + storeProvider: STORE_PROVIDER, stateDirectory, + defaultCwd: cwd, platform, version: 1, - runtime: () => ({}), fresh: (identity) => ({ version: 1, provider: STORE_PROVIDER, identity, interrupted: false }), validate: (saved) => (saved.sessionId === undefined || isUuid(saved.sessionId)) && (saved.cost === undefined || (typeof saved.cost === 'number' && saved.cost >= 0)), }); + this.models = models; + this.run = run; + this.checkPermissions = checkPermissions ?? missingPermissions; } - private selection(body: MessagesRequest) { + protected selection(body: MessagesRequest) { return selectGrokModel(this.models, body.model, body.output_config?.effort); } @@ -128,108 +109,17 @@ export class GrokHarness { return prepareGrokRequest(body, selection.model.id).inputTokens; } - /** The scope's last reply from its session record, located as `handle` locates it. */ - async recordedResponse(scope: string, context?: PermissionContext) { - const cwd = await realpath(context?.cwd ?? this.defaultCwd); - return this.store.recordedResponse(`${cwd}\0${scope}`); - } - - async handle( - body: MessagesRequest, - scope: string, - signal: AbortSignal, - emit?: Emit, - context?: PermissionContext, - observe?: NativeProgressObserver, - ): Promise { - if (this.closed) { - throw new Error('Grok harness is closed'); - } - signal.throwIfAborted(); - if (!context) { - throw new Error('Grok requires an explicit Claude permission context'); - } - const selection = this.selection(body); - this.validate(body); - const cwd = await realpath(context.cwd ?? this.defaultCwd); - const identity = `${cwd}\0${scope}`; - const key = digest([ - 'grok', - identity, - { - ...body, - stream: undefined, - messages: grokHistoryHash(body.messages), - system: undefined, - }, - { - permissionMode: context.permissionMode, - tools: context.tools, - disallowedTools: context.disallowedTools, - nativePermissionError: context.nativePermissionError, - compaction: context.compaction, - }, - ]); - let exchange = this.exchanges.get(key); - if (!exchange) { - if (this.exchanges.size >= 256) { - throw new Error('Too many concurrent Grok requests'); - } - exchange = this.exchanges.start(key, (startedExchange, forward) => - this.serve(body, context, selection, cwd, identity, key, startedExchange, forward, observe), - ); - } - return this.exchanges.observe(exchange, signal, emit); + protected requestKey(body: MessagesRequest) { + return { + ...body, + stream: undefined, + messages: grokHistoryHash(body.messages), + system: undefined, + }; } - private async serve( - body: MessagesRequest, - context: PermissionContext, - selection: ReturnType, - cwd: string, - identity: string, - key: string, - exchange: HarnessExchange, - emit: Emit, - observe?: NativeProgressObserver, - ): Promise { - let lease: Awaited['acquireLease']>>; - try { - lease = await this.store.acquireLease(identity); - } catch (error) { - throw new GrokProviderError(error); - } - const session = lease.session; - try { - const replayed = await replayPersisted({ - stateDirectory: this.stateDirectory, - key, - saved: session.saved, - emit, - provider: PROVIDER, - }); - if (replayed !== undefined) { - return replayed; - } - await archiveHarnessReply({ - session, - stateDirectory: this.stateDirectory, - platform: this.platform, - }); - return await this.execute( - body, - context, - selection, - cwd, - session, - key, - exchange, - emit, - observe, - ); - } finally { - await lease.release(); - } + protected providerError(error: unknown) { + return new GrokProviderError(error); } /** @@ -238,10 +128,7 @@ export class GrokHarness { * `execute` to keep its own branching under the cognitive-complexity limit. */ private async openTurn( - body: MessagesRequest, - context: PermissionContext, - selection: ReturnType, - cwd: string, + turn: CliTurn, session: GrokSession, wasInterrupted: boolean, emit: Emit, @@ -250,6 +137,7 @@ export class GrokHarness { policy: GrokPolicy; response: HarnessResponse; }> { + const { body, context, selection, cwd } = turn; // Computed only once the record is held, so a continuation error still releases // it in `execute`'s `finally` instead of leaving the session busy forever. const messages = session.saved.sessionId ? continuation(body, PROVIDER) : (body.messages ?? []); @@ -277,26 +165,19 @@ export class GrokHarness { return { prepared, policy, response }; } - private async execute( - body: MessagesRequest, - context: PermissionContext, - selection: ReturnType, - cwd: string, + protected async execute( + turn: CliTurn, session: GrokSession, - key: string, exchange: HarnessExchange, emit: Emit, - observe?: NativeProgressObserver, ): Promise { + const { selection, cwd, key, observe } = turn; const signal = exchange.controller.signal; const wasInterrupted = session.saved.interrupted; let started = false; try { const { prepared, policy, response } = await this.openTurn( - body, - context, - selection, - cwd, + turn, session, wasInterrupted, emit, @@ -304,7 +185,6 @@ export class GrokHarness { signal.throwIfAborted(); const sessionId = session.saved.sessionId ?? randomUUID(); let startSave: Promise | undefined; - const startedAt = performance.now(); // Each finished tool call becomes a display row in this reply, named after // Grok's own tool; the terse summary is written when the run settles. const actions = new NativeActionTracker(PROVIDER, observe, (block) => @@ -347,10 +227,17 @@ export class GrokHarness { 'Grok native run', ); await startSave; - return await this.settle( - { session, response, outcome, selection, key, exchange, emit, actions, calls }, - performance.now() - startedAt, - ); + return await this.settle({ + session, + response, + outcome, + selection, + key, + exchange, + emit, + actions, + calls, + }); } catch (error) { // Traced before the rethrow: a failure that never reached the CLI left no // other evidence at all, which cost one diagnosis already. @@ -369,20 +256,17 @@ export class GrokHarness { } } - private async settle( - run: { - session: GrokSession; - response: HarnessResponse; - outcome: GrokRunResult; - selection: ReturnType; - key: string; - exchange: HarnessExchange; - emit: Emit; - actions: NativeActionTracker; - calls: HarnessModelCalls; - }, - elapsedMs: number, - ): Promise { + private async settle(run: { + session: GrokSession; + response: HarnessResponse; + outcome: GrokRunResult; + selection: GrokSelection; + key: string; + exchange: HarnessExchange; + emit: Emit; + actions: NativeActionTracker; + calls: HarnessModelCalls; + }): Promise { const { session, response, outcome, selection, key, exchange, emit, actions, calls } = run; const result = outcome.result; // The CLI owns the identity it reports; a mismatch would silently fork history. @@ -391,8 +275,17 @@ export class GrokHarness { `Grok answered on session ${result.sessionId} instead of ${session.saved.sessionId}`, ); } + if (result.stopReason !== 'end_turn') { + // Only end_turn is a finished answer. Cancelled, refused or truncated turns may have + // run steps, so the next request resumes the session with the interruption notice. + session.saved.sessionId = result.sessionId; + session.saved.interrupted = true; + session.saved.cost = (session.saved.cost ?? 0) + (result.costUsd ?? 0); + await this.store.save(session); + throw new GrokProviderError(`Grok stopped before finishing its turn (${result.stopReason})`); + } response.text(actions.text(calls.count || result.turns)); - appendDiagnostics(response, outcome, elapsedMs); + appendDiagnostics(response, outcome); const finished = response.finish( calls.turn(toHarnessUsage(result.usage), result.turns), selection.model.id, @@ -413,19 +306,6 @@ export class GrokHarness { }, }); } - - async close() { - this.closed = true; - const running: Promise[] = []; - for (const exchange of this.exchanges.all()) { - if (!exchange.settled) { - exchange.controller.abort(new Error('Grok gateway closed')); - running.push(exchange.result); - } - } - await bounded(Promise.allSettled(running)); - await this.store.closeAll(); - } } /** @@ -471,18 +351,8 @@ function toHarnessUsage(usage: GrokUsage | undefined): HarnessUsageFields | unde }; } -/** The CLI reports cost per invocation, so it is shown as billed for this run. */ -function appendDiagnostics(response: HarnessResponse, outcome: GrokRunResult, elapsedMs: number) { - const parts = []; - if (Number.isFinite(elapsedMs)) { - parts.push(`completed in ${(Math.max(0, elapsedMs) / 1000).toFixed(1)}s`); - } - if (outcome.result.costUsd !== undefined) { - parts.push(`$${outcome.result.costUsd.toFixed(4)} billed`); - } - if (parts.length) { - response.text(`\n[Grok] ${parts.join(' · ')}\n`); - } +/** Timing and cost reach the user through receipts; only warnings the model can act on stay. */ +function appendDiagnostics(response: HarnessResponse, outcome: GrokRunResult) { const diagnostics = stderrDiagnostics(outcome.stderr); if (diagnostics) { response.text(`[Grok] ${diagnostics}\n`); @@ -493,56 +363,17 @@ function isUuid(value: unknown): value is string { return typeof value === 'string' && /^[0-9a-f-]{36}$/.test(value); } -async function bounded(operation: Promise) { - let timer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - operation, - new Promise((resolve) => { - timer = setTimeout(resolve, abortGraceMs); - }), - ]); - } finally { - clearTimeout(timer); - } -} - const missingPermissions: CheckGrokPermissions = async () => { throw new Error('Grok native permission policy is not configured'); }; -/** - * A machine momentarily out of processes, file handles or memory starts the CLI on - * the next attempt; a missing binary or a denied path never does. Only the second - * kind is answered as a request error. - */ -const TRANSIENT_SPAWN = new Set(['EAGAIN', 'EMFILE', 'ENFILE', 'ENOMEM', 'ETXTBSY']); - -function permanentFailure(error: GrokCliError): boolean { - if (error.code === 'policy') { - return true; - } - return error.code === 'spawn' && !TRANSIENT_SPAWN.has(error.systemCode ?? ''); -} - export class GrokProviderError extends Error { readonly failure: { status: number; message: string }; constructor(error: unknown) { - // A busy agent, a policy the CLI did not apply, or a CLI that will not start, - // fails the same way on every attempt. Reporting them as 502 had Claude retry a - // paid run ten times over one prompt, so they are answered as a request error. - const deterministic = - error instanceof HarnessBusyError || - (error instanceof GrokCliError && permanentFailure(error)); - const detail = error && typeof error === 'object' && 'message' in error ? error.message : error; - const reported = String(detail ?? 'unknown Grok failure'); - const advice = grokFailureAdvice(reported); - const message = `${reported}${advice ? ` ${advice}` : ''}` - .replace(/\s+/g, ' ') - .trim() - .slice(0, 500); - super(message, { cause: error }); + // Deterministic failures are request errors; retrying them repeats a paid run. + const failure = harnessFailure(error, 'unknown Grok failure', grokFailureAdvice); + super(failure.message, { cause: error }); this.name = 'GrokProviderError'; - this.failure = { status: deterministic ? 400 : 502, message }; + this.failure = failure; } } diff --git a/plugins/multi-grok/src/models.ts b/plugins/multi-grok/src/models.ts index d416bfa..ad4d1c1 100644 --- a/plugins/multi-grok/src/models.ts +++ b/plugins/multi-grok/src/models.ts @@ -4,7 +4,7 @@ import { executableInvocation, resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; -import type { Effort } from '../../multi-openai/src/responses.ts'; +import type { Effort } from '../../multi-core/src/gateway/responses.ts'; import { grokEnvironment } from './cli.ts'; /** diff --git a/plugins/multi-grok/src/progress.ts b/plugins/multi-grok/src/progress.ts index 083fb90..e31ad65 100644 --- a/plugins/multi-grok/src/progress.ts +++ b/plugins/multi-grok/src/progress.ts @@ -2,6 +2,7 @@ import type { NativeActionKind, NativeActionTracker, } from '../../multi-core/src/gateway/harness-progress.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import type { GrokStreamEvent, GrokToolCall } from './cli.ts'; // ACP tool kinds as the Grok Build CLI reports them. @@ -14,10 +15,6 @@ const kinds: Record = { execute: 'shell', }; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** The text a tool update carries in its content blocks. */ function grokContentText(content: unknown): string { if (!Array.isArray(content)) { diff --git a/plugins/multi-grok/src/usage.ts b/plugins/multi-grok/src/usage.ts index fd002c5..42e5f91 100644 --- a/plugins/multi-grok/src/usage.ts +++ b/plugins/multi-grok/src/usage.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; /** * Grok Build exposes no account quota endpoint, so the useful account fact is the @@ -26,10 +27,6 @@ export function grokAuthFile(options: GrokAuthOptions = {}): string { return join(options.homedir ?? os.homedir(), '.grok', 'auth.json'); } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** Loads native auth to report key/refresh-token presence and access-token expiry only. */ export async function readGrokAuth(options: GrokAuthOptions = {}): Promise { let source: string; diff --git a/plugins/multi-openai/src/approval.ts b/plugins/multi-openai/src/approval.ts index e794198..54896c5 100644 --- a/plugins/multi-openai/src/approval.ts +++ b/plugins/multi-openai/src/approval.ts @@ -9,8 +9,9 @@ import type { } from '../../multi-core/src/gateway/approval.ts'; import { NativeApprovalBridge } from '../../multi-core/src/gateway/approval.ts'; import type { GatewayFetch } from '../../multi-core/src/gateway/fetch.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; +import { readSse } from '../../multi-core/src/gateway/responses.ts'; import { codexRequest } from './auth.ts'; -import { readSse } from './responses.ts'; const REVIEW_FAILURE_EVENTS: readonly unknown[] = [ 'response.failed', @@ -19,9 +20,6 @@ const REVIEW_FAILURE_EVENTS: readonly unknown[] = [ 'error', ]; const endpoint = 'https://chatgpt.com/backend-api/codex'; -const record = (value: unknown): value is Record => - !!value && typeof value === 'object' && !Array.isArray(value); - /** Discover the subscription reviewer; never substitute the working model. */ export async function discoverOpenAIReviewer( authFile: string, @@ -42,9 +40,9 @@ export async function discoverOpenAIReviewer( } const catalog: unknown = await response.json(); return ( - record(catalog) && + isRecord(catalog) && Array.isArray(catalog.models) && - catalog.models.some((model: unknown) => record(model) && model.slug === 'codex-auto-review') + catalog.models.some((model: unknown) => isRecord(model) && model.slug === 'codex-auto-review') ); } @@ -55,7 +53,7 @@ export async function inspectApprovalPath( { platform = process.platform }: { platform?: NodeJS.Platform } = {}, ): Promise { if ( - !record(input) || + !isRecord(input) || typeof input.path !== 'string' || Object.keys(input).some((key) => key !== 'path') ) { @@ -238,7 +236,7 @@ async function readReviewerOutput(response: Response): Promise[] = []; let bytes = 0; for await (const event of readSse(response.body)) { - if (!record(event)) { + if (!isRecord(event)) { throw new Error('Invalid reviewer stream'); } bytes += JSON.stringify(event).length; @@ -248,7 +246,7 @@ async function readReviewerOutput(response: Response): Promise[], completed: unknown, ): Record[] { - if (!record(completed) || completed.status !== 'completed' || !Array.isArray(completed.output)) { + if ( + !isRecord(completed) || + completed.status !== 'completed' || + !Array.isArray(completed.output) + ) { throw new Error('Incomplete reviewer response'); } // Codex's subscription stream may leave final output empty; done events carry the items. const output: unknown[] = items.length ? items : completed.output; - if (!output.every(record)) { + if (!output.every(isRecord)) { throw new Error('Invalid reviewer output item'); } if (JSON.stringify(output).length > 131072) { @@ -331,7 +333,7 @@ function reviewerVerdict(output: Record[]): ApprovalVerdict { throw new Error('Invalid reviewer verdict'); } return item.content.map((part: unknown) => { - if (!record(part) || part.type !== 'output_text' || typeof part.text !== 'string') { + if (!isRecord(part) || part.type !== 'output_text' || typeof part.text !== 'string') { throw new Error('Invalid reviewer verdict'); } return part.text; @@ -341,7 +343,7 @@ function reviewerVerdict(output: Record[]): ApprovalVerdict { throw new Error('Invalid reviewer verdict'); } const verdict: unknown = JSON.parse(text.join('')); - if (!record(verdict) || (verdict.outcome !== 'allow' && verdict.outcome !== 'deny')) { + if (!isRecord(verdict) || (verdict.outcome !== 'allow' && verdict.outcome !== 'deny')) { throw new Error('Invalid reviewer verdict'); } if ( diff --git a/plugins/multi-openai/src/auth.ts b/plugins/multi-openai/src/auth.ts index 1622b9c..1aee2a7 100644 --- a/plugins/multi-openai/src/auth.ts +++ b/plugins/multi-openai/src/auth.ts @@ -7,6 +7,7 @@ import { resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; import { terminateProcessTree } from '../../multi-core/src/gateway/process-tree.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; /** Codex's saved ChatGPT login, forwarded as OpenAI request headers. */ export interface CodexAuthHeaders { @@ -20,10 +21,6 @@ const refreshing = new Map>(); const renewalFailure = () => new CodexAuthError('Codex could not renew the ChatGPT login. Run codex login and retry.'); -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - async function savedAuth(authFile: string) { let auth: unknown; try { @@ -163,7 +160,7 @@ async function refreshIfUnchanged( env: environment, configuredPath: options.executable, }), - ['app-server', '-c', 'cli_auth_credentials_store="file"'], + ['app-server', '-c', 'cli_auth_credentials_store=file'], options.platform, environment, ); diff --git a/plugins/multi-openai/src/gateway-request.ts b/plugins/multi-openai/src/gateway-request.ts new file mode 100644 index 0000000..8c79285 --- /dev/null +++ b/plugins/multi-openai/src/gateway-request.ts @@ -0,0 +1,108 @@ +import { createHash } from 'node:crypto'; +import type { GatewayFetch } from '../../multi-core/src/gateway/fetch.ts'; +import type { + PreparedRequest, + ProviderRequestInput, +} from '../../multi-core/src/gateway/provider-request.ts'; +import { ProviderAuthError } from '../../multi-core/src/gateway/provider-request.ts'; +import { + fromResponses, + OPENAI_SIGNATURE_PREFIX, + toResponses, +} from '../../multi-core/src/gateway/responses.ts'; +import { estimateInputTokens } from '../../multi-core/src/gateway/tokens.ts'; +import { originalToolNames } from '../../multi-core/src/gateway/tools.ts'; +import { CodexAuthError, codexRequest } from './auth.ts'; +import { openaiInstructions } from './instructions.ts'; +import { MODELS } from './models.ts'; + +const OPENAI_URL = 'https://chatgpt.com/backend-api/codex/responses'; +const MESSAGES_PATHS = ['/v1/messages', '/v1/messages/count_tokens']; + +/** The prefixes of the reasoning signatures this provider writes into Claude's history. */ +export const openaiSignaturePrefixes: readonly string[] = [OPENAI_SIGNATURE_PREFIX]; + +const openaiAuthHelp = ' Renew the Codex login.'; + +/** The Responses request for one Claude request to a native `multi/openai/` model. */ +export function prepareOpenAIRequest( + { + method, + pathname, + body, + session, + fallbackSession, + clientSession, + agentId, + }: ProviderRequestInput, + externalModel: string, + authFile: string, +): PreparedRequest { + const model = Object.values(MODELS).find((model) => externalModel === `multi/openai/${model}`); + if (!model) { + throw new Error('Unknown native OpenAI model'); + } + if (!MESSAGES_PATHS.includes(pathname) || method !== 'POST') { + throw new Error('External models require POST /v1/messages or /v1/messages/count_tokens'); + } + const translated = toResponses(body, model); + const request = { + ...translated, + instructions: openaiInstructions(translated.instructions), + prompt_cache_key: createHash('sha256') + .update( + JSON.stringify(['openai', session || fallbackSession, agentId ?? 'main', translated.model]), + ) + .digest('hex'), + }; + let estimate: number | undefined; + const inputTokens = () => { + estimate ??= estimateInputTokens(request); + return estimate; + }; + let toolNames: Map | undefined; + return { + label: 'OpenAI', + authHelp: openaiAuthHelp, + endpoint: 'responses', + route: 'openai', + inputTokens, + completion: { model: request.model, effort: request.reasoning.effort }, + requestEvent: (agent) => ({ + route: 'openai-request', + agentId: agent, + model: request.model, + effort: request.reasoning.effort, + }), + failureEvent: (status) => ({ route: 'openai', status }), + async send(signal: AbortSignal, fetchImpl: GatewayFetch) { + toolNames = originalToolNames(body); + const headers = { + 'content-type': 'application/json', + accept: 'text/event-stream', + originator: 'cc_multi_native', + session_id: String(agentId ?? clientSession ?? fallbackSession), + }; + try { + return await codexRequest(authFile, signal, (auth) => + fetchImpl(OPENAI_URL, { + method: 'POST', + headers: { ...headers, ...auth }, + body: JSON.stringify(request), + signal, + redirect: 'error', + }), + ); + } catch (error) { + throw error instanceof CodexAuthError ? new ProviderAuthError(error.message) : error; + } + }, + translate: (stream, emit) => + fromResponses(stream, externalModel, emit, { + toolNames: toolNames ?? originalToolNames(body), + stopSequences: body.stop_sequences, + inputTokens: inputTokens(), + safeguards: body.safeguards, + }), + }; +} diff --git a/plugins/multi-openai/src/models.ts b/plugins/multi-openai/src/models.ts index 32e3604..d24658b 100644 --- a/plugins/multi-openai/src/models.ts +++ b/plugins/multi-openai/src/models.ts @@ -1,8 +1,8 @@ -import type { Effort } from './responses.ts'; +import type { Effort } from '../../multi-core/src/gateway/responses.ts'; export const MODELS = { 'openai-native': 'gpt-6-astra', - 'openai-sol': 'gpt-6-sol', + 'openai-sol': 'gpt-6.1-sol', 'openai-terra': 'gpt-5.6-terra', 'openai-luna': 'gpt-6-luna', }; diff --git a/plugins/multi-openai/src/usage.ts b/plugins/multi-openai/src/usage.ts index 63a7b30..05332b3 100644 --- a/plugins/multi-openai/src/usage.ts +++ b/plugins/multi-openai/src/usage.ts @@ -6,6 +6,7 @@ import { resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; import { terminateProcessTree } from '../../multi-core/src/gateway/process-tree.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; interface CodexQuotaWindow { label: string; @@ -43,7 +44,7 @@ export async function readCodexUsage( env: environment, configuredPath: options.executable, }), - ['app-server', '-c', 'cli_auth_credentials_store="file"'], + ['app-server', '-c', 'cli_auth_credentials_store=file'], platform, environment, ); @@ -103,10 +104,10 @@ async function quotaResponse( for await (const line of lines) { check(); const message: unknown = JSON.parse(line); - if (!record(message)) { + if (!isRecord(message)) { throw unavailable(); } - if (message.id === 1 && !initialized && record(message.result)) { + if (message.id === 1 && !initialized && isRecord(message.result)) { initialized = true; send({ method: 'initialized' }); send({ id: 2, method: 'account/rateLimits/read' }); @@ -121,12 +122,12 @@ async function quotaResponse( /** The multi-bucket response is authoritative; never count its legacy alias twice. */ export function normalizeCodexUsage(value: unknown): CodexQuota { - if (!record(value)) { + if (!isRecord(value)) { throw unavailable(); } const quota: CodexQuota = { windows: [] }; for (const [id, bucket] of quotaBuckets(value).slice(0, 32)) { - if (!record(bucket)) { + if (!isRecord(bucket)) { continue; } if (typeof bucket.planType === 'string') { @@ -145,7 +146,7 @@ export function normalizeCodexUsage(value: unknown): CodexQuota { } function quotaCredits(value: unknown): CodexQuota['credits'] { - if (!record(value)) { + if (!isRecord(value)) { return undefined; } return { @@ -155,17 +156,17 @@ function quotaCredits(value: unknown): CodexQuota['credits'] { } function quotaBuckets(value: Record): [string, unknown][] { - if (record(value.rateLimitsByLimitId) && Object.keys(value.rateLimitsByLimitId).length) { + if (isRecord(value.rateLimitsByLimitId) && Object.keys(value.rateLimitsByLimitId).length) { return Object.entries(value.rateLimitsByLimitId); } - if (record(value.rateLimits)) { + if (isRecord(value.rateLimits)) { return [['codex', value.rateLimits]]; } throw unavailable(); } function quotaWindow(value: unknown, name: string, slot: string): CodexQuotaWindow | undefined { - if (!record(value)) { + if (!isRecord(value)) { return undefined; } if ( @@ -202,6 +203,3 @@ function windowLabel(minutes: number) { } return `${minutes} minutes`; } -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/plugins/multi-zen/src/auth.ts b/plugins/multi-zen/src/auth.ts index d5fc742..e387b55 100644 --- a/plugins/multi-zen/src/auth.ts +++ b/plugins/multi-zen/src/auth.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; export class ZenAuthError extends Error {} @@ -11,10 +12,6 @@ export function validateZenKey(value: string): string { return value; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function pathForPlatform(platform: NodeJS.Platform) { return platform === 'win32' ? path.win32 : path.posix; } diff --git a/plugins/multi-zen/src/chat.ts b/plugins/multi-zen/src/chat.ts index d084926..0d1bd1f 100644 --- a/plugins/multi-zen/src/chat.ts +++ b/plugins/multi-zen/src/chat.ts @@ -7,11 +7,18 @@ import type { ResponseContentBlock, StopReason, } from '../../multi-core/src/gateway/messages.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; +import { prefixSafeLength, readSse } from '../../multi-core/src/gateway/responses.ts'; import { safeguardResults } from '../../multi-core/src/gateway/safeguards.ts'; import { callId, toolName } from '../../multi-core/src/gateway/tools.ts'; -import { prefixSafeLength, readSse } from '../../multi-openai/src/responses.ts'; const SIGNATURE_PREFIX = 'multi-zen-chat:'; +export const ZEN_RESPONSES_SIGNATURE_PREFIX = 'multi-zen-responses:'; +/** Every opaque reasoning signature Zen emits, for stripping from Claude-bound history. */ +export const ZEN_SIGNATURE_PREFIXES: readonly string[] = [ + ZEN_RESPONSES_SIGNATURE_PREFIX, + SIGNATURE_PREFIX, +]; const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); export interface ChatRequest { @@ -87,10 +94,6 @@ interface ChatEvent { error?: unknown; } -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function string(value: unknown, name: string): string { if (typeof value !== 'string') { throw new Error(`Invalid ${name}`); @@ -106,7 +109,7 @@ function blocks(value: unknown): ContentBlock[] { throw new Error('Expected text or content blocks'); } for (const block of value) { - if (!record(block) || typeof block.type !== 'string') { + if (!isRecord(block) || typeof block.type !== 'string') { throw new Error('Invalid content block'); } } @@ -121,7 +124,7 @@ function text(value: unknown): string { } return block.text; }) - .join(''); + .join('\n'); } function image(block: ContentBlock): ChatContent { @@ -156,7 +159,7 @@ function image(block: ContentBlock): ChatContent { function userContent(value: unknown): string | ChatContent[] { const content = blocks(value); if (content.every((block) => block.type === 'text')) { - return content.map((block) => block.text as string).join(''); + return content.map((block) => block.text as string).join('\n'); } return content.map((block) => { if (block.type === 'text') { @@ -192,7 +195,7 @@ function ownReasoning(model: string, block: ContentBlock): string | undefined { const decoded: unknown = JSON.parse( Buffer.from(block.signature.slice(prefix.length), 'base64url').toString(), ); - if (record(decoded) && typeof decoded.reasoning === 'string') { + if (isRecord(decoded) && typeof decoded.reasoning === 'string') { return decoded.reasoning; } } catch { @@ -247,7 +250,7 @@ function assistantText( return { kind: 'text', value: string(block.text, 'assistant text') }; } if (block.type === 'tool_use') { - if (!block.id || !block.name || !record(block.input)) { + if (!block.id || !block.name || !isRecord(block.input)) { throw new Error('Invalid tool_use'); } return { @@ -299,7 +302,7 @@ function plainToolResult(content: ContentBlock[], isError: boolean | undefined): const value = content .filter((item) => item.type === 'text') .map((item) => item.text as string) - .join(''); + .join('\n'); const references = content .filter((item) => item.type === 'tool_reference') .map((item) => { @@ -403,7 +406,7 @@ function lowerUserBlocks(content: ContentBlock[], messages: ChatMessage[]) { previous?.role === 'user' && typeof previous.content === 'string' ) { - previous.content += value; + previous.content += `\n${value}`; } else { messages.push({ role: 'user', content: value }); } @@ -415,20 +418,20 @@ function chatTools(body: MessagesRequest): ChatTool[] { .filter( (tool) => !isDeferredTool(tool) || - (record(tool) && typeof tool.name === 'string' && isDirectToolAvailable(body, tool.name)), + (isRecord(tool) && typeof tool.name === 'string' && isDirectToolAvailable(body, tool.name)), ) .map(chatTool); } function isDeferredTool(tool: unknown): boolean { - return record(tool) && tool.defer_loading === true; + return isRecord(tool) && tool.defer_loading === true; } function chatTool(tool: unknown): ChatTool { - if (!record(tool) || (tool.type !== undefined && tool.type !== 'custom')) { + if (!isRecord(tool) || (tool.type !== undefined && tool.type !== 'custom')) { throw new Error('Invalid tool'); } - if (typeof tool.name !== 'string' || !tool.name.trim() || !record(tool.input_schema)) { + if (typeof tool.name !== 'string' || !tool.name.trim() || !isRecord(tool.input_schema)) { throw new Error('Invalid function tool'); } if (tool.description !== undefined && typeof tool.description !== 'string') { @@ -463,7 +466,7 @@ function requestOptions(body: MessagesRequest, result: ChatRequest): ChatRequest } const format = body.output_config?.format ?? body.output_format; if (format !== undefined) { - if (format.type !== 'json_schema' || !record(format.schema)) { + if (format.type !== 'json_schema' || !isRecord(format.schema)) { throw new Error('Unsupported output format'); } result.response_format = { @@ -475,7 +478,7 @@ function requestOptions(body: MessagesRequest, result: ChatRequest): ChatRequest } function validUsage(value: unknown): value is ChatUsage { - if (!record(value)) { + if (!isRecord(value)) { return false; } if (!validUsageNumbers(value) || !validUsageDetails(value)) { @@ -514,14 +517,14 @@ function validUsageDetails(value: Record): boolean { return true; } return ( - record(item) && + isRecord(item) && Object.values(item).every((nested) => Number.isSafeInteger(nested) && Number(nested) >= 0) ); }); } function usageNumber(value: unknown, ...keys: string[]): number | undefined { - if (!record(value)) { + if (!isRecord(value)) { return undefined; } for (const key of keys) { @@ -564,7 +567,7 @@ function delta(value: unknown): ChatDelta { if (value === undefined || value === null) { return {}; } - if (!record(value)) { + if (!isRecord(value)) { throw new Error('Malformed Zen Chat choice'); } for (const key of ['content', 'reasoning_content']) { @@ -624,8 +627,8 @@ class ChatAccumulator { private stop?: StopReason; private started = false; private ended = false; - private textIndex?: number; - private reasoningIndex?: number; + /** The one content block currently open; blocks are strictly sequential. */ + private open?: { kind: 'text' | 'thinking'; index: number }; constructor(model: string, emit: Emit, options: ChatResponseOptions) { this.model = model; @@ -634,7 +637,7 @@ class ChatAccumulator { } accept(value: unknown) { - if (!record(value)) { + if (!isRecord(value)) { throw new Error('Malformed Zen Chat event'); } const event = value as ChatEvent; @@ -653,7 +656,9 @@ class ChatAccumulator { return; } throw new Error( - record(error) && typeof error.message === 'string' ? error.message : 'Zen Chat stream failed', + isRecord(error) && typeof error.message === 'string' + ? error.message + : 'Zen Chat stream failed', ); } @@ -676,7 +681,7 @@ class ChatAccumulator { return; } const choice = value[0]; - if (!record(choice)) { + if (!isRecord(choice)) { throw new Error('Malformed Zen Chat choice'); } if (choice.index !== undefined && choice.index !== 0) { @@ -725,36 +730,60 @@ class ChatAccumulator { } private beginText(): number { - if (this.textIndex !== undefined) { - return this.textIndex; - } - this.textIndex = this.content.length; + if (this.open?.kind === 'text') { + return this.open.index; + } + this.closeOpen(); + this.textValue = ''; + this.textEmitted = 0; + const index = this.content.length; + this.open = { kind: 'text', index }; this.content.push({ type: 'text', text: '' }); - this.emit('content_block_start', { - index: this.textIndex, - content_block: { type: 'text', text: '' }, - }); - return this.textIndex; + this.emit('content_block_start', { index, content_block: { type: 'text', text: '' } }); + return index; } private beginReasoning(): number { - if (this.reasoningIndex !== undefined) { - return this.reasoningIndex; + if (this.open?.kind === 'thinking') { + return this.open.index; } - this.reasoningIndex = this.content.length; + this.closeOpen(); + this.reasoning = ''; + const index = this.content.length; + this.open = { kind: 'thinking', index }; this.content.push({ type: 'thinking', thinking: '', signature: '' }); this.emit('content_block_start', { - index: this.reasoningIndex, + index, content_block: { type: 'thinking', thinking: '', signature: '' }, }); - return this.reasoningIndex; + return index; + } + + /** Finish the open block before another begins, as the Messages stream requires. */ + private closeOpen() { + const open = this.open; + if (!open) { + return; + } + const block = this.content[open.index]; + if (block?.type === 'thinking') { + block.signature = signature(this.model, this.reasoning); + this.emit('content_block_delta', { + index: open.index, + delta: { type: 'signature_delta', signature: block.signature }, + }); + } else { + this.flushText(); + } + this.emit('content_block_stop', { index: open.index }); + this.open = undefined; } private addReasoning(value: unknown) { if (typeof value !== 'string') { return; } - if (this.stoppedSequence) { + if (this.stoppedSequence || (!value && this.open?.kind !== 'thinking')) { return; } const index = this.beginReasoning(); @@ -776,7 +805,7 @@ class ChatAccumulator { if (typeof value !== 'string') { return; } - if (!value && this.textIndex === undefined) { + if (!value && this.open?.kind !== 'text') { return; } if (this.stoppedSequence) { @@ -831,7 +860,7 @@ class ChatAccumulator { } private toolSlot(raw: unknown): ToolSlot { - if (!record(raw) || !Number.isSafeInteger(raw.index) || Number(raw.index) < 0) { + if (!isRecord(raw) || !Number.isSafeInteger(raw.index) || Number(raw.index) < 0) { throw new Error('Malformed Zen tool call delta'); } const index = Number(raw.index); @@ -859,7 +888,7 @@ class ChatAccumulator { if (fn === undefined) { return; } - if (!record(fn)) { + if (!isRecord(fn)) { throw new Error('Malformed Zen function delta'); } if (fn.name !== undefined && fn.name !== null) { @@ -942,7 +971,7 @@ class ChatAccumulator { } catch { throw new Error('Zen Chat returned invalid tool arguments'); } - if (!record(input)) { + if (!isRecord(input)) { throw new Error('Zen Chat tool arguments must be an object'); } const block: Extract = { @@ -970,23 +999,15 @@ class ChatAccumulator { } private finishStreamedBlocks() { - for (const [index, block] of this.content.entries()) { - if (block.type === 'thinking') { - block.signature = signature(this.model, this.reasoning); - this.emit('content_block_delta', { - index, - delta: { type: 'signature_delta', signature: block.signature }, - }); - } - this.emit('content_block_stop', { index }); - } + this.closeOpen(); } private flushText() { - if (this.textIndex === undefined || this.stoppedSequence) { + if (this.open?.kind !== 'text' || this.stoppedSequence) { return; } - const block = this.content[this.textIndex]; + const textIndex = this.open.index; + const block = this.content[textIndex]; if (block?.type !== 'text') { throw new Error('Invalid text state'); } @@ -995,7 +1016,7 @@ class ChatAccumulator { this.textEmitted = this.textValue.length; if (remainder) { this.emit('content_block_delta', { - index: this.textIndex, + index: textIndex, delta: { type: 'text_delta', text: remainder }, }); } diff --git a/plugins/multi-zen/src/gateway-request.ts b/plugins/multi-zen/src/gateway-request.ts new file mode 100644 index 0000000..115ea6c --- /dev/null +++ b/plugins/multi-zen/src/gateway-request.ts @@ -0,0 +1,75 @@ +import { createHash } from 'node:crypto'; +import type { GatewayFetch } from '../../multi-core/src/gateway/fetch.ts'; +import type { + PreparedRequest, + ProviderRequestInput, +} from '../../multi-core/src/gateway/provider-request.ts'; +import { fromResponses } from '../../multi-core/src/gateway/responses.ts'; +import { originalToolNames } from '../../multi-core/src/gateway/tools.ts'; +import { fromChat, ZEN_SIGNATURE_PREFIXES } from './chat.ts'; +import { zenRequest } from './request.ts'; + +const ZEN_URL = 'https://opencode.ai/zen/v1'; +const MESSAGES_PATHS = ['/v1/messages', '/v1/messages/count_tokens']; + +export const zenUnavailable = + 'Zen is not configured. Set OPENCODE_API_KEY or connect OpenCode Zen.'; + +/** The prefixes of the reasoning signatures this provider writes into Claude's history. */ +export const zenSignaturePrefixes: readonly string[] = ZEN_SIGNATURE_PREFIXES; + +const zenAuthHelp = ' Check the Zen API key.'; + +/** The Zen request for one Claude request to a `multi/zen/` model. */ +export function prepareZenRequest( + { method, pathname, body, session, fallbackSession, agentId }: ProviderRequestInput, + apiKey: string, +): PreparedRequest { + if (method !== 'POST' || !MESSAGES_PATHS.includes(pathname)) { + throw new Error('Zen requires POST /v1/messages or /v1/messages/count_tokens'); + } + // Zen uses this for sticky upstream routing. Claude identity survives restarts; + // no per-request nonce is inserted into the prompt or cache key. + const cacheKey = createHash('sha256') + .update( + JSON.stringify([session || fallbackSession, agentId ?? 'main', body.model, process.cwd()]), + ) + .digest('hex'); + const prepared = zenRequest(body, cacheKey); + return { + label: 'Zen', + authHelp: zenAuthHelp, + endpoint: prepared.endpoint, + route: 'zen', + inputTokens: () => prepared.inputTokens, + requestEvent: (agent) => ({ route: 'zen-request', agentId: agent, model: body.model }), + failureEvent: (status, agent) => ({ route: 'zen', agentId: agent, status }), + send: (signal: AbortSignal, fetchImpl: GatewayFetch) => + fetchImpl(`${ZEN_URL}/${prepared.endpoint}`, { + method: 'POST', + headers: { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + accept: 'text/event-stream', + 'x-opencode-session': cacheKey, + 'x-opencode-client': 'cc-multi-cli-plugin', + }, + body: JSON.stringify(prepared.body), + signal, + redirect: 'error', + }), + translate: (stream, emit) => { + const options = { + toolNames: originalToolNames(body), + stopSequences: body.stop_sequences, + signaturePrefix: prepared.signaturePrefix, + requireUsage: true, + inputTokens: prepared.inputTokens, + safeguards: body.safeguards, + safeguardProvider: 'zen' as const, + }; + const translate = prepared.endpoint === 'responses' ? fromResponses : fromChat; + return translate(stream, String(body.model), emit, options); + }, + }; +} diff --git a/plugins/multi-zen/src/models.ts b/plugins/multi-zen/src/models.ts index 9cbed33..7574529 100644 --- a/plugins/multi-zen/src/models.ts +++ b/plugins/multi-zen/src/models.ts @@ -1,4 +1,4 @@ -import type { Effort } from '../../multi-openai/src/responses.ts'; +import type { Effort } from '../../multi-core/src/gateway/responses.ts'; type ZenProtocol = 'responses' | 'chat'; diff --git a/plugins/multi-zen/src/request.ts b/plugins/multi-zen/src/request.ts index 0f78423..4dd6379 100644 --- a/plugins/multi-zen/src/request.ts +++ b/plugins/multi-zen/src/request.ts @@ -1,8 +1,11 @@ import type { MessagesRequest } from '../../multi-core/src/gateway/messages.ts'; +import type { + ResponsesInputContent, + ResponsesRequest, +} from '../../multi-core/src/gateway/responses.ts'; +import { toResponses } from '../../multi-core/src/gateway/responses.ts'; import { estimateInputTokens, estimateTextTokens } from '../../multi-core/src/gateway/tokens.ts'; -import type { ResponsesInputContent, ResponsesRequest } from '../../multi-openai/src/responses.ts'; -import { toResponses } from '../../multi-openai/src/responses.ts'; -import { toChat } from './chat.ts'; +import { toChat, ZEN_RESPONSES_SIGNATURE_PREFIX } from './chat.ts'; import type { ZenModel } from './models.ts'; import { zenModel } from './models.ts'; @@ -42,7 +45,7 @@ export function zenRequest(body: MessagesRequest, cacheKey: string) { ) { throw new Error(`Zen max_tokens must be between 1 and ${model.maxOutputTokens}`); } - const signaturePrefix = `multi-zen-responses:${model.id}:`; + const signaturePrefix = `${ZEN_RESPONSES_SIGNATURE_PREFIX}${model.id}:`; const normalized = toResponses(body, model.id, signaturePrefix); validateMedia(normalized, model); const effort = body.output_config?.effort; diff --git a/plugins/multi-zen/src/usage.ts b/plugins/multi-zen/src/usage.ts index aefe406..4fd1a7e 100644 --- a/plugins/multi-zen/src/usage.ts +++ b/plugins/multi-zen/src/usage.ts @@ -1,3 +1,4 @@ +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { readZenKey, validateZenKey, type ZenAuthPathOptions } from './auth.ts'; interface ZenQuotaWindow { @@ -133,10 +134,6 @@ function parseWindow(value: unknown): ZenQuotaWindow | undefined { return { status: value.status, percent: value.percent, resetsAt: value.resetsAt }; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function isEntitlementError(value: unknown) { return ( isRecord(value) && diff --git a/test/e2e/fixtures/usage-receipts/wire.mjs b/test/e2e/fixtures/usage-receipts/wire.mjs index eee8d2a..5ec2b99 100644 --- a/test/e2e/fixtures/usage-receipts/wire.mjs +++ b/test/e2e/fixtures/usage-receipts/wire.mjs @@ -1,20 +1,28 @@ +import { randomUUID } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import { setTimeout } from 'node:timers/promises'; const [sessionId, kind, countText = '1'] = process.argv.slice(2); const base = process.env.MULTI_MOD_GATEWAY_URL; const token = process.env.MULTI_GATEWAY_TOKEN; -async function request(route, body, agentId, allowError = false) { +// The gateway issues a session's mod key on its first mod request; later changes present it. +const modKeys = new Map(); +async function request(route, body, agentId, allowError = false, session = sessionId) { const response = await fetch(new URL(route, base), { method: body ? 'POST' : 'GET', headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token, - 'x-claude-code-session-id': sessionId, + 'x-claude-code-session-id': session, ...(agentId ? { 'x-claude-code-agent-id': agentId } : {}), + ...(modKeys.has(session) ? { 'x-multi-mod-key': modKeys.get(session) } : {}), }, body: body ? JSON.stringify(body) : undefined, }); + const issued = response.headers.get('x-multi-mod-key'); + if (issued) { + modKeys.set(session, issued); + } const text = await response.text(); if (!response.ok && !allowError) { throw new Error(`${route}: ${response.status} ${text}`); @@ -29,29 +37,53 @@ if (kind === 'quota') { ); } else if (kind === 'grok') { // Admit settings without executing a setup worker, so model validation is the - // refusal being exercised rather than missing native permission context. - const mode = await request(`/multi/mod/mode${query}`); - let policy = await request('/multi/mod/policy', { - sessionId, - cwd: process.cwd(), - sourceGeneration: mode.generation, - }); + // refusal being exercised rather than missing native permission context. The mod + // holds this Claude session's key, so the wire drives a session of its own. + const session = randomUUID(); + const own = `?sessionId=${encodeURIComponent(session)}`; + await request( + '/multi/mod/session', + { sessionId: session, cwd: process.cwd(), model: 'multi/grok/e2e[1m]', event: 'start' }, + undefined, + false, + session, + ); + const mode = await request(`/multi/mod/mode${own}`, undefined, undefined, false, session); + let policy = await request( + '/multi/mod/policy', + { sessionId: session, cwd: process.cwd(), sourceGeneration: mode.generation }, + undefined, + false, + session, + ); const deadline = Date.now() + 10000; while (policy.status === 'pending' && Date.now() < deadline) { await setTimeout(25); - policy = await request('/multi/mod/policy', { sessionId, generation: policy.generation }); + policy = await request( + '/multi/mod/policy', + { sessionId: session, generation: policy.generation }, + undefined, + false, + session, + ); } if (policy.status !== 'ready') { throw new Error('Native policy did not become ready'); } - await request('/multi/mod/session', { - sessionId, - cwd: process.cwd(), - permissionMode: 'bypassPermissions', - model: 'multi/grok/e2e[1m]', - generation: mode.generation, - policyGeneration: policy.generation, - }); + await request( + '/multi/mod/session', + { + sessionId: session, + cwd: process.cwd(), + permissionMode: 'bypassPermissions', + model: 'multi/grok/e2e[1m]', + generation: mode.generation, + policyGeneration: policy.generation, + }, + undefined, + false, + session, + ); const body = { model: 'multi/grok/e2e[1m]', max_tokens: 64, @@ -59,7 +91,7 @@ if (kind === 'quota') { }; const replies = []; for (let attempt = 0; attempt < 2; attempt++) { - const reply = await request('/v1/messages', body, undefined, true); + const reply = await request('/v1/messages', body, undefined, true, session); replies.push(reply); console.log(JSON.stringify(reply)); } diff --git a/test/e2e/harness.ts b/test/e2e/harness.ts index ad193cf..81f2cb3 100644 --- a/test/e2e/harness.ts +++ b/test/e2e/harness.ts @@ -21,6 +21,8 @@ async function rejectingProxy(t: TestContext) { }); server.on('connect', (req, socket) => { connections.push(req.url ?? ''); + // A client that resets a refused tunnel must not fail the test process. + socket.on('error', () => {}); socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); diff --git a/test/e2e/model-routing-native.test.ts b/test/e2e/model-routing-native.test.ts index b56a729..4625cea 100644 --- a/test/e2e/model-routing-native.test.ts +++ b/test/e2e/model-routing-native.test.ts @@ -23,7 +23,7 @@ const grok: NativeScript = (request) => ({ { type: 'end', sessionId: request.args[request.args.indexOf('--session-id') + 1], - stopReason: 'stop', + stopReason: 'end_turn', }, ] .map((event) => JSON.stringify(event)) diff --git a/test/live/native-cursor-harness.ts b/test/live/native-cursor-harness.ts index 536dad5..0723f90 100644 --- a/test/live/native-cursor-harness.ts +++ b/test/live/native-cursor-harness.ts @@ -143,12 +143,7 @@ try { recovering = false; const followup = 'Without using tools, repeat the full line you just produced. One line only.'; const hooks = new PermissionModes(async () => ({})); - await hooks.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'live', - permission_mode: 'auto', - prompt: followup, - }); + hooks.recordModSession('live', { permissionMode: 'auto' }); const history = compact ? [] : [...(request.messages ?? []), { role: 'assistant', content: first.content }]; diff --git a/test/live/native-openai-cache.ts b/test/live/native-openai-cache.ts index 63ef6a6..030dd5e 100644 --- a/test/live/native-openai-cache.ts +++ b/test/live/native-openai-cache.ts @@ -5,11 +5,10 @@ import { createHash, randomUUID } from 'node:crypto'; import { mkdtemp, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { AgentCatalog } from '../../plugins/multi-core/src/gateway/agent-catalog.ts'; import type { MessagesResponse } from '../../plugins/multi-core/src/gateway/messages.ts'; +import type { ResponsesRequest } from '../../plugins/multi-core/src/gateway/responses.ts'; +import { readSse } from '../../plugins/multi-core/src/gateway/responses.ts'; import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; -import type { ResponsesRequest } from '../../plugins/multi-openai/src/responses.ts'; -import { readSse } from '../../plugins/multi-openai/src/responses.ts'; import { isolatedEnvironment } from './environment.ts'; interface Sample { @@ -60,7 +59,6 @@ const workers = { tools: ['Read'], }, }; -const catalog = new AgentCatalog(workers, ['multi/openai/gpt-6-astra']); const session = randomUUID(); const token = randomUUID(); const samples: Sample[] = []; @@ -237,7 +235,6 @@ for (let turn = 0; turn < 3; turn++) { token, authFile, blockAnthropic: true, - agentCatalog: catalog, fetchImpl: async (url, init) => { assert.equal(url, 'https://chatgpt.com/backend-api/codex/responses'); assert(samples.length < 6, 'Six-request inference budget exhausted'); @@ -252,8 +249,6 @@ for (let turn = 0; turn < 3; turn++) { JSON.stringify(body.input, null, 2), ); assert(input.includes('CACHE_VISIBLE_WORKER'), 'Native CLI catalog was not observed'); - assert(!input.includes('CACHE_HIDDEN_EFFORT'), 'Reasoning variant leaked into context'); - assert(!input.includes('CACHE_HIDDEN_MODEL'), 'Non-picker model leaked into context'); const sample: Sample = { turn, model: body.model, diff --git a/test/live/native-provider-approval.ts b/test/live/native-provider-approval.ts index 3b62930..7f644d0 100644 --- a/test/live/native-provider-approval.ts +++ b/test/live/native-provider-approval.ts @@ -12,10 +12,10 @@ import { stripVTControlCharacters } from 'node:util'; import { NativeApprovalBridge } from '../../plugins/multi-core/src/gateway/approval.ts'; import { hookCommand } from '../../plugins/multi-core/src/gateway/permission-hook.ts'; import { terminateProcessTree } from '../../plugins/multi-core/src/gateway/process-tree.ts'; +import { readSse } from '../../plugins/multi-core/src/gateway/responses.ts'; import type { GatewayEvent } from '../../plugins/multi-core/src/gateway/server.ts'; import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; import { readCodexAuth } from '../../plugins/multi-openai/src/auth.ts'; -import { readSse } from '../../plugins/multi-openai/src/responses.ts'; import { isolatedEnvironment } from './environment.ts'; import type { HookInput } from './native-events.ts'; import { pty } from './native-pty.ts'; diff --git a/test/live/native-zen.ts b/test/live/native-zen.ts index c76b2e7..f392347 100644 --- a/test/live/native-zen.ts +++ b/test/live/native-zen.ts @@ -8,11 +8,11 @@ import os from 'node:os'; import path from 'node:path'; import type { GatewayFetch } from '../../plugins/multi-core/src/gateway/fetch.ts'; import type { MessagesResponse } from '../../plugins/multi-core/src/gateway/messages.ts'; +import { readSse } from '../../plugins/multi-core/src/gateway/responses.ts'; import { createNativeGateway, type GatewayOptions, } from '../../plugins/multi-core/src/gateway/server.ts'; -import { readSse } from '../../plugins/multi-openai/src/responses.ts'; import { readZenKey } from '../../plugins/multi-zen/src/auth.ts'; import { zenModel, zenPickerOptions } from '../../plugins/multi-zen/src/models.ts'; import { isolatedEnvironment } from './environment.ts'; diff --git a/test/unit/gateway-provider-bounded.test.ts b/test/unit/gateway-provider-bounded.test.ts new file mode 100644 index 0000000..aeb9c26 --- /dev/null +++ b/test/unit/gateway-provider-bounded.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { addBounded, setBounded } from '../../plugins/multi-core/src/gateway/bounded.ts'; +import { + harnessProvider, + providerOwnedReview, + providerRoute, +} from '../../plugins/multi-core/src/gateway/provider.ts'; + +test('provider prefixes decide routing, review ownership and harness execution in one place', () => { + assert.equal(providerRoute(null), 'anthropic'); + assert.equal(providerRoute('multi/cursor/auto'), 'cursor'); + assert.equal(providerRoute('multi/zen/glm'), 'zen'); + assert.equal(providerRoute('multi/openai/gpt-6-luna'), 'openai'); + assert.equal(providerRoute('multi/unknown/x'), 'openai'); + assert.equal(harnessProvider('multi/grok/x'), 'grok'); + assert.equal(harnessProvider('multi/openai/x'), undefined); + assert.equal(harnessProvider(undefined), undefined); + assert.equal(providerOwnedReview('multi/antigravity/x'), true); + assert.equal(providerOwnedReview('multi/openai/x'), true); + assert.equal(providerOwnedReview('multi/unknown/x'), true); + assert.equal(providerOwnedReview('multi/zen/x'), false); + assert.equal(providerOwnedReview('claude-sonnet'), false); +}); + +test('bounded maps choose between recency and insertion eviction', () => { + const lru = new Map(); + for (const key of ['a', 'b', 'c']) { + setBounded(lru, key, 1, 3, 'lru'); + } + setBounded(lru, 'a', 2, 3, 'lru'); + setBounded(lru, 'd', 1, 3, 'lru'); + assert.deepEqual([...lru.keys()], ['c', 'a', 'd']); + + const insertion = new Map(); + for (const key of ['a', 'b', 'c']) { + setBounded(insertion, key, 1, 3, 'insertion'); + } + setBounded(insertion, 'a', 2, 3, 'insertion'); + assert.deepEqual([...insertion.keys()], ['a', 'b', 'c']); + assert.equal(insertion.get('a'), 2); + setBounded(insertion, 'd', 1, 3, 'insertion'); + assert.deepEqual([...insertion.keys()], ['b', 'c', 'd']); + + const set = new Set(); + for (const key of ['a', 'b', 'c', 'd']) { + addBounded(set, key, 3); + } + assert.deepEqual([...set], ['b', 'c', 'd']); +}); diff --git a/test/unit/mod-policy.test.ts b/test/unit/mod-policy.test.ts index ba26eed..6d1f63e 100644 --- a/test/unit/mod-policy.test.ts +++ b/test/unit/mod-policy.test.ts @@ -216,7 +216,7 @@ test('ambiguous simultaneous worker starts fail closed and catalog filtering pre assert.equal(modes.offered('/workspace', 'unknown'), false); }); -test('worker contexts retain the authenticated compaction deny marker and parent restrictions', async () => { +test('a main-loop compaction boundary is its own scope: workers keep tools and parent restrictions', async () => { const modes = new PermissionModes(async () => policy.workers); await modes.precompute('/workspace'); modes.recordModSession('s', { @@ -230,13 +230,52 @@ test('worker contexts retain the authenticated compaction deny marker and parent permissionMode: 'auto', }); modes.startPreparedModWorker('s', 'worker', 'cursor', '/workspace'); + const before = modes.resolve('s', 'worker').tools; modes.authorizeModCompaction('s'); - assert.equal(modes.resolve('s', 'worker').compaction, modes.resolve('s').compaction); + const armed = modes.resolve('s'); + assert.deepEqual(armed.tools, []); + assert.ok(armed.compaction); + // The boundary is for the main loop alone: a worker resolved now is an ordinary worker. + assert.equal(modes.resolve('s', 'worker').compaction, undefined); + assert.deepEqual(modes.resolve('s', 'worker').tools, before); assert.deepEqual(modes.resolve('s', 'worker').disallowedTools, ['Bash']); + // A failed or cancelled compaction clears it; it does not stay armed. + modes.cancelModCompaction('s'); + assert.equal(modes.resolve('s').compaction, undefined); + assert.equal(modes.resolve('s').tools, undefined); + modes.authorizeModCompaction('s'); + modes.finishModCompaction('s', undefined, modes.resolve('s').compaction); + assert.equal(modes.resolve('s').compaction, undefined); modes.forgetSession('s'); assert.throws(() => modes.resolve('s', 'worker'), /unavailable/); }); +test('a worker compaction boundary is restored when it is cancelled', async () => { + const modes = new PermissionModes(async () => policy.workers); + await modes.precompute('/workspace'); + modes.recordModSession('s', { permissionMode: 'plan', cwd: '/workspace' }); + await modes.prepareModWorker('s', { + subagentType: 'cursor', + cwd: '/workspace', + permissionMode: 'plan', + }); + modes.startPreparedModWorker('s', 'worker', 'cursor', '/workspace'); + modes.authorizeModCompaction('s', 'worker'); + assert.deepEqual(modes.resolve('s', 'worker').tools, []); + modes.cancelModCompaction('s', 'worker'); + assert.deepEqual(modes.resolve('s', 'worker').tools, ['Read']); + assert.equal(modes.resolve('s', 'worker').compaction, undefined); +}); + +test('the permission context table evicts its oldest entry instead of refusing new sessions', () => { + const modes = new PermissionModes(async () => ({})); + for (let index = 0; index < 4200; index++) { + modes.recordModSession(`session-${index}`, { permissionMode: 'default' }); + } + assert.equal(modes.resolve('session-4199').permissionMode, 'default'); + assert.throws(() => modes.resolve('session-0'), /unavailable/); +}); + test('a policy discovery that outlasts the hook is reused on retry until admitted', async () => { let finish: ((value: PreparedPolicy) => void) | undefined; let discoveries = 0; @@ -429,3 +468,55 @@ test('a planning parent binds every worker, whatever mode it was spawned or defi assert.equal(modes.resolve('s', 'explicit').permissionMode, 'bypassPermissions'); assert.throws(() => modes.planning('s', 'unregistered'), /unavailable/); }); + +test('a hook long-polls one request that returns when discovery ends', async () => { + let finish: ((value: PreparedPolicy) => void) | undefined; + const store = new ModPolicies( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const job = store.begin('s', '/workspace'); + const waiting = store.wait('s', job.generation, 5000); + finish?.(policy); + assert.equal((await waiting).status, 'ready'); + // A wait that outlasts its bound answers pending instead of blocking the hook. + const slow = store.begin('t', '/workspace'); + assert.equal((await store.wait('t', slow.generation, 5)).status, 'pending'); + await assert.rejects(store.wait('t', 'other', 5), /stale/); +}); + +test('an unconsumed policy job expires instead of being reused indefinitely', async () => { + let clock = 1000; + let discoveries = 0; + const store = new ModPolicies( + async () => { + discoveries++; + return policy; + }, + () => clock, + ); + const first = store.begin('s', '/workspace'); + await setImmediate(); + clock += 59_000; + assert.equal(store.begin('s', '/workspace').generation, first.generation); + clock += 2_000; + assert.notEqual(store.begin('s', '/workspace').generation, first.generation); + assert.equal(discoveries, 2); +}); + +test('policy sessions beyond capacity evict the least recent idle one', async () => { + const store = new ModPolicies(async () => policy); + const first = store.begin('session-0', '/workspace'); + for (let index = 1; index < 128; index++) { + store.begin(`session-${index}`, '/workspace'); + if (index % 32 === 0) { + await setImmediate(); + } + } + await setImmediate(); + const added = store.begin('session-new', '/workspace'); + assert.equal(added.status, 'pending'); + assert.throws(() => store.status('session-0', first.generation), /stale/); +}); diff --git a/test/unit/native-agent-catalog.test.ts b/test/unit/native-agent-catalog.test.ts deleted file mode 100644 index e4b2b69..0000000 --- a/test/unit/native-agent-catalog.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { AgentCatalog } from '../../plugins/multi-core/src/gateway/agent-catalog.ts'; -import type { MessagesRequest } from '../../plugins/multi-core/src/gateway/messages.ts'; - -const workers = { - 'openai-native': { - model: 'multi/openai/gpt-6-astra', - description: 'Astra', - tools: ['Read', 'Bash'], - }, - 'openai-native-high': { - model: 'multi/openai/gpt-6-astra', - description: 'Astra high', - tools: ['Read', 'Bash'], - }, - 'zen-other': { model: 'multi/zen/other', description: 'Other', tools: ['Read'] }, -}; -const rows = Object.entries(workers).map( - ([name, worker]) => `- ${name}: ${worker.description} (Tools: ${worker.tools.join(', ')})`, -); -const custom = '- custom: User-owned worker (Tools: Read)'; -const listing = (heading = 'Available agent types for the Agent tool:') => - `\nBefore\n${heading}\n${[...rows, custom].join('\n')}\n\nAfter\n`; -const request = (content: string): MessagesRequest => ({ messages: [{ role: 'user', content }] }); - -test('catalog advertises one worker per picker model without mutating registration or history', () => { - const original = structuredClone(workers); - const catalog = new AgentCatalog(workers, ['multi/openai/gpt-6-astra']); - for (const heading of [ - 'Available agent types for the Agent tool:', - 'New agent types are now available for the Agent tool:', - ]) { - const body = request(listing(heading)); - const before = structuredClone(body); - const compacted = catalog.compact(body); - assert.deepEqual(body, before); - const expected = listing(heading).replace(`${rows[1]}\n`, '').replace(`${rows[2]}\n`, ''); - assert.deepEqual(compacted, request(expected)); - assert.strictEqual(catalog.compact(compacted), compacted); - } - assert.deepEqual(workers, original); -}); - -test('catalog preserves ordinary text, custom overrides, tool results and unknown formats', () => { - const catalog = new AgentCatalog(workers, []); - for (const content of [ - rows.join('\n'), - listing().replaceAll('Agent tool:', 'Future tool:'), - listing().replaceAll('Astra', 'Custom Astra').replace(rows[2], custom), - ]) { - const body = request(content); - assert.strictEqual(catalog.compact(body), body); - } - const body: MessagesRequest = { - system: listing(), - messages: [ - { role: 'assistant', content: listing() }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: listing() }] }, - ], - }; - assert.strictEqual(catalog.compact(body), body); -}); - -test('catalog handles bare native system announcements without changing ordinary user text', () => { - const catalog = new AgentCatalog(workers, ['multi/openai/gpt-6-astra']); - const text = `Available agent types for the Agent tool:\n${[...rows, custom].join('\n')}\n\nKeep concurrency instructions.`; - const body: MessagesRequest = { - messages: [ - { role: 'system', content: text }, - { role: 'user', content: text }, - ], - }; - assert.deepEqual(catalog.compact(body), { - messages: [ - { role: 'system', content: text.replace(`${rows[1]}\n`, '').replace(`${rows[2]}\n`, '') }, - { role: 'user', content: text }, - ], - }); -}); - -test('catalog compacts text blocks only and follows a changed picker selection', () => { - const catalog = new AgentCatalog(workers, ['multi/zen/other']); - const body: MessagesRequest = { - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: listing(), cache_control: { type: 'ephemeral' } }, - { type: 'text', text: 'Keep user instructions' }, - ], - }, - ], - }; - assert.deepEqual(catalog.compact(body), { - messages: [ - { - role: 'user', - content: [ - { - type: 'text', - text: listing().replace(`${rows[0]}\n`, '').replace(`${rows[1]}\n`, ''), - cache_control: { type: 'ephemeral' }, - }, - { type: 'text', text: 'Keep user instructions' }, - ], - }, - ], - }); -}); diff --git a/test/unit/native-anthropic-passthrough.test.ts b/test/unit/native-anthropic-passthrough.test.ts index 6037738..b5bd324 100644 --- a/test/unit/native-anthropic-passthrough.test.ts +++ b/test/unit/native-anthropic-passthrough.test.ts @@ -140,3 +140,31 @@ test('/v1 routes the gateway does not own are forwarded as raw bytes without its const gated = await fetch(`http://127.0.0.1:${port}/multi/mod/session`, { method: 'POST' }); assert.equal(gated.status, 401); }); + +test('a request whose Host is not the gateway loopback name is refused before any relay', async (t) => { + let relayed = 0; + const port = await listen(t, async () => { + relayed += 1; + return new Response('ok'); + }); + const send = (host: string) => + new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: '/v1/files', method: 'GET', headers: { host } }, + (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }, + ); + req.on('error', reject); + req.end(); + }); + assert.equal(await send('evil.example'), 403); + assert.equal(await send(`evil.example:${port}`), 403); + assert.equal(await send('127.0.0.1'), 403); + assert.equal(relayed, 0); + assert.equal(await send(`localhost:${port}`), 200); + assert.equal(await send(`[::1]:${port}`), 200); + assert.equal(await send(`127.0.0.1:${port}`), 200); + assert.equal(relayed, 3); +}); diff --git a/test/unit/native-antigravity-cli.test.ts b/test/unit/native-antigravity-cli.test.ts index 385bbdd..3939b45 100644 --- a/test/unit/native-antigravity-cli.test.ts +++ b/test/unit/native-antigravity-cli.test.ts @@ -300,6 +300,28 @@ test('a mid-sized prompt takes the stdin path on Windows but stays an argument o assert.deepEqual(short.spawnArgs.slice(0, 2), ['-p', 'hello']); }); +test('a prompt with cmd metacharacters rides stdin through a non-shim .cmd launcher on Windows', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'agy-cmd-')); + t.after(() => removeTemporary(directory)); + const launcher = path.join(directory, 'agy.cmd'); + await writeFile(launcher, '@echo off\r\nrem not an npm shim\r\n'); + for (const prompt of ['say "hi"', '100% done', 'a & b']) { + const child = fakeWindowsChild(); + const run = await runAntigravity({ + cwd: process.cwd(), + executable: launcher, + prompt, + platform: 'win32', + signal: AbortSignal.timeout(5000), + spawn: child.spawn, + }); + assert.equal(run.result.status, 'SUCCESS'); + assert.equal(child.spawnArgs.join(' ').includes(prompt), false); + assert.equal(child.spawnArgs.join(' ').includes('--input-format stream-json'), true); + assert.equal(child.stdin().includes(JSON.stringify(prompt).slice(1, -1)), true); + } +}); + test('escalates cancellation and reports missing terminal evidence as aborted', async (t) => { const cli = await fakeCli(t); const controller = new AbortController(); diff --git a/test/unit/native-antigravity-gateway.test.ts b/test/unit/native-antigravity-gateway.test.ts index 9d3b475..9461f34 100644 --- a/test/unit/native-antigravity-gateway.test.ts +++ b/test/unit/native-antigravity-gateway.test.ts @@ -19,12 +19,7 @@ const reply: MessagesResponse = { test('Antigravity routing resolves authenticated mode and never executes observed tools', async (t) => { const modes = new PermissionModes(async () => ({})); - await modes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'test-session', - permission_mode: 'auto', - prompt: 'edit the fixture', - }); + modes.recordModSession('test-session', { permissionMode: 'auto' }); let calls = 0; const server = createNativeGateway({ token: 'test-token', @@ -71,12 +66,7 @@ test('Antigravity routing resolves authenticated mode and never executes observe test('a busy Antigravity agent is answered 400, not a retryable 502', async (t) => { const modes = new PermissionModes(async () => ({})); - await modes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'busy-session', - permission_mode: 'auto', - prompt: 'edit the fixture', - }); + modes.recordModSession('busy-session', { permissionMode: 'auto' }); const server = createNativeGateway({ token: 'test-token', authFile: '/unused', diff --git a/test/unit/native-antigravity-harness.test.ts b/test/unit/native-antigravity-harness.test.ts index af07f7f..8c6d1bd 100644 --- a/test/unit/native-antigravity-harness.test.ts +++ b/test/unit/native-antigravity-harness.test.ts @@ -4,9 +4,10 @@ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promi import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import type { - AntigravityRunOptions, - AntigravityRunResult, +import { + AntigravityCliError, + type AntigravityRunOptions, + type AntigravityRunResult, } from '../../plugins/multi-antigravity/src/cli.ts'; import { AntigravityHarness, @@ -926,3 +927,93 @@ test('a first-load race is refused with 400, not a retryable gateway failure', a assert.match(error.message, /already (running|loading)/); assert.equal(calls.length, 1, 'the refused request must not start a native run'); }); + +test('a failed run that already started steps keeps the interruption notice for the retry', async (t) => { + const stateDirectory = await mkdtemp(path.join(os.tmpdir(), 'agy-failed-steps-')); + t.after(() => removeTemporary(stateDirectory)); + const calls: AntigravityRunOptions[] = []; + const run = async (options: AntigravityRunOptions) => { + calls.push(options); + if (calls.length === 1) { + options.onEvent?.({ event: 'init', conversation_id: 'partial-conversation', init: {} }); + return { + result: { + conversation_id: 'partial-conversation', + status: 'ERROR' as const, + response: '', + error: 'model failed after a step', + }, + exitCode: 1, + signal: null, + stderr: '', + }; + } + return { + result: { + conversation_id: 'partial-conversation', + status: 'SUCCESS' as const, + response: 'ok', + }, + exitCode: 0, + signal: null, + stderr: '', + }; + }; + const harness = new AntigravityHarness([model], { + stateDirectory, + checkPermissions: policy, + run, + }); + t.after(() => harness.close()); + const request = { model: model.model, messages: [{ role: 'user', content: 'effects' }] }; + await assert.rejects( + harness.handle(request, 'session/worker', new AbortController().signal, undefined, context), + /model failed/, + ); + await harness.handle(request, 'session/worker', new AbortController().signal, undefined, context); + assert.match(calls[1].prompt, /previous turn was interrupted/); +}); + +test('a missing agy binary is a 400, a transient process shortage stays retryable', () => { + const missing = new AntigravityCliError('Failed to start agy', 'spawn', { systemCode: 'ENOENT' }); + assert.equal(new AntigravityProviderError(missing).failure.status, 400); + const busy = new AntigravityCliError('Failed to start agy', 'spawn', { systemCode: 'EAGAIN' }); + assert.equal(new AntigravityProviderError(busy).failure.status, 502); +}); + +test('an idle record is evicted after its turn so its lock is not held until shutdown', async (t) => { + const fixture = await setup(); + t.after(() => removeTemporary(fixture.stateDirectory)); + const first = new AntigravityHarness([model], { ...fixture, checkPermissions: policy }); + t.after(() => first.close()); + const second = new AntigravityHarness([model], { ...fixture, checkPermissions: policy }); + t.after(() => second.close()); + const ask = (harness: AntigravityHarness, content: string) => + harness.handle( + { model: model.model, messages: [{ role: 'user', content }] }, + 'session/worker', + new AbortController().signal, + undefined, + context, + ); + await ask(first, 'one'); + // Another gateway process can take the record because the first released its lock. + await ask(second, 'two'); + assert.equal(fixture.calls.length, 2); +}); + +test('the success text carries no timing or billing decoration', async (t) => { + const fixture = await setup(); + t.after(() => removeTemporary(fixture.stateDirectory)); + const harness = new AntigravityHarness([model], { ...fixture, checkPermissions: policy }); + t.after(() => harness.close()); + const response = await harness.handle( + { model: model.model, messages: [{ role: 'user', content: 'plain' }] }, + 'plain', + new AbortController().signal, + undefined, + context, + ); + const text = response.content.map((block) => (block.type === 'text' ? block.text : '')).join(''); + assert(!/completed in|billed/.test(text)); +}); diff --git a/test/unit/native-antigravity-hook.test.ts b/test/unit/native-antigravity-hook.test.ts index 5ac5ec4..c6446ab 100644 --- a/test/unit/native-antigravity-hook.test.ts +++ b/test/unit/native-antigravity-hook.test.ts @@ -103,7 +103,15 @@ test('plan policy allows only read-only native tools that Claude rules keep', () }); assert.deepEqual( new Set(policy.allowed), - new Set(['view_file', 'list_dir', 'grep_search', 'find_by_name', 'search_web']), + new Set([ + 'view_file', + 'list_dir', + 'grep_search', + 'find_by_name', + 'search_web', + 'finish', + 'wait', + 'wait_5_seconds', + ]), ); - assert.equal(antigravityPermissionPolicy({ permissionMode: 'auto' }).allowed, undefined); }); diff --git a/test/unit/native-antigravity-hooks.test.ts b/test/unit/native-antigravity-hooks.test.ts index 4475657..8c92628 100644 --- a/test/unit/native-antigravity-hooks.test.ts +++ b/test/unit/native-antigravity-hooks.test.ts @@ -9,6 +9,7 @@ import { antigravitySettingsFile, installAntigravityHook, } from '../../plugins/multi-antigravity/src/hooks.ts'; +import { removeAntigravityHook } from '../../plugins/multi-core/src/install/installation.ts'; import { removeTemporary } from '../temporary.ts'; interface InstalledPreToolUseHook { @@ -107,3 +108,17 @@ test('Antigravity replaces an existing Windows destination', async (t) => { assert.equal(installed.old, true); assert.ok(installed['multi-cli-antigravity']); }); + +test('uninstall removes only the Multi Antigravity hook entry and tolerates a missing file', async (t) => { + const home = await mkdtemp(path.join(os.tmpdir(), 'antigravity-uninstall-')); + t.after(() => removeTemporary(home)); + assert.equal(await removeAntigravityHook({ platform: 'linux', homedir: home }), false); + const file = antigravityHookFile({ platform: 'linux', homedir: home }); + await installAntigravityHook(file); + const other = { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'other' }] }] }; + const installed = JSON.parse(await readFile(file, 'utf8')) as Record; + await writeFile(file, `${JSON.stringify({ ...installed, other })}\n`); + assert.equal(await removeAntigravityHook({ platform: 'linux', homedir: home }), true); + assert.deepEqual(JSON.parse(await readFile(file, 'utf8')), { other }); + assert.equal(await removeAntigravityHook({ platform: 'linux', homedir: home }), false); +}); diff --git a/test/unit/native-antigravity-models.test.ts b/test/unit/native-antigravity-models.test.ts index ebd306c..0512a6f 100644 --- a/test/unit/native-antigravity-models.test.ts +++ b/test/unit/native-antigravity-models.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + antigravityDefaultWorkerModel, antigravityPickerOptions, discoverAntigravityModels, parseAntigravityModels, @@ -122,3 +123,13 @@ test('discovers Windows npm shims with injected platform and process execution', ['gemini-low'], ); }); + +test('the default worker model prefers Flash over Flash-Lite whatever order agy lists', () => { + const ids = ['gemini-3.8-flash-lite', 'gemini-3.8-flash']; + assert.equal(antigravityDefaultWorkerModel(ids), 'gemini-3.8-flash'); + assert.equal(antigravityDefaultWorkerModel([...ids].reverse()), 'gemini-3.8-flash'); + assert.equal( + antigravityDefaultWorkerModel(['gemini-3.8-flash', 'gemini-3.8-pro']), + 'gemini-3.8-pro', + ); +}); diff --git a/test/unit/native-antigravity-permissions.test.ts b/test/unit/native-antigravity-permissions.test.ts index 23f3f35..c7ffbe2 100644 --- a/test/unit/native-antigravity-permissions.test.ts +++ b/test/unit/native-antigravity-permissions.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { antigravityPermissionPolicy, + antigravityProtectedRoots, antigravityToolDecision, } from '../../plugins/multi-antigravity/src/permissions.ts'; @@ -12,6 +13,14 @@ const ALWAYS_DENIED = [ 'browser_subagent', 'call_mcp_tool', 'notebook_execution', + 'schedule', + 'manage_task', + 'send_message', + 'manage_inbox', + 'list_resources', + 'read_resource', + 'generate_image', + 'delete_knowledge', ]; test('Antigravity Auto denies excluded Claude tools and always denies delegation/MCP', () => { @@ -33,6 +42,7 @@ test('Bypass carries the same denylist shape as Auto', () => { assert.match(bypass.notice, /Claude Code rules take precedence/); assert.deepEqual(bypass.denied, ALWAYS_DENIED); assert.deepEqual(bypass.denied, auto.denied); + assert.deepEqual(bypass.allowed, auto.allowed); assert.equal(bypass.notice, auto.notice); }); @@ -70,6 +80,79 @@ test('an explicit tools allowlist denies natives for mapped tools left out of it assert(policy.denied.includes('notebook_edit')); }); +test('outside Plan the policy is an allowlist, so unmapped announced tools stay denied', () => { + const policy = antigravityPermissionPolicy({ + permissionMode: 'auto', + tools: ['Read', 'Grep', 'Glob'], + }); + const allowed = new Set(policy.allowed); + for (const name of ['view_file', 'list_dir', 'grep_search', 'find_by_name', 'finish', 'wait']) { + assert(allowed.has(name), `expected ${name} to be allowed`); + } + for (const name of [ + 'execute_browser_javascript', + 'open_browser_url', + 'read_browser_page', + 'schedule', + 'send_message', + 'manage_inbox', + 'manage_task', + 'delete_knowledge', + 'generate_image', + 'list_resources', + 'read_resource', + 'run_command', + 'write_to_file', + 'a_tool_agy_adds_later', + ]) { + assert(!allowed.has(name), `expected ${name} outside the allowlist`); + } + const decision = antigravityToolDecision( + { toolCall: { name: 'a_tool_agy_adds_later' } }, + JSON.stringify(policy.denied), + JSON.stringify(policy.allowed), + ); + assert.equal(decision?.decision, 'deny'); +}); + +test('browser tools follow the WebFetch grant', () => { + const withWeb = antigravityPermissionPolicy({ + permissionMode: 'auto', + tools: ['Read', 'WebFetch'], + }); + assert(withWeb.allowed?.includes('open_browser_url')); + assert(withWeb.allowed?.includes('execute_browser_javascript')); + const without = antigravityPermissionPolicy({ + permissionMode: 'auto', + disallowedTools: ['WebFetch'], + }); + assert(without.denied.includes('open_browser_url')); +}); + +test('write tools never target the agy hook and settings tree', () => { + const roots = antigravityProtectedRoots({ platform: 'linux', homedir: '/home/u' }); + const policyJson = JSON.stringify([]); + const deny = (name: string, parameters: unknown) => + antigravityToolDecision( + { toolCall: { name, parameters } }, + policyJson, + JSON.stringify([name]), + { protectedRoots: roots }, + ); + for (const [name, parameters] of [ + ['write_to_file', { TargetFile: '/home/u/.gemini/config/hooks.json' }], + [ + 'replace_file_content', + { TargetFile: '/home/u/.gemini/../.gemini/antigravity-cli/settings.json' }, + ], + ['sed_file', { AbsolutePath: '/work/../home/u/.gemini/config/x' }], + ] as const) { + assert.equal(deny(name, parameters)?.decision, 'deny', name); + } + assert.equal(deny('write_to_file', { TargetFile: '/work/project/a.txt' }), undefined); + assert.equal(deny('view_file', { AbsolutePath: '/home/u/.gemini/config/hooks.json' }), undefined); +}); + test('rejects unsupported permission modes and rule shapes', () => { assert.throws(() => antigravityPermissionPolicy({ permissionMode: 'default' }), /unsupported/); assert.throws(() => antigravityPermissionPolicy({ permissionMode: 'dontAsk' }), /unsupported/); diff --git a/test/unit/native-approval.test.ts b/test/unit/native-approval.test.ts index c68911a..bb8a3b3 100644 --- a/test/unit/native-approval.test.ts +++ b/test/unit/native-approval.test.ts @@ -430,7 +430,10 @@ test('headerless classifier uses pending worker context and rejects ambiguous ac // The mode Claude reports with the pending action binds review mid-turn. assert.equal(contexts[0].planMode, true); await prepare('worker-b', 'node b.js', 'plan', 'cd /other-workspace && node b.js'); - assert.equal((await send(request(1, session, 'node b.js'))).status, 400); + // No observed provider action matches, so this is not a provider action to review: it + // goes to Anthropic (the stub answers 200) and the GPT reviewer never sees it. + assert.equal((await send(request(1, session, 'node b.js'))).status, 200); + assert.equal(contexts.length, 1); await prepare( 'worker-b', 'node b.js', @@ -438,7 +441,8 @@ test('headerless classifier uses pending worker context and rejects ambiguous ac `${process.execPath} -e ${JSON.stringify('process.stdout.write("unsafe")')}`, path.join(os.tmpdir(), 'unsafe').replaceAll('\\', '/'), ); - assert.equal((await send(request(1, session, 'node b.js'))).status, 400); + assert.equal((await send(request(1, session, 'node b.js'))).status, 200); + assert.equal(contexts.length, 1); await prepare('worker-b', 'node b.js', 'plan', `cd ${classifierCwd} && node b.js`); assert.equal((await send(request(1, session, 'node b.js'))).status, 200); await prepare('worker-a', 'node b.js'); @@ -553,12 +557,18 @@ async function mixedReviewGateway(t: TestContext, reviewer = true, blockAnthropi }); const address = server.address(); assert(address && typeof address !== 'string'); - const send = (body: unknown, endpoint = '/v1/messages', worker?: string) => + const send = ( + body: unknown, + endpoint = '/v1/messages', + worker?: string, + extra: Record = {}, + ) => fetch(`http://127.0.0.1:${address.port}${endpoint}`, { method: 'POST', headers: { 'x-multi-gateway-token': 'token', ...(worker ? { 'x-claude-code-agent-id': worker } : {}), + ...extra, }, body: JSON.stringify(body), }); @@ -669,11 +679,11 @@ test('authenticated mixed-provider review follows main and headerless worker ori assert.equal((await gateway.classify('node zen.js')).status, 200); assert.equal(gateway.reviews.length, 2, 'Claude and Zen never borrow GPT review'); assert.equal(gateway.nativeReviews.length, 3); - assert.equal( - (await gateway.classify('node main.js')).status, - 400, - 'A provider switch invalidates stale main actions', - ); + // A provider switch invalidates the stale GPT action: nothing provider-owned matches + // any more, so the request is Claude's own classifier and goes to Anthropic. + assert.equal((await gateway.classify('node main.js')).status, 200); + assert.equal(gateway.reviews.length, 2, 'A stale GPT action is never reviewed again'); + assert.equal(gateway.nativeReviews.length, 4); }); test('Claude-only Auto passes native classifier formats and fallback models through unchanged', async (t) => { @@ -729,17 +739,38 @@ test('mixed-provider review rejects missing, cross-session, ambiguous, and unava const guard = await gateway.prepare(gpt, 'node unavailable.js'); assert.deepEqual(await guard.json(), {}); assert.equal((await gateway.classify('node unavailable.js')).status, 400); - assert.equal((await gateway.classify('node missing.js')).status, 400); + // A classifier format the gateway cannot read may be that GPT action, so it fails closed. + const unreadable = request(1, JSON.stringify({ session_id: 'mixed' }), 'node unavailable.js'); + const instruction = unreadable.messages[0].content.at(-1); + assert(instruction); + instruction.text = 'A native classifier format unknown to the gateway.'; + assert.equal((await gateway.send(unreadable)).status, 400); + // No observed provider action matches: Claude's own classifier, answered by Anthropic. + assert.equal((await gateway.classify('node missing.js')).status, 200); + assert.deepEqual(gateway.nativeReviews.length, 1); + // The session header disagrees with the metadata that selects the provider action. + const crossed = await gateway.send( + { + ...request(1, JSON.stringify({ session_id: 'mixed' }), 'node unavailable.js'), + model: 'claude-sonnet-5', + }, + '/v1/messages', + undefined, + { 'x-claude-code-session-id': 'other' }, + ); + assert.equal(crossed.status, 400); + assert.match(await crossed.text(), /disagree/); + // Another session owns no provider action here, so its classifier is Claude's own. assert.equal( (await gateway.classify('node unavailable.js', 1, 'claude-sonnet-5', 'other')).status, - 400, + 200, ); await gateway.prepare('claude-sonnet-5', 'node unavailable.js', 'claude-worker'); assert.equal((await gateway.classify('node unavailable.js')).status, 400); - assert.deepEqual( - gateway.nativeReviews, - [], - 'Never fall back to Claude for an unavailable or ambiguous GPT reviewer', + assert.equal( + gateway.nativeReviews.length, + 2, + 'An ambiguous GPT origin never falls back to Claude', ); }); diff --git a/test/unit/native-atomic-write.test.ts b/test/unit/native-atomic-write.test.ts index 3464383..795c8ce 100644 --- a/test/unit/native-atomic-write.test.ts +++ b/test/unit/native-atomic-write.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { mkdtemp, open, readFile, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -45,3 +45,33 @@ test('rejects an unbounded retry configuration', async () => { /retries must be a non-negative integer/, ); }); + +test('syncs the temporary file before rename and the directory after, except on Windows', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'atomic-write-sync-')); + t.after(() => removeTemporary(directory)); + for (const platform of ['linux', 'win32'] as const) { + const events: string[] = []; + const spyOpen = (async (target: string, flags: string, mode?: number) => { + const handle = await open(target, flags, mode); + const sync = handle.sync.bind(handle); + handle.sync = async () => { + events.push(`sync:${target === directory ? 'dir' : 'tmp'}`); + await sync(); + }; + return handle; + }) as typeof open; + await atomicWriteFile(path.join(directory, 'state.json'), 'v', { + platform, + open: spyOpen, + rename: async (from, to) => { + events.push('rename'); + const { rename } = await import('node:fs/promises'); + await rename(from, to); + }, + }); + assert.deepEqual( + events, + platform === 'win32' ? ['sync:tmp', 'rename'] : ['sync:tmp', 'rename', 'sync:dir'], + ); + } +}); diff --git a/test/unit/native-cursor-harness-http.test.ts b/test/unit/native-cursor-harness-http.test.ts index 3c59e5a..1665101 100644 --- a/test/unit/native-cursor-harness-http.test.ts +++ b/test/unit/native-cursor-harness-http.test.ts @@ -76,19 +76,9 @@ test('Cursor harness serves isolated main and worker SSE progress without replay }); const permissionModes = new PermissionModes(async () => ({ worker: {} })); for (const session_id of ['session-1', 'session-cancelled']) { - await permissionModes.record({ - hook_event_name: 'UserPromptSubmit', - session_id, - permission_mode: 'auto', - }); + permissionModes.recordModSession(session_id, { permissionMode: 'auto' }); } - await permissionModes.record({ - hook_event_name: 'SubagentStart', - session_id: 'session-1', - agent_id: 'worker-1', - agent_type: 'worker', - cwd: directory, - }); + permissionModes.recordModWorker('session-1', 'worker-1', { cwd: directory }); const server = createNativeGateway({ permissionModes, token: 'test-token', @@ -217,19 +207,9 @@ test('native SSE cancellation stops the SDK run without reporting successful com }); const permissionModes = new PermissionModes(async () => ({ worker: {} })); for (const session_id of ['session-1', 'session-cancelled']) { - await permissionModes.record({ - hook_event_name: 'UserPromptSubmit', - session_id, - permission_mode: 'auto', - }); + permissionModes.recordModSession(session_id, { permissionMode: 'auto' }); } - await permissionModes.record({ - hook_event_name: 'SubagentStart', - session_id: 'session-1', - agent_id: 'worker-1', - agent_type: 'worker', - cwd: directory, - }); + permissionModes.recordModWorker('session-1', 'worker-1', { cwd: directory }); const server = createNativeGateway({ permissionModes, token: 'test-token', @@ -307,11 +287,7 @@ test('a prompt that arrives during a native run is answered 400, not a retryable }), }); const permissionModes = new PermissionModes(async () => ({ worker: {} })); - await permissionModes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'session-busy', - permission_mode: 'auto', - }); + permissionModes.recordModSession('session-busy', { permissionMode: 'auto' }); const server = createNativeGateway({ permissionModes, token: 'test-token', diff --git a/test/unit/native-cursor-permissions.test.ts b/test/unit/native-cursor-permissions.test.ts index bffb8ca..510ae83 100644 --- a/test/unit/native-cursor-permissions.test.ts +++ b/test/unit/native-cursor-permissions.test.ts @@ -3,12 +3,12 @@ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { mergePermissions } from '../../plugins/multi-core/src/gateway/harness-settings.ts'; import { assertCursorClaudeSettings, cursorNativePermissions, cursorPermissionMode, cursorPermissionPolicy, - mergeCursorPermissions, } from '../../plugins/multi-cursor/src/permissions.ts'; import { removeTemporary } from '../temporary.ts'; @@ -160,12 +160,12 @@ test('native bypass disables review without widening explicit capabilities', asy }); test('restriction layers intersect tool lists and accumulate denials', () => { - const context = mergeCursorPermissions( + const context = mergePermissions( { permissionMode: 'auto', tools: ['Read', 'Bash'], disallowedTools: ['Edit'] }, { tools: ['Read', 'Write'], disallowedTools: ['Write'] }, ); assert.deepEqual(context.tools, ['Read']); assert.deepEqual(context.disallowedTools, ['Edit', 'Write']); assert.deepEqual(cursorPermissionPolicy(context).tools, ['read', 'ls']); - assert.deepEqual(mergeCursorPermissions(context, { tools: [] }).tools, []); + assert.deepEqual(mergePermissions(context, { tools: [] }).tools, []); }); diff --git a/test/unit/native-cursor-progress.test.ts b/test/unit/native-cursor-progress.test.ts index e5b3574..082dedf 100644 --- a/test/unit/native-cursor-progress.test.ts +++ b/test/unit/native-cursor-progress.test.ts @@ -238,7 +238,7 @@ test('an edit row takes its two sides from the diff, a created file becomes a Wr }); const rows = new DisplayRows(); rows.announce(['edit']); - rows.acknowledge(['edit']); + rows.acknowledge('s', ['edit']); const issued: Array> = []; const tracker = new NativeActionTracker( 'Cursor', @@ -401,7 +401,7 @@ test('a Cursor action becomes a display row under its native name; later text is }); const rows = new DisplayRows(); rows.announce(CURSOR_TOOLS); - rows.acknowledge(['read']); + rows.acknowledge('session', ['read']); const scope = JSON.stringify(['session', 'worker']); const events: Array<[string, unknown]> = []; const emit: Emit = (name, value) => { diff --git a/test/unit/native-cursor-settings.test.ts b/test/unit/native-cursor-settings.test.ts index 2a0bb3d..ccec16a 100644 --- a/test/unit/native-cursor-settings.test.ts +++ b/test/unit/native-cursor-settings.test.ts @@ -6,9 +6,11 @@ import path from 'node:path'; import test, { mock } from 'node:test'; import { antigravityPermissionPolicy } from '../../plugins/multi-antigravity/src/permissions.ts'; import { - type CursorSettingsOptions, - checkCursorSettings, -} from '../../plugins/multi-core/src/gateway/cursor-settings.ts'; + checkHarnessSettings, + type HarnessSettingsOptions, + policyCommandTimeoutMs, + systemCommandPath, +} from '../../plugins/multi-core/src/gateway/harness-settings.ts'; import type { PermissionContext } from '../../plugins/multi-core/src/gateway/mode-hook.ts'; import { sharedAdmission } from '../../plugins/multi-core/src/launcher.ts'; import { @@ -24,9 +26,9 @@ async function checkSettings( cwd: string, args: readonly string[], inlineSettings: Record, - options: CursorSettingsOptions = {}, + options: HarnessSettingsOptions = {}, ) { - return checkCursorSettings(cwd, args, inlineSettings, { + return checkHarnessSettings(cwd, args, inlineSettings, { platform: 'linux', osRelease: 'test-linux', runCommand: absentManagedPolicy, @@ -294,7 +296,7 @@ function commandError( return error; } -function absentManagedFiles(): Pick { +function absentManagedFiles(): Pick { return { readDir: async () => { throw commandError('ENOENT', 'missing'); @@ -563,3 +565,16 @@ test('native settings admission treats WSL as Linux managed file discovery', asy ); assert.deepEqual(policy.disallowedTools, ['Read']); }); + +test('policy probes use absolute system paths and a bounded timeout', () => { + assert.equal( + systemCommandPath('reg', 'win32', { SystemRoot: 'D:\\Win' }), + 'D:\\Win\\System32\\reg.exe', + ); + assert.equal( + systemCommandPath('powershell.exe', 'win32', {}), + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + ); + assert.equal(systemCommandPath('defaults', 'darwin'), '/usr/bin/defaults'); + assert.ok(policyCommandTimeoutMs > 0 && policyCommandTimeoutMs <= 30_000); +}); diff --git a/test/unit/native-cursor-workspaces.test.ts b/test/unit/native-cursor-workspaces.test.ts index 060345d..0ba3bc2 100644 --- a/test/unit/native-cursor-workspaces.test.ts +++ b/test/unit/native-cursor-workspaces.test.ts @@ -57,3 +57,43 @@ test('worktree workers route to their canonical workspace and close all SDK harn assert.deepEqual(closed, created); await assert.rejects(workspaces.handle(body, 'main', signal), /closed/); }); + +test('a recorded reply is read from the workspace that handled it, so a follow-up survives a restart', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'cursor-workspaces-recorded-')); + t.after(() => removeTemporary(root)); + const worktree = path.join(root, 'worktree'); + await mkdir(worktree); + await symlink(worktree, path.join(root, 'alias')); + const reply: MessagesResponse = { + id: 'msg', + type: 'message', + role: 'assistant', + model: 'test', + content: [{ type: 'text', text: 'recorded' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }; + const asked: string[] = []; + const workspaces = new CursorWorkspaces( + (cwd) => { + const harness = new CursorHarness([], { cwd }); + t.mock.method(harness, 'recordedResponse', async (scope: string) => { + asked.push(`${cwd}:${scope}`); + return cwd === (await realpath(worktree)) ? reply : undefined; + }); + return harness; + }, + await realpath(root), + ); + t.after(() => workspaces.close()); + assert.equal( + await workspaces.recordedResponse('worker', { + permissionMode: 'plan', + cwd: path.join(root, 'alias'), + }), + reply, + ); + assert.equal(await workspaces.recordedResponse('worker'), undefined); + assert.deepEqual(asked, [`${await realpath(worktree)}:worker`, `${await realpath(root)}:worker`]); +}); diff --git a/test/unit/native-display-rows.test.ts b/test/unit/native-display-rows.test.ts index 0b8041f..e723986 100644 --- a/test/unit/native-display-rows.test.ts +++ b/test/unit/native-display-rows.test.ts @@ -45,7 +45,7 @@ test('rows are issued only for registered native tools, with bounded native inpu error: false, }; assert.equal(rows.issue(scope, native), undefined, 'unregistered tools write no row'); - rows.acknowledge(['run_command', 'never_offered']); + rows.acknowledge('session', ['run_command', 'never_offered']); const issued = rows.issue(scope, { ...native, input: { CommandLine: 'y'.repeat(5000), [ROW_TOKEN]: 'forged', nested: { a: 1 } }, @@ -116,7 +116,7 @@ test('captured agy steps mirror Read and Bash, the native parameters kept under } const rows = new DisplayRows(); rows.announce(['run_command']); - rows.acknowledge(['run_command']); + rows.acknowledge('session', ['run_command']); const failed = steps.find((step) => step.info.parameters.CommandLine === 'cat /nonexistent/file'); assert.ok(failed); const issued = rows.issue(JSON.stringify(['session']), { @@ -274,7 +274,7 @@ test('fields derived from a result take precedence, and may name another built-i assert.equal(String(long.new_string).length, 16 * 1024 + 1); const rows = new DisplayRows(); rows.announce(['edit']); - rows.acknowledge(['edit']); + rows.acknowledge('s', ['edit']); const issued = rows.issue('["s"]', { tool: 'edit', input: created, @@ -514,3 +514,43 @@ test('the gateway forwards Anthropic requests without display tools or rows', as content: [{ type: 'text', text: 'Now summarise.' }], }); }); + +test('a session registers its own display tools and ToolSearch references are stripped', () => { + const rows = new DisplayRows(); + rows.announce(['run_command']); + rows.acknowledge('one', ['run_command']); + const native = { tool: 'run_command', input: {}, output: 'ok', error: false }; + assert.ok(rows.issue('["one","main"]', native)); + assert.equal( + rows.issue('["two","main"]', native), + undefined, + 'another session registered nothing', + ); + rows.forgetSession('one'); + assert.equal(rows.issue('["one","main"]', native), undefined); + const search = withoutDisplayTools({ + model: 'x', + messages: [ + { role: 'user', content: 'find tools' }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_search', name: 'ToolSearch', input: {} }], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_search', + content: [ + { type: 'tool_reference', tool_name: 'mcp__multi-core__run_command' }, + { type: 'tool_reference', tool_name: 'WebFetch' }, + ], + }, + ], + }, + ], + }); + assert.doesNotMatch(JSON.stringify(search), /mcp__multi-core/); + assert.match(JSON.stringify(search), /WebFetch/); +}); diff --git a/test/unit/native-executable.test.ts b/test/unit/native-executable.test.ts index 3917225..bdfcf07 100644 --- a/test/unit/native-executable.test.ts +++ b/test/unit/native-executable.test.ts @@ -1,8 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + argvSafe, executableInvocation, + isCmdSafeArgument, resolveExecutable, + UnsafeCommandArgumentError, } from '../../plugins/multi-core/src/gateway/executable.ts'; test('resolves Windows npm shims using PATHEXT order', () => { @@ -111,6 +114,29 @@ test('invokes a canonical npm cmd shim with Node directly', () => { assert.deepEqual(seen, [target]); }); +test('runs an npm cmd shim for a native binary directly', () => { + // npm's shim for Claude Code's bin/claude.exe: no Node program, the binary itself. + const shim = [ + '@ECHO off', + 'GOTO start', + ':find_dp0', + 'SET dp0=%~dp0', + 'EXIT /b', + ':start', + 'SETLOCAL', + 'CALL :find_dp0', + '"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*', + ].join('\r\n'); + const target = 'C:\\npm\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe'; + const options = { readShim: () => shim, exists: (filename: string) => filename === target }; + const settings = '{"permissions":{"allow":["Read"]}}'; + assert.deepEqual( + executableInvocation('C:\\npm\\claude.cmd', ['--settings', settings], 'win32', {}, options), + { command: target, args: ['--settings', settings], viaComSpec: false }, + ); + assert.equal(argvSafe('C:\\npm\\claude.cmd', settings, 'win32', options), true); +}); + test('resolves npm layouts in bat shims', () => { const target = 'C:\\tools\\node_modules\\claude\\cli.mjs'; assert.deepEqual( @@ -160,3 +186,61 @@ test('Windows resolution reads Path and PathExt regardless of spelling', () => { }); assert.equal(found, 'C:\\bin\\claude.cmd'); }); + +const comSpec = { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }; +const noShim = { readShim: () => 'unexpected shim body' }; + +test('refuses cmd.exe metacharacters on the ComSpec path', () => { + for (const hostile of [ + 'a"&calc&"', + '%PATH%', + '!VAR!', + 'x^y', + 'a&b', + 'a|b', + 'ab', + 'line\nbreak', + 'line\rbreak', + 'say "hi"', + ]) { + assert.throws( + () => executableInvocation('C:\\tools\\agy.cmd', ['-p', hostile], 'win32', comSpec, noShim), + UnsafeCommandArgumentError, + JSON.stringify(hostile), + ); + assert.equal(isCmdSafeArgument(hostile), false); + assert.equal(argvSafe('C:\\tools\\agy.cmd', hostile, 'win32', noShim), false); + } +}); + +test('refuses a hostile executable path on the ComSpec path', () => { + assert.throws( + () => executableInvocation('C:\\a%TEMP%\\agy.cmd', [], 'win32', comSpec, noShim), + UnsafeCommandArgumentError, + ); +}); + +test('plain text with spaces and parentheses still passes through cmd.exe', () => { + const invocation = executableInvocation( + 'C:\\tools\\agy.cmd', + ['-p', 'fix (the) bug, now'], + 'win32', + comSpec, + noShim, + ); + assert.equal(invocation.viaComSpec, true); + assert.equal(argvSafe('C:\\tools\\agy.cmd', 'fix (the) bug', 'win32', noShim), true); +}); + +test('argvSafe only restricts non-shim cmd launchers on Windows', () => { + assert.equal(argvSafe('C:\\tools\\agy.exe', '%PATH%', 'win32'), true); + assert.equal(argvSafe('/usr/bin/agy', '%PATH% "x"', 'linux'), true); + assert.equal( + argvSafe('C:\\tools\\agy.cmd', '"%PATH%"', 'win32', { + readShim: () => '"%dp0%\\node_modules\\pkg\\cli.js" %*', + exists: () => true, + }), + true, + ); +}); diff --git a/test/unit/native-gateway-transport.test.ts b/test/unit/native-gateway-transport.test.ts new file mode 100644 index 0000000..96cb259 --- /dev/null +++ b/test/unit/native-gateway-transport.test.ts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import type { TestContext } from 'node:test'; +import test from 'node:test'; +import type { GatewayFetch } from '../../plugins/multi-core/src/gateway/fetch.ts'; +import type { GatewayEvent } from '../../plugins/multi-core/src/gateway/server.ts'; +import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; + +const TOKEN = 'local-test-secret'; + +async function listen(t: TestContext, options: Partial[0]>) { + const server = createNativeGateway({ + token: TOKEN, + authFile: '/nonexistent/auth.json', + guardAuto: true, + ...options, + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + const address = server.address(); + assert(address !== null && typeof address === 'object', 'Gateway port'); + return address.port; +} + +test('a bodyless DELETE goes upstream without a streamed body', async (t) => { + const seen: { url: string; method: string; body: unknown; duplex: unknown }[] = []; + const fetchImpl: GatewayFetch = async (url, init) => { + seen.push({ url, method: init.method, body: init.body, duplex: init.duplex }); + return new Response(null, { status: 204 }); + }; + const port = await listen(t, { fetchImpl }); + const response = await fetch(`http://127.0.0.1:${port}/v1/files/file_1`, { method: 'DELETE' }); + assert.equal(response.status, 204); + assert.deepEqual(seen, [ + { + url: 'https://api.anthropic.com/v1/files/file_1', + method: 'DELETE', + body: undefined, + duplex: undefined, + }, + ]); +}); + +test('paths outside /v1 go to Anthropic raw, while /multi routes keep their own gate', async (t) => { + const urls: string[] = []; + const fetchImpl: GatewayFetch = async (url) => { + urls.push(url); + return new Response('ok'); + }; + const port = await listen(t, { fetchImpl }); + for (const route of ['/api/hello', '/api/claude_cli/bootstrap?x=1', '/']) { + assert.equal((await fetch(`http://127.0.0.1:${port}${route}`)).status, 200); + } + assert.deepEqual(urls, [ + 'https://api.anthropic.com/api/hello', + 'https://api.anthropic.com/api/claude_cli/bootstrap?x=1', + 'https://api.anthropic.com/', + ]); + assert.equal((await fetch(`http://127.0.0.1:${port}/multi/other`)).status, 403); + assert.equal(urls.length, 3); +}); + +test('the caller own base URL receives native traffic', async (t) => { + const urls: string[] = []; + const fetchImpl: GatewayFetch = async (url) => { + urls.push(url); + return new Response('ok'); + }; + const port = await listen(t, { fetchImpl, anthropicBaseUrl: 'https://corp.example/anthropic/' }); + await fetch(`http://127.0.0.1:${port}/v1/files?a=1`); + await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + body: JSON.stringify({ model: 'claude-opus-4-6', messages: [] }), + }); + assert.deepEqual(urls, [ + 'https://corp.example/anthropic/v1/files?a=1', + 'https://corp.example/anthropic/v1/messages', + ]); +}); + +test('the client encoding preference reaches Anthropic only for encodings fetch decodes', async (t) => { + const encodings: (string | undefined)[] = []; + const fetchImpl: GatewayFetch = async (_url, init) => { + encodings.push(init.headers['accept-encoding']); + return new Response('ok'); + }; + const port = await listen(t, { fetchImpl }); + for (const value of ['gzip, br;q=0.5, zstd', 'zstd', 'identity']) { + await fetch(`http://127.0.0.1:${port}/v1/files`, { headers: { 'accept-encoding': value } }); + } + assert.deepEqual(encodings, ['gzip, br', 'identity', 'identity']); +}); + +test('a gateway failure on a native request is reported as a diagnostic, not hidden', async (t) => { + const events: GatewayEvent[] = []; + const fetchImpl: GatewayFetch = async () => { + throw new TypeError('fetch failed'); + }; + const port = await listen(t, { fetchImpl, onEvent: (event) => events.push(event) }); + await assert.rejects( + fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + body: JSON.stringify({ model: 'claude-opus-4-6', messages: [] }), + }), + ); + assert( + events.some((event) => event.route === 'anthropic' && event.diagnostic === 'fetch failed'), + ); +}); + +test('a long observed tool_use line stops observation with a diagnostic and passes through', async (t) => { + const events: GatewayEvent[] = []; + const big = 'x'.repeat(9 * 1024 * 1024); + const body = `event: content_block_delta\ndata: ${big}\n`; + const fetchImpl: GatewayFetch = async () => + new Response(`${body}\nevent: ping\ndata: {}\n\n`, { + headers: { 'content-type': 'text/event-stream' }, + }); + const port = await listen(t, { fetchImpl, onEvent: (event) => events.push(event) }); + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + body: JSON.stringify({ + model: 'claude-opus-4-6', + stream: true, + tools: [{ name: 'Write', input_schema: { type: 'object' } }], + messages: [], + }), + }); + assert.equal((await response.text()).length, body.length + '\nevent: ping\ndata: {}\n\n'.length); + assert(events.some((event) => event.diagnostic?.includes('exceeds 8 MiB'))); +}); + +test('a non-streamed provider reply that outlasts the delay sends headers and keeps alive', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'keepalive-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const authFile = path.join(dir, 'auth.json'); + await writeFile( + authFile, + JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'fake', account_id: 'fake' } }), + ); + const events = [ + { type: 'response.created', response: { id: 'r', usage: null } }, + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'message', content: [] }, + }, + { type: 'response.output_text.delta', output_index: 0, delta: 'Done' }, + { + type: 'response.output_item.done', + output_index: 0, + item: { type: 'message', content: [{ type: 'output_text', text: 'Done' }] }, + }, + { + type: 'response.completed', + response: { id: 'r', usage: { input_tokens: 1, output_tokens: 1 } }, + }, + ] + .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + .join(''); + const fetchImpl: GatewayFetch = async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + return new Response(events, { headers: { 'content-type': 'text/event-stream' } }); + }; + const port = await listen(t, { fetchImpl, authFile, jsonKeepAliveMs: 50 }); + const request = JSON.stringify({ + model: 'multi/openai/gpt-6-luna', + max_tokens: 10, + messages: [{ role: 'user', content: 'hi' }], + }); + const started = Date.now(); + const timing = await new Promise<{ headersAt: number; text: string; status?: number }>( + (resolve, reject) => { + const req = http.request( + { + port, + host: '127.0.0.1', + path: '/v1/messages', + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + }, + (res) => { + const headersAt = Date.now() - started; + let text = ''; + res.on('data', (chunk) => { + text += chunk; + }); + res.on('end', () => resolve({ headersAt, text, status: res.statusCode })); + }, + ); + req.on('error', reject); + req.end(request); + }, + ); + assert.equal(timing.status, 200); + assert(timing.headersAt < 250, `headers came after ${timing.headersAt} ms`); + assert(timing.text.startsWith(' '), 'leading JSON whitespace'); + assert.equal(JSON.parse(timing.text).content[0].text, 'Done'); +}); + +test('a provider failure after the keepalive started is a valid assistant message', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'keepalive-fail-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const authFile = path.join(dir, 'auth.json'); + await writeFile( + authFile, + JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'fake', account_id: 'fake' } }), + ); + const fetchImpl: GatewayFetch = async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + throw new Error('provider exploded'); + }; + const port = await listen(t, { fetchImpl, authFile, jsonKeepAliveMs: 50 }); + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + body: JSON.stringify({ + model: 'multi/openai/gpt-6-luna', + max_tokens: 10, + messages: [{ role: 'user', content: 'hi' }], + }), + }); + assert.equal(response.status, 200); + const message = JSON.parse(await response.text()); + assert.equal(message.type, 'message'); + assert.equal(message.role, 'assistant'); + assert.equal(message.stop_reason, 'end_turn'); + assert.deepEqual(message.usage, { input_tokens: 0, output_tokens: 0 }); + assert.equal(message.content.length, 1); + assert.match(message.content[0].text, /^Multi: openai failed: .*provider exploded/); +}); diff --git a/test/unit/native-gateway.test.ts b/test/unit/native-gateway.test.ts index 359b29b..0879e27 100644 --- a/test/unit/native-gateway.test.ts +++ b/test/unit/native-gateway.test.ts @@ -4,7 +4,6 @@ import os from 'node:os'; import path from 'node:path'; import type { TestContext } from 'node:test'; import test from 'node:test'; -import { AgentCatalog } from '../../plugins/multi-core/src/gateway/agent-catalog.ts'; import { type ApprovalContext, NativeApprovalBridge, @@ -19,6 +18,18 @@ import type { } from '../../plugins/multi-core/src/gateway/messages.ts'; import { PermissionModes } from '../../plugins/multi-core/src/gateway/mode-hook.ts'; import { ReceiptLedger } from '../../plugins/multi-core/src/gateway/receipts.ts'; +import type { + ResponsesInputContent, + ResponsesInputItem, + ResponsesRequest, +} from '../../plugins/multi-core/src/gateway/responses.ts'; +import { + forAnthropic, + fromResponses, + OPENAI_SIGNATURE_PREFIX, + readSse, + toResponses, +} from '../../plugins/multi-core/src/gateway/responses.ts'; import type { GatewayEvent, GatewayOptions } from '../../plugins/multi-core/src/gateway/server.ts'; import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; import { estimateInputTokens } from '../../plugins/multi-core/src/gateway/tokens.ts'; @@ -30,19 +41,11 @@ import { import { readCodexAuth } from '../../plugins/multi-openai/src/auth.ts'; import { openaiInstructions } from '../../plugins/multi-openai/src/instructions.ts'; import { MODELS, OPENAI_WORKER_EFFORT } from '../../plugins/multi-openai/src/models.ts'; -import type { - ResponsesInputContent, - ResponsesInputItem, - ResponsesRequest, -} from '../../plugins/multi-openai/src/responses.ts'; -import { - forAnthropic, - fromResponses, - readSse, - toResponses, -} from '../../plugins/multi-openai/src/responses.ts'; +import { ZEN_SIGNATURE_PREFIXES } from '../../plugins/multi-zen/src/chat.ts'; import { removeTemporary } from '../temporary.ts'; +const FOREIGN_PREFIXES = [OPENAI_SIGNATURE_PREFIX, ...ZEN_SIGNATURE_PREFIXES]; + /** A test double for one OpenAI Responses SSE event; sent as JSON, never typed upstream. */ interface SseEvent { type: string; @@ -263,7 +266,7 @@ test('images retain their order and tool-result association across provider swit { type: 'input_text', text: 'Capture timed out' }, ], }); - assert.equal(forAnthropic(request), request); + assert.equal(forAnthropic(request, FOREIGN_PREFIXES), request); assert.deepEqual(request, snapshot, 'Conversion must not rewrite the stored transcript'); }); @@ -378,7 +381,7 @@ test('switching back to Claude removes OpenAI reasoning while preserving message }, ]; const mixed = { ...body, messages: stored }; - const cleaned = forAnthropic(mixed).messages ?? []; + const cleaned = forAnthropic(mixed, FOREIGN_PREFIXES).messages ?? []; assert.equal(cleaned.length, 3); assert.deepEqual(cleaned[0], stored[0]); assert.deepEqual(cleaned[1].content, [tool]); @@ -390,7 +393,11 @@ test('switching back to Claude removes OpenAI reasoning while preserving message 2, 'Stored transcript must not be mutated', ); - assert.equal(forAnthropic(body), body, 'Unmixed Claude requests retain byte-exact passthrough'); + assert.equal( + forAnthropic(body, FOREIGN_PREFIXES), + body, + 'Unmixed Claude requests retain byte-exact passthrough', + ); }); test('fragmented SSE and truncated or failed responses never become successful completions', async () => { @@ -617,7 +624,7 @@ test('OpenAI main and worker requests adapt instructions without losing runtime seen.push(JSON.parse(String(options.body))); return new Response(sse(textEvents)); }); - for (const name of ['gpt-6-astra', 'gpt-6-sol', 'gpt-5.6-terra', 'gpt-6-luna']) { + for (const name of ['gpt-6-astra', 'gpt-6.1-sol', 'gpt-5.6-terra', 'gpt-6-luna']) { await (await call({ ...payload, model: `multi/openai/${name}` })).text(); } await (await call(payload, { 'x-claude-code-agent-id': 'worker-a' })).text(); @@ -673,43 +680,6 @@ test('Claude prompts remain unchanged after OpenAI main and worker requests, inc assert.equal(claudeCalls, 8); }); -test('catalog filtering reaches Claude and OpenAI without changing user text or native registration', async (t) => { - const row = '- hidden: Hidden worker (Tools: Read)'; - const text = `\nAvailable agent types for the Agent tool:\n${row}\n- custom: Keep this (Tools: Read)\n`; - const seen: string[] = []; - const call = await gateway( - t, - async (url, options) => { - seen.push(String(options.body)); - return url.includes('anthropic') - ? Response.json({ content: [] }) - : new Response(sse(textEvents)); - }, - { - agentCatalog: new AgentCatalog( - { hidden: { model, description: 'Hidden worker', tools: ['Read'] } }, - [], - ), - }, - ); - for (const choice of [model, 'claude-sonnet-5']) { - await ( - await call({ - model: choice, - messages: [ - { role: 'user', content: text }, - { role: 'user', content: row }, - ], - }) - ).text(); - } - assert.equal(seen.length, 2); - for (const sent of seen) { - assert.equal(sent.split('Hidden worker').length - 1, 1); - assert(sent.includes('Keep this')); - } -}); - test('external route isolates provider credentials and handles simultaneous worker identities', async (t) => { const ids: string[] = []; const models: string[] = []; @@ -783,7 +753,7 @@ test('all registered model and reasoning choices reach OpenAI without substituti return new Response(sse(textEvents)); }); assert.equal(OPENAI_WORKER_EFFORT, 'medium'); - for (const slug of ['gpt-6-astra', 'gpt-6-sol', 'gpt-5.6-terra', 'gpt-6-luna']) { + for (const slug of ['gpt-6-astra', 'gpt-6.1-sol', 'gpt-5.6-terra', 'gpt-6-luna']) { assert(Object.values(MODELS).includes(slug), slug); for (const effort of ['low', 'medium', 'high', 'xhigh', 'max']) { const response = await call( @@ -1323,12 +1293,7 @@ const harnessReply = (model: string, text = 'native'): MessagesResponse => ({ async function harnessPermissionModes(session = 'routing-session') { const modes = new PermissionModes(async () => ({})); - await modes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: session, - permission_mode: 'auto', - prompt: 'route', - }); + modes.recordModSession(session, { permissionMode: 'auto' }); return modes; } @@ -1558,12 +1523,7 @@ test('plan mode binds OpenAI review and denies planned edits without review', as const session = 'plan-review-session'; const modes = new PermissionModes(async () => ({})); const prompt = (mode: string) => - modes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: session, - permission_mode: mode, - prompt: 'review', - }); + modes.recordModSession(session, { permissionMode: mode as 'default' }); const contexts: (ApprovalContext | undefined)[] = []; const bridge = new NativeApprovalBridge(async (_input, _signal, context) => { contexts.push(context); diff --git a/test/unit/native-grok-cli.test.ts b/test/unit/native-grok-cli.test.ts index 0b75c5b..36d97c7 100644 --- a/test/unit/native-grok-cli.test.ts +++ b/test/unit/native-grok-cli.test.ts @@ -180,6 +180,19 @@ test('tolerates the toolset growing as MCP servers connect', async (t) => { assert.equal(output.response, 'STORED'); }); +test('fails closed on an announced tool that is neither granted nor MCP', async (t) => { + const cli = await fakeCli(t); + // A native tool this build adds later is not on the forbidden list, but it was never granted. + await assert.rejects( + replay(cli, 'text-only.jsonl', { + tools: ['read_file', 'list_dir', 'grep'], + forbiddenTools: [], + }), + (error: unknown) => + isCliError(error) && error.code === 'policy' && /remains available/.test(error.message), + ); +}); + test('never infers a result when the stream stops without its terminal event', async (t) => { const cli = await fakeCli(t); await assert.rejects( @@ -194,13 +207,14 @@ test('sends explicit flags and refuses to both create and resume a session', asy const mode: GrokPermissionMode = 'plan'; await replay( cli, - 'text-only.jsonl', + // The announced toolset must be the requested one, so the fixture's own tools are granted. + 'session-create.jsonl', { model: 'grok-4.6', effort: 'high', mode, session: 'f6e4b375-8213-4b5e-993d-f546b91362e4', - tools: ['read_file', 'grep'], + tools: ['read_file', 'list_dir', 'grep'], disallowedTools: ['search_tool', 'use_tool'], allow: ['Read(**)'], deny: ['Bash(*)', 'MCPTool(*)'], @@ -217,7 +231,7 @@ test('sends explicit flags and refuses to both create and resume a session', asy assert.equal(args[args.indexOf('--permission-mode') + 1], 'plan'); assert.equal(args[args.indexOf('--session-id') + 1], 'f6e4b375-8213-4b5e-993d-f546b91362e4'); assert.equal(args.includes('--resume'), false); - assert.equal(args[args.indexOf('--tools') + 1], 'read_file,grep'); + assert.equal(args[args.indexOf('--tools') + 1], 'read_file,list_dir,grep'); assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'search_tool,use_tool'); assert.deepEqual( args.flatMap((value, index) => (args[index - 1] === '--deny' ? [value] : [])), @@ -279,6 +293,42 @@ test('passes an oversized prompt through a file and removes it afterwards', asyn await assert.rejects(stat(args[1])); }); +async function grokSpawnArguments( + launcher: string, + prompt: string, + platform: NodeJS.Platform, +): Promise { + let spawned: readonly string[] = []; + await runGrok({ + cwd: path.dirname(launcher), + executable: launcher, + prompt, + platform, + signal: AbortSignal.timeout(2000), + spawn: ((_command: string, args?: readonly string[]) => { + spawned = args ?? []; + throw Object.assign(new Error('stop'), { code: 'ENOENT' }); + }) as unknown as GrokRunOptions['spawn'], + }).catch(() => undefined); + return spawned; +} + +test('a prompt with cmd metacharacters goes through a file on a non-shim .cmd launcher on Windows', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'grok-cmd-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const launcher = path.join(directory, 'grok.cmd'); + await writeFile(launcher, '@echo off\r\nrem not an npm shim\r\n'); + for (const prompt of ['say "hi"', '100% done', 'a & b']) { + const spawned = await grokSpawnArguments(launcher, prompt, 'win32'); + assert.equal(spawned.join(' ').includes('--prompt-file'), true); + assert.equal(spawned.join(' ').includes(prompt), false); + } + const plain = await grokSpawnArguments(launcher, 'plain', 'win32'); + assert.equal(plain.join(' ').includes('-p plain'), true); + const posix = await grokSpawnArguments(launcher, 'say "hi"', 'linux'); + assert.deepEqual(posix.slice(0, 2), ['-p', 'say "hi"']); +}); + test('refuses unreadable output and bounds what it buffers', async (t) => { const cli = await fakeCli(t); await assert.rejects( diff --git a/test/unit/native-grok-gateway.test.ts b/test/unit/native-grok-gateway.test.ts index 954f804..9942134 100644 --- a/test/unit/native-grok-gateway.test.ts +++ b/test/unit/native-grok-gateway.test.ts @@ -47,12 +47,7 @@ async function gateway(t: test.TestContext, handle: Parameters ({})); - await value.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'test-session', - permission_mode: 'auto', - prompt: 'run the tests', - }); + value.recordModSession('test-session', { permissionMode: 'auto' }); return value; } diff --git a/test/unit/native-grok-harness.test.ts b/test/unit/native-grok-harness.test.ts index 17765b4..db4cb7d 100644 --- a/test/unit/native-grok-harness.test.ts +++ b/test/unit/native-grok-harness.test.ts @@ -240,7 +240,8 @@ test('summarizes native tool activity as text instead of replaying it', async (t // An ordinary tool error is not a permission decision and must not read as one. assert.match(text(response), /tool \(failed: Error: note\.txt does not exist\)/); assert.match(text(response), /I could not run it\./); - assert.match(text(response), /\$0\.0175 billed/); + // Timing and cost belong to receipts, not to the text the model reads back. + assert.doesNotMatch(text(response), /billed|completed in/); }); test('replays a completed identical request instead of running it twice', async (t) => { @@ -545,3 +546,36 @@ async function session(stateDirectory: string) { assert.notEqual(file, undefined); return JSON.parse(await readFile(path.join(stateDirectory, String(file)), 'utf8')); } + +test('only end_turn is a finished answer; a cancelled turn resumes with the interruption notice', async (t) => { + const { stateDirectory, calls } = await setup(t); + const harness = new GrokHarness([model], { + stateDirectory, + checkPermissions: policy, + run: async (options) => { + calls.push(options); + return echoSession(options, calls.length === 1 ? { stopReason: 'cancelled' } : {}); + }, + }); + t.after(() => harness.close()); + await assert.rejects(ask(harness, 'work'), /stopped before finishing.*cancelled/); + await ask(harness, 'work'); + assert.equal(calls.length, 2); + assert.match(calls[1].prompt, /previous turn was interrupted/); +}); + +test('an idle record is evicted after its turn so its lock is not held until shutdown', async (t) => { + const { stateDirectory } = await setup(t); + const make = () => + new GrokHarness([model], { + stateDirectory, + checkPermissions: policy, + run: async (options) => echoSession(options), + }); + const first = make(); + t.after(() => first.close()); + const second = make(); + t.after(() => second.close()); + await ask(first, 'one'); + await ask(second, 'two'); +}); diff --git a/test/unit/native-harness-cli.test.ts b/test/unit/native-harness-cli.test.ts new file mode 100644 index 0000000..8949365 --- /dev/null +++ b/test/unit/native-harness-cli.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { boundedWait, harnessFailure } from '../../plugins/multi-core/src/gateway/harness-cli.ts'; +import { HarnessBusyError } from '../../plugins/multi-core/src/gateway/harness-session.ts'; + +test('boundedWait returns at its timeout without rejecting a slow operation', async () => { + const started = Date.now(); + await boundedWait(new Promise(() => undefined), 20); + assert(Date.now() - started < 1000); + await boundedWait(Promise.resolve('done'), 20); +}); + +test('harnessFailure caps one line, appends advice, and applies the shared status policy', () => { + const busy = harnessFailure(new HarnessBusyError('agent already running\nnow'), 'unknown X'); + assert.equal(busy.status, 400); + assert.equal(busy.message.includes('\n'), false); + const advised = harnessFailure(new Error('boom'), 'unknown X', () => 'Try again.'); + assert.deepEqual(advised, { status: 502, message: 'boom Try again.' }); + assert.equal(harnessFailure(undefined, 'unknown X').message, 'unknown X'); + assert.equal(harnessFailure(new Error('x'.repeat(900)), 'unknown X').message.length, 500); +}); diff --git a/test/unit/native-harness-completion-prune.test.ts b/test/unit/native-harness-completion-prune.test.ts new file mode 100644 index 0000000..5007e69 --- /dev/null +++ b/test/unit/native-harness-completion-prune.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, utimes, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + archiveHarnessReply, + pruneArchives, +} from '../../plugins/multi-core/src/gateway/harness-completion.ts'; +import type { + HarnessSession, + HarnessSessionBase, +} from '../../plugins/multi-core/src/gateway/harness-session.ts'; +import { removeTemporary } from '../temporary.ts'; + +async function seedArchives(directory: string, count: number): Promise { + for (let index = 0; index < count; index += 1) { + const file = path.join(directory, `old${index}.response.json`); + await writeFile(file, '{}'); + const seconds = 1_000_000 + index; + await utimes(file, seconds, seconds); + } + await writeFile(path.join(directory, 'keep.session.json'), '{}'); +} + +test('pruneArchives keeps the newest archives and never touches other files', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'archive-prune-')); + t.after(() => removeTemporary(directory)); + await seedArchives(directory, 10); + await pruneArchives(directory, { + keep: 4, + maxAgeMs: Number.MAX_SAFE_INTEGER, + now: 2_000_000_000, + }); + const left = (await readdir(directory)).sort(); + assert.deepEqual(left, [ + 'keep.session.json', + 'old6.response.json', + 'old7.response.json', + 'old8.response.json', + 'old9.response.json', + ]); +}); + +test('pruneArchives drops archives older than the age limit', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'archive-age-')); + t.after(() => removeTemporary(directory)); + await seedArchives(directory, 3); + // mtimes are in seconds; `now` is in milliseconds. + await pruneArchives(directory, { keep: 10, maxAgeMs: 1000, now: 1_000_100_000 }); + assert.deepEqual(await readdir(directory), ['keep.session.json']); +}); + +test('archiveHarnessReply writes the archive and then prunes the directory', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'archive-turn-')); + t.after(() => removeTemporary(directory)); + await seedArchives(directory, 5); + const key = 'f'.repeat(64); + const session = { + saved: { replay: { key, events: [] }, response: { id: 'x' } }, + } as unknown as HarnessSession; + await archiveHarnessReply({ + session, + stateDirectory: directory, + platform: 'linux', + keep: 2, + maxAgeMs: Number.MAX_SAFE_INTEGER, + }); + const left = (await readdir(directory)).filter((name) => name.endsWith('.response.json')); + assert.equal(left.length, 2); + assert.ok(left.includes(`${key}.response.json`)); +}); diff --git a/test/unit/native-harness-failure.test.ts b/test/unit/native-harness-failure.test.ts new file mode 100644 index 0000000..1e657b1 --- /dev/null +++ b/test/unit/native-harness-failure.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { classifyHarnessFailure } from '../../plugins/multi-core/src/gateway/harness-failure.ts'; +import { NativeCliError } from '../../plugins/multi-core/src/gateway/harness-process.ts'; +import { HarnessBusyError } from '../../plugins/multi-core/src/gateway/harness-session.ts'; + +const status = (error: unknown) => classifyHarnessFailure(error).status; + +test('configuration and deterministic failures are request errors', () => { + assert.equal(status(new HarnessBusyError('busy')), 400); + assert.equal(status(new NativeCliError('policy', 'policy')), 400); + assert.equal(status(new NativeCliError('gone', 'spawn', { systemCode: 'ENOENT' })), 400); + assert.equal(status(new NativeCliError('denied', 'spawn', { systemCode: 'EACCES' })), 400); + assert.equal(status(Object.assign(new Error('Executable not found'), { code: 'ENOENT' })), 400); + assert.equal(status(Object.assign(new Error('unsafe'), { code: 'EUNSAFEARG' })), 400); +}); + +test('transient and uncertain failures stay retryable 502s', () => { + for (const systemCode of ['EAGAIN', 'EMFILE', 'ENOMEM']) { + assert.equal(status(new NativeCliError('busy host', 'spawn', { systemCode })), 502); + } + for (const code of ['no_terminal_result', 'aborted', 'output_limit', 'parse']) { + assert.equal(status(new NativeCliError('x', code)), 502); + } + assert.equal(status(new Error('unknown')), 502); + assert.equal(classifyHarnessFailure(new Error('x')).deterministic, false); +}); diff --git a/test/unit/native-harness-handback.test.ts b/test/unit/native-harness-handback.test.ts new file mode 100644 index 0000000..2b04260 --- /dev/null +++ b/test/unit/native-harness-handback.test.ts @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + withHandback, + withoutHarnessHandback, +} from '../../plugins/multi-core/src/gateway/harness-handback.ts'; +import { + continuation, + harnessHistoryHash, + historyRewound, +} from '../../plugins/multi-core/src/gateway/harness-notices.ts'; +import { HarnessResponse } from '../../plugins/multi-core/src/gateway/harness-response.ts'; +import type { + MessagesRequest, + MessagesResponse, +} from '../../plugins/multi-core/src/gateway/messages.ts'; +import type { NativeHarness } from '../../plugins/multi-core/src/gateway/native-harness.ts'; +import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; + +const model = 'multi/cursor/test'; +const handback = { name: 'SubagentHandback', input_schema: { type: 'object' } }; +const safeguards = [ + { type: 'dangerous_tool_use', classifier_context: { permission_mode: 'auto' } }, +]; + +async function gateway(t: test.TestContext, rows: boolean) { + let runs = 0; + let saved: MessagesResponse | undefined; + const inputs: MessagesRequest[] = []; + const harness: NativeHarness = { + validate: () => 1, + async handle(body, _scope, _signal, emit) { + inputs.push(body); + runs++; + const response = new HarnessResponse(model, 1, emit ?? (() => {}), body.safeguards); + response.text(`[Cursor] notice\nreport ${runs}`); + if (rows) { + response.displayRow({ + type: 'tool_use', + id: 'toolu_multi_12345678901234567890123456789012', + name: 'mcp__multi-core__run_command', + input: {}, + }); + response.text(`\nfinal ${runs}`); + } + saved = response.finish(undefined); + for (const [name, value] of response.takeTerminalEvents()) { + emit?.(name, value); + } + return saved; + }, + async recordedResponse() { + return saved; + }, + }; + const server = createNativeGateway({ + token: 'handback-test', + authFile: 'unused', + cursor: harness, + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const send = (messages: MessagesRequest['messages'], offered = true, stream = false) => + fetch(`http://127.0.0.1:${address.port}/v1/messages`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-multi-gateway-token': 'handback-test', + 'x-claude-code-session-id': 'handback-session', + 'x-claude-code-agent-id': 'worker', + }, + body: JSON.stringify({ + model, + messages, + tools: offered ? [handback] : [], + stream, + safeguards, + }), + }); + return { send, inputs, runs: () => runs, saved: () => saved }; +} + +function delivery(response: MessagesResponse) { + const block = response.content.at(-1); + assert.equal(block?.type, 'tool_use'); + assert.equal(block.name, 'SubagentHandback'); + assert.equal(response.stop_reason, 'tool_use'); + assert.deepEqual(response.safeguard_results?.[0].status.tool_uses[block.id], { + type: 'unavailable', + reason: 'error', + }); + return block; +} + +function streamEvents(raw: string) { + return raw + .split('\n\n') + .filter((part) => part.startsWith('event: ')) + .map((part) => JSON.parse(part.slice(part.indexOf('data: ') + 6)) as Record); +} + +test('a harness answer without rows sends its full report through handback in JSON and SSE', async (t) => { + const source = await gateway(t, false); + const first = (await ( + await source.send([{ role: 'user', content: 'work' }]) + ).json()) as MessagesResponse; + const block = delivery(first); + assert.deepEqual(block.input, { message: '[Cursor] notice\nreport 1' }); + assert.deepEqual(source.saved()?.content, [{ type: 'text', text: '[Cursor] notice\nreport 1' }]); + assert.equal( + source.inputs[0]?.tools?.length, + 0, + 'the native harness never sees the delivery tool', + ); + + const streamed = await source.send([{ role: 'user', content: 'stream' }], true, true); + const events = streamEvents(await streamed.text()); + assert.equal(events.filter((event) => event.type === 'message_delta').length, 1); + const terminal = events.find((event) => event.type === 'message_delta'); + assert.ok(terminal); + assert.equal((terminal.delta as { stop_reason: string }).stop_reason, 'tool_use'); + assert.equal(events.filter((event) => event.type === 'content_block_start').length, 2); + const inputDelta = events.find( + (event) => + event.type === 'content_block_delta' && + (event.delta as { type?: string }).type === 'input_json_delta', + ); + assert.ok(inputDelta); + assert.deepEqual(JSON.parse((inputDelta.delta as { partial_json: string }).partial_json), { + message: '[Cursor] notice\nreport 2', + }); +}); + +test('rows defer the delivery to the follow-up, including streamed follow-ups', async (t) => { + const source = await gateway(t, true); + const first = (await ( + await source.send([{ role: 'user', content: 'work' }]) + ).json()) as MessagesResponse; + assert.equal(first.stop_reason, 'end_turn'); + assert.equal( + first.content.some((block) => block.type === 'tool_use' && block.name === 'SubagentHandback'), + false, + ); + const row = first.content.find((block) => block.type === 'tool_use'); + assert.ok(row?.type === 'tool_use'); + const messages = [ + { role: 'user', content: 'work' }, + { role: 'assistant', content: first.content }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: row.id, content: 'done' }] }, + ]; + const followed = (await (await source.send(messages)).json()) as MessagesResponse; + assert.equal((delivery(followed).input as { message: string }).message, '\nfinal 1'); + const events = streamEvents(await (await source.send(messages, true, true)).text()); + const terminal = events.find((event) => event.type === 'message_delta'); + assert.ok(terminal); + assert.equal((terminal.delta as { stop_reason: string }).stop_reason, 'tool_use'); + assert.equal(events.filter((event) => event.type === 'content_block_start').length, 2); + assert.equal(source.runs(), 1); + + const recorded = source.saved(); + const handbackBlock = delivery(followed); + await source.send([ + ...messages, + { role: 'assistant', content: followed.content }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: handbackBlock.id, content: 'delivered' }], + }, + { role: 'user', content: 'next task' }, + ]); + const resumed = source.inputs.at(-1); + assert.ok(resumed); + assert.equal( + historyRewound({ response: recorded }, resumed.messages ?? [], harnessHistoryHash), + false, + ); + assert.deepEqual(continuation(resumed, 'Cursor'), [ + { role: 'user', content: [{ type: 'text', text: 'next task' }] }, + ]); + assert.equal(source.runs(), 2); +}); + +test('handback result and enforcement bounce are answered locally; resume strips delivery history', async (t) => { + const source = await gateway(t, false); + const first = (await ( + await source.send([{ role: 'user', content: 'work' }]) + ).json()) as MessagesResponse; + const block = delivery(first); + const history = [ + { role: 'user', content: 'work' }, + { role: 'assistant', content: first.content }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: block.id, content: 'delivered' }], + }, + ]; + const resultOnly = (await (await source.send(history)).json()) as MessagesResponse; + assert.equal( + (delivery(resultOnly).input as { message: string }).message, + '[Cursor] notice\nreport 1', + ); + const bounced = (await ( + await source.send([ + { role: 'user', content: 'work' }, + { role: 'user', content: '[handback-send-enforce] Your report has not been delivered.' }, + ]) + ).json()) as MessagesResponse; + assert.equal( + (delivery(bounced).input as { message: string }).message, + '[Cursor] notice\nreport 1', + ); + assert.equal(source.runs(), 1); + + const recorded = source.saved(); + await source.send([...history, { role: 'user', content: 'next task' }]); + const resumed = source.inputs.at(-1); + assert.ok(resumed); + assert.equal( + historyRewound({ response: recorded }, resumed.messages ?? [], harnessHistoryHash), + false, + ); + assert.deepEqual(continuation(resumed, 'Cursor'), [ + { role: 'user', content: [{ type: 'text', text: 'next task' }] }, + ]); + assert.equal(JSON.stringify(resumed.messages).includes('SubagentHandback'), false); + assert.equal(JSON.stringify(resumed.messages).includes('tool_result'), false); + assert.equal(source.runs(), 2); +}); + +test('without an offered handback tool, the harness answer stays an end turn', async (t) => { + const source = await gateway(t, false); + const response = (await ( + await source.send([{ role: 'user', content: 'work' }], false) + ).json()) as MessagesResponse; + assert.equal(response.stop_reason, 'end_turn'); + assert.deepEqual(response.content, [{ type: 'text', text: '[Cursor] notice\nreport 1' }]); +}); + +test('an enforcement bounce with no recorded answer fails before native dispatch', async (t) => { + const source = await gateway(t, false); + const reply = await source.send([ + { role: 'user', content: '[handback-send-enforce] Call SubagentHandback now.' }, + ]); + assert.equal(reply.status, 400); + assert.match(await reply.text(), /No recorded harness report/); + assert.equal(source.runs(), 0); +}); + +test('an empty native answer gets a report and remains a resumable assistant turn', () => { + const recorded: MessagesResponse = { + id: 'msg_empty', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 0 }, + }; + const delivered = withHandback(recorded, '', safeguards); + const block = delivery(delivered); + assert.deepEqual(block.input, { message: 'Native run finished.' }); + const next = withoutHarnessHandback({ + model, + tools: [handback], + messages: [ + { role: 'user', content: 'work' }, + { role: 'assistant', content: delivered.content }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: block.id, content: 'done' }] }, + { role: 'user', content: 'next' }, + ], + }); + assert.equal( + historyRewound({ response: recorded }, next.messages ?? [], harnessHistoryHash), + false, + ); + assert.deepEqual(continuation(next, 'Cursor'), [ + { role: 'user', content: [{ type: 'text', text: 'next' }] }, + ]); +}); diff --git a/test/unit/native-harness-process.test.ts b/test/unit/native-harness-process.test.ts index 2c75898..cea5464 100644 --- a/test/unit/native-harness-process.test.ts +++ b/test/unit/native-harness-process.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import type { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; import test from 'node:test'; @@ -11,6 +13,7 @@ import { NativeCliError, nativeEnvironment, promptArgumentLimitBytes, + promptArgvSafe, redactStderr, runNativeCli, terminateGraceMs, @@ -182,7 +185,7 @@ test('an oversized stream stops the run instead of buffering it', async (t) => { await assert.rejects(run, (error: unknown) => { assert.ok(error instanceof NativeCliError); assert.equal(error.code, 'output_limit'); - assert.match(error.message, /Native stdout exceeded its safety limit/); + assert.match(error.message, /Native stdout line exceeded its safety limit/); return true; }); }); @@ -293,3 +296,53 @@ test('platform limits and a native environment keep other providers out', () => ); assert.equal(new NativeCliError('bare', 'parse').systemCode, undefined); }); + +test('many small lines past the byte limit are not a failure', async () => { + const child = new FakeChild(); + const { run, parser } = start(child, { maxOutputBytes: 256 }); + await flush(); + for (let index = 0; index < 50; index += 1) { + child.stdout.write('{"type":"text","id":"a"}\n'); + child.stderr.write(`${'e'.repeat(100)}\n`); + } + child.stdout.write('{"type":"end","id":"long"}\n'); + await flush(); + child.emit('close', 0, null); + const outcome = await run; + assert.deepEqual(outcome.result, { id: 'long' }); + assert.equal(parser.lines.length, 51); + assert.ok(outcome.stderr.length <= 256); +}); + +test('after the child closes only the process group is signaled, never the bare PID', async (t) => { + const targets: number[] = []; + t.mock.method(process, 'kill', (pid: number) => { + targets.push(pid); + }); + const child = new FakeChild(); + const { run } = start(child); + await flush(); + child.emit('close', 0, null); + await run.catch(() => undefined); + assert.deepEqual(targets, [-4242]); +}); + +test('promptArgvSafe routes unsafe prompts away from cmd launchers only', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'argv-safe-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const launcher = path.join(directory, 'agy.cmd'); + await writeFile(launcher, '@echo off\r\nrem not an npm shim\r\n'); + const shim = path.join(directory, 'shim.cmd'); + await writeFile(shim, '"%dp0%\\cli.js" %*\r\n'); + await writeFile(path.join(directory, 'cli.js'), ''); + const unsafe = '100% "quoted" & more'; + const win = { executable: 'agy', platform: 'win32' as const, env: {} }; + assert.equal(promptArgvSafe(unsafe, { ...win, configuredPath: launcher }), false); + assert.equal(promptArgvSafe('plain words', { ...win, configuredPath: launcher }), true); + assert.equal(promptArgvSafe(unsafe, { ...win, configuredPath: process.execPath }), true); + assert.equal(promptArgvSafe(unsafe, { ...win, env: { PATH: '' } }), true); + assert.equal( + promptArgvSafe(unsafe, { ...win, platform: 'linux', configuredPath: launcher }), + true, + ); +}); diff --git a/test/unit/native-harness-progress.test.ts b/test/unit/native-harness-progress.test.ts index f29b3af..6b46166 100644 --- a/test/unit/native-harness-progress.test.ts +++ b/test/unit/native-harness-progress.test.ts @@ -16,6 +16,7 @@ import { } from '../../plugins/multi-antigravity/src/progress.ts'; import { DisplayRows, ROW_TOKEN } from '../../plugins/multi-core/src/gateway/display-rows.ts'; import { + maximumTracked, NativeActionTracker, type NativeObservation, } from '../../plugins/multi-core/src/gateway/harness-progress.ts'; @@ -397,12 +398,12 @@ async function gateway(t: test.TestContext, options: Parameters response.json())) as Record, /** What the mod does at session start: read the catalog, register, acknowledge. */ - register: async () => { + register: async (sessionId = 'progress-session') => { const catalog = (await fetch(`${base}/multi/mod/display-tools`, { headers }).then( (response) => response.json(), )) as { names: string[] }; await post('/multi/mod/display-tools', { - sessionId: 'progress-session', + sessionId, registered: catalog.names, }); return catalog.names; @@ -766,7 +767,8 @@ test('a follow-up is answered only to the session and worker whose reply wrote t displayTools: { antigravity: ANTIGRAVITY_TOOLS }, antigravity: admitted(harness), }); - await register(); + await register('session-a'); + await register('session-b'); const answer = (await ( await send(agyModel.model, 'agy-worker', undefined, 'session-a') ).json()) as MessagesResponse; @@ -825,7 +827,8 @@ test('a follow-up is recovered from the harness record after a restart, and fail displayTools: { antigravity: ANTIGRAVITY_TOOLS }, antigravity: admitted(first), }); - await before.register(); + await before.register('session-a'); + await before.register('session-b'); const answer = (await ( await before.send(agyModel.model, 'agy-worker', undefined, 'session-a') ).json()) as MessagesResponse; @@ -889,3 +892,24 @@ test('an action whose completion never arrives is unconfirmed: no success row, n assert.match(summary, /\[Native\] 2 native actions: 1 read, 1 edit\.\n/); assert.match(summary, /\[Native\] Unconfirmed: 1 action ended without a reported outcome\.\n/); }); + +test('actions past the tracking cap are counted and the summary says it truncated', () => { + const tracker = new NativeActionTracker('Test'); + const total = maximumTracked + 3; + for (let index = 0; index < total; index += 1) { + tracker.start(`a${index}`, { kind: 'read', tool: 'Read', input: {}, description: 'file' }); + tracker.finish(`a${index}`, { outcome: 'ok', error: false }); + } + const text = tracker.text(); + assert.match(text, new RegExp(`${maximumTracked} native actions: ${maximumTracked} read`)); + assert.match(text, /Truncated: 3 further actions beyond the first 512/); +}); + +test('a summary with only untracked actions still reports truncation', () => { + const tracker = new NativeActionTracker('Test'); + for (let index = 0; index < maximumTracked; index += 1) { + tracker.start(`a${index}`, { kind: 'read', tool: 'Read', input: {}, description: 'f' }); + } + tracker.start('extra', { kind: 'edit', tool: 'Edit', input: {}, description: 'f' }); + assert.match(tracker.text(), /Truncated: 1 further action beyond/); +}); diff --git a/test/unit/native-harness-session.test.ts b/test/unit/native-harness-session.test.ts index bc4e2f9..d4e4591 100644 --- a/test/unit/native-harness-session.test.ts +++ b/test/unit/native-harness-session.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -10,7 +10,6 @@ import { type HarnessSessionBase, HarnessSessionStore, isHash, - isRecord, optionalCount, readJson, validMessagesResponse, @@ -18,6 +17,7 @@ import { validReplay, } from '../../plugins/multi-core/src/gateway/harness-session.ts'; import type { MessagesResponse } from '../../plugins/multi-core/src/gateway/messages.ts'; +import { isRecord } from '../../plugins/multi-core/src/gateway/record.ts'; type Saved = HarnessSessionBase & { version: 1; sessionId?: string }; @@ -414,3 +414,25 @@ test('persisted state is validated before it is trusted', async (t) => { await writeFile(file, '{oops', 'utf8'); await assert.rejects(readJson(file)); }); + +test('an unsupported-version record is copied aside before the fresh record replaces it', async (t) => { + const { made, directory } = await store(t); + const file = made.sessionFile('worker-future'); + const original = JSON.stringify({ + version: 2, + provider: 'native', + identity: 'worker-future', + interrupted: false, + sessionId: 'native-from-the-future', + }); + await writeFile(file, original); + const lease = await made.acquireLease('worker-future'); + assert.equal(lease.session.saved.version, 1); + assert.equal(lease.session.saved.sessionId, undefined); + lease.session.saved.sessionId = 'fresh'; + await made.save(lease.session); + await lease.release(); + assert.equal(await readFile(`${file}.unsupported-v2`, 'utf8'), original); + assert.equal(JSON.parse(await readFile(file, 'utf8')).sessionId, 'fresh'); + assert.ok((await readdir(directory)).includes(path.basename(`${file}.unsupported-v2`))); +}); diff --git a/test/unit/native-install.test.ts b/test/unit/native-install.test.ts index 801325d..6ec73c9 100644 --- a/test/unit/native-install.test.ts +++ b/test/unit/native-install.test.ts @@ -179,7 +179,10 @@ test('wrapper follows installed core updates and enables only selected providers await f.install(); const old = await core(f.directory, 'core-v1'); await writeFile(f.listing, JSON.stringify(plugins(old))); - const args = ['--settings', '{"model":"sonnet"}', '--', 'literal $() and spaces']; + // The Windows fake Claude is a .cmd file, and cmd.exe cannot carry a `"` to it: the + // launcher refuses that argument, so this passthrough uses a settings file path. + const settings = f.windows ? 'C:\\settings dir\\multi.json' : '{"model":"sonnet"}'; + const args = ['--settings', settings, '--', 'literal $() and spaces']; const first = JSON.parse((await f.invoke('claude-multi', args)).stdout); assert.deepEqual(first.args, args); assert.equal(first.providers, 'zen'); diff --git a/test/unit/native-launcher-exit.test.ts b/test/unit/native-launcher-exit.test.ts new file mode 100644 index 0000000..bf68b4e --- /dev/null +++ b/test/unit/native-launcher-exit.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { removeTemporary } from '../temporary.ts'; + +test('a Claude that exits before the mod acknowledges ends the launch with its own exit code', { + skip: process.platform === 'win32', +}, async (t) => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'launcher-early-exit-')); + t.after(() => removeTemporary(cwd)); + await mkdir(path.join(cwd, 'bin')); + await writeFile( + path.join(cwd, 'bin', 'claude'), + `#!/usr/bin/env node +const args=process.argv.slice(2); +if(args.includes('plugin')&&args.includes('list')){console.log('not json');process.exit(1)} +if(args[0]==='--version'){console.log('2.1.287');process.exit(0)} +if(args[0]==='auth'){console.log(JSON.stringify({loggedIn:true}));process.exit(0)} +process.exit(7); +`, + { mode: 0o755 }, + ); + const launcher = fileURLToPath( + new URL('../../plugins/multi-core/src/launcher.ts', import.meta.url), + ); + const started = Date.now(); + const result = await promisify(execFile)(process.execPath, [launcher, '--', '--version'], { + cwd, + timeout: 20000, + env: { + PATH: path.join(cwd, 'bin') + path.delimiter + process.env.PATH, + HOME: cwd, + CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'), + CODEX_HOME: cwd, + MULTI_ENABLED_PROVIDERS: '', + ANTHROPIC_BASE_URL: 'https://corp.example/anthropic', + }, + }).then( + () => ({ code: 0, stderr: '' }), + (error: { code: number; stderr: string }) => ({ code: error.code, stderr: error.stderr }), + ); + assert.equal(result.code, 7); + assert(Date.now() - started < 15000, 'did not wait for the acknowledgement timeout'); + assert(!result.stderr.includes('did not acknowledge')); + // A failing plugin listing does not abort the launch; a caller base URL is accepted. + assert.match(result.stderr, /could not list Claude plugins/); +}); diff --git a/test/unit/native-launcher.test.ts b/test/unit/native-launcher.test.ts index 13a986d..bc57138 100644 --- a/test/unit/native-launcher.test.ts +++ b/test/unit/native-launcher.test.ts @@ -6,7 +6,6 @@ import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import { AgentCatalog } from '../../plugins/multi-core/src/gateway/agent-catalog.ts'; import { checkLauncherArgumentLimit, workerCatalog, @@ -56,7 +55,7 @@ if(args[0]==='--version'){console.log(process.env.TEST_CLAUDE_VERSION??'2.1.272' const result=(value)=>{const base=process.env.MULTI_MOD_GATEWAY_URL;if(!base){console.log(value);return}const url=new URL(base+'/multi/mod/session');const req=require('node:http').request(url,{method:'POST',headers:{'content-type':'application/json','x-multi-gateway-token':process.env.MULTI_GATEWAY_TOKEN}},()=>console.log(value));req.on('error',()=>console.log(value));req.end(JSON.stringify({sessionId:'fixture',event:'start'}));}; if(args[0]==='auth'){if(process.env.TEST_AUTH==='malformed'){console.log('not-json');process.exit(0)}if(process.env.TEST_AUTH==='error'){process.exit(2)}if(process.env.TEST_AUTH==='missing'){console.log('{}');process.exit(0)}process.stdout.write(JSON.stringify({loggedIn:process.env.TEST_AUTH==='yes'}));process.exitCode=process.env.TEST_AUTH==='yes'?0:1}else{ const settings=JSON.parse(fs.readFileSync(args[args.indexOf('--settings')+1],'utf8')); - result(JSON.stringify({agentView:process.env.CLAUDE_CODE_DISABLE_AGENT_VIEW,backgroundTasks:process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS,functionHooks:process.env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS,settings,models:args.filter(x=>x.startsWith('multi/')),args,settingsCount:args.filter(x=>x==='--settings').length,hasLocalToken:!!process.env.MULTI_GATEWAY_TOKEN,apiTimeout:process.env.API_TIMEOUT_MS,toolSearch:process.env.ENABLE_TOOL_SEARCH,auth:process.env.ANTHROPIC_API_KEY?'api':process.env.ANTHROPIC_AUTH_TOKEN?'local':'native'}));} + result(JSON.stringify({firstPath:(process.env.PATH||'').split(require('node:path').delimiter)[0],agentView:process.env.CLAUDE_CODE_DISABLE_AGENT_VIEW,backgroundTasks:process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS,functionHooks:process.env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS,settings,models:args.filter(x=>x.startsWith('multi/')),args,settingsCount:args.filter(x=>x==='--settings').length,hasLocalToken:!!process.env.MULTI_GATEWAY_TOKEN,apiTimeout:process.env.API_TIMEOUT_MS,toolSearch:process.env.ENABLE_TOOL_SEARCH,auth:process.env.ANTHROPIC_API_KEY?'api':process.env.ANTHROPIC_AUTH_TOKEN?'local':'native'}));} `, ); const launcher = fileURLToPath( @@ -99,7 +98,12 @@ const settings=JSON.parse(fs.readFileSync(args[args.indexOf('--settings')+1],'ut assert.equal(result.args.at(-1), path.resolve(path.dirname(launcher), '../../..')); assert.equal(result.settingsCount, 1); assert.equal(result.settings.disableAgentView, true); - assert.equal(result.agentView, '1'); + assert.equal( + result.agentView, + '0', + 'the setting scopes agent view; the environment is left alone', + ); + assert.match(result.firstPath, /multi-native-settings-[^/\\]+[/\\]bin$/); assert.equal(result.backgroundTasks, undefined); assert.equal(result.functionHooks, '1'); assert.equal( @@ -497,27 +501,6 @@ result(JSON.stringify({settings,agents,args,models:args.filter(x=>x.startsWith(' assert.equal(agents['multi-antigravity'].model, rows[0].model); assert.equal(agents['multi-antigravity'].effort, undefined); assert.match(agents['multi-antigravity'].description, /\(gemini, sonnet-thinking\)/); - const catalog = new AgentCatalog( - agents, - rows.map(({ model }) => model), - ); - const listing = Object.entries(agents) - .map(([name, value]) => { - const worker = value as { description: string; tools: string[] }; - return `- ${name}: ${worker.description} (Tools: ${worker.tools.join(', ')})`; - }) - .join('\n'); - const compacted = JSON.stringify( - catalog.compact({ - messages: [ - { - role: 'user', - content: `\nAvailable agent types for the Agent tool:\n${listing}\n`, - }, - ], - }), - ); - assert.match(compacted, /- multi-antigravity:/); // The opt-out has to stay reversible. A selection saved while rows were tagged is the // spelling the user copied out of the picker, so it must still name a row once the tag @@ -684,3 +667,46 @@ test('the Zen model listing is available without authentication', async () => { const models = JSON.parse(stdout); assert(models.some((model: { id: string }) => model.id === 'minimax-m2.7')); }); + +test('a launch inside another Multi session starts from the user original environment', { + skip: process.platform === 'win32', +}, async (t) => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'launcher-nested-test-')); + t.after(() => removeTemporary(cwd)); + await mkdir(path.join(cwd, 'bin')); + await writeClaudeFixture( + path.join(cwd, 'bin'), + `#!/usr/bin/env node +const args=process.argv.slice(2); +if(args.includes('plugin')&&args.includes('list')){console.log('[]');process.exit(0)} +if(args[0]==='--version'){console.log('2.1.272');process.exit(0)} +if(args[0]==='auth'){process.stdout.write(JSON.stringify({loggedIn:true}));process.exit(0)} +console.log(JSON.stringify({headers:process.env.ANTHROPIC_CUSTOM_HEADERS,orig:process.env.MULTI_ORIG_ANTHROPIC_BASE_URL,origHeaders:process.env.MULTI_ORIG_ANTHROPIC_CUSTOM_HEADERS,base:process.env.ANTHROPIC_BASE_URL})); +`, + ); + const launcher = fileURLToPath( + new URL('../../plugins/multi-core/src/launcher.ts', import.meta.url), + ); + const { stdout } = await promisify(execFile)(process.execPath, [launcher], { + cwd, + timeout: 20000, + env: { + PATH: path.join(cwd, 'bin') + path.delimiter + process.env.PATH, + HOME: cwd, + CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'), + CODEX_HOME: cwd, + // What the outer Multi session left in this process. + MULTI_GATEWAY_TOKEN: 'outer-token', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:1', + ANTHROPIC_CUSTOM_HEADERS: 'x-team: a\nx-multi-gateway-token: outer-token', + MULTI_ORIG_ANTHROPIC_BASE_URL: 'https://corp.example', + MULTI_ORIG_ANTHROPIC_CUSTOM_HEADERS: 'x-team: a', + }, + }); + const seen = JSON.parse(stdout); + assert.match(seen.base, /^http:\/\/127\.0\.0\.1:(?!1$)\d+$/); + assert.equal(seen.orig, 'https://corp.example'); + assert.equal(seen.origHeaders, 'x-team: a'); + assert(seen.headers.startsWith('x-team: a\nx-multi-gateway-token: ')); + assert(!seen.headers.includes('outer-token')); +}); diff --git a/test/unit/native-mod-routes.test.ts b/test/unit/native-mod-routes.test.ts index 4e6af39..b2e6920 100644 --- a/test/unit/native-mod-routes.test.ts +++ b/test/unit/native-mod-routes.test.ts @@ -265,6 +265,22 @@ test('compaction core fallback authenticates generation and removes all native c assert.equal(typeof modes.resolve('s').compaction, 'string'); }); +test('cancelling a main compaction clears its permission boundary', async (t) => { + const modes = new PermissionModes(async () => ({})); + const base = await start(t, modes); + const generation = await admit(base); + const accepted = await request(base, '/multi/mod/compact/authorize', { + sessionId: 's', + generation, + }); + assert.equal(accepted.body.allow, true); + assert.equal(typeof modes.resolve('s').compaction, 'string'); + const cancelled = await request(base, '/multi/mod/compact/cancel', { sessionId: 's' }); + assert.equal(cancelled.body.accepted, true); + assert.equal(modes.resolve('s').compaction, undefined); + assert.notDeepEqual(modes.resolve('s').tools, []); +}); + test('worker route authenticates catalog and generation before child-start acknowledgement', async (t) => { const modes = new PermissionModes(async () => ({ worker: { model: 'multi/cursor/auto', tools: ['Read'] }, @@ -496,3 +512,19 @@ test('wrong tokens cannot update sessions, acknowledge workers, or authorize com assert.equal(rejectedCompaction.status, 401); assert.throws(() => modes.resolve('unauthorized'), /unavailable/); }); + +test('snapshots beyond capacity evict the least recently used idle scope', () => { + const bridge = new ModBridge(); + const effective = { permissionMode: 'default' }; + for (let index = 0; index < 128; index++) { + bridge.recordSession(JSON.stringify([`s${index}`, 'main']), { effective }); + } + const running = JSON.stringify(['s0', 'main']); + bridge.begin(running, 'multi/cursor/auto'); + // Reading s1 makes it recent, so the next oldest idle scope (s2) is evicted. + assert.ok(bridge.mode(JSON.stringify(['s1', 'main']))); + assert.ok(bridge.recordSession(JSON.stringify(['fresh', 'main']), { effective })); + assert.ok(bridge.mode(running), 'a running scope keeps its snapshot'); + assert.ok(bridge.mode(JSON.stringify(['s1', 'main']))); + assert.equal(bridge.mode(JSON.stringify(['s2', 'main'])), undefined); +}); diff --git a/test/unit/native-openai-stream.test.ts b/test/unit/native-openai-stream.test.ts index 08d2fa4..407dbad 100644 --- a/test/unit/native-openai-stream.test.ts +++ b/test/unit/native-openai-stream.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { Emit, StreamEventBody } from '../../plugins/multi-core/src/gateway/messages.ts'; +import { fromResponses, toResponses } from '../../plugins/multi-core/src/gateway/responses.ts'; import { safeguardResults } from '../../plugins/multi-core/src/gateway/safeguards.ts'; -import { fromResponses, toResponses } from '../../plugins/multi-openai/src/responses.ts'; const model = 'multi/openai/gpt-6-astra'; const created = { type: 'response.created', response: { id: 'resp' } }; @@ -386,3 +386,35 @@ test('an early null reasoning snapshot can be replaced by final encrypted state' assert.equal(result.content[0]?.type, 'thinking'); assert.equal(result.stop_reason, 'end_turn'); }); + +test('a stop sequence hit mid-stream still waits for the terminal event to record billed usage', async () => { + const delta = (value: string) => ({ + type: 'response.output_text.delta', + output_index: 0, + delta: value, + }); + const { events, emit } = capture(); + const result = await fromResponses( + stream([ + created, + itemEvent('added', { type: 'message', id: 'msg_a', content: [] }), + delta('prefix'), + delta(' suffix'), + delta(' ignored after the stop'), + terminal([text]), + ]), + model, + emit, + { stopSequences: [' suffix'] }, + ); + assert.equal(result.stop_reason, 'stop_sequence'); + assert.deepEqual(result.content, [{ type: 'text', text: 'prefix' }]); + assert.deepEqual(result.usage, { + input_tokens: 60, + cache_read_input_tokens: 40, + cache_creation_input_tokens: 0, + output_tokens: 20, + }); + assert.equal(result.multi_usage?.source, 'provider'); + assert.equal(events.filter((event) => event.type === 'message_stop').length, 1); +}); diff --git a/test/unit/native-process-tree.test.ts b/test/unit/native-process-tree.test.ts index 47a4c0a..0e4491e 100644 --- a/test/unit/native-process-tree.test.ts +++ b/test/unit/native-process-tree.test.ts @@ -15,13 +15,35 @@ test('terminates POSIX process groups and always attempts the direct PID fallbac }); test('uses taskkill tree termination on Windows', () => { - const calls: number[] = []; + const calls: [number, boolean][] = []; terminateProcessTree(42, { platform: 'win32', kill: () => assert.fail('Windows must not send POSIX signals'), - taskkill: (pid) => calls.push(pid), + taskkill: (pid, force) => calls.push([pid, force]), }); - assert.deepEqual(calls, [42]); + assert.deepEqual(calls, [[42, true]]); +}); + +test('Windows asks politely for SIGINT/SIGTERM and forces only for SIGKILL', () => { + const calls: [number, boolean][] = []; + for (const signal of ['SIGINT', 'SIGTERM', 'SIGKILL'] as const) { + terminateProcessTree(7, { + platform: 'win32', + signal, + taskkill: (pid, force) => calls.push([pid, force]), + }); + } + assert.deepEqual(calls, [ + [7, false], + [7, false], + [7, true], + ]); +}); + +test('groupOnly never signals the bare PID', () => { + const calls: number[] = []; + terminateProcessTree(42, { platform: 'linux', groupOnly: true, kill: (pid) => calls.push(pid) }); + assert.deepEqual(calls, [-42]); }); test('continues direct POSIX cleanup when group cleanup fails', () => { diff --git a/test/unit/native-session-isolation.test.ts b/test/unit/native-session-isolation.test.ts new file mode 100644 index 0000000..e2015f5 --- /dev/null +++ b/test/unit/native-session-isolation.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import type { TestContext } from 'node:test'; +import test from 'node:test'; +import { UnsafeCommandArgumentError } from '../../plugins/multi-core/src/gateway/executable.ts'; +import { ModSessionKeys } from '../../plugins/multi-core/src/gateway/mod-keys.ts'; +import { + nestedClaudeShim, + withPathPrefix, +} from '../../plugins/multi-core/src/gateway/nested-env.ts'; +import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; +import { + describeRunError, + needsEnvProxy, + rememberOriginalEnvironment, + withLoopbackNoProxy, + withoutGateway, +} from '../../plugins/multi-core/src/install/process.ts'; + +test('a nested Claude run gets the session start environment back, not the gateway', () => { + const original = { + ANTHROPIC_BASE_URL: 'https://corp.example', + ANTHROPIC_CUSTOM_HEADERS: 'x-team: a', + }; + const session: NodeJS.ProcessEnv = { + ...rememberOriginalEnvironment(original), + ANTHROPIC_BASE_URL: 'http://127.0.0.1:1', + ANTHROPIC_CUSTOM_HEADERS: 'x-team: a\nx-multi-gateway-token: tok', + ANTHROPIC_AUTH_TOKEN: 'tok', + MULTI_GATEWAY_TOKEN: 'tok', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:1', + KEEP: 'yes', + }; + assert.deepEqual(withoutGateway(session), { + ANTHROPIC_BASE_URL: 'https://corp.example', + ANTHROPIC_CUSTOM_HEADERS: 'x-team: a', + KEEP: 'yes', + }); + // A caller with no base URL of its own ends with none. + const bare = withoutGateway({ + ...rememberOriginalEnvironment({}), + ANTHROPIC_BASE_URL: 'http://127.0.0.1:1', + MULTI_GATEWAY_TOKEN: 'tok', + }); + assert.deepEqual(bare, {}); + const plain = { ANTHROPIC_BASE_URL: 'https://x' }; + assert.equal(withoutGateway(plain), plain); +}); + +test('the nested claude shim quotes its paths and goes first on PATH', () => { + const posix = nestedClaudeShim({ + platform: 'linux', + node: "/opt/no'de", + script: '/p/nested-claude.ts', + claude: '/c/claude', + }); + assert.equal(posix.file, 'claude'); + assert.equal( + posix.content, + `#!/bin/sh\nexec '/opt/no'\\''de' '/p/nested-claude.ts' '/c/claude' "$@"\n`, + ); + assert.equal( + nestedClaudeShim({ platform: 'win32', node: 'n', script: 's', claude: 'c' }).file, + 'claude.cmd', + ); + const windows = nestedClaudeShim({ platform: 'win32', node: 'n', script: 's', claude: 'c' }); + assert.deepEqual( + windows.files.map((entry) => entry.file), + ['claude.cmd', 'claude'], + ); + assert.equal(windows.files[1].content, `#!/bin/sh\nexec 'n' 's' 'c' "$@"\n`); + assert(!windows.files[1].content.includes('\r')); + assert.deepEqual(posix.files, [{ file: posix.file, content: posix.content }]); + assert.equal(withPathPrefix({ PATH: '/a' }, '/s', 'linux').PATH, '/s:/a'); + assert.equal(withPathPrefix({ Path: 'C:\\a' }, 'C:\\s', 'win32').Path, 'C:\\s;C:\\a'); +}); + +test('loopback joins NO_PROXY and no_proxy once when a proxy is configured', () => { + assert.deepEqual(withLoopbackNoProxy({ A: '1' }), { A: '1' }); + const added = withLoopbackNoProxy({ + HTTPS_PROXY: 'http://p', + NO_PROXY: 'corp.example, localhost', + }); + assert.equal(added.NO_PROXY, 'corp.example,localhost,127.0.0.1,::1'); + assert.equal(added.no_proxy, '127.0.0.1,localhost,::1'); + assert.equal(withLoopbackNoProxy(added).NO_PROXY, added.NO_PROXY); +}); + +test('a refused cmd argument is named in an actionable message', () => { + const message = describeRunError(new UnsafeCommandArgumentError('fix a & b')); + assert(message.includes('"fix a & b"')); + assert(message.includes('native installer')); + assert.equal(describeRunError(new Error('plain')), 'plain'); +}); + +test('a proxy in the environment needs Node started with NODE_USE_ENV_PROXY', () => { + assert.equal(needsEnvProxy({ HTTPS_PROXY: 'http://p' }), true); + assert.equal(needsEnvProxy({ https_proxy: 'http://p', NODE_USE_ENV_PROXY: '1' }), false); + assert.equal(needsEnvProxy({}), false); +}); + +async function listen(t: TestContext) { + const server = createNativeGateway({ + token: 'tok', + authFile: '/unused', + guardAuto: true, + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert(address && typeof address !== 'string'); + return (session: string, key?: string, route = 'session') => + fetch(`http://127.0.0.1:${address.port}/multi/mod/${route}`, { + method: 'POST', + headers: { 'x-multi-gateway-token': 'tok', ...(key ? { 'x-multi-mod-key': key } : {}) }, + body: JSON.stringify({ sessionId: session, permissionMode: 'default' }), + }); +} + +test('once the mod presents its session key, the token alone cannot change that session', async (t) => { + const post = await listen(t); + const first = await post('s1'); + const key = first.headers.get('x-multi-mod-key'); + assert(key, 'the first request for a session is handed a key'); + // Until the mod echoes it, requests stay admitted (a mod without the protocol works). + assert.notEqual((await post('s1')).status, 403); + assert.notEqual((await post('s1', key)).status, 403); + // Confirmed: a caller holding only the token, as a Bash `curl` does, is refused. + const bare = await post('s1'); + assert.equal(bare.status, 403); + assert.equal(bare.headers.get('x-multi-mod-key'), null); + assert.equal((await post('s1', 'wrong')).status, 403); + assert.notEqual((await post('s1', key)).status, 403); + // Another session has its own key. + assert.notEqual((await post('s2')).status, 403); +}); + +test('detaching a session drops its key so a reused session ID is issued a fresh one', async (t) => { + const post = await listen(t); + const key = (await post('s1')).headers.get('x-multi-mod-key'); + assert(key); + assert.notEqual((await post('s1', key)).status, 403); + assert.equal((await post('s1')).status, 403); + assert.equal((await post('s1', key, 'detach')).status, 200); + const reused = await post('s1'); + assert.notEqual(reused.status, 403); + assert.notEqual(reused.headers.get('x-multi-mod-key'), null); +}); + +test('minting fake sessions cannot evict a confirmed session key', () => { + const keys = new ModSessionKeys(); + const real = keys.check('real', undefined); + assert(!real.refused && real.issue); + assert.deepEqual(keys.check('real', real.issue), { refused: false }); + for (let index = 0; index < 2048; index++) { + keys.check(`fake-${index}`, undefined); + } + assert.deepEqual(keys.check('real', undefined), { refused: true }); + assert.deepEqual(keys.check('real', real.issue), { refused: false }); +}); + +test('a full table of confirmed sessions still admits a new one by evicting the oldest', () => { + const keys = new ModSessionKeys(); + for (let index = 0; index < 1024; index++) { + const verdict = keys.check(`s-${index}`, undefined); + assert(!verdict.refused && verdict.issue); + keys.check(`s-${index}`, verdict.issue); + } + assert(!keys.check('new', undefined).refused); + assert.equal(keys.check('s-0', undefined).refused, false); + assert.equal(keys.check('s-2', undefined).refused, true); +}); diff --git a/test/unit/native-state-lock.test.ts b/test/unit/native-state-lock.test.ts index 2f0dc3e..ba942ca 100644 --- a/test/unit/native-state-lock.test.ts +++ b/test/unit/native-state-lock.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; import os, { hostname } from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -26,6 +26,20 @@ test('acquires, excludes concurrent owners, and releases a state lock', async (t await nextRelease(); }); +test('waits a bounded time for a live owner when asked to', async (t) => { + const directory = await temporaryDirectory(t, 'multi-wait-lock-'); + const file = path.join(directory, 'hooks.lock'); + const release = await lockStateFile(file); + setTimeout(() => void release(), 100); + const waited = await lockStateFile(file, { waitMs: 2000 }); + await waited(); + const held = await lockStateFile(file); + const started = Date.now(); + await assert.rejects(lockStateFile(file, { waitMs: 150 }), /locked by another gateway/); + assert.ok(Date.now() - started >= 140); + await held(); +}); + test('legacy directory locks remain explicit recovery evidence', async (t) => { const directory = await temporaryDirectory(t, 'multi-legacy-lock-'); const file = path.join(directory, 'session.lock'); @@ -199,3 +213,57 @@ test('an empty marker left by the previous flock-based lock is taken over', asyn assert.match(await readFile(file, 'utf8'), /"pid"/); await release(); }); + +async function writeMarker(file: string, owner: Record) { + await writeFile(file, `${JSON.stringify(owner)}\n`, { mode: 0o600 }); +} + +test('takes over a dead owner recorded under a renamed host (.local vs .lan)', async (t) => { + const directory = await temporaryDirectory(t, 'multi-renamed-host-'); + const file = path.join(directory, 'session.lock'); + await writeMarker(file, { pid: 2147483647, hostname: 'Nicks-Mac.local', token: 'old' }); + const release = await lockStateFile(file, { hostname: 'nicks-mac.lan', machineId: undefined }); + await release(); +}); + +test('a matching machine id outranks a changed hostname, a different one refuses', async (t) => { + const directory = await temporaryDirectory(t, 'multi-machine-id-'); + const file = path.join(directory, 'session.lock'); + await writeMarker(file, { + pid: 2147483647, + hostname: 'Mac-2', + token: 'old', + machine: 'uuid-1', + }); + await assert.rejects( + lockStateFile(file, { hostname: 'Mac-2', machineId: 'uuid-2' }), + /locked by another gateway/, + ); + const release = await lockStateFile(file, { hostname: 'Totally-Different', machineId: 'uuid-1' }); + await release(); +}); + +test('puts back a live marker that a racing takeover moved aside', async (t) => { + const directory = await temporaryDirectory(t, 'multi-takeover-race-'); + const file = path.join(directory, 'session.lock'); + await writeMarker(file, { pid: 2147483647, hostname: hostname(), token: 'dead' }); + const live = { pid: process.pid, hostname: hostname(), token: 'live' }; + let raced = false; + await assert.rejects( + lockStateFile(file, { + machineId: undefined, + maxAttempts: 2, + rename: async (from, to) => { + if (!raced) { + raced = true; + // Another gateway wins the takeover and acquires before our rename lands. + await rm(file); + await writeMarker(file, live); + } + await rename(from, to); + }, + }), + /locked by another gateway/, + ); + assert.equal(JSON.parse(await readFile(file, 'utf8')).token, 'live'); +}); diff --git a/test/unit/native-tool-discovery.test.ts b/test/unit/native-tool-discovery.test.ts index 757c56e..a61ef0e 100644 --- a/test/unit/native-tool-discovery.test.ts +++ b/test/unit/native-tool-discovery.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { toResponses } from '../../plugins/multi-openai/src/responses.ts'; +import { toResponses } from '../../plugins/multi-core/src/gateway/responses.ts'; import { toChat } from '../../plugins/multi-zen/src/chat.ts'; const body = { diff --git a/test/unit/native-usage-menu.test.ts b/test/unit/native-usage-menu.test.ts index 627d5ee..ec74e2b 100644 --- a/test/unit/native-usage-menu.test.ts +++ b/test/unit/native-usage-menu.test.ts @@ -1,9 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -// Load the actual Mod modules without requiring Claude's host-only type package -// in the offline TypeScript project. All runtime imports in these modules are erased types. -const hooksUrl = new URL('../../plugins/multi-core/hooks/usage.ts', import.meta.url); +// The usage pane's hooks run in the engine and are covered by `npm run test:mod` +// (`plugins/multi-core/hooks/tests/usage.test.ts`); the Client module is plain code. const viewUrl = new URL('../../plugins/multi-core/hooks/usage-view.ts', import.meta.url); const dashboard = { updatedAt: 'today', @@ -18,64 +17,6 @@ const dashboard = { ], }; -test('usage client messages refresh props and receipts without losing providers or accepting foreign panes', async () => { - const requests: string[] = []; - const engine = { - env: { - get: async (name: string) => (name === 'MULTI_GATEWAY_TOKEN' ? 'secret' : 'http://localhost'), - }, - session: { id: async () => 'session/one' }, - http: { - fetch: async (url: string) => { - requests.push(url); - const result = url.includes('/receipts?') - ? { - receipts: [ - { - agentId: 'worker', - outcome: 'completed', - time: 'today', - requests: 1, - usage: { input_tokens: 2, output_tokens: 3 }, - }, - ], - } - : dashboard; - return { ok: true, text: JSON.stringify(result) }; - }, - }, - ui: { open: async () => {}, invalidate: () => {} }, - }; - type Result = { props?: { providers: unknown[]; receiptLines?: string[] }; text?: string }; - type Hook = ( - host: typeof engine, - event: Record, - next: () => Promise, - ) => Promise; - const hooks = new Map(); - const module = await import(hooksUrl.href); - module.register((name: string, _filter: unknown, hook: Hook) => hooks.set(name, hook)); - const command = hooks.get('command.run'); - const message = hooks.get('ui.message'); - assert(command && message); - const next = async () => ({}); - await command(engine, { args: '' }, next); - const event = { - requestId: 'multi-usage', - element: 'usage', - module: 'hooks/usage-view.ts', - data: { action: 'refresh' }, - }; - const refreshed = await message(engine, event, next); - assert.deepEqual(refreshed.props?.providers, dashboard.providers); - assert(requests[1].includes('refresh=true&sessionId=session%2Fone')); - const receipts = await message(engine, { ...event, data: { action: 'receipts' } }, next); - assert.deepEqual(receipts.props?.providers, dashboard.providers); - assert.match(receipts.props?.receiptLines?.[0] ?? '', /worker/); - await message(engine, { ...event, requestId: 'another-pane' }, next); - assert.equal(requests.length, 3); -}); - type Element = { type: string; props: Record }; function flatten(element: Element): Element[] { const children = element.props.children; @@ -124,103 +65,3 @@ test('usage Client supports provider navigation, receipts and refresh without mo keyHandler?.({ key: 'r' }); assert.deepEqual(posts.at(-1), { action: 'refresh' }); }); - -test('quota advice is opt-in, session scoped, advisory and removed on detach', async () => { - const module = await import(hooksUrl.href); - let session = 'advice-one'; - let reads = 0; - let available = true; - const engine = { - env: { - get: async (name: string) => (name === 'MULTI_GATEWAY_TOKEN' ? 'secret' : 'http://localhost'), - }, - session: { id: async () => session, model: async () => 'claude-sonnet-5' }, - http: { - fetch: async () => { - reads++; - return { ok: available, text: JSON.stringify(dashboard) }; - }, - }, - ui: { open: async () => {}, invalidate: () => {} }, - }; - type Hook = ( - host: typeof engine, - event: Record, - next: (event: Record) => Promise>, - ) => Promise>; - const hooks = new Map(); - module.register((name: string, filter: unknown, handler?: Hook) => { - hooks.set(name, typeof filter === 'function' ? (filter as Hook) : (handler as Hook)); - }); - const submit = hooks.get('classic.PreToolUse'); - const command = hooks.get('command.run'); - const message = hooks.get('ui.message'); - assert(submit && command && message); - const next = async (event: Record) => ({ - ...event, - additionalContext: ['existing context'], - ask: 'Existing permission review', - }); - const prompt = { - tool: 'Agent', - tool_use_id: 'call-1', - prompt: 'Delegate this task', - model: 'chosen-model', - subagent_type: 'cursor', - }; - assert.deepEqual(await submit(engine, prompt, next), await next(prompt)); - assert.equal(reads, 0); - await command(engine, { args: '' }, next); - const toggle = { - requestId: 'multi-usage', - element: 'usage', - data: { action: 'toggle-quota-advice' }, - }; - const enabled = await message(engine, toggle, next); - assert.equal((enabled.props as { quotaAdviceEnabled: boolean }).quotaAdviceEnabled, true); - const readsBeforeSpawn = reads; - const informed = await submit(engine, prompt, async (event) => { - assert.equal(reads, readsBeforeSpawn + 1); - assert.deepEqual(event, prompt); - return next(event); - }); - assert.equal(informed.model, prompt.model); - assert.equal(informed.prompt, prompt.prompt); - assert.equal(informed.ask, 'Existing permission review'); - const context = informed.additionalContext as string[]; - assert.equal(context[0], 'existing context'); - assert.match(context[1], /lowest subscription usage/); - assert.match(context[1], /may choose a more-used model/); - assert.match(context[1], /without waiting/); - assert(!context[1].includes('native spend')); - const readsBeforeSkipped = reads; - for (const event of [ - { ...prompt, tool: 'Read' }, - { ...prompt, agentId: 'nested-worker' }, - ]) { - assert.deepEqual(await submit(engine, event, next), await next(event)); - } - assert.equal(reads, readsBeforeSkipped); - const task = { ...prompt, tool: 'Task' }; - assert.equal(((await submit(engine, task, next)).additionalContext as string[]).length, 2); - assert.equal(reads, readsBeforeSkipped + 1); - const beforeOther = reads; - session = 'advice-two'; - assert.deepEqual(await submit(engine, prompt, next), await next(prompt)); - assert.equal(reads, beforeOther); - session = 'advice-one'; - available = false; - assert.match( - ((await submit(engine, prompt, next)).additionalContext as string[])[1], - /could not be retrieved/, - ); - await message(engine, toggle, next); - const beforeDisabled = reads; - assert.deepEqual(await submit(engine, prompt, next), await next(prompt)); - assert.equal(reads, beforeDisabled); - await message(engine, toggle, next); - module.forgetUsageSession(session); - assert.deepEqual(await submit(engine, prompt, next), await next(prompt)); - assert.equal(reads, beforeDisabled); - assert(!hooks.has('prompt.submit')); -}); diff --git a/test/unit/native-zen-chat.test.ts b/test/unit/native-zen-chat.test.ts index 13f87e8..674812a 100644 --- a/test/unit/native-zen-chat.test.ts +++ b/test/unit/native-zen-chat.test.ts @@ -624,3 +624,64 @@ test('fromChat rejects a tool finish without calls and normalizes tools on stop' ); assert.equal(result.stop_reason, 'tool_use'); }); + +test('fromChat emits strictly sequential blocks, one per reasoning or text segment', async () => { + const seen = capture(); + const chunk = (delta: Record, finish: string | null = null) => ({ + id: 'chat-seq', + choices: [{ index: 0, delta, finish_reason: finish }], + }); + const result = await fromChat( + sse([ + chunk({ reasoning_content: 'one' }), + chunk({ content: 'say' }), + chunk({ reasoning_content: 'two' }), + chunk({ content: 'more' }, 'stop'), + { id: 'chat-seq', choices: [], usage: { prompt_tokens: 5, completion_tokens: 4 } }, + ]), + model, + seen.emit, + ); + assert.deepEqual( + result.content.map((block) => block.type), + ['thinking', 'text', 'thinking', 'text'], + ); + assert.equal(result.content[2]?.type === 'thinking' ? result.content[2].thinking : '', 'two'); + const order = seen.events + .filter((event) => event.type.startsWith('content_block_s')) + .map((event) => `${event.type}:${(event.value as { index: number }).index}`); + assert.deepEqual(order, [ + 'content_block_start:0', + 'content_block_stop:0', + 'content_block_start:1', + 'content_block_stop:1', + 'content_block_start:2', + 'content_block_stop:2', + 'content_block_start:3', + 'content_block_stop:3', + ]); +}); + +test('toChat joins separate system and user text blocks with a newline', () => { + const request = toChat( + { + model, + system: [ + { type: 'text', text: 'first rule' }, + { type: 'text', text: 'second rule' }, + ], + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'alpha' }, + { type: 'text', text: 'beta' }, + ], + }, + ], + } as never, + 'kimi-k3', + ) as { messages: { role: string; content: unknown }[] }; + assert.equal(request.messages[0]?.content, 'first rule\nsecond rule'); + assert.equal(request.messages[1]?.content, 'alpha\nbeta'); +}); diff --git a/test/unit/native-zen-gateway.test.ts b/test/unit/native-zen-gateway.test.ts index d12da3a..dc98289 100644 --- a/test/unit/native-zen-gateway.test.ts +++ b/test/unit/native-zen-gateway.test.ts @@ -175,7 +175,6 @@ test('Zen native tools cannot acquire OpenAI review; explicit bypass stays expli let reviewed = 0; const base = await gateway(t, async () => completion(true), { guardAuto: true, - approvalProviders: ['openai'], approvalBridge: { respond: async () => { reviewed++; diff --git a/test/unit/native-zen-request.test.ts b/test/unit/native-zen-request.test.ts index 78a28b6..4adaa5e 100644 --- a/test/unit/native-zen-request.test.ts +++ b/test/unit/native-zen-request.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { MessagesRequest } from '../../plugins/multi-core/src/gateway/messages.ts'; -import type { ResponsesInputItem } from '../../plugins/multi-openai/src/responses.ts'; -import { fromResponses } from '../../plugins/multi-openai/src/responses.ts'; +import type { ResponsesInputItem } from '../../plugins/multi-core/src/gateway/responses.ts'; +import { fromResponses } from '../../plugins/multi-core/src/gateway/responses.ts'; import { toChat } from '../../plugins/multi-zen/src/chat.ts'; import { zenRequest } from '../../plugins/multi-zen/src/request.ts';