mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 23:56:38 -10:00
Compare commits
99
Commits
No files matched your search
@@ -65,6 +65,12 @@ crates/engine/extension-host/dist/** text eol=lf
|
||||
crates/engine/tests/fixtures/extension_host/** text eol=lf
|
||||
crates/engine/extension-host/src/dsh/dsh-message-compat.js text eol=lf
|
||||
|
||||
# DeepSeek Harness creator pins are compared byte for byte against recorded
|
||||
# SHA-256 values (UPSTREAM.json) and embedded with include_bytes!(); a CRLF
|
||||
# Windows checkout must not rewrite any of them.
|
||||
crates/skills/assets/dsh-creator/** text eol=lf
|
||||
crates/engine/extension-host/src/dsh/upstream/creator/** text eol=lf
|
||||
|
||||
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
|
||||
*.rs text eol=lf
|
||||
|
||||
|
||||
@@ -612,6 +612,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
node web/scripts/sync-latest-release.mjs
|
||||
node web/scripts/derive-facts.mjs
|
||||
node web/scripts/derive-release-docs.mjs
|
||||
- name: Prove the record passes the web fact gates
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -620,6 +621,7 @@ jobs:
|
||||
node web/scripts/sync-latest-release.mjs --check
|
||||
node web/scripts/check-cloud-facts.mjs
|
||||
node web/scripts/check-facts.mjs
|
||||
node web/scripts/check-release-docs.mjs
|
||||
- name: Propose the record to the default branch
|
||||
env:
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
@@ -633,6 +635,8 @@ jobs:
|
||||
docs/cloud-facts/stable.json
|
||||
web/lib/facts.generated.ts
|
||||
web/data/agent-docs.generated.json
|
||||
web/data/agent-docs.release.json
|
||||
web/data/agent-docs.release-index.json
|
||||
web/data/features.generated.json
|
||||
)
|
||||
if git diff --quiet -- "${paths[@]}"; then
|
||||
|
||||
+37
-14
@@ -7,7 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
|
||||
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
|
||||
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
|
||||
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
|
||||
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
|
||||
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
|
||||
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
|
||||
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
|
||||
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
|
||||
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
|
||||
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
|
||||
- **Skill install and import:** `/skill install` now works out an archive's wrapper directory from every entry instead of the first one, so a GitHub repository laid out as `<name>/SKILL.md` installs instead of failing with "missing SKILL.md in archive", a repository-root `SKILL.md` is chosen ahead of a nested `skills/<name>/SKILL.md`, and a flat tarball that lists `scripts/run.sh` before `SKILL.md` no longer installs without its `SKILL.md`. Importing an external skill that contains dotfiles such as `.DS_Store`, `.gitignore` or `.config/` no longer fails with "imported content digest mismatch"; the dotfiles are copied, and the source's own `.trusted` and `.installed-from` are still left behind. Thanks [@luochen211](https://github.com/luochen211) (PR 7311).
|
||||
- **Workflow authoring:** a declarative Workflow or structured `workflow` plan whose goal or prompt mentions words such as "async", "import" or "open(" is no longer rejected as an unsupported construct. Fleet TOML values written as 'single-quoted' strings, or with escapes such as `\"`, are now read without their quotes. Workflow scripts can no longer reach the internal `__workflow_slot_dropped` binding, which had let a script mark a clean run as Degraded. Thanks [@luochen211](https://github.com/luochen211) (PR 7312).
|
||||
- **Tool output escape stripping:** tool output no longer shows a stray character after a character-set escape such as the `ESC ( B` that `tput sgr0` prints, and a stray `ESC [` no longer hides the Chinese, Japanese or Korean text after it. Coloured tool output now keeps only well-formed colour sequences. Thanks [@luochen211](https://github.com/luochen211) (PR 7313).
|
||||
- **Patches with short hunk headers:** `apply_patch` now reads every line of a hunk when the `@@` header counts are too small. Lines past twice the header counts used to be dropped without an error, so a long hunk, or a new file written under `@@ -0,0 +1 @@`, could lose its later lines. A header with an enormous count no longer crashes the parser, and stray text after such a hunk now fails as a context mismatch. Thanks [@luochen211](https://github.com/luochen211) (PR 7287).
|
||||
- **Script tools with `env -S` shebangs:** a script tool whose first line is `#!/usr/bin/env -S python3 -u` now starts `python3 -u` instead of failing to launch a program named `-S`. Other `env` options, the attached `-Sprogram` form and `env -S` quoting are still not interpreted. Thanks [@luochen211](https://github.com/luochen211) (PR 7288).
|
||||
- **Timeline summaries:** a thread or task timeline summary that already fits its length limit is now shown in full, instead of having its last characters replaced by `...`. Thanks [@luochen211](https://github.com/luochen211) (PR 7289).
|
||||
- **RLM context names:** `rlm_open` without a `name` now keeps non-ASCII letters and digits from the file name, so `docs/报告.txt` and `docs/总结.txt` open as `报告_txt` and `总结_txt` instead of both becoming `txt` and the second open failing with "already exists". Names derived from ASCII file names are unchanged. Thanks [@luochen211](https://github.com/luochen211) (PR 7290).
|
||||
- **Web fetch and `web.run` entities:** text a page escaped on purpose now stays escaped. A page that writes `&lt;br&gt;` to show a literal `<br>` now reads as `<br>` in fetched text, `web.run` lines, page titles and meta descriptions; before, it was decoded twice into `<br>`. Both tools now use the search scraper's single-pass decoder, so numeric references such as `—` and names such as `—` are decoded there too. Thanks [@luochen211](https://github.com/luochen211) (PR 7291).
|
||||
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
|
||||
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
|
||||
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
|
||||
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
|
||||
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
|
||||
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
|
||||
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) bundled, unmodified, with their reference and template files and DeepSeek's MIT notice beside each. They stay hidden from the model until Creator mode. A bundled skill can now carry files; `load_skill` prints its base directory and a short Codewhale host-notes block, and a file you edited is never overwritten on upgrade.
|
||||
- **Conversation modes (Engine):** `GET /v1/modes` lists two built-in modes, `standard` (default) and `creator`, each with `id`, `name`, `description`, `source` and `available` (plus `reason` when not; Creator is listed unavailable, "not ready in this build", until it loads its own tools, and then also when no Node.js or Bun plugin runtime is found). A thread's mode is `agent_preset`, set on `POST /v1/threads` or by `PATCH /v1/threads/{id}` until the thread has a turn, saved on the thread record and the terminal session file and shown in `GET /v1/threads/summary`; records without it load as `standard`. An unknown or unavailable id, a change after the first turn, or a mode together with another vendor's agent is refused with `error.code` `unknown_mode`, `mode_unavailable`, `mode_locked` or `mode_not_supported_with_executor`. Source only: Creator loads nothing extra yet and no menu uses it.
|
||||
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
|
||||
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the facts row lists many agents on one task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other coding agents. This replaces the earlier reorganization of the home page, so the community panel whose GitHub numbers filled in from the browser is no longer on it, and the dated OrcaRouter offer strip, English only, with its referral disclosure, stays below the first screen until it ends; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
|
||||
- **Website models and plugins:** /models leads with recently added models, three ways to connect (DeepSeek, first-party providers, any OpenAI-compatible endpoint) and a catalog grouped by maker; Ollama and OpenRouter are listed but no longer featured. /plugins leads with "Everything is a plugin" (credited to DeepSeek Harness) and shows MCP connectors from the connection catalog.
|
||||
- **Model catalog:** Claude Fable 5.2 (`claude-fable-5-2`) and Gemini 4 Carbon (`gemini-4-carbon`) are in the bundled catalog, and a daily catalog-refresh workflow proposes new first-party models from models.dev as a pull request. The workflow has not run in GitHub Actions yet.
|
||||
- **Gemini 4 output limit:** Gemini 4 Carbon and Gemini 4 Argon now allow 65,536 output tokens per reply, up from 32,768, matching Google's published limit.
|
||||
@@ -38,11 +68,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- **Docs for agents match the release:** `/llms-full.txt` and `/docs-markdown/*` now serve the documents as they were at the published release tag, from a committed snapshot that is checked against the published release.
|
||||
- **Extension host (experimental): trusted plugins ask for review once more.** With `extension_host` turned on, plugins you already trusted are shown for review again after this update, because the capability table they were reviewed against gained the `remote` service. No plugin can use that service yet: none is granted it.
|
||||
- **Website text for agents:** llms.txt now gives agents the install steps for the released version, the current release and the state of each part of Codewhale, from the same content as the pages. `/docs-markdown/install.md` serves those install steps as a page, and the main-branch documents beside it now say which release they can run ahead of.
|
||||
- **Website design:** codewhale.net now uses the desktop app's 5.3 light and dark colour roles and Geist / Geist Mono, replacing the warm-paper palette and Shannon Sans; the vendored design copy is synced to 5.3.0 and the whale-road ocean bands are unchanged.
|
||||
|
||||
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Plugins, DSH and MCP tools are not yet attached to ACP-driven sessions. A real-editor journey is not yet proved.
|
||||
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Sessions an editor drives now run the plugin tools (TypeScript and DSH, when the extension host is on) and the MCP servers configured in Codewhale; their calls show as ACP tool calls and ask through the session's permission setting, and `initialize` reports the state under `agentCapabilities._meta.codewhale`. MCP servers an editor passes in `mcpServers` and configured shell hooks still do not run in ACP sessions. A real-editor journey is not yet proved.
|
||||
- Extension host: the `sessions` and `systemPrompt` services now come from a reviewed DSH adapter instead of inline shims, and `sessions.flush` refuses an empty checkpoint ticket. Session reads and events and the `agent/pre-step` hook are still not available to plugins. (CW-7959)
|
||||
- Extension host: DSH service adapters now have one ticketed `dsh/broker` request to the core, each ticket is bound to the plugin, the service and the session, and every service (workspace, llm, network, settings, catalog, terminal and remote) refuses with a typed `not_available` until its broker lands. Nothing new is offered to plugins yet. (CW-8012)
|
||||
- **Plugin host services and who approved:** a plugin can now be granted a Codewhale host service, and only by a review that shows it. The review names each service and the plugins it is shared with, and every review records who approved it: you, the agent (in Full Access, in Auto-Review, or from a shell command carrying the agent's mark), or the command line with `--yes`; who turned the plugin on is recorded too. Plugins share one process, so a service works only while every loaded plugin of the same kind holds the same grant. A plugin the agent approved is active only in its own project and its code does not run beside the plugins you reviewed until you review it. No service is offered yet, and a plugin reviewed before this release holds no grant until it is reviewed again. Once a plugin has been reviewed or turned on in this release, Codewhale 0.10.2 and earlier cannot read the plugin state file and turn every plugin off when run on the same home folder. (CW-8012)
|
||||
- Fleet member starts are now durable: replaying an operation key after a timeout or restart returns the original worker, and GET /v1/threads/{id}/members/operations/{key} reports the outcome. A member start now answers with `state` `accepted` (200) or `unknown` (202) instead of `started` or `queued`. Verified by local HTTP tests on Linux; a real Engine restart is simulated, not exercised. (CW-7964)
|
||||
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
|
||||
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
|
||||
@@ -53,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
- The install page now gives the exact steps for the released version on every platform, including what the installer adds to PATH, how to turn off usage counting, and how to remove it, and the install command no longer gets cut off on phones.
|
||||
@@ -633,7 +668,7 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
- **Windows uninstall removes the receipt and update leftovers.** The installer's uninstaller also deletes `.codewhale-install.sha256` and `codewhale.old-*`/`codew.old-*` left by a self-update, so `bin` can be removed. Refs #7091.
|
||||
- **Windows `/undo` matches recorded file paths regardless of case.** A write recorded under a different drive-letter or directory case, or a `\\?\` prefix, is no longer dropped from the restore plan. Refs #6970.
|
||||
- **Windows: Fleet and metrics read live files again.** Replaying a Fleet ledger and reading audit or receipt records no longer fail with a sharing violation while their writer is still open; the same regular-file, link and identity checks apply. Plugin enablement now says which snapshot check refused (stage root, content identity or final check) instead of one generic message. Generated protocol and SDK exports keep LF line endings in Windows checkouts.
|
||||
- **A held coordination lock no longer makes a new session read-only.** When another session owns the delegated-agent store, the new session starts on private state and says so, instead of refusing every sub-agent with “Failed to clean up sub-agents”.
|
||||
- **Sessions in one folder no longer leave each other's sub-agents read-only or private:** a second session works beside another one, stale or suspended included, the older sub-agent store upgrades right away, and a store that cannot be opened is retried on the next sub-agent change instead of needing a restart. While another Codewhale session, a suspended one included, is open in a folder the desktop app or `serve` was asked to work in (other than the folder it started in), that Runtime still refuses sub-agents that would edit the folder directly; isolated worktrees are unaffected. A sub-agent store that cannot be opened shows its refusal once instead of repeating cleanup warnings on every sidebar refresh. Restart any session started with Codewhale 0.10.2 or older: once the folder upgrades it can no longer save sub-agent state (Refs #7232).
|
||||
- **A dev server can outlive the turn in an interactive session.** `persist:true` now works in the TUI and desktop when the thread has Full Access; restricted postures and Fleet/sub-agents still refuse it.
|
||||
- **`codewhale exec --add-dir <path>`** (repeatable) and `addDirs` on `POST /v1/threads` let the file tools read and write a sibling directory, a second checkout or a shared scratch root without turning on Full Access.
|
||||
- **Unsettled usage is persisted in production, not only in tests.** The interactive sink now records a legacy usage receipt when a provider response has no settlement, so children and turns stop reporting settlement failure.
|
||||
@@ -670,12 +705,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
composer. Real Pinyin input on macOS and Windows is not yet confirmed.
|
||||
Thanks [@cmdcorp6534](https://github.com/cmdcorp6534) for the report
|
||||
(Refs #2323).
|
||||
- **Sub-agents beside an older session:** while a Codewhale 0.10.2 session
|
||||
still uses a folder's older delegated-agent store, a 0.10.3 session in the
|
||||
same folder keeps working sub-agents with private state that is not saved,
|
||||
instead of refusing every sub-agent action. The older session's files are
|
||||
left untouched, and the store upgrades when a session starts after the older
|
||||
one exits (Refs #7232).
|
||||
- **Fleet members:** a replayed member start now matches only within its own
|
||||
session, so two sessions that reuse an operation key no longer receive each
|
||||
other's agent.
|
||||
@@ -704,12 +733,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
bare self-package exports resolve inside the selected package directory.
|
||||
Invalid or incompatible ranges and paths outside the reviewed package still
|
||||
refuse. Real published-plugin activation remains unqualified.
|
||||
- **Coordination lock diagnostics (#7232):** stale lock-file contents no longer
|
||||
identify an owner PID. A refused legacy admission keeps its reason in Work
|
||||
and shows one notice per cause instead of repeating cleanup warnings on
|
||||
every sidebar refresh. Legacy migration still requires the actual holder to
|
||||
exit and the refused owning Runtime to restart; locks and retained state
|
||||
remain protected.
|
||||
- **A local proxy that stops mid-session no longer strands the session (#7231):** the model, catalog, search, vision, finance, MCP-registry, profile and remote-control clients watch a loopback proxy port and go direct while it refuses connections, then resume proxying when it returns. Only a definite refusal from every resolved address changes the route; remote proxies, `NO_PROXY` matches, credentials and the existing precedence are unchanged. Set `CODEWHALE_LOCAL_PROXY_FALLBACK=0` to keep the old build-time snapshot.
|
||||
- **Proxy diagnostic privacy ([#7233](https://github.com/codewhale-hq/codewhale/issues/7233)):** malformed MCP proxy warnings omit configured values; local-proxy log labels mask userinfo and omit URL paths, queries and fragments. Routing is unchanged. All 23 existing proxy tests passed on macOS; runtime log capture and other platforms remain unqualified. Thanks [@SparkofSpike](https://github.com/SparkofSpike) for the report.
|
||||
- **Fork, undo and retry work after a workspace drift notice (#7234):** the history-boundary walks now treat a whole `<context_update>` envelope as engine-owned, so a session that received one can be cut at every saved turn. A person quoting a notice or a truncated envelope still counts as their own prompt.
|
||||
|
||||
Generated
+13
-12
@@ -1509,6 +1509,7 @@ dependencies = [
|
||||
"codewhale-protocol",
|
||||
"codewhale-sanitize",
|
||||
"serde_json",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1999,7 +2000,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2526,7 +2527,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2667,7 +2668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2770,7 +2771,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4993,7 +4994,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6339,15 +6340,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6403,7 +6404,7 @@ dependencies = [
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7213,7 +7214,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7245,7 +7246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8364,7 +8365,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -164,6 +164,44 @@ The host bundle's own copy of these notices is `dist/LICENSES.txt`, generated
|
||||
from the bundler's metafile; Codewhale writes it beside the bundle it
|
||||
materialises (`~/.codewhale/extension-host/<sha256>/LICENSES.txt`).
|
||||
|
||||
## DeepSeek Harness Creator files (`dsh-agent-preset` skills, `dsh-plugin-manager`, `dsh-tool-cordis`, `dsh-sandbox`) — MIT
|
||||
|
||||
Codewhale ships, unmodified, the four authoring skills of
|
||||
`@deepseek-ai/dsh-agent-preset` 0.2.2-rc.1
|
||||
(`crates/skills/assets/dsh-creator/`), and keeps byte-for-byte copies of
|
||||
`@deepseek-ai/dsh-plugin-manager` 0.2.2-rc.1 `lib/types/tools.js` and
|
||||
`@deepseek-ai/dsh-tool-cordis` 0.2.2-rc.1 `lib/index.js`
|
||||
(`crates/engine/extension-host/src/dsh/upstream/creator/`).
|
||||
`@deepseek-ai/dsh-sandbox` 0.2.2-rc.1 is named for the approval excerpt that
|
||||
slice will add. Each package is published under the licence text below, and
|
||||
each pin's `UPSTREAM.json` records the SHA-256 of every file.
|
||||
|
||||
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DeepSeek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Model Context Protocol SDK and bundled dependencies
|
||||
|
||||
The pinned builtin MCP bundle (`crates/engine/extension-host/dist/builtin/mcp.mjs`)
|
||||
|
||||
+879
-675
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,441 @@
|
||||
use super::*;
|
||||
use agent_client_protocol::v1::{ConnectionTo, Responder};
|
||||
use agent_client_protocol::{Agent, Client, Lines, UntypedMessage};
|
||||
use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, MAX_FRAME_BYTES};
|
||||
use futures_util::{Sink, Stream};
|
||||
use std::io;
|
||||
use std::sync::{MutexGuard, PoisonError};
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
|
||||
const PROMPT_BUSY: &str = "a session/prompt turn is already in progress";
|
||||
const OUTGOING_QUEUE_FRAMES: usize = 256;
|
||||
const OVERSIZE_RESPONSE: &str =
|
||||
"ACP response exceeds the 8 MiB transport limit; inspect canonical state without replay";
|
||||
type Cx = ConnectionTo<Client>;
|
||||
|
||||
pub(super) struct Shared {
|
||||
server: tokio::sync::Mutex<AcpServer>,
|
||||
prompt: std::sync::Mutex<Option<mpsc::UnboundedSender<Inbound>>>,
|
||||
idle: Notify,
|
||||
ending: Notify,
|
||||
failure: std::sync::Mutex<Option<anyhow::Error>>,
|
||||
}
|
||||
|
||||
struct PromptGuard(Arc<Shared>);
|
||||
|
||||
impl Drop for PromptGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.release();
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptGuard {
|
||||
fn finish(self, responder: Responder<Value>, result: std::result::Result<Value, SdkError>) {
|
||||
let mut slot = self.0.slot();
|
||||
let _ = responder.respond_with_result(result);
|
||||
*slot = None;
|
||||
drop(slot);
|
||||
self.0.idle.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub(super) fn new(server: AcpServer) -> Self {
|
||||
Self {
|
||||
server: tokio::sync::Mutex::new(server),
|
||||
prompt: std::sync::Mutex::new(None),
|
||||
idle: Notify::new(),
|
||||
ending: Notify::new(),
|
||||
failure: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn fail(&self, error: anyhow::Error) {
|
||||
self.failure
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get_or_insert(error);
|
||||
self.tell_prompt(Inbound::Eof);
|
||||
self.ending.notify_one();
|
||||
}
|
||||
|
||||
pub(super) fn take_failure(&self) -> Option<anyhow::Error> {
|
||||
self.failure
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn into_server(self) -> AcpServer {
|
||||
self.server.into_inner()
|
||||
}
|
||||
|
||||
fn slot(&self) -> MutexGuard<'_, Option<mpsc::UnboundedSender<Inbound>>> {
|
||||
self.prompt.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn prompt_active(&self) -> bool {
|
||||
self.slot().is_some()
|
||||
}
|
||||
|
||||
fn tell_prompt(&self, message: Inbound) {
|
||||
if let Some(inbox) = self.slot().as_ref() {
|
||||
let _ = inbox.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn release(&self) {
|
||||
*self.slot() = None;
|
||||
self.idle.notify_waiters();
|
||||
}
|
||||
|
||||
async fn wait_idle(&self) {
|
||||
loop {
|
||||
let notified = self.idle.notified();
|
||||
if !self.prompt_active() {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&self, params: &Value) {
|
||||
if let Some(session) = params.get("sessionId").and_then(Value::as_str) {
|
||||
self.tell_prompt(Inbound::Cancel {
|
||||
session: session.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn request(
|
||||
self: &Arc<Self>,
|
||||
method: String,
|
||||
params: Value,
|
||||
responder: Responder<Value>,
|
||||
cx: Cx,
|
||||
) {
|
||||
match method.as_str() {
|
||||
"session/cancel" => {
|
||||
self.cancel(¶ms);
|
||||
let _ = responder.respond(Value::Null);
|
||||
}
|
||||
"session/prompt" => self.start_prompt(params, responder, cx).await,
|
||||
_ if self.prompt_active() => self.during_prompt(&cx, method, params, responder),
|
||||
"shutdown" => {
|
||||
let _ = responder.respond(Value::Null);
|
||||
self.ending.notify_one();
|
||||
}
|
||||
"session/load" => self.load(params, responder, cx).await,
|
||||
_ => {
|
||||
let result = self
|
||||
.server
|
||||
.lock()
|
||||
.await
|
||||
.handle_request(&method, params)
|
||||
.await;
|
||||
let _ = responder.respond_with_result(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn during_prompt(
|
||||
self: &Arc<Self>,
|
||||
cx: &Cx,
|
||||
method: String,
|
||||
params: Value,
|
||||
responder: Responder<Value>,
|
||||
) {
|
||||
let served = matches!(
|
||||
method.as_str(),
|
||||
"session/list"
|
||||
| "session/set_config_option"
|
||||
| "session/set_mode"
|
||||
| "session/set_model"
|
||||
| "session/listProviders"
|
||||
| "session/currentModel"
|
||||
| "session/selectModel"
|
||||
);
|
||||
if !served {
|
||||
let error = if matches!(
|
||||
method.as_str(),
|
||||
"initialize" | "session/new" | "session/load" | "shutdown"
|
||||
) {
|
||||
SdkError::new(-32603, PROMPT_BUSY)
|
||||
} else {
|
||||
SdkError::method_not_found().data(Value::String(method))
|
||||
};
|
||||
let _ = responder.respond_with_error(error);
|
||||
return;
|
||||
}
|
||||
let shared = Arc::clone(self);
|
||||
let _ = cx.spawn(async move {
|
||||
let result = shared
|
||||
.server
|
||||
.lock()
|
||||
.await
|
||||
.handle_request(&method, params)
|
||||
.await;
|
||||
let _ = responder.respond_with_result(result);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
async fn load(&self, params: Value, responder: Responder<Value>, cx: Cx) {
|
||||
let mut server = self.server.lock().await;
|
||||
let result = async {
|
||||
let loaded = server.load_session(params).await.map_err(SdkError::from)?;
|
||||
if let Some(session) = loaded.get("sessionId").and_then(Value::as_str) {
|
||||
let updates = server
|
||||
.history_updates(session)
|
||||
.await
|
||||
.map_err(|error| SdkError::new(-32603, error.to_string()))?;
|
||||
for update in updates {
|
||||
cx.send_notification(acp::SessionNotification::new(
|
||||
session.to_string(),
|
||||
update,
|
||||
))?;
|
||||
}
|
||||
}
|
||||
Ok::<_, SdkError>(loaded)
|
||||
}
|
||||
.await;
|
||||
drop(server);
|
||||
let _ = responder.respond_with_result(result);
|
||||
}
|
||||
|
||||
async fn start_prompt(self: &Arc<Self>, params: Value, responder: Responder<Value>, cx: Cx) {
|
||||
if self.prompt_active() {
|
||||
let _ = responder.respond_with_error(SdkError::new(-32603, PROMPT_BUSY));
|
||||
return;
|
||||
}
|
||||
let admitted = {
|
||||
let server = self.server.lock().await;
|
||||
server.validate_prompt(¶ms).map(|(session, prompt)| {
|
||||
let thread = server.sessions[&session].thread_id.clone();
|
||||
(session, thread, prompt, server.prompt_run())
|
||||
})
|
||||
};
|
||||
let (session, thread, prompt, run) = match admitted {
|
||||
Ok(admitted) => admitted,
|
||||
Err(error) => {
|
||||
let _ = responder.respond_with_error(error.into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (answers, mut inbox) = mpsc::unbounded_channel();
|
||||
*self.slot() = Some(answers.clone());
|
||||
let guard = PromptGuard(Arc::clone(self));
|
||||
let task_cx = cx.clone();
|
||||
let _ = cx.spawn(async move {
|
||||
let result = {
|
||||
let mut emit = |outgoing: Outgoing| -> Result<()> {
|
||||
match outgoing {
|
||||
Outgoing::Update(notification) => task_cx
|
||||
.send_notification(notification)
|
||||
.map_err(|error| anyhow!("ACP update was not sent: {}", error.message)),
|
||||
Outgoing::Permission { key, request } => {
|
||||
let answers = answers.clone();
|
||||
task_cx
|
||||
.send_request(request)
|
||||
.on_receiving_result(async move |reply| {
|
||||
let gone = matches!(
|
||||
&reply,
|
||||
Err(error)
|
||||
if agent_client_protocol::is_incoming_transport_closed(error)
|
||||
|| agent_client_protocol::is_connection_terminated(error)
|
||||
);
|
||||
if !gone {
|
||||
let _ = answers.send(Inbound::Permission {
|
||||
key,
|
||||
allow: permission_allowed(&reply),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|error| {
|
||||
anyhow!("ACP permission request was not sent: {}", error.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
run.drive_prompt(&session, &thread, prompt, &mut emit, &mut inbox)
|
||||
.await
|
||||
};
|
||||
let result = match result {
|
||||
Ok(stop) => to_wire(&acp::PromptResponse::new(stop)).map_err(SdkError::from),
|
||||
Err(error) => Err(SdkError::new(
|
||||
-32603,
|
||||
codewhale_engine::client::redact_model_bound_text(&error.to_string(), &[]),
|
||||
)),
|
||||
};
|
||||
guard.finish(responder, result);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_allowed(
|
||||
reply: &std::result::Result<acp::RequestPermissionResponse, SdkError>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
reply,
|
||||
Ok(response) if matches!(
|
||||
&response.outcome,
|
||||
acp::RequestPermissionOutcome::Selected(selected)
|
||||
if &*selected.option_id.0 == "allow-once"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn closes_input(line: &str) -> bool {
|
||||
line.len() <= 256
|
||||
&& line.contains("daemon/input_closed")
|
||||
&& serde_json::from_str::<Value>(line)
|
||||
.is_ok_and(|message| crate::is_control_input_closed(&message))
|
||||
}
|
||||
|
||||
fn incoming_lines(
|
||||
input: Box<dyn AsyncBufRead + Send + Unpin>,
|
||||
shared: Arc<Shared>,
|
||||
) -> impl Stream<Item = io::Result<String>> + Send + 'static {
|
||||
futures_util::stream::unfold(
|
||||
(BoundedLines::new(input), shared),
|
||||
|(mut lines, shared)| async move {
|
||||
loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) if line.trim().is_empty() => continue,
|
||||
Ok(Some(line)) if closes_input(&line) => return None,
|
||||
Ok(Some(line)) => return Some((Ok(line), (lines, shared))),
|
||||
Ok(None) => return None,
|
||||
Err(error) if crate::stdio::is_frame_refusal(&error) => {
|
||||
return Some((Ok(error.to_string()), (lines, shared)));
|
||||
}
|
||||
Err(error) => {
|
||||
shared.fail(anyhow!("ACP input failed: {error}"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn bound_outgoing(line: String) -> io::Result<String> {
|
||||
if line.len() <= MAX_FRAME_BYTES {
|
||||
return Ok(line);
|
||||
}
|
||||
let refused = || io::Error::new(io::ErrorKind::InvalidData, FrameRefusal::TooLarge);
|
||||
let Ok(Value::Object(frame)) = serde_json::from_str::<Value>(&line) else {
|
||||
return Err(refused());
|
||||
};
|
||||
if frame.contains_key("method") || !frame.contains_key("id") {
|
||||
return Err(refused());
|
||||
}
|
||||
let failure = |id: Value| {
|
||||
json!({"jsonrpc":"2.0","id":id,"error":{"code":-32603,"message":OVERSIZE_RESPONSE}})
|
||||
.to_string()
|
||||
};
|
||||
let replaced = failure(frame["id"].clone());
|
||||
Ok(if replaced.len() <= MAX_FRAME_BYTES {
|
||||
replaced
|
||||
} else {
|
||||
failure(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn outgoing_lines(
|
||||
frames: mpsc::Sender<String>,
|
||||
shared: Arc<Shared>,
|
||||
) -> impl Sink<String, Error = io::Error> + Send + 'static {
|
||||
futures_util::sink::unfold(
|
||||
(frames, shared),
|
||||
|(frames, shared), line: String| async move {
|
||||
let line = match bound_outgoing(line) {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
shared.fail(anyhow!("ACP output refused: {error}"));
|
||||
return Ok((frames, shared));
|
||||
}
|
||||
};
|
||||
frames
|
||||
.send(line)
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ACP output closed"))?;
|
||||
Ok::<_, io::Error>((frames, shared))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn serve_connection<'a>(
|
||||
server: AcpServer,
|
||||
input: Box<dyn AsyncBufRead + Send + Unpin>,
|
||||
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
|
||||
) -> Result<Arc<Shared>> {
|
||||
let shared = Arc::new(Shared::new(server));
|
||||
let (frames, mut queued) = mpsc::channel::<String>(OUTGOING_QUEUE_FRAMES);
|
||||
let transport = Lines::new(
|
||||
outgoing_lines(frames, Arc::clone(&shared)),
|
||||
incoming_lines(input, Arc::clone(&shared)),
|
||||
);
|
||||
let requests = Arc::clone(&shared);
|
||||
let notifications = Arc::clone(&shared);
|
||||
let finishing = Arc::clone(&shared);
|
||||
let connection = Agent
|
||||
.v1()
|
||||
.on_receive_request(
|
||||
async move |request: UntypedMessage, responder, cx| {
|
||||
let (method, params) = request.into_parts();
|
||||
requests.request(method, params, responder, cx).await;
|
||||
Ok(())
|
||||
},
|
||||
agent_client_protocol::on_receive_request!(),
|
||||
)
|
||||
.on_receive_notification(
|
||||
async move |notification: UntypedMessage, _cx| {
|
||||
let (method, params) = notification.into_parts();
|
||||
if method == "session/cancel" {
|
||||
notifications.cancel(¶ms);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
agent_client_protocol::on_receive_notification!(),
|
||||
)
|
||||
.connect_with(transport, async move |cx| {
|
||||
tokio::select! {
|
||||
() = cx.incoming_closed() => {}
|
||||
() = finishing.ending.notified() => {}
|
||||
}
|
||||
finishing.tell_prompt(Inbound::Eof);
|
||||
finishing.wait_idle().await;
|
||||
Ok(())
|
||||
});
|
||||
let connection = async {
|
||||
match connection.await {
|
||||
Err(error) if agent_client_protocol::is_incoming_transport_closed(&error) => Ok(()),
|
||||
other => other.map_err(|error| anyhow!("ACP connection failed: {}", error.message)),
|
||||
}
|
||||
};
|
||||
let failing = Arc::clone(&shared);
|
||||
let pump = async {
|
||||
let mut broken = false;
|
||||
while let Some(frame) = queued.recv().await {
|
||||
if broken {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) =
|
||||
crate::stdio::write_control_line(&mut output, frame.as_bytes()).await
|
||||
{
|
||||
broken = true;
|
||||
failing.fail(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ended, ()) = tokio::join!(connection, pump);
|
||||
drop(failing);
|
||||
if let Some(error) = shared.take_failure() {
|
||||
return Err(error);
|
||||
}
|
||||
ended?;
|
||||
Ok(shared)
|
||||
}
|
||||
@@ -5,12 +5,17 @@ use codewhale_engine::runtime_threads::{
|
||||
manager::ExternalApprovalDecision, manager::StartTurnRequest,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
struct Permission {
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
approval_id: String,
|
||||
execution_id: String,
|
||||
}
|
||||
pub(super) struct PromptRun {
|
||||
pub(super) runtime: Arc<RuntimeThreadManager>,
|
||||
pub(super) sessions_dir: PathBuf,
|
||||
}
|
||||
struct Projection<'a> {
|
||||
session: &'a str,
|
||||
thread: &'a str,
|
||||
@@ -42,21 +47,20 @@ impl Drop for TurnClaim {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl AcpServer {
|
||||
pub(super) async fn drive_prompt<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
&mut self,
|
||||
params: Value,
|
||||
reader: &mut crate::stdio::BoundedLines<R>,
|
||||
writer: &mut W,
|
||||
) -> Result<&'static str> {
|
||||
let (session_id, prompt) = self
|
||||
.validate_prompt(¶ms)
|
||||
.map_err(|error| anyhow!(error.message))?;
|
||||
let binding = self
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.ok_or_else(|| anyhow!("unknown sessionId"))?;
|
||||
let thread_id = binding.thread_id.clone();
|
||||
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
|
||||
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
|
||||
}
|
||||
impl PromptRun {
|
||||
pub(super) async fn drive_prompt(
|
||||
&self,
|
||||
session_id: &str,
|
||||
thread_id: &str,
|
||||
prompt: String,
|
||||
emit: Emit<'_>,
|
||||
inbox: &mut UnboundedReceiver<Inbound>,
|
||||
) -> Result<acp::StopReason> {
|
||||
let session_id = session_id.to_string();
|
||||
let thread_id = thread_id.to_string();
|
||||
// Subscribe before snapshot/admission; replay deduplicates by seq.
|
||||
let mut receiver = self.runtime.subscribe_events();
|
||||
let cursor = self.runtime.get_thread_detail(&thread_id).await?.latest_seq;
|
||||
@@ -86,7 +90,7 @@ impl AcpServer {
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let terminal = match self
|
||||
.project_turn(&mut projection, &mut receiver, reader, writer)
|
||||
.project_turn(&mut projection, &mut receiver, inbox, emit)
|
||||
.await
|
||||
{
|
||||
Ok(terminal) => terminal,
|
||||
@@ -144,9 +148,6 @@ impl AcpServer {
|
||||
}
|
||||
};
|
||||
claim.armed = false;
|
||||
if let Some(binding) = self.sessions.get_mut(&session_id) {
|
||||
binding.cursor = projection.cursor;
|
||||
}
|
||||
self.save_checkpoint(&thread_id, &session_id).await?;
|
||||
if terminal
|
||||
.model_request_diagnostics
|
||||
@@ -154,11 +155,13 @@ impl AcpServer {
|
||||
.and_then(|facts| facts.stop_reason)
|
||||
== Some(RuntimeTurnStopReason::StepBudgetExhausted)
|
||||
{
|
||||
return Ok("max_turn_requests");
|
||||
return Ok(acp::StopReason::MaxTurnRequests);
|
||||
}
|
||||
match terminal.status {
|
||||
RuntimeTurnStatus::Completed => Ok("end_turn"),
|
||||
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => Ok("cancelled"),
|
||||
RuntimeTurnStatus::Completed => Ok(acp::StopReason::EndTurn),
|
||||
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => {
|
||||
Ok(acp::StopReason::Cancelled)
|
||||
}
|
||||
_ => Err(anyhow!(
|
||||
"{}",
|
||||
terminal.error.unwrap_or_else(
|
||||
@@ -209,12 +212,12 @@ impl AcpServer {
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
async fn project_turn<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
async fn project_turn(
|
||||
&self,
|
||||
projection: &mut Projection<'_>,
|
||||
receiver: &mut tokio::sync::broadcast::Receiver<RuntimeEventRecord>,
|
||||
reader: &mut crate::stdio::BoundedLines<R>,
|
||||
writer: &mut W,
|
||||
inbox: &mut UnboundedReceiver<Inbound>,
|
||||
emit: Emit<'_>,
|
||||
) -> Result<TurnRecord> {
|
||||
let session = projection.session;
|
||||
let thread = projection.thread;
|
||||
@@ -232,45 +235,26 @@ impl AcpServer {
|
||||
}
|
||||
tokio::select! {
|
||||
() = async { match cancel_deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, None => std::future::pending().await } } => return Err(anyhow!("Core cancellation remains nonterminal; ACP cannot report a fabricated cancelled receipt")),
|
||||
line = reader.next_line(), if !eof => {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) if crate::stdio::is_frame_refusal(&error) => {
|
||||
write_jsonrpc_error(writer, None, -32700, error.to_string()).await?;
|
||||
continue;
|
||||
message = inbox.recv(), if !eof => {
|
||||
match message {
|
||||
Some(Inbound::Cancel { session: cancelled_session }) => {
|
||||
if cancelled_session == session {
|
||||
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
|
||||
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let Some(line) = line else {
|
||||
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
|
||||
cancel_deadline = Some(tokio::time::Instant::now() + Duration::from_secs(30)); continue;
|
||||
};
|
||||
if line.trim().is_empty() { continue; }
|
||||
let message: Value = match serde_json::from_str(&line) {
|
||||
Ok(message) => message,
|
||||
Err(error) => { write_jsonrpc_error(writer, None, -32700, format!("invalid json: {error}")).await?; continue; }
|
||||
};
|
||||
if crate::is_control_input_closed(&message) {
|
||||
eof=true; cancelled=true; projection.permissions.clear();self.interrupt_claimed_turn(thread,turn).await?;
|
||||
cancel_deadline.get_or_insert(tokio::time::Instant::now()+Duration::from_secs(30));continue;
|
||||
}
|
||||
let response_id = message.get("id").cloned().map(|id| self.response_id_policy.response_id(id));
|
||||
if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { write_jsonrpc_error(writer, response_id, -32600, "jsonrpc version must be 2.0").await?; continue; }
|
||||
if is_jsonrpc_response(&message) {
|
||||
if !cancelled && let Some(id) = message.get("id").and_then(Value::as_str) && let Some(permission) = projection.permissions.remove(id) { self.answer_permission(&permission, &message).await?; }
|
||||
continue;
|
||||
}
|
||||
if message.get("method").and_then(Value::as_str) == Some("session/cancel") {
|
||||
if message.pointer("/params/sessionId").and_then(Value::as_str) == Some(session) {
|
||||
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
|
||||
Some(Inbound::Permission { key, allow }) => {
|
||||
if !cancelled && let Some(permission) = projection.permissions.remove(&key) { self.answer_permission(&permission, allow).await?; }
|
||||
}
|
||||
Some(Inbound::Eof) | None => {
|
||||
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
|
||||
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
|
||||
}
|
||||
if let Some(id) = response_id { write_jsonrpc_result(writer, id, json!(null)).await?; }
|
||||
} else if let Some(id) = response_id { write_jsonrpc_error(writer, Some(id), -32603, "a session/prompt turn is already in progress").await?; }
|
||||
}
|
||||
}
|
||||
record = async { replay_batch.pop_front().expect("nonempty bounded replay batch") }, if !replay_batch.is_empty() => {
|
||||
projection.emit = !cancelled && !eof;
|
||||
if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); }
|
||||
if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); }
|
||||
}
|
||||
batch = async { replay.as_mut().expect("active replay").batches.recv().await }, if replay.is_some() && replay_batch.is_empty() => {
|
||||
match batch {
|
||||
@@ -280,7 +264,7 @@ impl AcpServer {
|
||||
}
|
||||
event = receiver.recv(), if replay.is_none() && replay_batch.is_empty() => {
|
||||
match event {
|
||||
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); } },
|
||||
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); } },
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
let captured = self.runtime.replay_events(thread, Some(projection.cursor), None).await?;
|
||||
if captured.base_seq > projection.cursor { return Err(anyhow!("ACP replay no longer covers the cursor; projection is incomplete")); }
|
||||
@@ -292,7 +276,7 @@ impl AcpServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn answer_permission(&self, permission: &Permission, response: &Value) -> Result<()> {
|
||||
async fn answer_permission(&self, permission: &Permission, allow: bool) -> Result<()> {
|
||||
let detail = self
|
||||
.runtime
|
||||
.get_thread_detail(&permission.thread_id)
|
||||
@@ -304,15 +288,6 @@ impl AcpServer {
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
let allow = response.get("error").is_none()
|
||||
&& response
|
||||
.pointer("/result/outcome/outcome")
|
||||
.and_then(Value::as_str)
|
||||
== Some("selected")
|
||||
&& response
|
||||
.pointer("/result/outcome/optionId")
|
||||
.and_then(Value::as_str)
|
||||
== Some("allow-once");
|
||||
self.runtime
|
||||
.deliver_approval_from(
|
||||
&permission.approval_id,
|
||||
@@ -327,11 +302,11 @@ impl AcpServer {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn project_event<W: AsyncWrite + Unpin>(
|
||||
async fn project_event(
|
||||
&self,
|
||||
projection: &mut Projection<'_>,
|
||||
record: &RuntimeEventRecord,
|
||||
writer: &mut W,
|
||||
out: Emit<'_>,
|
||||
) -> Result<Option<TurnRecord>> {
|
||||
let session = projection.session;
|
||||
let thread = projection.thread;
|
||||
@@ -353,7 +328,7 @@ impl AcpServer {
|
||||
if emit && p.get("kind").and_then(Value::as_str) == Some("agent_message") =>
|
||||
{
|
||||
if let Some(text) = p.get("delta").and_then(Value::as_str) {
|
||||
write_session_update(writer, session, text.to_string()).await?;
|
||||
out(update(session, agent_text(text.to_string())))?;
|
||||
}
|
||||
}
|
||||
"item.started" => {
|
||||
@@ -373,7 +348,10 @@ impl AcpServer {
|
||||
input: tool.get("input").cloned().unwrap_or(Value::Null),
|
||||
};
|
||||
if emit {
|
||||
write_tool_call_start(writer, session, &call).await?;
|
||||
out(update(
|
||||
session,
|
||||
acp::SessionUpdate::ToolCall(tool_call_start(&call)),
|
||||
))?;
|
||||
}
|
||||
calls.insert(id, call);
|
||||
}
|
||||
@@ -386,7 +364,15 @@ impl AcpServer {
|
||||
let call = calls
|
||||
.get(id)
|
||||
.ok_or_else(|| anyhow!("Dispatch projection lacks its admitted start"))?;
|
||||
write_tool_call_update(writer, session, call, "in_progress", None).await?;
|
||||
out(update(
|
||||
session,
|
||||
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
|
||||
call,
|
||||
acp::ToolCallStatus::InProgress,
|
||||
None,
|
||||
&[],
|
||||
)),
|
||||
))?;
|
||||
}
|
||||
"item.completed" | "item.failed" | "item.interrupted" if emit => {
|
||||
if let Some(item) = p.get("item")
|
||||
@@ -403,19 +389,19 @@ impl AcpServer {
|
||||
.unwrap_or_default();
|
||||
let status = if item.get("status").and_then(Value::as_str) == Some("completed")
|
||||
{
|
||||
"completed"
|
||||
acp::ToolCallStatus::Completed
|
||||
} else {
|
||||
"failed"
|
||||
acp::ToolCallStatus::Failed
|
||||
};
|
||||
write_tool_call_update_with_blocks(
|
||||
writer,
|
||||
out(update(
|
||||
session,
|
||||
&call,
|
||||
status,
|
||||
item.get("detail").and_then(Value::as_str),
|
||||
&blocks,
|
||||
)
|
||||
.await?;
|
||||
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
|
||||
&call,
|
||||
status,
|
||||
item.get("detail").and_then(Value::as_str),
|
||||
&blocks,
|
||||
)),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
"approval.required" if emit => {
|
||||
@@ -436,11 +422,41 @@ impl AcpServer {
|
||||
let call = calls
|
||||
.get(execution)
|
||||
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
|
||||
let id = format!(
|
||||
"codewhale-permission-{}",
|
||||
NEXT_ACP_PERMISSION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let title = tool_call_title(call);
|
||||
let text = p
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map_or_else(|| title.clone(), str::to_string);
|
||||
let request = acp::RequestPermissionRequest::new(
|
||||
session.to_string(),
|
||||
acp::ToolCallUpdate::new(
|
||||
execution.to_string(),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
.title(title)
|
||||
.kind(tool_call_kind(call))
|
||||
.status(acp::ToolCallStatus::Pending)
|
||||
.raw_input((!call.input.is_null()).then(|| call.input.clone()))
|
||||
.content(vec![acp::ToolCallContent::from(text)]),
|
||||
),
|
||||
vec![
|
||||
acp::PermissionOption::new(
|
||||
"allow-once",
|
||||
"Allow once",
|
||||
acp::PermissionOptionKind::AllowOnce,
|
||||
),
|
||||
acp::PermissionOption::new(
|
||||
"reject-once",
|
||||
"Reject",
|
||||
acp::PermissionOptionKind::RejectOnce,
|
||||
),
|
||||
],
|
||||
);
|
||||
write_json_line(writer, json!({"jsonrpc":"2.0","id":id,"method":"session/request_permission","params":{"sessionId":session,"toolCall":{"toolCallId":execution,"title":tool_call_title(call),"kind":tool_call_kind(call),"status":"pending","rawInput":call.input,"content":[{"type":"content","content":{"type":"text","text":p.get("description")}}]},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}})).await?;
|
||||
out(Outgoing::Permission {
|
||||
key: id.clone(),
|
||||
request,
|
||||
})?;
|
||||
permissions.insert(
|
||||
id,
|
||||
Permission {
|
||||
@@ -489,6 +505,22 @@ impl AcpServer {
|
||||
mod tests {
|
||||
use super::super::tests::{Rig, fixture_config};
|
||||
use super::*;
|
||||
fn frame(outgoing: Outgoing) -> Value {
|
||||
match outgoing {
|
||||
Outgoing::Update(notification) => {
|
||||
json!({"jsonrpc":"2.0","method":"session/update","params":serde_json::to_value(notification).unwrap()})
|
||||
}
|
||||
Outgoing::Permission { key, request } => {
|
||||
json!({"jsonrpc":"2.0","id":key,"method":"session/request_permission","params":serde_json::to_value(request).unwrap()})
|
||||
}
|
||||
}
|
||||
}
|
||||
fn recording(out: &mut Vec<Value>) -> impl FnMut(Outgoing) -> Result<()> + Send + '_ {
|
||||
move |outgoing| {
|
||||
out.push(frame(outgoing));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn event(seq: u64, thread: &str, turn: &str, name: &str, payload: Value) -> RuntimeEventRecord {
|
||||
serde_json::from_value(json!({"seq":seq,"timestamp":chrono::Utc::now(),"thread_id":thread,"turn_id":turn,"event":name,"payload":payload})).unwrap()
|
||||
}
|
||||
@@ -606,14 +638,15 @@ mod tests {
|
||||
calls: HashMap::new(),
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut output: Vec<Value> = Vec::new();
|
||||
// Project the actual Runtime records, not synthetic ACP frames.
|
||||
for record in rig.server.runtime.events_since(&thread, None)? {
|
||||
rig.server
|
||||
.project_event(&mut projection, &record, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut projection, &record, &mut recording(&mut output))
|
||||
.await?;
|
||||
}
|
||||
let wire = super::super::tests::parse_lines(output);
|
||||
let wire = output;
|
||||
let requests: Vec<_> = wire
|
||||
.iter()
|
||||
.filter(|frame| frame["method"] == "session/request_permission")
|
||||
@@ -635,12 +668,8 @@ mod tests {
|
||||
)
|
||||
.ok_or_else(|| anyhow!("ACP permission was not retained"))?;
|
||||
rig.server
|
||||
.answer_permission(
|
||||
&permission,
|
||||
&json!({
|
||||
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}
|
||||
}),
|
||||
)
|
||||
.prompt_run()
|
||||
.answer_permission(&permission, true)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(10), engine.recv_approval_event())
|
||||
@@ -758,7 +787,7 @@ mod tests {
|
||||
})
|
||||
.await??;
|
||||
let mut terminal = None;
|
||||
let mut settled_output = Vec::new();
|
||||
let mut settled_output: Vec<Value> = Vec::new();
|
||||
for record in rig
|
||||
.server
|
||||
.runtime
|
||||
@@ -766,7 +795,12 @@ mod tests {
|
||||
{
|
||||
if let Some(completed) = rig
|
||||
.server
|
||||
.project_event(&mut projection, &record, &mut settled_output)
|
||||
.prompt_run()
|
||||
.project_event(
|
||||
&mut projection,
|
||||
&record,
|
||||
&mut recording(&mut settled_output),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
terminal = Some(completed);
|
||||
@@ -781,7 +815,7 @@ mod tests {
|
||||
assert!(projection.permissions.is_empty());
|
||||
assert!(projection.calls.is_empty());
|
||||
assert!(
|
||||
!super::super::tests::parse_lines(settled_output)
|
||||
!settled_output
|
||||
.iter()
|
||||
.any(|frame| frame["method"] == "session/request_permission")
|
||||
);
|
||||
@@ -804,7 +838,7 @@ mod tests {
|
||||
calls: HashMap::new(),
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut output: Vec<Value> = Vec::new();
|
||||
let start = event(
|
||||
1,
|
||||
&thread,
|
||||
@@ -813,11 +847,13 @@ mod tests {
|
||||
json!({"tool":{"id":"core-call","name":"read","input":{"path":"x"}}}),
|
||||
);
|
||||
rig.server
|
||||
.project_event(&mut p, &start, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &start, &mut recording(&mut output))
|
||||
.await?;
|
||||
let before = output.len();
|
||||
rig.server
|
||||
.project_event(&mut p, &start, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &start, &mut recording(&mut output))
|
||||
.await?;
|
||||
assert_eq!(output.len(), before);
|
||||
let dispatch = event(
|
||||
@@ -829,7 +865,8 @@ mod tests {
|
||||
);
|
||||
assert!(
|
||||
rig.server
|
||||
.project_event(&mut p, &dispatch, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &dispatch, &mut recording(&mut output))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -850,7 +887,7 @@ mod tests {
|
||||
calls: HashMap::new(),
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut output: Vec<Value> = Vec::new();
|
||||
for record in [
|
||||
event(
|
||||
1,
|
||||
@@ -882,7 +919,8 @@ mod tests {
|
||||
),
|
||||
] {
|
||||
rig.server
|
||||
.project_event(&mut p, &record, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &record, &mut recording(&mut output))
|
||||
.await?;
|
||||
}
|
||||
assert!(output.is_empty());
|
||||
@@ -904,7 +942,7 @@ mod tests {
|
||||
calls: HashMap::new(),
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut output: Vec<Value> = Vec::new();
|
||||
let failure = event(
|
||||
1,
|
||||
&thread,
|
||||
@@ -914,7 +952,8 @@ mod tests {
|
||||
);
|
||||
let error = rig
|
||||
.server
|
||||
.project_event(&mut p, &failure, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &failure, &mut recording(&mut output))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("no terminal receipt"));
|
||||
@@ -922,90 +961,4 @@ mod tests {
|
||||
rig.close().await;
|
||||
Ok(())
|
||||
}
|
||||
struct FailedAnswerWriter(Vec<u8>);
|
||||
impl AsyncWrite for FailedAnswerWriter {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
bytes: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
if String::from_utf8_lossy(bytes).contains("agent_message_chunk") {
|
||||
return std::task::Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"fixture client disappeared after tool effect",
|
||||
)));
|
||||
}
|
||||
self.0.extend_from_slice(bytes);
|
||||
std::task::Poll::Ready(Ok(bytes.len()))
|
||||
}
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn failed_transport_after_completed_write_retains_actual_core_terminal_and_checkpoint()
|
||||
-> Result<()> {
|
||||
use codewhale_engine::llm_client::mock::canned;
|
||||
let mut config = fixture_config();
|
||||
config.yolo = Some(true);
|
||||
let mut rig = Rig::new(
|
||||
config,
|
||||
vec![
|
||||
canned::tool_call_turn(
|
||||
"write",
|
||||
"write",
|
||||
r#"{"path":"writer-failure.txt","content":"completed"}"#,
|
||||
),
|
||||
canned::simple_text_turn("final answer"),
|
||||
],
|
||||
)?;
|
||||
let id = rig.new_session().await;
|
||||
let (client, input) = tokio::io::duplex(1024);
|
||||
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(input));
|
||||
let mut writer = FailedAnswerWriter(Vec::new());
|
||||
let error = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
rig.server.drive_prompt(
|
||||
json!({"sessionId":id,"prompt":"write then answer"}),
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
.unwrap_err();
|
||||
drop(client);
|
||||
assert!(error.to_string().contains("partial receipts retained"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(rig.workspace.join("writer-failure.txt"))?,
|
||||
"completed"
|
||||
);
|
||||
let store = codewhale_engine::session_manager::SessionManager::new(
|
||||
rig.server.sessions_dir.clone(),
|
||||
)?;
|
||||
let saved = store.load_session(&id)?;
|
||||
assert!(
|
||||
saved
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(|m| &m.content)
|
||||
.any(|b| matches!(b, codewhale_models::ContentBlock::ToolResult { .. }))
|
||||
);
|
||||
let thread = &rig.server.sessions[&id].thread_id;
|
||||
let detail = rig.server.runtime.get_thread_detail(thread).await?;
|
||||
assert!(!matches!(
|
||||
detail.turns.last().unwrap().status,
|
||||
RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress
|
||||
));
|
||||
rig.close().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,34 @@ mod platform {
|
||||
tokio::io::WriteHalf<Connection>,
|
||||
>;
|
||||
|
||||
fn resolved_store_form(path: PathBuf) -> Option<PathBuf> {
|
||||
let checked = codewhale_engine::runtime_threads::checked_runtime_store_root(path).ok()?;
|
||||
let mut tail = Vec::new();
|
||||
let mut probe = checked.as_path();
|
||||
loop {
|
||||
match probe.canonicalize() {
|
||||
Ok(real) => {
|
||||
return Some(tail.iter().rev().fold(real, |acc, part| acc.join(part)));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
tail.push(probe.file_name()?.to_owned());
|
||||
probe = probe.parent()?;
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_store_form_of(path: &std::path::Path) -> PathBuf {
|
||||
resolved_store_form(path.to_path_buf()).unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn same_store_path(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
left == right
|
||||
|| (cfg!(any(target_os = "macos", windows))
|
||||
&& left.to_string_lossy().to_lowercase() == right.to_string_lossy().to_lowercase())
|
||||
}
|
||||
|
||||
fn same_socket_selection(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
if left == right {
|
||||
return true;
|
||||
@@ -227,6 +255,34 @@ mod platform {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
|
||||
let gone = resolved_store_form(selected_store).is_some_and(|selected| {
|
||||
same_store_path(&selected, &resolved_store_form_of(&self.receipt().data_dir))
|
||||
&& matches!(
|
||||
selected.symlink_metadata(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||||
)
|
||||
});
|
||||
if !gone || !matches!(self.revalidate_receipt(), Ok(true)) {
|
||||
return Ok(false);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
self.publication.parent.retire_private_receipt(
|
||||
&self.publication.receipt_name,
|
||||
&self.publication.file
|
||||
)?,
|
||||
PublicationChangedBeforeAttach
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
async fn discard_if_store_gone(
|
||||
self: &Arc<Self>,
|
||||
selected_store: &std::path::Path,
|
||||
) -> Result<bool> {
|
||||
let held = self.clone();
|
||||
let selected_store = selected_store.to_path_buf();
|
||||
owner_work(move || held.discard_if_store_gone_blocking(selected_store)).await
|
||||
}
|
||||
/// Caller retains the real Runtime lease; this checks evidence only.
|
||||
pub(crate) fn revalidate_receipt(&self) -> Result<bool> {
|
||||
if !self.publication.receipt_is_current()? {
|
||||
@@ -345,8 +401,9 @@ mod platform {
|
||||
pub async fn probe_owner_for_host_startup(
|
||||
config_path: Option<PathBuf>,
|
||||
selected: Option<PathBuf>,
|
||||
selected_store: &std::path::Path,
|
||||
) -> Result<HostOwnerProbe> {
|
||||
probe_frontend(
|
||||
match probe_frontend(
|
||||
config_path,
|
||||
selected,
|
||||
crate::daemon_socket::AttachFrontend::Control,
|
||||
@@ -355,7 +412,15 @@ mod platform {
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
{
|
||||
HostOwnerProbe::Unavailable(publication)
|
||||
if publication.discard_if_store_gone(selected_store).await? =>
|
||||
{
|
||||
Ok(HostOwnerProbe::Absent)
|
||||
}
|
||||
probe => Ok(probe),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
|
||||
@@ -1349,6 +1349,7 @@ mod platform {
|
||||
let config = root.join("config.toml");
|
||||
std::fs::write(&config, "")?;
|
||||
let parent = PrivateDirectory::admit(path.parent().unwrap())?;
|
||||
std::fs::create_dir_all(root.join("runtime"))?;
|
||||
let stale = std::os::unix::net::UnixListener::bind(&path)?;
|
||||
let identity = parent.socket_identity("daemon.sock")?.unwrap();
|
||||
parent.protect_socket("daemon.sock", identity)?;
|
||||
@@ -1383,6 +1384,7 @@ mod platform {
|
||||
crate::daemon_client::probe_owner_for_host_startup(
|
||||
Some(config.clone()),
|
||||
Some(path.clone()),
|
||||
&owner.data_dir,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
@@ -1392,6 +1394,7 @@ mod platform {
|
||||
let observed_hook = observed.clone();
|
||||
let selected = path.clone();
|
||||
let selected_config = config.clone();
|
||||
let selected_store = owner.data_dir.clone();
|
||||
let stale_capture = captured.clone();
|
||||
BEFORE_OWNER_REBIND.with(|slot| {
|
||||
*slot.borrow_mut() = Some(Box::new(move |endpoint| {
|
||||
@@ -1412,6 +1415,7 @@ mod platform {
|
||||
crate::daemon_client::probe_owner_for_host_startup(
|
||||
Some(selected_config),
|
||||
Some(endpoint),
|
||||
&selected_store,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
|
||||
@@ -668,6 +668,7 @@ pub async fn run_exec_agent(
|
||||
execution_config.subagent_heartbeat_timeout_secs_for_provider(&effective_identity),
|
||||
),
|
||||
prefer_bwrap: execution_config.prefers_bwrap(),
|
||||
tool_launcher: execution_config.tool_launcher.clone(),
|
||||
bwrap_extensions: codewhale_sandbox::BwrapMountExtensions {
|
||||
read_only_roots: execution_config.bwrap_ro_roots.clone(),
|
||||
device_roots: execution_config.bwrap_dev_roots.clone(),
|
||||
|
||||
@@ -76,6 +76,18 @@ impl McpServerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse_shell_tools(&mut self) -> Vec<String> {
|
||||
let (refused, kept): (Vec<String>, Vec<String>) = std::mem::take(&mut self.expose_tools)
|
||||
.into_iter()
|
||||
.partition(|name| {
|
||||
build_exposed_tools(std::slice::from_ref(name))
|
||||
.iter()
|
||||
.any(|tool| tool.internal == "exec_shell")
|
||||
});
|
||||
self.expose_tools = kept;
|
||||
refused
|
||||
}
|
||||
|
||||
fn from_toml(contents: &str) -> Result<Self> {
|
||||
let config: McpServerConfigFile = toml::from_str(contents)?;
|
||||
Ok(Self {
|
||||
@@ -98,12 +110,21 @@ pub async fn run_mcp_server(
|
||||
workspace: PathBuf,
|
||||
skills_dir: PathBuf,
|
||||
skills_config: std::sync::Arc<codewhale_runtime::config::SkillsConfig>,
|
||||
tool_launcher_configured: bool,
|
||||
) -> Result<()> {
|
||||
// Settings load is a synchronous config read; keep it off the async
|
||||
// worker per the blocking-call convention.
|
||||
let settings = tokio::task::spawn_blocking(McpServerSettings::load)
|
||||
let mut settings = tokio::task::spawn_blocking(McpServerSettings::load)
|
||||
.await
|
||||
.context("MCP server settings task failed")??;
|
||||
if tool_launcher_configured {
|
||||
for name in settings.refuse_shell_tools() {
|
||||
eprintln!(
|
||||
"codewhale mcp server: not exposing '{name}': the managed configuration sets \
|
||||
tool_launcher, and this server does not run commands through it."
|
||||
);
|
||||
}
|
||||
}
|
||||
let server = McpServer::new(workspace, settings, skills_dir, skills_config)?;
|
||||
// stdout carries the protocol; the notice goes to stderr so an operator
|
||||
// whose config lists write tools sees why they are missing.
|
||||
@@ -906,6 +927,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_launcher_refuses_shell_tools_even_when_the_operator_allows_them() {
|
||||
let mut settings = McpServerSettings::from_toml(
|
||||
"[server]\nexpose_tools = [\"file_read\", \"shell\", \"exec_shell\", \"Bash\"]\nrequire_approval = false\n",
|
||||
)
|
||||
.expect("parse config");
|
||||
assert_eq!(settings.refuse_shell_tools(), vec!["shell", "exec_shell"]);
|
||||
let server = test_server(PathBuf::from("."), settings);
|
||||
let names: Vec<&str> = server.tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||
assert_eq!(names, vec!["file_read"]);
|
||||
for internal in ["exec_shell", "Bash", "bash"] {
|
||||
assert!(server.registry.get(internal).is_none(), "{internal}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_config_without_require_approval_withholds_and_names_write_tools() {
|
||||
let settings = McpServerSettings::from_toml(
|
||||
|
||||
@@ -1323,6 +1323,7 @@ struct ThreadSummary {
|
||||
preview: String,
|
||||
model: String,
|
||||
mode: String,
|
||||
agent_preset: String,
|
||||
workspace: PathBuf,
|
||||
branch: Option<String>,
|
||||
head: Option<String>,
|
||||
@@ -1578,10 +1579,41 @@ struct RuntimeInfoResponse {
|
||||
job_operation_store_id: Option<String>,
|
||||
account: RuntimeAccountInfo,
|
||||
experimental: RuntimeExperimentalCapabilities,
|
||||
sandbox: RuntimeSandboxInfo,
|
||||
// Backward-compatible alias kept for existing clients.
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RuntimeSandboxInfo {
|
||||
kind: String,
|
||||
tool_launcher: bool,
|
||||
}
|
||||
|
||||
async fn runtime_sandbox_info(state: &RuntimeApiState) -> RuntimeSandboxInfo {
|
||||
let (prefer_bwrap, tool_launcher, denied_read_paths) = {
|
||||
let cfg = state.config.read();
|
||||
(
|
||||
cfg.prefers_bwrap(),
|
||||
cfg.tool_launcher.clone(),
|
||||
codewhale_sandbox::read_guard::ReadDenylist::from_config(&cfg).subtree_paths(),
|
||||
)
|
||||
};
|
||||
let launcher_configured = tool_launcher.is_some();
|
||||
let kind = tokio::task::spawn_blocking(move || {
|
||||
let mut manager = codewhale_sandbox::SandboxManager::with_bwrap_preference(prefer_bwrap);
|
||||
manager.set_tool_launcher(tool_launcher);
|
||||
manager.set_denied_read_subpaths(denied_read_paths);
|
||||
manager.configured_sandbox().unwrap_or_default().to_string()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| codewhale_sandbox::SandboxType::None.to_string());
|
||||
RuntimeSandboxInfo {
|
||||
kind,
|
||||
tool_launcher: launcher_configured,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_runtime_capabilities() -> RuntimeCapabilities {
|
||||
RuntimeCapabilities {
|
||||
client_token_intents: true,
|
||||
@@ -2035,7 +2067,7 @@ async fn admit_runtime_host(
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let probe = match crate::daemon_client::probe_owner_for_host_startup(
|
||||
config_path.clone(), selected_socket.clone(),
|
||||
config_path.clone(), selected_socket.clone(), &selected_store,
|
||||
).await {
|
||||
Err(error) if error.is::<PublicationChangedBeforeAttach>() => {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
@@ -3283,6 +3315,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.route("/v1/tasks/{id}", get(get_task))
|
||||
.route("/v1/tasks/{id}/cancel", post(cancel_task))
|
||||
.route("/v1/skills", get(list_skills))
|
||||
.route("/v1/modes", get(list_modes))
|
||||
.route("/v1/commands", get(list_commands))
|
||||
.route("/v1/threads/{id}/commands", post(run_thread_command))
|
||||
.route(
|
||||
@@ -3904,7 +3937,7 @@ async fn create_thread(
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let Some(refusal) = external_agent_refusal(&e) {
|
||||
if let Some(refusal) = external_agent_refusal(&e).or_else(|| mode_refusal(&e)) {
|
||||
refusal
|
||||
} else if e
|
||||
.is::<codewhale_engine::runtime_threads::manager::RuntimeThreadCreateConflict>()
|
||||
@@ -4153,6 +4186,10 @@ async fn list_threads_summary(
|
||||
preview,
|
||||
model: thread.model,
|
||||
mode: thread.mode,
|
||||
agent_preset: codewhale_engine::conversation_modes::effective_id(
|
||||
thread.agent_preset.as_deref(),
|
||||
)
|
||||
.to_string(),
|
||||
branch: workspace_git.and_then(|git| git.branch.clone()),
|
||||
head: workspace_git.and_then(|git| git.head.clone()),
|
||||
dirty: workspace_git.is_some_and(|git| git.dirty),
|
||||
@@ -6217,6 +6254,17 @@ async fn list_hooks(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_modes(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Json<codewhale_engine::conversation_modes::ConversationModeTable> {
|
||||
Json(
|
||||
codewhale_engine::conversation_modes::current_table(
|
||||
state.runtime_threads.extension_host_options(),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_skills(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<SkillsResponse>, ApiError> {
|
||||
@@ -7083,6 +7131,7 @@ async fn runtime_info(
|
||||
capabilities.fleet_run_create = !session_only;
|
||||
capabilities.fleet_run_start = !session_only;
|
||||
capabilities.fleet_local_target = !session_only;
|
||||
let sandbox = runtime_sandbox_info(&state).await;
|
||||
Json(RuntimeInfoResponse {
|
||||
service: "codewhale-runtime-api",
|
||||
runtime_api_version: RUNTIME_API_VERSION,
|
||||
@@ -7097,6 +7146,7 @@ async fn runtime_info(
|
||||
.then(|| state.runtime_threads.job_operation_store_id().to_string()),
|
||||
account,
|
||||
experimental: RuntimeExperimentalCapabilities::default(),
|
||||
sandbox,
|
||||
version,
|
||||
})
|
||||
}
|
||||
@@ -14796,6 +14846,20 @@ fn map_automation_err(err: anyhow::Error) -> ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_refusal(err: &anyhow::Error) -> Option<ApiError> {
|
||||
use codewhale_engine::conversation_modes::ModeRefusal;
|
||||
let error = err.downcast_ref::<ModeRefusal>()?;
|
||||
Some(ApiError {
|
||||
status: match error.code {
|
||||
ModeRefusal::MODE_UNAVAILABLE => StatusCode::FAILED_DEPENDENCY,
|
||||
ModeRefusal::MODE_LOCKED => StatusCode::CONFLICT,
|
||||
_ => StatusCode::BAD_REQUEST,
|
||||
},
|
||||
message: error.message.clone(),
|
||||
code: Some(error.code),
|
||||
})
|
||||
}
|
||||
|
||||
fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
|
||||
use codewhale_engine::fleet::agents::ExecutorUnavailable;
|
||||
let error = err.downcast_ref::<ExecutorUnavailable>()?;
|
||||
@@ -14811,7 +14875,7 @@ fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
|
||||
}
|
||||
|
||||
fn map_thread_err(err: anyhow::Error) -> ApiError {
|
||||
if let Some(refusal) = external_agent_refusal(&err) {
|
||||
if let Some(refusal) = external_agent_refusal(&err).or_else(|| mode_refusal(&err)) {
|
||||
return refusal;
|
||||
}
|
||||
if let Some(error) = err.downcast_ref::<codewhale_engine::runtime_threads::manager::RuntimeTurnOperationLookupError>() {
|
||||
|
||||
@@ -515,9 +515,11 @@ fn parse_porcelain(porcelain: &str) -> Vec<GitFileEntry> {
|
||||
}
|
||||
let index = record.as_bytes()[0] as char;
|
||||
let worktree = record.as_bytes()[1] as char;
|
||||
let path = record[3..].to_string();
|
||||
let Some(path) = record.get(3..).map(str::to_string) else {
|
||||
continue;
|
||||
};
|
||||
let mut old_path = None;
|
||||
if matches!(index, 'R' | 'C') {
|
||||
if matches!(index, 'R' | 'C') || matches!(worktree, 'R' | 'C') {
|
||||
old_path = records.next().map(str::to_string);
|
||||
}
|
||||
entries.push(GitFileEntry {
|
||||
@@ -2909,6 +2911,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_rename_consumes_its_original_path_record() {
|
||||
let rows = parse_porcelain(" R new.md\0ab说明.md\0 M a.txt\0");
|
||||
let rows: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|row| (row.path.as_str(), row.status, row.old_path.as_deref()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("new.md", "renamed", Some("ab说明.md")),
|
||||
("a.txt", "modified", None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn repo() -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let dir = tmp.path();
|
||||
|
||||
@@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use codewhale_plugins::host_services::{PersonVia, PluginApprover, ReviewApproval};
|
||||
use codewhale_plugins::marketplace::document::{
|
||||
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
|
||||
};
|
||||
@@ -1432,8 +1433,16 @@ async fn run_registry_mutation(
|
||||
|
||||
let mut registry = (*registry).clone();
|
||||
let result = match mutation {
|
||||
RegistryMutation::Trust { .. } => registry.trust(selector),
|
||||
RegistryMutation::Enable => registry.enable(selector),
|
||||
RegistryMutation::Trust { .. } => registry.trust_reviewed(
|
||||
selector,
|
||||
ReviewApproval {
|
||||
approved_by: PluginApprover::person(PersonVia::App),
|
||||
host_services: None,
|
||||
},
|
||||
),
|
||||
RegistryMutation::Enable => {
|
||||
registry.enable_by(selector, PluginApprover::person(PersonVia::App))
|
||||
}
|
||||
RegistryMutation::Restage => registry.restage(selector),
|
||||
RegistryMutation::Disable => registry.disable(selector),
|
||||
RegistryMutation::Revoke => registry.revoke_trust(selector),
|
||||
|
||||
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
||||
mod command_catalog;
|
||||
#[cfg(unix)]
|
||||
mod control_socket_selection;
|
||||
mod conversation_modes;
|
||||
#[cfg(unix)]
|
||||
mod external_agent_options;
|
||||
#[cfg(unix)]
|
||||
@@ -423,6 +424,7 @@ fn saved_session_with_blocks(blocks: Vec<codewhale_models::ContentBlock>) -> Sav
|
||||
composer_color: None,
|
||||
last_auto_route: None,
|
||||
turn_outcomes: Vec::new(),
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,6 +565,7 @@ fn messages_from_thread_detail_batches_tool_results() {
|
||||
last_read: None,
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
};
|
||||
let turn = TurnRecord {
|
||||
source_usage_origin_turn_id: None,
|
||||
@@ -795,6 +798,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
|
||||
last_read: None,
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
},
|
||||
turns: Vec::new(),
|
||||
items: Vec::new(),
|
||||
@@ -11083,6 +11087,8 @@ async fn runtime_info_reports_bind_state() -> Result<()> {
|
||||
assert!(info["account"].get("refresh_token").is_none());
|
||||
assert!(info["account"].get("email").is_none());
|
||||
assert!(info["experimental"].is_object());
|
||||
assert!(info["sandbox"]["kind"].is_string());
|
||||
assert_eq!(info["sandbox"]["tool_launcher"], false);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
use super::*;
|
||||
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
|
||||
|
||||
#[tokio::test]
|
||||
async fn modes_route_lists_the_table_and_threads_carry_and_refuse_modes() -> Result<()> {
|
||||
let _unavailable = CreatorAvailabilityGuard::unforced();
|
||||
let Some((addr, _threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let base = format!("http://{addr}");
|
||||
|
||||
let table: Value = client
|
||||
.get(format!("{base}/v1/modes"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(table["default"], "standard");
|
||||
assert_eq!(table["modes"][0]["id"], "standard");
|
||||
assert_eq!(table["modes"][0]["source"], "builtin");
|
||||
assert_eq!(table["modes"][0]["available"], true);
|
||||
assert!(table["modes"][0].get("reason").is_none());
|
||||
assert_eq!(table["modes"][1]["id"], "creator");
|
||||
assert_eq!(table["modes"][1]["available"], false);
|
||||
assert_eq!(
|
||||
table["modes"][1]["reason"],
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON
|
||||
);
|
||||
|
||||
let unavailable = client
|
||||
.post(format!("{base}/v1/threads"))
|
||||
.json(&json!({"agent_preset": "creator"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(unavailable.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
|
||||
let body: Value = unavailable.json().await?;
|
||||
assert_eq!(body["error"]["code"], "mode_unavailable");
|
||||
|
||||
let unknown = client
|
||||
.post(format!("{base}/v1/threads"))
|
||||
.json(&json!({"agent_preset": "minimal"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(unknown.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
let body: Value = unknown.json().await?;
|
||||
assert_eq!(body["error"]["code"], "unknown_mode");
|
||||
|
||||
let created: Value = client
|
||||
.post(format!("{base}/v1/threads"))
|
||||
.json(&json!({"agent_preset": "standard"}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let id = created["id"].as_str().expect("thread id").to_string();
|
||||
let summary: Value = client
|
||||
.get(format!("{base}/v1/threads/summary"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let row = summary
|
||||
.as_array()
|
||||
.and_then(|rows| rows.iter().find(|row| row["id"] == id.as_str()))
|
||||
.expect("summary row");
|
||||
assert_eq!(row["agent_preset"], "standard");
|
||||
|
||||
let patched = client
|
||||
.patch(format!("{base}/v1/threads/{id}"))
|
||||
.json(&json!({"agent_preset": "creator"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(patched.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
|
||||
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -135,6 +135,11 @@ async fn ui(
|
||||
Ok((status, response.json().await?))
|
||||
}
|
||||
|
||||
async fn mutate(client: &reqwest::Client, url: String) -> Result<(StatusCode, String)> {
|
||||
let response = client.post(url).send().await?;
|
||||
Ok((response.status(), response.text().await?))
|
||||
}
|
||||
|
||||
fn refusal(status: u16, body: &serde_json::Value) -> (u16, String) {
|
||||
(
|
||||
status,
|
||||
@@ -370,14 +375,14 @@ async fn plugin_ui_dsh_client_serves_admitted_seats_and_denies_and_revokes() ->
|
||||
.is_client_error()
|
||||
);
|
||||
|
||||
let disabled = client.post(format!("{base}/{name}/disable")).send().await?;
|
||||
assert_eq!(disabled.status(), StatusCode::OK);
|
||||
let (status, body) = mutate(&client, format!("{base}/{name}/disable")).await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
for url in [&asset_url, &route] {
|
||||
let response = client.get(url).header("If-Match", &revision).send().await?;
|
||||
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED, "{url}");
|
||||
}
|
||||
let enabled = client.post(format!("{base}/{name}/enable")).send().await?;
|
||||
assert_eq!(enabled.status(), StatusCode::OK);
|
||||
let (status, body) = mutate(&client, format!("{base}/{name}/enable")).await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let stale = client
|
||||
.get(&asset_url)
|
||||
.header("If-Match", &revision)
|
||||
@@ -459,13 +464,22 @@ async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() ->
|
||||
fs::create_dir_all(&root)?;
|
||||
let bundle = write_native_ui_bundle(tmp.path(), "nativeui")?;
|
||||
|
||||
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
|
||||
let Some((addr, handle)) = spawn_plugin_api_server(root.clone(), workspace.clone()).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let base = format!("http://{addr}/v1/apps/plugins");
|
||||
let installed = install_active(&client, &base, format!("path:{}", bundle.display())).await?;
|
||||
let name = installed["name"].as_str().unwrap().to_string();
|
||||
let recorded =
|
||||
isolated_plugin_discovery(&root, &workspace).inspect_registry_for_workspace(&workspace);
|
||||
let by_app = codewhale_plugins::host_services::PluginApprover::person(
|
||||
codewhale_plugins::host_services::PersonVia::App,
|
||||
);
|
||||
let plugin = recorded.get(&name).unwrap();
|
||||
assert_eq!(plugin.approved_by.as_ref(), Some(&by_app));
|
||||
assert_eq!(plugin.enabled_by.as_ref(), Some(&by_app));
|
||||
|
||||
let (status, minted) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
|
||||
assert_eq!(status, 200, "{minted}");
|
||||
@@ -509,8 +523,8 @@ async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() ->
|
||||
let body: serde_json::Value = routed.json().await?;
|
||||
assert_eq!(body["error"]["code"], "plugin_route_not_admitted");
|
||||
|
||||
let revoked = client.post(format!("{base}/{name}/revoke")).send().await?;
|
||||
assert_eq!(revoked.status(), StatusCode::OK);
|
||||
let (status, body) = mutate(&client, format!("{base}/{name}/revoke")).await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let after = client
|
||||
.get(&asset)
|
||||
.header("If-Match", &revision)
|
||||
|
||||
@@ -559,6 +559,7 @@ impl RuntimeChatRelayHost {
|
||||
parent_thread_id: None,
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
operation_key: None,
|
||||
model: Some(command.model.clone()),
|
||||
model_provider: Some(command.model_provider.clone()),
|
||||
@@ -721,6 +722,7 @@ impl RuntimeChatRelayHost {
|
||||
parent_thread_id: None,
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
environments: Vec::new(),
|
||||
})
|
||||
|
||||
@@ -18,50 +18,12 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
|
||||
const KNOWN: &[Allowed] = &[
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "initialize response",
|
||||
problem: "field `.agentCapabilities.modelSelection` is not in the official type",
|
||||
reason: "Codewhale extension flag; official AgentCapabilities has no modelSelection",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/new response",
|
||||
problem: "field `.models` is not in the official type",
|
||||
reason: "pre-configOptions model selector; official NewSessionResponse has no models",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/load response",
|
||||
problem: "field `.models` is not in the official type",
|
||||
reason: "pre-configOptions model selector; official LoadSessionResponse has no models",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/load response",
|
||||
problem: "field `.sessionId` is not in the official type",
|
||||
reason: "session/load reuses the session/new body; official LoadSessionResponse has no sessionId",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/list response",
|
||||
problem: "field `.sessions[].createdAt` is not in the official type",
|
||||
reason: "official SessionInfo carries updatedAt only",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/list response",
|
||||
problem: "field `.sessions[].messageCount` is not in the official type",
|
||||
reason: "official SessionInfo has no message count",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "response",
|
||||
problem: "answers no outstanding request (id null)",
|
||||
reason: "an unknown notification is answered with an id-null -32601 error; JSON-RPC forbids replying to a notification",
|
||||
},
|
||||
];
|
||||
const KNOWN: &[Allowed] = &[Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/load response",
|
||||
problem: "field `.sessionId` is not in the official type",
|
||||
reason: "session/load keeps the session/new body with its root sessionId through the 0.10.3 tag; official LoadSessionResponse has no sessionId",
|
||||
}];
|
||||
|
||||
fn wire<T: Serialize>(value: T) -> Value {
|
||||
serde_json::to_value(value).expect("official type serializes")
|
||||
|
||||
@@ -8,7 +8,8 @@ use codewhale_engine::runtime_threads::RuntimeTurnStatus;
|
||||
use codewhale_engine::runtime_threads::manager::{
|
||||
RuntimeThreadManager, RuntimeThreadManagerConfig,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
@@ -296,3 +297,203 @@ async fn journey() -> Result<()> {
|
||||
manager.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MCP_FIXTURE: &str = r#"import readline from 'node:readline';
|
||||
readline.createInterface({input:process.stdin}).on('line', line => {
|
||||
const r = JSON.parse(line); if (r.id === undefined) return;
|
||||
let result = {};
|
||||
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
|
||||
if (r.method === 'tools/list') result = {tools:[{name:'echo',description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}]};
|
||||
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-echo:' + r.params.arguments.text}]};
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
|
||||
});
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn plugin_and_mcp_tools_run_as_acp_tool_calls_through_the_permission_dial() -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(64 << 20)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(120), extension_journey())
|
||||
.await
|
||||
.map_err(|_| anyhow!("extension journey exceeded 120s"))?
|
||||
})
|
||||
})?
|
||||
.join()
|
||||
.map_err(|_| anyhow!("extension journey thread panicked"))?
|
||||
}
|
||||
|
||||
async fn extension_journey() -> Result<()> {
|
||||
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
|
||||
let Some(node) = node_for_tests("plugin_and_mcp_tools_run_as_acp_tool_calls") else {
|
||||
return Ok(());
|
||||
};
|
||||
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
|
||||
let fixture = FixturePlugins::new(&["plugin-context"]).await;
|
||||
let host = fixture.manager(node.clone());
|
||||
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
|
||||
let dir = tempfile::tempdir()?;
|
||||
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
|
||||
let workspace = fixture.workspace().to_path_buf();
|
||||
let server = dir.path().join("fixture-mcp.mjs");
|
||||
std::fs::write(&server, MCP_FIXTURE)?;
|
||||
let mcp_config = dir.path().join("mcp.json");
|
||||
std::fs::write(
|
||||
&mcp_config,
|
||||
serde_json::to_vec(&json!({"servers":{"fixture":{"command":node,"args":[server]}}}))?,
|
||||
)?;
|
||||
let runtime_dir = dir.path().join("runtime");
|
||||
let mut config = fixture_config();
|
||||
config.set_feature("mcp", true).unwrap();
|
||||
config.set_feature("extension_host", true).unwrap();
|
||||
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
|
||||
let manager = Arc::new(RuntimeThreadManager::open_host(
|
||||
config.clone(),
|
||||
workspace.clone(),
|
||||
RuntimeThreadManagerConfig {
|
||||
mcp_auth_context: Default::default(),
|
||||
data_dir: runtime_dir.clone(),
|
||||
task_data_dir: runtime_dir,
|
||||
sessions_dir: None,
|
||||
max_active_threads: 4,
|
||||
},
|
||||
fixture.registry(),
|
||||
codewhale_engine::core::engine::EngineHostProfile::Acp,
|
||||
None,
|
||||
None,
|
||||
)?);
|
||||
let provider = Arc::new(MockLlmClient::new(vec![
|
||||
canned::tool_call_turn("plugin-call", "ctx_probe", "{}"),
|
||||
canned::tool_call_turn("mcp-call", "mcp_fixture_echo", r#"{"text":"from-acp"}"#),
|
||||
canned::simple_text_turn("both extension tools ran"),
|
||||
]));
|
||||
manager.set_test_model_client(provider.clone());
|
||||
let frontend = capture_frontend(
|
||||
config,
|
||||
"fixture-model".into(),
|
||||
workspace.clone(),
|
||||
Arc::clone(&manager),
|
||||
codewhale_engine::session_manager::default_sessions_dir()?,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let (mut client, agent_read, agent_write) = Client::connect(false);
|
||||
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
|
||||
let script = async {
|
||||
let result = async {
|
||||
let (initialized, _) = client
|
||||
.request(
|
||||
"initialize",
|
||||
json!({"protocolVersion":1,"clientCapabilities":{}}),
|
||||
)
|
||||
.await?;
|
||||
let meta = &initialized["result"]["agentCapabilities"]["_meta"]["codewhale"];
|
||||
assert_eq!(meta["plugins"], true);
|
||||
assert_eq!(meta["configuredMcpServers"], true);
|
||||
assert_eq!(meta["clientMcpServers"], false);
|
||||
let (created, _) = client
|
||||
.request("session/new", json!({"cwd":workspace}))
|
||||
.await?;
|
||||
let session = created["result"]["sessionId"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
|
||||
.to_string();
|
||||
client.next_id += 1;
|
||||
let prompt_id = client.next_id;
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
|
||||
"sessionId":session,"prompt":[{"type":"text","text":"use both extension tools"}]}}))
|
||||
.await?;
|
||||
let mut asked = Vec::new();
|
||||
let mut frames = Vec::new();
|
||||
let done = loop {
|
||||
let frame = client.recv().await?;
|
||||
if frame["method"] == "session/request_permission" {
|
||||
asked.push(
|
||||
frame["params"]["toolCall"]["title"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
);
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":frame["id"],
|
||||
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}))
|
||||
.await?;
|
||||
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
|
||||
break frame;
|
||||
} else {
|
||||
frames.push(frame);
|
||||
}
|
||||
};
|
||||
assert_eq!(done["result"]["stopReason"], "end_turn");
|
||||
assert!(
|
||||
asked.iter().any(|title| title.starts_with("ctx_probe")),
|
||||
"the plugin tool asks through the permission dial: {asked:?} {frames:#?}"
|
||||
);
|
||||
assert!(
|
||||
asked
|
||||
.iter()
|
||||
.any(|title| title.starts_with("mcp_fixture_echo")),
|
||||
"the MCP tool asks through the permission dial: {asked:?}"
|
||||
);
|
||||
let announced: Vec<_> = updates(&frames, "tool_call")
|
||||
.iter()
|
||||
.filter_map(|frame| frame.pointer("/params/update/title")?.as_str())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
assert!(announced.iter().any(|title| title == "ctx_probe"), "{announced:?}");
|
||||
assert!(
|
||||
announced.iter().any(|title| title == "mcp_fixture_echo"),
|
||||
"{announced:?}"
|
||||
);
|
||||
let completed = updates(&frames, "tool_call_update")
|
||||
.iter()
|
||||
.filter(|frame| {
|
||||
frame.pointer("/params/update/status") == Some(&json!("completed"))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(completed, 2, "both extension calls complete: {frames:#?}");
|
||||
let rendered = serde_json::to_string(&frames)?;
|
||||
assert!(rendered.contains("mcp-echo:from-acp"), "{rendered}");
|
||||
let probed = updates(&frames, "tool_call_update")
|
||||
.iter()
|
||||
.filter_map(|frame| frame.pointer("/params/update/content/0/content/text"))
|
||||
.filter_map(Value::as_str)
|
||||
.filter_map(|text| serde_json::from_str::<Value>(&text[text.find('{')?..]).ok())
|
||||
.find_map(|result| Some(PathBuf::from(result.get("workspace")?.as_str()?)))
|
||||
.ok_or_else(|| anyhow!("the plugin result did not reach the editor: {rendered}"))?;
|
||||
assert_eq!(probed.canonicalize()?, workspace.canonicalize()?);
|
||||
assert_eq!(
|
||||
text_of(&frames, "agent_message_chunk"),
|
||||
"both extension tools ran"
|
||||
);
|
||||
client.request("shutdown", json!({})).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
let _ = client.write.shutdown().await;
|
||||
}
|
||||
result
|
||||
};
|
||||
let (served, scripted) = tokio::join!(connection, script);
|
||||
served?;
|
||||
scripted?;
|
||||
let first = serde_json::to_string(
|
||||
provider
|
||||
.captured_requests()
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("no model request"))?,
|
||||
)?;
|
||||
assert!(
|
||||
first.contains("ctx_probe") && first.contains("mcp_fixture_echo"),
|
||||
"the first model request carries both extension tools"
|
||||
);
|
||||
manager.shutdown_and_wait().await?;
|
||||
host.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -139,6 +139,7 @@ impl BootstrapPublicationFixture {
|
||||
if reused {
|
||||
process_start.push_str("-different-generation");
|
||||
}
|
||||
std::fs::create_dir_all(harness.root.join("runtime")).unwrap();
|
||||
let owner = codewhale_protocol::RuntimeOwnerReceipt {
|
||||
version: 1,
|
||||
data_dir: harness.root.join("runtime"),
|
||||
@@ -168,6 +169,7 @@ impl BootstrapPublicationFixture {
|
||||
match codewhale_app_server::daemon_client::probe_owner_for_host_startup(
|
||||
self.owner.config_path.clone(),
|
||||
Some(self.owner.socket_path.clone()),
|
||||
&self.owner.data_dir,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -250,12 +252,105 @@ async fn owner_bootstrap_unavailable_is_host_only_and_read_only() {
|
||||
fixture.parent.socket_identity("daemon.sock").unwrap(),
|
||||
identity
|
||||
);
|
||||
assert!(
|
||||
!fixture.owner.data_dir.exists(),
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&fixture.owner.data_dir).unwrap().count(),
|
||||
0,
|
||||
"standalone control must not open or mint a store"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dead_owner_whose_store_was_deleted_is_discarded_and_startup_proceeds() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-gone", true, false).await;
|
||||
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
|
||||
assert!(matches!(
|
||||
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
|
||||
fixture.owner.config_path.clone(),
|
||||
Some(fixture.owner.socket_path.clone()),
|
||||
&fixture.harness.root.join("another-store"),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
codewhale_app_server::daemon_client::HostOwnerProbe::Unavailable(_)
|
||||
));
|
||||
fixture.assert_publication(&fixture.bytes);
|
||||
let probe = codewhale_app_server::daemon_client::probe_owner_for_host_startup(
|
||||
fixture.owner.config_path.clone(),
|
||||
Some(fixture.owner.socket_path.clone()),
|
||||
&fixture.owner.data_dir,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
probe,
|
||||
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.parent
|
||||
.read_private_receipt("daemon.sock.owner.json", 16384)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(!fixture.owner.data_dir.exists());
|
||||
fixture.bind_replacement(None).await.unwrap();
|
||||
}
|
||||
|
||||
async fn assert_gone_store_spelled_as_is_discarded(
|
||||
label: &str,
|
||||
spell: impl FnOnce(&Path) -> PathBuf,
|
||||
) {
|
||||
let fixture = BootstrapPublicationFixture::new(label, true, false).await;
|
||||
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
|
||||
let spelled = spell(&fixture.harness.root);
|
||||
assert_ne!(spelled, fixture.owner.data_dir);
|
||||
assert!(matches!(
|
||||
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
|
||||
fixture.owner.config_path.clone(),
|
||||
Some(fixture.owner.socket_path.clone()),
|
||||
&spelled,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.parent
|
||||
.read_private_receipt("daemon.sock.owner.json", 16384)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_through_a_symlink() {
|
||||
assert_gone_store_spelled_as_is_discarded("boot-gone-link", |root| {
|
||||
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
|
||||
root.join("alias").join("runtime")
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_in_another_letter_case() {
|
||||
assert_gone_store_spelled_as_is_discarded("boot-gone-case", |root| {
|
||||
let upper = PathBuf::from(root.to_string_lossy().to_uppercase());
|
||||
assert!(upper.exists(), "this test needs a case-insensitive volume");
|
||||
upper.join("RUNTIME")
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_owner_with_a_missing_store_is_never_discarded() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-gone-live", false, false).await;
|
||||
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
|
||||
fixture.capture().await;
|
||||
fixture.assert_publication(&fixture.bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_live_process_and_reused_pid_refuse_retirement() {
|
||||
for reused in [false, true] {
|
||||
|
||||
+32
-4
@@ -117,7 +117,7 @@ struct Cli {
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "BOOL",
|
||||
help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
|
||||
help = "Control usage reporting (default on; Codewhale + PostHog; \
|
||||
durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
|
||||
)]
|
||||
telemetry: Option<bool>,
|
||||
@@ -234,10 +234,10 @@ enum Commands {
|
||||
#[command(about = "Install a plugin bundle without trusting or enabling it")]
|
||||
Install(TuiPassthroughArgs),
|
||||
#[command(
|
||||
about = "Inspect or restage reviewed plugin bundles",
|
||||
about = "Inspect, review, trust, enable, disable or revoke plugin bundles",
|
||||
alias = "plugin",
|
||||
override_usage = "codewhale plugins <COMMAND>",
|
||||
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot"
|
||||
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot\n trust <NAME> [--json] Show the capability review; in a terminal, then ask\n trust <NAME> --review-token T [--yes] [--json] Trust exactly the content the bundle review token names\n enable <NAME> [--json] Enable a trusted plugin\n disable <NAME> [--json] Disable a plugin, keeping its trust\n revoke <NAME> [--json] Revoke trust\n\nThe bundle review token ties trust to exact content; it does not show that anyone read the review,\nand it is not the host-service review token. A --yes trust is recorded as the command line. When\nCODEWHALE_AGENT_PROJECT marks the process, as it does every command the agent runs, trust and enable\nare the agent's, for that project only; that mark is a label the agent could remove in Full Access."
|
||||
)]
|
||||
Plugins(TuiPassthroughArgs),
|
||||
/// Bootstrap MCP config and/or skills directories.
|
||||
@@ -1957,7 +1957,7 @@ enum ConfigCommand {
|
||||
Unset {
|
||||
key: String,
|
||||
},
|
||||
/// Review aggregate usage counting by Codewhale and PostHog (default on).
|
||||
/// Review usage reporting by Codewhale and PostHog (default on).
|
||||
Telemetry {
|
||||
/// Optional compatibility form: enable future sessions under this policy version.
|
||||
#[arg(long, value_name = "VERSION")]
|
||||
@@ -6792,6 +6792,10 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
|
||||
codewhale_config::TELEMETRY_FLOOR_ENV,
|
||||
if floor { "1" } else { "0" },
|
||||
);
|
||||
set_tui_env(
|
||||
codewhale_config::TELEMETRY_SOURCE_ENV,
|
||||
resolved_runtime.telemetry_source.as_str(),
|
||||
);
|
||||
if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
|
||||
set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
|
||||
}
|
||||
@@ -7226,6 +7230,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatcher_hands_the_telemetry_source_to_the_tui() {
|
||||
let _lock = env_lock();
|
||||
let _guards = [
|
||||
ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
|
||||
ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
|
||||
ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
|
||||
ScopedEnvVar::remove(codewhale_config::TELEMETRY_SOURCE_ENV),
|
||||
];
|
||||
|
||||
let cli = parse_ok(&["codewhale"]);
|
||||
let mut runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
|
||||
runtime.telemetry = false;
|
||||
runtime.telemetry_source = codewhale_config::TelemetrySource::Config;
|
||||
apply_tui_env(&cli, &runtime, &[]);
|
||||
|
||||
assert_eq!(std::env::var("CODEWHALE_TELEMETRY").as_deref(), Ok("false"));
|
||||
assert!(std::env::var(codewhale_config::TELEMETRY_FLOOR_ENV).is_ok());
|
||||
assert_eq!(
|
||||
std::env::var(codewhale_config::TELEMETRY_SOURCE_ENV).as_deref(),
|
||||
Ok("config")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_command_definition_is_consistent() {
|
||||
Cli::command().debug_assert();
|
||||
|
||||
+129
-2
@@ -425,6 +425,74 @@ pub struct RuntimeRequestStats {
|
||||
pub provider_reported_output_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Serialize)]
|
||||
pub struct SessionUsageStats {
|
||||
pub sessions: u64,
|
||||
pub usage_receipts: u64,
|
||||
pub usage_receipts_incomplete: u64,
|
||||
pub provider_reported_input_tokens: u64,
|
||||
pub provider_reported_output_tokens: u64,
|
||||
pub sessions_with_total_tokens_only: u64,
|
||||
pub total_tokens_only: u64,
|
||||
}
|
||||
|
||||
fn record_session_usage(v: &Value, rollup: &mut Rollup) {
|
||||
let Some(sources) = v
|
||||
.pointer("/metadata/cost/accounted_usage_sources")
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
let total = v
|
||||
.pointer("/metadata/total_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
if total > 0 {
|
||||
let stats = &mut rollup.session_usage;
|
||||
stats.sessions_with_total_tokens_only =
|
||||
stats.sessions_with_total_tokens_only.saturating_add(1);
|
||||
stats.total_tokens_only = stats.total_tokens_only.saturating_add(total);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let stats = &mut rollup.session_usage;
|
||||
let mut counted = false;
|
||||
for contribution in sources.values() {
|
||||
let record = contribution.get("record");
|
||||
let reported = match record
|
||||
.and_then(|record| record.pointer("/settlement/coverage"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some(coverage) => matches!(coverage, "reported" | "incomplete"),
|
||||
None => record
|
||||
.and_then(|record| record.pointer("/usage/usage"))
|
||||
.is_some_and(|usage| {
|
||||
usage.get("input_tokens").and_then(Value::as_u64) != Some(0)
|
||||
|| usage.get("output_tokens").and_then(Value::as_u64) != Some(0)
|
||||
}),
|
||||
};
|
||||
if !reported {
|
||||
continue;
|
||||
}
|
||||
let tokens = |name: &str| {
|
||||
record
|
||||
.and_then(|record| record.pointer(&format!("/usage/usage/{name}")))
|
||||
.and_then(Value::as_u64)
|
||||
};
|
||||
let (Some(input), Some(output)) = (tokens("input_tokens"), tokens("output_tokens")) else {
|
||||
stats.usage_receipts_incomplete = stats.usage_receipts_incomplete.saturating_add(1);
|
||||
continue;
|
||||
};
|
||||
stats.usage_receipts = stats.usage_receipts.saturating_add(1);
|
||||
stats.provider_reported_input_tokens =
|
||||
stats.provider_reported_input_tokens.saturating_add(input);
|
||||
stats.provider_reported_output_tokens =
|
||||
stats.provider_reported_output_tokens.saturating_add(output);
|
||||
counted = true;
|
||||
}
|
||||
if counted {
|
||||
stats.sessions = stats.sessions.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level rollup.
|
||||
#[derive(Debug, Default, serde::Serialize)]
|
||||
pub struct Rollup {
|
||||
@@ -445,6 +513,7 @@ pub struct Rollup {
|
||||
pub capacity: CapacityStats,
|
||||
pub credentials: CredentialStats,
|
||||
pub runtime_requests: RuntimeRequestStats,
|
||||
pub session_usage: SessionUsageStats,
|
||||
/// Total lines read across all sources.
|
||||
pub total_lines: u64,
|
||||
/// Lines successfully parsed.
|
||||
@@ -936,6 +1005,8 @@ fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rol
|
||||
rollup.touch_ts(&ts);
|
||||
}
|
||||
|
||||
record_session_usage(&v, rollup);
|
||||
|
||||
// Walk messages looking for tool_use calls with associated results.
|
||||
let messages = match v.get("messages").and_then(|m| m.as_array()) {
|
||||
Some(m) => m,
|
||||
@@ -1640,15 +1711,44 @@ fn print_human(rollup: &Rollup, since: Option<DateTime<Utc>>) {
|
||||
);
|
||||
}
|
||||
if runtime.provider_usage_receipts == 0 {
|
||||
println!("Provider usage receipts: (none recorded; this is not zero usage)");
|
||||
println!(
|
||||
"Provider usage receipts (Runtime events): (none recorded; this is not zero usage)"
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Provider usage receipts: {} records, {} input tokens, {} output tokens",
|
||||
"Provider usage receipts (Runtime events): {} records, {} input tokens, {} output tokens",
|
||||
fmt_num(runtime.provider_usage_receipts),
|
||||
fmt_num(runtime.provider_reported_input_tokens),
|
||||
fmt_num(runtime.provider_reported_output_tokens),
|
||||
);
|
||||
}
|
||||
let session_usage = &rollup.session_usage;
|
||||
if session_usage.usage_receipts == 0 {
|
||||
println!(
|
||||
"Provider usage receipts (saved terminal sessions): (none recorded; this is not zero usage)"
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Provider usage receipts (saved terminal sessions): {} records in {} sessions, {} input tokens, {} output tokens",
|
||||
fmt_num(session_usage.usage_receipts),
|
||||
fmt_num(session_usage.sessions),
|
||||
fmt_num(session_usage.provider_reported_input_tokens),
|
||||
fmt_num(session_usage.provider_reported_output_tokens),
|
||||
);
|
||||
}
|
||||
if session_usage.sessions_with_total_tokens_only > 0 {
|
||||
println!(
|
||||
"Saved sessions with a combined token total only: {} sessions, {} tokens (no per-call usage receipts saved)",
|
||||
fmt_num(session_usage.sessions_with_total_tokens_only),
|
||||
fmt_num(session_usage.total_tokens_only),
|
||||
);
|
||||
}
|
||||
if session_usage.usage_receipts_incomplete > 0 {
|
||||
println!(
|
||||
"Saved-session usage coverage: {} usage receipts incomplete, not counted as zero",
|
||||
fmt_num(session_usage.usage_receipts_incomplete),
|
||||
);
|
||||
}
|
||||
if runtime.diagnostics_unavailable_turn_receipts > 0
|
||||
|| runtime.diagnostics_incomplete_turn_receipts > 0
|
||||
|| runtime.terminal_receipts_without_identity > 0
|
||||
@@ -2241,6 +2341,33 @@ mod tests {
|
||||
assert_eq!(runtime.model_requests_started, 2, "status is not a request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_terminal_session_usage_is_counted_apart_from_runtime_events() {
|
||||
let session = serde_json::json!({
|
||||
"metadata": { "cost": { "accounted_usage_sources": {
|
||||
"a": { "record": { "settlement": { "coverage": "reported" }, "usage": { "usage": { "input_tokens": 40, "output_tokens": 6 } } } },
|
||||
"b": { "record": { "settlement": { "coverage": "missing" }, "usage": { "usage": { "input_tokens": 0, "output_tokens": 0 } } } },
|
||||
"c": { "record": { "settlement": { "coverage": "incomplete" }, "usage": { "usage": { "input_tokens": 3 } } } }
|
||||
} } }
|
||||
});
|
||||
let mut rollup = Rollup::default();
|
||||
record_session_usage(&session, &mut rollup);
|
||||
let usage = &rollup.session_usage;
|
||||
assert_eq!(usage.usage_receipts, 1);
|
||||
assert_eq!(usage.usage_receipts_incomplete, 1);
|
||||
assert_eq!(usage.sessions, 1);
|
||||
assert_eq!(usage.provider_reported_input_tokens, 40);
|
||||
assert_eq!(usage.provider_reported_output_tokens, 6);
|
||||
record_session_usage(&serde_json::json!({ "metadata": {} }), &mut rollup);
|
||||
assert_eq!(rollup.session_usage.sessions, 1);
|
||||
record_session_usage(
|
||||
&serde_json::json!({ "metadata": { "total_tokens": 900 } }),
|
||||
&mut rollup,
|
||||
);
|
||||
assert_eq!(rollup.session_usage.sessions_with_total_tokens_only, 1);
|
||||
assert_eq!(rollup.session_usage.total_tokens_only, 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_usage_receipts_deduplicate_by_runtime_event_identity() {
|
||||
let usage = runtime_event(
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct Receipt {
|
||||
/// own record; empty when no turn recorded one.
|
||||
pub postures: Vec<&'static str>,
|
||||
pub totals: ReceiptTotals,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<ReceiptUsage>,
|
||||
pub actions: Vec<ReceiptAction>,
|
||||
/// Actions left off the list because it hit [`MAX_RECEIPT_ACTIONS`].
|
||||
pub omitted_actions: usize,
|
||||
@@ -47,6 +49,50 @@ pub struct Receipt {
|
||||
pub claim_ceiling: [&'static str; 3],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ReceiptUsage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tokens: Option<ReceiptTokens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<ReceiptCost>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct ReceiptTokens {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<u64>,
|
||||
pub total: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub calls: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ReceiptCost {
|
||||
pub state: ReceiptCostState,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub amount: Option<f64>,
|
||||
pub currency: &'static str,
|
||||
pub priced_turns: u32,
|
||||
pub unpriced_turns: u32,
|
||||
pub report: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReceiptCostState {
|
||||
Priced,
|
||||
Partial,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SourceKind {
|
||||
|
||||
@@ -263,6 +263,7 @@ fn empty_receipt() -> Receipt {
|
||||
turn: None,
|
||||
postures: vec![],
|
||||
totals: ReceiptTotals::default(),
|
||||
usage: None,
|
||||
actions: vec![],
|
||||
omitted_actions: 0,
|
||||
not_recorded: vec![],
|
||||
|
||||
@@ -217,7 +217,21 @@ pub fn cost(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
|
||||
};
|
||||
let projection = diagnostics.cost_projection();
|
||||
let details = arg.is_some_and(|arg| arg.trim().eq_ignore_ascii_case("details"));
|
||||
let plan_only = projection.total == 0.0
|
||||
if cost_is_subscription_only(&projection) && !details {
|
||||
return CommandResult::message(format!(
|
||||
"Session Cost:\n ─────────────────────────────\n {SUBSCRIPTION_ONLY_COST_NOTE}\n\n Run /cost details for the route receipts."
|
||||
));
|
||||
}
|
||||
match cost_report(&projection, presentation) {
|
||||
Ok(report) => CommandResult::message(report),
|
||||
Err(error) => CommandResult::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub const SUBSCRIPTION_ONLY_COST_NOTE: &str = "These routes use subscription billing. Codewhale does not know the amount charged by your provider.";
|
||||
|
||||
pub fn cost_is_subscription_only(projection: &DebugCostProjection) -> bool {
|
||||
projection.total == 0.0
|
||||
&& projection.priced_turns == 0
|
||||
&& projection.unpriced_turns == 0
|
||||
&& !projection.legacy_coverage_unknown
|
||||
@@ -227,19 +241,10 @@ pub fn cost(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
|
||||
receipt
|
||||
.split_whitespace()
|
||||
.any(|field| field == "billing_mode=subscription")
|
||||
});
|
||||
if plan_only && !details {
|
||||
return CommandResult::message(
|
||||
"Session Cost:\n ─────────────────────────────\n These routes use subscription billing. Codewhale does not know the amount charged by your provider.\n\n Run /cost details for the route receipts.".to_string(),
|
||||
);
|
||||
}
|
||||
match cost_report(&projection, presentation) {
|
||||
Ok(report) => CommandResult::message(report),
|
||||
Err(error) => CommandResult::error(error),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn cost_report(
|
||||
pub fn cost_report(
|
||||
cost: &DebugCostProjection,
|
||||
presentation: &dyn CommandPresentationContext,
|
||||
) -> Result<String, String> {
|
||||
|
||||
@@ -414,6 +414,53 @@ pub fn action_line(action: &ReceiptAction) -> String {
|
||||
one_line(&line)
|
||||
}
|
||||
|
||||
fn tokens_line(tokens: &ReceiptTokens) -> String {
|
||||
let mut line = match (tokens.input, tokens.output) {
|
||||
(Some(input), Some(output)) => {
|
||||
format!("Tokens: {input} in · {output} out · {} total", tokens.total)
|
||||
}
|
||||
_ => format!("Tokens: {} total", tokens.total),
|
||||
};
|
||||
if let Some(calls) = tokens.calls {
|
||||
line.push_str(&format!(
|
||||
" · {}",
|
||||
plural(calls, "provider call", "provider calls")
|
||||
));
|
||||
}
|
||||
let detail: Vec<String> = [
|
||||
("cache read", tokens.cache_read),
|
||||
("cache write", tokens.cache_write),
|
||||
("reasoning", tokens.reasoning),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(label, value)| value.map(|value| format!("{value} {label}")))
|
||||
.collect();
|
||||
if !detail.is_empty() {
|
||||
line.push_str(&format!(" ({})", detail.join(", ")));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
fn usage_block(usage: &ReceiptUsage) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(tokens) = &usage.tokens {
|
||||
out.push_str(&one_line(&tokens_line(tokens)));
|
||||
out.push('\n');
|
||||
}
|
||||
if let Some(cost) = &usage.cost {
|
||||
out.push_str("Cost, as /cost reports it:\n");
|
||||
for line in cost
|
||||
.report
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
out.push_str(&format!(" {}\n", one_line(line)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The readable receipt: header, totals, one line per action, then what the
|
||||
/// record does not hold. Valid Markdown and plain enough for a terminal.
|
||||
#[must_use]
|
||||
@@ -457,6 +504,10 @@ pub fn render_markdown(receipt: &Receipt) -> String {
|
||||
out.push_str("\n\n");
|
||||
out.push_str(&one_line(&totals_line(receipt)));
|
||||
out.push_str("\n\n");
|
||||
if let Some(usage) = &receipt.usage {
|
||||
out.push_str(&usage_block(usage));
|
||||
out.push('\n');
|
||||
}
|
||||
let width = receipt.actions.len().to_string().len();
|
||||
for action in &receipt.actions {
|
||||
out.push_str(&format!(
|
||||
|
||||
@@ -605,7 +605,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
def(
|
||||
"show_thinking",
|
||||
SettingKind::Bool(SHOW_THINKING),
|
||||
"false",
|
||||
"true",
|
||||
ui(
|
||||
TAB_APPEARANCE,
|
||||
"display",
|
||||
@@ -1546,6 +1546,12 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"false",
|
||||
None,
|
||||
),
|
||||
def(
|
||||
"account_preferences_notice_shown",
|
||||
SettingKind::String,
|
||||
"",
|
||||
None,
|
||||
),
|
||||
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
|
||||
def(
|
||||
"work_surface_bottom_migrated",
|
||||
|
||||
@@ -33,6 +33,8 @@ pub const LEGACY_CLI_API_KEY_SOURCE_ENV: &str = "DEEPSEEK_API_KEY_SOURCE";
|
||||
/// [`CLI_API_KEY_SOURCE_ENV`]. Set to `1`/`0` on every delegated run.
|
||||
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
|
||||
|
||||
pub const TELEMETRY_SOURCE_ENV: &str = "CODEWHALE_TELEMETRY_SOURCE";
|
||||
|
||||
/// Whether an environment-level kill switch forces telemetry off here.
|
||||
///
|
||||
/// A floor is *not* the same as "telemetry resolved to false": off is the
|
||||
@@ -92,6 +94,32 @@ impl TelemetrySource {
|
||||
Self::Default => "default",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_label(label: &str) -> Option<Self> {
|
||||
match label {
|
||||
"cli" => Some(Self::Cli),
|
||||
"env" => Some(Self::Env),
|
||||
"config" => Some(Self::Config),
|
||||
"default" => Some(Self::Default),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn dispatched_telemetry_source() -> Option<TelemetrySource> {
|
||||
parse_bool(&std::env::var(TELEMETRY_FLOOR_ENV).ok()?).ok()?;
|
||||
TelemetrySource::from_label(&std::env::var(TELEMETRY_SOURCE_ENV).ok()?)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn dispatched_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
|
||||
let (on, source) = resolved_telemetry_consent(file_telemetry);
|
||||
match (source, dispatched_telemetry_source()) {
|
||||
(TelemetrySource::Env, Some(declared)) => (on, declared),
|
||||
_ => (on, source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the telemetry environment override, reporting an unreadable value
|
||||
|
||||
@@ -8763,6 +8763,7 @@ struct TelemetryEnvGuard {
|
||||
codewhale: Option<OsString>,
|
||||
deepseek: Option<OsString>,
|
||||
floor: Option<OsString>,
|
||||
source: Option<OsString>,
|
||||
codewhale_endpoint: Option<OsString>,
|
||||
deepseek_endpoint: Option<OsString>,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
@@ -8775,6 +8776,7 @@ impl TelemetryEnvGuard {
|
||||
codewhale: env::var_os("CODEWHALE_TELEMETRY"),
|
||||
deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
|
||||
floor: env::var_os(TELEMETRY_FLOOR_ENV),
|
||||
source: env::var_os(TELEMETRY_SOURCE_ENV),
|
||||
// The endpoint variables are cleared too. Resolution now has a
|
||||
// shipped default, so an ambient endpoint in the developer's or
|
||||
// CI's environment is the difference between pinning the default
|
||||
@@ -8788,6 +8790,7 @@ impl TelemetryEnvGuard {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
env::remove_var(TELEMETRY_SOURCE_ENV);
|
||||
env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT");
|
||||
}
|
||||
@@ -8808,6 +8811,13 @@ impl TelemetryEnvGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_source(&self, value: &str) {
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
env::set_var(TELEMETRY_SOURCE_ENV, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_floor(&self, value: &str) {
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
@@ -8821,6 +8831,7 @@ impl TelemetryEnvGuard {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
env::remove_var(TELEMETRY_SOURCE_ENV);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8841,6 +8852,10 @@ impl Drop for TelemetryEnvGuard {
|
||||
Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
|
||||
None => env::remove_var(TELEMETRY_FLOOR_ENV),
|
||||
}
|
||||
match self.source.take() {
|
||||
Some(value) => env::set_var(TELEMETRY_SOURCE_ENV, value),
|
||||
None => env::remove_var(TELEMETRY_SOURCE_ENV),
|
||||
}
|
||||
match self.codewhale_endpoint.take() {
|
||||
Some(value) => env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value),
|
||||
None => env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT"),
|
||||
@@ -8995,6 +9010,57 @@ fn telemetry_consent_names_its_source() {
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatched_consent_names_the_dispatchers_source_not_its_hand_off() {
|
||||
let guard = TelemetryEnvGuard::take();
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("0");
|
||||
guard.set_source("config");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Config);
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("env");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
|
||||
guard.set("true");
|
||||
guard.set_floor("0");
|
||||
guard.set_source("default");
|
||||
let (on, source) = dispatched_telemetry_consent(None);
|
||||
assert!(on);
|
||||
assert_eq!(source, TelemetrySource::Default);
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("cli");
|
||||
let (on, source) = dispatched_telemetry_consent(None);
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Cli);
|
||||
|
||||
guard.clear();
|
||||
guard.set("0");
|
||||
guard.set_source("config");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(
|
||||
source,
|
||||
TelemetrySource::Env,
|
||||
"a source with no dispatcher floor statement is not believed"
|
||||
);
|
||||
|
||||
guard.clear();
|
||||
guard.set("0");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("nonsense");
|
||||
let (_, source) = dispatched_telemetry_consent(None);
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_runtime_options_mints_a_route_candidate() {
|
||||
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
+78
-31
@@ -727,7 +727,7 @@ var init_lib2 = __esm({
|
||||
} else throw new TypeError("Invalid effect");
|
||||
}, runner.getOuterStack);
|
||||
}
|
||||
effect(execute, label = "anonymous") {
|
||||
effect(execute, label2 = "anonymous") {
|
||||
this.assertActive();
|
||||
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
|
||||
const disposables = [];
|
||||
@@ -745,7 +745,7 @@ var init_lib2 = __esm({
|
||||
return disposalTask = task2;
|
||||
};
|
||||
const meta = {
|
||||
label,
|
||||
label: label2,
|
||||
children: []
|
||||
};
|
||||
const runner = {
|
||||
@@ -1167,7 +1167,7 @@ var init_lib2 = __esm({
|
||||
};
|
||||
return dispatch();
|
||||
}
|
||||
register(label, name, callback, options) {
|
||||
register(label2, name, callback, options) {
|
||||
const method = options.prepend ? "unshift" : "push";
|
||||
return this.ctx.fiber.effect(() => {
|
||||
(this._hooks[name] ??= [])[method]({
|
||||
@@ -1176,7 +1176,7 @@ var init_lib2 = __esm({
|
||||
...options
|
||||
});
|
||||
return () => this.unregister(name, callback);
|
||||
}, label);
|
||||
}, label2);
|
||||
}
|
||||
unregister(name, callback) {
|
||||
const hooks = this._hooks[name];
|
||||
@@ -1205,8 +1205,8 @@ var init_lib2 = __esm({
|
||||
listener = this.ctx.reflect.bind(listener);
|
||||
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
|
||||
if (result) return result;
|
||||
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
|
||||
return this.register(label, name, listener, options);
|
||||
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
|
||||
return this.register(label2, name, listener, options);
|
||||
}
|
||||
/**
|
||||
* Register an event listener that disposes itself after the first call.
|
||||
@@ -1951,9 +1951,9 @@ var init_lib2 = __esm({
|
||||
* @param label — scope label to join; defaults to a fresh unique symbol.
|
||||
* @returns a child context whose `name` service resolves in the new scope.
|
||||
*/
|
||||
isolate(name, label) {
|
||||
isolate(name, label2) {
|
||||
const shadow = Object.create(this[symbols.isolate]);
|
||||
shadow[name] = label ?? Symbol(name);
|
||||
shadow[name] = label2 ?? Symbol(name);
|
||||
return this.extend({ [symbols.isolate]: shadow });
|
||||
}
|
||||
intercept(name, config) {
|
||||
@@ -2650,14 +2650,14 @@ function isolate(ctx) {
|
||||
const delims = /* @__PURE__ */ Object.create(null);
|
||||
function access2(entry, name, create = false) {
|
||||
let realm;
|
||||
const label = entry.options.isolate?.[name];
|
||||
if (!label) return;
|
||||
if (label === true) {
|
||||
const label2 = entry.options.isolate?.[name];
|
||||
if (!label2) return;
|
||||
if (label2 === true) {
|
||||
realm = entry.realm ??= new LocalRealm(entry);
|
||||
} else if (create) {
|
||||
realm = realms[label] ??= new GlobalRealm(label);
|
||||
realm = realms[label2] ??= new GlobalRealm(label2);
|
||||
} else {
|
||||
realm = realms[label];
|
||||
realm = realms[label2];
|
||||
}
|
||||
return realm?.access(name, create);
|
||||
}
|
||||
@@ -2714,10 +2714,10 @@ function isolate(ctx) {
|
||||
}
|
||||
});
|
||||
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
|
||||
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
|
||||
if (label === true) continue;
|
||||
if (active && entry.options.isolate?.[name] === label) continue;
|
||||
const realm = realms[label];
|
||||
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
|
||||
if (label2 === true) continue;
|
||||
if (active && entry.options.isolate?.[name] === label2) continue;
|
||||
const realm = realms[label2];
|
||||
if (!realm) continue;
|
||||
for (const entry2 of ctx.loader.entries()) {
|
||||
if (entry2.options.isolate?.[name] === realm.label) return;
|
||||
@@ -2761,9 +2761,9 @@ var init_isolate = __esm({
|
||||
}
|
||||
};
|
||||
GlobalRealm = class extends Realm {
|
||||
constructor(label) {
|
||||
constructor(label2) {
|
||||
super();
|
||||
this.label = label;
|
||||
this.label = label2;
|
||||
}
|
||||
label;
|
||||
get suffix() {
|
||||
@@ -7370,9 +7370,9 @@ var init_acorn = __esm({
|
||||
keywordRelationalOperator = /^in(stanceof)?$/;
|
||||
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
|
||||
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
|
||||
TokenType = function TokenType2(label, conf) {
|
||||
TokenType = function TokenType2(label2, conf) {
|
||||
if (conf === void 0) conf = {};
|
||||
this.label = label;
|
||||
this.label = label2;
|
||||
this.keyword = conf.keyword;
|
||||
this.beforeExpr = !!conf.beforeExpr;
|
||||
this.startsExpr = !!conf.startsExpr;
|
||||
@@ -8421,8 +8421,8 @@ var init_acorn = __esm({
|
||||
};
|
||||
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
|
||||
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
|
||||
var label = list2[i$1];
|
||||
if (label.name === maybeName) {
|
||||
var label2 = list2[i$1];
|
||||
if (label2.name === maybeName) {
|
||||
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
|
||||
}
|
||||
}
|
||||
@@ -12795,7 +12795,8 @@ var init_compatibility = __esm({
|
||||
"prompt",
|
||||
"avatars",
|
||||
"mcp",
|
||||
"shellHooks"
|
||||
"shellHooks",
|
||||
"remote"
|
||||
]
|
||||
},
|
||||
refused_services: [
|
||||
@@ -14483,6 +14484,51 @@ var init_agents = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/remote.ts
|
||||
function label(value) {
|
||||
if (value === void 0) return void 0;
|
||||
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
|
||||
return value;
|
||||
}
|
||||
function grant(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
|
||||
return value;
|
||||
}
|
||||
var remoteAdapter;
|
||||
var init_remote = __esm({
|
||||
"src/dsh/remote.ts"() {
|
||||
"use strict";
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
remoteAdapter = {
|
||||
service: "remote",
|
||||
define(host) {
|
||||
class RemoteService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "remote");
|
||||
}
|
||||
status() {
|
||||
return host.broker(this.ctx, "remote", "status", {});
|
||||
}
|
||||
pair(options) {
|
||||
const value = label(options?.label);
|
||||
return host.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
|
||||
}
|
||||
clients() {
|
||||
return host.broker(this.ctx, "remote", "clients", {});
|
||||
}
|
||||
revoke(id) {
|
||||
return host.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
|
||||
}
|
||||
}
|
||||
Object.freeze(RemoteService.prototype);
|
||||
return RemoteService;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/adapters.ts
|
||||
var DSH_ADAPTERS;
|
||||
var init_adapters = __esm({
|
||||
@@ -14490,7 +14536,8 @@ var init_adapters = __esm({
|
||||
"use strict";
|
||||
init_session_prompt();
|
||||
init_agents();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
|
||||
init_remote();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14719,16 +14766,16 @@ function entryListProblem(rows, at2 = "") {
|
||||
return at2 === "" ? "the composition must be a top-level list of plugin rows" : `group ${at2} must hold a list of plugin rows`;
|
||||
}
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const label = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
|
||||
const label2 = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
|
||||
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
||||
return `${label} is not a plugin row (expected a map with a "name")`;
|
||||
return `${label2} is not a plugin row (expected a map with a "name")`;
|
||||
}
|
||||
const { name, group, config } = row;
|
||||
if (typeof name !== "string" || name === "") {
|
||||
return `${label} names no plugin (a "name" string is required)`;
|
||||
return `${label2} names no plugin (a "name" string is required)`;
|
||||
}
|
||||
if (group === true) {
|
||||
const nested = entryListProblem(config, label);
|
||||
const nested = entryListProblem(config, label2);
|
||||
if (nested !== void 0) return nested;
|
||||
}
|
||||
}
|
||||
@@ -14751,8 +14798,8 @@ async function unresolvableRows(rows, presetBase, harnessBase, resolves, io, at2
|
||||
continue;
|
||||
}
|
||||
if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves, io)) continue;
|
||||
const label = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
|
||||
found.push({ label, name: row.name });
|
||||
const label2 = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
|
||||
found.push({ label: label2, name: row.name });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
+75
-27
@@ -921,7 +921,7 @@ var init_lib2 = __esm({
|
||||
} else throw new TypeError("Invalid effect");
|
||||
}, runner.getOuterStack);
|
||||
}
|
||||
effect(execute, label = "anonymous") {
|
||||
effect(execute, label2 = "anonymous") {
|
||||
this.assertActive();
|
||||
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
|
||||
const disposables = [];
|
||||
@@ -939,7 +939,7 @@ var init_lib2 = __esm({
|
||||
return disposalTask = task2;
|
||||
};
|
||||
const meta = {
|
||||
label,
|
||||
label: label2,
|
||||
children: []
|
||||
};
|
||||
const runner = {
|
||||
@@ -1361,7 +1361,7 @@ var init_lib2 = __esm({
|
||||
};
|
||||
return dispatch();
|
||||
}
|
||||
register(label, name, callback, options) {
|
||||
register(label2, name, callback, options) {
|
||||
const method = options.prepend ? "unshift" : "push";
|
||||
return this.ctx.fiber.effect(() => {
|
||||
(this._hooks[name] ??= [])[method]({
|
||||
@@ -1370,7 +1370,7 @@ var init_lib2 = __esm({
|
||||
...options
|
||||
});
|
||||
return () => this.unregister(name, callback);
|
||||
}, label);
|
||||
}, label2);
|
||||
}
|
||||
unregister(name, callback) {
|
||||
const hooks = this._hooks[name];
|
||||
@@ -1399,8 +1399,8 @@ var init_lib2 = __esm({
|
||||
listener = this.ctx.reflect.bind(listener);
|
||||
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
|
||||
if (result) return result;
|
||||
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
|
||||
return this.register(label, name, listener, options);
|
||||
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
|
||||
return this.register(label2, name, listener, options);
|
||||
}
|
||||
/**
|
||||
* Register an event listener that disposes itself after the first call.
|
||||
@@ -2145,9 +2145,9 @@ var init_lib2 = __esm({
|
||||
* @param label — scope label to join; defaults to a fresh unique symbol.
|
||||
* @returns a child context whose `name` service resolves in the new scope.
|
||||
*/
|
||||
isolate(name, label) {
|
||||
isolate(name, label2) {
|
||||
const shadow = Object.create(this[symbols.isolate]);
|
||||
shadow[name] = label ?? Symbol(name);
|
||||
shadow[name] = label2 ?? Symbol(name);
|
||||
return this.extend({ [symbols.isolate]: shadow });
|
||||
}
|
||||
intercept(name, config) {
|
||||
@@ -4543,14 +4543,14 @@ function isolate(ctx) {
|
||||
const delims = /* @__PURE__ */ Object.create(null);
|
||||
function access2(entry, name, create = false) {
|
||||
let realm;
|
||||
const label = entry.options.isolate?.[name];
|
||||
if (!label) return;
|
||||
if (label === true) {
|
||||
const label2 = entry.options.isolate?.[name];
|
||||
if (!label2) return;
|
||||
if (label2 === true) {
|
||||
realm = entry.realm ??= new LocalRealm(entry);
|
||||
} else if (create) {
|
||||
realm = realms[label] ??= new GlobalRealm(label);
|
||||
realm = realms[label2] ??= new GlobalRealm(label2);
|
||||
} else {
|
||||
realm = realms[label];
|
||||
realm = realms[label2];
|
||||
}
|
||||
return realm?.access(name, create);
|
||||
}
|
||||
@@ -4607,10 +4607,10 @@ function isolate(ctx) {
|
||||
}
|
||||
});
|
||||
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
|
||||
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
|
||||
if (label === true) continue;
|
||||
if (active && entry.options.isolate?.[name] === label) continue;
|
||||
const realm = realms[label];
|
||||
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
|
||||
if (label2 === true) continue;
|
||||
if (active && entry.options.isolate?.[name] === label2) continue;
|
||||
const realm = realms[label2];
|
||||
if (!realm) continue;
|
||||
for (const entry2 of ctx.loader.entries()) {
|
||||
if (entry2.options.isolate?.[name] === realm.label) return;
|
||||
@@ -4655,9 +4655,9 @@ var init_isolate = __esm({
|
||||
}
|
||||
};
|
||||
GlobalRealm = class extends Realm {
|
||||
constructor(label) {
|
||||
constructor(label2) {
|
||||
super();
|
||||
this.label = label;
|
||||
this.label = label2;
|
||||
}
|
||||
label;
|
||||
get suffix() {
|
||||
@@ -9683,9 +9683,9 @@ var init_acorn = __esm({
|
||||
keywordRelationalOperator = /^in(stanceof)?$/;
|
||||
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
|
||||
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
|
||||
TokenType = function TokenType2(label, conf) {
|
||||
TokenType = function TokenType2(label2, conf) {
|
||||
if (conf === void 0) conf = {};
|
||||
this.label = label;
|
||||
this.label = label2;
|
||||
this.keyword = conf.keyword;
|
||||
this.beforeExpr = !!conf.beforeExpr;
|
||||
this.startsExpr = !!conf.startsExpr;
|
||||
@@ -10734,8 +10734,8 @@ var init_acorn = __esm({
|
||||
};
|
||||
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
|
||||
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
|
||||
var label = list2[i$1];
|
||||
if (label.name === maybeName) {
|
||||
var label2 = list2[i$1];
|
||||
if (label2.name === maybeName) {
|
||||
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
|
||||
}
|
||||
}
|
||||
@@ -15147,7 +15147,8 @@ var init_compatibility = __esm({
|
||||
"prompt",
|
||||
"avatars",
|
||||
"mcp",
|
||||
"shellHooks"
|
||||
"shellHooks",
|
||||
"remote"
|
||||
]
|
||||
},
|
||||
refused_services: [
|
||||
@@ -16662,6 +16663,52 @@ var init_agents = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/remote.ts
|
||||
function label(value) {
|
||||
if (value === void 0) return void 0;
|
||||
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
|
||||
return value;
|
||||
}
|
||||
function grant(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
|
||||
return value;
|
||||
}
|
||||
var remoteAdapter;
|
||||
var init_remote = __esm({
|
||||
"src/dsh/remote.ts"() {
|
||||
"use strict";
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
remoteAdapter = {
|
||||
service: "remote",
|
||||
define(host2) {
|
||||
class RemoteService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "remote");
|
||||
}
|
||||
status() {
|
||||
return host2.broker(this.ctx, "remote", "status", {});
|
||||
}
|
||||
pair(options) {
|
||||
const value = label(options?.label);
|
||||
return host2.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
|
||||
}
|
||||
clients() {
|
||||
return host2.broker(this.ctx, "remote", "clients", {});
|
||||
}
|
||||
revoke(id) {
|
||||
return host2.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
|
||||
}
|
||||
}
|
||||
Object.freeze(RemoteService.prototype);
|
||||
return RemoteService;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/adapters.ts
|
||||
var DSH_ADAPTERS;
|
||||
var init_adapters = __esm({
|
||||
@@ -16670,7 +16717,8 @@ var init_adapters = __esm({
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
init_session_prompt();
|
||||
init_agents();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
|
||||
init_remote();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17449,10 +17497,10 @@ function describeError2(error) {
|
||||
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
||||
return String(error);
|
||||
}
|
||||
function withDeadline(promise, ms, label) {
|
||||
function withDeadline(promise, ms, label2) {
|
||||
let timer;
|
||||
const deadline = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} exceeded ${ms} ms`)), ms);
|
||||
timer = setTimeout(() => reject(new Error(`${label2} exceeded ${ms} ms`)), ms);
|
||||
timer.unref();
|
||||
});
|
||||
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
|
||||
|
||||
+78
-31
@@ -755,7 +755,7 @@ var init_lib2 = __esm({
|
||||
} else throw new TypeError("Invalid effect");
|
||||
}, runner.getOuterStack);
|
||||
}
|
||||
effect(execute, label = "anonymous") {
|
||||
effect(execute, label2 = "anonymous") {
|
||||
this.assertActive();
|
||||
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
|
||||
const disposables = [];
|
||||
@@ -773,7 +773,7 @@ var init_lib2 = __esm({
|
||||
return disposalTask = task2;
|
||||
};
|
||||
const meta = {
|
||||
label,
|
||||
label: label2,
|
||||
children: []
|
||||
};
|
||||
const runner = {
|
||||
@@ -1195,7 +1195,7 @@ var init_lib2 = __esm({
|
||||
};
|
||||
return dispatch();
|
||||
}
|
||||
register(label, name, callback, options) {
|
||||
register(label2, name, callback, options) {
|
||||
const method = options.prepend ? "unshift" : "push";
|
||||
return this.ctx.fiber.effect(() => {
|
||||
(this._hooks[name] ??= [])[method]({
|
||||
@@ -1204,7 +1204,7 @@ var init_lib2 = __esm({
|
||||
...options
|
||||
});
|
||||
return () => this.unregister(name, callback);
|
||||
}, label);
|
||||
}, label2);
|
||||
}
|
||||
unregister(name, callback) {
|
||||
const hooks = this._hooks[name];
|
||||
@@ -1233,8 +1233,8 @@ var init_lib2 = __esm({
|
||||
listener = this.ctx.reflect.bind(listener);
|
||||
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
|
||||
if (result) return result;
|
||||
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
|
||||
return this.register(label, name, listener, options);
|
||||
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
|
||||
return this.register(label2, name, listener, options);
|
||||
}
|
||||
/**
|
||||
* Register an event listener that disposes itself after the first call.
|
||||
@@ -1979,9 +1979,9 @@ var init_lib2 = __esm({
|
||||
* @param label — scope label to join; defaults to a fresh unique symbol.
|
||||
* @returns a child context whose `name` service resolves in the new scope.
|
||||
*/
|
||||
isolate(name, label) {
|
||||
isolate(name, label2) {
|
||||
const shadow = Object.create(this[symbols.isolate]);
|
||||
shadow[name] = label ?? Symbol(name);
|
||||
shadow[name] = label2 ?? Symbol(name);
|
||||
return this.extend({ [symbols.isolate]: shadow });
|
||||
}
|
||||
intercept(name, config) {
|
||||
@@ -2621,14 +2621,14 @@ function isolate(ctx) {
|
||||
const delims = /* @__PURE__ */ Object.create(null);
|
||||
function access2(entry, name, create = false) {
|
||||
let realm;
|
||||
const label = entry.options.isolate?.[name];
|
||||
if (!label) return;
|
||||
if (label === true) {
|
||||
const label2 = entry.options.isolate?.[name];
|
||||
if (!label2) return;
|
||||
if (label2 === true) {
|
||||
realm = entry.realm ??= new LocalRealm(entry);
|
||||
} else if (create) {
|
||||
realm = realms[label] ??= new GlobalRealm(label);
|
||||
realm = realms[label2] ??= new GlobalRealm(label2);
|
||||
} else {
|
||||
realm = realms[label];
|
||||
realm = realms[label2];
|
||||
}
|
||||
return realm?.access(name, create);
|
||||
}
|
||||
@@ -2685,10 +2685,10 @@ function isolate(ctx) {
|
||||
}
|
||||
});
|
||||
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
|
||||
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
|
||||
if (label === true) continue;
|
||||
if (active && entry.options.isolate?.[name] === label) continue;
|
||||
const realm = realms[label];
|
||||
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
|
||||
if (label2 === true) continue;
|
||||
if (active && entry.options.isolate?.[name] === label2) continue;
|
||||
const realm = realms[label2];
|
||||
if (!realm) continue;
|
||||
for (const entry2 of ctx.loader.entries()) {
|
||||
if (entry2.options.isolate?.[name] === realm.label) return;
|
||||
@@ -2732,9 +2732,9 @@ var init_isolate = __esm({
|
||||
}
|
||||
};
|
||||
GlobalRealm = class extends Realm {
|
||||
constructor(label) {
|
||||
constructor(label2) {
|
||||
super();
|
||||
this.label = label;
|
||||
this.label = label2;
|
||||
}
|
||||
label;
|
||||
get suffix() {
|
||||
@@ -7348,9 +7348,9 @@ var init_acorn = __esm({
|
||||
keywordRelationalOperator = /^in(stanceof)?$/;
|
||||
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
|
||||
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
|
||||
TokenType = function TokenType2(label, conf) {
|
||||
TokenType = function TokenType2(label2, conf) {
|
||||
if (conf === void 0) conf = {};
|
||||
this.label = label;
|
||||
this.label = label2;
|
||||
this.keyword = conf.keyword;
|
||||
this.beforeExpr = !!conf.beforeExpr;
|
||||
this.startsExpr = !!conf.startsExpr;
|
||||
@@ -8399,8 +8399,8 @@ var init_acorn = __esm({
|
||||
};
|
||||
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
|
||||
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
|
||||
var label = list2[i$1];
|
||||
if (label.name === maybeName) {
|
||||
var label2 = list2[i$1];
|
||||
if (label2.name === maybeName) {
|
||||
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
|
||||
}
|
||||
}
|
||||
@@ -12773,7 +12773,8 @@ var init_compatibility = __esm({
|
||||
"prompt",
|
||||
"avatars",
|
||||
"mcp",
|
||||
"shellHooks"
|
||||
"shellHooks",
|
||||
"remote"
|
||||
]
|
||||
},
|
||||
refused_services: [
|
||||
@@ -14159,6 +14160,51 @@ var init_agents = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/remote.ts
|
||||
function label(value) {
|
||||
if (value === void 0) return void 0;
|
||||
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
|
||||
return value;
|
||||
}
|
||||
function grant(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
|
||||
return value;
|
||||
}
|
||||
var remoteAdapter;
|
||||
var init_remote = __esm({
|
||||
"src/dsh/remote.ts"() {
|
||||
"use strict";
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
remoteAdapter = {
|
||||
service: "remote",
|
||||
define(host) {
|
||||
class RemoteService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "remote");
|
||||
}
|
||||
status() {
|
||||
return host.broker(this.ctx, "remote", "status", {});
|
||||
}
|
||||
pair(options) {
|
||||
const value = label(options?.label);
|
||||
return host.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
|
||||
}
|
||||
clients() {
|
||||
return host.broker(this.ctx, "remote", "clients", {});
|
||||
}
|
||||
revoke(id) {
|
||||
return host.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
|
||||
}
|
||||
}
|
||||
Object.freeze(RemoteService.prototype);
|
||||
return RemoteService;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/adapters.ts
|
||||
var DSH_ADAPTERS;
|
||||
var init_adapters = __esm({
|
||||
@@ -14166,7 +14212,8 @@ var init_adapters = __esm({
|
||||
"use strict";
|
||||
init_session_prompt();
|
||||
init_agents();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
|
||||
init_remote();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14395,16 +14442,16 @@ function entryListProblem(rows, at2 = "") {
|
||||
return at2 === "" ? "the composition must be a top-level list of plugin rows" : `group ${at2} must hold a list of plugin rows`;
|
||||
}
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const label = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
|
||||
const label2 = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
|
||||
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
||||
return `${label} is not a plugin row (expected a map with a "name")`;
|
||||
return `${label2} is not a plugin row (expected a map with a "name")`;
|
||||
}
|
||||
const { name, group, config } = row;
|
||||
if (typeof name !== "string" || name === "") {
|
||||
return `${label} names no plugin (a "name" string is required)`;
|
||||
return `${label2} names no plugin (a "name" string is required)`;
|
||||
}
|
||||
if (group === true) {
|
||||
const nested = entryListProblem(config, label);
|
||||
const nested = entryListProblem(config, label2);
|
||||
if (nested !== void 0) return nested;
|
||||
}
|
||||
}
|
||||
@@ -14427,8 +14474,8 @@ async function unresolvableRows(rows, presetBase, harnessBase, resolves, io, at2
|
||||
continue;
|
||||
}
|
||||
if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves, io)) continue;
|
||||
const label = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
|
||||
found.push({ label, name: row.name });
|
||||
const label2 = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
|
||||
found.push({ label: label2, name: row.name });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(here, '..')
|
||||
const SOURCES = {
|
||||
compatibility: join(root, 'src/dsh/compatibility.json'),
|
||||
hostRoot: join(root, 'src/root.ts'),
|
||||
shellHooks: join(root, 'src/dsh/shell-hooks.ts'),
|
||||
seatTable: join(root, '../../app-server/src/runtime_api/plugins.rs'),
|
||||
catalog: join(root, 'src/dsh/inspect-catalog.json'),
|
||||
}
|
||||
|
||||
const read = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
const SERVICES = {
|
||||
tools: {
|
||||
summary: 'The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: ToolDefinition): () => void', summary: 'Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer.' },
|
||||
{ name: 'get', signature: 'get(name: string, agent: Agent): { name: string } | undefined', summary: 'Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call\'s own agent.' },
|
||||
],
|
||||
},
|
||||
commands: {
|
||||
summary: 'Slash commands a person types. A handler runs only when the person types the command.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void', summary: 'Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer.' },
|
||||
],
|
||||
},
|
||||
storage: {
|
||||
summary: 'Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'get', signature: 'get(key: string): Promise<Json | undefined>', summary: 'Read one stored value.' },
|
||||
{ name: 'set', signature: 'set(key: string, value: Json): Promise<void>', summary: 'Store one JSON value, within the bounded key, value and owner limits.' },
|
||||
{ name: 'delete', signature: 'delete(key: string): Promise<void>', summary: 'Remove one stored value.' },
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
summary: 'Skill roots: reviewed folders of skills added to the Engine\'s skill catalog.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerRoot', signature: 'registerRoot(definition: { path: string }): () => void', summary: 'Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused.' },
|
||||
],
|
||||
},
|
||||
sessions: {
|
||||
summary: 'The session of the calling tool invocation.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'flush', signature: 'flush(session: Session): Promise<boolean>', summary: 'Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available.' },
|
||||
],
|
||||
},
|
||||
userQuestions: {
|
||||
summary: 'Questions put to the person through the Engine\'s own question card.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'ask', signature: 'ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>', summary: 'Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported.' },
|
||||
],
|
||||
},
|
||||
subagents: {
|
||||
summary: 'One independent child agent a root tool call may start.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'getProvider', signature: 'getProvider(id: \'spawn\'): Provider', summary: 'Describe the one provider: a fresh child with no inherited conversation.' },
|
||||
{ name: 'startActivation', signature: 'startActivation(options: { provider: \'spawn\', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: \'caller\' | \'parent\' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>', summary: 'Start the child through the Engine\'s approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child.' },
|
||||
{ name: 'result', signature: 'activation.result: Promise<{ stopReason: string, output: { type: \'text\', text: string }[], diagnostic?: string }>', summary: 'A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks.' },
|
||||
{ name: 'dispose', signature: 'activation.dispose(): Promise<void>', summary: 'Stop that exact child and its own descendants.' },
|
||||
],
|
||||
},
|
||||
agents: {
|
||||
summary: 'Reserved. The host offers no methods on agents.',
|
||||
availability: 'none',
|
||||
methods: [],
|
||||
},
|
||||
systemPrompt: {
|
||||
summary: 'The child agent\'s persona slot, reachable only inside an agent/created listener.',
|
||||
availability: 'child-agent-hook',
|
||||
methods: [
|
||||
{ name: 'section', signature: 'agent.ctx.systemPrompt.section({ name: \'deployment:persona-prefix\', order: agent.ctx.systemPrompt.getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\'), text: string }): () => void', summary: 'Set the child\'s persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine\'s own system prompt cannot be replaced.' },
|
||||
],
|
||||
},
|
||||
logger: {
|
||||
summary: 'Cordis logging. Every line becomes a host log notification attributed to the plugin.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'logger', signature: 'ctx.logger(name?: string): Logger', summary: 'A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info.' },
|
||||
],
|
||||
},
|
||||
events: {
|
||||
summary: 'Cordis event dispatch. Query the host Event provider for the events this host fires.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'on', signature: 'ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider).' },
|
||||
{ name: 'once', signature: 'ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event once.' },
|
||||
{ name: 'emit', signature: 'ctx.emit(name: string, ...args: any[]): void', summary: 'Dispatch an event to its listeners.' },
|
||||
],
|
||||
},
|
||||
reflect: {
|
||||
summary: 'Cordis service reflection. A plugin may provide its own services but not the Engine\'s.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'provide', signature: 'ctx.reflect.provide(name: string, value?: any): () => void', summary: 'Provide a service under a name. Names in refusedServices are refused: the Engine owns them.' },
|
||||
{ name: 'get', signature: 'ctx.reflect.get(name: string, strict?: boolean): any', summary: 'Read a provided service.' },
|
||||
],
|
||||
},
|
||||
registry: {
|
||||
summary: 'Cordis plugin registry.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'get', signature: 'ctx.registry.get(plugin: Plugin): Runtime | undefined', summary: 'Look up the runtime record of a plugin.' },
|
||||
{ name: 'resolve', signature: 'ctx.registry.resolve(plugin: Plugin): Function | undefined', summary: 'Resolve a plugin shape to its callback.' },
|
||||
],
|
||||
},
|
||||
prompt: {
|
||||
summary: 'Attributed sections of the Engine\'s runtime prompt. A plugin cannot replace the system prompt.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerSection', signature: 'registerSection(definition: { id: string, text: string, interpolate?: \'model-cwd\' }): () => void', summary: 'Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded.' },
|
||||
],
|
||||
},
|
||||
avatars: {
|
||||
summary: 'Avatar packs for the terminal.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerPack', signature: 'registerPack(definition: { path: string }): () => void', summary: 'Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin.' },
|
||||
],
|
||||
},
|
||||
mcp: {
|
||||
summary: 'Proposals of MCP servers. The Engine owns connection, credentials and tool calls.',
|
||||
availability: 'native-review',
|
||||
methods: [
|
||||
{ name: 'registerServer', signature: 'registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void', summary: 'Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition.' },
|
||||
],
|
||||
},
|
||||
shellHooks: {
|
||||
summary: 'Claude Code and Codex style command hooks, run by the Engine.',
|
||||
availability: 'native-review',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: { dialect: \'claude-code\' | \'codex\', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void', summary: 'Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported.' },
|
||||
],
|
||||
},
|
||||
remote: {
|
||||
summary: 'Remote pairing, served by the Engine.',
|
||||
availability: 'review-grant',
|
||||
methods: [
|
||||
{ name: 'status', signature: 'status(): Promise<Status>', summary: 'Whether remote access is on.' },
|
||||
{ name: 'pair', signature: 'pair(options?: { label?: string }): Promise<Pairing>', summary: 'Start a pairing.' },
|
||||
{ name: 'clients', signature: 'clients(): Promise<Client[]>', summary: 'List paired clients.' },
|
||||
{ name: 'revoke', signature: 'revoke(id: string): Promise<void>', summary: 'Revoke one grant by the id pair returned.' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const AVAILABILITY = {
|
||||
always: 'Available to every plugin that injects it.',
|
||||
'tool-invocation': 'Answers only inside a live tool call of the plugin.',
|
||||
'child-agent-hook': 'Answers only inside an agent/created listener, on the child agent.',
|
||||
'native-review': 'Available to a reviewed native plugin.',
|
||||
'review-grant': 'Served by the Engine only when the plugin\'s review granted it.',
|
||||
none: 'No methods.',
|
||||
}
|
||||
|
||||
const EVENTS_SUPPORTED = [
|
||||
{
|
||||
name: 'tools/pre-execute',
|
||||
status: 'supported',
|
||||
mode: 'ordered listeners, native hooks first, then plugin listeners in registration order',
|
||||
signature: 'ctx.on(\'tools/pre-execute\', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)',
|
||||
summary: 'Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: \'deny\', reason }, { kind: \'ask\', reason? }, { kind: \'revise\', input }, { kind: \'annotate\', text } or { kind: \'abstain\' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.',
|
||||
options: 'prepend and global are refused.',
|
||||
},
|
||||
{
|
||||
name: 'agent/created',
|
||||
status: 'supported',
|
||||
mode: 'listeners of the owning plugin only',
|
||||
signature: 'ctx.on(\'agent/created\', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)',
|
||||
summary: 'Fires for a child agent that this plugin\'s own subagents.startActivation creates, before the child starts. The listener may set the child\'s persona and deny tools to it; the window closes when the listeners return.',
|
||||
options: 'global is refused.',
|
||||
},
|
||||
]
|
||||
|
||||
const REFUSED_EVENT_REASON = 'Not projected by this host: no core projection exists, so a listener would never fire.'
|
||||
|
||||
const ROW_PACKAGES = {
|
||||
'@deepseek-ai/dsh-mcp-client': {
|
||||
summary: 'An MCP server row. The Engine proposes the server and owns the connection.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['transport', 'serverName'],
|
||||
properties: {
|
||||
transport: { type: 'string', enum: ['stdio', 'streamable-http', 'sse'] },
|
||||
serverName: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$' },
|
||||
command: { type: 'string', description: 'stdio only: the program to run.' },
|
||||
args: { type: 'array', items: { type: 'string' }, description: 'stdio only.' },
|
||||
env: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
|
||||
cwd: { type: 'string', description: 'stdio only: a bundle-relative directory, no .. components.' },
|
||||
url: { type: 'string', description: 'streamable-http and sse: the endpoint, with no credentials, query or fragment.' },
|
||||
headers: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
|
||||
toolCallTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000 },
|
||||
failOnStartupError: { type: 'boolean', const: false, description: 'Only false: the Engine connects lazily and reports failures through /mcp.' },
|
||||
},
|
||||
},
|
||||
limits: ['stdio rows name a command; streamable-http and sse rows name a url.', 'Reconnect and instruction bounds belong to the Engine and are not configurable here.'],
|
||||
example: { transport: 'stdio', serverName: 'docs', command: 'node', args: ['server.js'], cwd: '.' },
|
||||
},
|
||||
'@deepseek-ai/dsh-skill-filesystem': {
|
||||
summary: 'A skill folder row, limited to reviewed roots inside the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['includeDefaultRoots', 'watch', 'customSkillDirs'],
|
||||
properties: {
|
||||
providerName: { type: 'string', maxLength: 64 },
|
||||
includeDefaultRoots: { type: 'boolean', const: false, description: 'Must be false: ambient roots are not read.' },
|
||||
watch: { type: 'boolean', const: false, description: 'Must be false: there is no file watcher.' },
|
||||
customSkillDirs: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'string' }, description: 'Bundle-relative skill roots, no duplicates.' },
|
||||
},
|
||||
},
|
||||
limits: ['Both includeDefaultRoots and watch must be written out as false.'],
|
||||
example: { includeDefaultRoots: false, watch: false, customSkillDirs: ['skills'] },
|
||||
},
|
||||
'@deepseek-ai/dsh-hooks-claude-code': {
|
||||
summary: 'Claude Code style command hooks read from a reviewed file in the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
required: ['configPath'],
|
||||
properties: {
|
||||
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
|
||||
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
|
||||
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
|
||||
},
|
||||
},
|
||||
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
|
||||
example: { configPath: 'hooks/hooks.json' },
|
||||
},
|
||||
'@deepseek-ai/dsh-hooks-codex': {
|
||||
summary: 'Codex style command hooks read from a reviewed file in the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
required: ['configPath'],
|
||||
properties: {
|
||||
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
|
||||
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
|
||||
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
|
||||
},
|
||||
},
|
||||
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
|
||||
example: { configPath: 'hooks/hooks.json' },
|
||||
},
|
||||
'@deepseek-ai/dsh-persona': {
|
||||
summary: 'Text added before and after the Engine\'s own prompt for this plugin\'s entry.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['prefix'],
|
||||
properties: {
|
||||
prefix: { type: 'string', description: 'At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded.' },
|
||||
suffix: { type: 'string', description: 'Same limits as prefix.' },
|
||||
complete: { type: 'boolean', const: false, description: 'Replacing the whole prompt belongs to the Engine.' },
|
||||
includeRuntimeContext: { type: 'boolean', const: true, description: 'Suppressing the runtime context belongs to the Engine.' },
|
||||
},
|
||||
},
|
||||
limits: ['Needs an exact Engine-selected entry scope.'],
|
||||
example: { prefix: 'Answer in short sentences.' },
|
||||
},
|
||||
}
|
||||
|
||||
const THEME = {
|
||||
tokens: [],
|
||||
colorSchemes: ['light', 'dark'],
|
||||
scheme: 'The frame sets data-theme and color-scheme on its document to the app\'s current scheme and follows changes.',
|
||||
api: [
|
||||
{ name: 'ctx.theme.getTheme', signature: 'getTheme(): { active: { colorScheme: \'light\' | \'dark\' } }', summary: 'The scheme in use now.' },
|
||||
{ name: 'ctx.theme.overrideTokens', signature: 'overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void', summary: 'Set custom properties on the frame\'s document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer.' },
|
||||
],
|
||||
notes: [
|
||||
'The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.',
|
||||
'The frame\'s background is transparent and its margin is zero, so the plugin sits on the app\'s own surface.',
|
||||
],
|
||||
}
|
||||
|
||||
const SEAT_NOTES = {
|
||||
'customize.plugins.detail': { drawn: true, where: 'The plugin\'s own page under Customize, Plugins.' },
|
||||
'conversation.composer.dock': { drawn: false, where: 'Under the composer. The desktop app does not place it yet.' },
|
||||
'conversation.chat.node': { drawn: false, where: 'Inside the conversation. The desktop app does not place it yet.' },
|
||||
'workpane.panel': { drawn: false, where: 'The work pane. The desktop app does not place it yet.' },
|
||||
'sidebar.section': { drawn: false, where: 'The sidebar. The desktop app does not place it yet.' },
|
||||
'command.row': { drawn: false, where: 'The command list. The desktop app does not place it yet.' },
|
||||
}
|
||||
|
||||
function between(text, open, close, what) {
|
||||
const start = text.indexOf(open)
|
||||
if (start < 0) throw new Error(`${what}: cannot find ${open}`)
|
||||
const end = text.indexOf(close, start + open.length)
|
||||
if (end < 0) throw new Error(`${what}: cannot find the end of ${open}`)
|
||||
return text.slice(start + open.length, end)
|
||||
}
|
||||
|
||||
export function parseSeatTable(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const constants = Object.fromEntries([...text.matchAll(/const ([A-Z_]+): &str = "([^"]+)";/g)].map((m) => [m[1], m[2]]))
|
||||
const resolve = (literal, name) => literal !== undefined ? literal : constants[name]
|
||||
const seatBlock = between(text, 'const PLUGIN_UI_SEATS: [&str; ', '];', 'seat list')
|
||||
const declaredSeats = Number(seatBlock.slice(0, seatBlock.indexOf(']')))
|
||||
const seatBody = seatBlock.slice(seatBlock.indexOf('= [') + 3)
|
||||
const seats = [...seatBody.matchAll(/"([^"]+)"|([A-Z_]+)/g)].map((m) => resolve(m[1], m[2]))
|
||||
if (seats.length !== declaredSeats || seats.some((seat) => seat === undefined)) throw new Error('the seat list does not match its declared length')
|
||||
const tableBlock = between(text, 'const DSH_SEAT_TABLE: [(&str, DshSeat); ', '];\n', 'slot table')
|
||||
const declaredSlots = Number(tableBlock.slice(0, tableBlock.indexOf(']')))
|
||||
const tableBody = tableBlock.slice(tableBlock.indexOf('= [') + 3)
|
||||
const slots = [...tableBody.matchAll(/\(\s*"([^"]+)",\s*DshSeat::(Seat|Unsupported)\(\s*(?:"([^"]*)"|([A-Z_]+))\s*\)\s*,?\s*\)/g)].map((m) => {
|
||||
const value = resolve(m[3], m[4])
|
||||
return m[2] === 'Seat' ? { slot: m[1], seat: value, supported: true } : { slot: m[1], seat: null, supported: false, reason: value }
|
||||
})
|
||||
if (slots.length !== declaredSlots) throw new Error(`the slot table declares ${declaredSlots} rows and ${slots.length} were read`)
|
||||
for (const slot of slots) if (slot.supported && !seats.includes(slot.seat)) throw new Error(`slot ${slot.slot} maps to ${slot.seat}, which is not a seat`)
|
||||
return { seats, slots }
|
||||
}
|
||||
|
||||
export function parseRefusedEvents(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const body = between(text, 'const unsupportedDshLifecycle = new Set([', '])', 'refused events')
|
||||
return [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
}
|
||||
|
||||
export function parseHookEvents(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const body = between(text, 'const EVENTS:Record<string,string>={', '}', 'hook events')
|
||||
return [...body.matchAll(/([A-Za-z]+):'([a-z_]+)'/g)].map((m) => ({ event: m[1], point: m[2] }))
|
||||
}
|
||||
|
||||
export function buildCatalog(paths = SOURCES) {
|
||||
const compatibility = JSON.parse(read(paths.compatibility))
|
||||
const provided = Object.entries(compatibility.provided_services).flatMap(([group, names]) => names.map((name) => ({ group, name })))
|
||||
const known = new Set(Object.keys(SERVICES))
|
||||
const missing = provided.filter(({ name }) => !known.has(name)).map(({ name }) => name)
|
||||
if (missing.length) throw new Error(`compatibility.json provides services with no entry in the method table: ${missing.join(', ')}`)
|
||||
const extra = [...known].filter((name) => !provided.some((entry) => entry.name === name))
|
||||
if (extra.length) throw new Error(`the method table names services the host does not provide: ${extra.join(', ')}`)
|
||||
|
||||
const services = provided.map(({ group, name }) => {
|
||||
const entry = SERVICES[name]
|
||||
return {
|
||||
key: name,
|
||||
group,
|
||||
summary: entry.summary,
|
||||
availability: entry.availability,
|
||||
availabilityText: AVAILABILITY[entry.availability],
|
||||
methods: entry.methods,
|
||||
}
|
||||
})
|
||||
|
||||
const refusedEvents = parseRefusedEvents(read(paths.hostRoot))
|
||||
const hookEvents = parseHookEvents(read(paths.shellHooks))
|
||||
const events = [
|
||||
...EVENTS_SUPPORTED,
|
||||
...refusedEvents.map((name) => ({ name, status: 'refused', summary: REFUSED_EVENT_REASON })),
|
||||
]
|
||||
|
||||
const rowPackages = Object.keys(compatibility.row_bridges)
|
||||
const unknownRows = rowPackages.filter((name) => !ROW_PACKAGES[name])
|
||||
const staleRows = Object.keys(ROW_PACKAGES).filter((name) => !rowPackages.includes(name))
|
||||
if (unknownRows.length || staleRows.length) throw new Error(`row packages differ from compatibility.json: missing ${unknownRows.join(', ')}; stale ${staleRows.join(', ')}`)
|
||||
const rows = rowPackages.map((name) => ({
|
||||
id: `row:${name}`,
|
||||
name,
|
||||
bridge: compatibility.row_bridges[name],
|
||||
summary: ROW_PACKAGES[name].summary,
|
||||
limits: ROW_PACKAGES[name].limits,
|
||||
example: ROW_PACKAGES[name].example,
|
||||
...(name.includes('hooks-') ? { hookEvents } : {}),
|
||||
schema: { $schema: 'https://json-schema.org/draft/2020-12/schema', title: name, ...ROW_PACKAGES[name].schema },
|
||||
}))
|
||||
|
||||
const table = parseSeatTable(read(paths.seatTable))
|
||||
const unknownSeatNotes = table.seats.filter((seat) => !SEAT_NOTES[seat])
|
||||
const staleSeatNotes = Object.keys(SEAT_NOTES).filter((seat) => !table.seats.includes(seat))
|
||||
if (unknownSeatNotes.length || staleSeatNotes.length) throw new Error(`seat notes differ from the Engine seat list: missing ${unknownSeatNotes.join(', ')}; stale ${staleSeatNotes.join(', ')}`)
|
||||
const seats = table.seats.map((seat) => ({ seat, drawn: SEAT_NOTES[seat].drawn, where: SEAT_NOTES[seat].where }))
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
targetDshVersion: compatibility.target_dsh_version,
|
||||
host: {
|
||||
services,
|
||||
refusedServices: compatibility.refused_services,
|
||||
events,
|
||||
cordisInternalEvents: 'Cordis fires its own internal/* lifecycle events. They are not part of this host\'s supported contract and are not listed.',
|
||||
config: rows,
|
||||
},
|
||||
client: {
|
||||
seats,
|
||||
slots: table.slots,
|
||||
theme: THEME,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCatalog(catalog) {
|
||||
return `${JSON.stringify(catalog, null, 2)}\n`
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const text = renderCatalog(buildCatalog())
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = existsOrEmpty(SOURCES.catalog)
|
||||
if (current !== text) {
|
||||
process.stderr.write('inspect-catalog.json is out of date: run node scripts/gen-inspect-catalog.mjs\n')
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
writeFileSync(SOURCES.catalog, text)
|
||||
}
|
||||
}
|
||||
|
||||
function existsOrEmpty(path) {
|
||||
try {
|
||||
return read(path)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { RpcPeer } from '../rpc.ts'
|
||||
import type { HostTier } from '../tier.ts'
|
||||
import { sessionsAdapter, systemPromptAdapter } from './session-prompt.ts'
|
||||
import { DSH_AGENT_ADAPTERS } from './agents.ts'
|
||||
import { remoteAdapter } from './remote.ts'
|
||||
|
||||
export interface AdapterHost {
|
||||
readonly tier: HostTier
|
||||
@@ -20,4 +21,4 @@ export interface DshAdapter {
|
||||
define(host: AdapterHost): new (ctx: any) => Service
|
||||
}
|
||||
|
||||
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS]
|
||||
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter]
|
||||
@@ -23,7 +23,8 @@
|
||||
"prompt",
|
||||
"avatars",
|
||||
"mcp",
|
||||
"shellHooks"
|
||||
"shellHooks",
|
||||
"remote"
|
||||
]
|
||||
},
|
||||
"refused_services": [
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
{
|
||||
"version": 1,
|
||||
"targetDshVersion": "0.2.2-rc.1",
|
||||
"host": {
|
||||
"services": [
|
||||
{
|
||||
"key": "tools",
|
||||
"group": "dsh",
|
||||
"summary": "The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: ToolDefinition): () => void",
|
||||
"summary": "Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer."
|
||||
},
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "get(name: string, agent: Agent): { name: string } | undefined",
|
||||
"summary": "Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call's own agent."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "commands",
|
||||
"group": "dsh",
|
||||
"summary": "Slash commands a person types. A handler runs only when the person types the command.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void",
|
||||
"summary": "Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "storage",
|
||||
"group": "dsh",
|
||||
"summary": "Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "get(key: string): Promise<Json | undefined>",
|
||||
"summary": "Read one stored value."
|
||||
},
|
||||
{
|
||||
"name": "set",
|
||||
"signature": "set(key: string, value: Json): Promise<void>",
|
||||
"summary": "Store one JSON value, within the bounded key, value and owner limits."
|
||||
},
|
||||
{
|
||||
"name": "delete",
|
||||
"signature": "delete(key: string): Promise<void>",
|
||||
"summary": "Remove one stored value."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "skills",
|
||||
"group": "dsh",
|
||||
"summary": "Skill roots: reviewed folders of skills added to the Engine's skill catalog.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerRoot",
|
||||
"signature": "registerRoot(definition: { path: string }): () => void",
|
||||
"summary": "Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "sessions",
|
||||
"group": "dsh",
|
||||
"summary": "The session of the calling tool invocation.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "flush",
|
||||
"signature": "flush(session: Session): Promise<boolean>",
|
||||
"summary": "Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "userQuestions",
|
||||
"group": "dsh",
|
||||
"summary": "Questions put to the person through the Engine's own question card.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "ask",
|
||||
"signature": "ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>",
|
||||
"summary": "Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "subagents",
|
||||
"group": "dsh",
|
||||
"summary": "One independent child agent a root tool call may start.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "getProvider",
|
||||
"signature": "getProvider(id: 'spawn'): Provider",
|
||||
"summary": "Describe the one provider: a fresh child with no inherited conversation."
|
||||
},
|
||||
{
|
||||
"name": "startActivation",
|
||||
"signature": "startActivation(options: { provider: 'spawn', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: 'caller' | 'parent' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>",
|
||||
"summary": "Start the child through the Engine's approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child."
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"signature": "activation.result: Promise<{ stopReason: string, output: { type: 'text', text: string }[], diagnostic?: string }>",
|
||||
"summary": "A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks."
|
||||
},
|
||||
{
|
||||
"name": "dispose",
|
||||
"signature": "activation.dispose(): Promise<void>",
|
||||
"summary": "Stop that exact child and its own descendants."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "agents",
|
||||
"group": "dsh",
|
||||
"summary": "Reserved. The host offers no methods on agents.",
|
||||
"availability": "none",
|
||||
"availabilityText": "No methods.",
|
||||
"methods": []
|
||||
},
|
||||
{
|
||||
"key": "systemPrompt",
|
||||
"group": "dsh",
|
||||
"summary": "The child agent's persona slot, reachable only inside an agent/created listener.",
|
||||
"availability": "child-agent-hook",
|
||||
"availabilityText": "Answers only inside an agent/created listener, on the child agent.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "section",
|
||||
"signature": "agent.ctx.systemPrompt.section({ name: 'deployment:persona-prefix', order: agent.ctx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX'), text: string }): () => void",
|
||||
"summary": "Set the child's persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder('DEPLOYMENT_PERSONA_PREFIX') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine's own system prompt cannot be replaced."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "logger",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis logging. Every line becomes a host log notification attributed to the plugin.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "logger",
|
||||
"signature": "ctx.logger(name?: string): Logger",
|
||||
"summary": "A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "events",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis event dispatch. Query the host Event provider for the events this host fires.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "on",
|
||||
"signature": "ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
|
||||
"summary": "Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider)."
|
||||
},
|
||||
{
|
||||
"name": "once",
|
||||
"signature": "ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
|
||||
"summary": "Listen to an event once."
|
||||
},
|
||||
{
|
||||
"name": "emit",
|
||||
"signature": "ctx.emit(name: string, ...args: any[]): void",
|
||||
"summary": "Dispatch an event to its listeners."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "reflect",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis service reflection. A plugin may provide its own services but not the Engine's.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "provide",
|
||||
"signature": "ctx.reflect.provide(name: string, value?: any): () => void",
|
||||
"summary": "Provide a service under a name. Names in refusedServices are refused: the Engine owns them."
|
||||
},
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "ctx.reflect.get(name: string, strict?: boolean): any",
|
||||
"summary": "Read a provided service."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "registry",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis plugin registry.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "ctx.registry.get(plugin: Plugin): Runtime | undefined",
|
||||
"summary": "Look up the runtime record of a plugin."
|
||||
},
|
||||
{
|
||||
"name": "resolve",
|
||||
"signature": "ctx.registry.resolve(plugin: Plugin): Function | undefined",
|
||||
"summary": "Resolve a plugin shape to its callback."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "prompt",
|
||||
"group": "codewhale",
|
||||
"summary": "Attributed sections of the Engine's runtime prompt. A plugin cannot replace the system prompt.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerSection",
|
||||
"signature": "registerSection(definition: { id: string, text: string, interpolate?: 'model-cwd' }): () => void",
|
||||
"summary": "Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "avatars",
|
||||
"group": "codewhale",
|
||||
"summary": "Avatar packs for the terminal.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerPack",
|
||||
"signature": "registerPack(definition: { path: string }): () => void",
|
||||
"summary": "Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "mcp",
|
||||
"group": "codewhale",
|
||||
"summary": "Proposals of MCP servers. The Engine owns connection, credentials and tool calls.",
|
||||
"availability": "native-review",
|
||||
"availabilityText": "Available to a reviewed native plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerServer",
|
||||
"signature": "registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void",
|
||||
"summary": "Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "shellHooks",
|
||||
"group": "codewhale",
|
||||
"summary": "Claude Code and Codex style command hooks, run by the Engine.",
|
||||
"availability": "native-review",
|
||||
"availabilityText": "Available to a reviewed native plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: { dialect: 'claude-code' | 'codex', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void",
|
||||
"summary": "Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "remote",
|
||||
"group": "codewhale",
|
||||
"summary": "Remote pairing, served by the Engine.",
|
||||
"availability": "review-grant",
|
||||
"availabilityText": "Served by the Engine only when the plugin's review granted it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "status",
|
||||
"signature": "status(): Promise<Status>",
|
||||
"summary": "Whether remote access is on."
|
||||
},
|
||||
{
|
||||
"name": "pair",
|
||||
"signature": "pair(options?: { label?: string }): Promise<Pairing>",
|
||||
"summary": "Start a pairing."
|
||||
},
|
||||
{
|
||||
"name": "clients",
|
||||
"signature": "clients(): Promise<Client[]>",
|
||||
"summary": "List paired clients."
|
||||
},
|
||||
{
|
||||
"name": "revoke",
|
||||
"signature": "revoke(id: string): Promise<void>",
|
||||
"summary": "Revoke one grant by the id pair returned."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"refusedServices": [
|
||||
"shellHooks",
|
||||
"loader",
|
||||
"approval",
|
||||
"agents",
|
||||
"subagents",
|
||||
"sessions",
|
||||
"llm",
|
||||
"sandboxPolicy",
|
||||
"credentials",
|
||||
"fs",
|
||||
"subprocess",
|
||||
"systemPrompt",
|
||||
"runtimeLoop",
|
||||
"tools",
|
||||
"commands",
|
||||
"prompt",
|
||||
"storage",
|
||||
"skills",
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"name": "tools/pre-execute",
|
||||
"status": "supported",
|
||||
"mode": "ordered listeners, native hooks first, then plugin listeners in registration order",
|
||||
"signature": "ctx.on('tools/pre-execute', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)",
|
||||
"summary": "Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: 'deny', reason }, { kind: 'ask', reason? }, { kind: 'revise', input }, { kind: 'annotate', text } or { kind: 'abstain' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.",
|
||||
"options": "prepend and global are refused."
|
||||
},
|
||||
{
|
||||
"name": "agent/created",
|
||||
"status": "supported",
|
||||
"mode": "listeners of the owning plugin only",
|
||||
"signature": "ctx.on('agent/created', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)",
|
||||
"summary": "Fires for a child agent that this plugin's own subagents.startActivation creates, before the child starts. The listener may set the child's persona and deny tools to it; the window closes when the listeners return.",
|
||||
"options": "global is refused."
|
||||
},
|
||||
{
|
||||
"name": "agent/pre-step",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "agent/turn-stopping",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "tools/post-execute",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "subagent/start",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "subagent/end",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
}
|
||||
],
|
||||
"cordisInternalEvents": "Cordis fires its own internal/* lifecycle events. They are not part of this host's supported contract and are not listed.",
|
||||
"config": [
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-mcp-client",
|
||||
"name": "@deepseek-ai/dsh-mcp-client",
|
||||
"bridge": "mcp",
|
||||
"summary": "An MCP server row. The Engine proposes the server and owns the connection.",
|
||||
"limits": [
|
||||
"stdio rows name a command; streamable-http and sse rows name a url.",
|
||||
"Reconnect and instruction bounds belong to the Engine and are not configurable here."
|
||||
],
|
||||
"example": {
|
||||
"transport": "stdio",
|
||||
"serverName": "docs",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"server.js"
|
||||
],
|
||||
"cwd": "."
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-mcp-client",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"transport",
|
||||
"serverName"
|
||||
],
|
||||
"properties": {
|
||||
"transport": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stdio",
|
||||
"streamable-http",
|
||||
"sse"
|
||||
]
|
||||
},
|
||||
"serverName": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "stdio only: the program to run."
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "stdio only."
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"maxProperties": 0,
|
||||
"description": "Must be empty or absent: credentials stay with the Engine."
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "stdio only: a bundle-relative directory, no .. components."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "streamable-http and sse: the endpoint, with no credentials, query or fragment."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"maxProperties": 0,
|
||||
"description": "Must be empty or absent: credentials stay with the Engine."
|
||||
},
|
||||
"toolCallTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000
|
||||
},
|
||||
"failOnStartupError": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Only false: the Engine connects lazily and reports failures through /mcp."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-skill-filesystem",
|
||||
"name": "@deepseek-ai/dsh-skill-filesystem",
|
||||
"bridge": "skills",
|
||||
"summary": "A skill folder row, limited to reviewed roots inside the bundle.",
|
||||
"limits": [
|
||||
"Both includeDefaultRoots and watch must be written out as false."
|
||||
],
|
||||
"example": {
|
||||
"includeDefaultRoots": false,
|
||||
"watch": false,
|
||||
"customSkillDirs": [
|
||||
"skills"
|
||||
]
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-skill-filesystem",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"includeDefaultRoots",
|
||||
"watch",
|
||||
"customSkillDirs"
|
||||
],
|
||||
"properties": {
|
||||
"providerName": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"includeDefaultRoots": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Must be false: ambient roots are not read."
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Must be false: there is no file watcher."
|
||||
},
|
||||
"customSkillDirs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Bundle-relative skill roots, no duplicates."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-hooks-claude-code",
|
||||
"name": "@deepseek-ai/dsh-hooks-claude-code",
|
||||
"bridge": "hooks-claude-code",
|
||||
"summary": "Claude Code style command hooks read from a reviewed file in the bundle.",
|
||||
"limits": [
|
||||
"projectDir is refused: each process uses the current workspace.",
|
||||
"Only command hooks run; non-command and asynchronous hooks fail the row.",
|
||||
"At most 128 commands per row."
|
||||
],
|
||||
"example": {
|
||||
"configPath": "hooks/hooks.json"
|
||||
},
|
||||
"hookEvents": [
|
||||
{
|
||||
"event": "SessionStart",
|
||||
"point": "session_start"
|
||||
},
|
||||
{
|
||||
"event": "UserPromptSubmit",
|
||||
"point": "message_submit"
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"point": "tool_call_before"
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"point": "tool_call_after"
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"point": "turn_end"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStart",
|
||||
"point": "subagent_spawn"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStop",
|
||||
"point": "subagent_complete"
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-hooks-claude-code",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"configPath"
|
||||
],
|
||||
"properties": {
|
||||
"configPath": {
|
||||
"type": "string",
|
||||
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
|
||||
},
|
||||
"pluginRoot": {
|
||||
"type": "string",
|
||||
"description": "Absent, \".\", or this reviewed bundle root."
|
||||
},
|
||||
"defaultTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000,
|
||||
"description": "Whole seconds, used when a hook sets none; default 600000."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-hooks-codex",
|
||||
"name": "@deepseek-ai/dsh-hooks-codex",
|
||||
"bridge": "hooks-codex",
|
||||
"summary": "Codex style command hooks read from a reviewed file in the bundle.",
|
||||
"limits": [
|
||||
"projectDir is refused: each process uses the current workspace.",
|
||||
"Only command hooks run; non-command and asynchronous hooks fail the row.",
|
||||
"At most 128 commands per row."
|
||||
],
|
||||
"example": {
|
||||
"configPath": "hooks/hooks.json"
|
||||
},
|
||||
"hookEvents": [
|
||||
{
|
||||
"event": "SessionStart",
|
||||
"point": "session_start"
|
||||
},
|
||||
{
|
||||
"event": "UserPromptSubmit",
|
||||
"point": "message_submit"
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"point": "tool_call_before"
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"point": "tool_call_after"
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"point": "turn_end"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStart",
|
||||
"point": "subagent_spawn"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStop",
|
||||
"point": "subagent_complete"
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-hooks-codex",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"configPath"
|
||||
],
|
||||
"properties": {
|
||||
"configPath": {
|
||||
"type": "string",
|
||||
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
|
||||
},
|
||||
"pluginRoot": {
|
||||
"type": "string",
|
||||
"description": "Absent, \".\", or this reviewed bundle root."
|
||||
},
|
||||
"defaultTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000,
|
||||
"description": "Whole seconds, used when a hook sets none; default 600000."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-persona",
|
||||
"name": "@deepseek-ai/dsh-persona",
|
||||
"bridge": "persona",
|
||||
"summary": "Text added before and after the Engine's own prompt for this plugin's entry.",
|
||||
"limits": [
|
||||
"Needs an exact Engine-selected entry scope."
|
||||
],
|
||||
"example": {
|
||||
"prefix": "Answer in short sentences."
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-persona",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"prefix"
|
||||
],
|
||||
"properties": {
|
||||
"prefix": {
|
||||
"type": "string",
|
||||
"description": "At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded."
|
||||
},
|
||||
"suffix": {
|
||||
"type": "string",
|
||||
"description": "Same limits as prefix."
|
||||
},
|
||||
"complete": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Replacing the whole prompt belongs to the Engine."
|
||||
},
|
||||
"includeRuntimeContext": {
|
||||
"type": "boolean",
|
||||
"const": true,
|
||||
"description": "Suppressing the runtime context belongs to the Engine."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"client": {
|
||||
"seats": [
|
||||
{
|
||||
"seat": "customize.plugins.detail",
|
||||
"drawn": true,
|
||||
"where": "The plugin's own page under Customize, Plugins."
|
||||
},
|
||||
{
|
||||
"seat": "conversation.composer.dock",
|
||||
"drawn": false,
|
||||
"where": "Under the composer. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "conversation.chat.node",
|
||||
"drawn": false,
|
||||
"where": "Inside the conversation. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "workpane.panel",
|
||||
"drawn": false,
|
||||
"where": "The work pane. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "sidebar.section",
|
||||
"drawn": false,
|
||||
"where": "The sidebar. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "command.row",
|
||||
"drawn": false,
|
||||
"where": "The command list. The desktop app does not place it yet."
|
||||
}
|
||||
],
|
||||
"slots": [
|
||||
{
|
||||
"slot": "plugins.bundle.config",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.row.config",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.tab",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.detail.section",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.section",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.plugin.item",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.general.item",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.plugins.tab",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.node",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.turnTail",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.assistant-actions",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.text-interaction",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.user.text.node",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "tool.call.toolview",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.view",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.dock",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.overlay",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.right",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.composer.dock",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.right.pane.tab",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "main.conversation",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "main.page-menu",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.panellist",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.workspaces",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.footer.action",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.session.row.leading",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.workspaces.session.row.action",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.session.header.actions",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
},
|
||||
{
|
||||
"slot": "conversation.session.header.utilities",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
},
|
||||
{
|
||||
"slot": "contextMenu.icon",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
}
|
||||
],
|
||||
"theme": {
|
||||
"tokens": [],
|
||||
"colorSchemes": [
|
||||
"light",
|
||||
"dark"
|
||||
],
|
||||
"scheme": "The frame sets data-theme and color-scheme on its document to the app's current scheme and follows changes.",
|
||||
"api": [
|
||||
{
|
||||
"name": "ctx.theme.getTheme",
|
||||
"signature": "getTheme(): { active: { colorScheme: 'light' | 'dark' } }",
|
||||
"summary": "The scheme in use now."
|
||||
},
|
||||
{
|
||||
"name": "ctx.theme.overrideTokens",
|
||||
"signature": "overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void",
|
||||
"summary": "Set custom properties on the frame's document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer."
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.",
|
||||
"The frame's background is transparent and its margin is zero, so the plugin sits on the app's own surface."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { isJson } from '../json.ts'
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './dsh-tools-compat.js'
|
||||
import catalogData from './inspect-catalog.json' with { type: 'json' }
|
||||
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
export type InspectPlatform = 'host' | 'client'
|
||||
|
||||
export interface InspectMethod {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: JsonValue
|
||||
outputSchema: JsonValue
|
||||
}
|
||||
|
||||
export interface InspectProviderView {
|
||||
platform: InspectPlatform
|
||||
id: string
|
||||
description: string
|
||||
methods: InspectMethod[]
|
||||
}
|
||||
|
||||
export interface InstalledRow {
|
||||
id: string
|
||||
name: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface ClientState {
|
||||
connected: boolean
|
||||
drawnSeats?: readonly string[]
|
||||
}
|
||||
|
||||
export interface InspectSources {
|
||||
tools?: (agent: unknown, signal: AbortSignal) => Promise<readonly JsonValue[]> | readonly JsonValue[]
|
||||
installed?: (signal: AbortSignal) => Promise<readonly InstalledRow[]> | readonly InstalledRow[]
|
||||
client?: () => ClientState
|
||||
}
|
||||
|
||||
export interface CordisInspect {
|
||||
list(): InspectProviderView[]
|
||||
query(platform: string, provider: string, method: string, input: JsonValue | undefined, agent?: unknown, signal?: AbortSignal): Promise<JsonValue>
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
platform: InspectPlatform
|
||||
id: string
|
||||
description: string
|
||||
methods: InspectMethod[]
|
||||
run(method: string, input: Record<string, JsonValue>, agent: unknown, signal: AbortSignal): Promise<any> | any
|
||||
}
|
||||
|
||||
const catalog = catalogData as any
|
||||
const EMPTY_INPUT: JsonValue = { type: 'object', properties: {}, additionalProperties: false }
|
||||
const ANY_OUTPUT: JsonValue = { description: 'JSON data owned by this inspect provider.' }
|
||||
const NO_CLIENT = 'No client is connected. Plugin screens are drawn by the desktop app; in a terminal nothing is drawn, so every slot reports drawn: false.'
|
||||
|
||||
const exactInput = (field: string, description: string): JsonValue => ({
|
||||
type: 'object',
|
||||
properties: { [field]: { type: 'string', description } },
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
const method = (name: string, description: string, inputSchema: JsonValue = EMPTY_INPUT, outputSchema: JsonValue = ANY_OUTPUT): InspectMethod =>
|
||||
({ name, description, inputSchema, outputSchema })
|
||||
|
||||
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||
|
||||
function serviceView(service: any): JsonValue {
|
||||
return {
|
||||
key: service.key,
|
||||
group: service.group,
|
||||
summary: service.summary,
|
||||
availability: service.availability,
|
||||
availabilityText: service.availabilityText,
|
||||
methods: service.methods,
|
||||
}
|
||||
}
|
||||
|
||||
function serviceProvider(): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Service',
|
||||
description: 'The services this host gives plugins, with their methods, and the services a plugin may not provide. A name that is not listed does not exist here.',
|
||||
methods: [method('listService', 'Without input: the directory of services with one-line summaries and method signatures. With service: that service\'s exact contract, with availability. These are services a plugin injects, not DSH\'s full catalogue.',
|
||||
exactInput('service', 'Exact service key. Omit it for the directory.'))],
|
||||
run(_name, input) {
|
||||
const key = input.service
|
||||
if (key === undefined) {
|
||||
return {
|
||||
services: catalog.host.services.map((s: any) => ({ key: s.key, group: s.group, summary: s.summary, availability: s.availability, signatures: s.methods.map((m: any) => m.signature) })),
|
||||
refusedServices: catalog.host.refusedServices,
|
||||
note: 'refusedServices are names a plugin may not provide: the Engine owns them. Service methods named by the host Event and Config providers are separate.',
|
||||
targetDshVersion: catalog.targetDshVersion,
|
||||
}
|
||||
}
|
||||
const found = catalog.host.services.find((s: any) => s.key === key)
|
||||
if (!found) throw new Error(`Host service "${String(key)}" is not provided by this host. Call without service for the directory.`)
|
||||
return { service: serviceView(found), targetDshVersion: catalog.targetDshVersion }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function eventProvider(): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Event',
|
||||
description: 'The events a plugin may listen to in this host, and the ones the host refuses.',
|
||||
methods: [method('listEvents', 'Without input: the directory of events with status supported or refused. With event: its exact contract.',
|
||||
exactInput('event', 'Exact event name. Omit it for the directory.'))],
|
||||
run(_name, input) {
|
||||
const key = input.event
|
||||
if (key === undefined) {
|
||||
return {
|
||||
events: catalog.host.events.map((e: any) => ({ name: e.name, status: e.status, summary: e.summary })),
|
||||
note: catalog.host.cordisInternalEvents,
|
||||
}
|
||||
}
|
||||
const found = catalog.host.events.find((e: any) => e.name === key)
|
||||
if (!found) throw new Error(`Host event "${String(key)}" is not known to this host. Call without event for the directory.`)
|
||||
return { event: found }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG_INPUT: JsonValue = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entry: { type: 'string', description: 'Exact entry id from the directory; returns that entry\'s Config schema.' },
|
||||
name: { type: 'string', description: 'Exact package or plugin name; limits the directory to it.' },
|
||||
offset: { type: 'integer', description: 'Zero-based directory offset; defaults to 0.' },
|
||||
limit: { type: 'integer', description: 'Directory page size, from 1 to 100; defaults to 25.' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
function configProvider(sources: InspectSources): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Config',
|
||||
description: 'Config a composition row may carry: the packages a row may name, each with the config it accepts, and the plugins installed on this host with their state.',
|
||||
methods: [method('listConfigs', 'Without entry: a page of the directory (row packages first, then installed plugins) with total and nextOffset. With entry: one row package\'s exact JSON Schema, limits and a valid example. Installed plugins are listed with their state; their own config is not described here.', CONFIG_INPUT)],
|
||||
async run(_name, input, _agent, signal) {
|
||||
const rows: any[] = catalog.host.config.map((r: any) => ({ id: r.id, name: r.name, kind: 'row-package', status: 'schema', bridge: r.bridge, summary: r.summary }))
|
||||
const installed = sources.installed ? await sources.installed(signal) : []
|
||||
for (const row of installed) rows.push({ id: `plugin:${row.id}`, name: row.name, kind: 'installed-plugin', status: row.state })
|
||||
if (input.entry !== undefined) {
|
||||
const pkg = catalog.host.config.find((r: any) => r.id === input.entry)
|
||||
if (pkg) return { entry: pkg.id, name: pkg.name, kind: 'row-package', bridge: pkg.bridge, summary: pkg.summary, limits: pkg.limits, example: pkg.example, ...pkg.hookEvents ? { hookEvents: pkg.hookEvents } : {}, schema: pkg.schema }
|
||||
const live = rows.find((r) => r.id === input.entry)
|
||||
if (live) return { entry: live.id, name: live.name, kind: live.kind, status: live.status, schema: null, note: 'An installed plugin\'s own Config is not described by this host.' }
|
||||
throw new Error(`Host Config entry "${String(input.entry)}" is not listed. Call without entry for the directory.`)
|
||||
}
|
||||
const filtered = input.name === undefined ? rows : rows.filter((r) => r.name === input.name)
|
||||
const offset = typeof input.offset === 'number' ? input.offset : 0
|
||||
const limit = typeof input.limit === 'number' ? input.limit : 25
|
||||
if (offset < 0 || limit < 1 || limit > 100) throw new Error('Host Config directory needs offset >= 0 and 1 <= limit <= 100')
|
||||
const page = filtered.slice(offset, offset + limit)
|
||||
const next = offset + page.length
|
||||
return { entries: page, total: filtered.length, ...next < filtered.length ? { nextOffset: next } : {} }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toolProvider(sources: InspectSources): Provider | undefined {
|
||||
const source = sources.tools
|
||||
if (!source) return undefined
|
||||
return {
|
||||
platform: 'host', id: 'Tool',
|
||||
description: 'Tools visible to the requesting agent.',
|
||||
methods: [method('listTools', 'Return every tool schema currently callable by this agent.')],
|
||||
async run(_name, _input, agent, signal) {
|
||||
return { tools: [...await source(agent, signal)] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SUBTREE_INPUT = exactInput('root', 'Exact slot name to select. Omit it for every slot.')
|
||||
|
||||
function slotsProvider(sources: InspectSources): Provider {
|
||||
return {
|
||||
platform: 'client', id: 'Slots',
|
||||
description: 'The DSH slot names this host accepts, the seat each maps to, and whether the connected client draws that seat.',
|
||||
methods: [method('listSubTree', 'Return every slot with its seat and drawn state; with root, also that slot selected. Slots are static names here, not a live tree, and their props are not described by this host: a plugin registers on a slot, the Engine serves it on the mapped seat, and the client draws only the seats it places.', SUBTREE_INPUT)],
|
||||
run(_name, input) {
|
||||
const state = sources.client?.() ?? { connected: false }
|
||||
const drawn = new Set(state.connected ? state.drawnSeats ?? catalog.client.seats.filter((s: any) => s.drawn).map((s: any) => s.seat) : [])
|
||||
const slots = catalog.client.slots.map((s: any) => ({
|
||||
type: 'slot', name: s.slot, seat: s.seat, supported: s.supported,
|
||||
drawn: s.supported && drawn.has(s.seat),
|
||||
...s.reason ? { reason: s.reason } : {},
|
||||
}))
|
||||
const seats = catalog.client.seats.map((s: any) => ({ seat: s.seat, drawn: drawn.has(s.seat), where: s.where }))
|
||||
const out: Record<string, JsonValue> = {
|
||||
client: state.connected ? { connected: true } : { connected: false, message: NO_CLIENT },
|
||||
seats, trees: slots, referencedTypes: [],
|
||||
}
|
||||
if (input.root !== undefined) {
|
||||
const selected = slots.find((s: any) => s.name === input.root)
|
||||
out.requestedRoot = { name: input.root, available: selected !== undefined }
|
||||
if (selected) out.selected = selected
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function themeProvider(): Provider {
|
||||
return {
|
||||
platform: 'client', id: 'Theme',
|
||||
description: 'What the plugin frame provides for theming.',
|
||||
methods: [method('listTokens', 'Return the named tokens the frame provides (none), the colour schemes, and the theme calls a plugin has.')],
|
||||
run() {
|
||||
return { tokens: catalog.client.theme.tokens, colorSchemes: catalog.client.theme.colorSchemes, scheme: catalog.client.theme.scheme, api: catalog.client.theme.api, notes: catalog.client.theme.notes, referencedTypes: [] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCordisInspect(sources: InspectSources = {}): CordisInspect {
|
||||
const providers: Provider[] = [serviceProvider(), eventProvider(), configProvider(sources), toolProvider(sources), slotsProvider(sources), themeProvider()]
|
||||
.filter((p): p is Provider => p !== undefined)
|
||||
for (const provider of providers) {
|
||||
if (provider.id.trim() === '' || provider.description.trim() === '') throw new Error('Cordis inspect provider needs an id and a description')
|
||||
for (const m of provider.methods) {
|
||||
assertSupportedJsonSchema(m.inputSchema)
|
||||
assertSupportedJsonSchema(m.outputSchema)
|
||||
}
|
||||
}
|
||||
const label = (platform: InspectPlatform) => platform === 'host' ? 'Host' : 'Client'
|
||||
return {
|
||||
list() {
|
||||
return providers.map((p) => ({ platform: p.platform, id: p.id, description: p.description, methods: copy(p.methods) }))
|
||||
},
|
||||
async query(platform, providerId, methodName, input, agent, signal = new AbortController().signal) {
|
||||
if (platform !== 'host' && platform !== 'client') throw new Error(`Cordis inspect platform must be "host" or "client", not "${String(platform)}"`)
|
||||
const provider = providers.find((p) => p.platform === platform && p.id === providerId)
|
||||
if (!provider) throw new Error(`${label(platform)} Cordis inspect provider "${providerId}" is not registered`)
|
||||
const found = provider.methods.find((m) => m.name === methodName)
|
||||
if (!found) throw new Error(`Cordis inspect provider "${provider.id}" has no method "${methodName}"`)
|
||||
const violations = validateJsonSchemaValue(found.inputSchema, input ?? {}, 'input') as string[]
|
||||
if (violations.length > 0) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} rejected input: ${violations.join('; ')}`)
|
||||
signal.throwIfAborted()
|
||||
const data = await provider.run(methodName, (input ?? {}) as Record<string, JsonValue>, agent, signal)
|
||||
signal.throwIfAborted()
|
||||
if (!isJson(data)) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} returned a non-JSON value`)
|
||||
return copy(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { DshAdapter } from './adapters.ts'
|
||||
|
||||
function label(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'string') throw new RpcError(ErrorCode.InvalidParams, 'remote.pair label must be a string')
|
||||
return value
|
||||
}
|
||||
|
||||
function grant(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, 'remote.revoke needs the grant id remote.pair returned')
|
||||
return value
|
||||
}
|
||||
|
||||
export const remoteAdapter: DshAdapter = {
|
||||
service: 'remote',
|
||||
define(host) {
|
||||
class RemoteService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'remote') }
|
||||
status() { return host.broker(this.ctx, 'remote', 'status', {}) }
|
||||
pair(options?: { label?: unknown }) {
|
||||
const value = label(options?.label)
|
||||
return host.broker(this.ctx, 'remote', 'pair', value === undefined ? {} : { label: value })
|
||||
}
|
||||
clients() { return host.broker(this.ctx, 'remote', 'clients', {}) }
|
||||
revoke(id: unknown) { return host.broker(this.ctx, 'remote', 'revoke', { grant: grant(id) }) }
|
||||
}
|
||||
Object.freeze(RemoteService.prototype)
|
||||
return RemoteService
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { DshAdapter } from './adapters.ts'
|
||||
|
||||
const SESSION = /^[A-Za-z0-9._-]{1,64}$/
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new RpcError(ErrorCode.InvalidParams, message)
|
||||
}
|
||||
|
||||
function session(value: unknown): string {
|
||||
if (typeof value !== 'string' || !SESSION.test(value)) invalid('a terminal name is 1 to 64 letters, digits, `_`, `-` or `.`')
|
||||
return value as string
|
||||
}
|
||||
|
||||
function text(value: unknown, what: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) invalid(`${what} is a non-empty string`)
|
||||
return value as string
|
||||
}
|
||||
|
||||
function bounded(value: unknown, what: string, min: number, max: number): number | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) invalid(`${what} is an integer between ${min} and ${max}`)
|
||||
return value as number
|
||||
}
|
||||
|
||||
function compact(input: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined))
|
||||
}
|
||||
|
||||
export const terminalAdapter: DshAdapter = {
|
||||
service: 'terminal',
|
||||
define(host) {
|
||||
class TerminalService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'terminal') }
|
||||
list() { return host.broker(this.ctx, 'terminal', 'list', {}) }
|
||||
run(command: unknown, options?: { session?: unknown, timeoutSecs?: unknown }) {
|
||||
return host.broker(this.ctx, 'terminal', 'run', compact({
|
||||
command: text(command, 'a command'),
|
||||
session: options?.session === undefined ? undefined : session(options.session),
|
||||
timeout_secs: bounded(options?.timeoutSecs, 'timeoutSecs', 1, 600),
|
||||
}))
|
||||
}
|
||||
send(name: unknown, input: unknown, options?: { waitMs?: unknown }) {
|
||||
return host.broker(this.ctx, 'terminal', 'send', compact({
|
||||
session: session(name),
|
||||
text: text(input, 'terminal input'),
|
||||
wait_ms: bounded(options?.waitMs, 'waitMs', 0, 60_000),
|
||||
}))
|
||||
}
|
||||
wait(name: unknown, options?: { timeoutSecs?: unknown }) {
|
||||
return host.broker(this.ctx, 'terminal', 'wait', compact({
|
||||
session: session(name),
|
||||
timeout_secs: bounded(options?.timeoutSecs, 'timeoutSecs', 1, 600),
|
||||
}))
|
||||
}
|
||||
cancel(name: unknown) { return host.broker(this.ctx, 'terminal', 'cancel', { session: session(name) }) }
|
||||
reset(name: unknown) { return host.broker(this.ctx, 'terminal', 'reset', { session: session(name) }) }
|
||||
}
|
||||
Object.freeze(TerminalService.prototype)
|
||||
return TerminalService
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DeepSeek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,17 @@
|
||||
# DeepSeek Harness creator files, pinned
|
||||
|
||||
Byte-for-byte copies of the files DeepSeek Harness 0.2.2-rc.1 uses for its
|
||||
Creator mode, kept so Codewhale hosts the same tool and skill text rather than
|
||||
a rewrite. `UPSTREAM.json` records the package, version, upstream path and
|
||||
SHA-256 of every file; `test/creator-pin.test.mjs` fails when a file drifts.
|
||||
|
||||
- `plugin-manager/tools.js`: `@deepseek-ai/dsh-plugin-manager` `lib/types/tools.js`
|
||||
- `tool-cordis/index.js`: `@deepseek-ai/dsh-tool-cordis` `lib/index.js`
|
||||
- `sandbox/escalation.js`: not yet pinned (see `UPSTREAM.json`, `pending`)
|
||||
|
||||
The four authoring skills of `@deepseek-ai/dsh-agent-preset` are pinned beside
|
||||
the skills crate at `crates/skills/assets/dsh-creator/`.
|
||||
|
||||
The files are MIT licensed by DeepSeek; `LICENSE` is the licence text shipped
|
||||
in each of the four packages. Nothing here is edited: change the pin by
|
||||
replacing the file and its recorded hash together, at a new DSH version.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"repository": "https://github.com/deepseek-ai/deepseek-harness",
|
||||
"version": "0.2.2-rc.1",
|
||||
"license": "MIT",
|
||||
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
|
||||
"source": "npm packages as shipped in DeepSeek Harness desktop 0.2.2-rc.1",
|
||||
"packages": [
|
||||
"@deepseek-ai/dsh-agent-preset",
|
||||
"@deepseek-ai/dsh-plugin-manager",
|
||||
"@deepseek-ai/dsh-tool-cordis",
|
||||
"@deepseek-ai/dsh-sandbox"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "plugin-manager/tools.js",
|
||||
"package": "@deepseek-ai/dsh-plugin-manager",
|
||||
"upstream_path": "lib/types/tools.js",
|
||||
"sha256": "18d9f06f20a821bd205b6d88f87f440659730ef9431fb92351a50d86779da3ca",
|
||||
"bytes": 12583,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "tool-cordis/index.js",
|
||||
"package": "@deepseek-ai/dsh-tool-cordis",
|
||||
"upstream_path": "lib/index.js",
|
||||
"sha256": "ce0cd4d8983532c920cab7713dc51874f6d710c2c46c4a095167bfa980becdb0",
|
||||
"bytes": 3687,
|
||||
"verbatim": true
|
||||
}
|
||||
],
|
||||
"pending": [
|
||||
{
|
||||
"path": "sandbox/escalation.js",
|
||||
"package": "@deepseek-ai/dsh-sandbox",
|
||||
"reason": "excerpt of approveEscalation and its mode table; needs the published tarball, not yet fetched"
|
||||
}
|
||||
],
|
||||
"skills_pin": "crates/skills/assets/dsh-creator/UPSTREAM.json"
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/** Agent-facing current-profile management using the same service as Web controls. */
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values';
|
||||
import { approveEscalation } from '@deepseek-ai/dsh-sandbox';
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools';
|
||||
import { getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot';
|
||||
/** What the model does next for each refusal the manager reports. */
|
||||
const ERROR_STEPS = {
|
||||
'management-required': 'This entry belongs to plugin management itself and cannot be changed.',
|
||||
'unaddressable': 'This entry has no unique profile patch target; tell the user to edit the profile patch file directly.',
|
||||
'unknown-plugin': 'Call list_plugins and retry with an exact entryId.',
|
||||
'invalid-spec': 'Retry with an npm package name, a git URL, a tarball URL, or an absolute local directory.',
|
||||
'already-installed': 'The package is already installed; to install another version, call remove_bundle first, then install_bundle again.',
|
||||
'ambiguous-install': 'The dependency change named no single package; call list_bundles to see what was installed.',
|
||||
'not-bundle': 'The package declares no dsh.bundle and cannot be managed as a plugin; install a DSH plugin bundle instead.',
|
||||
'not-removable': 'Call list_bundles; only bundles marked removable can be removed.',
|
||||
'stop-profile': 'This profile runs without HMR; tell the user to stop DSH and run `dsh plugin --profile <profile> remove <bundle>`.',
|
||||
'bundle-in-use': 'Call list_plugins, disable the entries from other layers that still use this bundle, then retry.',
|
||||
'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.',
|
||||
'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.',
|
||||
'unsupported-platform': 'This bundle cannot run on the current Host platform; ask the user to use a supported Host.',
|
||||
'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.',
|
||||
};
|
||||
/** What the model does next for each package-manager failure. */
|
||||
const FAILURE_STEPS = {
|
||||
'pnpm-missing': 'pnpm is not available to DSH; tell the user to install pnpm.',
|
||||
'timeout': 'Retry, or retry with another registry.',
|
||||
'not-found': 'Check the package name and version with the user, then retry.',
|
||||
'no-matching-version': 'Retry with a published version.',
|
||||
'network': 'Retry later or pass another registry.',
|
||||
'disk-full': 'Tell the user to free disk space, then retry.',
|
||||
'permission': 'Tell the user DSH cannot write the profile directory; retry after they fix its permissions.',
|
||||
'build-blocked': 'Ask the user whether to allow the install scripts of the packages in pendingBuilds; if they approve, retry with approvedBuilds.',
|
||||
};
|
||||
/** Why a change waits for a restart, as the model tells the user. */
|
||||
const RESTART_STEPS = {
|
||||
'no-hmr': 'this profile applies changes only at startup',
|
||||
'package-moved': 'an installed package now resolves to another directory, or the change replaces a package the DSH installation provides; remove a package before installing it from another location',
|
||||
'host-module': 'the change reaches modules DSH itself loaded',
|
||||
'unmanaged-plugin': 'the change reaches a plugin running outside the plugin list',
|
||||
'module-comparison': 'DSH could not compare loaded plugins with their files',
|
||||
};
|
||||
/**
|
||||
* The model-facing result of a change: the manager's result plus the next step its outcome calls for.
|
||||
* @param result The manager's result.
|
||||
* @returns The result with `nextStep` when it is not plainly applied.
|
||||
*/
|
||||
function withNextStep(result) {
|
||||
switch (result.application) {
|
||||
case 'applied':
|
||||
return result;
|
||||
case 'cancelled':
|
||||
return { ...result, nextStep: 'The installation was cancelled and its files restored; nothing changed.' };
|
||||
case 'overridden':
|
||||
return { ...result, nextStep: 'Saved, but a higher-priority layer such as the agent preset overrides it; tell the user it is not in effect.' };
|
||||
case 'restart-required': {
|
||||
const reason = result.restartReason === undefined ? '' : `: ${RESTART_STEPS[result.restartReason]}`;
|
||||
return { ...result, nextStep: `Tell the user to restart DSH to apply this change${reason}.` };
|
||||
}
|
||||
case 'failed': {
|
||||
const code = result.error?.code ?? 'operation-error';
|
||||
// A refusal code is more specific than the package manager's failure kind.
|
||||
const kind = code === 'operation-error' ? result.packageResult?.kind : undefined;
|
||||
return { ...result, nextStep: (kind === undefined ? undefined : FAILURE_STEPS[kind]) ?? ERROR_STEPS[code] };
|
||||
}
|
||||
/* v8 ignore next -- ChangeResult.application is a closed union */
|
||||
default: return assertNever(result.application);
|
||||
}
|
||||
}
|
||||
/** Required services for the management tool. */
|
||||
export const inject = ['tools', 'pluginManager', 'sandboxPolicy'];
|
||||
/** Register one management tool for discovery and the four persistent actions.
|
||||
* @param ctx Agent-scoped tool registration context.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'plugin_manager',
|
||||
description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Unmet DSH version requirements (engines.dsh, else DSH peer dependencies) block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.',
|
||||
parameters: {
|
||||
action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption'], description: 'Management operation.' },
|
||||
target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle.' },
|
||||
enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes.' },
|
||||
runtimeVersion: { type: 'string', description: 'For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range.' },
|
||||
acceptRisk: { type: 'boolean', description: 'For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough.' },
|
||||
approvedBuilds: { type: 'array', items: { type: 'string' }, description: 'For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.' },
|
||||
registry: { type: 'string', description: 'For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable.' },
|
||||
offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' },
|
||||
limit: { type: 'number', description: 'List page size, from 1 to 100; defaults to 25.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const policy = ctx.sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
|
||||
await approveEscalation({
|
||||
requestedMode: 'danger-full-access', effectiveMode: policy.mode, subject: 'plugin management operation',
|
||||
justification: `plugin_manager ${JSON.stringify(args)}. Profile changes persist across sessions; installed Host code runs outside the workspace sandbox.`,
|
||||
}, { approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId,
|
||||
toolName: 'plugin_manager', signal: exec.signal });
|
||||
exec.signal.throwIfAborted();
|
||||
const manager = ctx.pluginManager;
|
||||
switch (args.action) {
|
||||
case 'list_version_exemptions':
|
||||
return JSON.stringify({ runtimeVersion: getDshRuntimeVersion(), ...manager.listVersionExemptions() });
|
||||
case 'set_version_exemption':
|
||||
if (args.target === undefined || args.runtimeVersion === undefined || args.enabled === undefined) {
|
||||
throw new Error('target package-name@version, runtimeVersion, and enabled are required');
|
||||
}
|
||||
return JSON.stringify(withNextStep(await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk)));
|
||||
case 'list_plugins':
|
||||
case 'list_bundles': {
|
||||
const offset = args.offset ?? 0;
|
||||
const limit = args.limit ?? 25;
|
||||
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) {
|
||||
throw new Error('offset must be a non-negative integer and limit must be an integer from 1 to 100');
|
||||
}
|
||||
const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles();
|
||||
const entries = rows.slice(offset, offset + limit).map(({ meta: _meta, ...row }) => 'rows' in row
|
||||
// GUI listing and update eligibility do not change the tool's management operations.
|
||||
? (({ updatable: _updatable, listedByDefault: _listedByDefault, ...bundle }) => ({
|
||||
...bundle, rows: bundle.rows.map(({ meta: _rowMeta, ...declared }) => declared),
|
||||
}))(row)
|
||||
: row);
|
||||
return JSON.stringify({ entries, total: rows.length,
|
||||
nextOffset: offset + entries.length < rows.length ? offset + entries.length : null });
|
||||
}
|
||||
case 'set_plugin':
|
||||
case 'set_bundle': {
|
||||
if (args.target === undefined || args.enabled === undefined)
|
||||
throw new Error('target and enabled are required');
|
||||
return JSON.stringify(withNextStep(await (args.action === 'set_plugin'
|
||||
? manager.setPluginEnabled(args.target, args.enabled)
|
||||
: manager.setBundleEnabled(args.target, args.enabled))));
|
||||
}
|
||||
case 'install_bundle':
|
||||
if (args.target === undefined)
|
||||
throw new Error('target package spec is required');
|
||||
return JSON.stringify(withNextStep(await manager.installBundle(args.target, {
|
||||
...args.enabled === undefined ? {} : { enabled: args.enabled },
|
||||
...args.approvedBuilds === undefined ? {} : { approvedBuilds: args.approvedBuilds },
|
||||
...args.registry === undefined ? {} : { registry: args.registry },
|
||||
})));
|
||||
case 'remove_bundle':
|
||||
if (args.target === undefined)
|
||||
throw new Error('target bundle name is required');
|
||||
return JSON.stringify(withNextStep(await manager.removeBundle(args.target)));
|
||||
/* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */
|
||||
default: return assertNever(args.action);
|
||||
}
|
||||
},
|
||||
presentCall: args => ({ card: 'generic', title: 'Manage profile plugins', kind: args.action.startsWith('list_') ? 'read' : 'other', rawInput: args }),
|
||||
}));
|
||||
}
|
||||
//# sourceMappingURL=tools.js.map
|
||||
@@ -0,0 +1,100 @@
|
||||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||
//#region lib/types/present.js
|
||||
/**
|
||||
* Render provider-directory inspection.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
function presentInspectListCall() {
|
||||
return {
|
||||
card: "generic",
|
||||
kind: "read",
|
||||
title: "List Cordis Inspect Providers"
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Render one provider query.
|
||||
* @param args - target platform, provider, and method.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
function presentInspectQueryCall(args) {
|
||||
return {
|
||||
card: "generic",
|
||||
kind: "read",
|
||||
title: `Query Cordis ${args.platform} ${args.provider}.${args.method}`
|
||||
};
|
||||
}
|
||||
//#endregion
|
||||
//#region lib/types/index.js
|
||||
const name = "tool-cordis";
|
||||
const inject = ["tools", "cordisInspect"];
|
||||
function requireAgent(exec) {
|
||||
if (exec.agent === void 0) throw new Error("Cordis inspection requires an Agent-backed session");
|
||||
return exec.agent;
|
||||
}
|
||||
/** Register read-only runtime inspection tools over the Host providers that
|
||||
* `@deepseek-ai/dsh-tool-cordis/host` registers once per process.
|
||||
* @param ctx Agent-scoped registration context.
|
||||
*/
|
||||
function apply(ctx) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: "cordis_inspect_list",
|
||||
description: "List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before writing or configuring a plugin, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.",
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: "json" },
|
||||
render: (_args, value) => [{
|
||||
type: "text",
|
||||
text: JSON.stringify(value, null, 2)
|
||||
}]
|
||||
},
|
||||
execute(_args, _exec) {
|
||||
return Promise.resolve({ providers: ctx.cordisInspect.list() });
|
||||
},
|
||||
presentCall: presentInspectListCall
|
||||
}));
|
||||
ctx.tools.register(defineTool({
|
||||
name: "cordis_inspect_query",
|
||||
description: "Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, plugin Config schemas, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response within the configured timeout; otherwise it reports a Client failure or asks you to reconnect and retry. This Tool cannot invoke business Service methods or modify the runtime.",
|
||||
parameters: {
|
||||
platform: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["host", "client"],
|
||||
description: "Runtime platform that owns the Provider."
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Exact Provider ID returned by cordis_inspect_list."
|
||||
},
|
||||
method: {
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Exact method name declared by the Provider manifest."
|
||||
},
|
||||
input: {
|
||||
type: "json",
|
||||
description: "Optional query input; it must satisfy the method input schema."
|
||||
}
|
||||
},
|
||||
output: {
|
||||
schema: { type: "json" },
|
||||
render: (_args, value) => [{
|
||||
type: "text",
|
||||
text: JSON.stringify(value, null, 2)
|
||||
}]
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const data = await ctx.cordisInspect.query(args.platform, args.provider, args.method, args.input, requireAgent(exec), exec.signal);
|
||||
return {
|
||||
platform: args.platform,
|
||||
provider: args.provider,
|
||||
method: args.method,
|
||||
data
|
||||
};
|
||||
},
|
||||
presentCall: presentInspectQueryCall
|
||||
}));
|
||||
}
|
||||
//#endregion
|
||||
export { apply, inject, name };
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { DshAdapter } from './adapters.ts'
|
||||
|
||||
const MAX_PATH = 512
|
||||
const MAX_WRITE = 1024 * 1024
|
||||
const MAX_ARGV = 9
|
||||
const MAX_ARG = 1024
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new RpcError(ErrorCode.InvalidParams, message)
|
||||
}
|
||||
|
||||
function path(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PATH || value.trim() !== value || /[\x00-\x1f\x7f]/.test(value)) {
|
||||
invalid(`a path is 1 to ${MAX_PATH} characters on one line with no surrounding spaces`)
|
||||
}
|
||||
return value as string
|
||||
}
|
||||
|
||||
function bounded(value: unknown, what: string, min: number, max: number): number | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) invalid(`${what} is an integer between ${min} and ${max}`)
|
||||
return value as number
|
||||
}
|
||||
|
||||
function argv(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ARGV || value.some(arg => typeof arg !== 'string' || arg.length > MAX_ARG || /[\x00-\x1f\x7f]/.test(arg))) {
|
||||
invalid(`argv is 1 to ${MAX_ARGV} strings of at most ${MAX_ARG} characters`)
|
||||
}
|
||||
return [...value as string[]]
|
||||
}
|
||||
|
||||
function compact(input: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined))
|
||||
}
|
||||
|
||||
export const workspaceAdapter: DshAdapter = {
|
||||
service: 'workspace',
|
||||
define(host) {
|
||||
class WorkspaceService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'workspace') }
|
||||
read(file: unknown, options?: { startLine?: unknown, maxLines?: unknown }) {
|
||||
return host.broker(this.ctx, 'workspace', 'read', compact({
|
||||
path: path(file),
|
||||
start_line: bounded(options?.startLine, 'startLine', 1, Number.MAX_SAFE_INTEGER),
|
||||
max_lines: bounded(options?.maxLines, 'maxLines', 1, 10_000),
|
||||
}))
|
||||
}
|
||||
list(dir?: unknown) {
|
||||
return host.broker(this.ctx, 'workspace', 'list', compact({ path: dir === undefined ? undefined : path(dir) }))
|
||||
}
|
||||
write(file: unknown, content: unknown, options?: { expectedHash?: unknown }) {
|
||||
if (typeof content !== 'string' || Buffer.byteLength(content) > MAX_WRITE) invalid(`content is a string of at most ${MAX_WRITE} bytes`)
|
||||
const hash = options?.expectedHash
|
||||
if (hash !== undefined && (typeof hash !== 'string' || hash.length === 0 || hash.length > 128)) invalid('expectedHash is 1 to 128 characters')
|
||||
return host.broker(this.ctx, 'workspace', 'write', compact({ path: path(file), content, expected_hash: hash }))
|
||||
}
|
||||
exec(command: unknown, options?: { cwd?: unknown }) {
|
||||
return host.broker(this.ctx, 'workspace', 'exec', compact({
|
||||
argv: argv(command),
|
||||
cwd: options?.cwd === undefined ? undefined : path(options.cwd),
|
||||
}))
|
||||
}
|
||||
}
|
||||
Object.freeze(WorkspaceService.prototype)
|
||||
return WorkspaceService
|
||||
},
|
||||
}
|
||||
@@ -44,7 +44,7 @@ before(async () => {
|
||||
const adapterFor = (service) => ({ service, define: () => class extends Service { constructor(ctx) { super(ctx, service) } } })
|
||||
|
||||
test('the shipped adapter table holds the reviewed adapters, and the root still builds', () => {
|
||||
assert.deepEqual(DSH_ADAPTERS.map((adapter) => adapter.service), ['sessions', 'systemPrompt', 'subagents', 'agents'])
|
||||
assert.deepEqual(DSH_ADAPTERS.map((adapter) => adapter.service), ['sessions', 'systemPrompt', 'subagents', 'agents', 'remote'])
|
||||
assert.ok(new HostRoot(rpc, 'plugin'))
|
||||
assert.ok(new HostRoot(rpc, 'builtin'))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { dirname, join, relative, sep } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pin = join(here, '..', 'src', 'dsh', 'upstream', 'creator')
|
||||
const manifest = JSON.parse(readFileSync(join(pin, 'UPSTREAM.json'), 'utf8'))
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
|
||||
function walk(dir) {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name)
|
||||
return statSync(path).isDirectory() ? walk(path) : [path]
|
||||
})
|
||||
}
|
||||
|
||||
test('every pinned creator file equals its recorded hash and is verbatim', () => {
|
||||
assert.equal(manifest.version, '0.2.2-rc.1')
|
||||
assert.ok(manifest.files.length >= 2)
|
||||
for (const entry of manifest.files) {
|
||||
assert.equal(entry.verbatim, true, entry.path)
|
||||
const bytes = readFileSync(join(pin, entry.path))
|
||||
assert.equal(bytes.length, entry.bytes, `${entry.path} size`)
|
||||
assert.equal(sha256(bytes), entry.sha256, `${entry.path} hash`)
|
||||
assert.ok(!bytes.includes(13), `${entry.path} must keep LF line endings`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the pin directory holds exactly the files the manifest lists', () => {
|
||||
const listed = new Set([...manifest.files.map((entry) => entry.path), 'UPSTREAM.json', 'LICENSE', 'README.md'])
|
||||
const present = walk(pin).map((path) => relative(pin, path).split(sep).join('/'))
|
||||
assert.deepEqual(present.sort(), [...listed].sort())
|
||||
})
|
||||
|
||||
test('the licence is the MIT text the four packages ship', () => {
|
||||
const license = readFileSync(join(pin, 'LICENSE'))
|
||||
assert.equal(sha256(license), manifest.license_sha256)
|
||||
assert.match(license.toString('utf8'), /^MIT License\n\nCopyright \(c\) 2026 DeepSeek\n/)
|
||||
assert.deepEqual(manifest.packages.sort(), [
|
||||
'@deepseek-ai/dsh-agent-preset',
|
||||
'@deepseek-ai/dsh-plugin-manager',
|
||||
'@deepseek-ai/dsh-sandbox',
|
||||
'@deepseek-ai/dsh-tool-cordis',
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { build } from 'esbuild'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { after, test } from 'node:test'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { buildCatalog, parseSeatTable, renderCatalog } from '../scripts/gen-inspect-catalog.mjs'
|
||||
import { createCordisInspect } from '../src/dsh/inspect.ts'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = (path) => join(here, '..', 'src', path)
|
||||
const text = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
|
||||
const compatibility = JSON.parse(text(src('dsh/compatibility.json')))
|
||||
const catalog = JSON.parse(text(src('dsh/inspect-catalog.json')))
|
||||
const seatSource = text(join(here, '..', '..', '..', 'app-server', 'src', 'runtime_api', 'plugins.rs'))
|
||||
const rootSource = text(src('root.ts'))
|
||||
const inspect = createCordisInspect()
|
||||
const ask = (platform, provider, method, input) => inspect.query(platform, provider, method, input)
|
||||
|
||||
test('the committed catalog is what the generator writes now', () => {
|
||||
assert.equal(text(src('dsh/inspect-catalog.json')), renderCatalog(buildCatalog()))
|
||||
})
|
||||
|
||||
test('list names the providers DSH calls and leaves out Tool until a source exists', async () => {
|
||||
const ids = inspect.list().map((p) => `${p.platform}.${p.id}.${p.methods.map((m) => m.name).join(',')}`)
|
||||
assert.deepEqual(ids, ['host.Service.listService', 'host.Event.listEvents', 'host.Config.listConfigs', 'client.Slots.listSubTree', 'client.Theme.listTokens'])
|
||||
const withTools = createCordisInspect({ tools: () => [{ name: 'read_file' }] })
|
||||
assert.ok(withTools.list().some((p) => p.id === 'Tool'))
|
||||
assert.deepEqual(await withTools.query('host', 'Tool', 'listTools', undefined, {}), { tools: [{ name: 'read_file' }] })
|
||||
})
|
||||
|
||||
test('errors use DSH registry wording', async () => {
|
||||
await assert.rejects(ask('host', 'Nope', 'x'), /Host Cordis inspect provider "Nope" is not registered/)
|
||||
await assert.rejects(ask('client', 'Nope', 'x'), /Client Cordis inspect provider "Nope" is not registered/)
|
||||
await assert.rejects(ask('host', 'Service', 'nope'), /Cordis inspect provider "Service" has no method "nope"/)
|
||||
await assert.rejects(ask('host', 'Service', 'listService', { service: 3 }), /Host Cordis inspect Service.listService rejected input/)
|
||||
await assert.rejects(ask('host', 'Tool', 'listTools'), /not registered/)
|
||||
await assert.rejects(ask('page', 'Service', 'listService'), /platform must be/)
|
||||
})
|
||||
|
||||
test('every service the catalog names is provided, and no provided service is missing', () => {
|
||||
const provided = Object.values(compatibility.provided_services).flat().sort()
|
||||
assert.deepEqual(catalog.host.services.map((s) => s.key).sort(), provided)
|
||||
assert.deepEqual(catalog.host.refusedServices, compatibility.refused_services)
|
||||
})
|
||||
|
||||
test('methods match the compatibility tables where it has them', () => {
|
||||
const tables = { sessions: 'session_methods', userQuestions: 'user_question_methods', subagents: 'subagent_methods', agents: 'agents_methods', systemPrompt: 'system_prompt_methods' }
|
||||
for (const [service, table] of Object.entries(tables)) {
|
||||
const named = catalog.host.services.find((s) => s.key === service).methods.map((m) => m.name).sort()
|
||||
assert.deepEqual(named, Object.keys(compatibility[table]).sort(), service)
|
||||
}
|
||||
})
|
||||
|
||||
const classBody = (source, name) => {
|
||||
const start = source.indexOf(`class ${name} extends Service`)
|
||||
assert.ok(start >= 0, `class ${name}`)
|
||||
const rest = source.slice(start + 1)
|
||||
const next = rest.search(/\n\s*(export )?(class |function )/)
|
||||
return next < 0 ? rest : rest.slice(0, next)
|
||||
}
|
||||
const defines = (body, name) => new RegExp(`^\\s*(async\\s+)?${name}\\s*\\(`, 'm').test(body)
|
||||
|
||||
test('every curated method is defined on the class that serves it', () => {
|
||||
const served = {
|
||||
tools: ['root.ts', 'ToolsShim'], commands: ['shims/commands.ts', 'CommandsShim'], storage: ['root.ts', 'StorageShim'],
|
||||
skills: ['shims/skills.ts', 'SkillsShim'], prompt: ['shims/prompt.ts', 'PromptShim'], avatars: ['shims/avatars.ts', 'AvatarsShim'],
|
||||
mcp: ['shims/mcp.ts', 'McpShim'], shellHooks: ['shims/shell-hooks.ts', 'ShellHooks'], remote: ['dsh/remote.ts', 'RemoteService'],
|
||||
sessions: ['dsh/session-prompt.ts', 'SessionsService'], userQuestions: ['root.ts', 'UserQuestionsShim'],
|
||||
subagents: ['dsh/agents.ts', 'SubagentsShim'], systemPrompt: ['dsh/session-prompt.ts', 'SystemPromptService'],
|
||||
}
|
||||
const cordis = { events: ['events', ['on', 'once', 'emit']], reflect: ['reflect', ['provide', 'get']], registry: ['registry', ['get', 'resolve']] }
|
||||
const agentsSource = text(src('dsh/agents.ts'))
|
||||
for (const service of catalog.host.services) {
|
||||
if (service.key === 'logger' || service.key === 'agents') continue
|
||||
if (cordis[service.key]) {
|
||||
const [file, names] = cordis[service.key]
|
||||
const body = text(join(here, '..', 'node_modules', '@deepseek-ai', 'cordis', 'lib', 'types', `${file}.d.ts`))
|
||||
assert.deepEqual(service.methods.map((m) => m.name).sort(), [...names].sort(), service.key)
|
||||
for (const name of names) assert.ok(defines(body, name), `${service.key}.${name}`)
|
||||
continue
|
||||
}
|
||||
const [file, cls] = served[service.key]
|
||||
const body = classBody(text(src(file)), cls)
|
||||
for (const m of service.methods) {
|
||||
if (service.key === 'subagents' && m.name === 'result') assert.match(agentsSource, /^\s*get result\(\)/m, 'subagents.result is a getter')
|
||||
else if (service.key === 'subagents' && m.name === 'dispose') assert.match(agentsSource, /^\s*dispose\(\)/m, 'subagents.dispose')
|
||||
else assert.ok(defines(body, m.name), `${service.key}.${m.name}`)
|
||||
}
|
||||
}
|
||||
assert.match(agentsSource, /^\s*section\(section: unknown\)/m)
|
||||
assert.match(agentsSource, /^\s*getSectionOrder\(name: unknown\)/m)
|
||||
})
|
||||
|
||||
test('the catalog states the contracts the host actually enforces', () => {
|
||||
const agentsSource = text(src('dsh/agents.ts'))
|
||||
const personaName = text(src('dsh/dsh-message-compat.js')).match(/PERSONA_PREFIX_SECTION = "([^"]+)"/)[1]
|
||||
const section = catalog.host.services.find((s) => s.key === 'systemPrompt').methods[0]
|
||||
assert.ok(section.signature.includes(`name: '${personaName}'`))
|
||||
assert.ok(section.signature.includes("getSectionOrder('DEPLOYMENT_PERSONA_PREFIX')"))
|
||||
assert.match(agentsSource, /PERSONA_ORDER = 0/)
|
||||
const subagents = catalog.host.services.find((s) => s.key === 'subagents').methods
|
||||
const result = subagents.find((m) => m.name === 'result')
|
||||
assert.match(result.signature, /^activation\.result: Promise<\{ stopReason: string, output: \{ type: 'text', text: string \}\[\]/)
|
||||
assert.ok(!result.signature.includes('result()'))
|
||||
assert.match(subagents.find((m) => m.name === 'startActivation').signature, /Promise<\{ childId: string, messageId: string, result: Promise</)
|
||||
})
|
||||
|
||||
test('an output that is not JSON is refused as DSH refuses it', async () => {
|
||||
const bad = createCordisInspect({ tools: () => [{ name: 'x', extra: undefined }] })
|
||||
await assert.rejects(bad.query('host', 'Tool', 'listTools', undefined, {}), /returned a non-JSON value/)
|
||||
})
|
||||
|
||||
test('every slot and its seat equal the Engine seat table', () => {
|
||||
const block = seatSource.slice(seatSource.indexOf('const DSH_SEAT_TABLE'), seatSource.indexOf('];', seatSource.indexOf('const DSH_SEAT_TABLE')))
|
||||
const names = [...block.matchAll(/\(\s*"([^"]+)",\s*DshSeat::/g)].map((m) => m[1])
|
||||
assert.equal(names.length, 30)
|
||||
assert.deepEqual(catalog.client.slots.map((s) => s.slot), names)
|
||||
const unsupported = [...block.matchAll(/\(\s*"([^"]+)",\s*DshSeat::Unsupported/g)].map((m) => m[1])
|
||||
assert.deepEqual(catalog.client.slots.filter((s) => !s.supported).map((s) => s.slot), unsupported)
|
||||
const seats = [...seatSource.slice(seatSource.indexOf('const PLUGIN_UI_SEATS'), seatSource.indexOf('];', seatSource.indexOf('const PLUGIN_UI_SEATS'))).matchAll(/"([^"]+)"/g)].map((m) => m[1])
|
||||
for (const slot of catalog.client.slots.filter((s) => s.supported)) assert.ok(slot.seat === 'customize.plugins.detail' || seats.includes(slot.seat), slot.slot)
|
||||
assert.equal(catalog.client.seats.length, 6)
|
||||
assert.deepEqual(parseSeatTable(seatSource.replace(/\n/g, '\r\n')), parseSeatTable(seatSource))
|
||||
})
|
||||
|
||||
test('the refused events equal the list in root.ts', () => {
|
||||
const body = rootSource.slice(rootSource.indexOf('unsupportedDshLifecycle = new Set(['), rootSource.indexOf('])', rootSource.indexOf('unsupportedDshLifecycle = new Set([')))
|
||||
const refused = [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
assert.equal(refused.length, 5)
|
||||
assert.deepEqual(catalog.host.events.filter((e) => e.status === 'refused').map((e) => e.name), refused)
|
||||
for (const name of catalog.host.events.filter((e) => e.status === 'supported').map((e) => e.name)) assert.ok(rootSource.includes(`'${name}'`) || text(src('dsh/agents.ts')).includes(`'${name}'`), name)
|
||||
})
|
||||
|
||||
test('the Service provider answers a directory, one contract, and refuses a name the host lacks', async () => {
|
||||
const directory = await ask('host', 'Service', 'listService')
|
||||
assert.equal(directory.services.length, 18)
|
||||
assert.ok(!directory.services.some((s) => s.key === 'fs' || s.key === 'llm'))
|
||||
const one = await ask('host', 'Service', 'listService', { service: 'prompt' })
|
||||
assert.equal(one.service.methods[0].name, 'registerSection')
|
||||
await assert.rejects(ask('host', 'Service', 'listService', { service: 'llm' }), /not provided by this host/)
|
||||
})
|
||||
|
||||
test('the Event provider separates supported from refused', async () => {
|
||||
const { events } = await ask('host', 'Event', 'listEvents')
|
||||
assert.deepEqual(events.map((e) => e.status), ['supported', 'supported', 'refused', 'refused', 'refused', 'refused', 'refused'])
|
||||
assert.equal((await ask('host', 'Event', 'listEvents', { event: 'tools/pre-execute' })).event.name, 'tools/pre-execute')
|
||||
await assert.rejects(ask('host', 'Event', 'listEvents', { event: 'subagent/start-x' }), /not known/)
|
||||
})
|
||||
|
||||
test('the Config provider lists exactly the row packages, pages, and shows installed rows', async () => {
|
||||
const page = await ask('host', 'Config', 'listConfigs')
|
||||
assert.deepEqual(page.entries.map((e) => e.name).sort(), Object.keys(compatibility.row_bridges).sort())
|
||||
assert.equal(page.total, 5)
|
||||
const paged = await ask('host', 'Config', 'listConfigs', { limit: 2 })
|
||||
assert.equal(paged.entries.length, 2)
|
||||
assert.equal(paged.nextOffset, 2)
|
||||
const schema = await ask('host', 'Config', 'listConfigs', { entry: 'row:@deepseek-ai/dsh-persona' })
|
||||
assert.deepEqual(schema.schema.required, ['prefix'])
|
||||
await assert.rejects(ask('host', 'Config', 'listConfigs', { limit: 500 }), /limit/)
|
||||
await assert.rejects(ask('host', 'Config', 'listConfigs', { entry: 'row:nope' }), /not listed/)
|
||||
const live = createCordisInspect({ installed: () => [{ id: 'abc', name: 'hello', state: 'active' }] })
|
||||
const after = await live.query('host', 'Config', 'listConfigs', { name: 'hello' })
|
||||
assert.deepEqual(after.entries, [{ id: 'plugin:abc', name: 'hello', kind: 'installed-plugin', status: 'active' }])
|
||||
})
|
||||
|
||||
test('each row example passes the bridge that reads it', async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-inspect-'))
|
||||
after(() => rmSync(dir, { recursive: true, force: true }))
|
||||
const bridges = join(dir, 'bridges.mjs')
|
||||
await build({
|
||||
stdin: { contents: "export { normalizePersonaConfig } from './src/dsh/persona.ts'\nexport { reviewedMcpModule } from './src/dsh/mcp.ts'\nexport { reviewedSkillModule } from './src/dsh/skill-filesystem.ts'", resolveDir: join(here, '..'), loader: 'ts' },
|
||||
outfile: bridges, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent',
|
||||
})
|
||||
const { normalizePersonaConfig, reviewedMcpModule, reviewedSkillModule } = await import(pathToFileURL(bridges).href)
|
||||
const example = (name) => catalog.host.config.find((r) => r.name === name).example
|
||||
assert.doesNotThrow(() => normalizePersonaConfig(example('@deepseek-ai/dsh-persona')))
|
||||
assert.throws(() => normalizePersonaConfig({ prefix: 'x', complete: true }))
|
||||
const servers = []
|
||||
reviewedMcpModule().apply({ mcp: { registerServer: (d) => servers.push(d) } }, example('@deepseek-ai/dsh-mcp-client'))
|
||||
assert.equal(servers[0].serverName, 'docs')
|
||||
const roots = []
|
||||
reviewedSkillModule().apply({ skills: { registerRoot: (d) => roots.push(d) } }, example('@deepseek-ai/dsh-skill-filesystem'))
|
||||
assert.equal(roots.length, 1)
|
||||
})
|
||||
|
||||
test('Slots says no client is connected in a terminal and marks no slot drawn', async () => {
|
||||
const out = await ask('client', 'Slots', 'listSubTree')
|
||||
assert.equal(out.client.connected, false)
|
||||
assert.match(out.client.message, /No client is connected/)
|
||||
assert.equal(out.trees.length, 30)
|
||||
assert.ok(out.trees.every((s) => s.drawn === false))
|
||||
assert.deepEqual((await ask('client', 'Slots', 'listSubTree', { root: 'conversation.composer.dock' })).selected.seat, 'conversation.composer.dock')
|
||||
assert.equal((await ask('client', 'Slots', 'listSubTree', { root: 'nope' })).requestedRoot.available, false)
|
||||
})
|
||||
|
||||
test('Slots marks only the drawn seats when a client is connected', async () => {
|
||||
const connected = createCordisInspect({ client: () => ({ connected: true }) })
|
||||
const out = await connected.query('client', 'Slots', 'listSubTree')
|
||||
const drawn = out.trees.filter((s) => s.drawn).map((s) => s.name)
|
||||
assert.deepEqual(drawn, ['plugins.bundle.config', 'plugins.row.config', 'plugins.tab', 'plugins.detail.section', 'settings.section', 'settings.plugin.item', 'settings.general.item', 'settings.plugins.tab'])
|
||||
const all = createCordisInspect({ client: () => ({ connected: true, drawnSeats: ['conversation.composer.dock'] }) })
|
||||
const composer = (await all.query('client', 'Slots', 'listSubTree')).trees.filter((s) => s.drawn).map((s) => s.name)
|
||||
assert.ok(composer.includes('conversation.input.dock') && !composer.includes('plugins.tab'))
|
||||
assert.ok((await ask('client', 'Slots', 'listSubTree')).trees.filter((s) => !s.supported).every((s) => s.seat === null && s.reason))
|
||||
})
|
||||
|
||||
test('Theme reports that the frame defines no named tokens', async () => {
|
||||
const out = await ask('client', 'Theme', 'listTokens')
|
||||
assert.deepEqual(out.tokens, [])
|
||||
assert.deepEqual(out.colorSchemes, ['light', 'dark'])
|
||||
assert.ok(out.api.some((a) => a.name === 'ctx.theme.overrideTokens'))
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let remoteAdapter
|
||||
let Context
|
||||
let ErrorCode
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-remote-'))
|
||||
const bundle = async (entry, name) => {
|
||||
const outfile = join(dir, name)
|
||||
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
return import(pathToFileURL(outfile).href)
|
||||
}
|
||||
;({ remoteAdapter } = await bundle('../src/dsh/remote.ts', 'remote.mjs'))
|
||||
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
|
||||
;({ Context } = await import('@deepseek-ai/cordis'))
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
async function harness() {
|
||||
const calls = []
|
||||
const host = {
|
||||
tier: 'plugin',
|
||||
rpc: { request: async () => { throw new Error('remote never calls the core directly') }, notify() {} },
|
||||
ownerOf: () => undefined,
|
||||
liveInvocation: () => undefined,
|
||||
broker: async (ctx, service, method, input) => { calls.push([service, method, input]); return { ok: method } },
|
||||
log() {},
|
||||
}
|
||||
const ctx = new Context()
|
||||
ctx.plugin(remoteAdapter.define(host))
|
||||
await settle()
|
||||
return { ctx, calls }
|
||||
}
|
||||
|
||||
test('remote sends every method through the remote broker with a typed payload and nothing else', async () => {
|
||||
const { ctx, calls } = await harness()
|
||||
assert.deepEqual(await ctx.remote.status(), { ok: 'status' })
|
||||
await ctx.remote.pair()
|
||||
await ctx.remote.pair({ label: 'phone' })
|
||||
await ctx.remote.clients()
|
||||
await ctx.remote.revoke('cwrg_1')
|
||||
assert.deepEqual(calls, [
|
||||
['remote', 'status', {}],
|
||||
['remote', 'pair', {}],
|
||||
['remote', 'pair', { label: 'phone' }],
|
||||
['remote', 'clients', {}],
|
||||
['remote', 'revoke', { grant: 'cwrg_1' }],
|
||||
])
|
||||
})
|
||||
|
||||
test('remote refuses malformed arguments locally, before any broker call', async () => {
|
||||
const { ctx, calls } = await harness()
|
||||
assert.throws(() => ctx.remote.pair({ label: 3 }), (error) => error.code === ErrorCode.InvalidParams)
|
||||
for (const id of [undefined, '', 7, 'x'.repeat(129)]) {
|
||||
assert.throws(() => ctx.remote.revoke(id), (error) => error.code === ErrorCode.InvalidParams)
|
||||
}
|
||||
assert.equal(typeof ctx.remote.listen, 'undefined')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let terminalAdapter
|
||||
let Context
|
||||
let ErrorCode
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-terminal-'))
|
||||
const bundle = async (entry, name) => {
|
||||
const outfile = join(dir, name)
|
||||
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
return import(pathToFileURL(outfile).href)
|
||||
}
|
||||
;({ terminalAdapter } = await bundle('../src/dsh/terminal.ts', 'terminal.mjs'))
|
||||
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
|
||||
;({ Context } = await import('@deepseek-ai/cordis'))
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
async function harness() {
|
||||
const calls = []
|
||||
const host = {
|
||||
tier: 'plugin',
|
||||
rpc: { request: async () => { throw new Error('terminal never calls the core directly') }, notify() {} },
|
||||
ownerOf: () => undefined,
|
||||
liveInvocation: () => undefined,
|
||||
broker: async (ctx, service, method, input) => { calls.push([service, method, input]); return { ok: method } },
|
||||
log() {},
|
||||
}
|
||||
const ctx = new Context()
|
||||
ctx.plugin(terminalAdapter.define(host))
|
||||
await settle()
|
||||
return { ctx, calls }
|
||||
}
|
||||
|
||||
test('terminal sends every method through the terminal broker with a typed payload and nothing else', async () => {
|
||||
const { ctx, calls } = await harness()
|
||||
assert.deepEqual(await ctx.terminal.list(), { ok: 'list' })
|
||||
await ctx.terminal.run('make')
|
||||
await ctx.terminal.run('make', { session: 'build', timeoutSecs: 30, cwd: '/', sandbox_permissions: 'danger' })
|
||||
await ctx.terminal.send('build', 'y\n', { waitMs: 0 })
|
||||
await ctx.terminal.wait('build')
|
||||
await ctx.terminal.wait('build', { timeoutSecs: 600 })
|
||||
await ctx.terminal.cancel('build')
|
||||
await ctx.terminal.reset('build')
|
||||
assert.deepEqual(calls, [
|
||||
['terminal', 'list', {}],
|
||||
['terminal', 'run', { command: 'make' }],
|
||||
['terminal', 'run', { command: 'make', session: 'build', timeout_secs: 30 }],
|
||||
['terminal', 'send', { session: 'build', text: 'y\n', wait_ms: 0 }],
|
||||
['terminal', 'wait', { session: 'build' }],
|
||||
['terminal', 'wait', { session: 'build', timeout_secs: 600 }],
|
||||
['terminal', 'cancel', { session: 'build' }],
|
||||
['terminal', 'reset', { session: 'build' }],
|
||||
])
|
||||
})
|
||||
|
||||
test('terminal refuses malformed arguments locally, before any broker call', async () => {
|
||||
const { ctx, calls } = await harness()
|
||||
const refused = (act) => assert.throws(act, (error) => error.code === ErrorCode.InvalidParams)
|
||||
for (const command of [undefined, '', 7, ['ls']]) refused(() => ctx.terminal.run(command))
|
||||
for (const name of [undefined, '', 7, '../term-1', 'dsh:b:x', 'a b', 'n'.repeat(65)]) {
|
||||
refused(() => ctx.terminal.run('true', { session: name === undefined ? null : name }))
|
||||
refused(() => ctx.terminal.send(name, 'x'))
|
||||
refused(() => ctx.terminal.wait(name))
|
||||
refused(() => ctx.terminal.cancel(name))
|
||||
refused(() => ctx.terminal.reset(name))
|
||||
}
|
||||
refused(() => ctx.terminal.send('build', ''))
|
||||
for (const secs of [0, 601, 1.5, '30']) {
|
||||
refused(() => ctx.terminal.run('true', { timeoutSecs: secs }))
|
||||
refused(() => ctx.terminal.wait('build', { timeoutSecs: secs }))
|
||||
}
|
||||
refused(() => ctx.terminal.send('build', 'x', { waitMs: 60_001 }))
|
||||
for (const absent of ['spawn', 'kill', 'resize', 'read', 'exec']) assert.equal(typeof ctx.terminal[absent], 'undefined')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let workspaceAdapter
|
||||
let Context
|
||||
let ErrorCode
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-workspace-'))
|
||||
const bundle = async (entry, name) => {
|
||||
const outfile = join(dir, name)
|
||||
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
return import(pathToFileURL(outfile).href)
|
||||
}
|
||||
;({ workspaceAdapter } = await bundle('../src/dsh/workspace.ts', 'workspace.mjs'))
|
||||
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
|
||||
;({ Context } = await import('@deepseek-ai/cordis'))
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
async function harness() {
|
||||
const calls = []
|
||||
const host = {
|
||||
tier: 'plugin',
|
||||
rpc: { request: async () => { throw new Error('workspace never calls the core directly') }, notify() {} },
|
||||
ownerOf: () => undefined,
|
||||
liveInvocation: () => undefined,
|
||||
broker: async (ctx, service, method, input) => { calls.push([service, method, input]); return { ok: method } },
|
||||
log() {},
|
||||
}
|
||||
const ctx = new Context()
|
||||
ctx.plugin(workspaceAdapter.define(host))
|
||||
await settle()
|
||||
return { ctx, calls }
|
||||
}
|
||||
|
||||
test('workspace sends every method through the workspace broker with a typed payload and nothing else', async () => {
|
||||
const { ctx, calls } = await harness()
|
||||
assert.deepEqual(await ctx.workspace.read('src/a.ts'), { ok: 'read' })
|
||||
await ctx.workspace.read('src/a.ts', { startLine: 3, maxLines: 10, follow: true })
|
||||
await ctx.workspace.list()
|
||||
await ctx.workspace.list('src')
|
||||
await ctx.workspace.write('src/b.ts', 'x', { expectedHash: 'h', mode: 0o777 })
|
||||
await ctx.workspace.write('src/c.ts', '')
|
||||
await ctx.workspace.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: '/w', env: { GIT_DIR: '/' }, shell: true })
|
||||
assert.deepEqual(calls, [
|
||||
['workspace', 'read', { path: 'src/a.ts' }],
|
||||
['workspace', 'read', { path: 'src/a.ts', start_line: 3, max_lines: 10 }],
|
||||
['workspace', 'list', {}],
|
||||
['workspace', 'list', { path: 'src' }],
|
||||
['workspace', 'write', { path: 'src/b.ts', content: 'x', expected_hash: 'h' }],
|
||||
['workspace', 'write', { path: 'src/c.ts', content: '' }],
|
||||
['workspace', 'exec', { argv: ['git', 'rev-parse', '--show-toplevel'], cwd: '/w' }],
|
||||
])
|
||||
})
|
||||
|
||||
test('workspace refuses malformed arguments locally, before any broker call', async () => {
|
||||
const { ctx, calls } = await harness()
|
||||
const refused = (act) => assert.throws(act, (error) => error.code === ErrorCode.InvalidParams)
|
||||
for (const bad of [undefined, '', 7, ' a', 'a\nb', 'p'.repeat(513), ['a']]) {
|
||||
refused(() => ctx.workspace.read(bad))
|
||||
refused(() => ctx.workspace.write(bad, 'x'))
|
||||
if (bad !== undefined) refused(() => ctx.workspace.list(bad))
|
||||
}
|
||||
for (const lines of [0, 10_001, 1.5, '3']) refused(() => ctx.workspace.read('a', { maxLines: lines }))
|
||||
refused(() => ctx.workspace.read('a', { startLine: 0 }))
|
||||
for (const content of [undefined, 7, 'c'.repeat(1024 * 1024 + 1)]) refused(() => ctx.workspace.write('a', content))
|
||||
refused(() => ctx.workspace.write('a', 'x', { expectedHash: '' }))
|
||||
for (const command of [undefined, 'git status', [], [7], ['git', 'a\u0000b'], new Array(10).fill('git')]) refused(() => ctx.workspace.exec(command))
|
||||
refused(() => ctx.workspace.exec(['git'], { cwd: '' }))
|
||||
for (const absent of ['delete', 'rename', 'spawn', 'shell', 'watch', 'readSync']) assert.equal(typeof ctx.workspace[absent], 'undefined')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
@@ -44,8 +44,9 @@ use super::CodewhaleClient;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
extract_sse_data_value, from_api_tool_name, next_sse_line, push_sse_event_data,
|
||||
to_api_tool_name,
|
||||
ToolNameWire, check_history_tool_names, documented_tool_name_limit, extract_sse_data_value,
|
||||
from_api_tool_name, next_sse_line, over_limit_tool_name_message, push_sse_event_data,
|
||||
to_api_tool_name, tool_name_exceeds_limit, tool_name_limit,
|
||||
};
|
||||
|
||||
/// Maximum `cache_control` breakpoints Anthropic accepts per request.
|
||||
@@ -103,6 +104,11 @@ impl CodewhaleClient {
|
||||
repair_dangling_tool_uses(&mut messages);
|
||||
body["messages"] = Value::Array(messages);
|
||||
|
||||
let tool_name_limit = tool_name_limit(self.api_provider, ToolNameWire::AnthropicMessages);
|
||||
check_history_tool_names(
|
||||
request,
|
||||
documented_tool_name_limit(self.api_provider, ToolNameWire::AnthropicMessages),
|
||||
)?;
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let mut omitted_logical_names = Vec::new();
|
||||
if let Some(tools) = request.tools.as_ref()
|
||||
@@ -113,6 +119,11 @@ impl CodewhaleClient {
|
||||
};
|
||||
let mut wire_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_logical_names.push(tool.name.clone());
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(route_projection, &tool.input_schema, tool.strict) {
|
||||
ToolSchemaProjection::Exact {
|
||||
parameters,
|
||||
@@ -155,6 +166,9 @@ impl CodewhaleClient {
|
||||
if let Some(name) = forced_tool_name(tool_choice)
|
||||
&& omitted_logical_names.iter().any(|omitted| omitted == name)
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
anyhow::bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
@@ -1305,6 +1319,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_at_128() {
|
||||
let client = anthropic_test_client(None);
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let mid = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let long = format!(
|
||||
"mcp__tracker__{}.v2.issues.list-by-assignee",
|
||||
"a".repeat(70)
|
||||
);
|
||||
assert!(to_api_tool_name(mid).len() > 64 && to_api_tool_name(mid).len() <= 128);
|
||||
assert!(to_api_tool_name(&long).len() > 128);
|
||||
|
||||
let mut request = request_with("claude-sonnet-4-6", None, None, None);
|
||||
request.tools = Some(vec![tool("read_file"), tool(mid), tool(&long)]);
|
||||
let (body, omitted) = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect("body builds");
|
||||
let sent: Vec<&str> = body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(sent, vec!["read_file".to_string(), to_api_tool_name(mid)]);
|
||||
assert_eq!(omitted, vec![to_api_tool_name(&long)]);
|
||||
|
||||
request.tool_choice = Some(json!({"type": "tool", "name": long}));
|
||||
let error = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect_err("forcing a withheld tool is refused");
|
||||
assert!(error.to_string().contains("at most 128"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_100_character_encoded_name_is_offered_on_an_anthropic_wire_route_without_a_documented_limit()
|
||||
{
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let name = "a".repeat(100);
|
||||
assert_eq!(to_api_tool_name(&name).len(), 100);
|
||||
let client = minimax_test_client();
|
||||
let mut request = request_with("MiniMax-M2", None, None, None);
|
||||
request.tools = Some(vec![tool(&name)]);
|
||||
let (body, omitted) = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect("body builds");
|
||||
assert_eq!(body["tools"][0]["name"], name);
|
||||
assert!(omitted.is_empty());
|
||||
}
|
||||
|
||||
fn request_with(
|
||||
model: &str,
|
||||
reasoning_effort: Option<&str>,
|
||||
|
||||
@@ -41,8 +41,10 @@ use super::reasoning_effort::apply_reasoning_effort;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK, SSE_BACKPRESSURE_SLEEP_MS,
|
||||
SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer, bounded_error_text, from_api_tool_name,
|
||||
parse_usage, release_stream_buffer, system_to_instructions, to_api_tool_name,
|
||||
SSE_MAX_LINES_PER_CHUNK, ToolNameWire, acquire_stream_buffer, bounded_error_text,
|
||||
check_history_tool_names, documented_tool_name_limit, from_api_tool_name,
|
||||
over_limit_tool_name_message, parse_usage, release_stream_buffer, system_to_instructions,
|
||||
to_api_tool_name, tool_name_exceeds_limit, tool_name_limit,
|
||||
};
|
||||
use super::wire::{extract_sse_data_value, flush_sse_line, push_sse_event_data, take_sse_line};
|
||||
use codewhale_config::route::RouteLimits;
|
||||
@@ -1208,11 +1210,20 @@ pub fn build_chat_wire_body_with_window(
|
||||
body["top_p"] = json!(top_p);
|
||||
}
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let tool_name_limit = tool_name_limit(provider, ToolNameWire::OpenAiCompatible);
|
||||
check_history_tool_names(
|
||||
request,
|
||||
documented_tool_name_limit(provider, ToolNameWire::OpenAiCompatible),
|
||||
)?;
|
||||
if let Some(tools) = request.tools.as_ref() {
|
||||
let route_projection =
|
||||
chat_route_tool_projection(provider, base_url, strict_tools_disabled);
|
||||
let mut chat_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(route_projection, &tool.input_schema, tool.strict) {
|
||||
ToolSchemaProjection::Exact {
|
||||
parameters,
|
||||
@@ -1247,6 +1258,9 @@ pub fn build_chat_wire_body_with_window(
|
||||
.iter()
|
||||
.any(|omitted| omitted == name || *omitted == to_api_tool_name(name))
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
@@ -3255,11 +3269,25 @@ fn deepseek_base_url_supports_strict_tools(base_url: &str) -> bool {
|
||||
|
||||
fn map_tool_choice_for_chat(choice: &Value) -> Option<Value> {
|
||||
if let Some(choice_str) = choice.as_str() {
|
||||
return Some(json!(choice_str));
|
||||
return Some(match forced_tool_name(choice) {
|
||||
Some(name) => json!({
|
||||
"type": "function",
|
||||
"function": { "name": to_api_tool_name(name) }
|
||||
}),
|
||||
None => json!(choice_str),
|
||||
});
|
||||
}
|
||||
let Some(choice_type) = choice.get("type").and_then(Value::as_str) else {
|
||||
return Some(choice.clone());
|
||||
};
|
||||
if choice_type == "function"
|
||||
&& let Some(name) = forced_tool_name(choice)
|
||||
{
|
||||
return Some(json!({
|
||||
"type": "function",
|
||||
"function": { "name": to_api_tool_name(name) }
|
||||
}));
|
||||
}
|
||||
|
||||
match choice_type {
|
||||
"auto" | "none" => Some(json!(choice_type)),
|
||||
@@ -6664,9 +6692,13 @@ mod image_block_wire_tests {
|
||||
//! most of them at once. The shape is fixed by OpenAI's spec: a `user`
|
||||
//! message whose `content` is an array of parts, with the image as
|
||||
//! `{"type":"image_url","image_url":{"url":…}}`.
|
||||
use super::{ProviderKind, build_chat_messages, build_chat_wire_body};
|
||||
use super::{
|
||||
ChatWireBody, ProviderKind, build_chat_messages, build_chat_wire_body,
|
||||
map_tool_choice_for_chat, to_api_tool_name,
|
||||
};
|
||||
use codewhale_models::Role;
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest};
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest, Tool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const DATA_URL: &str = "data:image/png;base64,QUJD";
|
||||
|
||||
@@ -6973,6 +7005,220 @@ mod image_block_wire_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn named_tool(name: &str) -> Tool {
|
||||
Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_object_tool_choice_encodes_the_tool_name() {
|
||||
let encoded = to_api_tool_name("web.run");
|
||||
assert_eq!(encoded, "web-x00002E-run");
|
||||
let expected = json!({"type": "function", "function": {"name": encoded}});
|
||||
for choice in [
|
||||
json!({"type": "function", "function": {"name": "web.run"}}),
|
||||
json!({"type": "function", "name": "web.run"}),
|
||||
json!({"type": "tool", "name": "web.run"}),
|
||||
json!("web.run"),
|
||||
] {
|
||||
assert_eq!(
|
||||
map_tool_choice_for_chat(&choice),
|
||||
Some(expected.clone()),
|
||||
"{choice}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
map_tool_choice_for_chat(&json!("required")),
|
||||
Some(json!("required"))
|
||||
);
|
||||
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![named_tool("read_file"), named_tool("web.run")]);
|
||||
request.tool_choice = Some(json!({"type": "function", "function": {"name": "web.run"}}));
|
||||
let body = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("wire body");
|
||||
assert_eq!(body.body["tool_choice"], expected);
|
||||
let declared: Vec<&str> = body.body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
|
||||
.collect();
|
||||
assert!(declared.contains(&"web-x00002E-run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_by_provider_limit() {
|
||||
let mid = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let long = format!(
|
||||
"mcp__tracker__{}.v2.issues.list-by-assignee",
|
||||
"a".repeat(70)
|
||||
);
|
||||
let mid_len = to_api_tool_name(mid).len();
|
||||
assert!(mid_len > 64 && mid_len <= 128, "{mid_len}");
|
||||
assert!(to_api_tool_name(&long).len() > 128);
|
||||
|
||||
let build = |provider, base_url: &str, choice: Option<Value>| {
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![
|
||||
named_tool("read_file"),
|
||||
named_tool(mid),
|
||||
named_tool(&long),
|
||||
]);
|
||||
request.tool_choice = choice;
|
||||
build_chat_wire_body(&request, provider, base_url, false, None)
|
||||
};
|
||||
let declared = |body: &ChatWireBody| -> Vec<String> {
|
||||
body.body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let openai = build(ProviderKind::Openai, "https://api.openai.com/v1", None).expect("body");
|
||||
assert_eq!(declared(&openai), vec!["read_file".to_string()]);
|
||||
assert_eq!(
|
||||
openai.omitted_tool_names,
|
||||
vec![to_api_tool_name(mid), to_api_tool_name(&long)]
|
||||
);
|
||||
|
||||
let deepseek =
|
||||
build(ProviderKind::Deepseek, "https://api.deepseek.com", None).expect("body");
|
||||
assert_eq!(
|
||||
declared(&deepseek),
|
||||
vec!["read_file".to_string(), to_api_tool_name(mid)]
|
||||
);
|
||||
assert_eq!(deepseek.omitted_tool_names, vec![to_api_tool_name(&long)]);
|
||||
|
||||
let forced = json!({"type": "function", "function": {"name": mid}});
|
||||
let error = build(
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
Some(forced),
|
||||
)
|
||||
.err()
|
||||
.expect("forcing a withheld tool is refused");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains(&format!("{mid_len} characters")),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("at most 64"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_tool_name_in_history_is_refused_with_the_limit() {
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let mut request = request_with_image();
|
||||
request.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
id: "call_1".to_string(),
|
||||
name: long.to_string(),
|
||||
input: json!({}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
});
|
||||
let error = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.err()
|
||||
.expect("history name over the limit is refused");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("at most 64") && message.contains(long),
|
||||
"{message}"
|
||||
);
|
||||
build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Deepseek,
|
||||
"https://api.deepseek.com",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("the same history fits a 128-character route");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_name_of_65_to_128_characters_is_sent_unchanged_on_undocumented_routes() {
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(to_api_tool_name(long).len() > 64);
|
||||
let mut request = request_with_image();
|
||||
request.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
id: "call_1".to_string(),
|
||||
name: long.to_string(),
|
||||
input: json!({}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
});
|
||||
for (provider, base_url) in [
|
||||
(ProviderKind::Ollama, "http://localhost:11434/v1"),
|
||||
(ProviderKind::Custom, "http://localhost:8000/v1"),
|
||||
(ProviderKind::Minimax, "https://api.minimax.io/v1"),
|
||||
] {
|
||||
build_chat_wire_body(&request, provider, base_url, false, None)
|
||||
.unwrap_or_else(|error| panic!("{provider:?}: {error}"));
|
||||
}
|
||||
let error = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.err()
|
||||
.expect("documented 64-character route refuses the history name");
|
||||
assert!(
|
||||
error.to_string().contains("earlier in this session"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_100_character_encoded_name_is_withheld_on_a_chat_wire_route() {
|
||||
let name = "a".repeat(100);
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![named_tool("read_file"), named_tool(&name)]);
|
||||
let body = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Minimax,
|
||||
"https://api.minimax.io/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("wire body");
|
||||
assert_eq!(body.omitted_tool_names, vec![name]);
|
||||
assert_eq!(body.body["tools"].as_array().expect("tools").len(), 1);
|
||||
}
|
||||
|
||||
fn request_with_image() -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
|
||||
@@ -35,8 +35,10 @@ use super::chat::REASONING_REPLAY_PLACEHOLDER;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
ERROR_BODY_MAX_BYTES, bounded_error_text, from_api_tool_name, system_to_instructions,
|
||||
to_api_tool_name,
|
||||
ERROR_BODY_MAX_BYTES, TOOL_NAME_LIMIT_64, ToolNameWire, bounded_error_text,
|
||||
check_history_tool_names, documented_tool_name_limit, from_api_tool_name,
|
||||
over_limit_tool_name_message, system_to_instructions, to_api_tool_name,
|
||||
tool_name_exceeds_limit, tool_name_limit,
|
||||
};
|
||||
use super::wire::{extract_sse_data_value, next_sse_line, push_sse_event_data};
|
||||
use crate::llm_client::LlmError;
|
||||
@@ -135,6 +137,7 @@ pub(super) fn build_responses_wire_body(
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.unwrap_or_else(|| "You are a helpful assistant.".to_string());
|
||||
|
||||
let provider_kind = ProviderKind::parse(context.provider_id);
|
||||
let mut input = convert_messages_to_responses_input(request, profile, context);
|
||||
if profile.request.instructions == InstructionsPlacement::LeadingSystemInput {
|
||||
input.insert(
|
||||
@@ -150,12 +153,26 @@ pub(super) fn build_responses_wire_body(
|
||||
}
|
||||
body["input"] = json!(input);
|
||||
|
||||
let tool_name_limit = provider_kind.map_or(TOOL_NAME_LIMIT_64, |provider| {
|
||||
tool_name_limit(provider, ToolNameWire::OpenAiCompatible)
|
||||
});
|
||||
check_history_tool_names(
|
||||
request,
|
||||
provider_kind.and_then(|provider| {
|
||||
documented_tool_name_limit(provider, ToolNameWire::OpenAiCompatible)
|
||||
}),
|
||||
)?;
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let mut omitted_logical_names = Vec::new();
|
||||
// Convert tools to Responses function tools.
|
||||
if let Some(tools) = request.tools.as_ref() {
|
||||
let mut responses_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_logical_names.push(tool.name.clone());
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(
|
||||
RouteToolProjection::RelaxedNonStrict,
|
||||
&tool.input_schema,
|
||||
@@ -185,6 +202,9 @@ pub(super) fn build_responses_wire_body(
|
||||
if let Some(name) = request.tool_choice.as_ref().and_then(forced_tool_name)
|
||||
&& omitted_logical_names.iter().any(|omitted| omitted == name)
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
anyhow::bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
|
||||
@@ -755,6 +755,43 @@ async fn responses_stream_inserts_boundary_between_reasoning_summary_parts() {
|
||||
assert_eq!(thinking, "partA\n\npartB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_from_responses_at_64() {
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(to_api_tool_name(long).len() > 64);
|
||||
let mut request = minimal_responses_request();
|
||||
request.tools = Some(vec![tool("read_file"), tool(long)]);
|
||||
let (body, omitted) = build_responses_wire_body(
|
||||
&request,
|
||||
profile_for_provider(ProviderKind::Openai),
|
||||
ResponsesReplayContext {
|
||||
provider_id: ProviderKind::Openai.as_str(),
|
||||
wire_model_id: request.model.as_str(),
|
||||
reasoning_api_id: None,
|
||||
},
|
||||
)
|
||||
.expect("body builds");
|
||||
let sent: Vec<&str> = body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(sent, vec!["read_file"]);
|
||||
assert_eq!(omitted, vec![to_api_tool_name(long)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_reasoning_effort_uses_responses_labels() {
|
||||
assert_eq!(codex_responses_reasoning_effort("max"), Some("max"));
|
||||
|
||||
@@ -1449,9 +1449,10 @@ fn tool_name_scenario() {
|
||||
}
|
||||
// from tool_name_decode_bare_hex_no_trailing_dash
|
||||
{
|
||||
let mangled = "foo_x00002Ebar";
|
||||
let decoded = from_api_tool_name(mangled);
|
||||
assert_eq!(decoded, "foo_.bar");
|
||||
let exact = "foo_x00002Ebar";
|
||||
assert_eq!(from_api_tool_name(exact), exact);
|
||||
let mangled = "foo_.x00002Ebar";
|
||||
assert_eq!(from_api_tool_name(mangled), "foo_..bar");
|
||||
}
|
||||
// from tool_name_bare_hex_preserves_alnum
|
||||
{
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
use codewhale_models::{ServerToolUsage, SystemPrompt, Usage};
|
||||
use codewhale_config::ProviderKind;
|
||||
use codewhale_models::{ContentBlock, MessageRequest, ServerToolUsage, SystemPrompt, Usage};
|
||||
use std::sync::{Mutex as StdMutex, OnceLock};
|
||||
|
||||
pub fn saturating_u32(value: u64) -> u32 {
|
||||
@@ -315,7 +316,7 @@ pub fn to_api_tool_name(name: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn from_api_tool_name(name: &str) -> String {
|
||||
fn decode_delimited_tool_name(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut iter = name.chars().peekable();
|
||||
while let Some(ch) = iter.next() {
|
||||
@@ -357,12 +358,87 @@ pub fn from_api_tool_name(name: &str) -> String {
|
||||
}
|
||||
out.push('-');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Second pass: decode bare hex escapes (e.g. `x00002E`) that the model
|
||||
// may produce when it mangles the `-x00002E-` delimiter form. Only
|
||||
// decode when the resulting character is one that `to_api_tool_name`
|
||||
// would have encoded (not alphanumeric, not `_`, not `-`).
|
||||
decode_bare_hex_escapes(&out)
|
||||
pub fn from_api_tool_name(name: &str) -> String {
|
||||
let exact = decode_delimited_tool_name(name);
|
||||
if to_api_tool_name(&exact) == name {
|
||||
return exact;
|
||||
}
|
||||
decode_bare_hex_escapes(&exact)
|
||||
}
|
||||
|
||||
pub const TOOL_NAME_LIMIT_64: usize = 64;
|
||||
pub const TOOL_NAME_LIMIT_128: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolNameWire {
|
||||
AnthropicMessages,
|
||||
OpenAiCompatible,
|
||||
}
|
||||
|
||||
pub fn documented_tool_name_limit(provider: ProviderKind, wire: ToolNameWire) -> Option<usize> {
|
||||
match (provider, wire) {
|
||||
(ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic, _) => Some(TOOL_NAME_LIMIT_128),
|
||||
(ProviderKind::Anthropic, ToolNameWire::AnthropicMessages) => Some(TOOL_NAME_LIMIT_128),
|
||||
(ProviderKind::Openai | ProviderKind::OpenaiCodex, ToolNameWire::OpenAiCompatible) => {
|
||||
Some(TOOL_NAME_LIMIT_64)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_name_limit(provider: ProviderKind, wire: ToolNameWire) -> usize {
|
||||
if let Some(limit) = documented_tool_name_limit(provider, wire) {
|
||||
return limit;
|
||||
}
|
||||
match (provider, wire) {
|
||||
(_, ToolNameWire::AnthropicMessages) => TOOL_NAME_LIMIT_128,
|
||||
(
|
||||
ProviderKind::Ollama
|
||||
| ProviderKind::OllamaCloud
|
||||
| ProviderKind::Vllm
|
||||
| ProviderKind::Sglang
|
||||
| ProviderKind::Custom,
|
||||
_,
|
||||
) => TOOL_NAME_LIMIT_128,
|
||||
(_, ToolNameWire::OpenAiCompatible) => TOOL_NAME_LIMIT_64,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_history_tool_names(request: &MessageRequest, documented: Option<usize>) -> Result<()> {
|
||||
let Some(limit) = documented else {
|
||||
return Ok(());
|
||||
};
|
||||
for message in &request.messages {
|
||||
for block in &message.content {
|
||||
if let ContentBlock::ToolUse { name, .. } = block
|
||||
&& tool_name_exceeds_limit(name, limit)
|
||||
{
|
||||
anyhow::bail!(over_limit_history_tool_name_message(name, limit));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn tool_name_exceeds_limit(name: &str, limit: usize) -> bool {
|
||||
to_api_tool_name(name).len() > limit
|
||||
}
|
||||
|
||||
pub fn over_limit_tool_name_message(name: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"tool name `{name}` encodes to {} characters but this provider accepts at most {limit}; rename the tool (shorter name, fewer dots, hyphens or other special characters) or remove it",
|
||||
to_api_tool_name(name).len()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn over_limit_history_tool_name_message(name: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"tool `{name}` was called earlier in this session and its encoded name is {} characters, but this provider accepts at most {limit}; switch to a route with a larger limit or start a new session",
|
||||
to_api_tool_name(name).len()
|
||||
)
|
||||
}
|
||||
|
||||
pub const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 1024 * 1024; // 1 MB
|
||||
@@ -465,6 +541,98 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tool_name_encoding_is_collision_free_for_hostile_names() {
|
||||
let names = [
|
||||
"web.run",
|
||||
"web-x00002E-run",
|
||||
"web--x00002E--run",
|
||||
"webx00002Erun",
|
||||
"web_x00002Erun",
|
||||
"web_.run",
|
||||
"a-b",
|
||||
"a--b",
|
||||
"a_b",
|
||||
"a.b",
|
||||
"ax00002Eb",
|
||||
"a-x00002E-b",
|
||||
"mcp__srv__tool.name",
|
||||
"mcp__srv__tool-x00002E-name",
|
||||
];
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for name in names {
|
||||
let encoded = to_api_tool_name(name);
|
||||
assert!(
|
||||
encoded
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
|
||||
"{encoded}"
|
||||
);
|
||||
if let Some(previous) = seen.insert(encoded.clone(), name) {
|
||||
panic!("{previous} and {name} both encode to {encoded}");
|
||||
}
|
||||
assert_eq!(from_api_tool_name(&encoded), name, "round trip of {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_name_limit_follows_the_wire_unless_the_provider_documents_one() {
|
||||
use ToolNameWire::{AnthropicMessages, OpenAiCompatible};
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::Anthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::MinimaxAnthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(
|
||||
ProviderKind::ModelstudioTokenPlanAnthropic,
|
||||
AnthropicMessages
|
||||
),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::Deepseek, OpenAiCompatible),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::DeepseekAnthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Openai, OpenAiCompatible), 64);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Minimax, OpenAiCompatible), 64);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Ollama, OpenAiCompatible), 128);
|
||||
assert_eq!(
|
||||
documented_tool_name_limit(ProviderKind::Ollama, OpenAiCompatible),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
documented_tool_name_limit(ProviderKind::Custom, OpenAiCompatible),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_name_limit_counts_the_encoded_form() {
|
||||
let dotted = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(dotted.len() <= 64);
|
||||
assert!(tool_name_exceeds_limit(dotted, TOOL_NAME_LIMIT_64));
|
||||
assert!(!tool_name_exceeds_limit(dotted, TOOL_NAME_LIMIT_128));
|
||||
let plain = "a".repeat(64);
|
||||
assert!(!tool_name_exceeds_limit(&plain, TOOL_NAME_LIMIT_64));
|
||||
assert!(tool_name_exceeds_limit(
|
||||
&format!("{plain}a"),
|
||||
TOOL_NAME_LIMIT_64
|
||||
));
|
||||
let message = over_limit_tool_name_message(dotted, TOOL_NAME_LIMIT_64);
|
||||
assert!(
|
||||
message.contains("at most 64") && message.contains(dotted),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_line_limit_rejects_oversized_pending_complete_and_final_lines() {
|
||||
let mut decoder = SseLineDecoder::new();
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::extension_host::ExtensionHostOptions;
|
||||
|
||||
pub const STANDARD: &str = "standard";
|
||||
pub const CREATOR: &str = "creator";
|
||||
pub const SOURCE_BUILTIN: &str = "builtin";
|
||||
|
||||
pub const CREATOR_LOADS_ITS_OWN_TOOLS: bool = false;
|
||||
pub const CREATOR_NOT_READY_REASON: &str = "Creator mode is not ready in this build.";
|
||||
|
||||
const AVAILABILITY_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct ModeRefusal {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ModeRefusal {
|
||||
pub const UNKNOWN_MODE: &'static str = "unknown_mode";
|
||||
pub const MODE_UNAVAILABLE: &'static str = "mode_unavailable";
|
||||
pub const MODE_LOCKED: &'static str = "mode_locked";
|
||||
pub const MODE_NOT_SUPPORTED_WITH_EXECUTOR: &'static str = "mode_not_supported_with_executor";
|
||||
|
||||
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn locked() -> Self {
|
||||
Self::new(
|
||||
Self::MODE_LOCKED,
|
||||
"The conversation mode is fixed once the conversation has its first turn; start a new conversation to use another mode.",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_executor() -> Self {
|
||||
Self::new(
|
||||
Self::MODE_NOT_SUPPORTED_WITH_EXECUTOR,
|
||||
"A session with another agent uses that agent's own tools and instructions; conversation modes are unavailable.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ConversationMode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub source: &'static str,
|
||||
pub available: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ConversationModeTable {
|
||||
pub default: &'static str,
|
||||
pub modes: Vec<ConversationMode>,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn effective_id(stored: Option<&str>) -> &str {
|
||||
stored.unwrap_or(STANDARD)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn stored_form(id: &str) -> Option<String> {
|
||||
(id != STANDARD).then(|| id.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn table(creator_unavailable: Option<String>) -> ConversationModeTable {
|
||||
ConversationModeTable {
|
||||
default: STANDARD,
|
||||
modes: vec![
|
||||
ConversationMode {
|
||||
id: STANDARD.to_string(),
|
||||
name: "Standard".to_string(),
|
||||
description: "The everyday Codewhale agent with its normal tools and skills."
|
||||
.to_string(),
|
||||
source: SOURCE_BUILTIN,
|
||||
available: true,
|
||||
reason: None,
|
||||
},
|
||||
ConversationMode {
|
||||
id: CREATOR.to_string(),
|
||||
name: "Creator".to_string(),
|
||||
description:
|
||||
"Build, inspect and test Codewhale plugins in this conversation, with the plugin authoring tools and guides loaded."
|
||||
.to_string(),
|
||||
source: SOURCE_BUILTIN,
|
||||
available: creator_unavailable.is_none(),
|
||||
reason: creator_unavailable,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
type Cached = Option<(Instant, Option<String>)>;
|
||||
|
||||
static CREATOR_PROBE: Mutex<Cached> = Mutex::new(None);
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
static CREATOR_PROBE_OVERRIDE: Mutex<Option<Option<String>>> = Mutex::new(None);
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
static CREATOR_PROBE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub struct CreatorAvailabilityGuard {
|
||||
_serial: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl CreatorAvailabilityGuard {
|
||||
pub fn set(unavailable_reason: Option<&str>) -> Self {
|
||||
Self::hold(Some(unavailable_reason.map(str::to_string)))
|
||||
}
|
||||
|
||||
pub fn unforced() -> Self {
|
||||
Self::hold(None)
|
||||
}
|
||||
|
||||
fn hold(forced: Option<Option<String>>) -> Self {
|
||||
let serial = CREATOR_PROBE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*CREATOR_PROBE_OVERRIDE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = forced;
|
||||
Self { _serial: serial }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl Drop for CreatorAvailabilityGuard {
|
||||
fn drop(&mut self) {
|
||||
*CREATOR_PROBE_OVERRIDE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_host_runtime(options: &ExtensionHostOptions) -> Option<String> {
|
||||
let resolution = codewhale_dependencies::resolve_extension_host_runtime(
|
||||
options.runtime,
|
||||
options.node_override.as_deref(),
|
||||
options.bun_override.as_deref(),
|
||||
crate::extension_host::bundle_sha256(),
|
||||
);
|
||||
if resolution.selected.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"Creator mode needs a plugin runtime (Node.js or Bun), and none was found. {}",
|
||||
resolution.failure()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn creator_unavailable_reason(options: ExtensionHostOptions) -> Option<String> {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if let Some(forced) = CREATOR_PROBE_OVERRIDE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
{
|
||||
return forced;
|
||||
}
|
||||
if !CREATOR_LOADS_ITS_OWN_TOOLS {
|
||||
return Some(CREATOR_NOT_READY_REASON.to_string());
|
||||
}
|
||||
if let Some((at, reason)) = CREATOR_PROBE.lock().expect("probe cache").clone()
|
||||
&& at.elapsed() < AVAILABILITY_TTL
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
let reason = tokio::task::spawn_blocking(move || probe_host_runtime(&options))
|
||||
.await
|
||||
.unwrap_or_else(|_| Some("Creator mode could not check for a plugin runtime.".to_string()));
|
||||
*CREATOR_PROBE.lock().expect("probe cache") = Some((Instant::now(), reason.clone()));
|
||||
reason
|
||||
}
|
||||
|
||||
pub async fn current_table(options: ExtensionHostOptions) -> ConversationModeTable {
|
||||
table(creator_unavailable_reason(options).await)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModeSelection {
|
||||
pub id: String,
|
||||
pub unavailable: Option<String>,
|
||||
}
|
||||
|
||||
impl ModeSelection {
|
||||
pub fn require_available(&self) -> Result<(), ModeRefusal> {
|
||||
match &self.unavailable {
|
||||
Some(reason) => Err(ModeRefusal::new(
|
||||
ModeRefusal::MODE_UNAVAILABLE,
|
||||
reason.clone(),
|
||||
)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn select(id: &str, options: ExtensionHostOptions) -> Result<ModeSelection, ModeRefusal> {
|
||||
let table = current_table(options).await;
|
||||
match table.modes.into_iter().find(|mode| mode.id == id) {
|
||||
Some(mode) => Ok(ModeSelection {
|
||||
id: mode.id,
|
||||
unavailable: if mode.available {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
mode.reason
|
||||
.unwrap_or_else(|| "This mode is unavailable.".to_string()),
|
||||
)
|
||||
},
|
||||
}),
|
||||
None => Err(ModeRefusal::new(
|
||||
ModeRefusal::UNKNOWN_MODE,
|
||||
format!("`{id}` is not a conversation mode; GET /v1/modes lists them."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -863,6 +863,38 @@ mod tests {
|
||||
TurnAuthority::from_effective_fields(mode, true, false, auto_approve, approval_mode)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_managed_network_grant_survives_the_ask_posture_and_shares_the_network() {
|
||||
let workspace = Path::new("/srv/workspace");
|
||||
for approval_mode in [ApprovalMode::Suggest, ApprovalMode::Bypass] {
|
||||
let policy = sandbox_policy_for_turn(
|
||||
AppMode::Agent,
|
||||
approval_mode,
|
||||
Some("workspace-write"),
|
||||
workspace,
|
||||
SandboxNetworkAccess::from_config(Some(true)),
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
policy,
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: true,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{approval_mode:?}: {policy:?}"
|
||||
);
|
||||
}
|
||||
let unset = sandbox_policy_for_turn(
|
||||
AppMode::Agent,
|
||||
ApprovalMode::Suggest,
|
||||
Some("workspace-write"),
|
||||
workspace,
|
||||
SandboxNetworkAccess::from_config(None),
|
||||
);
|
||||
assert!(!unset.has_network_access(), "{unset:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_carve_out_posture_is_exactly_accept_edits() {
|
||||
assert!(write_carve_out_posture(
|
||||
|
||||
@@ -2758,6 +2758,42 @@ mod tests {
|
||||
assert!(decision.reason.contains("publish-like"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_trust_and_enable_from_a_shell_follow_the_session_approval() {
|
||||
let policy = AutoReviewPolicy::default();
|
||||
for command in [
|
||||
"codewhale plugins trust demo --review-token a.b --yes",
|
||||
"codewhale plugins enable demo",
|
||||
"codewhale plugins trust demo",
|
||||
] {
|
||||
for mode in [
|
||||
ApprovalMode::Suggest,
|
||||
ApprovalMode::AcceptEdits,
|
||||
ApprovalMode::Auto,
|
||||
] {
|
||||
let mut ctx = ctx_for(
|
||||
"exec_shell",
|
||||
json!({ "command": command }),
|
||||
RunOrigin::Interactive,
|
||||
mode,
|
||||
);
|
||||
ctx.session_runtime_risk = None;
|
||||
let decision = policy.evaluate(&ctx);
|
||||
assert_eq!(decision.action, AutoReviewAction::AskUser, "{command}");
|
||||
assert!(!decision.built_in_safety_gate, "{command}");
|
||||
assert!(!decision.reason.contains("plugin"), "{command}");
|
||||
}
|
||||
let full_access = policy.evaluate(&ctx_for(
|
||||
"exec_shell",
|
||||
json!({ "command": command }),
|
||||
RunOrigin::Interactive,
|
||||
ApprovalMode::Bypass,
|
||||
));
|
||||
assert!(!full_access.built_in_safety_gate, "{command}");
|
||||
assert_ne!(full_access.action, AutoReviewAction::Block, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_test_shell_is_not_held_by_safety_floor() {
|
||||
// #3883: an ordinary build/test command flagged background must not
|
||||
|
||||
@@ -314,8 +314,8 @@ pub struct EngineConfig {
|
||||
pub session_id: Option<String>,
|
||||
/// Optional host-owned root for delegated-agent runtime state.
|
||||
///
|
||||
/// When unset, the worker ledger, complete transcript artifacts and
|
||||
/// coordination lock retain their historical location under
|
||||
/// When unset, the worker ledger and complete transcript artifacts
|
||||
/// retain their historical location under
|
||||
/// `workspace/.codewhale/state`. Embedders may set a session-scoped root
|
||||
/// to separate that control-plane state from the execution workspace.
|
||||
/// Child cwd and file authority still derive from `workspace`; hosts using
|
||||
@@ -566,6 +566,7 @@ pub struct EngineConfig {
|
||||
/// When true and `/usr/bin/bwrap` is executable on Linux, route exec_shell
|
||||
/// through bubblewrap (#2184).
|
||||
pub prefer_bwrap: bool,
|
||||
pub tool_launcher: Option<Vec<String>>,
|
||||
/// User-configured bwrap mount extensions (#5410): extra read-only roots
|
||||
/// and writable device nodes such as `/dev/null`.
|
||||
pub bwrap_extensions: codewhale_sandbox::BwrapMountExtensions,
|
||||
@@ -700,6 +701,7 @@ impl Default for EngineConfig {
|
||||
goal_max_steps: None,
|
||||
// Mirrors `Config::prefers_bwrap`: on unless explicitly opted out.
|
||||
prefer_bwrap: true,
|
||||
tool_launcher: None,
|
||||
bwrap_extensions: codewhale_sandbox::BwrapMountExtensions::default(),
|
||||
// Fail-closed (F7): `Engine::new` unconditionally installs this
|
||||
// list process-wide via `read_guard::set_active`, so a default
|
||||
@@ -2541,12 +2543,14 @@ impl Engine {
|
||||
match shell_manager.lock() {
|
||||
Ok(mut manager) => {
|
||||
manager.set_prefer_bwrap(config.prefer_bwrap);
|
||||
manager.set_tool_launcher(config.tool_launcher.clone());
|
||||
manager.set_bwrap_extensions(config.bwrap_extensions.clone());
|
||||
manager.set_denied_read_subpaths(config.read_denylist.subtree_paths());
|
||||
}
|
||||
Err(poisoned) => {
|
||||
let mut manager = poisoned.into_inner();
|
||||
manager.set_prefer_bwrap(config.prefer_bwrap);
|
||||
manager.set_tool_launcher(config.tool_launcher.clone());
|
||||
manager.set_bwrap_extensions(config.bwrap_extensions.clone());
|
||||
manager.set_denied_read_subpaths(config.read_denylist.subtree_paths());
|
||||
}
|
||||
@@ -6242,7 +6246,9 @@ impl Engine {
|
||||
};
|
||||
}
|
||||
if self.is_acp_turn() {
|
||||
return self.acp_tool_build(input_policy, &route, allowed_tools);
|
||||
return self
|
||||
.acp_turn_tool_build(input_policy, &route, allowed_tools, mcp_access)
|
||||
.await;
|
||||
}
|
||||
if self.rlm_host.is_some() {
|
||||
return self.rlm_tool_build(input_policy, &route);
|
||||
|
||||
@@ -40,11 +40,61 @@ impl Engine {
|
||||
self.host_profile.is_acp() || self.turn_narrowing.is_acp()
|
||||
}
|
||||
|
||||
pub(super) async fn acp_turn_tool_build(
|
||||
&mut self,
|
||||
authority: &TurnAuthority,
|
||||
route: &TurnRouteContext,
|
||||
allowed: Option<Vec<String>>,
|
||||
mcp_access: McpAccess,
|
||||
) -> TurnToolBuild {
|
||||
if let Some(attachment) = self
|
||||
.extension_host
|
||||
.as_ref()
|
||||
.filter(|_| self.config.features.enabled(Feature::ExtensionHost))
|
||||
{
|
||||
if attachment.selection_settled() {
|
||||
attachment.sync_in_background();
|
||||
} else {
|
||||
attachment.sync_within(EXTENSION_FIRST_SYNC_WAIT).await;
|
||||
}
|
||||
}
|
||||
let mcp = if self.config.features.enabled(Feature::Mcp) {
|
||||
if mcp_access.may_connect() {
|
||||
self.wait_for_mcp_boot().await;
|
||||
let tools = self.mcp_tools().await;
|
||||
let server_count = match self.mcp_pool.as_ref() {
|
||||
Some(pool) => pool.lock().await.connected_servers().len(),
|
||||
None => 0,
|
||||
};
|
||||
McpToolState::Live {
|
||||
tools,
|
||||
server_count,
|
||||
}
|
||||
} else {
|
||||
self.passive_mcp_snapshot().await
|
||||
}
|
||||
} else {
|
||||
McpToolState::Disabled
|
||||
};
|
||||
self.acp_tool_build_with_mcp(authority, route, allowed, mcp)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn acp_tool_build(
|
||||
&self,
|
||||
authority: &TurnAuthority,
|
||||
route: &TurnRouteContext,
|
||||
allowed: Option<Vec<String>>,
|
||||
) -> TurnToolBuild {
|
||||
self.acp_tool_build_with_mcp(authority, route, allowed, McpToolState::Disabled)
|
||||
}
|
||||
|
||||
pub(super) fn acp_tool_build_with_mcp(
|
||||
&self,
|
||||
authority: &TurnAuthority,
|
||||
route: &TurnRouteContext,
|
||||
allowed: Option<Vec<String>>,
|
||||
mcp: McpToolState,
|
||||
) -> TurnToolBuild {
|
||||
let mut context = self.build_tool_context_for_turn(authority, route);
|
||||
let requested = self
|
||||
@@ -85,6 +135,13 @@ impl Engine {
|
||||
builder = builder.with_foreground_shell_tools();
|
||||
}
|
||||
let mut registry = builder.build(context);
|
||||
let extension_host = self
|
||||
.extension_host
|
||||
.as_ref()
|
||||
.filter(|_| self.config.features.enabled(Feature::ExtensionHost));
|
||||
if let Some(attachment) = extension_host {
|
||||
attachment.manager().note_native_names(registry.names());
|
||||
}
|
||||
if let Some(overrides) = self
|
||||
.api_config
|
||||
.tools
|
||||
@@ -95,15 +152,28 @@ impl Engine {
|
||||
remove_acp_overridden_builtin(&mut registry, name);
|
||||
}
|
||||
}
|
||||
let plugin_tool_names: HashSet<String> = extension_host
|
||||
.map(|attachment| attachment.install_tools(&mut registry))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut mcp_tools = if self.child_host.is_none() {
|
||||
mcp.tools().to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
mcp_tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
let mcp_tool_names: Vec<String> = mcp_tools.iter().map(|tool| tool.name.clone()).collect();
|
||||
let allowed = Some(
|
||||
registry
|
||||
.names()
|
||||
.into_iter()
|
||||
.filter(|name| tool_catalog::tool_allowed(allowed.as_deref(), name))
|
||||
.map(str::to_string)
|
||||
.chain(mcp_tool_names.iter().cloned())
|
||||
.filter(|name| tool_catalog::tool_allowed(allowed.as_deref(), name))
|
||||
.collect(),
|
||||
);
|
||||
let catalog = self.child_host.as_ref().map_or_else(
|
||||
let mut catalog = self.child_host.as_ref().map_or_else(
|
||||
|| registry.to_api_tools_with_cache(true),
|
||||
|child| {
|
||||
child
|
||||
@@ -111,6 +181,15 @@ impl Engine {
|
||||
.tools_for_model(®istry, &child.authority.agent_type)
|
||||
},
|
||||
);
|
||||
for tool in &mut catalog {
|
||||
if plugin_tool_names.contains(&tool.name) {
|
||||
tool.defer_loading = Some(false);
|
||||
}
|
||||
}
|
||||
catalog.extend(mcp_tools.into_iter().map(|mut tool| {
|
||||
tool.defer_loading = Some(false);
|
||||
tool
|
||||
}));
|
||||
let always_load = catalog.iter().map(|tool| tool.name.clone()).collect();
|
||||
TurnToolBuild {
|
||||
surface: ToolSurfacePolicy::new(
|
||||
@@ -125,11 +204,11 @@ impl Engine {
|
||||
self.config.max_tool_calls,
|
||||
tool_catalog::ToolMode::Direct,
|
||||
),
|
||||
mcp_tool_names: Vec::new(),
|
||||
mcp: McpToolState::Disabled,
|
||||
mcp_tool_names,
|
||||
mcp,
|
||||
subagent_runtime_model: None,
|
||||
mailbox: None,
|
||||
plugin_tool_names: HashSet::new(),
|
||||
plugin_tool_names,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ impl Engine {
|
||||
biased;
|
||||
() = cancel.cancelled() => Err("RLM Python startup cancelled by its originating turn".to_string()),
|
||||
() = wait_for_deadline(invocation.deadline) => Err("RLM Python startup exhausted its original wall-clock deadline".to_string()),
|
||||
result = codewhale_repl::PythonRuntime::spawn_with_context(&path) => result,
|
||||
result = codewhale_repl::PythonRuntime::spawn_with_context(&path, &caller.context.workspace) => result,
|
||||
};
|
||||
match startup {
|
||||
Ok(runtime) => {
|
||||
|
||||
@@ -179,6 +179,49 @@ async fn child_ceiling_filters_acp_foreground_catalog_without_optional_allow_lis
|
||||
assert!(!dir.path().join("blocked.txt").exists());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn child_engine_gets_no_mcp_tools_from_the_acp_tool_build() {
|
||||
let dir = tempdir().unwrap();
|
||||
let _home = crate::test_support::SealedHome::at(dir.path());
|
||||
let (child, _handle, _authority, route) = fixture(dir.path(), None);
|
||||
let (mut root, _root_handle) = Engine::new(
|
||||
EngineConfig {
|
||||
workspace: dir.path().into(),
|
||||
snapshots_enabled: false,
|
||||
memory_enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
&route.api_config,
|
||||
);
|
||||
root.host_profile = EngineHostProfile::Acp;
|
||||
let authority =
|
||||
TurnAuthority::from_effective_fields(AppMode::Agent, true, false, true, ApprovalMode::Auto);
|
||||
let live = || McpToolState::Live {
|
||||
tools: vec![api_tool("mcp_fixture_echo")],
|
||||
server_count: 1,
|
||||
};
|
||||
let offered = root.acp_tool_build_with_mcp(&authority, &route, None, live());
|
||||
assert_eq!(offered.mcp_tool_names, ["mcp_fixture_echo"]);
|
||||
assert!(
|
||||
offered
|
||||
.surface
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|tool| tool.name == "mcp_fixture_echo")
|
||||
);
|
||||
assert!(offered.surface.passes_allow_list("mcp_fixture_echo"));
|
||||
let build = child.acp_tool_build_with_mcp(&authority, &route, None, live());
|
||||
assert!(build.mcp_tool_names.is_empty());
|
||||
assert!(
|
||||
!build
|
||||
.surface
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|tool| tool.name == "mcp_fixture_echo")
|
||||
);
|
||||
assert!(!build.surface.passes_allow_list("mcp_fixture_echo"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn child_registry_rejects_context_override_identity_before_tool_effect() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
@@ -1300,7 +1300,7 @@ async fn narrowing_the_posture_discards_python_kernels() {
|
||||
client,
|
||||
);
|
||||
// Without a Python interpreter there is no kernel to discard.
|
||||
let Ok(kernel) = codewhale_repl::runtime::PythonRuntime::new().await else {
|
||||
let Ok(kernel) = codewhale_repl::runtime::PythonRuntime::new(&std::env::temp_dir()).await else {
|
||||
return;
|
||||
};
|
||||
engine.repl_kernel = Some(kernel);
|
||||
@@ -1363,7 +1363,7 @@ async fn a_broken_repl_kernel_is_replaced_by_the_next_turn() {
|
||||
engine.session.auto_approve = true;
|
||||
|
||||
// What a turn dropped mid-round leaves behind.
|
||||
let mut stale = codewhale_repl::PythonRuntime::new().await.expect("spawn");
|
||||
let mut stale = codewhale_repl::PythonRuntime::new(&std::env::temp_dir()).await.expect("spawn");
|
||||
let dropped = tokio::time::timeout(
|
||||
Duration::from_millis(50),
|
||||
stale.execute("import time\ntime.sleep(30)"),
|
||||
|
||||
@@ -742,7 +742,10 @@ impl Engine {
|
||||
if let Some(context) = context
|
||||
&& (context.acp_host.is_some() || context.child_host.is_some())
|
||||
{
|
||||
if context.acp_host.is_some() || tool_name != EXECUTE_TOOLS_TOOL_NAME {
|
||||
let acp_mcp_call = context.acp_host.is_some() && McpPool::is_mcp_tool(&tool_name);
|
||||
if !acp_mcp_call
|
||||
&& (context.acp_host.is_some() || tool_name != EXECUTE_TOOLS_TOOL_NAME)
|
||||
{
|
||||
let spec = registry
|
||||
.and_then(|registry| registry.get(&tool_name))
|
||||
.ok_or_else(|| {
|
||||
|
||||
@@ -2044,7 +2044,11 @@ impl Engine {
|
||||
)));
|
||||
}
|
||||
|
||||
if blocked_error.is_none() && self.is_acp_turn() && !registry_has_spec {
|
||||
if blocked_error.is_none()
|
||||
&& self.is_acp_turn()
|
||||
&& !registry_has_spec
|
||||
&& !McpPool::is_mcp_tool(&tool_name)
|
||||
{
|
||||
blocked_error = Some(ToolError::not_available(format!(
|
||||
"{tool_name} is outside the ACP foreground tool profile"
|
||||
)));
|
||||
|
||||
@@ -91,7 +91,7 @@ impl Engine {
|
||||
Err("REPL startup cancelled".into())
|
||||
}
|
||||
() = wait_for_deadline(repl_deadline) => Err("parent turn deadline reached during REPL startup".into()),
|
||||
result = codewhale_repl::runtime::PythonRuntime::new() => result,
|
||||
result = codewhale_repl::runtime::PythonRuntime::new(&self.session.workspace) => result,
|
||||
};
|
||||
self.repl_kernel = match startup {
|
||||
Ok(runtime) => Some(runtime),
|
||||
|
||||
@@ -1044,7 +1044,7 @@ pub fn tool_projection_warning_message(
|
||||
omitted_tool_count: usize,
|
||||
) -> String {
|
||||
format!(
|
||||
"Warning: {provider} omitted incompatible tools for this request: {}",
|
||||
"Warning: {provider} omitted tools for this request: {}. A tool is omitted when its input schema is incompatible with this route or when its encoded name is longer than the provider's tool-name limit (64 characters on OpenAI-style routes, 128 on Anthropic-style routes and DeepSeek); a name ending in … is cut for display. Shorten the tool's name (dots, hyphens and other special characters each add characters) or remove it.",
|
||||
tool_projection_warning_tool_list(omitted_tool_names, omitted_tool_count)
|
||||
)
|
||||
}
|
||||
@@ -1251,6 +1251,15 @@ mod tool_projection_warning_tests {
|
||||
bounded_tool_projection_warning_names,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn projection_warning_message_names_the_tool_and_the_name_limit() {
|
||||
let names = vec!["web-x00002E-run".to_string()];
|
||||
let message = tool_projection_warning_message("OpenAI", &names, 1);
|
||||
assert!(message.contains("web-x00002E-run"), "{message}");
|
||||
assert!(message.contains("64 characters"), "{message}");
|
||||
assert!(message.contains("128 on Anthropic-style"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_warning_names_are_count_and_length_bounded() {
|
||||
let names: Vec<String> = (0..12)
|
||||
|
||||
+52
-4
@@ -1,11 +1,12 @@
|
||||
//! Stable diagnostics translation keys mapped to the existing catalog.
|
||||
//! This host-only bridge does not introduce new copy or infer provider prices.
|
||||
//! This bridge does not introduce new copy or infer provider prices.
|
||||
|
||||
use codewhale_localization::MessageId;
|
||||
use codewhale_command_contract::facets::CommandPresentationContext;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
|
||||
pub(super) fn resolve(key: &str) -> Option<MessageId> {
|
||||
pub fn resolve(key: &str) -> Option<MessageId> {
|
||||
if let Some(reason) = key.strip_prefix("cost_reason_") {
|
||||
return Some(codewhale_engine::pricing::UnpricedReason::from_label(reason).message_id());
|
||||
return Some(crate::pricing::UnpricedReason::from_label(reason).message_id());
|
||||
}
|
||||
Some(match key {
|
||||
"cmd_diff_git_unavailable" => MessageId::CmdDiffGitUnavailable,
|
||||
@@ -83,3 +84,50 @@ pub(super) fn resolve(key: &str) -> Option<MessageId> {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_named_replacements(template: &str, replacements: &[(&str, &str)]) -> Option<String> {
|
||||
let supplied: std::collections::BTreeMap<&str, &str> = replacements.iter().copied().collect();
|
||||
if supplied.len() != replacements.len() {
|
||||
return None;
|
||||
}
|
||||
let mut placeholders = std::collections::BTreeSet::new();
|
||||
let mut cursor = 0usize;
|
||||
while let Some(start) = template[cursor..].find('{') {
|
||||
let start = cursor + start;
|
||||
let Some(end) = template[start + 1..].find('}') else {
|
||||
break;
|
||||
};
|
||||
let end = start + 1 + end;
|
||||
let name = &template[start + 1..end];
|
||||
if !name.is_empty() {
|
||||
placeholders.insert(name);
|
||||
}
|
||||
cursor = end + 1;
|
||||
}
|
||||
if placeholders != supplied.keys().copied().collect() {
|
||||
return None;
|
||||
}
|
||||
let placeholders: Vec<_> = replacements
|
||||
.iter()
|
||||
.map(|(name, _)| format!("{{{name}}}"))
|
||||
.collect();
|
||||
let replacements: Vec<_> = placeholders
|
||||
.iter()
|
||||
.zip(replacements)
|
||||
.map(|(placeholder, (_, value))| (placeholder.as_str(), *value))
|
||||
.collect();
|
||||
Some(codewhale_protocol::display::interpolate(
|
||||
template,
|
||||
&replacements,
|
||||
))
|
||||
}
|
||||
|
||||
pub struct EnglishDiagnostics;
|
||||
|
||||
impl CommandPresentationContext for EnglishDiagnostics {
|
||||
fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
|
||||
let message_id = resolve(key).ok_or_else(|| "unknown translation key".to_string())?;
|
||||
apply_named_replacements(&tr(Locale::En, message_id), replacements)
|
||||
.ok_or_else(|| "invalid translation replacement contract".to_string())
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@
|
||||
//! Full Access still opens their card. Auto-Review and Never refuse them
|
||||
//! (`resolve_approval_request_disposition`); explicit denials always win.
|
||||
//!
|
||||
//! **Caps.** Per invocation: 50 `core/call`s in all (the ticket's uses), 4 at
|
||||
//! **Caps.** Per invocation: 50 core tool calls in all, counted together
|
||||
//! across `core/call` and the nested calls of broker arms, 4 at
|
||||
//! once (code mode's own cap), and one approval card at a time (the turn
|
||||
//! loop serves one request at a time). Per host: the channel's 256 in-flight
|
||||
//! host requests. A burst of invalid tickets ends the host (`ticket`).
|
||||
@@ -102,6 +103,7 @@ pub const MAX_CALLS_PER_INVOCATION: u32 = 50;
|
||||
/// A backstop only: an invocation ends (and revokes its ticket) long before
|
||||
/// this, and its own deadline pauses while a person decides.
|
||||
const TICKET_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const GRANT_PROBE: Duration = Duration::from_millis(50);
|
||||
/// Receipts attached to an extension tool's result metadata.
|
||||
pub const RECEIPTS_ATTACHED: usize = 50;
|
||||
|
||||
@@ -266,7 +268,7 @@ pub struct Invocation {
|
||||
gate: NestedCallGate,
|
||||
root_caller: Option<SessionCheckpointCaller>,
|
||||
broker: Option<BrokerScope>,
|
||||
broker_core_calls: AtomicU32,
|
||||
nested_calls: AtomicU32,
|
||||
tickets: Vec<Ticket>,
|
||||
caller_tools: Vec<String>,
|
||||
activations: Mutex<
|
||||
@@ -318,18 +320,13 @@ impl BrokerContext {
|
||||
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.invocation
|
||||
.broker_core_calls
|
||||
.fetch_add(1, Ordering::SeqCst)
|
||||
>= MAX_CALLS_PER_INVOCATION
|
||||
{
|
||||
if self.invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
|
||||
return Err(BrokerRefusal::nested(
|
||||
self.service,
|
||||
NestedFailure::Rejected {
|
||||
decision: NestedDecision::Refused,
|
||||
message: format!(
|
||||
"an invocation's brokers may make at most {MAX_CALLS_PER_INVOCATION} core tool calls"
|
||||
"a tool call may make at most {MAX_CALLS_PER_INVOCATION} core tool calls"
|
||||
),
|
||||
},
|
||||
));
|
||||
@@ -365,7 +362,7 @@ impl BrokerContext {
|
||||
gate,
|
||||
root_caller: None,
|
||||
broker: None,
|
||||
broker_core_calls: AtomicU32::new(0),
|
||||
nested_calls: AtomicU32::new(0),
|
||||
tickets: Vec::new(),
|
||||
caller_tools: Vec::new(),
|
||||
activations: Mutex::new(HashMap::new()),
|
||||
@@ -414,6 +411,7 @@ impl CoreCalls {
|
||||
gate,
|
||||
None,
|
||||
String::new(),
|
||||
&dsh_broker::test_grants().unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -428,6 +426,7 @@ impl CoreCalls {
|
||||
gate: &NestedCallGate,
|
||||
scope: Option<codewhale_config::EntryRef>,
|
||||
content_hash: String,
|
||||
broker_services: &[DshService],
|
||||
) -> Option<InvocationGuard> {
|
||||
let (caller, specs) = gate.extension()?;
|
||||
if caller != expected {
|
||||
@@ -517,7 +516,7 @@ impl CoreCalls {
|
||||
.flat_map(|session| {
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|service| dsh_broker::granted(*service))
|
||||
.filter(|service| tier == HostTier::Plugin && broker_services.contains(service))
|
||||
.map(move |service| {
|
||||
(
|
||||
service,
|
||||
@@ -566,7 +565,7 @@ impl CoreCalls {
|
||||
gate: gate.clone(),
|
||||
root_caller,
|
||||
broker,
|
||||
broker_core_calls: AtomicU32::new(0),
|
||||
nested_calls: AtomicU32::new(0),
|
||||
tickets: std::iter::once(ticket.clone())
|
||||
.chain(checkpoint_ticket.iter().cloned())
|
||||
.chain(question_ticket.iter().cloned())
|
||||
@@ -703,10 +702,30 @@ impl CoreCalls {
|
||||
method: &str,
|
||||
cx: &HostRequestContext,
|
||||
) -> Result<Arc<Invocation>, RpcErrorWire> {
|
||||
let refuse = |code, message| RpcErrorWire {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
self.redeem_invocation_or_limit(shared, tier, host_generation, owner, ticket, method, cx)
|
||||
.map_err(|(_, error)| error)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn redeem_invocation_or_limit(
|
||||
&self,
|
||||
shared: &ManagerShared,
|
||||
tier: HostTier,
|
||||
host_generation: u64,
|
||||
owner: &OwnerRef,
|
||||
ticket: &str,
|
||||
method: &str,
|
||||
cx: &HostRequestContext,
|
||||
) -> Result<Arc<Invocation>, (bool, RpcErrorWire)> {
|
||||
let refuse = |code, message| {
|
||||
(
|
||||
false,
|
||||
RpcErrorWire {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
let redeemed = self.tickets.redeem(&Presented {
|
||||
ticket,
|
||||
@@ -723,10 +742,8 @@ impl CoreCalls {
|
||||
"too many invalid {method} tickets from one host process"
|
||||
));
|
||||
}
|
||||
return Err(refuse(
|
||||
error_code::REFUSED,
|
||||
refused.reason.describe().to_string(),
|
||||
));
|
||||
let (_, error) = refuse(error_code::REFUSED, refused.reason.describe().to_string());
|
||||
return Err((refused.reason == super::ticket::Refusal::Exhausted, error));
|
||||
}
|
||||
// The redemption proved the ticket is live and ours; the owner must
|
||||
// still be the current one of this tier.
|
||||
@@ -1257,7 +1274,7 @@ impl CoreCalls {
|
||||
let service = DshService::parse(¶ms.service)
|
||||
.ok_or_else(|| BrokerRefusal::UnknownService(params.service.clone()).wire())?;
|
||||
let invocation = self
|
||||
.redeem_invocation(
|
||||
.redeem_invocation_or_limit(
|
||||
shared,
|
||||
tier,
|
||||
host_generation,
|
||||
@@ -1266,14 +1283,24 @@ impl CoreCalls {
|
||||
dsh_broker::ticket_method(service),
|
||||
&cx,
|
||||
)
|
||||
.map_err(|error| dsh_broker::coded(error, dsh_broker::TICKET_REFUSED))?;
|
||||
.map_err(|(limit, mut error)| {
|
||||
if limit {
|
||||
error.message = format!(
|
||||
"this tool call's limit for the {} service is used up",
|
||||
service.name()
|
||||
);
|
||||
dsh_broker::coded(error, dsh_broker::LIMIT)
|
||||
} else {
|
||||
dsh_broker::coded(error, dsh_broker::TICKET_REFUSED)
|
||||
}
|
||||
})?;
|
||||
let (session_id, tool) = invocation
|
||||
.broker
|
||||
.as_ref()
|
||||
.map(|scope| (scope.session_id.clone(), Arc::clone(&scope.context)))
|
||||
.ok_or_else(|| refused("dsh/broker requires an invocation in a session".into()))?;
|
||||
if !dsh_broker::granted(service) {
|
||||
return Err(BrokerRefusal::NotAvailable(service).wire());
|
||||
if !shared.broker_granted(¶ms.owner, service).await {
|
||||
return Err(BrokerRefusal::NotGranted(service).wire());
|
||||
}
|
||||
let arm = dsh_broker::arm_in(table, service)
|
||||
.ok_or_else(|| BrokerRefusal::NotAvailable(service).wire())?;
|
||||
@@ -1292,10 +1319,19 @@ impl CoreCalls {
|
||||
cancel: withdraw.clone(),
|
||||
},
|
||||
};
|
||||
let withdrawn = async {
|
||||
loop {
|
||||
tokio::time::sleep(GRANT_PROBE).await;
|
||||
if !shared.broker_granted(¶ms.owner, service).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
() = cx.cancel.cancelled() => return Err(cancelled("dsh/broker was cancelled by its host")),
|
||||
() = withdraw.cancelled() => return Err(cancelled("dsh/broker invocation ended")),
|
||||
() = withdrawn => return Err(BrokerRefusal::NotGranted(service).wire()),
|
||||
outcome = arm(call) => outcome,
|
||||
};
|
||||
if shared
|
||||
@@ -1319,6 +1355,9 @@ impl CoreCalls {
|
||||
invocation.scope.as_ref(),
|
||||
)
|
||||
.map_err(refused)?;
|
||||
if !shared.broker_granted(¶ms.owner, service).await {
|
||||
return Err(BrokerRefusal::NotGranted(service).wire());
|
||||
}
|
||||
outcome.map_err(BrokerRefusal::wire)
|
||||
}
|
||||
|
||||
@@ -1347,6 +1386,12 @@ impl CoreCalls {
|
||||
METHOD,
|
||||
&cx,
|
||||
)?;
|
||||
if invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
|
||||
return Err(refuse(
|
||||
error_code::REFUSED,
|
||||
super::ticket::Refusal::Exhausted.describe().to_string(),
|
||||
));
|
||||
}
|
||||
// Withdrawn when the host cancels this request, its owner is revoked,
|
||||
// the host exits (`cx.cancel`), or the invocation ends.
|
||||
let withdraw = invocation.cancel.child_token();
|
||||
|
||||
@@ -1818,10 +1818,12 @@ async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is
|
||||
let echo: &[(DshService, super::dsh_broker::Arm)] =
|
||||
&[(DshService::Workspace, echo_arm as super::dsh_broker::Arm)];
|
||||
|
||||
for service in DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|s| *s != DshService::Network)
|
||||
{
|
||||
for service in DshService::ALL.into_iter().filter(|s| {
|
||||
!matches!(
|
||||
s,
|
||||
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
|
||||
)
|
||||
}) {
|
||||
let ticket = &tickets_a[service.name()];
|
||||
let result = f
|
||||
.broker(
|
||||
@@ -2039,8 +2041,9 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
|
||||
assert!(
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.all(|service| !super::dsh_broker::granted(service))
|
||||
.all(|service| !super::dsh_broker::offered(service))
|
||||
);
|
||||
assert!(f.manager.shared.broker_services(&f.owner).is_empty());
|
||||
let none = f.begin(7, &f.owner);
|
||||
assert!(
|
||||
none.broker_tickets().is_none(),
|
||||
@@ -2095,7 +2098,10 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
|
||||
service.name()
|
||||
);
|
||||
}
|
||||
if service != DshService::Network {
|
||||
if !matches!(
|
||||
service,
|
||||
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
|
||||
) {
|
||||
let result = f
|
||||
.broker(
|
||||
TABLE,
|
||||
@@ -2127,14 +2133,17 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&withdrawn),
|
||||
(error_code::NOT_AVAILABLE, "not_available".into()),
|
||||
(error_code::REFUSED, "not_granted".into()),
|
||||
"a grant withdrawn after minting refuses {} before the arm runs",
|
||||
service.name()
|
||||
);
|
||||
assert_eq!(
|
||||
withdrawn.unwrap_err().message,
|
||||
format!("the {} broker is not available yet", service.name()),
|
||||
"the refusal names no grant"
|
||||
format!(
|
||||
"the {} service is not granted to this plugin",
|
||||
service.name()
|
||||
),
|
||||
"the refusal names no other plugin and no reason"
|
||||
);
|
||||
drop(guard);
|
||||
}
|
||||
@@ -2488,6 +2497,15 @@ async fn an_invocations_brokers_share_one_cap_on_nested_core_calls() {
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
MAX_CALLS_PER_INVOCATION as usize
|
||||
);
|
||||
let (direct, _) = f.serve(HostTier::Plugin, 7, &f.owner, guard.ticket()).await;
|
||||
assert!(
|
||||
refused(&direct),
|
||||
"core/call and the brokers share the one cap: {direct:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
MAX_CALLS_PER_INVOCATION as usize
|
||||
);
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
@@ -2568,3 +2586,715 @@ async fn dsh_network_arm_refuses_foreign_tickets_unknown_methods_and_revoked_own
|
||||
drop(guard_b);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_remote_broker_pairs_through_the_host_and_refuses_foreign_stale_and_unknown_calls() {
|
||||
use super::dsh_broker::TABLE;
|
||||
use super::protocol::DshService;
|
||||
use super::{RemoteDenial, remote_grants};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let _grants = super::dsh_broker::TestGrants::only(&[DshService::Workspace, DshService::Remote]);
|
||||
let tickets = |guard: &super::core_call::InvocationGuard| -> BTreeMap<String, String> {
|
||||
guard
|
||||
.broker_tickets()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|wire| (wire.service, wire.ticket))
|
||||
.collect()
|
||||
};
|
||||
let mut f = fixture(true);
|
||||
f.in_session("remote-session-a");
|
||||
let guard_a = f.begin(7, &f.owner);
|
||||
let a = tickets(&guard_a);
|
||||
let remote = a["remote"].clone();
|
||||
|
||||
let paired = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&remote,
|
||||
"remote",
|
||||
"pair",
|
||||
json!({"label": "phone"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let grant = paired["grant"].as_str().unwrap().to_string();
|
||||
assert_eq!(paired["state"], "awaiting_host");
|
||||
assert!(!paired.to_string().contains("cwrp_"), "{paired}");
|
||||
|
||||
let presented = remote_grants()
|
||||
.awaiting_host()
|
||||
.into_iter()
|
||||
.find(|row| row.grant == grant)
|
||||
.expect("the host sees the pairing");
|
||||
assert_eq!(
|
||||
(presented.plugin_id.as_str(), presented.session_id.as_str()),
|
||||
("a", "remote-session-a")
|
||||
);
|
||||
let client = remote_grants().redeem(&presented.secret, "pixel").unwrap();
|
||||
assert_eq!(
|
||||
remote_grants().redeem(&presented.secret, "pixel"),
|
||||
Err(RemoteDenial::Replayed)
|
||||
);
|
||||
assert_eq!(
|
||||
remote_grants().authorize(&client.token, "remote-session-a"),
|
||||
Ok(grant.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
remote_grants().authorize(&client.token, "remote-session-b"),
|
||||
Err(RemoteDenial::ForeignSession)
|
||||
);
|
||||
|
||||
let unknown = f
|
||||
.broker(TABLE, &f.owner, &remote, "remote", "tunnel", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&unknown),
|
||||
(error_code::INVALID_PARAMS, "unknown_method".into())
|
||||
);
|
||||
let wrong_service = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&a["workspace"],
|
||||
"remote",
|
||||
"status",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&wrong_service),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
let foreign_plugin = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.other,
|
||||
&remote,
|
||||
"remote",
|
||||
"revoke",
|
||||
json!({"grant": grant}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&foreign_plugin),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
|
||||
f.in_session("remote-session-b");
|
||||
let guard_b = f.begin(7, &f.owner);
|
||||
let remote_b = tickets(&guard_b)["remote"].clone();
|
||||
let foreign_session = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&remote_b,
|
||||
"remote",
|
||||
"revoke",
|
||||
json!({"grant": grant}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&foreign_session),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into())
|
||||
);
|
||||
let listed_b = f
|
||||
.broker(TABLE, &f.owner, &remote_b, "remote", "clients", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed_b, json!({"clients": []}));
|
||||
assert_eq!(
|
||||
remote_grants().authorize(&client.token, "remote-session-a"),
|
||||
Ok(grant.clone())
|
||||
);
|
||||
|
||||
drop(guard_a);
|
||||
let stale = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&remote,
|
||||
"remote",
|
||||
"revoke",
|
||||
json!({"grant": grant}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&stale),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
assert!(
|
||||
remote_grants()
|
||||
.authorize(&client.token, "remote-session-a")
|
||||
.is_ok(),
|
||||
"a paired client outlives the invocation that asked for it"
|
||||
);
|
||||
|
||||
f.in_session("remote-session-a");
|
||||
let guard_a2 = f.begin(7, &f.owner);
|
||||
let remote_a2 = tickets(&guard_a2)["remote"].clone();
|
||||
let listed = f
|
||||
.broker(TABLE, &f.owner, &remote_a2, "remote", "clients", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed["clients"][0]["grant"], grant.as_str());
|
||||
assert_eq!(listed["clients"][0]["state"], "paired");
|
||||
assert!(!listed.to_string().contains("cwrc_"), "{listed}");
|
||||
let revoked = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&remote_a2,
|
||||
"remote",
|
||||
"revoke",
|
||||
json!({"grant": grant}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revoked, json!({"grant": grant, "revoked": true}));
|
||||
assert_eq!(
|
||||
remote_grants().authorize(&client.token, "remote-session-a"),
|
||||
Err(RemoteDenial::Unknown)
|
||||
);
|
||||
drop(guard_a2);
|
||||
drop(guard_b);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_env = "ohos")))]
|
||||
fn terminal_asked(f: &Fixture) -> usize {
|
||||
f.server.asked.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_env = "ohos")))]
|
||||
#[tokio::test]
|
||||
async fn dsh_terminal_arm_runs_through_the_gate_in_the_plugins_own_thread_owned_terminals() {
|
||||
use super::dsh_broker::{TABLE, TestGrants};
|
||||
use super::dsh_terminal::MAX_TERMINALS;
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::terminal_session;
|
||||
use codewhale_agent_contract::worker_profile::ShellPolicy;
|
||||
use codewhale_sandbox::SandboxPolicy;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace, DshService::Terminal]);
|
||||
let mut f = fixture(true);
|
||||
let workspace = f.context.workspace.clone();
|
||||
let specs = crate::tools::registry::ToolRegistryBuilder::new()
|
||||
.with_file_tools()
|
||||
.with_terminal_tools()
|
||||
.build(ToolContext::new(&workspace))
|
||||
.all();
|
||||
f.gate = f.gate.clone().for_extension(f.caller(), specs);
|
||||
f.context.elevated_sandbox_policy = Some(SandboxPolicy::DangerFullAccess);
|
||||
f.context.state_namespace = "thread-a".into();
|
||||
f.in_session("terminal-session-a");
|
||||
let guard_a = f.begin(7, &f.owner);
|
||||
let terminal = broker_ticket(&guard_a, "terminal").unwrap();
|
||||
let other_service = broker_ticket(&guard_a, "workspace").unwrap();
|
||||
let run = json!({"command": "printf 'dsh-%s' terminal", "session": "build"});
|
||||
|
||||
let unknown = f
|
||||
.broker(TABLE, &f.owner, &terminal, "terminal", "spawn", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&unknown),
|
||||
(error_code::INVALID_PARAMS, "unknown_method".into())
|
||||
);
|
||||
let escaped = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&terminal,
|
||||
"terminal",
|
||||
"run",
|
||||
json!({"command": "true", "session": "../term-1"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&escaped),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into())
|
||||
);
|
||||
for (owner, ticket) in [
|
||||
(&f.other, terminal.as_str()),
|
||||
(&f.owner, other_service.as_str()),
|
||||
(&f.owner, guard_a.ticket()),
|
||||
] {
|
||||
let result = f
|
||||
.broker(TABLE, owner, ticket, "terminal", "run", run.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
}
|
||||
let (cx, _violations, host_cancel) = HostRequestContext::for_test(51);
|
||||
host_cancel.cancel();
|
||||
let cancelled = f
|
||||
.broker_in(
|
||||
cx,
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&terminal,
|
||||
"terminal",
|
||||
"run",
|
||||
run.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&cancelled),
|
||||
(error_code::CANCELLED, "cancelled".into())
|
||||
);
|
||||
assert_eq!(terminal_asked(&f), 0, "nothing refused reached the gate");
|
||||
assert!(
|
||||
terminal_session::owned_sessions(&workspace, "thread-a")
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"nothing refused started a shell"
|
||||
);
|
||||
|
||||
let empty = f
|
||||
.broker(TABLE, &f.owner, &terminal, "terminal", "list", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(empty["terminals"], json!([]));
|
||||
assert_eq!(empty["instance"], terminal_session::instance_id());
|
||||
assert_eq!(terminal_asked(&f), 0, "a listing asks no card");
|
||||
|
||||
let ran = f
|
||||
.broker(TABLE, &f.owner, &terminal, "terminal", "run", run.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ran["ok"], true, "{ran}");
|
||||
assert_eq!(ran["session"], "build");
|
||||
assert!(
|
||||
ran["output"].as_str().unwrap().contains("dsh-terminal"),
|
||||
"{ran}"
|
||||
);
|
||||
assert_eq!(terminal_asked(&f), 1, "the command went through the gate");
|
||||
let terminal_id = ran["terminal_id"].as_str().unwrap().to_string();
|
||||
let owned = terminal_session::owned_sessions(&workspace, "thread-a").unwrap();
|
||||
assert_eq!(owned.len(), 1);
|
||||
assert_eq!(owned[0].name, "dsh:a:build");
|
||||
assert_eq!(owned[0].id, terminal_id);
|
||||
let shell = terminal_session::lookup(&workspace, Some("thread-a"), &terminal_id)
|
||||
.expect("the thread's own terminal routes reach the shell the broker started");
|
||||
terminal_session::resize_session(&shell.lock().unwrap(), 30, 100).unwrap();
|
||||
assert!(terminal_session::lookup(&workspace, Some("thread-b"), &terminal_id).is_none());
|
||||
let receipts = guard_a.receipts().expect("the terminal call is receipted");
|
||||
assert_eq!(receipts["total"], 1, "{receipts}");
|
||||
assert!(receipts.to_string().contains("terminal/run"), "{receipts}");
|
||||
|
||||
terminal_session::get_or_create(
|
||||
"thread-a",
|
||||
"term-1",
|
||||
&workspace,
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
&f.context.shell_manager,
|
||||
)
|
||||
.unwrap();
|
||||
for method in ["send", "wait", "cancel", "reset"] {
|
||||
let mut input = json!({"session": "term-1"});
|
||||
if method == "send" {
|
||||
input["text"] = json!("exit\n");
|
||||
}
|
||||
let result = f
|
||||
.broker(TABLE, &f.owner, &terminal, "terminal", method, input)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into()),
|
||||
"{method}: the model's own shell is not the plugin's"
|
||||
);
|
||||
}
|
||||
let listed = f
|
||||
.broker(TABLE, &f.owner, &terminal, "terminal", "list", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed["terminals"].as_array().unwrap().len(), 1, "{listed}");
|
||||
assert_eq!(listed["terminals"][0]["session"], "build");
|
||||
assert_eq!(listed["terminals"][0]["terminal_id"], terminal_id.as_str());
|
||||
assert_eq!(listed["terminals"][0]["running"], true);
|
||||
assert_eq!(terminal_asked(&f), 1);
|
||||
|
||||
let allowed = f.context.clone();
|
||||
for (denied_tools, policy, list_code) in [
|
||||
(vec!["terminal/run".to_string()], ShellPolicy::Full, None),
|
||||
(
|
||||
vec!["terminal/wait".to_string()],
|
||||
ShellPolicy::Full,
|
||||
Some("refused"),
|
||||
),
|
||||
(Vec::new(), ShellPolicy::ReadOnly, None),
|
||||
(Vec::new(), ShellPolicy::None, Some("refused")),
|
||||
] {
|
||||
f.context = allowed.clone().with_shell_policy(policy);
|
||||
f.context.disallowed_tools = denied_tools.clone();
|
||||
let narrowed = f.begin(7, &f.owner);
|
||||
let ticket = broker_ticket(&narrowed, "terminal").unwrap();
|
||||
if denied_tools != ["terminal/wait"] {
|
||||
let result = f
|
||||
.broker(TABLE, &f.owner, &ticket, "terminal", "run", run.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into()),
|
||||
"{denied_tools:?} {policy:?}"
|
||||
);
|
||||
}
|
||||
let listing = f
|
||||
.broker(TABLE, &f.owner, &ticket, "terminal", "list", json!({}))
|
||||
.await;
|
||||
match list_code {
|
||||
Some(code) => assert_eq!(dsh_refusal(&listing), (error_code::REFUSED, code.into())),
|
||||
None => assert!(listing.is_ok(), "{listing:?}"),
|
||||
}
|
||||
drop(narrowed);
|
||||
}
|
||||
f.context = allowed;
|
||||
assert_eq!(
|
||||
terminal_asked(&f),
|
||||
1,
|
||||
"a caller that may not use the tool never reaches the gate"
|
||||
);
|
||||
|
||||
for n in 1..MAX_TERMINALS {
|
||||
terminal_session::get_or_create(
|
||||
"thread-a",
|
||||
&format!("dsh:a:n{n}"),
|
||||
&workspace,
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
&f.context.shell_manager,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let over = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&terminal,
|
||||
"terminal",
|
||||
"run",
|
||||
json!({"command": "true", "session": "one-too-many"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(dsh_refusal(&over), (error_code::REFUSED, "refused".into()));
|
||||
assert_eq!(
|
||||
terminal_asked(&f),
|
||||
1,
|
||||
"the limit is checked before the card"
|
||||
);
|
||||
|
||||
f.context.state_namespace = "thread-b".into();
|
||||
f.in_session("terminal-session-b");
|
||||
let guard_b = f.begin(7, &f.owner);
|
||||
let terminal_b = broker_ticket(&guard_b, "terminal").unwrap();
|
||||
let elsewhere = f
|
||||
.broker(TABLE, &f.owner, &terminal_b, "terminal", "list", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
elsewhere["terminals"],
|
||||
json!([]),
|
||||
"another session sees none"
|
||||
);
|
||||
let foreign = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&terminal_b,
|
||||
"terminal",
|
||||
"wait",
|
||||
json!({"session": "build"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&foreign),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into())
|
||||
);
|
||||
drop(guard_a);
|
||||
let stale = f
|
||||
.broker(TABLE, &f.owner, &terminal, "terminal", "list", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&stale),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
terminal_session::kill_owner_sessions(&workspace, "thread-a").unwrap(),
|
||||
MAX_TERMINALS + 1,
|
||||
"deleting the thread ends the plugin's shells with the thread's own"
|
||||
);
|
||||
assert!(terminal_session::lookup(&workspace, Some("thread-a"), &terminal_id).is_none());
|
||||
|
||||
f.manager.shared.core_calls.revoke_owner("a");
|
||||
let revoked = f
|
||||
.broker(TABLE, &f.owner, &terminal_b, "terminal", "list", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&revoked),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
drop(guard_b);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_workspace_arm_reads_writes_and_runs_literal_git_only_through_the_gate_inside_the_workspace()
|
||||
{
|
||||
use super::dsh_broker::{TABLE, TestGrants};
|
||||
use super::dsh_workspace::TestOverlay;
|
||||
use super::protocol::DshService;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace, DshService::Terminal]);
|
||||
let mut f = fixture(true);
|
||||
let workspace = f.context.workspace.canonicalize().unwrap();
|
||||
std::fs::create_dir_all(workspace.join("src")).unwrap();
|
||||
std::fs::write(workspace.join("src/a.txt"), "alpha\n").unwrap();
|
||||
let git_ready = std::process::Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(&workspace)
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
let specs = crate::tools::registry::ToolRegistryBuilder::new()
|
||||
.with_file_tools()
|
||||
.with_git_tools()
|
||||
.build(ToolContext::new(&workspace))
|
||||
.all();
|
||||
f.gate = f.gate.clone().for_extension(f.caller(), specs);
|
||||
f.in_session("workspace-session-a");
|
||||
let guard_a = f.begin(7, &f.owner);
|
||||
let ticket = broker_ticket(&guard_a, "workspace").unwrap();
|
||||
let other_service = broker_ticket(&guard_a, "terminal").unwrap();
|
||||
let read = json!({"path": "src/a.txt"});
|
||||
|
||||
let unknown = f
|
||||
.broker(TABLE, &f.owner, &ticket, "workspace", "delete", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&unknown),
|
||||
(error_code::INVALID_PARAMS, "unknown_method".into())
|
||||
);
|
||||
for (owner, presented) in [
|
||||
(&f.other, ticket.as_str()),
|
||||
(&f.owner, other_service.as_str()),
|
||||
(&f.owner, guard_a.ticket()),
|
||||
] {
|
||||
let result = f
|
||||
.broker(TABLE, owner, presented, "workspace", "read", read.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
}
|
||||
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("secret"), "s").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(outside.path(), workspace.join("out")).unwrap();
|
||||
let mut escapes = vec![
|
||||
("read", json!({"path": "../secret"})),
|
||||
(
|
||||
"read",
|
||||
json!({"path": outside.path().join("secret").to_str().unwrap()}),
|
||||
),
|
||||
("list", json!({"path": "src/../.."})),
|
||||
(
|
||||
"exec",
|
||||
json!({"argv": ["git", "rev-parse", "--show-toplevel"], "cwd": "src"}),
|
||||
),
|
||||
("exec", json!({"argv": ["git", "status"]})),
|
||||
("exec", json!({"argv": ["sh", "-c", "git status"]})),
|
||||
("write", json!({"path": "src/b.txt", "content": "b"})),
|
||||
];
|
||||
if cfg!(unix) {
|
||||
escapes.extend([
|
||||
("read", json!({"path": "out/secret"})),
|
||||
("list", json!({"path": "out"})),
|
||||
]);
|
||||
}
|
||||
for (method, input) in escapes {
|
||||
let result = f
|
||||
.broker(TABLE, &f.owner, &ticket, "workspace", method, input.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into()),
|
||||
"{method} {input}"
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains(outside.path().to_str().unwrap()),
|
||||
"a refusal names no path"
|
||||
);
|
||||
}
|
||||
assert!(!workspace.join("src/b.txt").exists());
|
||||
assert_eq!(
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
0,
|
||||
"nothing refused reached the gate"
|
||||
);
|
||||
|
||||
let ran = f
|
||||
.broker(TABLE, &f.owner, &ticket, "workspace", "read", read.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ran["ok"], true, "{ran}");
|
||||
assert_eq!(ran["path"], "src/a.txt");
|
||||
assert!(ran["output"].as_str().unwrap().contains("alpha"), "{ran}");
|
||||
let listed = f
|
||||
.broker(TABLE, &f.owner, &ticket, "workspace", "list", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
listed["output"].as_str().unwrap().contains("src"),
|
||||
"{listed}"
|
||||
);
|
||||
assert_eq!(f.server.asked.load(Ordering::SeqCst), 2);
|
||||
|
||||
{
|
||||
let _overlay = TestOverlay::granted();
|
||||
let mut excluded = vec![".git/config", "../new.txt", "."];
|
||||
if cfg!(unix) {
|
||||
excluded.push("out/new.txt");
|
||||
}
|
||||
for path in excluded {
|
||||
let result = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"write",
|
||||
json!({"path": path, "content": "x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into()),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
assert!(!outside.path().join("new.txt").exists());
|
||||
let wrote = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"write",
|
||||
json!({"path": "src/new/b.txt", "content": "beta\n"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wrote["ok"], true, "{wrote}");
|
||||
assert_eq!(wrote["path"], "src/new/b.txt");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.join("src/new/b.txt")).unwrap(),
|
||||
"beta\n",
|
||||
"the overlay write persists in the workspace"
|
||||
);
|
||||
assert_eq!(f.server.asked.load(Ordering::SeqCst), 3);
|
||||
|
||||
if git_ready {
|
||||
let root = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"exec",
|
||||
json!({"argv": ["git", "rev-parse", "--show-toplevel"], "cwd": workspace.to_str().unwrap()}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(root["exit_code"].is_i64(), "{root}");
|
||||
assert!(root["stderr"].is_string(), "{root}");
|
||||
if root["exit_code"] == 0 {
|
||||
assert_eq!(root["ok"], true, "{root}");
|
||||
assert_eq!(
|
||||
std::path::Path::new(root["stdout"].as_str().unwrap().trim())
|
||||
.canonicalize()
|
||||
.unwrap(),
|
||||
workspace
|
||||
);
|
||||
}
|
||||
assert_eq!(f.server.asked.load(Ordering::SeqCst), 4);
|
||||
}
|
||||
|
||||
let receipts = guard_a.receipts().expect("workspace calls are receipted");
|
||||
let receipted = receipts.to_string();
|
||||
assert!(receipted.contains("read_file"), "{receipts}");
|
||||
assert!(receipted.contains("write_file"), "{receipts}");
|
||||
|
||||
let allowed = f.context.clone();
|
||||
f.context.disallowed_tools = vec!["read_file".into()];
|
||||
let narrowed = f.begin(7, &f.owner);
|
||||
let narrowed_ticket = broker_ticket(&narrowed, "workspace").unwrap();
|
||||
let asked = f.server.asked.load(Ordering::SeqCst);
|
||||
let denied = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&narrowed_ticket,
|
||||
"workspace",
|
||||
"read",
|
||||
read.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&denied),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
assert_eq!(
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
asked,
|
||||
"a denied tool never reaches the gate"
|
||||
);
|
||||
drop(narrowed);
|
||||
f.context = allowed;
|
||||
|
||||
let (cx, _violations, host_cancel) = HostRequestContext::for_test(52);
|
||||
host_cancel.cancel();
|
||||
let cancelled = f
|
||||
.broker_in(
|
||||
cx,
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"read",
|
||||
read.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&cancelled),
|
||||
(error_code::CANCELLED, "cancelled".into())
|
||||
);
|
||||
|
||||
f.in_session("workspace-session-b");
|
||||
let guard_b = f.begin(7, &f.owner);
|
||||
let ticket_b = broker_ticket(&guard_b, "workspace").unwrap();
|
||||
drop(guard_a);
|
||||
let ended = f
|
||||
.broker(TABLE, &f.owner, &ticket, "workspace", "read", read.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&ended),
|
||||
(error_code::REFUSED, "ticket_refused".into()),
|
||||
"session a's ticket dies with its invocation"
|
||||
);
|
||||
let in_b = f
|
||||
.broker(TABLE, &f.owner, &ticket_b, "workspace", "read", read)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(in_b["ok"], true);
|
||||
drop(guard_b);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::protocol::{DshService, RpcErrorWire, error_code};
|
||||
use super::registry::OwnerRegistry;
|
||||
use super::{
|
||||
dsh_catalog, dsh_llm, dsh_network, dsh_remote, dsh_settings, dsh_terminal, dsh_workspace,
|
||||
};
|
||||
@@ -34,6 +35,7 @@ pub enum BrokerRefusal {
|
||||
message: String,
|
||||
},
|
||||
NotAvailable(DshService),
|
||||
NotGranted(DshService),
|
||||
Denied {
|
||||
service: DshService,
|
||||
message: String,
|
||||
@@ -52,6 +54,7 @@ impl BrokerRefusal {
|
||||
Self::UnknownMethod { .. } => "unknown_method",
|
||||
Self::InvalidInput { .. } => "invalid_input",
|
||||
Self::NotAvailable(_) => "not_available",
|
||||
Self::NotGranted(_) => "not_granted",
|
||||
Self::Denied { .. } => "denied",
|
||||
Self::Refused { .. } => "refused",
|
||||
}
|
||||
@@ -85,6 +88,14 @@ impl BrokerRefusal {
|
||||
format!("the {} broker is not available yet", service.name()),
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
Self::NotGranted(service) => (
|
||||
error_code::REFUSED,
|
||||
format!(
|
||||
"the {} service is not granted to this plugin",
|
||||
service.name()
|
||||
),
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
Self::Denied { service, message } => (
|
||||
error_code::DENIED,
|
||||
message,
|
||||
@@ -118,6 +129,7 @@ impl BrokerRefusal {
|
||||
}
|
||||
|
||||
pub const TICKET_REFUSED: &str = "ticket_refused";
|
||||
pub const LIMIT: &str = "limit";
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
#[must_use]
|
||||
@@ -139,8 +151,6 @@ pub const TABLE: &[(DshService, Arm)] = &[
|
||||
(DshService::Remote, dsh_remote::serve as Arm),
|
||||
];
|
||||
|
||||
const GRANTED: &[DshService] = &[];
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_GRANTS: std::cell::RefCell<Option<Vec<DshService>>> =
|
||||
@@ -165,13 +175,36 @@ impl Drop for TestGrants {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_grants() -> Option<Vec<DshService>> {
|
||||
TEST_GRANTS.with(|grants| grants.borrow().clone())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(super) fn test_grants() -> Option<Vec<DshService>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted(service: DshService) -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(granted) = TEST_GRANTS.with(|grants| grants.borrow().clone()) {
|
||||
return granted.contains(&service);
|
||||
pub fn offered(service: DshService) -> bool {
|
||||
use codewhale_plugins::host_services::HostService;
|
||||
HostService::parse(service.name()).is_some_and(codewhale_plugins::host_services::offered)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService) -> bool {
|
||||
match test_grants() {
|
||||
Some(granted) => granted.contains(&service),
|
||||
None => offered(service) && registry.host_service_granted(owner, service),
|
||||
}
|
||||
GRANTED.contains(&service)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted_services(registry: &OwnerRegistry, owner: &OwnerRef) -> Vec<DshService> {
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|service| granted(registry, owner, *service))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::core_call::InvocationGuard;
|
||||
use super::dsh_broker::{Arm, BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::{DshBrokerParams, DshService, HostRequest, RpcErrorWire, error_code};
|
||||
use super::supervisor::HostRequestContext;
|
||||
use super::tier::HostTier;
|
||||
use super::{ExtensionHostManager, ExtensionHostOptions};
|
||||
use crate::tools::codemode::{ExtensionCaller, NestedCallGate};
|
||||
use crate::tools::spec::ToolContext;
|
||||
use codewhale_config::OwnerRef;
|
||||
use codewhale_plugins::PluginRegistry;
|
||||
use codewhale_plugins::activation::TestPolicyGuard;
|
||||
use codewhale_plugins::discovery::{DiscoveryConfig, discover_with_config};
|
||||
use codewhale_plugins::host_services::{
|
||||
AgentApproval, AgentPosture, HostService, PersonVia, PluginApprover, ReviewApproval, TestOffer,
|
||||
};
|
||||
|
||||
const HOST: u64 = 7;
|
||||
|
||||
struct Plugins {
|
||||
_temp: tempfile::TempDir,
|
||||
config: DiscoveryConfig,
|
||||
}
|
||||
|
||||
impl Plugins {
|
||||
fn new(plugins: &[(&str, &[&str])]) -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let config = DiscoveryConfig {
|
||||
workspace: root.join("project"),
|
||||
user_plugins_dir: root.join("user"),
|
||||
workspace_plugins_dir: root.join("workspace"),
|
||||
builtin_plugin_dirs: Vec::new(),
|
||||
state_path: root.join("state/plugin-state.json"),
|
||||
};
|
||||
std::fs::create_dir_all(&config.workspace).unwrap();
|
||||
for (name, asks) in plugins {
|
||||
let plugin = config.user_plugins_dir.join(name);
|
||||
std::fs::create_dir_all(plugin.join("native")).unwrap();
|
||||
std::fs::write(
|
||||
plugin.join("native/index.mjs"),
|
||||
"export function apply() {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let asked = if asks.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"[capabilities]\nhost_services = [{}]\n",
|
||||
asks.iter()
|
||||
.map(|service| format!("{service:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
std::fs::write(
|
||||
plugin.join("plugin.toml"),
|
||||
format!(
|
||||
"schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n[native]\npath = \"native/index.mjs\"\n{asked}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let plugins_fixture = Self {
|
||||
_temp: temp,
|
||||
config,
|
||||
};
|
||||
for (name, _) in plugins {
|
||||
plugins_fixture.review(name, true);
|
||||
}
|
||||
plugins_fixture
|
||||
}
|
||||
|
||||
fn registry(&self) -> PluginRegistry {
|
||||
discover_with_config(&self.config)
|
||||
}
|
||||
|
||||
fn review(&self, name: &str, showing_services: bool) {
|
||||
let mut registry = self.registry();
|
||||
let shown = registry
|
||||
.host_service_review(name)
|
||||
.filter(|_| showing_services)
|
||||
.map(|review| review.token);
|
||||
registry
|
||||
.trust_reviewed(
|
||||
name,
|
||||
ReviewApproval {
|
||||
approved_by: PluginApprover::person(PersonVia::Terminal),
|
||||
host_services: shown,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
registry.enable(name).unwrap();
|
||||
}
|
||||
|
||||
fn review_by_agent(&self, name: &str) {
|
||||
self.registry().revoke_trust(name).unwrap();
|
||||
let mut registry = self.registry();
|
||||
let approver = PluginApprover::Agent(AgentApproval {
|
||||
posture: AgentPosture::FullAccess,
|
||||
workspace: self.config.workspace.to_string_lossy().into_owned(),
|
||||
thread_id: Some("thread".into()),
|
||||
turn_id: Some("turn".into()),
|
||||
tool_call_id: Some("call".into()),
|
||||
});
|
||||
let shown = registry
|
||||
.host_service_review_for(name, &approver)
|
||||
.map(|review| review.token);
|
||||
registry
|
||||
.trust_reviewed(
|
||||
name,
|
||||
ReviewApproval {
|
||||
approved_by: approver.clone(),
|
||||
host_services: shown,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
registry.enable_by(name, approver).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
struct Host {
|
||||
manager: ExtensionHostManager,
|
||||
context: ToolContext,
|
||||
gate: NestedCallGate,
|
||||
}
|
||||
|
||||
fn host(workspace: &Path) -> Host {
|
||||
let manager = ExtensionHostManager::new(ExtensionHostOptions::default());
|
||||
let (tx_event, mut rx_event) = mpsc::channel(8);
|
||||
tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
|
||||
let (gate, _requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
|
||||
let context = ToolContext::new(workspace)
|
||||
.with_session_objects(crate::rlm::session::SessionObjectSnapshot::new(
|
||||
"grant-session".into(),
|
||||
"fixture-model".into(),
|
||||
workspace.to_path_buf(),
|
||||
None,
|
||||
Arc::new(Vec::new()),
|
||||
))
|
||||
.with_origin_turn_id("grant-turn");
|
||||
Host {
|
||||
manager,
|
||||
context,
|
||||
gate: gate.for_extension(
|
||||
ExtensionCaller {
|
||||
origin: "extension:grant".to_string(),
|
||||
tool: "grant_tool".to_string(),
|
||||
scope: "ext:grant@hash".to_string(),
|
||||
},
|
||||
Vec::new(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn load(&self, plugins: &Plugins, name: &str) -> OwnerRef {
|
||||
let authority = plugins.registry().authority_for(name).unwrap();
|
||||
let services = codewhale_plugins::registry::granted_host_services(&authority)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|service| DshService::parse(service.name()))
|
||||
.collect();
|
||||
let mut registry = self.manager.shared.registry.lock().unwrap();
|
||||
let owner = registry
|
||||
.begin_owner(
|
||||
HostTier::Plugin,
|
||||
authority.plugin_id.as_str(),
|
||||
name,
|
||||
Some(authority.clone()),
|
||||
&authority.content_hash,
|
||||
)
|
||||
.unwrap();
|
||||
registry.set_host_services(&owner, services);
|
||||
registry.mark_active(&owner);
|
||||
owner
|
||||
}
|
||||
|
||||
fn mint(&self, tier: HostTier, owner: &OwnerRef) -> InvocationGuard {
|
||||
self.manager
|
||||
.shared
|
||||
.core_calls
|
||||
.begin_scoped(
|
||||
tier,
|
||||
HOST,
|
||||
owner,
|
||||
"call-1",
|
||||
self.gate.extension().unwrap().0,
|
||||
&self.context,
|
||||
&self.gate,
|
||||
None,
|
||||
String::new(),
|
||||
&self.manager.shared.broker_services(owner),
|
||||
)
|
||||
.expect("an invocation")
|
||||
}
|
||||
|
||||
fn services(&self, owner: &OwnerRef) -> Vec<&'static str> {
|
||||
self.mint(HostTier::Plugin, owner)
|
||||
.broker_tickets()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|wire| DshService::parse(&wire.service).map(DshService::name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn call(
|
||||
&self,
|
||||
table: &[(DshService, Arm)],
|
||||
owner: &OwnerRef,
|
||||
ticket: &str,
|
||||
service: &str,
|
||||
) -> (Result<Value, RpcErrorWire>, Vec<String>) {
|
||||
let (cx, mut violations, _cancel) = HostRequestContext::for_test(1);
|
||||
let result = self
|
||||
.manager
|
||||
.shared
|
||||
.core_calls
|
||||
.serve_broker_in(
|
||||
table,
|
||||
&self.manager.shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
DshBrokerParams {
|
||||
owner: owner.clone(),
|
||||
ticket: ticket.to_string(),
|
||||
service: service.to_string(),
|
||||
method: "echo".to_string(),
|
||||
input: json!({ "text": "ok", "granted": ["llm"], "host_services": ["llm"] }),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
let mut seen = Vec::new();
|
||||
while let Ok(violation) = violations.try_recv() {
|
||||
seen.push(violation);
|
||||
}
|
||||
(result, seen)
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket(guard: &InvocationGuard, service: &str) -> String {
|
||||
guard
|
||||
.broker_tickets()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|wire| wire.service == service)
|
||||
.map(|wire| wire.ticket)
|
||||
.unwrap_or_else(|| panic!("no {service} ticket"))
|
||||
}
|
||||
|
||||
fn refusal(result: &Result<Value, RpcErrorWire>) -> (i64, String, String) {
|
||||
let error = result.as_ref().expect_err("a refusal");
|
||||
(
|
||||
error.code,
|
||||
error.data.as_ref().unwrap()["dsh_refusal"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
error.message.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn not_granted(service: &str) -> (i64, String, String) {
|
||||
(
|
||||
error_code::REFUSED,
|
||||
"not_granted".to_string(),
|
||||
format!("the {service} service is not granted to this plugin"),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "method", content = "input")]
|
||||
enum Echo {
|
||||
#[serde(rename = "echo")]
|
||||
Echo { text: String },
|
||||
}
|
||||
|
||||
impl BrokerMethods for Echo {
|
||||
const NAMES: &'static [&'static str] = &["echo"];
|
||||
}
|
||||
|
||||
fn echo_arm(call: BrokerCall) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let Echo::Echo { text } = decode::<Echo>(&call)?;
|
||||
Ok(json!({ "plugin": call.owner.plugin_id, "service": call.service.name(), "text": text }))
|
||||
})
|
||||
}
|
||||
|
||||
fn echo() -> Vec<(DshService, Arm)> {
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.map(|service| (service, echo_arm as Arm))
|
||||
.collect()
|
||||
}
|
||||
|
||||
static WAITING: AtomicBool = AtomicBool::new(false);
|
||||
static WITHDRAWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn waiting_arm(call: BrokerCall) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let cancel = call.context.cancel().clone();
|
||||
tokio::spawn(async move {
|
||||
cancel.cancelled().await;
|
||||
WITHDRAWN.store(true, Ordering::SeqCst);
|
||||
});
|
||||
WAITING.store(true, Ordering::SeqCst);
|
||||
std::future::pending::<()>().await;
|
||||
Err(BrokerRefusal::NotAvailable(call.service))
|
||||
})
|
||||
}
|
||||
|
||||
async fn until(what: &str, done: impl Fn() -> bool) {
|
||||
for _ in 0..400 {
|
||||
if done() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("timed out waiting for {what}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_engine_and_the_review_record_name_the_same_services() {
|
||||
assert_eq!(
|
||||
DshService::ALL.map(DshService::name),
|
||||
HostService::ALL.map(HostService::name)
|
||||
);
|
||||
for service in DshService::ALL {
|
||||
assert!(!super::dsh_broker::offered(service));
|
||||
}
|
||||
let _offer = TestOffer::only(&[HostService::Remote]);
|
||||
assert!(super::dsh_broker::offered(DshService::Remote));
|
||||
assert!(!super::dsh_broker::offered(DshService::Network));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_approved_owner_never_loads_into_the_reviewed_host() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("reviewed", &["network"]), ("made", &["network"])]);
|
||||
let (desired, errors) = super::desired_owners(&plugins.registry());
|
||||
assert_eq!(desired.len(), 2, "{errors:?}");
|
||||
|
||||
plugins.review_by_agent("made");
|
||||
let registry = plugins.registry();
|
||||
assert!(registry.get("made").unwrap().active());
|
||||
let (desired, errors) = super::desired_owners(®istry);
|
||||
let names: Vec<&str> = desired
|
||||
.values()
|
||||
.map(|owner| owner.plugin_name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["reviewed"]);
|
||||
assert_eq!(
|
||||
desired.values().next().unwrap().host_services,
|
||||
vec![DshService::Network]
|
||||
);
|
||||
assert_eq!(
|
||||
errors
|
||||
.iter()
|
||||
.filter(|error| error.contains("`made` was approved by the agent"))
|
||||
.count(),
|
||||
1,
|
||||
"{errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_that_cannot_be_read_at_the_scan_loads_no_owner() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let plugins = Plugins::new(&[("alpha", &[])]);
|
||||
let registry = plugins.registry();
|
||||
let (sources, errors) = codewhale_plugins::runtime::active_component_sources(
|
||||
®istry,
|
||||
codewhale_plugins::activation::PluginActivationCapability::Native,
|
||||
);
|
||||
assert_eq!(sources.len(), 1, "{errors:?}");
|
||||
plugins.registry().disable("alpha").unwrap();
|
||||
let (desired, errors) = super::desired_owners_from(®istry, sources, errors);
|
||||
assert!(desired.is_empty(), "{:?}", desired.keys());
|
||||
assert_eq!(
|
||||
errors
|
||||
.iter()
|
||||
.filter(|error| error
|
||||
.contains("`alpha` was not loaded because its review record could not be read"))
|
||||
.count(),
|
||||
1,
|
||||
"{errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_broker_ticket_is_minted_for_a_plugin_whose_reviewed_record_does_not_name_the_service() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
|
||||
let granted = Plugins::new(&[("alpha", &["network"])]);
|
||||
let host_a = host(&granted.config.workspace);
|
||||
let alpha = host_a.load(&granted, "alpha");
|
||||
assert_eq!(host_a.services(&alpha), vec!["network"]);
|
||||
let guard = host_a.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
let (served, _) = host_a.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(served.unwrap()["service"], "network");
|
||||
let (wrong, _) = host_a.call(&echo(), &alpha, &network, "llm").await;
|
||||
assert_eq!(refusal(&wrong).1, "ticket_refused");
|
||||
|
||||
let ungranted = Plugins::new(&[("beta", &[])]);
|
||||
let host_b = host(&ungranted.config.workspace);
|
||||
let beta = host_b.load(&ungranted, "beta");
|
||||
assert!(host_b.services(&beta).is_empty());
|
||||
let forged = OwnerRef {
|
||||
plugin_id: beta.plugin_id.clone(),
|
||||
..alpha.clone()
|
||||
};
|
||||
let (borrowed, _) = host_b.call(&echo(), &forged, &network, "network").await;
|
||||
assert_eq!(refusal(&borrowed).1, "ticket_refused");
|
||||
|
||||
let unshown = Plugins::new(&[("gamma", &["network"])]);
|
||||
unshown.review("gamma", false);
|
||||
let host_c = host(&unshown.config.workspace);
|
||||
let gamma = host_c.load(&unshown, "gamma");
|
||||
assert!(
|
||||
host_c.services(&gamma).is_empty(),
|
||||
"a review that did not show the service grants none"
|
||||
);
|
||||
|
||||
drop(offer);
|
||||
assert!(
|
||||
host_a.services(&alpha).is_empty(),
|
||||
"a build that offers no service mints no broker ticket, whatever is recorded"
|
||||
);
|
||||
let (unreachable, _) = host_a.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&unreachable), not_granted("network"));
|
||||
for service in DshService::ALL {
|
||||
let (stub, _) = host_a
|
||||
.call(super::dsh_broker::TABLE, &alpha, &network, service.name())
|
||||
.await;
|
||||
assert!(stub.is_err(), "{}", service.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_broker_ticket_while_a_plugin_without_the_grant_shares_the_host() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
|
||||
let plugins = Plugins::new(&[
|
||||
("alpha", &["network", "llm"]),
|
||||
("beta", &[]),
|
||||
("gamma", &["network"]),
|
||||
]);
|
||||
plugins.review("alpha", true);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
assert_eq!(host.services(&alpha), vec!["llm", "network"]);
|
||||
let alone = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&alone, "network");
|
||||
let llm = ticket(&alone, "llm");
|
||||
|
||||
let gamma = host.load(&plugins, "gamma");
|
||||
assert_eq!(host.services(&alpha), vec!["network"]);
|
||||
assert_eq!(host.services(&gamma), vec!["network"]);
|
||||
let (shared, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(shared.unwrap()["plugin"], alpha.plugin_id);
|
||||
let (unshared, _) = host.call(&echo(), &alpha, &llm, "llm").await;
|
||||
assert_eq!(refusal(&unshared), not_granted("llm"));
|
||||
let report = host
|
||||
.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take_withheld_report()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
report,
|
||||
"host service `llm` is off: the review of `gamma` does not include it"
|
||||
);
|
||||
assert!(
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take_withheld_report()
|
||||
.is_none(),
|
||||
"reported once"
|
||||
);
|
||||
|
||||
let beta = {
|
||||
let authority = plugins.registry().authority_for("beta").unwrap();
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.begin_owner(
|
||||
HostTier::Plugin,
|
||||
authority.plugin_id.as_str(),
|
||||
"beta",
|
||||
Some(authority.clone()),
|
||||
&authority.content_hash,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(
|
||||
host.services(&alpha).is_empty(),
|
||||
"an activating plugin without the grant already withholds it"
|
||||
);
|
||||
assert!(host.services(&gamma).is_empty());
|
||||
assert!(host.services(&beta).is_empty());
|
||||
let (withheld, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
let own = Plugins::new(&[("delta", &[])]);
|
||||
let other_host = self::host(&own.config.workspace);
|
||||
let delta = other_host.load(&own, "delta");
|
||||
let lacking = other_host.mint(HostTier::Plugin, &delta);
|
||||
assert!(lacking.broker_tickets().is_none());
|
||||
assert_eq!(
|
||||
refusal(&withheld),
|
||||
not_granted("network"),
|
||||
"the refusal is the same whoever lacks the grant, and names nobody"
|
||||
);
|
||||
assert!(!refusal(&withheld).2.contains("beta"));
|
||||
let report = host
|
||||
.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take_withheld_report()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
report,
|
||||
"host service `llm` is off: the reviews of `beta`, `gamma` do not include it; host service `network` is off: the review of `beta` does not include it"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_plugin_that_left_the_host_withholds_every_service_until_the_host_exits() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"]), ("gamma", &["network"])]);
|
||||
plugins.review("alpha", true);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let gamma = host.load(&plugins, "gamma");
|
||||
assert_eq!(host.services(&alpha), vec!["network"]);
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
assert!(
|
||||
!host
|
||||
.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.host_services_await_restart()
|
||||
);
|
||||
|
||||
{
|
||||
let mut registry = host.manager.shared.registry.lock().unwrap();
|
||||
assert_eq!(registry.revoke_owner(&gamma.plugin_id), Some(gamma.clone()));
|
||||
registry.forget_owner(&gamma.plugin_id);
|
||||
assert!(registry.host_services_await_restart());
|
||||
assert_eq!(
|
||||
registry.take_withheld_report().unwrap(),
|
||||
"host services are off until the plugin host restarts: a plugin that was turned off, changed or failed is still loaded in it"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
host.services(&alpha).is_empty(),
|
||||
"code that was loaded cannot be unloaded: nothing is given until the process is gone"
|
||||
);
|
||||
let (lingering, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&lingering), not_granted("network"));
|
||||
|
||||
let again = host.load(&plugins, "gamma");
|
||||
assert!(host.services(&alpha).is_empty());
|
||||
assert!(host.services(&again).is_empty());
|
||||
|
||||
{
|
||||
let mut registry = host.manager.shared.registry.lock().unwrap();
|
||||
registry.host_exited(HostTier::Plugin, "planned restart");
|
||||
assert!(!registry.host_services_await_restart());
|
||||
}
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
assert_eq!(host.services(&alpha), vec!["network"]);
|
||||
|
||||
let failing = host.load(&plugins, "gamma");
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.mark_failed(&failing, super::registry::OwnerState::Failed("boom".into()));
|
||||
assert!(host.services(&alpha).is_empty());
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.host_process_started(HostTier::Plugin);
|
||||
assert_eq!(host.services(&alpha), vec!["network"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_grant_is_read_from_the_owner_record_at_mint_and_at_redeem_never_from_the_wire() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"])]);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
let (served, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert!(served.is_ok(), "{served:?}");
|
||||
let (claimed, _) = host.call(&echo(), &alpha, &network, "llm").await;
|
||||
assert_eq!(
|
||||
refusal(&claimed).1,
|
||||
"ticket_refused",
|
||||
"input that claims a grant grants nothing"
|
||||
);
|
||||
|
||||
plugins.review("alpha", false);
|
||||
let (reviewed_again, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(
|
||||
refusal(&reviewed_again),
|
||||
not_granted("network"),
|
||||
"a new review that drops the service refuses the next call of the running tool"
|
||||
);
|
||||
assert_eq!(
|
||||
host.services(&alpha),
|
||||
vec!["network"],
|
||||
"the owner record is replaced by reconcile; until then redeem reads the review record"
|
||||
);
|
||||
|
||||
plugins.review("alpha", true);
|
||||
let (stale, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(
|
||||
refusal(&stale),
|
||||
not_granted("network"),
|
||||
"an owner from before the review holds nothing, whatever the new review grants"
|
||||
);
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.host_exited(HostTier::Plugin, "restart");
|
||||
let current = host.load(&plugins, "alpha");
|
||||
let guard = host.mint(HostTier::Plugin, ¤t);
|
||||
let (fresh, _) = host
|
||||
.call(&echo(), ¤t, &ticket(&guard, "network"), "network")
|
||||
.await;
|
||||
assert!(fresh.is_ok(), "{fresh:?}");
|
||||
|
||||
std::fs::write(&plugins.config.state_path, b"{ not json").unwrap();
|
||||
let (unreadable, _) = host
|
||||
.call(&echo(), ¤t, &ticket(&guard, "network"), "network")
|
||||
.await;
|
||||
assert_eq!(refusal(&unreadable), not_granted("network"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_disable_from_another_process_stops_broker_calls_of_a_running_tool() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"]), ("gamma", &["network"])]);
|
||||
plugins.review("alpha", true);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let _gamma = host.load(&plugins, "gamma");
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
let waiting: Vec<(DshService, Arm)> = vec![(DshService::Network, waiting_arm as Arm)];
|
||||
|
||||
let in_flight = host.call(&waiting, &alpha, &network, "network");
|
||||
let elsewhere = async {
|
||||
until("the arm to start", || WAITING.load(Ordering::SeqCst)).await;
|
||||
plugins.registry().disable("gamma").unwrap();
|
||||
};
|
||||
let ((stopped, violations), ()) = tokio::join!(in_flight, elsewhere);
|
||||
assert_eq!(
|
||||
refusal(&stopped),
|
||||
not_granted("network"),
|
||||
"a co-hosted plugin turned off elsewhere withdraws the call in flight"
|
||||
);
|
||||
assert!(violations.is_empty());
|
||||
until("the arm's cancellation", || {
|
||||
WITHDRAWN.load(Ordering::SeqCst)
|
||||
})
|
||||
.await;
|
||||
|
||||
let (next, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&next), not_granted("network"));
|
||||
assert_eq!(
|
||||
host.services(&alpha),
|
||||
vec!["network"],
|
||||
"this process has not reconciled yet; the review record already refuses"
|
||||
);
|
||||
|
||||
plugins.registry().enable("gamma").unwrap();
|
||||
let (spent, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(
|
||||
refusal(&spent),
|
||||
not_granted("network"),
|
||||
"the loaded owner is from before the plugin was turned off"
|
||||
);
|
||||
plugins.registry().disable("alpha").unwrap();
|
||||
let (own, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&own), not_granted("network"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_builtin_tier_owner_never_gets_a_broker_ticket() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&HostService::ALL);
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let host = host(workspace.path());
|
||||
let builtin = {
|
||||
let mut registry = host.manager.shared.registry.lock().unwrap();
|
||||
let owner = registry
|
||||
.begin_owner(HostTier::Builtin, "host:mcp", "mcp", None, "digest")
|
||||
.unwrap();
|
||||
registry.set_host_services(&owner, DshService::ALL.to_vec());
|
||||
registry.mark_active(&owner);
|
||||
assert!(
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.all(|service| !registry.host_service_granted(&owner, service))
|
||||
);
|
||||
owner
|
||||
};
|
||||
assert!(host.manager.shared.broker_services(&builtin).is_empty());
|
||||
assert!(
|
||||
host.mint(HostTier::Builtin, &builtin)
|
||||
.broker_tickets()
|
||||
.is_none()
|
||||
);
|
||||
let _everything = super::dsh_broker::TestGrants::only(&DshService::ALL);
|
||||
let forced = host
|
||||
.manager
|
||||
.shared
|
||||
.core_calls
|
||||
.begin(
|
||||
HostTier::Builtin,
|
||||
HOST,
|
||||
&builtin,
|
||||
"call-2",
|
||||
host.gate.extension().unwrap().0,
|
||||
&host.context,
|
||||
&host.gate,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
forced.broker_tickets().is_none(),
|
||||
"no override mints a broker ticket for the built-in tier"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_broker_ticket_is_refused_by_core_call_checkpoint_question_and_subagent() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"])]);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
let shared = &host.manager.shared;
|
||||
let cx = |id| HostRequestContext::for_test(id).0;
|
||||
let refused = |result: Result<Value, RpcErrorWire>, what: &str| {
|
||||
let error = result.expect_err(what);
|
||||
assert_eq!(error.code, error_code::REFUSED, "{what}: {error:?}");
|
||||
assert_eq!(
|
||||
error.message, "the ticket is not valid for this request",
|
||||
"{what}"
|
||||
);
|
||||
};
|
||||
|
||||
refused(
|
||||
shared
|
||||
.core_calls
|
||||
.serve(
|
||||
shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
super::protocol::CoreCallParams {
|
||||
owner: alpha.clone(),
|
||||
ticket: network.clone(),
|
||||
name: "read_file".into(),
|
||||
input: json!({ "path": "x" }),
|
||||
},
|
||||
cx(1),
|
||||
)
|
||||
.await,
|
||||
"core/call",
|
||||
);
|
||||
refused(
|
||||
shared
|
||||
.core_calls
|
||||
.serve_checkpoint(
|
||||
shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
super::protocol::SessionCheckpointParams {
|
||||
owner: alpha.clone(),
|
||||
ticket: network.clone(),
|
||||
},
|
||||
cx(2),
|
||||
)
|
||||
.await,
|
||||
"session/checkpoint",
|
||||
);
|
||||
refused(
|
||||
shared
|
||||
.core_calls
|
||||
.serve_question(
|
||||
shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
super::protocol::UserQuestionParams {
|
||||
owner: alpha.clone(),
|
||||
ticket: network.clone(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
cx(3),
|
||||
)
|
||||
.await,
|
||||
"user-questions/ask",
|
||||
);
|
||||
refused(
|
||||
shared
|
||||
.core_calls
|
||||
.serve_subagent(
|
||||
shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
HostRequest::SubagentStart(super::protocol::SubagentStartParams {
|
||||
owner: alpha.clone(),
|
||||
ticket: network.clone(),
|
||||
child_id: "child".into(),
|
||||
prompt: "p".into(),
|
||||
model: None,
|
||||
persona: None,
|
||||
denied_tools: Vec::new(),
|
||||
delivery: "caller".into(),
|
||||
}),
|
||||
cx(4),
|
||||
)
|
||||
.await,
|
||||
"subagent/start",
|
||||
);
|
||||
let (still_good, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert!(still_good.is_ok(), "{still_good:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_spent_or_just_ended_broker_ticket_is_a_limit_not_a_forgery() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"])]);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
for _ in 0..super::core_call::MAX_CALLS_PER_INVOCATION {
|
||||
let (served, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert!(served.is_ok(), "{served:?}");
|
||||
}
|
||||
let (spent, violations) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(
|
||||
refusal(&spent),
|
||||
(
|
||||
error_code::REFUSED,
|
||||
"limit".to_string(),
|
||||
"this tool call's limit for the network service is used up".to_string()
|
||||
)
|
||||
);
|
||||
assert!(violations.is_empty());
|
||||
|
||||
drop(guard);
|
||||
let mut ended = Vec::new();
|
||||
for _ in 0..(super::ticket::INVALID_BURST * 3) {
|
||||
let (late, violations) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&late).1, "ticket_refused");
|
||||
ended.extend(violations);
|
||||
}
|
||||
assert!(
|
||||
ended.is_empty(),
|
||||
"frames already in the pipe when a tool call ends do not end the host: {ended:?}"
|
||||
);
|
||||
for _ in 0..super::ticket::INVALID_BURST {
|
||||
let (_, violations) = host.call(&echo(), &alpha, "cwt.guess", "network").await;
|
||||
ended.extend(violations);
|
||||
}
|
||||
assert_eq!(ended.len(), 1, "a guessed ticket still does: {ended:?}");
|
||||
}
|
||||
@@ -1,6 +1,569 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::DshService;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Remote)
|
||||
pub const PAIRING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
pub const CLIENT_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
pub const MAX_PENDING_PAIRINGS: usize = 4;
|
||||
const MAX_LABEL_CHARS: usize = 64;
|
||||
const GRANT_PREFIX: &str = "cwrg_";
|
||||
const PAIRING_PREFIX: &str = "cwrp_";
|
||||
const CLIENT_PREFIX: &str = "cwrc_";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(
|
||||
tag = "method",
|
||||
content = "input",
|
||||
rename_all = "snake_case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
enum RemoteMethod {
|
||||
Status {},
|
||||
Pair {
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
},
|
||||
Clients {},
|
||||
Revoke {
|
||||
grant: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BrokerMethods for RemoteMethod {
|
||||
const NAMES: &'static [&'static str] = &["status", "pair", "clients", "revoke"];
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePairing {
|
||||
pub grant: String,
|
||||
pub plugin_id: String,
|
||||
pub session_id: String,
|
||||
pub label: String,
|
||||
pub secret: String,
|
||||
pub expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteClient {
|
||||
pub grant: String,
|
||||
pub session_id: String,
|
||||
pub token: String,
|
||||
pub expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteDenial {
|
||||
Unknown,
|
||||
Expired,
|
||||
Replayed,
|
||||
ForeignSession,
|
||||
}
|
||||
|
||||
struct Client {
|
||||
token_digest: [u8; 32],
|
||||
label: String,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
struct Grant {
|
||||
plugin_id: String,
|
||||
session_id: String,
|
||||
label: String,
|
||||
pairing_digest: [u8; 32],
|
||||
pairing: Option<String>,
|
||||
pairing_expires_at: Instant,
|
||||
client: Option<Client>,
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
fn state(&self, now: Instant) -> &'static str {
|
||||
match &self.client {
|
||||
Some(client) if now < client.expires_at => "paired",
|
||||
Some(_) => "expired",
|
||||
None if now < self.pairing_expires_at => "awaiting_host",
|
||||
None => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
fn retire_at(&self, pairing_ttl: Duration) -> Instant {
|
||||
self.client
|
||||
.as_ref()
|
||||
.map_or(self.pairing_expires_at, |client| client.expires_at)
|
||||
+ pairing_ttl
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Grants {
|
||||
by_id: HashMap<String, Grant>,
|
||||
by_pairing: HashMap<[u8; 32], String>,
|
||||
by_client: HashMap<[u8; 32], String>,
|
||||
}
|
||||
|
||||
impl Grants {
|
||||
fn remove(&mut self, id: &str) -> Option<Grant> {
|
||||
let grant = self.by_id.remove(id)?;
|
||||
self.by_pairing.remove(&grant.pairing_digest);
|
||||
if let Some(client) = &grant.client {
|
||||
self.by_client.remove(&client.token_digest);
|
||||
}
|
||||
Some(grant)
|
||||
}
|
||||
|
||||
fn prune(&mut self, now: Instant, pairing_ttl: Duration) {
|
||||
let retired: Vec<String> = self
|
||||
.by_id
|
||||
.iter()
|
||||
.filter(|(_, grant)| now >= grant.retire_at(pairing_ttl))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in retired {
|
||||
self.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RemoteGrants {
|
||||
pairing_ttl: Duration,
|
||||
client_ttl: Duration,
|
||||
grants: Mutex<Grants>,
|
||||
}
|
||||
|
||||
pub fn remote_grants() -> &'static RemoteGrants {
|
||||
static GRANTS: OnceLock<RemoteGrants> = OnceLock::new();
|
||||
GRANTS.get_or_init(|| RemoteGrants::new(PAIRING_TTL, CLIENT_TTL))
|
||||
}
|
||||
|
||||
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
Box::pin(std::future::ready(remote_grants().serve(&call)))
|
||||
}
|
||||
|
||||
impl RemoteGrants {
|
||||
#[must_use]
|
||||
pub fn new(pairing_ttl: Duration, client_ttl: Duration) -> Self {
|
||||
Self {
|
||||
pairing_ttl,
|
||||
client_ttl,
|
||||
grants: Mutex::new(Grants::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Grants> {
|
||||
let mut grants = self
|
||||
.grants
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
grants.prune(Instant::now(), self.pairing_ttl);
|
||||
grants
|
||||
}
|
||||
|
||||
fn serve(&self, call: &BrokerCall) -> Result<Value, BrokerRefusal> {
|
||||
let plugin = call.owner.plugin_id.as_str();
|
||||
let session = call.session_id.as_str();
|
||||
match decode::<RemoteMethod>(call)? {
|
||||
RemoteMethod::Status {} => Ok(self.status(plugin, session)),
|
||||
RemoteMethod::Pair { label } => self.pair(plugin, session, label.as_deref()),
|
||||
RemoteMethod::Clients {} => Ok(self.clients(plugin, session)),
|
||||
RemoteMethod::Revoke { grant } => self.revoke(plugin, session, &grant),
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self, plugin: &str, session: &str) -> Value {
|
||||
let grants = self.lock();
|
||||
let now = Instant::now();
|
||||
let mut awaiting = 0;
|
||||
let mut paired = 0;
|
||||
for grant in grants.by_id.values() {
|
||||
if grant.plugin_id != plugin || grant.session_id != session {
|
||||
continue;
|
||||
}
|
||||
match grant.state(now) {
|
||||
"awaiting_host" => awaiting += 1,
|
||||
"paired" => paired += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"transport": "runtime_http_sse",
|
||||
"pairing": "host_presented",
|
||||
"public_listener": false,
|
||||
"tunnel": false,
|
||||
"awaiting_host": awaiting,
|
||||
"paired": paired,
|
||||
"max_pending": MAX_PENDING_PAIRINGS,
|
||||
})
|
||||
}
|
||||
|
||||
fn pair(
|
||||
&self,
|
||||
plugin: &str,
|
||||
session: &str,
|
||||
label: Option<&str>,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let label = clean_label(label).map_err(|message| BrokerRefusal::InvalidInput {
|
||||
service: DshService::Remote,
|
||||
message,
|
||||
})?;
|
||||
let mut grants = self.lock();
|
||||
let now = Instant::now();
|
||||
let pending = grants
|
||||
.by_id
|
||||
.values()
|
||||
.filter(|grant| {
|
||||
grant.plugin_id == plugin
|
||||
&& grant.session_id == session
|
||||
&& grant.state(now) == "awaiting_host"
|
||||
})
|
||||
.count();
|
||||
if pending >= MAX_PENDING_PAIRINGS {
|
||||
return Err(BrokerRefusal::InvalidInput {
|
||||
service: DshService::Remote,
|
||||
message: format!(
|
||||
"this plugin already has {MAX_PENDING_PAIRINGS} pairings awaiting the host in this session; revoke one first"
|
||||
),
|
||||
});
|
||||
}
|
||||
let id = random_capability(GRANT_PREFIX);
|
||||
let secret = random_capability(PAIRING_PREFIX);
|
||||
let pairing_digest = digest(&secret);
|
||||
grants.by_pairing.insert(pairing_digest, id.clone());
|
||||
grants.by_id.insert(
|
||||
id.clone(),
|
||||
Grant {
|
||||
plugin_id: plugin.to_string(),
|
||||
session_id: session.to_string(),
|
||||
label: label.clone(),
|
||||
pairing_digest,
|
||||
pairing: Some(secret),
|
||||
pairing_expires_at: now + self.pairing_ttl,
|
||||
client: None,
|
||||
},
|
||||
);
|
||||
Ok(json!({
|
||||
"grant": id,
|
||||
"label": label,
|
||||
"state": "awaiting_host",
|
||||
"expires_in_seconds": self.pairing_ttl.as_secs(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn clients(&self, plugin: &str, session: &str) -> Value {
|
||||
let grants = self.lock();
|
||||
let now = Instant::now();
|
||||
let mut rows: Vec<Value> = grants
|
||||
.by_id
|
||||
.iter()
|
||||
.filter(|(_, grant)| grant.plugin_id == plugin && grant.session_id == session)
|
||||
.map(|(id, grant)| {
|
||||
let expires_at = grant
|
||||
.client
|
||||
.as_ref()
|
||||
.map_or(grant.pairing_expires_at, |client| client.expires_at);
|
||||
json!({
|
||||
"grant": id,
|
||||
"label": grant.label,
|
||||
"client": grant.client.as_ref().map(|client| client.label.clone()),
|
||||
"state": grant.state(now),
|
||||
"expires_in_seconds": expires_at.saturating_duration_since(now).as_secs(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a["grant"].as_str().cmp(&b["grant"].as_str()));
|
||||
json!({ "clients": rows })
|
||||
}
|
||||
|
||||
fn revoke(&self, plugin: &str, session: &str, grant: &str) -> Result<Value, BrokerRefusal> {
|
||||
let mut grants = self.lock();
|
||||
let owned = grants
|
||||
.by_id
|
||||
.get(grant)
|
||||
.is_some_and(|row| row.plugin_id == plugin && row.session_id == session);
|
||||
if !owned {
|
||||
return Err(BrokerRefusal::InvalidInput {
|
||||
service: DshService::Remote,
|
||||
message: "no such remote grant for this plugin and session".into(),
|
||||
});
|
||||
}
|
||||
grants.remove(grant);
|
||||
Ok(json!({ "grant": grant, "revoked": true }))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn awaiting_host(&self) -> Vec<RemotePairing> {
|
||||
let grants = self.lock();
|
||||
let now = Instant::now();
|
||||
let mut rows: Vec<RemotePairing> = grants
|
||||
.by_id
|
||||
.iter()
|
||||
.filter(|(_, grant)| grant.state(now) == "awaiting_host")
|
||||
.filter_map(|(id, grant)| {
|
||||
Some(RemotePairing {
|
||||
grant: id.clone(),
|
||||
plugin_id: grant.plugin_id.clone(),
|
||||
session_id: grant.session_id.clone(),
|
||||
label: grant.label.clone(),
|
||||
secret: grant.pairing.clone()?,
|
||||
expires_in_seconds: grant
|
||||
.pairing_expires_at
|
||||
.saturating_duration_since(now)
|
||||
.as_secs(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.grant.cmp(&b.grant));
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn redeem(&self, secret: &str, client_label: &str) -> Result<RemoteClient, RemoteDenial> {
|
||||
let mut grants = self.lock();
|
||||
let now = Instant::now();
|
||||
let id = grants
|
||||
.by_pairing
|
||||
.get(&digest(secret))
|
||||
.cloned()
|
||||
.ok_or(RemoteDenial::Unknown)?;
|
||||
let client_ttl = self.client_ttl;
|
||||
let grant = grants.by_id.get_mut(&id).ok_or(RemoteDenial::Unknown)?;
|
||||
if grant.client.is_some() {
|
||||
return Err(RemoteDenial::Replayed);
|
||||
}
|
||||
if now >= grant.pairing_expires_at {
|
||||
return Err(RemoteDenial::Expired);
|
||||
}
|
||||
let token = random_capability(CLIENT_PREFIX);
|
||||
let token_digest = digest(&token);
|
||||
grant.pairing = None;
|
||||
grant.client = Some(Client {
|
||||
token_digest,
|
||||
label: clean_label(Some(client_label)).unwrap_or_default(),
|
||||
expires_at: now + client_ttl,
|
||||
});
|
||||
let session_id = grant.session_id.clone();
|
||||
grants.by_client.insert(token_digest, id.clone());
|
||||
Ok(RemoteClient {
|
||||
grant: id,
|
||||
session_id,
|
||||
token,
|
||||
expires_in_seconds: client_ttl.as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn authorize(&self, token: &str, session_id: &str) -> Result<String, RemoteDenial> {
|
||||
let grants = self.lock();
|
||||
let id = grants
|
||||
.by_client
|
||||
.get(&digest(token))
|
||||
.ok_or(RemoteDenial::Unknown)?;
|
||||
let grant = grants.by_id.get(id).ok_or(RemoteDenial::Unknown)?;
|
||||
let client = grant.client.as_ref().ok_or(RemoteDenial::Unknown)?;
|
||||
if Instant::now() >= client.expires_at {
|
||||
return Err(RemoteDenial::Expired);
|
||||
}
|
||||
if grant.session_id != session_id {
|
||||
return Err(RemoteDenial::ForeignSession);
|
||||
}
|
||||
Ok(id.clone())
|
||||
}
|
||||
|
||||
pub fn revoke_session(&self, session_id: &str) -> usize {
|
||||
let mut grants = self.lock();
|
||||
let ids: Vec<String> = grants
|
||||
.by_id
|
||||
.iter()
|
||||
.filter(|(_, grant)| grant.session_id == session_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in &ids {
|
||||
grants.remove(id);
|
||||
}
|
||||
ids.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_label(label: Option<&str>) -> Result<String, String> {
|
||||
let label = label.unwrap_or("").trim();
|
||||
if label.chars().any(char::is_control) {
|
||||
return Err("a remote label may not contain control characters".into());
|
||||
}
|
||||
if label.chars().count() > MAX_LABEL_CHARS {
|
||||
return Err(format!(
|
||||
"a remote label is at most {MAX_LABEL_CHARS} characters"
|
||||
));
|
||||
}
|
||||
Ok(label.to_string())
|
||||
}
|
||||
|
||||
fn digest(secret: &str) -> [u8; 32] {
|
||||
Sha256::digest(secret.as_bytes()).into()
|
||||
}
|
||||
|
||||
fn random_capability(prefix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}{}{}",
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_config::OwnerRef;
|
||||
|
||||
fn call(plugin: &str, session: &str, method: &str, input: Value) -> BrokerCall {
|
||||
let owner = OwnerRef {
|
||||
plugin_id: plugin.into(),
|
||||
generation: 1,
|
||||
owner_token: "owner".into(),
|
||||
};
|
||||
BrokerCall {
|
||||
context: super::super::dsh_broker::BrokerContext::detached_for_test(
|
||||
&owner,
|
||||
DshService::Remote,
|
||||
),
|
||||
owner,
|
||||
session_id: session.into(),
|
||||
service: DshService::Remote,
|
||||
method: method.into(),
|
||||
input,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_grants_pair_present_redeem_once_and_revoke_bound_to_plugin_and_session() {
|
||||
let grants = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
|
||||
let paired = grants
|
||||
.serve(&call("a", "s1", "pair", json!({"label": "phone"})))
|
||||
.unwrap();
|
||||
let id = paired["grant"].as_str().unwrap().to_string();
|
||||
assert_eq!(paired["state"], "awaiting_host");
|
||||
assert!(!paired.to_string().contains(PAIRING_PREFIX));
|
||||
|
||||
let shown = grants.awaiting_host();
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert_eq!(
|
||||
(shown[0].plugin_id.as_str(), shown[0].session_id.as_str()),
|
||||
("a", "s1")
|
||||
);
|
||||
let client = grants.redeem(&shown[0].secret, "pixel").unwrap();
|
||||
assert_eq!(client.session_id, "s1");
|
||||
assert_eq!(
|
||||
grants.redeem(&shown[0].secret, "pixel"),
|
||||
Err(RemoteDenial::Replayed)
|
||||
);
|
||||
assert!(grants.awaiting_host().is_empty());
|
||||
assert_eq!(grants.authorize(&client.token, "s1"), Ok(id.clone()));
|
||||
assert_eq!(
|
||||
grants.authorize(&client.token, "s2"),
|
||||
Err(RemoteDenial::ForeignSession)
|
||||
);
|
||||
|
||||
let listed = grants
|
||||
.serve(&call("a", "s1", "clients", json!({})))
|
||||
.unwrap();
|
||||
assert_eq!(listed["clients"][0]["state"], "paired");
|
||||
assert_eq!(listed["clients"][0]["client"], "pixel");
|
||||
assert!(!listed.to_string().contains(CLIENT_PREFIX));
|
||||
for (plugin, session) in [("b", "s1"), ("a", "s2")] {
|
||||
assert_eq!(
|
||||
grants
|
||||
.serve(&call(plugin, session, "clients", json!({})))
|
||||
.unwrap(),
|
||||
json!({"clients": []})
|
||||
);
|
||||
assert!(matches!(
|
||||
grants.serve(&call(plugin, session, "revoke", json!({"grant": id}))),
|
||||
Err(BrokerRefusal::InvalidInput { .. })
|
||||
));
|
||||
}
|
||||
assert_eq!(grants.authorize(&client.token, "s1"), Ok(id.clone()));
|
||||
assert_eq!(
|
||||
grants
|
||||
.serve(&call("a", "s1", "revoke", json!({"grant": id})))
|
||||
.unwrap(),
|
||||
json!({"grant": id, "revoked": true})
|
||||
);
|
||||
assert_eq!(
|
||||
grants.authorize(&client.token, "s1"),
|
||||
Err(RemoteDenial::Unknown)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_grants_refuse_stale_pairings_and_clients_and_cap_pending_pairings() {
|
||||
let stale = RemoteGrants::new(Duration::ZERO, Duration::from_secs(60));
|
||||
stale.serve(&call("a", "s", "pair", json!({}))).unwrap();
|
||||
let secret = {
|
||||
let grants = stale.grants.lock().unwrap();
|
||||
grants.by_id.values().next().unwrap().pairing.clone()
|
||||
};
|
||||
assert!(stale.awaiting_host().is_empty());
|
||||
assert_eq!(
|
||||
stale.redeem(&secret.unwrap_or_default(), "x"),
|
||||
Err(RemoteDenial::Unknown)
|
||||
);
|
||||
|
||||
let expiring = RemoteGrants::new(Duration::from_secs(60), Duration::ZERO);
|
||||
expiring.serve(&call("a", "s", "pair", json!({}))).unwrap();
|
||||
let secret = expiring.awaiting_host()[0].secret.clone();
|
||||
let client = expiring.redeem(&secret, "x").unwrap();
|
||||
assert_eq!(
|
||||
expiring.authorize(&client.token, "s"),
|
||||
Err(RemoteDenial::Expired)
|
||||
);
|
||||
|
||||
let capped = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
|
||||
for _ in 0..MAX_PENDING_PAIRINGS {
|
||||
capped.serve(&call("a", "s", "pair", json!({}))).unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
capped.serve(&call("a", "s", "pair", json!({}))),
|
||||
Err(BrokerRefusal::InvalidInput { .. })
|
||||
));
|
||||
assert!(capped.serve(&call("b", "s", "pair", json!({}))).is_ok());
|
||||
assert!(matches!(
|
||||
capped.serve(&call("a", "s", "pair", json!({"label": "a\u{7}"}))),
|
||||
Err(BrokerRefusal::InvalidInput { .. })
|
||||
));
|
||||
assert_eq!(capped.revoke_session("s"), MAX_PENDING_PAIRINGS + 1);
|
||||
assert!(capped.awaiting_host().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_methods_decode_strictly_and_refuse_unknown_methods() {
|
||||
let grants = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
|
||||
for method in ["listen", "tunnel", "expose", "anything"] {
|
||||
assert!(matches!(
|
||||
grants.serve(&call("a", "s", method, json!({}))),
|
||||
Err(BrokerRefusal::UnknownMethod {
|
||||
service: DshService::Remote,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
for (method, input) in [
|
||||
("pair", json!({"label": 3})),
|
||||
("pair", json!({"label": "x", "public": true})),
|
||||
("revoke", json!({})),
|
||||
("status", Value::Null),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
grants.serve(&call("a", "s", method, input.clone())),
|
||||
Err(BrokerRefusal::InvalidInput { .. })
|
||||
),
|
||||
"{method} {input}"
|
||||
);
|
||||
}
|
||||
let status = grants.serve(&call("a", "s", "status", json!({}))).unwrap();
|
||||
assert_eq!(status["public_listener"], false);
|
||||
assert_eq!(status["transport"], "runtime_http_sse");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,524 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Terminal)
|
||||
use codewhale_agent_contract::worker_profile::ShellPolicy;
|
||||
use codewhale_workflow_js::ToolCallResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::execution_envelope::enforce_tool_denial;
|
||||
|
||||
const SUPPORTED: bool = cfg!(all(unix, not(target_env = "ohos")));
|
||||
const DEFAULT_SESSION: &str = "term-1";
|
||||
const MAX_SESSION_NAME: usize = 64;
|
||||
const MAX_COMMAND_BYTES: usize = 16 * 1024;
|
||||
const MAX_INPUT_BYTES: usize = 64 * 1024;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_SETTLE_MS: u64 = 60_000;
|
||||
pub(super) const MAX_TERMINALS: usize = 8;
|
||||
const LOG: &str = "codewhale::dsh_terminal";
|
||||
|
||||
static REGISTRY_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum TerminalMethod {
|
||||
#[serde(rename = "list")]
|
||||
List(NoInput),
|
||||
#[serde(rename = "run")]
|
||||
Run(RunInput),
|
||||
#[serde(rename = "send")]
|
||||
Send(SendInput),
|
||||
#[serde(rename = "wait")]
|
||||
Wait(WaitInput),
|
||||
#[serde(rename = "cancel")]
|
||||
Cancel(SessionInput),
|
||||
#[serde(rename = "reset")]
|
||||
Reset(SessionInput),
|
||||
}
|
||||
|
||||
impl BrokerMethods for TerminalMethod {
|
||||
const NAMES: &'static [&'static str] = &["list", "run", "send", "wait", "cancel", "reset"];
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct NoInput {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RunInput {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
session: Option<String>,
|
||||
#[serde(default)]
|
||||
timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SendInput {
|
||||
session: String,
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
wait_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WaitInput {
|
||||
session: String,
|
||||
#[serde(default)]
|
||||
timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SessionInput {
|
||||
session: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Terminal {
|
||||
terminal_id: String,
|
||||
session: String,
|
||||
state: String,
|
||||
running: bool,
|
||||
exit_code: Option<u32>,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
struct Live {
|
||||
instance: String,
|
||||
terminals: Vec<Terminal>,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
fn holds(&self, session: &str) -> bool {
|
||||
self.terminals
|
||||
.iter()
|
||||
.any(|terminal| terminal.session == session)
|
||||
}
|
||||
|
||||
fn existing(&self, session: &str) -> Result<(), BrokerRefusal> {
|
||||
if self.holds(session) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid(
|
||||
"this plugin has no live terminal with that name in this session; `list` reports the live ones",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let method = decode::<TerminalMethod>(&call)?;
|
||||
if call.context.cancel().is_cancelled() {
|
||||
return Err(refused("the terminal call was withdrawn"));
|
||||
}
|
||||
match method {
|
||||
TerminalMethod::List(NoInput {}) => {
|
||||
let live = live(&call, "terminal/wait").await?;
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, terminals = live.terminals.len(), "terminals listed");
|
||||
Ok(json!({
|
||||
"instance": live.instance,
|
||||
"limit": MAX_TERMINALS,
|
||||
"terminals": live.terminals,
|
||||
}))
|
||||
}
|
||||
TerminalMethod::Run(RunInput {
|
||||
command,
|
||||
session,
|
||||
timeout_secs,
|
||||
}) => {
|
||||
let session = session_name(session.as_deref().unwrap_or(DEFAULT_SESSION))?;
|
||||
if command.trim().is_empty()
|
||||
|| command.len() > MAX_COMMAND_BYTES
|
||||
|| command.contains('\0')
|
||||
{
|
||||
return Err(invalid(format!(
|
||||
"a command is 1 to {MAX_COMMAND_BYTES} bytes with no NUL"
|
||||
)));
|
||||
}
|
||||
let timeout_secs = timeout(timeout_secs)?;
|
||||
let live = live(&call, "terminal/run").await?;
|
||||
if !live.holds(session) && live.terminals.len() >= MAX_TERMINALS {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "terminal limit reached");
|
||||
return Err(refused(
|
||||
"this plugin already holds the most terminals it may open in this session",
|
||||
));
|
||||
}
|
||||
let mut input =
|
||||
json!({ "command": command, "session": owned_name(&call, session) });
|
||||
if let Some(timeout_secs) = timeout_secs {
|
||||
input["timeout_secs"] = json!(timeout_secs);
|
||||
}
|
||||
nested(&call, "terminal/run", session, input).await
|
||||
}
|
||||
TerminalMethod::Send(SendInput {
|
||||
session,
|
||||
text,
|
||||
wait_ms,
|
||||
}) => {
|
||||
let session = session_name(&session)?;
|
||||
if text.is_empty() || text.len() > MAX_INPUT_BYTES {
|
||||
return Err(invalid(format!(
|
||||
"terminal input is 1 to {MAX_INPUT_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
if wait_ms.is_some_and(|wait_ms| wait_ms > MAX_SETTLE_MS) {
|
||||
return Err(invalid(format!("wait_ms is at most {MAX_SETTLE_MS}")));
|
||||
}
|
||||
live(&call, "terminal/send").await?.existing(session)?;
|
||||
let mut input = json!({ "session": owned_name(&call, session), "text": text });
|
||||
if let Some(wait_ms) = wait_ms {
|
||||
input["wait_ms"] = json!(wait_ms);
|
||||
}
|
||||
nested(&call, "terminal/send", session, input).await
|
||||
}
|
||||
TerminalMethod::Wait(WaitInput {
|
||||
session,
|
||||
timeout_secs,
|
||||
}) => {
|
||||
let session = session_name(&session)?;
|
||||
let timeout_secs = timeout(timeout_secs)?;
|
||||
live(&call, "terminal/wait").await?.existing(session)?;
|
||||
let mut input = json!({ "session": owned_name(&call, session) });
|
||||
if let Some(timeout_secs) = timeout_secs {
|
||||
input["timeout_secs"] = json!(timeout_secs);
|
||||
}
|
||||
nested(&call, "terminal/wait", session, input).await
|
||||
}
|
||||
TerminalMethod::Cancel(SessionInput { session }) => {
|
||||
let session = session_name(&session)?;
|
||||
live(&call, "terminal/cancel").await?.existing(session)?;
|
||||
let input = json!({ "session": owned_name(&call, session) });
|
||||
nested(&call, "terminal/cancel", session, input).await
|
||||
}
|
||||
TerminalMethod::Reset(SessionInput { session }) => {
|
||||
let session = session_name(&session)?;
|
||||
live(&call, "terminal/reset").await?.existing(session)?;
|
||||
let input = json!({ "session": owned_name(&call, session) });
|
||||
nested(&call, "terminal/reset", session, input).await
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> BrokerRefusal {
|
||||
BrokerRefusal::InvalidInput {
|
||||
service: DshService::Terminal,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(message: &str) -> BrokerRefusal {
|
||||
BrokerRefusal::Refused {
|
||||
service: DshService::Terminal,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
|
||||
let ok = !name.is_empty()
|
||||
&& name.len() <= MAX_SESSION_NAME
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
|
||||
if ok {
|
||||
Ok(name)
|
||||
} else {
|
||||
Err(invalid(format!(
|
||||
"a terminal name is 1 to {MAX_SESSION_NAME} letters, digits, `_`, `-` or `.`"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(secs: Option<u64>) -> Result<Option<u64>, BrokerRefusal> {
|
||||
match secs {
|
||||
Some(secs) if !(1..=MAX_TIMEOUT_SECS).contains(&secs) => Err(invalid(format!(
|
||||
"timeout_secs is between 1 and {MAX_TIMEOUT_SECS}"
|
||||
))),
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(plugin: &str) -> String {
|
||||
format!("dsh:{plugin}:")
|
||||
}
|
||||
|
||||
fn owned_name(call: &BrokerCall, session: &str) -> String {
|
||||
format!("{}{session}", prefix(&call.owner.plugin_id))
|
||||
}
|
||||
|
||||
async fn live(call: &BrokerCall, tool_name: &'static str) -> Result<Live, BrokerRefusal> {
|
||||
if !SUPPORTED {
|
||||
return Err(BrokerRefusal::NotAvailable(DshService::Terminal));
|
||||
}
|
||||
let mut tool = call.context.tool().clone();
|
||||
let _ = tool.refresh_live_posture();
|
||||
let needs_full = matches!(
|
||||
tool_name,
|
||||
"terminal/run" | "terminal/send" | "terminal/reset"
|
||||
);
|
||||
if !tool.shell_policy.allows_shell()
|
||||
|| (needs_full && tool.shell_policy != ShellPolicy::Full)
|
||||
|| enforce_tool_denial(&tool, tool_name, &json!({})).is_err()
|
||||
{
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, tool = tool_name, "terminal refused: the caller may not use this shell tool");
|
||||
return Err(refused("terminals are not available to this caller"));
|
||||
}
|
||||
let workspace = tool.workspace.clone();
|
||||
let owner = tool.state_namespace.clone();
|
||||
let mine = prefix(&call.owner.plugin_id);
|
||||
let read = async move {
|
||||
let permit = REGISTRY_GATE.acquire().await.map_err(|_| ())?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
owned(&workspace, &owner, &mine)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ())?
|
||||
.map_err(|error| {
|
||||
tracing::warn!(target: LOG, %error, "terminal registry read failed");
|
||||
})
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = call.context.cancel().cancelled() => Err(refused("the terminal call was withdrawn")),
|
||||
live = read => live.map_err(|()| refused("the terminal registry could not be read")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_env = "ohos")))]
|
||||
fn owned(workspace: &Path, owner: &str, mine: &str) -> Result<Live, String> {
|
||||
use crate::tools::terminal_session;
|
||||
|
||||
let terminals = terminal_session::owned_sessions(workspace, owner)?
|
||||
.into_iter()
|
||||
.filter_map(|info| {
|
||||
let session = info.name.strip_prefix(mine)?;
|
||||
session_name(session).ok()?;
|
||||
Some(Terminal {
|
||||
session: session.to_string(),
|
||||
state: match info.exit_code {
|
||||
Some(_) => "exited".to_string(),
|
||||
None => serde_json::to_value(info.state)
|
||||
.ok()
|
||||
.and_then(|state| state.as_str().map(str::to_owned))
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
running: info.exit_code.is_none(),
|
||||
exit_code: info.exit_code,
|
||||
terminal_id: info.id,
|
||||
updated_at: info.updated_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Live {
|
||||
instance: terminal_session::instance_id().to_string(),
|
||||
terminals,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_env = "ohos"))))]
|
||||
fn owned(_workspace: &Path, _owner: &str, _mine: &str) -> Result<Live, String> {
|
||||
Err("terminal sessions are not supported on this platform".to_string())
|
||||
}
|
||||
|
||||
async fn nested(
|
||||
call: &BrokerCall,
|
||||
tool: &'static str,
|
||||
session: &str,
|
||||
input: Value,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let plugin = &call.owner.plugin_id;
|
||||
match call.context.core_call(tool, input).await {
|
||||
Ok(response) => {
|
||||
tracing::info!(target: LOG, %plugin, tool, session, ok = response.ok, "terminal call ran through the gate");
|
||||
Ok(outcome(session, response))
|
||||
}
|
||||
Err(refusal) => {
|
||||
let code = refusal.code();
|
||||
let detail = match &refusal {
|
||||
BrokerRefusal::Denied { message, .. } | BrokerRefusal::Refused { message, .. } => {
|
||||
message.as_str()
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
tracing::info!(target: LOG, %plugin, tool, session, code, detail, "terminal call not run");
|
||||
Err(match refusal {
|
||||
BrokerRefusal::Denied { .. } => BrokerRefusal::Denied {
|
||||
service: DshService::Terminal,
|
||||
message: "the terminal call was denied".to_string(),
|
||||
},
|
||||
_ => refused("the terminal call was refused"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome(session: &str, response: ToolCallResponse) -> Value {
|
||||
if !response.ok {
|
||||
return json!({ "ok": false, "session": session, "output": text(&response.result) });
|
||||
}
|
||||
let metadata = &response.result["metadata"];
|
||||
json!({
|
||||
"ok": true,
|
||||
"session": session,
|
||||
"output": text(&response.result["content"]),
|
||||
"truncated": !response.result["truncated"].is_null(),
|
||||
"status": metadata["status"],
|
||||
"exit_code": metadata["exit_code"],
|
||||
"cwd": metadata["cwd"],
|
||||
"terminal_id": metadata["terminal_session_id"],
|
||||
"state": metadata["terminal_state"],
|
||||
"previous_terminal_id": metadata["previous_session_id"],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::dsh_broker::BrokerContext;
|
||||
use super::*;
|
||||
use codewhale_config::OwnerRef;
|
||||
|
||||
fn call(owner: &OwnerRef, method: &str, input: Value) -> BrokerCall {
|
||||
BrokerCall {
|
||||
owner: owner.clone(),
|
||||
session_id: "session".into(),
|
||||
service: DshService::Terminal,
|
||||
method: method.into(),
|
||||
input,
|
||||
context: BrokerContext::detached_for_test(owner, DshService::Terminal),
|
||||
}
|
||||
}
|
||||
|
||||
fn owner() -> OwnerRef {
|
||||
OwnerRef {
|
||||
plugin_id: "a".into(),
|
||||
generation: 1,
|
||||
owner_token: "t".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_methods_decode_strictly_and_bound_every_input_before_anything_runs() {
|
||||
let owner = owner();
|
||||
for method in ["spawn", "open", "kill", "resize", "read", "exec", "core"] {
|
||||
let refusal = serve(call(&owner, method, json!({}))).await.unwrap_err();
|
||||
assert_eq!(refusal.code(), "unknown_method", "{method}");
|
||||
}
|
||||
let long_name = "n".repeat(MAX_SESSION_NAME + 1);
|
||||
let long_command = "c".repeat(MAX_COMMAND_BYTES + 1);
|
||||
let long_text = "t".repeat(MAX_INPUT_BYTES + 1);
|
||||
for (method, input) in [
|
||||
("list", json!({ "workspace": "/" })),
|
||||
("run", json!({})),
|
||||
("run", json!({ "command": "" })),
|
||||
("run", json!({ "command": " \n" })),
|
||||
("run", json!({ "command": "a\u{0}b" })),
|
||||
("run", json!({ "command": long_command })),
|
||||
("run", json!({ "command": "true", "session": "" })),
|
||||
("run", json!({ "command": "true", "session": "../term-1" })),
|
||||
("run", json!({ "command": "true", "session": "dsh:b:x" })),
|
||||
("run", json!({ "command": "true", "session": long_name })),
|
||||
("run", json!({ "command": "true", "timeout_secs": 0 })),
|
||||
("run", json!({ "command": "true", "timeout_secs": 601 })),
|
||||
(
|
||||
"run",
|
||||
json!({ "command": "true", "sandbox_permissions": "danger" }),
|
||||
),
|
||||
("run", json!({ "command": "true", "name": "exec_shell" })),
|
||||
("send", json!({ "session": "x" })),
|
||||
("send", json!({ "session": "x", "text": "" })),
|
||||
("send", json!({ "session": "x", "text": long_text })),
|
||||
(
|
||||
"send",
|
||||
json!({ "session": "x", "text": "y", "wait_ms": 60_001 }),
|
||||
),
|
||||
("wait", json!({ "session": "x", "timeout_secs": 601 })),
|
||||
("wait", json!({})),
|
||||
("cancel", json!({ "session": "a b" })),
|
||||
("reset", json!({ "session": "x", "force": true })),
|
||||
] {
|
||||
let refusal = serve(call(&owner, method, input.clone()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(refusal.code(), "invalid_input", "{method} {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_terminal_call_whose_cancel_has_fired_has_no_effect() {
|
||||
let owner = owner();
|
||||
let fired = call(&owner, "run", json!({ "command": "true" }));
|
||||
fired.context.cancel().cancel();
|
||||
let refusal = serve(fired).await.unwrap_err();
|
||||
assert_eq!(refusal.code(), "refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_names_are_scoped_to_the_plugin_and_a_result_is_a_typed_projection() {
|
||||
assert_eq!(prefix("a"), "dsh:a:");
|
||||
assert!(
|
||||
"dsh:a:b:x"
|
||||
.strip_prefix(&prefix("a"))
|
||||
.is_some_and(|rest| session_name(rest).is_err())
|
||||
);
|
||||
let ran = outcome(
|
||||
"build",
|
||||
ToolCallResponse {
|
||||
ok: true,
|
||||
result: json!({
|
||||
"content": 42,
|
||||
"truncated": null,
|
||||
"metadata": {
|
||||
"status": "completed",
|
||||
"exit_code": 0,
|
||||
"cwd": "/w",
|
||||
"terminal_session_id": "id-1",
|
||||
"terminal_state": "idle",
|
||||
"state_path": "/private/state.json",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
ran,
|
||||
json!({
|
||||
"ok": true,
|
||||
"session": "build",
|
||||
"output": "42",
|
||||
"truncated": false,
|
||||
"status": "completed",
|
||||
"exit_code": 0,
|
||||
"cwd": "/w",
|
||||
"terminal_id": "id-1",
|
||||
"state": "idle",
|
||||
"previous_terminal_id": null,
|
||||
})
|
||||
);
|
||||
let failed = outcome(
|
||||
"build",
|
||||
ToolCallResponse {
|
||||
ok: false,
|
||||
result: json!("boom"),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
failed,
|
||||
json!({ "ok": false, "session": "build", "output": "boom" })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,713 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Workspace)
|
||||
use codewhale_workflow_js::ToolCallResponse;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::execution_envelope::enforce_tool_denial;
|
||||
use crate::tools::spec::{ToolContext, resolve_strict_authority_path};
|
||||
|
||||
const MAX_PATH_BYTES: usize = 512;
|
||||
const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_READ_LINES: u64 = 10_000;
|
||||
const MAX_ARGS: usize = 8;
|
||||
const MAX_ARG_BYTES: usize = 1024;
|
||||
const PROTECTED: &[&str] = &[".git", ".codewhale"];
|
||||
const LOG: &str = "codewhale::dsh_workspace";
|
||||
|
||||
static CONFINE_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum WorkspaceMethod {
|
||||
#[serde(rename = "read")]
|
||||
Read(ReadInput),
|
||||
#[serde(rename = "list")]
|
||||
List(ListInput),
|
||||
#[serde(rename = "write")]
|
||||
Write(WriteInput),
|
||||
#[serde(rename = "exec")]
|
||||
Exec(ExecInput),
|
||||
}
|
||||
|
||||
impl BrokerMethods for WorkspaceMethod {
|
||||
const NAMES: &'static [&'static str] = &["read", "list", "write", "exec"];
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ReadInput {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
start_line: Option<u64>,
|
||||
#[serde(default)]
|
||||
max_lines: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListInput {
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WriteInput {
|
||||
path: String,
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
expected_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ExecInput {
|
||||
argv: Vec<String>,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Access {
|
||||
Read,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let method = decode::<WorkspaceMethod>(&call)?;
|
||||
if call.context.cancel().is_cancelled() {
|
||||
return Err(refused("the workspace call was withdrawn"));
|
||||
}
|
||||
match method {
|
||||
WorkspaceMethod::Read(ReadInput {
|
||||
path,
|
||||
start_line,
|
||||
max_lines,
|
||||
}) => {
|
||||
if start_line == Some(0) {
|
||||
return Err(invalid("start_line starts at 1"));
|
||||
}
|
||||
if max_lines.is_some_and(|lines| !(1..=MAX_READ_LINES).contains(&lines)) {
|
||||
return Err(invalid(format!(
|
||||
"max_lines is between 1 and {MAX_READ_LINES}"
|
||||
)));
|
||||
}
|
||||
let tool = caller(&call, "read_file")?;
|
||||
let (shown, resolved) = confine(&call, &tool, &path, Access::Read).await?;
|
||||
let mut input = json!({ "path": resolved });
|
||||
if let Some(start_line) = start_line {
|
||||
input["start_line"] = json!(start_line);
|
||||
}
|
||||
if let Some(max_lines) = max_lines {
|
||||
input["max_lines"] = json!(max_lines);
|
||||
}
|
||||
nested(&call, "read_file", input)
|
||||
.await
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::List(ListInput { path }) => {
|
||||
let tool = caller(&call, "list_dir")?;
|
||||
let raw = path.as_deref().unwrap_or(".");
|
||||
let (shown, resolved) = confine(&call, &tool, raw, Access::Read).await?;
|
||||
nested(&call, "list_dir", json!({ "path": resolved }))
|
||||
.await
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::Write(WriteInput {
|
||||
path,
|
||||
content,
|
||||
expected_hash,
|
||||
}) => {
|
||||
if content.len() > MAX_WRITE_BYTES {
|
||||
return Err(invalid(format!(
|
||||
"a write is at most {MAX_WRITE_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
if expected_hash
|
||||
.as_deref()
|
||||
.is_some_and(|hash| hash.is_empty() || hash.len() > 128)
|
||||
{
|
||||
return Err(invalid("expected_hash is 1 to 128 characters"));
|
||||
}
|
||||
if !overlay_granted() {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "workspace write refused: no reviewed overlay grant");
|
||||
return Err(refused("this plugin may not write to the workspace"));
|
||||
}
|
||||
let tool = caller(&call, "write_file")?;
|
||||
let (shown, resolved) = confine(&call, &tool, &path, Access::Overlay).await?;
|
||||
let mut input = json!({ "path": resolved, "content": content });
|
||||
if let Some(expected_hash) = expected_hash {
|
||||
input["expected_hash"] = json!(expected_hash);
|
||||
}
|
||||
nested(&call, "write_file", input)
|
||||
.await
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::Exec(ExecInput { argv, cwd }) => {
|
||||
let query = literal_git(&argv)?;
|
||||
let tool = caller(&call, "Git")?;
|
||||
if let Some(cwd) = cwd.as_deref() {
|
||||
let (shown, _) = confine(&call, &tool, cwd, Access::Read).await?;
|
||||
if shown != "." {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "workspace exec refused: cwd is not the workspace root");
|
||||
return Err(refused("git runs only at the workspace root"));
|
||||
}
|
||||
}
|
||||
let mut input = query;
|
||||
input["action"] = json!("review_scope");
|
||||
nested(&call, "Git", input).await.map(exec_outcome)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> BrokerRefusal {
|
||||
BrokerRefusal::InvalidInput {
|
||||
service: DshService::Workspace,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(message: &str) -> BrokerRefusal {
|
||||
BrokerRefusal::Refused {
|
||||
service: DshService::Workspace,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_OVERLAY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) struct TestOverlay(bool);
|
||||
|
||||
#[cfg(test)]
|
||||
impl TestOverlay {
|
||||
pub(super) fn granted() -> Self {
|
||||
Self(TEST_OVERLAY.with(|overlay| overlay.replace(true)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TestOverlay {
|
||||
fn drop(&mut self) {
|
||||
TEST_OVERLAY.with(|overlay| overlay.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_granted() -> bool {
|
||||
#[cfg(test)]
|
||||
if TEST_OVERLAY.with(std::cell::Cell::get) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
|
||||
let mut tool = call.context.tool().clone();
|
||||
let _ = tool.refresh_live_posture();
|
||||
if enforce_tool_denial(&tool, tool_name, &json!({})).is_err() {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, tool = tool_name, "workspace refused: the caller may not use this tool");
|
||||
return Err(refused(
|
||||
"this workspace call is not available to this caller",
|
||||
));
|
||||
}
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
fn relative(workspace: &Path, raw: &str) -> Option<String> {
|
||||
let path = Path::new(raw);
|
||||
if !path.is_absolute() {
|
||||
return Some(raw.to_string());
|
||||
}
|
||||
let canonical = workspace.canonicalize().ok();
|
||||
let rest = path.strip_prefix(workspace).ok().or_else(|| {
|
||||
canonical
|
||||
.as_deref()
|
||||
.and_then(|root| path.strip_prefix(root).ok())
|
||||
})?;
|
||||
let rest = rest.to_str()?;
|
||||
Some(if rest.is_empty() {
|
||||
".".into()
|
||||
} else {
|
||||
rest.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn shape(raw: &str) -> Result<(), BrokerRefusal> {
|
||||
if raw.is_empty()
|
||||
|| raw.len() > MAX_PATH_BYTES
|
||||
|| raw.trim() != raw
|
||||
|| raw.chars().any(char::is_control)
|
||||
{
|
||||
return Err(invalid(format!(
|
||||
"a path is 1 to {MAX_PATH_BYTES} bytes on one line with no surrounding spaces"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn protected(relative: &str) -> bool {
|
||||
relative.replace('\\', "/").split('/').any(|segment| {
|
||||
PROTECTED
|
||||
.iter()
|
||||
.any(|name| segment.eq_ignore_ascii_case(name))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve(tool: &ToolContext, raw: &str, access: Access) -> Result<(String, PathBuf), String> {
|
||||
let relative = relative(&tool.workspace, raw).ok_or_else(|| "outside".to_string())?;
|
||||
let resolved = resolve_strict_authority_path(tool, &relative).map_err(|e| e.to_string())?;
|
||||
let root = tool.workspace.canonicalize().map_err(|e| e.to_string())?;
|
||||
let shown = resolved
|
||||
.strip_prefix(&root)
|
||||
.map_err(|_| "outside".to_string())?
|
||||
.to_str()
|
||||
.ok_or_else(|| "not UTF-8".to_string())?
|
||||
.replace('\\', "/");
|
||||
let shown = if shown.is_empty() { ".".into() } else { shown };
|
||||
if access == Access::Overlay && (shown == "." || protected(&shown)) {
|
||||
return Err(format!("overlay excludes {shown}"));
|
||||
}
|
||||
Ok((shown, resolved))
|
||||
}
|
||||
|
||||
async fn confine(
|
||||
call: &BrokerCall,
|
||||
tool: &ToolContext,
|
||||
raw: &str,
|
||||
access: Access,
|
||||
) -> Result<(String, String), BrokerRefusal> {
|
||||
shape(raw)?;
|
||||
let owned = tool.clone();
|
||||
let raw_owned = raw.to_string();
|
||||
let work = async move {
|
||||
let permit = CONFINE_GATE
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| "closed".to_string())?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
resolve(&owned, &raw_owned, access)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
};
|
||||
let resolved = tokio::select! {
|
||||
biased;
|
||||
() = call.context.cancel().cancelled() => return Err(refused("the workspace call was withdrawn")),
|
||||
resolved = work => resolved,
|
||||
};
|
||||
match resolved {
|
||||
Ok((shown, path)) => {
|
||||
let path = path
|
||||
.to_str()
|
||||
.ok_or_else(|| refused("that path is not available to this plugin"))?
|
||||
.to_string();
|
||||
Ok((shown, path))
|
||||
}
|
||||
Err(detail) => {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, %detail, "workspace path refused");
|
||||
Err(refused("that path is not available to this plugin"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn literal_git(argv: &[String]) -> Result<Value, BrokerRefusal> {
|
||||
if argv.is_empty()
|
||||
|| argv.len() > MAX_ARGS + 1
|
||||
|| argv
|
||||
.iter()
|
||||
.any(|arg| arg.len() > MAX_ARG_BYTES || arg.chars().any(char::is_control))
|
||||
{
|
||||
return Err(invalid(format!(
|
||||
"argv is 1 to {} strings of at most {MAX_ARG_BYTES} bytes with no control characters",
|
||||
MAX_ARGS + 1
|
||||
)));
|
||||
}
|
||||
let refuse = || {
|
||||
tracing::info!(target: LOG, program = %argv[0].chars().take(64).collect::<String>(), "workspace exec refused: not a literal read-only git command");
|
||||
refused("only read-only git metadata commands run for plugins")
|
||||
};
|
||||
if argv[0] != "git" {
|
||||
return Err(refuse());
|
||||
}
|
||||
let args: Vec<&str> = argv[1..].iter().map(String::as_str).collect();
|
||||
let args = match args.split_first() {
|
||||
Some((&"--no-optional-locks", rest)) => rest,
|
||||
_ => &args[..],
|
||||
};
|
||||
let query = match args {
|
||||
["for-each-ref", "--format=%(refname:short)", "refs/heads"] => {
|
||||
json!({ "query": "branches" })
|
||||
}
|
||||
["log", "-n", "100", "--format=%H%x09%s"] => json!({ "query": "commits" }),
|
||||
["rev-parse", "--show-toplevel"] => json!({ "query": "root" }),
|
||||
["rev-parse", "--verify", "--end-of-options", rev] => {
|
||||
json!({ "query": "resolve", "rev": rev })
|
||||
}
|
||||
["rev-list", "--left-right", "--count", range] => match range.split_once("...") {
|
||||
Some((ours, theirs)) if !theirs.contains("...") => {
|
||||
json!({ "query": "ahead_behind", "ours": ours, "theirs": theirs })
|
||||
}
|
||||
_ => return Err(refuse()),
|
||||
},
|
||||
["merge-base", ours, theirs] => {
|
||||
json!({ "query": "merge_base", "ours": ours, "theirs": theirs })
|
||||
}
|
||||
_ => return Err(refuse()),
|
||||
};
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
async fn nested(
|
||||
call: &BrokerCall,
|
||||
tool: &'static str,
|
||||
input: Value,
|
||||
) -> Result<ToolCallResponse, BrokerRefusal> {
|
||||
let plugin = &call.owner.plugin_id;
|
||||
match call.context.core_call(tool, input).await {
|
||||
Ok(response) => {
|
||||
tracing::info!(target: LOG, %plugin, tool, ok = response.ok, "workspace call ran through the gate");
|
||||
Ok(response)
|
||||
}
|
||||
Err(refusal) => {
|
||||
let code = refusal.code();
|
||||
let detail = match &refusal {
|
||||
BrokerRefusal::Denied { message, .. } | BrokerRefusal::Refused { message, .. } => {
|
||||
message.as_str()
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
tracing::info!(target: LOG, %plugin, tool, code, detail, "workspace call not run");
|
||||
Err(match refusal {
|
||||
BrokerRefusal::Denied { .. } => BrokerRefusal::Denied {
|
||||
service: DshService::Workspace,
|
||||
message: "the workspace call was denied".to_string(),
|
||||
},
|
||||
_ => refused("the workspace call was refused"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_outcome(path: &str, response: ToolCallResponse) -> Value {
|
||||
if !response.ok {
|
||||
return json!({ "ok": false, "path": path, "output": text(&response.result) });
|
||||
}
|
||||
json!({
|
||||
"ok": true,
|
||||
"path": path,
|
||||
"output": text(&response.result["content"]),
|
||||
"truncated": !response.result["truncated"].is_null(),
|
||||
})
|
||||
}
|
||||
|
||||
fn exec_outcome(response: ToolCallResponse) -> Value {
|
||||
if !response.ok {
|
||||
return json!({ "ok": false, "stdout": "", "stderr": text(&response.result), "exit_code": null });
|
||||
}
|
||||
let parsed = serde_json::from_str::<Value>(&text(&response.result["content"]))
|
||||
.ok()
|
||||
.filter(|value| value["stdout"].is_string() && value["stderr"].is_string());
|
||||
match parsed {
|
||||
Some(value) => json!({
|
||||
"ok": value["exit_code"] == json!(0),
|
||||
"stdout": value["stdout"],
|
||||
"stderr": value["stderr"],
|
||||
"exit_code": value["exit_code"],
|
||||
}),
|
||||
None => json!({
|
||||
"ok": false,
|
||||
"stdout": "",
|
||||
"stderr": "git output exceeded what a plugin call returns",
|
||||
"exit_code": null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::dsh_broker::BrokerContext;
|
||||
use super::*;
|
||||
use codewhale_config::OwnerRef;
|
||||
|
||||
fn call(owner: &OwnerRef, method: &str, input: Value) -> BrokerCall {
|
||||
BrokerCall {
|
||||
owner: owner.clone(),
|
||||
session_id: "session".into(),
|
||||
service: DshService::Workspace,
|
||||
method: method.into(),
|
||||
input,
|
||||
context: BrokerContext::detached_for_test(owner, DshService::Workspace),
|
||||
}
|
||||
}
|
||||
|
||||
fn owner() -> OwnerRef {
|
||||
OwnerRef {
|
||||
plugin_id: "a".into(),
|
||||
generation: 1,
|
||||
owner_token: "t".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn argv(args: &[&str]) -> Vec<String> {
|
||||
args.iter().map(|arg| (*arg).to_string()).collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_methods_decode_strictly_and_bound_every_input_before_anything_runs() {
|
||||
let owner = owner();
|
||||
for method in ["delete", "rename", "spawn", "shell", "watch", "core", "Git"] {
|
||||
let refusal = serve(call(&owner, method, json!({}))).await.unwrap_err();
|
||||
assert_eq!(refusal.code(), "unknown_method", "{method}");
|
||||
}
|
||||
let long_path = "p".repeat(MAX_PATH_BYTES + 1);
|
||||
let long_content = "c".repeat(MAX_WRITE_BYTES + 1);
|
||||
for (method, input) in [
|
||||
("read", json!({})),
|
||||
("read", json!({ "path": "" })),
|
||||
("read", json!({ "path": " x" })),
|
||||
("read", json!({ "path": "a\nb" })),
|
||||
("read", json!({ "path": long_path })),
|
||||
("read", json!({ "path": "x", "start_line": 0 })),
|
||||
("read", json!({ "path": "x", "max_lines": 0 })),
|
||||
(
|
||||
"read",
|
||||
json!({ "path": "x", "max_lines": MAX_READ_LINES + 1 }),
|
||||
),
|
||||
("read", json!({ "path": "x", "follow_symlinks": true })),
|
||||
("list", json!({ "path": "x", "recursive": true })),
|
||||
("write", json!({ "path": "x" })),
|
||||
("write", json!({ "path": "x", "content": long_content })),
|
||||
(
|
||||
"write",
|
||||
json!({ "path": "x", "content": "", "expected_hash": "" }),
|
||||
),
|
||||
("write", json!({ "path": "x", "content": "", "mode": 493 })),
|
||||
("exec", json!({})),
|
||||
("exec", json!({ "argv": [] })),
|
||||
("exec", json!({ "argv": ["git", "a\u{0}b"] })),
|
||||
("exec", json!({ "argv": ["git"], "env": {} })),
|
||||
("exec", json!({ "argv": ["git"], "shell": true })),
|
||||
] {
|
||||
let refusal = serve(call(&owner, method, input.clone()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(refusal.code(), "invalid_input", "{method} {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_workspace_call_whose_cancel_has_fired_has_no_effect() {
|
||||
let owner = owner();
|
||||
let _overlay = TestOverlay::granted();
|
||||
for (method, input) in [
|
||||
("read", json!({ "path": "x" })),
|
||||
("list", json!({})),
|
||||
("write", json!({ "path": "x", "content": "y" })),
|
||||
(
|
||||
"exec",
|
||||
json!({ "argv": ["git", "rev-parse", "--show-toplevel"] }),
|
||||
),
|
||||
] {
|
||||
let fired = call(&owner, method, input);
|
||||
fired.context.cancel().cancel();
|
||||
let refusal = serve(fired).await.unwrap_err();
|
||||
assert_eq!(refusal.code(), "refused", "{method}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_write_without_the_reviewed_overlay_grant_is_refused_before_any_path_is_read() {
|
||||
let owner = owner();
|
||||
let refusal = serve(call(
|
||||
&owner,
|
||||
"write",
|
||||
json!({ "path": "x", "content": "y" }),
|
||||
))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(refusal.code(), "refused");
|
||||
assert_eq!(
|
||||
refusal.wire().message,
|
||||
"this plugin may not write to the workspace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_literal_read_only_git_metadata_argv_maps_to_the_git_tool() {
|
||||
for (args, expected) in [
|
||||
(
|
||||
argv(&["git", "rev-parse", "--show-toplevel"]),
|
||||
json!({ "query": "root" }),
|
||||
),
|
||||
(
|
||||
argv(&[
|
||||
"git",
|
||||
"--no-optional-locks",
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"refs/heads",
|
||||
]),
|
||||
json!({ "query": "branches" }),
|
||||
),
|
||||
(
|
||||
argv(&["git", "log", "-n", "100", "--format=%H%x09%s"]),
|
||||
json!({ "query": "commits" }),
|
||||
),
|
||||
(
|
||||
argv(&["git", "rev-parse", "--verify", "--end-of-options", "main"]),
|
||||
json!({ "query": "resolve", "rev": "main" }),
|
||||
),
|
||||
(
|
||||
argv(&["git", "rev-list", "--left-right", "--count", "a...b"]),
|
||||
json!({ "query": "ahead_behind", "ours": "a", "theirs": "b" }),
|
||||
),
|
||||
(
|
||||
argv(&["git", "merge-base", "a", "b"]),
|
||||
json!({ "query": "merge_base", "ours": "a", "theirs": "b" }),
|
||||
),
|
||||
] {
|
||||
assert_eq!(literal_git(&args).unwrap(), expected, "{args:?}");
|
||||
}
|
||||
for args in [
|
||||
argv(&["sh", "-c", "git status"]),
|
||||
argv(&["/usr/bin/git", "rev-parse", "--show-toplevel"]),
|
||||
argv(&["git"]),
|
||||
argv(&["git", "status"]),
|
||||
argv(&["git", "rev-parse", "--show-toplevel", "--git-dir"]),
|
||||
argv(&[
|
||||
"git",
|
||||
"-c",
|
||||
"core.fsmonitor=x",
|
||||
"rev-parse",
|
||||
"--show-toplevel",
|
||||
]),
|
||||
argv(&[
|
||||
"git",
|
||||
"--no-optional-locks",
|
||||
"--no-optional-locks",
|
||||
"rev-parse",
|
||||
"--show-toplevel",
|
||||
]),
|
||||
argv(&["git", "log", "-n", "100", "--format=%H%x09%s", "-p"]),
|
||||
argv(&["git", "rev-list", "--left-right", "--count", "a..b"]),
|
||||
argv(&["git", "rev-list", "--left-right", "--count", "a...b...c"]),
|
||||
argv(&["git", "push", "origin", "main"]),
|
||||
argv(&["git", "config", "core.hooksPath", "/tmp"]),
|
||||
] {
|
||||
assert_eq!(
|
||||
literal_git(&args).unwrap_err().code(),
|
||||
"refused",
|
||||
"{args:?}"
|
||||
);
|
||||
}
|
||||
let too_many = vec!["git".to_string(); MAX_ARGS + 2];
|
||||
assert_eq!(literal_git(&too_many).unwrap_err().code(), "invalid_input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_are_confined_to_the_workspace_and_the_overlay_excludes_its_internals() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/a.txt"), "a").unwrap();
|
||||
let tool = ToolContext::new(&root);
|
||||
let read = |raw: &str| resolve(&tool, raw, Access::Read).map(|(shown, _)| shown);
|
||||
let write = |raw: &str| resolve(&tool, raw, Access::Overlay).map(|(shown, _)| shown);
|
||||
|
||||
assert_eq!(read("src/a.txt").unwrap(), "src/a.txt");
|
||||
assert_eq!(read("./src//a.txt").unwrap(), "src/a.txt");
|
||||
assert_eq!(
|
||||
read(root.join("src/a.txt").to_str().unwrap()).unwrap(),
|
||||
"src/a.txt"
|
||||
);
|
||||
assert_eq!(read(root.to_str().unwrap()).unwrap(), ".");
|
||||
assert_eq!(read(".").unwrap(), ".");
|
||||
assert_eq!(write("src/new/b.txt").unwrap(), "src/new/b.txt");
|
||||
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
for raw in [
|
||||
"../x",
|
||||
"src/../../x",
|
||||
outside.path().to_str().unwrap(),
|
||||
"/etc/passwd",
|
||||
] {
|
||||
assert!(read(raw).is_err(), "{raw}");
|
||||
assert!(write(raw).is_err(), "{raw}");
|
||||
}
|
||||
for raw in [
|
||||
".",
|
||||
".git/config",
|
||||
".GIT/hooks/pre-commit",
|
||||
"src/.git",
|
||||
".codewhale/x",
|
||||
] {
|
||||
assert!(write(raw).is_err(), "{raw}");
|
||||
}
|
||||
assert_eq!(read(".git/HEAD").unwrap(), ".git/HEAD");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_anywhere_on_the_path_is_refused_even_when_it_points_inside() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("secret"), "s").unwrap();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/a.txt"), "a").unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
|
||||
std::os::unix::fs::symlink(root.join("src"), root.join("in")).unwrap();
|
||||
std::os::unix::fs::symlink(root.join("src/a.txt"), root.join("link.txt")).unwrap();
|
||||
let tool = ToolContext::new(&root);
|
||||
for raw in ["out/secret", "out", "in/a.txt", "link.txt", "out/new.txt"] {
|
||||
assert!(resolve(&tool, raw, Access::Read).is_err(), "{raw}");
|
||||
assert!(resolve(&tool, raw, Access::Overlay).is_err(), "{raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_are_typed_projections() {
|
||||
let read = file_outcome(
|
||||
"src/a.txt",
|
||||
ToolCallResponse {
|
||||
ok: true,
|
||||
result: json!({ "content": "1: a", "truncated": null, "metadata": { "path": "/private" } }),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
read,
|
||||
json!({ "ok": true, "path": "src/a.txt", "output": "1: a", "truncated": false })
|
||||
);
|
||||
let ran = exec_outcome(ToolCallResponse {
|
||||
ok: true,
|
||||
result: json!({ "content": json!({ "stdout": "/w\n", "stderr": "", "exit_code": 0 }).to_string() }),
|
||||
});
|
||||
assert_eq!(
|
||||
ran,
|
||||
json!({ "ok": true, "stdout": "/w\n", "stderr": "", "exit_code": 0 })
|
||||
);
|
||||
let cut = exec_outcome(ToolCallResponse {
|
||||
ok: true,
|
||||
result: json!({ "content": "{\"stdout\": \"/w [output truncated" }),
|
||||
});
|
||||
assert_eq!(cut["ok"], false);
|
||||
assert_eq!(cut["exit_code"], Value::Null);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ mod dsh_catalog;
|
||||
mod dsh_llm;
|
||||
mod dsh_network;
|
||||
mod dsh_remote;
|
||||
pub use dsh_remote::{RemoteClient, RemoteDenial, RemoteGrants, RemotePairing, remote_grants};
|
||||
mod dsh_settings;
|
||||
mod dsh_terminal;
|
||||
mod dsh_workspace;
|
||||
@@ -151,6 +152,8 @@ mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
mod core_call_tests;
|
||||
#[cfg(test)]
|
||||
mod dsh_grant_tests;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
#[cfg(test)]
|
||||
@@ -539,6 +542,7 @@ struct DesiredOwner {
|
||||
plugin_name: String,
|
||||
authority: PluginAuthority,
|
||||
entries: Vec<(PathBuf, String)>,
|
||||
host_services: Vec<protocol::DshService>,
|
||||
}
|
||||
|
||||
/// What [`ExtensionHostManager::activate_owner`] activates, on either tier:
|
||||
@@ -554,6 +558,7 @@ struct Activation {
|
||||
/// pinned source digest.
|
||||
content_hash: String,
|
||||
entries: Vec<(PathBuf, String)>,
|
||||
host_services: Vec<protocol::DshService>,
|
||||
}
|
||||
|
||||
/// One engine's view: its plugin snapshot and the owners (plugin id →
|
||||
@@ -1374,7 +1379,15 @@ fn restart_dirty_host_when_idle(
|
||||
return false;
|
||||
}
|
||||
let mut supervision = runtime.supervision.lock().expect("supervision lock");
|
||||
if !supervision.dirty_restart_pending || !host.terminate_if_idle(DIRTY_RESTART_REASON) {
|
||||
let unload = host.tier == HostTier::Plugin
|
||||
&& shared
|
||||
.registry
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.host_services_await_restart();
|
||||
if !(supervision.dirty_restart_pending || unload)
|
||||
|| !host.terminate_if_idle(DIRTY_RESTART_REASON)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
supervision.planned_restart = Some(generation);
|
||||
@@ -2223,6 +2236,7 @@ impl ExtensionHostManager {
|
||||
authority: Some(want.authority),
|
||||
content_hash,
|
||||
entries: want.entries,
|
||||
host_services: want.host_services,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -2250,6 +2264,14 @@ impl ExtensionHostManager {
|
||||
state.settled = state.selection.revision;
|
||||
}
|
||||
}
|
||||
let withheld = shared
|
||||
.registry
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.take_withheld_report();
|
||||
if let Some(withheld) = withheld {
|
||||
shared.diagnostic(withheld);
|
||||
}
|
||||
match (plugins, builtin) {
|
||||
(Err(error), Err(other)) => {
|
||||
shared.diagnostic(other);
|
||||
@@ -2396,6 +2418,7 @@ impl ExtensionHostManager {
|
||||
authority: None,
|
||||
content_hash: module.source_sha256.to_string(),
|
||||
entries: vec![entry],
|
||||
host_services: Vec::new(),
|
||||
}),
|
||||
Err(reason) => {
|
||||
let mut registry = shared.registry.lock().expect("registry lock");
|
||||
@@ -2443,13 +2466,17 @@ impl ExtensionHostManager {
|
||||
{
|
||||
Ok(owner.owner.clone())
|
||||
} else {
|
||||
registry.begin_owner(
|
||||
want.tier,
|
||||
plugin_id,
|
||||
&want.name,
|
||||
want.authority.clone(),
|
||||
&want.content_hash,
|
||||
)
|
||||
registry
|
||||
.begin_owner(
|
||||
want.tier,
|
||||
plugin_id,
|
||||
&want.name,
|
||||
want.authority.clone(),
|
||||
&want.content_hash,
|
||||
)
|
||||
.inspect(|owner| {
|
||||
registry.set_host_services(owner, want.host_services.clone());
|
||||
})
|
||||
}
|
||||
};
|
||||
let owner = match begun {
|
||||
@@ -2770,6 +2797,11 @@ impl ExtensionHostManager {
|
||||
supervision.planned_restart = None;
|
||||
runtime.host_generation.fetch_add(1, Ordering::SeqCst) + 1
|
||||
};
|
||||
shared
|
||||
.registry
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.host_process_started(tier);
|
||||
// At most two launches: under `auto`, before anything is pinned, a
|
||||
// Bun that cannot start is reported once and Node is resolved for the
|
||||
// rest of the session. An explicit `bun` or `node` never falls back.
|
||||
@@ -3187,10 +3219,18 @@ fn builtin_source_path(root: &Path, module: &BuiltinModule) -> PathBuf {
|
||||
/// Reviewed, enabled plugins with `native` entries, keyed by plugin id, read
|
||||
/// from Codewhale's immutable staged snapshot. Blocking.
|
||||
fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap<String, DesiredOwner>, Vec<String>) {
|
||||
let (sources, mut errors) = codewhale_plugins::runtime::active_component_sources(
|
||||
let (sources, errors) = codewhale_plugins::runtime::active_component_sources(
|
||||
plugins,
|
||||
PluginActivationCapability::Native,
|
||||
);
|
||||
desired_owners_from(plugins, sources, errors)
|
||||
}
|
||||
|
||||
fn desired_owners_from(
|
||||
plugins: &PluginRegistry,
|
||||
sources: Vec<codewhale_plugins::runtime::PluginComponentSource>,
|
||||
mut errors: Vec<String>,
|
||||
) -> (BTreeMap<String, DesiredOwner>, Vec<String>) {
|
||||
let mut desired: BTreeMap<String, DesiredOwner> = BTreeMap::new();
|
||||
let mut broken: BTreeSet<String> = BTreeSet::new();
|
||||
let mut needs_selection: BTreeSet<String> = BTreeSet::new();
|
||||
@@ -3230,11 +3270,60 @@ fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap<String, DesiredOwner>,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let owner = desired.entry(plugin_id).or_insert_with(|| DesiredOwner {
|
||||
plugin_name: source.plugin_name.clone(),
|
||||
authority: source.authority.clone(),
|
||||
entries: Vec::new(),
|
||||
});
|
||||
if !desired.contains_key(&plugin_id) {
|
||||
let record = match codewhale_plugins::registry::reviewed_record(
|
||||
&source.authority,
|
||||
) {
|
||||
Ok(record) => record,
|
||||
Err(reason) => {
|
||||
if broken.insert(plugin_id) {
|
||||
errors.push(format!(
|
||||
"Plugin `{}` was not loaded because its review record could not be read: {reason}",
|
||||
source.plugin_name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let by_agent =
|
||||
|approver: Option<&codewhale_plugins::host_services::PluginApprover>| {
|
||||
approver.is_some_and(|approver| {
|
||||
approver.cohort()
|
||||
!= codewhale_plugins::host_services::PluginCohort::Reviewed
|
||||
})
|
||||
};
|
||||
if by_agent(record.approved_by.as_ref())
|
||||
|| by_agent(
|
||||
plugins
|
||||
.get(&plugin_id)
|
||||
.and_then(|plugin| plugin.approved_by.as_ref()),
|
||||
)
|
||||
{
|
||||
if broken.insert(plugin_id) {
|
||||
errors.push(format!(
|
||||
"Plugin `{}` was approved by the agent, so its code does not run in the host of the plugins you reviewed; review it to run its code",
|
||||
source.plugin_name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
desired.insert(
|
||||
plugin_id.clone(),
|
||||
DesiredOwner {
|
||||
plugin_name: source.plugin_name.clone(),
|
||||
authority: source.authority.clone(),
|
||||
entries: Vec::new(),
|
||||
host_services: record
|
||||
.host_services
|
||||
.into_iter()
|
||||
.filter_map(|service| protocol::DshService::parse(service.name()))
|
||||
.collect(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let Some(owner) = desired.get_mut(&plugin_id) else {
|
||||
continue;
|
||||
};
|
||||
// The manifest may name one file twice; it is one entry.
|
||||
if !owner.entries.iter().any(|(path, _)| *path == source.path) {
|
||||
owner
|
||||
@@ -3539,6 +3628,45 @@ impl ManagerShared {
|
||||
&& state.selection.includes(plugin_id, hash, scope)
|
||||
})
|
||||
}
|
||||
pub(super) fn broker_services(&self, owner: &OwnerRef) -> Vec<protocol::DshService> {
|
||||
dsh_broker::granted_services(&self.registry.lock().expect("registry lock"), owner)
|
||||
}
|
||||
|
||||
pub(super) async fn broker_granted(
|
||||
&self,
|
||||
owner: &OwnerRef,
|
||||
service: protocol::DshService,
|
||||
) -> bool {
|
||||
if let Some(granted) = dsh_broker::test_grants() {
|
||||
return granted.contains(&service);
|
||||
}
|
||||
let authorities = {
|
||||
let registry = self.registry.lock().expect("registry lock");
|
||||
if !dsh_broker::granted(®istry, owner, service) {
|
||||
return false;
|
||||
}
|
||||
registry.plugin_host_authorities()
|
||||
};
|
||||
let Some(wanted) = codewhale_plugins::host_services::HostService::parse(service.name())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let policy = activation::extension_host_policy_enabled();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let env_scope = crate::test_support::env_scope_ticket();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _env_scope = crate::test_support::join_env_scope(env_scope);
|
||||
let _scope = activation::PolicyScope::propagate(policy);
|
||||
authorities.iter().all(|authority| {
|
||||
codewhale_plugins::registry::granted_host_services(authority)
|
||||
.is_ok_and(|granted| granted.contains(&wanted))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn check_selection(
|
||||
&self,
|
||||
selection: Option<codewhale_plugins::composition_scope::SelectionRevision>,
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::protocol::{RegisterKind, RegisterParams};
|
||||
use super::protocol::{DshService, RegisterKind, RegisterParams};
|
||||
use super::tier::HostTier;
|
||||
use codewhale_config::PluginAuthority;
|
||||
use codewhale_config::{EntryRef, OwnerRef};
|
||||
@@ -78,10 +78,21 @@ pub struct OwnerEntry {
|
||||
/// [`OwnerRegistry::set_config_hash`]). A change of config is a different
|
||||
/// activation: reconcile revokes the owner and activates a new generation.
|
||||
pub config_hash: String,
|
||||
pub host_services: Vec<DshService>,
|
||||
pub state: OwnerState,
|
||||
pub scopes: HashMap<EntryRef, OwnerState>,
|
||||
}
|
||||
|
||||
impl OwnerEntry {
|
||||
fn live(&self) -> bool {
|
||||
matches!(self.state, OwnerState::Activating | OwnerState::Active)
|
||||
}
|
||||
|
||||
fn loaded_in_plugin_host(&self) -> bool {
|
||||
self.tier == HostTier::Plugin && self.live()
|
||||
}
|
||||
}
|
||||
|
||||
/// Most schema violations one refused call reports back to the model.
|
||||
const MAX_REPORTED_INPUT_ERRORS: usize = 5;
|
||||
/// Bound on the refusal text (violations quote the offending values).
|
||||
@@ -261,6 +272,8 @@ pub struct OwnerRegistry {
|
||||
/// process build different native surfaces, and a name that is native
|
||||
/// anywhere is refused everywhere.
|
||||
native_names: HashSet<String>,
|
||||
plugin_host_departed: bool,
|
||||
withheld_reported: String,
|
||||
}
|
||||
|
||||
fn mint_token() -> String {
|
||||
@@ -428,6 +441,7 @@ impl OwnerRegistry {
|
||||
authority,
|
||||
content_hash: content_hash.to_string(),
|
||||
config_hash: String::new(),
|
||||
host_services: Vec::new(),
|
||||
state: OwnerState::Activating,
|
||||
scopes: HashMap::new(),
|
||||
},
|
||||
@@ -444,6 +458,110 @@ impl OwnerRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_host_services(&mut self, owner: &OwnerRef, services: Vec<DshService>) {
|
||||
if let Some(entry) = self.owners.get_mut(&owner.plugin_id)
|
||||
&& entry.owner == *owner
|
||||
&& entry.tier == HostTier::Plugin
|
||||
&& entry.authority.is_some()
|
||||
{
|
||||
entry.host_services = services;
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_service_granted(&self, owner: &OwnerRef, service: DshService) -> bool {
|
||||
self.current(owner)
|
||||
.is_some_and(|entry| entry.tier == HostTier::Plugin && entry.authority.is_some())
|
||||
&& !self.plugin_host_departed
|
||||
&& self
|
||||
.owners
|
||||
.values()
|
||||
.filter(|entry| entry.loaded_in_plugin_host())
|
||||
.all(|entry| entry.host_services.contains(&service))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugin_host_authorities(&self) -> Vec<PluginAuthority> {
|
||||
self.owners
|
||||
.values()
|
||||
.filter(|entry| entry.loaded_in_plugin_host())
|
||||
.filter_map(|entry| entry.authority.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_services_await_restart(&self) -> bool {
|
||||
self.plugin_host_departed
|
||||
&& self
|
||||
.owners
|
||||
.values()
|
||||
.any(|entry| entry.loaded_in_plugin_host() && !entry.host_services.is_empty())
|
||||
}
|
||||
|
||||
pub fn host_process_started(&mut self, tier: HostTier) {
|
||||
if tier == HostTier::Plugin {
|
||||
self.plugin_host_departed = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_withheld_report(&mut self) -> Option<String> {
|
||||
let loaded: Vec<&OwnerEntry> = self
|
||||
.owners
|
||||
.values()
|
||||
.filter(|entry| entry.loaded_in_plugin_host())
|
||||
.collect();
|
||||
let mut held: Vec<DshService> = DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|service| {
|
||||
loaded
|
||||
.iter()
|
||||
.any(|entry| entry.host_services.contains(service))
|
||||
})
|
||||
.collect();
|
||||
let report = if held.is_empty() {
|
||||
String::new()
|
||||
} else if self.plugin_host_departed {
|
||||
"host services are off until the plugin host restarts: a plugin that was turned off, changed or failed is still loaded in it".to_string()
|
||||
} else {
|
||||
held.retain(|service| {
|
||||
loaded
|
||||
.iter()
|
||||
.any(|entry| !entry.host_services.contains(service))
|
||||
});
|
||||
held.iter()
|
||||
.map(|service| {
|
||||
let mut without: Vec<&str> = loaded
|
||||
.iter()
|
||||
.filter(|entry| !entry.host_services.contains(service))
|
||||
.map(|entry| entry.plugin_name.as_str())
|
||||
.collect();
|
||||
without.sort_unstable();
|
||||
format!(
|
||||
"host service `{}` is off: the {} of {} {} not include it",
|
||||
service.name(),
|
||||
if without.len() == 1 {
|
||||
"review"
|
||||
} else {
|
||||
"reviews"
|
||||
},
|
||||
without
|
||||
.iter()
|
||||
.map(|name| format!("`{name}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
if without.len() == 1 { "does" } else { "do" },
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
};
|
||||
if report == self.withheld_reported {
|
||||
return None;
|
||||
}
|
||||
self.withheld_reported.clone_from(&report);
|
||||
(!report.is_empty()).then_some(report)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn owner(&self, plugin_id: &str) -> Option<&OwnerEntry> {
|
||||
self.owners.get(plugin_id)
|
||||
@@ -482,6 +600,7 @@ impl OwnerRegistry {
|
||||
if matches {
|
||||
self.remove_registrations_of(&owner.plugin_id);
|
||||
if let Some(entry) = self.owners.get_mut(&owner.plugin_id) {
|
||||
self.plugin_host_departed |= entry.loaded_in_plugin_host();
|
||||
entry.state = state;
|
||||
}
|
||||
}
|
||||
@@ -1594,7 +1713,8 @@ impl OwnerRegistry {
|
||||
pub fn revoke_owner(&mut self, plugin_id: &str) -> Option<OwnerRef> {
|
||||
self.remove_registrations_of(plugin_id);
|
||||
let entry = self.owners.get_mut(plugin_id)?;
|
||||
let was_live = matches!(entry.state, OwnerState::Activating | OwnerState::Active);
|
||||
let was_live = entry.live();
|
||||
self.plugin_host_departed |= entry.loaded_in_plugin_host();
|
||||
entry.state = OwnerState::Revoked;
|
||||
was_live.then(|| entry.owner.clone())
|
||||
}
|
||||
@@ -1602,7 +1722,9 @@ impl OwnerRegistry {
|
||||
/// Forget an owner entirely (after revocation, when its plugin is gone).
|
||||
pub fn forget_owner(&mut self, plugin_id: &str) {
|
||||
self.remove_registrations_of(plugin_id);
|
||||
self.owners.remove(plugin_id);
|
||||
if let Some(entry) = self.owners.remove(plugin_id) {
|
||||
self.plugin_host_departed |= entry.loaded_in_plugin_host();
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget owners that are not live (failed, faulted, revoked) so a new
|
||||
@@ -1655,6 +1777,7 @@ impl OwnerRegistry {
|
||||
/// owners are not touched: they live in another process.
|
||||
pub fn host_exited(&mut self, tier: HostTier, reason: &str) {
|
||||
self.clear_tier_registrations(tier);
|
||||
self.host_process_started(tier);
|
||||
let activating: Vec<_> = self
|
||||
.owners
|
||||
.values()
|
||||
@@ -1679,6 +1802,7 @@ impl OwnerRegistry {
|
||||
if entry.tier == tier
|
||||
&& matches!(entry.state, OwnerState::Activating | OwnerState::Active)
|
||||
{
|
||||
self.plugin_host_departed |= entry.tier == HostTier::Plugin;
|
||||
entry.state = OwnerState::Failed(reason.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2164,6 +2164,7 @@ fn attachment_changes_discard_the_complete_scan_before_owner_side_effects() {
|
||||
plugin_name: "plugin".into(),
|
||||
authority: fake_authority("plugin"),
|
||||
entries: Vec::new(),
|
||||
host_services: Vec::new(),
|
||||
},
|
||||
)]
|
||||
.into(),
|
||||
|
||||
@@ -130,6 +130,7 @@ pub enum Refusal {
|
||||
WrongOwner,
|
||||
WrongMethod,
|
||||
WrongTarget,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
@@ -158,12 +159,48 @@ pub struct Refused {
|
||||
/// [`INVALID_WINDOW`] are a protocol violation.
|
||||
pub const INVALID_BURST: usize = 8;
|
||||
pub const INVALID_WINDOW: Duration = Duration::from_secs(60);
|
||||
pub const REVOKED_GRACE: Duration = Duration::from_secs(10);
|
||||
const MAX_REVOKED: usize = 4096;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
rows: HashMap<String, Row>,
|
||||
/// Recent invalid presentations, by host process `(tier, generation)`.
|
||||
invalid: HashMap<(HostTier, u64), VecDeque<Instant>>,
|
||||
revoked: VecDeque<(String, Instant)>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn note_revoked(&mut self, ids: impl IntoIterator<Item = String>, now: Instant) {
|
||||
self.revoked.extend(ids.into_iter().map(|id| (id, now)));
|
||||
while self.revoked.len() > MAX_REVOKED
|
||||
|| self
|
||||
.revoked
|
||||
.front()
|
||||
.is_some_and(|(_, at)| now.saturating_duration_since(*at) >= REVOKED_GRACE)
|
||||
{
|
||||
self.revoked.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn revoked_just_now(&self, ticket: &str, now: Instant) -> bool {
|
||||
self.revoked
|
||||
.iter()
|
||||
.any(|(id, at)| id == ticket && now.saturating_duration_since(*at) < REVOKED_GRACE)
|
||||
}
|
||||
|
||||
fn revoke_where(&mut self, gone: impl Fn(&Row) -> bool) {
|
||||
let ids: Vec<String> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|(_, row)| gone(row))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in &ids {
|
||||
self.rows.remove(id);
|
||||
}
|
||||
self.note_revoked(ids, Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Every live ticket, behind one mutex.
|
||||
@@ -210,7 +247,9 @@ impl TicketTable {
|
||||
|
||||
fn redeem_at(&self, presented: &Presented<'_>, now: Instant) -> Result<Value, Refused> {
|
||||
let mut state = self.state.lock().expect("ticket lock");
|
||||
let revoked = state.revoked_just_now(presented.ticket, now);
|
||||
let verdict = match state.rows.get_mut(presented.ticket) {
|
||||
None if revoked => Err(Refusal::Revoked),
|
||||
None => Err(Refusal::Unknown),
|
||||
Some(row) => Self::check(row, presented, now).map(|target| {
|
||||
row.uses_left -= 1;
|
||||
@@ -225,8 +264,8 @@ impl TicketTable {
|
||||
Ok(target)
|
||||
}
|
||||
Err(reason) => {
|
||||
let violation =
|
||||
reason != Refusal::Exhausted && Self::note_invalid(&mut state, presented, now);
|
||||
let violation = !matches!(reason, Refusal::Exhausted | Refusal::Revoked)
|
||||
&& Self::note_invalid(&mut state, presented, now);
|
||||
Err(Refused { reason, violation })
|
||||
}
|
||||
}
|
||||
@@ -279,11 +318,10 @@ impl TicketTable {
|
||||
|
||||
/// Revoke one ticket (its invocation ended). Idempotent.
|
||||
pub fn revoke(&self, ticket: &Ticket) {
|
||||
self.state
|
||||
.lock()
|
||||
.expect("ticket lock")
|
||||
.rows
|
||||
.remove(ticket.expose());
|
||||
let mut state = self.state.lock().expect("ticket lock");
|
||||
if state.rows.remove(ticket.expose()).is_some() {
|
||||
state.note_revoked([ticket.expose().to_string()], Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke every ticket of `plugin_id` (its owner was revoked).
|
||||
@@ -291,8 +329,7 @@ impl TicketTable {
|
||||
self.state
|
||||
.lock()
|
||||
.expect("ticket lock")
|
||||
.rows
|
||||
.retain(|_, row| row.owner.plugin_id != plugin_id);
|
||||
.revoke_where(|row| row.owner.plugin_id == plugin_id);
|
||||
}
|
||||
|
||||
/// Revoke every ticket of one host process and forget its invalid-burst
|
||||
@@ -473,6 +510,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ticket_revoked_a_moment_ago_does_not_end_the_host() {
|
||||
let table = TicketTable::default();
|
||||
let a = owner("a", "token-a");
|
||||
let ended = table.mint(grant(5));
|
||||
let withdrawn = table.mint(grant(5));
|
||||
table.revoke(&ended);
|
||||
table.revoke_owner("a");
|
||||
let now = Instant::now();
|
||||
for ticket in [&ended, &withdrawn] {
|
||||
for _ in 0..(INVALID_BURST * 3) {
|
||||
assert_eq!(
|
||||
table.redeem_at(&presented(ticket, &a), now),
|
||||
Err(Refused {
|
||||
reason: Refusal::Revoked,
|
||||
violation: false
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(Refusal::Revoked.describe(), Refusal::Unknown.describe());
|
||||
let guess = Ticket("cwt.guess".to_string());
|
||||
let violations = (0..INVALID_BURST)
|
||||
.filter(|_| {
|
||||
table
|
||||
.redeem_at(&presented(&guess, &a), now)
|
||||
.unwrap_err()
|
||||
.violation
|
||||
})
|
||||
.count();
|
||||
assert_eq!(violations, 1, "a guess still counts");
|
||||
|
||||
let fresh = TicketTable::default();
|
||||
let stale = fresh.mint(grant(5));
|
||||
fresh.revoke(&stale);
|
||||
let later = Instant::now() + REVOKED_GRACE;
|
||||
let outcomes: Vec<Refused> = (0..INVALID_BURST)
|
||||
.map(|_| fresh.redeem_at(&presented(&stale, &a), later).unwrap_err())
|
||||
.collect();
|
||||
assert!(
|
||||
outcomes
|
||||
.iter()
|
||||
.all(|refused| refused.reason == Refusal::Unknown)
|
||||
);
|
||||
assert!(
|
||||
outcomes.last().unwrap().violation,
|
||||
"after the grace it is a forgery again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tickets_are_revoked_with_their_invocation_owner_and_host_and_never_printed() {
|
||||
let table = TicketTable::default();
|
||||
@@ -492,12 +579,12 @@ mod tests {
|
||||
table.revoke(&by_call);
|
||||
assert_eq!(
|
||||
refusal_of(&table, &presented(&by_call, &a)),
|
||||
Refusal::Unknown
|
||||
Refusal::Revoked
|
||||
);
|
||||
table.revoke_owner("a");
|
||||
assert_eq!(
|
||||
refusal_of(&table, &presented(&by_owner, &a)),
|
||||
Refusal::Unknown
|
||||
Refusal::Revoked
|
||||
);
|
||||
assert_eq!(table.live(), 2, "only a's tickets went");
|
||||
table.revoke_host(HostTier::Plugin, 9);
|
||||
|
||||
@@ -362,6 +362,7 @@ impl ToolSpec for HostToolSpec {
|
||||
gate,
|
||||
registration.scope.clone(),
|
||||
registration.content_hash.clone(),
|
||||
&self.manager.broker_services(®istration.owner),
|
||||
)
|
||||
});
|
||||
let request = CoreRequest::ToolCall(ToolCallParams {
|
||||
|
||||
+620
-214
File diff suppressed because it is too large.
Load diff
@@ -1727,9 +1727,10 @@ fn ssh_client_env() -> BTreeMap<String, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn process_base_env() -> BTreeMap<String, String> {
|
||||
base_env_from(std::env::vars_os())
|
||||
}
|
||||
const MANAGED_CONFIG_ENV: [&str; 2] = [
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
];
|
||||
|
||||
/// The worker's inherited environment: the repository's scrubbed child-env
|
||||
/// allowlist (`child_env`), so proxy routing, CA bundles, temp directories,
|
||||
@@ -1742,11 +1743,22 @@ where
|
||||
K: AsRef<std::ffi::OsStr>,
|
||||
V: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let parent: Vec<(std::ffi::OsString, std::ffi::OsString)> = parent
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string()))
|
||||
.collect();
|
||||
let mut env: BTreeMap<String, String> =
|
||||
codewhale_runtime::child_env::sanitized_runtime_env_from(parent)
|
||||
codewhale_runtime::child_env::sanitized_runtime_env_from(parent.iter().cloned())
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
|
||||
.collect();
|
||||
for (key, value) in &parent {
|
||||
if MANAGED_CONFIG_ENV.iter().any(|name| key == name)
|
||||
&& let (Some(key), Some(value)) = (key.to_str(), value.to_str())
|
||||
{
|
||||
env.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
force_worker_telemetry_off(&mut env);
|
||||
env
|
||||
}
|
||||
@@ -1754,7 +1766,7 @@ where
|
||||
/// Fleet workers are an implementation detail of the parent session, not
|
||||
/// sessions of their own — the parent already accounts for the dispatch.
|
||||
///
|
||||
/// The spawn path `env_clear()`s and rebuilds from [`process_base_env`], so an
|
||||
/// The spawn path `env_clear()`s and rebuilds from [`base_env_from`], so an
|
||||
/// operator's opt-out would otherwise never reach a worker at all. Hard-off
|
||||
/// here means a worker can never emit, can never inherit an ambient "on", and
|
||||
/// can never write telemetry state into the operator's home.
|
||||
@@ -1772,8 +1784,24 @@ fn worker_env(
|
||||
request_env: &BTreeMap<String, String>,
|
||||
allowlist: &BTreeSet<String>,
|
||||
) -> FleetHostResult<BTreeMap<String, String>> {
|
||||
let mut env = process_base_env();
|
||||
env.extend(filtered_env(request_env, allowlist)?);
|
||||
worker_env_from(std::env::vars_os(), request_env, allowlist)
|
||||
}
|
||||
|
||||
fn worker_env_from<K, V>(
|
||||
parent: impl IntoIterator<Item = (K, V)>,
|
||||
request_env: &BTreeMap<String, String>,
|
||||
allowlist: &BTreeSet<String>,
|
||||
) -> FleetHostResult<BTreeMap<String, String>>
|
||||
where
|
||||
K: AsRef<std::ffi::OsStr>,
|
||||
V: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let mut env = base_env_from(parent);
|
||||
let mut requested = filtered_env(request_env, allowlist)?;
|
||||
for key in MANAGED_CONFIG_ENV {
|
||||
requested.remove(key);
|
||||
}
|
||||
env.extend(requested);
|
||||
force_worker_telemetry_off(&mut env);
|
||||
Ok(env)
|
||||
}
|
||||
@@ -2770,11 +2798,47 @@ mod tests {
|
||||
assert_eq!(config.codewhale_binary, "/usr/local/bin/codewhale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_env_keeps_the_parent_managed_config_path_and_a_request_cannot_replace_it() {
|
||||
let parent = [
|
||||
("CODEWHALE_MANAGED_CONFIG_PATH", "/etc/example/managed.toml"),
|
||||
(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
"/etc/deepseek/managed_config.toml",
|
||||
),
|
||||
("USER", "fleet"),
|
||||
];
|
||||
let request_env = BTreeMap::from([
|
||||
(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH".to_string(),
|
||||
"/nonexistent/managed.toml".to_string(),
|
||||
),
|
||||
(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH".to_string(),
|
||||
"/nonexistent/managed.toml".to_string(),
|
||||
),
|
||||
]);
|
||||
let allowlist: BTreeSet<String> = request_env.keys().cloned().collect();
|
||||
let env = worker_env_from(parent, &request_env, &allowlist).expect("worker env");
|
||||
for (key, value) in &parent[..2] {
|
||||
assert_eq!(env.get(*key).map(String::as_str), Some(*value), "{key}");
|
||||
}
|
||||
|
||||
let env =
|
||||
worker_env_from([("USER", "fleet")], &request_env, &allowlist).expect("worker env");
|
||||
for key in MANAGED_CONFIG_ENV {
|
||||
assert!(
|
||||
!env.contains_key(key),
|
||||
"{key} must come only from the parent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_env_forces_telemetry_off_even_when_the_allowlist_says_otherwise() {
|
||||
// The spawn path env_clear()s and rebuilds from this map, so anything
|
||||
// absent here simply does not exist inside the worker.
|
||||
let base = process_base_env();
|
||||
let base = base_env_from(std::env::vars_os());
|
||||
assert_eq!(
|
||||
base.get("CODEWHALE_TELEMETRY").map(String::as_str),
|
||||
Some("false")
|
||||
|
||||
@@ -15,8 +15,10 @@ pub mod codex_model_cache;
|
||||
pub mod command_catalog;
|
||||
pub mod compaction;
|
||||
pub mod compaction_archive;
|
||||
pub mod conversation_modes;
|
||||
pub mod core;
|
||||
pub mod cost_status;
|
||||
pub mod diagnostics_messages;
|
||||
pub mod exec_events;
|
||||
pub mod extension_host;
|
||||
pub mod fleet;
|
||||
|
||||
@@ -70,6 +70,12 @@ const CLAIM_CEILING: [&str; 3] = [
|
||||
|
||||
pub use codewhale_command_contract::facets::debug_receipts::*;
|
||||
|
||||
mod usage;
|
||||
pub use usage::{
|
||||
cost_projection_from_snapshot, receipt_cost, saved_session_usage, session_usage,
|
||||
tokens_from_sources, with_usage,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalized input
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -325,7 +331,15 @@ pub fn thread_receipt(
|
||||
let notes = BTreeSet::from([
|
||||
"Shell file changes: a Runtime thread's workspace snapshots are not tagged with the thread, so files a command changed are not itemized; only file tools are.".to_string(),
|
||||
]);
|
||||
Ok(assemble(
|
||||
let covered: Vec<&TurnRecord> = ordered
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|record| turn.is_none_or(|turn| record.id == turn))
|
||||
.collect();
|
||||
let tokens = usage::thread_tokens(&covered);
|
||||
let mut notes = notes;
|
||||
notes.insert(usage::thread_cost_note().to_string());
|
||||
let mut receipt = assemble(
|
||||
source,
|
||||
steps,
|
||||
approvals,
|
||||
@@ -338,7 +352,12 @@ pub fn thread_receipt(
|
||||
approvals_recorded: true,
|
||||
},
|
||||
notes,
|
||||
))
|
||||
);
|
||||
receipt.usage = tokens.map(|tokens| ReceiptUsage {
|
||||
tokens: Some(tokens),
|
||||
cost: None,
|
||||
});
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -414,13 +433,16 @@ pub fn run_receipts_command(
|
||||
Some(prefix) => {
|
||||
let id = sessions.resolve_session_id_prefix(prefix)?;
|
||||
let session = sessions.load_session_snapshot(&id)?;
|
||||
session_receipt(
|
||||
session_source(&session.metadata),
|
||||
&session.messages,
|
||||
&session.approval_receipts,
|
||||
&session.turn_outcomes,
|
||||
turn,
|
||||
)?
|
||||
with_usage(
|
||||
session_receipt(
|
||||
session_source(&session.metadata),
|
||||
&session.messages,
|
||||
&session.approval_receipts,
|
||||
&session.turn_outcomes,
|
||||
turn,
|
||||
)?,
|
||||
saved_session_usage(&session.metadata),
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let latest_session = sessions.list_sessions()?.into_iter().next();
|
||||
@@ -440,13 +462,16 @@ pub fn run_receipts_command(
|
||||
}
|
||||
(Some(metadata), _, _) => {
|
||||
let session = sessions.load_session_snapshot(&metadata.id)?;
|
||||
session_receipt(
|
||||
session_source(&session.metadata),
|
||||
&session.messages,
|
||||
&session.approval_receipts,
|
||||
&session.turn_outcomes,
|
||||
turn,
|
||||
)?
|
||||
with_usage(
|
||||
session_receipt(
|
||||
session_source(&session.metadata),
|
||||
&session.messages,
|
||||
&session.approval_receipts,
|
||||
&session.turn_outcomes,
|
||||
turn,
|
||||
)?,
|
||||
saved_session_usage(&session.metadata),
|
||||
)
|
||||
}
|
||||
_ => anyhow::bail!("no saved session or Runtime thread to read"),
|
||||
}
|
||||
@@ -2009,6 +2034,7 @@ fn assemble(
|
||||
turn: turn.map(str::to_string),
|
||||
postures,
|
||||
totals,
|
||||
usage: None,
|
||||
actions,
|
||||
omitted_actions,
|
||||
not_recorded: notes.into_iter().collect(),
|
||||
|
||||
@@ -490,7 +490,10 @@ fn markdown_and_json_share_one_record() {
|
||||
"Changed 1 file (+2 −1) · ran 1 command · made 1 MCP call · 1 approved by you · 1 approved by session rule · 1 ran without asking under Ask · 1 denied by you · 2 other failures"
|
||||
));
|
||||
assert!(markdown.contains("\n1. edited `src/parse.rs` (+2 −1)"));
|
||||
assert!(markdown.contains("\nNot recorded:\n- Shell file changes:"));
|
||||
assert!(
|
||||
markdown.contains("\nNot recorded:\n- Cost: ")
|
||||
&& markdown.contains("\n- Shell file changes:")
|
||||
);
|
||||
|
||||
let json: Value = serde_json::from_str(&render_json(&receipt)).expect("json");
|
||||
assert_eq!(json["schema_id"], RECEIPT_SCHEMA_ID);
|
||||
@@ -1173,3 +1176,119 @@ fn session_receipts_join_only_unambiguous_execution_identities() {
|
||||
assert!(steps[0].output.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn unpriced_cost() -> crate::session_manager::SessionCostSnapshot {
|
||||
crate::session_manager::SessionCostSnapshot {
|
||||
unpriced_turns: 1,
|
||||
cny_unpriced_turns: 1,
|
||||
unpriced_reasons: ["unknown_billing_basis".to_string()].into(),
|
||||
cny_unpriced_reasons: ["unknown_billing_basis".to_string()].into(),
|
||||
coverage_recorded: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_receipt_carries_tokens_and_the_cost_line_cost_prints() {
|
||||
let receipt = session_receipt(session_source_fixture(), &[], &[], &[], None).unwrap();
|
||||
let projection = cost_projection_from_snapshot(&unpriced_cost());
|
||||
let usage = session_usage(&projection, &Default::default(), 1290);
|
||||
let receipt = with_usage(receipt, usage);
|
||||
let usage = receipt.usage.as_ref().expect("usage section");
|
||||
assert_eq!(usage.tokens.as_ref().map(|tokens| tokens.total), Some(1290));
|
||||
let cost = usage.cost.as_ref().expect("cost");
|
||||
assert_eq!(cost.state, ReceiptCostState::Unknown);
|
||||
assert!(cost.amount.is_none());
|
||||
assert!(
|
||||
cost.report.contains("Estimated total: unknown"),
|
||||
"{}",
|
||||
cost.report
|
||||
);
|
||||
assert!(
|
||||
cost.report.contains("billing basis unknown"),
|
||||
"{}",
|
||||
cost.report
|
||||
);
|
||||
assert_eq!(
|
||||
cost.report,
|
||||
codewhale_commands::debug::tokens::cost_report(
|
||||
&projection,
|
||||
&crate::diagnostics_messages::EnglishDiagnostics
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let json: Value = serde_json::from_str(&render_json(&receipt)).unwrap();
|
||||
assert_eq!(json["usage"]["cost"]["state"], "unknown");
|
||||
assert!(json["usage"]["cost"].get("amount").is_none());
|
||||
assert_eq!(json["usage"]["tokens"]["total"], 1290);
|
||||
let markdown = render_markdown(&receipt);
|
||||
assert!(markdown.contains("Tokens: 1290 total"), "{markdown}");
|
||||
assert!(markdown.contains("Estimated total: unknown"), "{markdown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priced_session_receipt_reports_the_amount_and_a_partial_total_as_partial() {
|
||||
let mut cost = crate::session_manager::SessionCostSnapshot {
|
||||
priced_turns: 2,
|
||||
session_cost_usd: 0.0123,
|
||||
coverage_recorded: true,
|
||||
..Default::default()
|
||||
};
|
||||
let priced = receipt_cost(&cost_projection_from_snapshot(&cost));
|
||||
assert_eq!(priced.state, ReceiptCostState::Priced);
|
||||
assert_eq!(priced.amount, Some(0.0123));
|
||||
assert_eq!(priced.currency, "USD");
|
||||
cost.unpriced_turns = 1;
|
||||
cost.unpriced_reasons.insert("unknown_billing_basis".into());
|
||||
let partial = receipt_cost(&cost_projection_from_snapshot(&cost));
|
||||
assert_eq!(partial.state, ReceiptCostState::Partial);
|
||||
assert_eq!(partial.amount, Some(0.0123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_turn_session_receipt_leaves_usage_out_and_says_so() {
|
||||
let messages: Vec<Message> = serde_json::from_value(json!([
|
||||
{"role":"user","content":[{"type":"text","text":"hi\n<turn_meta>\npermission_posture: Ask\n</turn_meta>"}]}
|
||||
]))
|
||||
.unwrap();
|
||||
let receipt =
|
||||
session_receipt(session_source_fixture(), &messages, &[], &[], Some("1")).unwrap();
|
||||
let usage = session_usage(
|
||||
&cost_projection_from_snapshot(&unpriced_cost()),
|
||||
&Default::default(),
|
||||
10,
|
||||
);
|
||||
let receipt = with_usage(receipt, usage);
|
||||
assert!(receipt.usage.is_none());
|
||||
assert!(
|
||||
receipt
|
||||
.not_recorded
|
||||
.iter()
|
||||
.any(|note| note.starts_with("Usage:"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_receipt_sums_turn_tokens_and_points_to_the_usage_endpoint_for_cost() {
|
||||
let (thread, mut turns, items, events) = thread_fixture();
|
||||
turns[0].usage = Some(codewhale_models::Usage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
..Default::default()
|
||||
});
|
||||
let receipt = thread_receipt(&thread, &turns, &items, &events, None).unwrap();
|
||||
let usage = receipt.usage.as_ref().expect("usage");
|
||||
let tokens = usage.tokens.as_ref().expect("tokens");
|
||||
assert_eq!(
|
||||
(tokens.input, tokens.output, tokens.total),
|
||||
(Some(100), Some(20), 120)
|
||||
);
|
||||
assert_eq!(tokens.calls, None);
|
||||
assert!(usage.cost.is_none());
|
||||
assert!(
|
||||
receipt
|
||||
.not_recorded
|
||||
.iter()
|
||||
.any(|note| note.starts_with("Cost:"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use codewhale_command_contract::facets::{
|
||||
DebugCostProjection, ReceiptCost, ReceiptCostState, ReceiptTokens, ReceiptUsage,
|
||||
};
|
||||
use codewhale_command_contract::types::CommandCurrency;
|
||||
use codewhale_commands::debug::tokens::{
|
||||
SUBSCRIPTION_ONLY_COST_NOTE, cost_is_subscription_only, cost_report,
|
||||
};
|
||||
|
||||
use super::Receipt;
|
||||
use crate::cost_status::UsageSourceContribution;
|
||||
use crate::diagnostics_messages::EnglishDiagnostics;
|
||||
use crate::llm_client::ProviderUsageCoverage;
|
||||
use crate::session_manager::{SessionCostSnapshot, SessionMetadata};
|
||||
|
||||
const TURN_SCOPE_NOTE: &str = "Usage: token counts and cost are kept for the whole session, not per turn, so a single-turn receipt leaves them out.";
|
||||
const NO_TOKENS_NOTE: &str = "Tokens: the session saved no provider token counts, so none are shown; that is not zero usage.";
|
||||
const THREAD_COST_NOTE: &str = "Cost: a Runtime thread receipt carries token counts only; GET /v1/threads/{id}/usage reports the thread's cost and what it covers.";
|
||||
|
||||
fn sanitize(amount: f64) -> f64 {
|
||||
if amount.is_finite() && amount >= 0.0 {
|
||||
amount
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cost_projection_from_snapshot(cost: &SessionCostSnapshot) -> DebugCostProjection {
|
||||
let cny = cost.cny_priced_turns > 0 && cost.priced_turns == 0;
|
||||
let (parent, auxiliary, high_water, priced, unpriced, reasons) = if cny {
|
||||
(
|
||||
cost.session_cost_cny,
|
||||
cost.subagent_cost_cny,
|
||||
cost.displayed_cost_high_water_cny,
|
||||
cost.cny_priced_turns,
|
||||
cost.cny_unpriced_turns,
|
||||
&cost.cny_unpriced_reasons,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
cost.session_cost_usd,
|
||||
cost.subagent_cost_usd,
|
||||
cost.displayed_cost_high_water_usd,
|
||||
cost.priced_turns,
|
||||
cost.unpriced_turns,
|
||||
&cost.unpriced_reasons,
|
||||
)
|
||||
};
|
||||
let parent_turns = sanitize(parent);
|
||||
let subagents = sanitize(auxiliary);
|
||||
let current = parent_turns + subagents;
|
||||
let total = current.max(sanitize(high_water));
|
||||
DebugCostProjection {
|
||||
currency: if cny {
|
||||
CommandCurrency::Cny
|
||||
} else {
|
||||
CommandCurrency::Usd
|
||||
},
|
||||
total,
|
||||
parent_turns,
|
||||
subagents,
|
||||
display_floor: (total - current).max(0.0),
|
||||
priced_turns: priced,
|
||||
unpriced_turns: unpriced,
|
||||
legacy_coverage_unknown: cost.coverage_is_legacy_unknown(),
|
||||
user_declared_estimates: cost.pricing_provenances.contains("user_override"),
|
||||
itemized_turns: 0,
|
||||
route_amounts: Vec::new(),
|
||||
turn_history_capacity: 0,
|
||||
unpriced_reason_labels: reasons.iter().cloned().collect(),
|
||||
unpriced_classes: cost.unpriced_classes.iter().cloned().collect(),
|
||||
pricing_provenances: cost.pricing_provenances.iter().cloned().collect(),
|
||||
live_pricing_defects: cost.live_pricing_defects.iter().cloned().collect(),
|
||||
unusable_pricing_defects: cost.live_pricing_unusable_defects.iter().cloned().collect(),
|
||||
route_receipts: cost.route_receipts.iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn receipt_cost(projection: &DebugCostProjection) -> ReceiptCost {
|
||||
let has_amount = projection.priced_turns > 0
|
||||
|| (projection.legacy_coverage_unknown && projection.total > 0.0);
|
||||
let state = if !has_amount {
|
||||
ReceiptCostState::Unknown
|
||||
} else if projection.unpriced_turns > 0 || projection.legacy_coverage_unknown {
|
||||
ReceiptCostState::Partial
|
||||
} else {
|
||||
ReceiptCostState::Priced
|
||||
};
|
||||
let report = if cost_is_subscription_only(projection) {
|
||||
SUBSCRIPTION_ONLY_COST_NOTE.to_string()
|
||||
} else {
|
||||
cost_report(projection, &EnglishDiagnostics)
|
||||
.unwrap_or_else(|error| format!("The cost report is unavailable: {error}"))
|
||||
};
|
||||
ReceiptCost {
|
||||
state,
|
||||
amount: has_amount.then_some(projection.total),
|
||||
currency: match projection.currency {
|
||||
CommandCurrency::Usd => "USD",
|
||||
CommandCurrency::Cny => "CNY",
|
||||
},
|
||||
priced_turns: projection.priced_turns,
|
||||
unpriced_turns: projection.unpriced_turns,
|
||||
report,
|
||||
}
|
||||
}
|
||||
|
||||
fn add(total: &mut Option<u64>, value: Option<u32>) {
|
||||
if let Some(value) = value {
|
||||
*total = Some(total.unwrap_or(0).saturating_add(u64::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tokens_from_sources(
|
||||
sources: &BTreeMap<String, UsageSourceContribution>,
|
||||
saved_total: u64,
|
||||
) -> Option<ReceiptTokens> {
|
||||
let mut tokens = ReceiptTokens::default();
|
||||
let (mut input, mut output, mut calls) = (0u64, 0u64, 0usize);
|
||||
for contribution in sources.values() {
|
||||
let record = &contribution.record;
|
||||
let reported = record.settlement.as_ref().map_or_else(
|
||||
|| record.usage.usage != Default::default(),
|
||||
|revision| {
|
||||
matches!(
|
||||
revision.coverage,
|
||||
ProviderUsageCoverage::Reported | ProviderUsageCoverage::Incomplete
|
||||
)
|
||||
},
|
||||
);
|
||||
if !reported {
|
||||
continue;
|
||||
}
|
||||
let usage = &record.usage.usage;
|
||||
input = input.saturating_add(u64::from(usage.input_tokens));
|
||||
output = output.saturating_add(u64::from(usage.output_tokens));
|
||||
add(&mut tokens.cache_read, usage.prompt_cache_hit_tokens);
|
||||
add(&mut tokens.cache_write, usage.prompt_cache_write_tokens);
|
||||
add(&mut tokens.reasoning, usage.reasoning_tokens);
|
||||
calls += 1;
|
||||
}
|
||||
if calls > 0 {
|
||||
tokens.input = Some(input);
|
||||
tokens.output = Some(output);
|
||||
tokens.total = input.saturating_add(output);
|
||||
tokens.calls = Some(calls);
|
||||
Some(tokens)
|
||||
} else if saved_total > 0 {
|
||||
tokens.total = saved_total;
|
||||
Some(tokens)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_usage(
|
||||
projection: &DebugCostProjection,
|
||||
sources: &BTreeMap<String, UsageSourceContribution>,
|
||||
saved_total_tokens: u64,
|
||||
) -> ReceiptUsage {
|
||||
ReceiptUsage {
|
||||
tokens: tokens_from_sources(sources, saved_total_tokens),
|
||||
cost: Some(receipt_cost(projection)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn saved_session_usage(metadata: &SessionMetadata) -> ReceiptUsage {
|
||||
session_usage(
|
||||
&cost_projection_from_snapshot(&metadata.cost),
|
||||
&metadata.cost.accounted_usage_sources,
|
||||
metadata.total_tokens,
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_usage(mut receipt: Receipt, usage: ReceiptUsage) -> Receipt {
|
||||
if receipt.turn.is_some() {
|
||||
receipt.not_recorded.push(TURN_SCOPE_NOTE.to_string());
|
||||
return receipt;
|
||||
}
|
||||
if usage.tokens.is_none() {
|
||||
receipt.not_recorded.push(NO_TOKENS_NOTE.to_string());
|
||||
}
|
||||
receipt.usage = Some(usage);
|
||||
receipt
|
||||
}
|
||||
|
||||
pub fn thread_tokens(turns: &[&crate::runtime_threads::TurnRecord]) -> Option<ReceiptTokens> {
|
||||
let mut tokens = ReceiptTokens::default();
|
||||
let (mut input, mut output, mut turns_with_usage) = (0u64, 0u64, 0usize);
|
||||
for usage in turns.iter().filter_map(|turn| turn.usage.as_ref()) {
|
||||
input = input.saturating_add(u64::from(usage.input_tokens));
|
||||
output = output.saturating_add(u64::from(usage.output_tokens));
|
||||
add(&mut tokens.cache_read, usage.prompt_cache_hit_tokens);
|
||||
add(&mut tokens.cache_write, usage.prompt_cache_write_tokens);
|
||||
add(&mut tokens.reasoning, usage.reasoning_tokens);
|
||||
turns_with_usage += 1;
|
||||
}
|
||||
(turns_with_usage > 0).then(|| {
|
||||
tokens.input = Some(input);
|
||||
tokens.output = Some(output);
|
||||
tokens.total = input.saturating_add(output);
|
||||
tokens
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thread_cost_note() -> &'static str {
|
||||
THREAD_COST_NOTE
|
||||
}
|
||||
@@ -422,8 +422,8 @@ pub fn derive_session_name(source_hint: Option<&str>) -> String {
|
||||
.unwrap_or("context");
|
||||
let mut out = String::new();
|
||||
for ch in hint.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
if ch.is_alphanumeric() {
|
||||
out.extend(ch.to_lowercase());
|
||||
} else if !out.ends_with('_') {
|
||||
out.push('_');
|
||||
}
|
||||
@@ -454,6 +454,8 @@ mod tests {
|
||||
derive_session_name(Some("src/Big File.rs")),
|
||||
"big_file_rs".to_string()
|
||||
);
|
||||
assert_eq!(derive_session_name(Some("docs/报告.txt")), "报告_txt");
|
||||
assert_eq!(derive_session_name(Some("docs/总结.txt")), "总结_txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -430,6 +430,8 @@ pub struct ThreadRecord {
|
||||
pub executor: Option<ExecutorRecord>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub environment: Option<ThreadEnvironment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -1465,6 +1465,8 @@ pub struct CreateThreadRequest {
|
||||
pub executor: Option<CreateThreadExecutor>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub environment: Option<ThreadEnvironment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -1514,6 +1516,8 @@ pub struct UpdateThreadRequest {
|
||||
/// route name in `model_provider`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -3966,6 +3970,12 @@ impl RuntimeThreadManager {
|
||||
self.config.read()
|
||||
}
|
||||
|
||||
pub fn extension_host_options(&self) -> crate::extension_host::ExtensionHostOptions {
|
||||
crate::extension_host::ExtensionHostOptions::from_config(
|
||||
self.read_config().extension_host.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn resolved_route_for_thread(
|
||||
&self,
|
||||
config: &Config,
|
||||
@@ -7171,6 +7181,18 @@ impl RuntimeThreadManager {
|
||||
if let Some(environment) = req.environment.as_ref() {
|
||||
return Err(environment.refusal().into());
|
||||
}
|
||||
if req.executor.is_some() && req.agent_preset.is_some() {
|
||||
return Err(crate::conversation_modes::ModeRefusal::with_executor().into());
|
||||
}
|
||||
let agent_preset = match req.agent_preset.as_deref() {
|
||||
Some(id) => {
|
||||
let selection =
|
||||
crate::conversation_modes::select(id, self.extension_host_options()).await?;
|
||||
selection.require_available()?;
|
||||
crate::conversation_modes::stored_form(&selection.id)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let executor = match req.executor.as_ref() {
|
||||
Some(requested) => {
|
||||
anyhow::ensure!(
|
||||
@@ -7311,6 +7333,7 @@ impl RuntimeThreadManager {
|
||||
last_read: None,
|
||||
executor,
|
||||
environment: None,
|
||||
agent_preset,
|
||||
};
|
||||
if let Some(operation_key) = req.operation_key {
|
||||
let key_fingerprint = runtime_turn_operation_key_fingerprint(
|
||||
@@ -7318,28 +7341,32 @@ impl RuntimeThreadManager {
|
||||
":thread-create:",
|
||||
&operation_key,
|
||||
)?;
|
||||
let mut fingerprint_body = json!({
|
||||
"version": 1,
|
||||
"model": thread.model,
|
||||
"model_provider": thread.model_provider,
|
||||
"model_provider_id": thread.model_provider_id,
|
||||
"reasoning_effort": thread.reasoning_effort,
|
||||
"allowed_tools": thread.allowed_tools,
|
||||
"add_dirs": thread.add_dirs,
|
||||
"workspace": thread.workspace,
|
||||
"mode": thread.mode,
|
||||
"permission_posture": thread.permission_posture,
|
||||
"allow_shell": thread.allow_shell,
|
||||
"trust_mode": thread.trust_mode,
|
||||
"auto_approve": thread.auto_approve,
|
||||
"archived": thread.archived,
|
||||
"system_prompt": thread.system_prompt,
|
||||
"task_id": thread.task_id,
|
||||
"dynamic_tools": req.dynamic_tools,
|
||||
"environments": req.environments,
|
||||
"executor": thread.executor,
|
||||
});
|
||||
if let Some(preset) = thread.agent_preset.as_ref() {
|
||||
fingerprint_body["agent_preset"] = json!(preset);
|
||||
}
|
||||
let request_fingerprint =
|
||||
codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(&json!({
|
||||
"version": 1,
|
||||
"model": thread.model,
|
||||
"model_provider": thread.model_provider,
|
||||
"model_provider_id": thread.model_provider_id,
|
||||
"reasoning_effort": thread.reasoning_effort,
|
||||
"allowed_tools": thread.allowed_tools,
|
||||
"add_dirs": thread.add_dirs,
|
||||
"workspace": thread.workspace,
|
||||
"mode": thread.mode,
|
||||
"permission_posture": thread.permission_posture,
|
||||
"allow_shell": thread.allow_shell,
|
||||
"trust_mode": thread.trust_mode,
|
||||
"auto_approve": thread.auto_approve,
|
||||
"archived": thread.archived,
|
||||
"system_prompt": thread.system_prompt,
|
||||
"task_id": thread.task_id,
|
||||
"dynamic_tools": req.dynamic_tools,
|
||||
"environments": req.environments,
|
||||
"executor": thread.executor,
|
||||
}))?);
|
||||
codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(&fingerprint_body)?);
|
||||
let _emit_order = self.event_emit.lock().await;
|
||||
let store = self.store.clone();
|
||||
let (thread, event) = tokio::task::spawn_blocking(move || {
|
||||
@@ -8400,9 +8427,26 @@ impl RuntimeThreadManager {
|
||||
&& req.workspace.is_none()
|
||||
&& req.model_provider.is_none()
|
||||
&& req.model_provider_id.is_none()
|
||||
&& req.agent_preset.is_none()
|
||||
{
|
||||
bail!("At least one thread field is required");
|
||||
}
|
||||
let preset_selection = match req.agent_preset.as_deref() {
|
||||
Some(preset_id) => {
|
||||
if self
|
||||
.store
|
||||
.load_thread(id)
|
||||
.is_ok_and(|thread| thread.executor.is_some())
|
||||
{
|
||||
return Err(crate::conversation_modes::ModeRefusal::with_executor().into());
|
||||
}
|
||||
Some(
|
||||
crate::conversation_modes::select(preset_id, self.extension_host_options())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if (req.model.is_some()
|
||||
|| req.model_provider.is_some()
|
||||
|| req.model_provider_id.is_some()
|
||||
@@ -8571,6 +8615,24 @@ impl RuntimeThreadManager {
|
||||
thread.trust_mode = trust_mode;
|
||||
changes.insert("trust_mode".to_string(), json!(trust_mode));
|
||||
}
|
||||
if let Some(selection) = preset_selection.as_ref() {
|
||||
let current =
|
||||
crate::conversation_modes::effective_id(thread.agent_preset.as_deref());
|
||||
if selection.id != current {
|
||||
if thread.latest_turn_id.is_some()
|
||||
|| active
|
||||
.engines
|
||||
.get(id)
|
||||
.and_then(|state| state.active_turn.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
return Err(crate::conversation_modes::ModeRefusal::locked().into());
|
||||
}
|
||||
selection.require_available()?;
|
||||
thread.agent_preset = crate::conversation_modes::stored_form(&selection.id);
|
||||
changes.insert("agent_preset".to_string(), json!(selection.id));
|
||||
}
|
||||
}
|
||||
let requested_model = match provider_switch {
|
||||
Some((model_provider, model_provider_id, model)) => {
|
||||
if thread.model_provider.as_deref() != Some(model_provider.as_str())
|
||||
@@ -8639,7 +8701,9 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
|
||||
let workspace_changed = changes.contains_key("workspace");
|
||||
let rebuild_engine = workspace_changed || changes.contains_key("system_prompt");
|
||||
let rebuild_engine = workspace_changed
|
||||
|| changes.contains_key("system_prompt")
|
||||
|| changes.contains_key("agent_preset");
|
||||
if rebuild_engine
|
||||
&& active
|
||||
.engines
|
||||
@@ -14568,10 +14632,7 @@ source_usage_records: Default::default(),
|
||||
}
|
||||
manager
|
||||
};
|
||||
let mut features = cfg.features();
|
||||
if self.host_profile.is_acp() {
|
||||
features.disable(codewhale_runtime::features::Feature::Mcp);
|
||||
}
|
||||
let features = cfg.features();
|
||||
let engine_cfg = EngineConfig {
|
||||
user_input_responder: true,
|
||||
session_checkpoint_backend: Some(
|
||||
@@ -14741,6 +14802,7 @@ source_usage_records: Default::default(),
|
||||
cfg.subagent_heartbeat_timeout_secs_for_provider(&route_identity),
|
||||
),
|
||||
prefer_bwrap: cfg.prefers_bwrap(),
|
||||
tool_launcher: cfg.tool_launcher.clone(),
|
||||
bwrap_extensions: codewhale_sandbox::BwrapMountExtensions {
|
||||
read_only_roots: cfg.bwrap_ro_roots.clone(),
|
||||
device_roots: cfg.bwrap_dev_roots.clone(),
|
||||
@@ -15158,10 +15220,11 @@ source_usage_records: Default::default(),
|
||||
if let Some(environment) = thread.environment.as_ref() {
|
||||
return Err(environment.refusal().into());
|
||||
}
|
||||
let (prefer_bwrap, bwrap_extensions, denied_read_paths) = {
|
||||
let (prefer_bwrap, tool_launcher, bwrap_extensions, denied_read_paths) = {
|
||||
let cfg = self.read_config();
|
||||
(
|
||||
cfg.prefers_bwrap(),
|
||||
cfg.tool_launcher.clone(),
|
||||
codewhale_sandbox::BwrapMountExtensions {
|
||||
read_only_roots: cfg.bwrap_ro_roots.clone(),
|
||||
device_roots: cfg.bwrap_dev_roots.clone(),
|
||||
@@ -15176,6 +15239,7 @@ source_usage_records: Default::default(),
|
||||
let manager = new_shared_shell_manager(thread.workspace.clone());
|
||||
if let Ok(mut guard) = manager.lock() {
|
||||
guard.set_prefer_bwrap(prefer_bwrap);
|
||||
guard.set_tool_launcher(tool_launcher);
|
||||
guard.set_bwrap_extensions(bwrap_extensions);
|
||||
guard.set_denied_read_subpaths(denied_read_paths);
|
||||
}
|
||||
@@ -18973,21 +19037,12 @@ pub async fn receipt_secret_values(config: Config) -> Result<Vec<String>> {
|
||||
}
|
||||
|
||||
pub fn summarize_text(text: &str, limit: usize) -> String {
|
||||
let take = limit.saturating_sub(3);
|
||||
let mut count = 0;
|
||||
let mut out = String::new();
|
||||
for ch in text.chars() {
|
||||
if count >= take {
|
||||
out.push_str("...");
|
||||
return out;
|
||||
}
|
||||
if ch.is_control() && ch != '\n' && ch != '\t' {
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
count += 1;
|
||||
}
|
||||
out
|
||||
let visible: String = text
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control() || matches!(ch, '\n' | '\t'))
|
||||
.take(limit.saturating_add(1))
|
||||
.collect();
|
||||
crate::rlm::turn::truncate_text(&visible, limit)
|
||||
}
|
||||
|
||||
pub(super) fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
|
||||
@@ -2668,6 +2668,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
|
||||
last_read: None,
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11614,10 +11615,9 @@ async fn compact_thread_receipt_keeps_exact_named_custom_identity() -> Result<()
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_thread_with_real_engine_reaches_terminal_status() -> Result<()> {
|
||||
// A real engine takes the workspace coordination lock exclusively, and
|
||||
// nextest runs tests in parallel processes with this crate as their working
|
||||
// directory. A `"."` workspace would therefore be the crate directory
|
||||
// itself, and two exclusive holders of it cannot both start.
|
||||
// itself, shared by every test process.
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let manager = RuntimeThreadManager::open(
|
||||
{
|
||||
@@ -19929,6 +19929,7 @@ async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Resu
|
||||
fn summarize_text_truncates() {
|
||||
let out = summarize_text("abcdefghijklmnopqrstuvwxyz", 10);
|
||||
assert_eq!(out, "abcdefg...");
|
||||
assert_eq!(summarize_text("abcdefgh", 10), "abcdefgh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -19967,6 +19968,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
|
||||
last_read: None,
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
};
|
||||
manager.store.save_thread(&thread)?;
|
||||
|
||||
@@ -21758,6 +21760,7 @@ async fn report_store_failure_names_the_item_file_without_terminalizing() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod conversation_modes;
|
||||
mod external_agent;
|
||||
mod task_ownership;
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::*;
|
||||
use crate::conversation_modes::{CREATOR, CreatorAvailabilityGuard, ModeRefusal, STANDARD};
|
||||
|
||||
fn refusal_code(error: &anyhow::Error) -> &'static str {
|
||||
error
|
||||
.downcast_ref::<ModeRefusal>()
|
||||
.expect("typed refusal")
|
||||
.code
|
||||
}
|
||||
|
||||
fn create_request(preset: Option<&str>) -> CreateThreadRequest {
|
||||
CreateThreadRequest {
|
||||
agent_preset: preset.map(str::to_string),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_record_without_agent_preset_loads_as_standard() -> Result<()> {
|
||||
let thread = sample_thread("thr_modes_legacy");
|
||||
let mut raw = serde_json::to_value(&thread)?;
|
||||
assert!(raw.get("agent_preset").is_none());
|
||||
let legacy: ThreadRecord = serde_json::from_value(raw.clone())?;
|
||||
assert_eq!(legacy.agent_preset, None);
|
||||
assert_eq!(
|
||||
crate::conversation_modes::effective_id(legacy.agent_preset.as_deref()),
|
||||
STANDARD
|
||||
);
|
||||
raw["agent_preset"] = json!(CREATOR);
|
||||
let creator: ThreadRecord = serde_json::from_value(raw)?;
|
||||
assert_eq!(creator.agent_preset.as_deref(), Some(CREATOR));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mode_is_set_at_creation_and_refused_when_unknown_or_unavailable() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let manager = test_manager(tmp.path().to_path_buf())?;
|
||||
{
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let created = manager.create_thread(create_request(Some(CREATOR))).await?;
|
||||
assert_eq!(created.agent_preset.as_deref(), Some(CREATOR));
|
||||
let standard = manager
|
||||
.create_thread(create_request(Some(STANDARD)))
|
||||
.await?;
|
||||
assert_eq!(standard.agent_preset, None);
|
||||
let unknown = manager
|
||||
.create_thread(create_request(Some("minimal")))
|
||||
.await
|
||||
.expect_err("unknown id");
|
||||
assert_eq!(refusal_code(&unknown), ModeRefusal::UNKNOWN_MODE);
|
||||
}
|
||||
let _missing = CreatorAvailabilityGuard::set(Some("no runtime"));
|
||||
let unavailable = manager
|
||||
.create_thread(create_request(Some(CREATOR)))
|
||||
.await
|
||||
.expect_err("unavailable");
|
||||
assert_eq!(refusal_code(&unavailable), ModeRefusal::MODE_UNAVAILABLE);
|
||||
assert_eq!(unavailable.to_string(), "no runtime");
|
||||
let table = crate::conversation_modes::current_table(manager.extension_host_options()).await;
|
||||
assert!(table.modes[0].available && table.modes[0].reason.is_none());
|
||||
assert!(!table.modes[1].available);
|
||||
assert_eq!(table.modes[1].reason.as_deref(), Some("no runtime"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mode_changes_only_before_the_first_turn_and_never_with_an_executor() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let manager = test_manager(tmp.path().to_path_buf())?;
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let thread = manager.create_thread(create_request(None)).await?;
|
||||
let patch = |preset: &str| UpdateThreadRequest {
|
||||
agent_preset: Some(preset.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let updated = manager.update_thread(&thread.id, patch(CREATOR)).await?;
|
||||
assert_eq!(updated.agent_preset.as_deref(), Some(CREATOR));
|
||||
let unknown = manager
|
||||
.update_thread(&thread.id, patch("nope"))
|
||||
.await
|
||||
.expect_err("unknown id");
|
||||
assert_eq!(refusal_code(&unknown), ModeRefusal::UNKNOWN_MODE);
|
||||
|
||||
let mut with_turn = manager.store.load_thread(&thread.id)?;
|
||||
with_turn.latest_turn_id = Some("turn_first".to_string());
|
||||
manager.store.save_thread(&with_turn)?;
|
||||
let locked = manager
|
||||
.update_thread(&thread.id, patch(STANDARD))
|
||||
.await
|
||||
.expect_err("locked");
|
||||
assert_eq!(refusal_code(&locked), ModeRefusal::MODE_LOCKED);
|
||||
manager.update_thread(&thread.id, patch(CREATOR)).await?;
|
||||
|
||||
let with_executor = CreateThreadRequest {
|
||||
agent_preset: Some(CREATOR.to_string()),
|
||||
executor: Some(CreateThreadExecutor {
|
||||
kind: "acp".to_string(),
|
||||
agent_id: "opencode".to_string(),
|
||||
config: CreateThreadExecutorConfig::default(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let refused = manager
|
||||
.create_thread(with_executor)
|
||||
.await
|
||||
.expect_err("executor");
|
||||
assert_eq!(
|
||||
refusal_code(&refused),
|
||||
ModeRefusal::MODE_NOT_SUPPORTED_WITH_EXECUTOR
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creator_is_listed_unavailable_until_it_loads_its_tools() -> Result<()> {
|
||||
let _real = CreatorAvailabilityGuard::unforced();
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let manager = test_manager(tmp.path().to_path_buf())?;
|
||||
let table = crate::conversation_modes::current_table(manager.extension_host_options()).await;
|
||||
assert!(table.modes[0].available);
|
||||
assert!(!table.modes[1].available);
|
||||
assert_eq!(
|
||||
table.modes[1].reason.as_deref(),
|
||||
Some(crate::conversation_modes::CREATOR_NOT_READY_REASON)
|
||||
);
|
||||
let refused = manager
|
||||
.create_thread(create_request(Some(CREATOR)))
|
||||
.await
|
||||
.expect_err("not ready");
|
||||
assert_eq!(refusal_code(&refused), ModeRefusal::MODE_UNAVAILABLE);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1155,6 +1155,51 @@ async fn interrupting_a_pending_approval_answers_the_agents_request_as_cancelled
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_that_ignores_cancel_is_stopped_after_the_grace_period() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, fixture_script());
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
std::fs::write(
|
||||
workspace.join("fixture-plan.json"),
|
||||
json!({ "ignoreCancel": true }).to_string(),
|
||||
)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
let thread = manager
|
||||
.create_thread(fixture_thread_request(&workspace, "ask"))
|
||||
.await?;
|
||||
let turn = manager.start_turn(&thread.id, prompt("never stop")).await?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while fixture_lines(&workspace, "held-prompts.jsonl").is_empty() {
|
||||
anyhow::ensure!(Instant::now() < deadline, "the agent never got the prompt");
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
let interrupted = Instant::now();
|
||||
manager.interrupt_turn(&thread.id, &turn.id).await?;
|
||||
let settled = wait_for_turn(&manager, &turn.id).await?;
|
||||
let waited = interrupted.elapsed();
|
||||
assert_eq!(settled.status, RuntimeTurnStatus::Interrupted);
|
||||
assert!(
|
||||
settled
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("ended its process")),
|
||||
"the turn did not settle with the killed reason: {:?}",
|
||||
settled.error
|
||||
);
|
||||
assert!(
|
||||
waited >= Duration::from_secs(9) && waited < Duration::from_secs(15),
|
||||
"the forced stop came after {waited:?}, outside the grace period plus five seconds"
|
||||
);
|
||||
manager.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn additional_directories_reach_only_an_agent_that_advertises_them() -> Result<()> {
|
||||
if !node_available().await {
|
||||
|
||||
@@ -2090,6 +2090,8 @@ pub struct SavedSession {
|
||||
/// record resume, export, and the Runtime API read back.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub turn_outcomes: Vec<SavedTurnOutcome>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
impl SavedSession {
|
||||
pub fn capture_publication(
|
||||
@@ -2302,6 +2304,7 @@ impl SavedSession {
|
||||
composer_color: None,
|
||||
last_auto_route: None,
|
||||
turn_outcomes: Vec::new(),
|
||||
agent_preset: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7079,6 +7082,7 @@ fn create_saved_session_inner(
|
||||
composer_color: None,
|
||||
last_auto_route: None,
|
||||
turn_outcomes: Vec::new(),
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
composer_color: None,
|
||||
last_auto_route: None,
|
||||
turn_outcomes: Vec::new(),
|
||||
agent_preset: None,
|
||||
};
|
||||
manager.save_session(&session).expect("save");
|
||||
}
|
||||
@@ -96,6 +97,7 @@
|
||||
composer_color: None,
|
||||
last_auto_route: None,
|
||||
turn_outcomes: Vec::new(),
|
||||
agent_preset: None,
|
||||
};
|
||||
manager.save_session(&session).expect("save empty");
|
||||
}
|
||||
|
||||
@@ -346,3 +346,19 @@
|
||||
primary.join("sessions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_session_without_agent_preset_loads_and_round_trips_the_mode() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
|
||||
let session = create_saved_session(&[], "test-model", tmp.path(), 0, None);
|
||||
let mut raw = serde_json::to_value(&session).expect("serialize");
|
||||
assert!(raw.get("agent_preset").is_none());
|
||||
let legacy: SavedSession = serde_json::from_value(raw.clone()).expect("legacy load");
|
||||
assert_eq!(legacy.agent_preset, None);
|
||||
raw["agent_preset"] = serde_json::json!("creator");
|
||||
let saved: SavedSession = serde_json::from_value(raw).expect("mode load");
|
||||
manager.save_session(&saved).expect("save");
|
||||
let loaded = manager.load_session(&saved.metadata.id).expect("load");
|
||||
assert_eq!(loaded.agent_preset.as_deref(), Some("creator"));
|
||||
}
|
||||
@@ -26,7 +26,7 @@ use crate::runtime_threads::{
|
||||
RUNTIME_STORE_FAILURE_EVENT, RuntimeEventRecord, RuntimeProcessOwnerLock, RuntimeTurnStatus,
|
||||
manager::CreateThreadRequest, manager::RuntimeThreadManager,
|
||||
manager::RuntimeThreadManagerConfig, manager::SharedRuntimeThreadManager,
|
||||
manager::StartTurnRequest,
|
||||
manager::StartTurnRequest, manager::summarize_text,
|
||||
};
|
||||
use codewhale_runtime::config::Config;
|
||||
use codewhale_util::utils::spawn_supervised;
|
||||
@@ -4177,24 +4177,6 @@ fn summarize_json(value: &Value) -> Option<String> {
|
||||
Some(summarize_text(&text, TIMELINE_SUMMARY_LIMIT))
|
||||
}
|
||||
|
||||
fn summarize_text(text: &str, limit: usize) -> String {
|
||||
let take = limit.saturating_sub(3);
|
||||
let mut count = 0;
|
||||
let mut out = String::new();
|
||||
for ch in text.chars() {
|
||||
if count >= take {
|
||||
out.push_str("...");
|
||||
return out;
|
||||
}
|
||||
if ch.is_control() && ch != '\n' && ch != '\t' {
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
count += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ensure_safe_storage_id(kind: &str, value: &str) -> Result<()> {
|
||||
let mut components = Path::new(value).components();
|
||||
let Some(component) = components.next() else {
|
||||
@@ -5006,14 +4988,16 @@ mod tests {
|
||||
Arc::new(MockExecutor),
|
||||
)
|
||||
.await?;
|
||||
sleep(STORE_IDLE_POLL_INTERVAL + Duration::from_millis(300)).await;
|
||||
settle_queue_fixture(&manager).await?;
|
||||
inspect_settled_workers(&manager).await?;
|
||||
manager.fingerprint_reads.store(0, Ordering::Relaxed);
|
||||
manager.store_loads.store(0, Ordering::Relaxed);
|
||||
|
||||
let window = Duration::from_secs(3);
|
||||
sleep(window).await;
|
||||
let started = Instant::now();
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
let reads = manager.fingerprint_reads.load(Ordering::Relaxed);
|
||||
let loads = manager.store_loads.load(Ordering::Relaxed);
|
||||
let window = started.elapsed();
|
||||
let ticks = (window.as_millis() / STORE_SETTLED_NAP.as_millis()) as usize + 1;
|
||||
assert!(
|
||||
reads <= workers * ticks,
|
||||
|
||||
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user