mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
Compare commits
50
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
|
||||
|
||||
+16
-1
@@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **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.
|
||||
- **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` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
|
||||
- **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`) unmodified, with their reference and template files. A bundled skill can now carry files: `load_skill` prints the skill's base directory and a short Codewhale host-notes block after DSH's text. A copy you edited is never overwritten on upgrade. Source-level only: the Creator plugin switch and agent entry that make these useful land in later slices.
|
||||
- **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.
|
||||
- **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 page says several agents share a 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 tools. 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 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 page says several agents share a 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 tools. This replaces the earlier reorganization of the home page, so the dated OrcaRouter offer strip and the community panel whose GitHub numbers filled in from the browser are no longer on it; 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 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 +48,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 +67,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.
|
||||
|
||||
Generated
+12
-12
@@ -1999,7 +1999,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 +2526,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2667,7 +2667,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 +2770,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4993,7 +4993,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 +6339,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 +6403,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 +7213,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7245,7 +7245,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 +8364,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(())
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,36 @@ mod platform {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
|
||||
let gone =
|
||||
codewhale_engine::runtime_threads::checked_runtime_store_root(selected_store)
|
||||
.is_ok_and(|selected| {
|
||||
selected == 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 +375,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 +386,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(),
|
||||
|
||||
@@ -1578,10 +1578,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 +2066,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;
|
||||
@@ -7083,6 +7114,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 +7129,7 @@ async fn runtime_info(
|
||||
.then(|| state.runtime_threads.job_operation_store_id().to_string()),
|
||||
account,
|
||||
experimental: RuntimeExperimentalCapabilities::default(),
|
||||
sandbox,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -11083,6 +11083,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(())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,58 @@ 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();
|
||||
}
|
||||
|
||||
#[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] {
|
||||
|
||||
+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!(
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -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,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,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, [])
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
)));
|
||||
|
||||
+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
@@ -17,6 +17,7 @@ pub mod compaction;
|
||||
pub mod compaction_archive;
|
||||
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
|
||||
}
|
||||
@@ -14568,10 +14568,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 +14738,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 +15156,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 +15175,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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -223,6 +223,15 @@ impl ToolSpec for GitTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_review_git_options(command: &mut std::process::Command, args: Vec<String>) {
|
||||
command
|
||||
.args(["--no-optional-locks", "--no-pager"])
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("GIT_NO_LAZY_FETCH", "1");
|
||||
}
|
||||
|
||||
async fn review_scope(input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let query = input
|
||||
.get("query")
|
||||
@@ -318,11 +327,7 @@ async fn review_scope(input: Value, context: &ToolContext) -> Result<ToolResult,
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!("Git preparation failed: {error}"))
|
||||
})??;
|
||||
command
|
||||
.args(["--no-optional-locks", "--no-pager", "--no-lazy-fetch"])
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0");
|
||||
apply_review_git_options(&mut command, args);
|
||||
let mut command = tokio::process::Command::from(command);
|
||||
let cancel = context.cancel_token.clone().unwrap_or_default();
|
||||
let stop = async {
|
||||
@@ -377,6 +382,73 @@ mod tests {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_git_options_use_env_not_a_flag_old_git_rejects() {
|
||||
let mut command = std::process::Command::new("git");
|
||||
apply_review_git_options(&mut command, vec!["rev-parse".into(), "HEAD".into()]);
|
||||
let args: Vec<_> = command
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
args,
|
||||
["--no-optional-locks", "--no-pager", "rev-parse", "HEAD"]
|
||||
);
|
||||
assert!(!args.iter().any(|a| a == "--no-lazy-fetch"));
|
||||
let lazy = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == "GIT_NO_LAZY_FETCH")
|
||||
.and_then(|(_, value)| value);
|
||||
assert_eq!(lazy, Some(std::ffi::OsStr::new("1")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_scope_resolve_runs_against_system_git() {
|
||||
if !<codewhale_dependencies::Git as codewhale_dependencies::ExternalTool>::available() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let run = |args: &[&str]| {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{args:?}: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
};
|
||||
run(&["init", "-q"]);
|
||||
run(&[
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init",
|
||||
]);
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let result = GitTool::new("Git")
|
||||
.execute(
|
||||
json!({"action": "review_scope", "query": "resolve", "rev": "HEAD"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("review_scope runs");
|
||||
let body: Value = serde_json::from_str(&result.content).expect("json");
|
||||
assert_eq!(body["exit_code"], 0, "{body}");
|
||||
assert_eq!(
|
||||
body["stdout"].as_str().map(|s| s.trim().len()),
|
||||
Some(40),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_action_is_refused_with_the_valid_values() {
|
||||
let message = err(json!({"path": "src"})).await;
|
||||
|
||||
@@ -708,7 +708,8 @@ pub fn enforce_tool_authority(
|
||||
| "context_archive_get"
|
||||
| "context_archive_search"
|
||||
| "present"
|
||||
) {
|
||||
) && tool.extension_caller().is_none()
|
||||
{
|
||||
return Err(ToolError::not_available(format!(
|
||||
"{name} is outside the ACP foreground tool profile"
|
||||
)));
|
||||
|
||||
@@ -2168,6 +2168,14 @@ impl ShellManager {
|
||||
self.sandbox_manager.set_prefer_bwrap(prefer);
|
||||
}
|
||||
|
||||
pub fn set_tool_launcher(&mut self, launcher: Option<Vec<String>>) {
|
||||
self.sandbox_manager.set_tool_launcher(launcher);
|
||||
}
|
||||
|
||||
pub fn tool_launcher_active(&self) -> bool {
|
||||
self.sandbox_manager.tool_launcher_active()
|
||||
}
|
||||
|
||||
/// Move the fallback working directory. Callers that pass an explicit
|
||||
/// `working_dir` are unaffected; this only keeps `None` honest when a
|
||||
/// thread's workspace changes while its jobs are still tracked here.
|
||||
@@ -2226,7 +2234,10 @@ impl ShellManager {
|
||||
)
|
||||
.with_policy(policy.clone())
|
||||
.with_env(env.clone());
|
||||
let prepared = self.sandbox_manager.prepare(&spec);
|
||||
let prepared = self
|
||||
.sandbox_manager
|
||||
.try_prepare(&spec)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if matches!(spec.sandbox_policy, ExecutionSandboxPolicy::ReadOnly { .. }) {
|
||||
require_native_readonly_execution(&prepared)?;
|
||||
}
|
||||
@@ -2462,7 +2473,11 @@ impl ShellManager {
|
||||
CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
|
||||
};
|
||||
let spec = spec.with_policy(policy).with_env(extra_env);
|
||||
let exec_env = self.sandbox_manager.prepare(&spec);
|
||||
let exec_env = self.sandbox_manager.try_prepare(&spec).map_err(|message| {
|
||||
anyhow::Error::msg(message.clone())
|
||||
.context(ShellLaunchNotStarted)
|
||||
.context(message)
|
||||
})?;
|
||||
if matches!(spec.sandbox_policy, ExecutionSandboxPolicy::ReadOnly { .. })
|
||||
&& readonly_workspace.is_none()
|
||||
{
|
||||
@@ -2534,7 +2549,10 @@ impl ShellManager {
|
||||
let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
|
||||
.with_policy(policy)
|
||||
.with_env(extra_env);
|
||||
let exec_env = self.sandbox_manager.prepare(&spec);
|
||||
let exec_env = self
|
||||
.sandbox_manager
|
||||
.try_prepare(&spec)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if matches!(spec.sandbox_policy, ExecutionSandboxPolicy::ReadOnly { .. }) {
|
||||
require_native_readonly_execution(&exec_env)?;
|
||||
}
|
||||
@@ -6043,6 +6061,25 @@ impl ToolSpec for BashTool {
|
||||
input: &serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Option<ToolResult> {
|
||||
if input
|
||||
.get("sandbox_permissions")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("danger-full-access")
|
||||
&& context
|
||||
.shell_manager
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.tool_launcher_active()
|
||||
{
|
||||
return Some(ToolResult {
|
||||
content: "BLOCKED: this host runs tool commands as another user, and only inside the sandbox, so a danger-full-access escalation cannot run. Retry without sandbox_permissions.".to_string(),
|
||||
success: false,
|
||||
metadata: Some(json!({
|
||||
"blocked": true,
|
||||
"reason": "tool_launcher_requires_sandbox",
|
||||
})),
|
||||
});
|
||||
}
|
||||
if context.auto_approve {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -6085,3 +6085,50 @@ async fn a_foreground_command_past_its_wait_moves_to_the_background_alive() {
|
||||
);
|
||||
assert!(manager.processes.get(&task_id).expect("tracked").background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_launcher_clamps_a_danger_full_access_escalation() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let escalation = json!({
|
||||
"command": "id",
|
||||
"sandbox_permissions": "danger-full-access",
|
||||
"justification": "needs the host",
|
||||
});
|
||||
let tool = BashTool::new("bash");
|
||||
assert!(tool.refusal_before_approval(&escalation, &ctx).is_none());
|
||||
|
||||
ctx.shell_manager
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set_tool_launcher(Some(vec!["/usr/bin/env".to_string()]));
|
||||
let refusal = tool
|
||||
.refusal_before_approval(&escalation, &ctx)
|
||||
.expect("escalation refused before approval");
|
||||
assert!(!refusal.success);
|
||||
assert!(
|
||||
refusal.content.contains("danger-full-access"),
|
||||
"{}",
|
||||
refusal.content
|
||||
);
|
||||
|
||||
let error = ctx
|
||||
.shell_manager
|
||||
.lock()
|
||||
.unwrap()
|
||||
.execute_with_options_env(
|
||||
"id",
|
||||
Some(&tmp.path().to_string_lossy()),
|
||||
1_000,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
Some(ExecutionSandboxPolicy::DangerFullAccess),
|
||||
HashMap::new(),
|
||||
)
|
||||
.expect_err("an approved escalation still never runs unwrapped");
|
||||
assert!(
|
||||
format!("{error:#}").contains("nothing was run"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
@@ -291,6 +291,20 @@ fn format_skill_body(skill: &Skill) -> String {
|
||||
out.push_str("## SKILL.md\n\n");
|
||||
out.push_str(skill.body.trim());
|
||||
out.push('\n');
|
||||
if matches!(skill.source, SkillSource::Native)
|
||||
&& let Some(context) =
|
||||
codewhale_skills::bundled_skill_load_context(&skill.name, &skill.path)
|
||||
{
|
||||
out.push_str(&format!(
|
||||
"\nBase directory for this skill: `{}`\n",
|
||||
context.base_dir.display()
|
||||
));
|
||||
if let Some(notes) = context.host_notes {
|
||||
out.push_str("\n## Host notes: Codewhale\n\n");
|
||||
out.push_str(notes.trim());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
let companions = collect_companion_files(skill);
|
||||
if !companions.is_empty() {
|
||||
@@ -884,4 +898,32 @@ mod tests {
|
||||
.contains("secret steps for model")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creator_skill_body_is_followed_by_base_directory_and_host_notes() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let tmp = tempdir().unwrap();
|
||||
let _home =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("home"));
|
||||
let owned = codewhale_runtime::config::owned_skills_dir().unwrap();
|
||||
codewhale_skills::install_system_skills(&owned, &Default::default()).unwrap();
|
||||
|
||||
let registry = SkillRegistry::discover(&owned);
|
||||
let skill = registry.get("cordis-plugin-development").unwrap();
|
||||
let rendered = format_skill_body(skill);
|
||||
let body_end = rendered.find(skill.body.trim()).unwrap() + skill.body.trim().len();
|
||||
let tail = &rendered[body_end..];
|
||||
let base = format!(
|
||||
"\nBase directory for this skill: `{}`\n",
|
||||
owned.join("cordis-plugin-development").display()
|
||||
);
|
||||
let tail = tail.strip_prefix('\n').unwrap();
|
||||
assert!(tail.starts_with(&base), "{tail}");
|
||||
assert!(tail[base.len()..].starts_with("\n## Host notes: Codewhale\n\n"));
|
||||
|
||||
let plain = registry.get("agent-experience").unwrap();
|
||||
let plain = format_skill_body(plain);
|
||||
assert!(plain.contains("Base directory for this skill"));
|
||||
assert!(!plain.contains("Host notes"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#[path = "support/acp_conformance.rs"]
|
||||
mod conformance;
|
||||
|
||||
use codewhale_engine::fleet::acp::{AcpClient, PermissionPolicy};
|
||||
use codewhale_engine::fleet::acp::{AcpClient, ControlWrite, PermissionPolicy};
|
||||
use codewhale_engine::fleet::host::{
|
||||
FleetHostAdapter, FleetWorkerCommand, FleetWorkerStartRequest, FleetWorkerTransport,
|
||||
LocalProcessFleetHostAdapter,
|
||||
@@ -11,6 +11,18 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const KNOWN: &[Allowed] = &[
|
||||
Allowed {
|
||||
side: Side::Client,
|
||||
frame: "session/set_model request",
|
||||
problem: "`session/set_model` is not a client request in official v1",
|
||||
reason: "compatibility, kept: sent only to an agent that offers the pre-v1 `models` object and no `model` config option",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/new response",
|
||||
problem: "field `.models` is not in the official type",
|
||||
reason: "the fixture's legacy leg offers the pre-v1 `models` object on purpose, to make the client send session/set_model",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "initialize response",
|
||||
@@ -149,6 +161,29 @@ fn the_acp_client_and_its_fixture_agents_speak_official_v1_shapes() {
|
||||
},
|
||||
));
|
||||
|
||||
let legacy_workspace = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
legacy_workspace.path().join("fixture-plan.json"),
|
||||
r#"{"legacyModels":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
frames.extend(captured(
|
||||
"acp-agent.mjs",
|
||||
legacy_workspace.path(),
|
||||
&capture.path().join("legacy.log"),
|
||||
|client| {
|
||||
let identity = client.initialize(STEP).unwrap();
|
||||
let created = client
|
||||
.new_session(&identity, legacy_workspace.path(), &[], STEP)
|
||||
.unwrap();
|
||||
let model = created.offer.control("model").unwrap();
|
||||
assert_eq!(model.write, Some(ControlWrite::SetModel));
|
||||
client
|
||||
.apply_control(&created.session_id, model, "legacy-b", STEP)
|
||||
.unwrap();
|
||||
},
|
||||
));
|
||||
|
||||
let mut observed = Conformance::checking(&[Side::Client, Side::Agent]);
|
||||
for (side, frame) in &frames {
|
||||
observed.observe(*side, frame);
|
||||
|
||||
+13
-1
@@ -96,7 +96,12 @@ function handle(message) {
|
||||
record('session-params.jsonl', { method: message.method, params: message.params });
|
||||
sessionId = `fixture-session-${process.pid}`;
|
||||
cwd = message.params?.cwd ?? cwd;
|
||||
result(message.id, { sessionId });
|
||||
result(message.id, {
|
||||
sessionId,
|
||||
...(plan().legacyModels
|
||||
? { models: { currentModelId: 'legacy-a', availableModels: [{ modelId: 'legacy-a', name: 'Legacy A' }, { modelId: 'legacy-b', name: 'Legacy B' }] } }
|
||||
: {}),
|
||||
});
|
||||
break;
|
||||
case 'session/load':
|
||||
record('session-params.jsonl', { method: message.method, params: message.params });
|
||||
@@ -106,11 +111,18 @@ function handle(message) {
|
||||
notify('session/update', { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'replayed answer' } } });
|
||||
result(message.id, {});
|
||||
break;
|
||||
case 'session/set_model':
|
||||
result(message.id, {});
|
||||
break;
|
||||
case 'session/set_config_option':
|
||||
configModel = message.params?.value ?? null;
|
||||
result(message.id, { configOptions: [] });
|
||||
break;
|
||||
case 'session/prompt':
|
||||
if (plan().ignoreCancel) {
|
||||
record('held-prompts.jsonl', { id: message.id });
|
||||
break;
|
||||
}
|
||||
if (HANG) {
|
||||
pendingPrompt = message;
|
||||
break;
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"prompt",
|
||||
"avatars",
|
||||
"mcp",
|
||||
"shellHooks"
|
||||
"shellHooks",
|
||||
"remote"
|
||||
]
|
||||
},
|
||||
"refused_services": [
|
||||
|
||||
@@ -1125,6 +1125,7 @@ fn capabilities_are_default(capabilities: &PluginCapabilities) -> bool {
|
||||
capabilities.filesystem_roots.is_empty()
|
||||
&& capabilities.network_hosts.is_empty()
|
||||
&& !capabilities.lifecycle_mutation
|
||||
&& capabilities.host_services.is_empty()
|
||||
}
|
||||
|
||||
fn config_to_standard_server(
|
||||
|
||||
@@ -399,6 +399,10 @@ fn load_plugin(
|
||||
content_hash: validated.content_hash,
|
||||
capability_hash: validated.capability_hash,
|
||||
state_generation: 0,
|
||||
approved_by: None,
|
||||
enabled_by: None,
|
||||
host_services: Vec::new(),
|
||||
outside_reach: false,
|
||||
skill_snapshots,
|
||||
diagnostics,
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,646 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HostService {
|
||||
Workspace,
|
||||
Llm,
|
||||
Network,
|
||||
Settings,
|
||||
Catalog,
|
||||
Terminal,
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl HostService {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Workspace,
|
||||
Self::Llm,
|
||||
Self::Network,
|
||||
Self::Settings,
|
||||
Self::Catalog,
|
||||
Self::Terminal,
|
||||
Self::Remote,
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "workspace",
|
||||
Self::Llm => "llm",
|
||||
Self::Network => "network",
|
||||
Self::Settings => "settings",
|
||||
Self::Catalog => "catalog",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Remote => "remote",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|service| service.name() == name)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "Workspace files and commands",
|
||||
Self::Llm => "Your model provider",
|
||||
Self::Network => "Network requests",
|
||||
Self::Settings => "Codewhale settings",
|
||||
Self::Catalog => "Plugins and provider routes",
|
||||
Self::Terminal => "Terminal sessions",
|
||||
Self::Remote => "Remote pairing",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plain(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => {
|
||||
"read and change files and run commands in the session's workspace, under Codewhale's approval rules"
|
||||
}
|
||||
Self::Llm => "send requests to the model provider you approved, on your account",
|
||||
Self::Network => {
|
||||
"send requests to the hosts in its review, with credentials it stores in Codewhale"
|
||||
}
|
||||
Self::Settings => "read and change the Codewhale settings in its review",
|
||||
Self::Catalog => {
|
||||
"list your provider routes, read your balance and plan plugin installs"
|
||||
}
|
||||
Self::Terminal => "start and drive terminal sessions in the session's workspace",
|
||||
Self::Remote => "pair another device with the session",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const HOST_SERVICE_INJECTS: &[(&str, HostService)] = &[];
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
static TEST_OFFER: std::cell::RefCell<Option<Vec<HostService>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub struct TestOffer(Option<Vec<HostService>>);
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl TestOffer {
|
||||
pub fn only(services: &[HostService]) -> Self {
|
||||
Self(TEST_OFFER.with(|offer| offer.replace(Some(services.to_vec()))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl Drop for TestOffer {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
TEST_OFFER.with(|offer| *offer.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn offered(service: HostService) -> bool {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if let Some(offer) = TEST_OFFER.with(|offer| offer.borrow().clone()) {
|
||||
return offer.contains(&service);
|
||||
}
|
||||
HOST_SERVICE_INJECTS
|
||||
.iter()
|
||||
.any(|(_, candidate)| *candidate == service)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn declared_injects(source: &str) -> BTreeSet<String> {
|
||||
static DECLARATION: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
static NAME: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
let declaration = DECLARATION.get_or_init(|| {
|
||||
regex::Regex::new(r"\binject\b\s*[:=]\s*(\[[^\]]*\]|\{[^;]*?\}\s*(?:[;,\n)]|$))")
|
||||
.expect("inject declaration pattern")
|
||||
});
|
||||
let name = NAME.get_or_init(|| {
|
||||
regex::Regex::new(
|
||||
r#""([A-Za-z_$][\w$]{0,63})"|'([A-Za-z_$][\w$]{0,63})'|([A-Za-z_$][\w$]{0,63})\s*:"#,
|
||||
)
|
||||
.expect("inject name pattern")
|
||||
});
|
||||
let mut names = BTreeSet::new();
|
||||
for found in declaration.captures_iter(source) {
|
||||
let body = found.get(1).map_or("", |body| body.as_str());
|
||||
for capture in name.captures_iter(body) {
|
||||
if let Some(matched) = capture.get(1).or(capture.get(2)).or(capture.get(3)) {
|
||||
names.insert(matched.as_str().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn services_for_injects<'a>(
|
||||
injects: impl IntoIterator<Item = &'a str>,
|
||||
table: &[(&str, HostService)],
|
||||
) -> Vec<HostService> {
|
||||
let services: BTreeSet<HostService> = injects
|
||||
.into_iter()
|
||||
.flat_map(|inject| {
|
||||
table
|
||||
.iter()
|
||||
.filter(move |(name, _)| *name == inject)
|
||||
.map(|(_, service)| *service)
|
||||
})
|
||||
.collect();
|
||||
services.into_iter().collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PersonVia {
|
||||
Terminal,
|
||||
App,
|
||||
ApprovalCard,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentPosture {
|
||||
FullAccess,
|
||||
AutoReview,
|
||||
CommandLine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentApproval {
|
||||
pub posture: AgentPosture,
|
||||
pub workspace: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum PluginApprover {
|
||||
Person { via: PersonVia },
|
||||
Agent(AgentApproval),
|
||||
CommandLine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum PluginCohort {
|
||||
Reviewed,
|
||||
Agent { workspace: String },
|
||||
}
|
||||
|
||||
impl PluginApprover {
|
||||
#[must_use]
|
||||
pub fn person(via: PersonVia) -> Self {
|
||||
Self::Person { via }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn command_line(agent_workspace: Option<&std::path::Path>) -> Self {
|
||||
match agent_workspace {
|
||||
Some(workspace) => Self::Agent(AgentApproval {
|
||||
posture: AgentPosture::CommandLine,
|
||||
workspace: workspace.to_string_lossy().into_owned(),
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
tool_call_id: None,
|
||||
}),
|
||||
None => Self::CommandLine,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Person { .. } => "person",
|
||||
Self::Agent(_) => "agent",
|
||||
Self::CommandLine => "command_line",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cohort(&self) -> PluginCohort {
|
||||
match self {
|
||||
Self::Agent(agent) => PluginCohort::Agent {
|
||||
workspace: agent.workspace.clone(),
|
||||
},
|
||||
Self::Person { .. } | Self::CommandLine => PluginCohort::Reviewed,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn agent_workspace(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Agent(agent) => Some(agent.workspace.as_str()),
|
||||
Self::Person { .. } | Self::CommandLine => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cohort_of(approver: Option<&PluginApprover>) -> PluginCohort {
|
||||
approver.map_or(PluginCohort::Reviewed, PluginApprover::cohort)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn approver_label(approver: Option<&PluginApprover>) -> &'static str {
|
||||
match approver {
|
||||
Some(PluginApprover::Person { .. }) => "approved by you",
|
||||
Some(PluginApprover::Agent(agent)) => match agent.posture {
|
||||
AgentPosture::FullAccess => "approved by the agent in Full Access",
|
||||
AgentPosture::AutoReview => "approved by the agent in Auto-Review",
|
||||
AgentPosture::CommandLine => "approved by the agent from a shell command",
|
||||
},
|
||||
Some(PluginApprover::CommandLine) => {
|
||||
"trusted from the command line with --yes; no review was shown there"
|
||||
}
|
||||
None => "approver not recorded (reviewed before 0.10.3)",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReviewApproval {
|
||||
pub approved_by: PluginApprover,
|
||||
pub host_services: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ReviewPeer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HostServiceLine {
|
||||
pub service: HostService,
|
||||
pub offered: bool,
|
||||
pub shared_with: Vec<ReviewPeer>,
|
||||
pub withheld_by: Vec<ReviewPeer>,
|
||||
}
|
||||
|
||||
pub const REVIEW_LEAD: &str = "This plugin asks to use services of the Codewhale host. Plugins share one process: a service works only while every plugin that is turned on holds it, and each of those plugins can use it while another's tool runs.";
|
||||
|
||||
fn quoted(peers: &[ReviewPeer]) -> String {
|
||||
peers
|
||||
.iter()
|
||||
.map(|peer| format!("`{}`", peer.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
impl HostServiceLine {
|
||||
#[must_use]
|
||||
pub fn sentence(&self) -> String {
|
||||
let label = self.service.label();
|
||||
if !self.offered {
|
||||
return format!(
|
||||
"{label}: this version of Codewhale does not offer it to plugins. Approving grants nothing for it."
|
||||
);
|
||||
}
|
||||
let mut sentence = format!("{label}: {}.", self.service.plain());
|
||||
if self.shared_with.is_empty() {
|
||||
sentence.push_str(" No other reviewed plugin holds it.");
|
||||
} else {
|
||||
sentence.push_str(&format!(
|
||||
" Shared with {}: each can use it as this plugin while this plugin's tool runs, and this plugin can use it as them.",
|
||||
quoted(&self.shared_with)
|
||||
));
|
||||
}
|
||||
if !self.withheld_by.is_empty() {
|
||||
sentence.push_str(&format!(
|
||||
" Off for now: {} {} turned on and {} review does not include it.",
|
||||
quoted(&self.withheld_by),
|
||||
if self.withheld_by.len() == 1 {
|
||||
"is"
|
||||
} else {
|
||||
"are"
|
||||
},
|
||||
if self.withheld_by.len() == 1 {
|
||||
"its"
|
||||
} else {
|
||||
"their"
|
||||
},
|
||||
));
|
||||
}
|
||||
sentence
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HostServiceReview {
|
||||
pub lines: Vec<HostServiceLine>,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl HostServiceReview {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
plugin_id: &str,
|
||||
content_hash: &str,
|
||||
capability_hash: &str,
|
||||
cohort: &PluginCohort,
|
||||
lines: Vec<HostServiceLine>,
|
||||
) -> Self {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut field = |label: &str, value: &str| {
|
||||
hasher.update(label.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update((value.len() as u64).to_le_bytes());
|
||||
hasher.update(value.as_bytes());
|
||||
};
|
||||
field("codewhale-host-service-review-v1", plugin_id);
|
||||
field("content", content_hash);
|
||||
field("capability", capability_hash);
|
||||
match cohort {
|
||||
PluginCohort::Reviewed => field("cohort", "reviewed"),
|
||||
PluginCohort::Agent { workspace } => field("cohort-agent", workspace),
|
||||
}
|
||||
for line in &lines {
|
||||
field("service", line.service.name());
|
||||
field("offered", if line.offered { "yes" } else { "no" });
|
||||
for peer in &line.shared_with {
|
||||
field("shared", &peer.id);
|
||||
}
|
||||
for peer in &line.withheld_by {
|
||||
field("withheld", &peer.id);
|
||||
}
|
||||
}
|
||||
let token = crate::manifest::hex_digest(hasher.finalize());
|
||||
Self { lines, token }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn grants(&self) -> Vec<HostService> {
|
||||
self.lines
|
||||
.iter()
|
||||
.filter(|line| line.offered)
|
||||
.map(|line| line.service)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> Vec<String> {
|
||||
if self.lines.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
std::iter::once(REVIEW_LEAD.to_string())
|
||||
.chain(self.lines.iter().map(HostServiceLine::sentence))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn peer(name: &str) -> ReviewPeer {
|
||||
ReviewPeer {
|
||||
id: format!("user/0/{name}"),
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line(shared: &[&str], withheld: &[&str], offered: bool) -> HostServiceLine {
|
||||
HostServiceLine {
|
||||
service: HostService::Network,
|
||||
offered,
|
||||
shared_with: shared.iter().map(|name| peer(name)).collect(),
|
||||
withheld_by: withheld.iter().map(|name| peer(name)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_host_service_is_offered_and_the_table_names_only_advertised_injects() {
|
||||
assert!(HOST_SERVICE_INJECTS.is_empty());
|
||||
assert!(
|
||||
HostService::ALL
|
||||
.into_iter()
|
||||
.all(|service| !offered(service))
|
||||
);
|
||||
let table: serde_json::Value =
|
||||
serde_json::from_slice(crate::activation::DSH_COMPATIBILITY).unwrap();
|
||||
let advertised: BTreeSet<&str> = table["provided_services"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.flat_map(|group| group.as_array().unwrap())
|
||||
.map(|name| name.as_str().unwrap())
|
||||
.collect();
|
||||
for (inject, _) in HOST_SERVICE_INJECTS {
|
||||
assert!(advertised.contains(inject), "{inject} is not advertised");
|
||||
}
|
||||
for service in HostService::ALL {
|
||||
assert_eq!(HostService::parse(service.name()), Some(service));
|
||||
assert_eq!(
|
||||
serde_json::to_value(service).unwrap(),
|
||||
serde_json::Value::String(service.name().into())
|
||||
);
|
||||
}
|
||||
let _offer = TestOffer::only(&[HostService::Llm]);
|
||||
assert!(offered(HostService::Llm));
|
||||
assert!(!offered(HostService::Network));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_review_says_whether_a_service_is_held_alone_shared_withheld_or_not_offered() {
|
||||
assert_eq!(
|
||||
line(&[], &[], true).sentence(),
|
||||
"Network requests: send requests to the hosts in its review, with credentials it stores in Codewhale. No other reviewed plugin holds it."
|
||||
);
|
||||
assert_eq!(
|
||||
line(&["alpha", "beta"], &[], true).sentence(),
|
||||
"Network requests: send requests to the hosts in its review, with credentials it stores in Codewhale. Shared with `alpha`, `beta`: each can use it as this plugin while this plugin's tool runs, and this plugin can use it as them."
|
||||
);
|
||||
assert_eq!(
|
||||
line(&[], &["gamma"], true).sentence(),
|
||||
"Network requests: send requests to the hosts in its review, with credentials it stores in Codewhale. No other reviewed plugin holds it. Off for now: `gamma` is turned on and its review does not include it."
|
||||
);
|
||||
assert!(
|
||||
line(&["alpha"], &["gamma", "delta"], true)
|
||||
.sentence()
|
||||
.ends_with("Off for now: `gamma`, `delta` are turned on and their review does not include it.")
|
||||
);
|
||||
assert_eq!(
|
||||
line(&["alpha"], &["gamma"], false).sentence(),
|
||||
"Network requests: this version of Codewhale does not offer it to plugins. Approving grants nothing for it."
|
||||
);
|
||||
let review = HostServiceReview::new(
|
||||
"id",
|
||||
"c",
|
||||
"k",
|
||||
&PluginCohort::Reviewed,
|
||||
vec![line(&["alpha"], &[], true)],
|
||||
);
|
||||
assert_eq!(review.text()[0], REVIEW_LEAD);
|
||||
assert_eq!(review.text().len(), 2);
|
||||
assert_eq!(review.grants(), vec![HostService::Network]);
|
||||
assert!(
|
||||
HostServiceReview::new(
|
||||
"id",
|
||||
"c",
|
||||
"k",
|
||||
&PluginCohort::Reviewed,
|
||||
vec![line(&[], &[], false)]
|
||||
)
|
||||
.grants()
|
||||
.is_empty()
|
||||
);
|
||||
let token =
|
||||
|lines| HostServiceReview::new("id", "c", "k", &PluginCohort::Reviewed, lines).token;
|
||||
let base = token(vec![line(&["alpha"], &[], true)]);
|
||||
assert_eq!(base.len(), 64);
|
||||
assert_ne!(base, token(vec![line(&["beta"], &[], true)]));
|
||||
assert_ne!(base, token(vec![line(&[], &["alpha"], true)]));
|
||||
assert_ne!(base, token(vec![line(&["alpha"], &[], false)]));
|
||||
assert_ne!(
|
||||
base,
|
||||
HostServiceReview::new(
|
||||
"other",
|
||||
"c",
|
||||
"k",
|
||||
&PluginCohort::Reviewed,
|
||||
vec![line(&["alpha"], &[], true)]
|
||||
)
|
||||
.token
|
||||
);
|
||||
assert_ne!(
|
||||
base,
|
||||
HostServiceReview::new(
|
||||
"id",
|
||||
"c2",
|
||||
"k",
|
||||
&PluginCohort::Reviewed,
|
||||
vec![line(&["alpha"], &[], true)]
|
||||
)
|
||||
.token
|
||||
);
|
||||
assert_ne!(
|
||||
base,
|
||||
HostServiceReview::new(
|
||||
"id",
|
||||
"c",
|
||||
"k",
|
||||
&PluginCohort::Agent {
|
||||
workspace: "/w".into()
|
||||
},
|
||||
vec![line(&["alpha"], &[], true)]
|
||||
)
|
||||
.token
|
||||
);
|
||||
assert!(
|
||||
HostServiceReview::new("id", "c", "k", &PluginCohort::Reviewed, Vec::new())
|
||||
.text()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_record_says_who_approved_in_one_shape_and_the_label_says_it_in_words() {
|
||||
let agent = PluginApprover::Agent(AgentApproval {
|
||||
posture: AgentPosture::AutoReview,
|
||||
workspace: "/w".into(),
|
||||
thread_id: Some("t".into()),
|
||||
turn_id: Some("u".into()),
|
||||
tool_call_id: Some("c".into()),
|
||||
});
|
||||
let cases = [
|
||||
(
|
||||
PluginApprover::person(PersonVia::ApprovalCard),
|
||||
serde_json::json!({"kind": "person", "via": "approval_card"}),
|
||||
"approved by you",
|
||||
),
|
||||
(
|
||||
agent.clone(),
|
||||
serde_json::json!({"kind": "agent", "posture": "auto_review", "workspace": "/w", "thread_id": "t", "turn_id": "u", "tool_call_id": "c"}),
|
||||
"approved by the agent in Auto-Review",
|
||||
),
|
||||
(
|
||||
PluginApprover::command_line(None),
|
||||
serde_json::json!({"kind": "command_line"}),
|
||||
"trusted from the command line with --yes; no review was shown there",
|
||||
),
|
||||
(
|
||||
PluginApprover::command_line(Some(std::path::Path::new("/w"))),
|
||||
serde_json::json!({"kind": "agent", "posture": "command_line", "workspace": "/w"}),
|
||||
"approved by the agent from a shell command",
|
||||
),
|
||||
];
|
||||
for (approver, json, label) in cases {
|
||||
assert_eq!(serde_json::to_value(&approver).unwrap(), json);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginApprover>(json).unwrap(),
|
||||
approver
|
||||
);
|
||||
assert_eq!(approver_label(Some(&approver)), label);
|
||||
}
|
||||
assert_eq!(
|
||||
approver_label(None),
|
||||
"approver not recorded (reviewed before 0.10.3)"
|
||||
);
|
||||
assert_eq!(cohort_of(None), PluginCohort::Reviewed);
|
||||
assert_eq!(
|
||||
PluginApprover::command_line(None).cohort(),
|
||||
PluginCohort::Reviewed
|
||||
);
|
||||
assert_eq!(
|
||||
agent.cohort(),
|
||||
PluginCohort::Agent {
|
||||
workspace: "/w".into()
|
||||
}
|
||||
);
|
||||
assert!(serde_json::from_value::<PluginApprover>(serde_json::json!("person")).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<PluginApprover>(serde_json::json!({"kind": "agent"})).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_declarations_map_to_host_services_through_one_table() {
|
||||
let table = [
|
||||
("llm", HostService::Llm),
|
||||
("credentials", HostService::Network),
|
||||
("pluginManager", HostService::Catalog),
|
||||
("deepseekAccount", HostService::Catalog),
|
||||
];
|
||||
let array =
|
||||
"export const inject = ['tools', 'llm', \"subagents\"]\nconst other = ['credentials']";
|
||||
assert_eq!(
|
||||
declared_injects(array),
|
||||
["llm", "subagents", "tools"].map(String::from).into()
|
||||
);
|
||||
assert_eq!(
|
||||
services_for_injects(declared_injects(array).iter().map(String::as_str), &table),
|
||||
vec![HostService::Llm]
|
||||
);
|
||||
let property =
|
||||
"var plugin={name:'w',inject:['webServer','credentials','connection'],apply(){}}";
|
||||
assert_eq!(
|
||||
services_for_injects(
|
||||
declared_injects(property).iter().map(String::as_str),
|
||||
&table
|
||||
),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
let object = "class P { static inject = { required: ['pluginManager'], optional: [\"llm\"] }; }\nPlugin.inject = { deepseekAccount: { required: false } };";
|
||||
assert_eq!(
|
||||
services_for_injects(declared_injects(object).iter().map(String::as_str), &table),
|
||||
vec![HostService::Llm, HostService::Catalog]
|
||||
);
|
||||
assert!(declared_injects("const injected = ['llm']; reinject('llm')").is_empty());
|
||||
assert!(
|
||||
services_for_injects(declared_injects(array).iter().map(String::as_str), &[])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2599,6 +2599,48 @@ fn host_hook_bridge(name: &str) -> bool {
|
||||
crate::activation::dsh_row_bridge(name)
|
||||
}
|
||||
|
||||
pub(crate) fn asked_host_services(
|
||||
files: &BTreeMap<String, Vec<u8>>,
|
||||
rows: &[String],
|
||||
client_closures: &[DshClientClosure],
|
||||
table: &[(&str, crate::host_services::HostService)],
|
||||
) -> Vec<crate::host_services::HostService> {
|
||||
if table.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut injects = std::collections::BTreeSet::new();
|
||||
for (path, bytes) in files {
|
||||
let Some(relative) = path.strip_prefix("source/") else {
|
||||
continue;
|
||||
};
|
||||
let module = relative
|
||||
.rsplit_once('.')
|
||||
.is_some_and(|(_, extension)| CLIENT_JS.contains(&extension));
|
||||
let own = match relative.split_once("node_modules/") {
|
||||
None => true,
|
||||
Some(("", dependency)) => rows.iter().any(|row| {
|
||||
dependency
|
||||
.strip_prefix(row.as_str())
|
||||
.and_then(|rest| rest.strip_prefix('/'))
|
||||
.is_some_and(|rest| !rest.split('/').any(|part| part == "node_modules"))
|
||||
}),
|
||||
Some(_) => false,
|
||||
};
|
||||
if !module
|
||||
|| !own
|
||||
|| client_closures
|
||||
.iter()
|
||||
.any(|closure| closure.files.contains_key(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||
injects.extend(crate::host_services::declared_injects(text));
|
||||
}
|
||||
}
|
||||
crate::host_services::services_for_injects(injects.iter().map(String::as_str), table)
|
||||
}
|
||||
|
||||
/// Closed relative modules and exact contained ESM package exports. Missing
|
||||
/// foreign rows remain manual ports for a portable-only import; a live Native
|
||||
/// graph refuses unresolved rows and never uses an ambient Node parent-walk.
|
||||
@@ -3061,8 +3103,33 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut capabilities = serde_json::Map::new();
|
||||
if !hosts.is_empty() {
|
||||
extension.insert("capabilities".into(), json!({"network_hosts": hosts}));
|
||||
capabilities.insert("network_hosts".into(), json!(hosts));
|
||||
}
|
||||
let host_services = if requires_native {
|
||||
asked_host_services(
|
||||
&output_files.files,
|
||||
&native_rows,
|
||||
&client_closures,
|
||||
crate::host_services::HOST_SERVICE_INJECTS,
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if !host_services.is_empty() {
|
||||
capabilities.insert("host_services".into(), json!(host_services));
|
||||
notes.push(format!(
|
||||
"asks for host services: {}; each is granted only by a review that shows it",
|
||||
host_services
|
||||
.iter()
|
||||
.map(|service| service.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
if !capabilities.is_empty() {
|
||||
extension.insert("capabilities".into(), Json::Object(capabilities));
|
||||
}
|
||||
if requires_node || loaded.platforms.is_some() {
|
||||
let mut when = serde_json::Map::new();
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod composition_scope;
|
||||
pub mod context;
|
||||
pub mod discovery;
|
||||
pub mod export;
|
||||
pub mod host_services;
|
||||
pub mod install;
|
||||
pub mod managed_policy;
|
||||
pub mod manifest;
|
||||
@@ -19,6 +20,8 @@ pub mod registry;
|
||||
pub mod runtime;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_service_tests;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_fixture;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::activation::CAPABILITY_HASH_DOMAIN_V2;
|
||||
use super::activation::{
|
||||
CAPABILITY_HASH_DOMAIN_V1, PluginActivationCapability, PluginActivationPolicy,
|
||||
};
|
||||
use super::host_services::HostService;
|
||||
use codewhale_mcp::config::{McpServerConfig, is_relative_stdio_path_arg};
|
||||
use codewhale_util::path_identity::metadata_is_link_or_reparse;
|
||||
#[cfg(windows)]
|
||||
@@ -146,6 +147,8 @@ pub struct PluginCapabilities {
|
||||
/// the bundle's supported declarative adapters from activating.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub lifecycle_mutation: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub host_services: Vec<HostService>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
@@ -191,6 +194,8 @@ pub struct PluginInventory {
|
||||
pub filesystem_roots: Vec<String>,
|
||||
pub network_hosts: Vec<String>,
|
||||
pub lifecycle_mutation: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub host_services: Vec<HostService>,
|
||||
}
|
||||
|
||||
/// Host compatibility of a reviewed bundle. This is independent of trust,
|
||||
@@ -324,7 +329,7 @@ impl PluginInventory {
|
||||
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
let mut summary = format!(
|
||||
"skills={} mcp={} (stdio={} remote={}) commands={} agents={} hooks={} lsp={} native={} providers={}",
|
||||
self.skills,
|
||||
self.mcp_servers,
|
||||
@@ -336,7 +341,12 @@ impl PluginInventory {
|
||||
self.lsp,
|
||||
self.native,
|
||||
self.providers
|
||||
)
|
||||
);
|
||||
if !self.host_services.is_empty() {
|
||||
summary.push_str(" host_services=");
|
||||
summary.push_str(&host_service_names(&self.host_services).join(","));
|
||||
}
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +624,12 @@ impl PluginManifest {
|
||||
validate_unique_texts("keyword", &self.plugin.keywords, 128)?;
|
||||
validate_unique_texts("filesystem root", &self.capabilities.filesystem_roots, 512)?;
|
||||
validate_unique_texts("network host", &self.capabilities.network_hosts, 253)?;
|
||||
let mut asked = BTreeSet::new();
|
||||
for service in &self.capabilities.host_services {
|
||||
if !asked.insert(*service) {
|
||||
return Err(format!("duplicate host service value `{}`", service.name()));
|
||||
}
|
||||
}
|
||||
let declared_network_hosts = self
|
||||
.capabilities
|
||||
.network_hosts
|
||||
@@ -946,6 +962,9 @@ impl PluginManifest {
|
||||
let mut filesystem_roots = self.capabilities.filesystem_roots.clone();
|
||||
filesystem_roots.sort();
|
||||
filesystem_roots.dedup();
|
||||
let mut host_services = self.capabilities.host_services.clone();
|
||||
host_services.sort();
|
||||
host_services.dedup();
|
||||
|
||||
Ok(PluginInventory {
|
||||
skills: components.skills.len(),
|
||||
@@ -961,6 +980,7 @@ impl PluginManifest {
|
||||
filesystem_roots,
|
||||
network_hosts,
|
||||
lifecycle_mutation: self.capabilities.lifecycle_mutation,
|
||||
host_services,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1601,10 +1621,14 @@ fn ensure_windows_path_still_opened(path: &Path, opened: &fs::File) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_inventory(inventory: &PluginInventory) -> String {
|
||||
pub(super) fn hash_inventory(inventory: &PluginInventory) -> String {
|
||||
hash_inventory_with_policy(inventory, PluginActivationPolicy::current())
|
||||
}
|
||||
|
||||
fn host_service_names(services: &[HostService]) -> Vec<&'static str> {
|
||||
services.iter().map(|service| service.name()).collect()
|
||||
}
|
||||
|
||||
fn hash_inventory_counts(inventory: &PluginInventory) -> BTreeMap<&'static str, String> {
|
||||
let mut normalized = BTreeMap::new();
|
||||
normalized.insert("skills", inventory.skills.to_string());
|
||||
@@ -1620,6 +1644,12 @@ fn hash_inventory_counts(inventory: &PluginInventory) -> BTreeMap<&'static str,
|
||||
normalized.insert("filesystem", inventory.filesystem_roots.join("\n"));
|
||||
normalized.insert("network", inventory.network_hosts.join("\n"));
|
||||
normalized.insert("lifecycle", inventory.lifecycle_mutation.to_string());
|
||||
if !inventory.host_services.is_empty() {
|
||||
normalized.insert(
|
||||
"host-services",
|
||||
host_service_names(&inventory.host_services).join("\n"),
|
||||
);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
@@ -2174,6 +2204,82 @@ mod tests {
|
||||
assert!(validated.inventory.can_activate_supported_components());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asked_host_services_move_the_capability_hash_and_none_keeps_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plain = PluginManifest::validate_from_path(&write_manifest(tmp.path(), "")).unwrap();
|
||||
assert!(plain.inventory.host_services.is_empty());
|
||||
let mut before = Sha256::new();
|
||||
PluginActivationPolicy::current().write_hash_material(&mut before);
|
||||
let inventory = &plain.inventory;
|
||||
for (key, value) in [
|
||||
("agents", inventory.agents.to_string()),
|
||||
("commands", inventory.commands.to_string()),
|
||||
("filesystem", inventory.filesystem_roots.join("\n")),
|
||||
("hooks", inventory.hooks.to_string()),
|
||||
("lifecycle", inventory.lifecycle_mutation.to_string()),
|
||||
("lsp", inventory.lsp.to_string()),
|
||||
("mcp", inventory.mcp_servers.to_string()),
|
||||
("mcp-remote", inventory.remote_mcp_servers.to_string()),
|
||||
("mcp-stdio", inventory.stdio_mcp_servers.to_string()),
|
||||
("native", inventory.native.to_string()),
|
||||
("network", inventory.network_hosts.join("\n")),
|
||||
("providers", inventory.providers.to_string()),
|
||||
("skills", inventory.skills.to_string()),
|
||||
] {
|
||||
before.update(key.as_bytes());
|
||||
before.update(b"\0");
|
||||
before.update(value.as_bytes());
|
||||
before.update(b"\0");
|
||||
}
|
||||
assert_eq!(
|
||||
plain.capability_hash,
|
||||
hex_digest(before.finalize()),
|
||||
"a bundle that asks for nothing hashes exactly as it did before"
|
||||
);
|
||||
assert!(!plain.inventory.summary().contains("host_services"));
|
||||
assert!(
|
||||
!serde_json::to_string(&plain.inventory)
|
||||
.unwrap()
|
||||
.contains("host_services")
|
||||
);
|
||||
|
||||
let asked = |list: &str| {
|
||||
PluginManifest::validate_from_path(&write_manifest(
|
||||
tmp.path(),
|
||||
&format!("[capabilities]\nhost_services = [{list}]\n"),
|
||||
))
|
||||
};
|
||||
let network = asked("\"network\"").unwrap();
|
||||
assert_eq!(network.inventory.host_services, vec![HostService::Network]);
|
||||
assert_ne!(network.capability_hash, plain.capability_hash);
|
||||
assert!(
|
||||
network
|
||||
.inventory
|
||||
.summary()
|
||||
.ends_with(" host_services=network")
|
||||
);
|
||||
let both = asked("\"network\", \"llm\"").unwrap();
|
||||
assert_eq!(
|
||||
both.inventory.host_services,
|
||||
vec![HostService::Llm, HostService::Network]
|
||||
);
|
||||
assert_ne!(both.capability_hash, network.capability_hash);
|
||||
assert_eq!(
|
||||
both.capability_hash,
|
||||
asked("\"llm\", \"network\"").unwrap().capability_hash
|
||||
);
|
||||
let reread: PluginInventory =
|
||||
serde_json::from_str(&serde_json::to_string(&both.inventory).unwrap()).unwrap();
|
||||
assert_eq!(reread, both.inventory);
|
||||
assert!(asked("\"everything\"").is_err());
|
||||
assert!(
|
||||
asked("\"llm\", \"llm\"")
|
||||
.unwrap_err()
|
||||
.contains("duplicate host service")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_policy_change_changes_the_capability_hash() {
|
||||
let inventory = PluginInventory {
|
||||
|
||||
@@ -437,6 +437,7 @@ fn count_declared_components(
|
||||
filesystem_roots: Vec::new(),
|
||||
network_hosts: Vec::new(),
|
||||
lifecycle_mutation: false,
|
||||
host_services: Vec::new(),
|
||||
}),
|
||||
diags,
|
||||
)
|
||||
|
||||
+413
-27
@@ -7,11 +7,17 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::activation::{PluginActivationCapability, PluginActivationPolicy};
|
||||
use super::host_services::{
|
||||
AgentPosture, HostService, HostServiceLine, HostServiceReview, PersonVia, PluginApprover,
|
||||
PluginCohort, ReviewApproval, ReviewPeer, cohort_of,
|
||||
};
|
||||
use super::managed_policy::{
|
||||
ManagedPluginPolicy, ManagedPolicyOutcome, load_managed_policy, resolve_managed_policy_path,
|
||||
};
|
||||
use super::manifest::PluginInventory;
|
||||
use super::types::{LoadedPlugin, PluginDiagnostic, PluginDiagnosticLevel, PluginTrustStatus};
|
||||
use super::types::{
|
||||
LoadedPlugin, PluginDiagnostic, PluginDiagnosticLevel, PluginScope, PluginTrustStatus,
|
||||
};
|
||||
use codewhale_config::{PluginAuthority, PluginId};
|
||||
use codewhale_util::path_identity::metadata_is_link_or_reparse;
|
||||
#[cfg(windows)]
|
||||
@@ -21,6 +27,7 @@ pub mod gc;
|
||||
|
||||
const STATE_SCHEMA_VERSION: u32 = 1;
|
||||
const MAX_REVIEW_HISTORY: usize = 32;
|
||||
const RECEIPT_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -46,6 +53,8 @@ struct PersistedPluginState {
|
||||
generation: u64,
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
enabled_by: Option<PluginApprover>,
|
||||
#[serde(default)]
|
||||
trust: Option<TrustReceipt>,
|
||||
#[serde(default)]
|
||||
@@ -59,6 +68,180 @@ struct TrustReceipt {
|
||||
capability_hash: String,
|
||||
reviewed_capabilities: PluginInventory,
|
||||
reviewed_at: String,
|
||||
#[serde(default, skip_serializing_if = "receipt_version_is_unset")]
|
||||
version: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
approved_by: Option<PluginApprover>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
host_services: Vec<HostService>,
|
||||
}
|
||||
|
||||
fn receipt_version_is_unset(version: &u32) -> bool {
|
||||
*version == 0
|
||||
}
|
||||
|
||||
impl TrustReceipt {
|
||||
fn granted(&self) -> Vec<HostService> {
|
||||
if self.version != RECEIPT_VERSION
|
||||
|| self.host_services.is_empty()
|
||||
|| !self
|
||||
.host_services
|
||||
.iter()
|
||||
.all(|service| self.reviewed_capabilities.host_services.contains(service))
|
||||
|| super::manifest::hash_inventory(&self.reviewed_capabilities) != self.capability_hash
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut granted = self.host_services.clone();
|
||||
granted.sort();
|
||||
granted.dedup();
|
||||
granted
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_name(id: &PluginId, names: &BTreeMap<PluginId, String>) -> String {
|
||||
names.get(id).cloned().unwrap_or_else(|| {
|
||||
id.as_str()
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn host_service_review_in(
|
||||
state: &PluginStateFile,
|
||||
plugin: &LoadedPlugin,
|
||||
names: &BTreeMap<PluginId, String>,
|
||||
cohort: &PluginCohort,
|
||||
) -> HostServiceReview {
|
||||
let peers = |holds: bool, service: HostService| -> Vec<ReviewPeer> {
|
||||
state
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|(id, _)| **id != plugin.id)
|
||||
.filter(|(_, entry)| {
|
||||
entry.trust.as_ref().is_some_and(|receipt| {
|
||||
if cohort_of(receipt.approved_by.as_ref()) != *cohort {
|
||||
return false;
|
||||
}
|
||||
if holds {
|
||||
receipt.granted().contains(&service)
|
||||
} else {
|
||||
entry.enabled
|
||||
&& receipt.reviewed_capabilities.native > 0
|
||||
&& !receipt.granted().contains(&service)
|
||||
}
|
||||
})
|
||||
})
|
||||
.map(|(id, _)| ReviewPeer {
|
||||
id: id.as_str().to_string(),
|
||||
name: peer_name(id, names),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let lines = plugin
|
||||
.inventory
|
||||
.host_services
|
||||
.iter()
|
||||
.map(|service| HostServiceLine {
|
||||
service: *service,
|
||||
offered: super::host_services::offered(*service),
|
||||
shared_with: peers(true, *service),
|
||||
withheld_by: peers(false, *service),
|
||||
})
|
||||
.collect();
|
||||
HostServiceReview::new(
|
||||
plugin.id.as_str(),
|
||||
&plugin.content_hash,
|
||||
&plugin.capability_hash,
|
||||
cohort,
|
||||
lines,
|
||||
)
|
||||
}
|
||||
|
||||
fn check_approver(
|
||||
approver: &PluginApprover,
|
||||
plugin: &LoadedPlugin,
|
||||
workspace: &Path,
|
||||
shown: bool,
|
||||
) -> Result<(), String> {
|
||||
let agent = match approver {
|
||||
PluginApprover::Agent(agent) => agent,
|
||||
PluginApprover::CommandLine if shown => {
|
||||
return Err(
|
||||
"An approval from the command line shows no review, so it cannot grant host services"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
PluginApprover::Person { .. } | PluginApprover::CommandLine => return Ok(()),
|
||||
};
|
||||
if !shown {
|
||||
return Err(
|
||||
"An approval by the agent must carry the token of the review composed for the call"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let identified = |value: &Option<String>| value.as_deref().is_some_and(|id| !id.is_empty());
|
||||
if matches!(
|
||||
agent.posture,
|
||||
AgentPosture::FullAccess | AgentPosture::AutoReview
|
||||
) && !(identified(&agent.thread_id)
|
||||
&& identified(&agent.turn_id)
|
||||
&& identified(&agent.tool_call_id))
|
||||
{
|
||||
return Err(
|
||||
"An approval by the agent must name the thread, turn and tool call that made it"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if agent.workspace.is_empty() || Path::new(&agent.workspace) != workspace {
|
||||
return Err(
|
||||
"An approval by the agent is limited to its own project, and this is not that project"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if plugin.scope == PluginScope::Builtin {
|
||||
return Err(
|
||||
"A built-in plugin ships with Codewhale, so the agent cannot approve it; ask the person"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if plugin.trusted()
|
||||
&& plugin
|
||||
.approved_by
|
||||
.as_ref()
|
||||
.and_then(PluginApprover::agent_workspace)
|
||||
.is_none()
|
||||
{
|
||||
return Err(format!(
|
||||
"Plugin bundle `{}` holds a review for every project, so the agent cannot replace it; ask the person",
|
||||
plugin.name()
|
||||
));
|
||||
}
|
||||
if plugin.trusted() && plugin.outside_reach {
|
||||
return Err(format!(
|
||||
"Plugin bundle `{}` holds the review of the agent of another project, so this agent cannot replace it; ask the person",
|
||||
plugin.name()
|
||||
));
|
||||
}
|
||||
if plugin.scope == PluginScope::Workspace {
|
||||
let inside = fs::canonicalize(workspace)
|
||||
.is_ok_and(|project| plugin.canonical_root.starts_with(project));
|
||||
if !inside {
|
||||
return Err(format!(
|
||||
"Plugin bundle `{}` is in a project plugin folder that resolves outside this project, so the agent cannot approve it; ask the person",
|
||||
plugin.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn outside_reach(approver: Option<&PluginApprover>, workspace: &Path) -> bool {
|
||||
approver
|
||||
.and_then(PluginApprover::agent_workspace)
|
||||
.is_some_and(|recorded| recorded.is_empty() || Path::new(recorded) != workspace)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -277,9 +460,21 @@ impl PluginRegistry {
|
||||
Some(_) => PluginTrustStatus::Trusted,
|
||||
None => PluginTrustStatus::NeverReviewed,
|
||||
};
|
||||
let receipt = persisted
|
||||
.and_then(|state| state.trust.as_ref())
|
||||
.filter(|_| plugin.trust_status == PluginTrustStatus::Trusted);
|
||||
plugin.approved_by = receipt.and_then(|receipt| receipt.approved_by.clone());
|
||||
plugin.enabled_by = persisted
|
||||
.filter(|state| state.enabled)
|
||||
.and_then(|state| state.enabled_by.clone());
|
||||
plugin.host_services = receipt.map(TrustReceipt::granted).unwrap_or_default();
|
||||
plugin.outside_reach = outside_reach(plugin.approved_by.as_ref(), &self.workspace);
|
||||
if self.state_error.is_some() {
|
||||
plugin.enabled = false;
|
||||
plugin.trust_status = PluginTrustStatus::NeverReviewed;
|
||||
plugin.approved_by = None;
|
||||
plugin.enabled_by = None;
|
||||
plugin.host_services = Vec::new();
|
||||
}
|
||||
// The managed policy is enforced here — inside the single choke
|
||||
// point that turns persisted state into live enablement — so a
|
||||
@@ -727,24 +922,98 @@ impl PluginRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_names(&self) -> BTreeMap<PluginId, String> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.map(|(id, plugin)| (id.clone(), plugin.name().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_service_review(&self, selector: &str) -> Option<HostServiceReview> {
|
||||
self.host_service_review_for(selector, &PluginApprover::person(PersonVia::Terminal))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_service_review_for(
|
||||
&self,
|
||||
selector: &str,
|
||||
approver: &PluginApprover,
|
||||
) -> Option<HostServiceReview> {
|
||||
if self.state_error.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(host_service_review_in(
|
||||
&self.state,
|
||||
self.get(selector)?,
|
||||
&self.peer_names(),
|
||||
&approver.cohort(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn trust(&mut self, selector: &str) -> Result<(), String> {
|
||||
self.trust_reviewed(
|
||||
selector,
|
||||
ReviewApproval {
|
||||
approved_by: PluginApprover::person(PersonVia::Terminal),
|
||||
host_services: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn trust_reviewed(
|
||||
&mut self,
|
||||
selector: &str,
|
||||
approval: ReviewApproval,
|
||||
) -> Result<(), String> {
|
||||
let plugin = self
|
||||
.get(selector)
|
||||
.ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?;
|
||||
let plugin = plugin.clone();
|
||||
let id = plugin.id.clone();
|
||||
check_approver(
|
||||
&approval.approved_by,
|
||||
&plugin,
|
||||
&self.workspace,
|
||||
approval.host_services.is_some(),
|
||||
)?;
|
||||
let state_path = self
|
||||
.state_path
|
||||
.as_deref()
|
||||
.ok_or_else(|| "Plugin registry has no persistence store".to_string())?;
|
||||
stage_bundle(state_path, &plugin, StageIntent::Review)?;
|
||||
let receipt = TrustReceipt {
|
||||
let names = self.peer_names();
|
||||
let cohort = approval.approved_by.cohort();
|
||||
let by_agent = matches!(approval.approved_by, PluginApprover::Agent(_));
|
||||
let mut receipt = TrustReceipt {
|
||||
content_hash: plugin.content_hash.clone(),
|
||||
capability_hash: plugin.capability_hash.clone(),
|
||||
reviewed_capabilities: plugin.inventory.clone(),
|
||||
reviewed_at: chrono::Utc::now().to_rfc3339(),
|
||||
version: RECEIPT_VERSION,
|
||||
approved_by: Some(approval.approved_by),
|
||||
host_services: Vec::new(),
|
||||
};
|
||||
self.commit_state_change(|state| {
|
||||
if by_agent
|
||||
&& state.plugins.get(&id).map_or(0, |entry| entry.generation)
|
||||
!= plugin.state_generation
|
||||
{
|
||||
return Err(format!(
|
||||
"Plugin bundle `{}` state or review changed; refresh plugin state and review it again",
|
||||
plugin.name()
|
||||
));
|
||||
}
|
||||
if let Some(shown) = approval.host_services.as_deref() {
|
||||
let review = host_service_review_in(state, &plugin, &names, &cohort);
|
||||
if review.token != shown {
|
||||
return Err(format!(
|
||||
"The host service review for plugin bundle `{}` changed since it was shown; read it again before approving",
|
||||
plugin.name()
|
||||
));
|
||||
}
|
||||
receipt.host_services = review.grants();
|
||||
}
|
||||
let entry = state.plugins.entry(id).or_default();
|
||||
entry.generation = entry
|
||||
.generation
|
||||
@@ -754,6 +1023,7 @@ impl PluginRegistry {
|
||||
// kept the enablement bit across revocation or content drift,
|
||||
// re-review must never reactivate the bundle implicitly.
|
||||
entry.enabled = false;
|
||||
entry.enabled_by = None;
|
||||
entry.trust = Some(receipt.clone());
|
||||
entry.review_history.push(receipt);
|
||||
if entry.review_history.len() > MAX_REVIEW_HISTORY {
|
||||
@@ -781,6 +1051,10 @@ impl PluginRegistry {
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, selector: &str) -> Result<(), String> {
|
||||
self.enable_by(selector, PluginApprover::person(PersonVia::Terminal))
|
||||
}
|
||||
|
||||
pub fn enable_by(&mut self, selector: &str, enabled_by: PluginApprover) -> Result<(), String> {
|
||||
let plugin = self
|
||||
.get(selector)
|
||||
.ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?
|
||||
@@ -816,6 +1090,26 @@ impl PluginRegistry {
|
||||
plugin.trust_status.as_str()
|
||||
));
|
||||
}
|
||||
if matches!(enabled_by, PluginApprover::Agent(_)) {
|
||||
if plugin
|
||||
.approved_by
|
||||
.as_ref()
|
||||
.and_then(PluginApprover::agent_workspace)
|
||||
.is_none()
|
||||
{
|
||||
return Err(format!(
|
||||
"Plugin bundle `{}` is approved for every project, so the agent cannot turn it on; ask the person",
|
||||
plugin.name()
|
||||
));
|
||||
}
|
||||
if plugin.outside_reach {
|
||||
return Err(format!(
|
||||
"Plugin bundle `{}` was approved by the agent of another project, so it cannot be turned on here",
|
||||
plugin.name()
|
||||
));
|
||||
}
|
||||
check_approver(&enabled_by, &plugin, &self.workspace, true)?;
|
||||
}
|
||||
let snapshot_rejection = match plugin.staged_root.as_deref() {
|
||||
None => Some(
|
||||
plugin
|
||||
@@ -874,6 +1168,7 @@ impl PluginRegistry {
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "Plugin authority generation is exhausted".to_string())?;
|
||||
entry.enabled = true;
|
||||
entry.enabled_by = Some(enabled_by);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -890,6 +1185,7 @@ impl PluginRegistry {
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "Plugin authority generation is exhausted".to_string())?;
|
||||
entry.enabled = false;
|
||||
entry.enabled_by = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -955,11 +1251,14 @@ impl PluginRegistry {
|
||||
let id = plugin.id.clone();
|
||||
let name = plugin.name().to_string();
|
||||
let applicable = plugin.applicable;
|
||||
let carried = TrustReceipt {
|
||||
let mut carried = TrustReceipt {
|
||||
content_hash: plugin.content_hash.clone(),
|
||||
capability_hash: plugin.capability_hash.clone(),
|
||||
reviewed_capabilities: plugin.inventory.clone(),
|
||||
reviewed_at: chrono::Utc::now().to_rfc3339(),
|
||||
version: 0,
|
||||
approved_by: None,
|
||||
host_services: Vec::new(),
|
||||
};
|
||||
self.commit_state_change(|state| {
|
||||
let Some(predecessor) = builtin_predecessor(state, &id, &name).cloned() else {
|
||||
@@ -968,9 +1267,14 @@ impl PluginRegistry {
|
||||
let Some(prior) = predecessor.trust else {
|
||||
return Ok(());
|
||||
};
|
||||
carried.approved_by.clone_from(&prior.approved_by);
|
||||
if carried.approved_by.is_some() {
|
||||
carried.version = RECEIPT_VERSION;
|
||||
}
|
||||
let mut entry = PersistedPluginState {
|
||||
generation: 1,
|
||||
enabled: false,
|
||||
enabled_by: None,
|
||||
trust: None,
|
||||
review_history: predecessor.review_history,
|
||||
};
|
||||
@@ -981,6 +1285,9 @@ impl PluginRegistry {
|
||||
return Ok(());
|
||||
}
|
||||
entry.enabled = predecessor.enabled && applicable;
|
||||
if entry.enabled {
|
||||
entry.enabled_by = predecessor.enabled_by;
|
||||
}
|
||||
entry.trust = Some(carried.clone());
|
||||
entry.review_history.push(carried);
|
||||
if entry.review_history.len() > MAX_REVIEW_HISTORY {
|
||||
@@ -1208,7 +1515,7 @@ fn persist_plugin_state_with_directory_sync(
|
||||
#[cfg(windows)]
|
||||
fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Result<(), String> {
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
use windows::Win32::Foundation::WIN32_ERROR;
|
||||
use windows::Win32::Foundation::{ERROR_ACCESS_DENIED, WIN32_ERROR};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_TEMPORARY, MOVEFILE_REPLACE_EXISTING,
|
||||
MOVEFILE_WRITE_THROUGH, MoveFileExW, SetFileAttributesW,
|
||||
@@ -1237,12 +1544,17 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul
|
||||
})?;
|
||||
|
||||
// The state lock serializes writers, but not everything that opens the
|
||||
// stable file: another registry re-applies the state directory's
|
||||
// stable file. Another registry re-applies the state directory's
|
||||
// inheritable ACL before it takes that lock, and Defender or the indexer
|
||||
// can scan the file. Each holds the destination only briefly, and
|
||||
// MoveFileExW then reports a refusal that clears on its own. Re-attempt
|
||||
// only the rename, on the schedule the other atomic writers share; the
|
||||
// hardened temporary and the stable file are both left untouched.
|
||||
// can scan the file; each holds it only briefly. A plugin UI grant holds
|
||||
// it for up to an hour. MoveFileExW refuses to replace a destination that
|
||||
// any handle has open, so on access denied publish with std::fs::rename,
|
||||
// which replaces a destination whose holders share delete access (POSIX
|
||||
// rename: NTFS, Windows 10 1709 or later), and flush through a handle
|
||||
// opened beforehand because that rename does not write through. When that
|
||||
// is refused too, re-attempt on the schedule the other atomic writers
|
||||
// share; a failed attempt leaves the hardened temporary and the stable
|
||||
// file untouched.
|
||||
let mut attempt = 0;
|
||||
// SAFETY: both paths are NUL-terminated and live.
|
||||
while let Err(error) = unsafe {
|
||||
@@ -1254,7 +1566,19 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul
|
||||
} {
|
||||
// MoveFileExW reports an HRESULT; the shared classifier reads the
|
||||
// Win32 code it wraps.
|
||||
let backoff = WIN32_ERROR::from_error(&error).and_then(|code| {
|
||||
let code = WIN32_ERROR::from_error(&error);
|
||||
if code == Some(ERROR_ACCESS_DENIED)
|
||||
&& let Ok(Some(source)) = open_existing_regular_file(&temporary_path, true)
|
||||
&& fs::rename(&temporary_path, path).is_ok()
|
||||
{
|
||||
temporary.disable_cleanup(true);
|
||||
return source.sync_all().map_err(|error| {
|
||||
format!(
|
||||
"plugin state was published but its durability could not be confirmed: {error}"
|
||||
)
|
||||
});
|
||||
}
|
||||
let backoff = code.and_then(|code| {
|
||||
codewhale_util::utils::windows_publish_retry_delay(
|
||||
&std::io::Error::from_raw_os_error(code.0 as i32),
|
||||
attempt,
|
||||
@@ -2977,6 +3301,30 @@ pub fn capture_plugin_state_authority(
|
||||
authority: &PluginAuthority,
|
||||
expected: Option<&fs::File>,
|
||||
) -> Result<fs::File, String> {
|
||||
read_active_receipt(authority, expected).map(|(state_file, _)| state_file)
|
||||
}
|
||||
|
||||
pub fn granted_host_services(authority: &PluginAuthority) -> Result<Vec<HostService>, String> {
|
||||
reviewed_record(authority).map(|record| record.host_services)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReviewedRecord {
|
||||
pub approved_by: Option<PluginApprover>,
|
||||
pub host_services: Vec<HostService>,
|
||||
}
|
||||
|
||||
pub fn reviewed_record(authority: &PluginAuthority) -> Result<ReviewedRecord, String> {
|
||||
read_active_receipt(authority, None).map(|(_, receipt)| ReviewedRecord {
|
||||
host_services: receipt.granted(),
|
||||
approved_by: receipt.approved_by,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_active_receipt(
|
||||
authority: &PluginAuthority,
|
||||
expected: Option<&fs::File>,
|
||||
) -> Result<(fs::File, TrustReceipt), String> {
|
||||
let policy_path = authority
|
||||
.managed_policy_source
|
||||
.as_deref()
|
||||
@@ -3038,29 +3386,31 @@ pub fn capture_plugin_state_authority(
|
||||
.map_err(|_| {
|
||||
"Plugin authority state is invalid; the bundle is disabled fail-closed".to_string()
|
||||
})?;
|
||||
let active = state
|
||||
let receipt = state
|
||||
.plugins
|
||||
.get(&authority.plugin_id)
|
||||
.is_some_and(|entry| {
|
||||
entry.generation == authority.state_generation
|
||||
&& entry.enabled
|
||||
&& entry.trust.as_ref().is_some_and(|receipt| {
|
||||
receipt.content_hash == authority.content_hash
|
||||
&& receipt.capability_hash == authority.capability_hash
|
||||
})
|
||||
});
|
||||
if !active {
|
||||
return Err(format!(
|
||||
"Plugin bundle `{}` is disabled, revoked, or no longer matches its review receipt",
|
||||
authority.plugin_name
|
||||
));
|
||||
}
|
||||
Ok(state_file)
|
||||
.filter(|entry| entry.generation == authority.state_generation && entry.enabled)
|
||||
.and_then(|entry| entry.trust.as_ref())
|
||||
.filter(|receipt| {
|
||||
receipt.content_hash == authority.content_hash
|
||||
&& receipt.capability_hash == authority.capability_hash
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Plugin bundle `{}` is disabled, revoked, or no longer matches its review receipt",
|
||||
authority.plugin_name
|
||||
)
|
||||
})?;
|
||||
Ok((state_file, receipt))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod state_publication_tests {
|
||||
use super::{PluginStateFile, harden_plugin_state_file, save_state_with_hardener};
|
||||
use super::{
|
||||
PluginStateFile, harden_plugin_state_file, open_existing_regular_file,
|
||||
save_state_with_hardener,
|
||||
};
|
||||
|
||||
fn prepare_private_directory(_path: &std::path::Path) {
|
||||
#[cfg(unix)]
|
||||
@@ -3093,6 +3443,42 @@ mod state_publication_tests {
|
||||
assert_eq!(entries, [std::ffi::OsString::from("state-鲸.json")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_publication_replaces_a_stable_file_that_a_reader_still_holds_open() {
|
||||
use std::io::Read as _;
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
prepare_private_directory(directory.path());
|
||||
let state_path = directory.path().join("state.json");
|
||||
std::fs::write(&state_path, b"old-authoritative-state").unwrap();
|
||||
let mut held = open_existing_regular_file(&state_path, false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
save_state_with_hardener(
|
||||
&state_path,
|
||||
&PluginStateFile::default(),
|
||||
harden_plugin_state_file,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut published = open_existing_regular_file(&state_path, false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!codewhale_util::files::same_file(&held, &published).unwrap());
|
||||
let mut superseded = String::new();
|
||||
held.read_to_string(&mut superseded).unwrap();
|
||||
assert_eq!(superseded, "old-authoritative-state");
|
||||
let mut current = String::new();
|
||||
published.read_to_string(&mut current).unwrap();
|
||||
assert!(current.contains("\"schema_version\": 1"));
|
||||
let entries = std::fs::read_dir(directory.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(entries, [std::ffi::OsString::from("state.json")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_temp_hardening_never_publishes_new_plugin_state() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -157,6 +157,9 @@ fn receipt(capability: &str, age: Duration) -> TrustReceipt {
|
||||
capability_hash: capability.into(),
|
||||
reviewed_capabilities: PluginInventory::default(),
|
||||
reviewed_at: rfc3339_ago(age),
|
||||
version: 0,
|
||||
approved_by: None,
|
||||
host_services: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +169,7 @@ fn trusted(capability: &str, enabled: bool, age: Duration) -> PersistedPluginSta
|
||||
PersistedPluginState {
|
||||
generation: 1,
|
||||
enabled,
|
||||
enabled_by: None,
|
||||
trust: Some(receipt.clone()),
|
||||
review_history: vec![receipt],
|
||||
}
|
||||
@@ -176,6 +180,7 @@ fn inert(age: Duration) -> PersistedPluginState {
|
||||
PersistedPluginState {
|
||||
generation: 4,
|
||||
enabled: false,
|
||||
enabled_by: None,
|
||||
trust: None,
|
||||
review_history: vec![receipt("cap", age)],
|
||||
}
|
||||
|
||||
@@ -154,6 +154,10 @@ pub struct LoadedPlugin {
|
||||
pub content_hash: String,
|
||||
pub capability_hash: String,
|
||||
pub state_generation: u64,
|
||||
pub approved_by: Option<super::host_services::PluginApprover>,
|
||||
pub enabled_by: Option<super::host_services::PluginApprover>,
|
||||
pub host_services: Vec<super::host_services::HostService>,
|
||||
pub outside_reach: bool,
|
||||
pub skill_snapshots: Vec<PluginSkillSnapshot>,
|
||||
pub diagnostics: Vec<PluginDiagnostic>,
|
||||
}
|
||||
@@ -187,6 +191,7 @@ impl LoadedPlugin {
|
||||
pub fn active(&self) -> bool {
|
||||
self.enabled
|
||||
&& self.trusted()
|
||||
&& !self.outside_reach
|
||||
&& self.staged_root.is_some()
|
||||
&& self.applicable
|
||||
&& self.inventory.can_activate_supported_components()
|
||||
|
||||
@@ -2159,6 +2159,8 @@ pub struct Config {
|
||||
/// NOT vendor bwrap.
|
||||
#[serde(alias = "preferBwrap")]
|
||||
pub prefer_bwrap: Option<bool>,
|
||||
#[serde(skip)]
|
||||
pub tool_launcher: Option<Vec<String>>,
|
||||
/// Additional host paths to bind read-only inside the bubblewrap sandbox
|
||||
/// (Linux, `prefer_bwrap = true`, #5410). The default root bind already
|
||||
/// exposes the host filesystem read-only; these cover setups where a
|
||||
@@ -3965,6 +3967,7 @@ impl Config {
|
||||
if let Some(msg) = warn_on_misplaced_top_level_keys(&contents) {
|
||||
tracing::warn!("{msg}");
|
||||
}
|
||||
reject_unmanaged_tool_launcher(&contents, path)?;
|
||||
parsed
|
||||
} else {
|
||||
apply_profile(ConfigFile::default(), profile)?;
|
||||
@@ -10172,6 +10175,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),
|
||||
sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),
|
||||
prefer_bwrap: override_cfg.prefer_bwrap.or(base.prefer_bwrap),
|
||||
tool_launcher: override_cfg.tool_launcher.or(base.tool_launcher),
|
||||
bwrap_ro_roots: if override_cfg.bwrap_ro_roots.is_empty() {
|
||||
base.bwrap_ro_roots
|
||||
} else {
|
||||
@@ -10714,10 +10718,8 @@ fn warn_on_misplaced_top_level_keys(raw: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn apply_managed_overrides(config: &mut Config) -> Result<()> {
|
||||
let path = config
|
||||
.managed_config_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
let path = managed_path_from_env()
|
||||
.or_else(|| config.managed_config_path.as_deref().map(expand_path))
|
||||
.or_else(default_managed_config_path);
|
||||
let Some(path) = path else {
|
||||
return Ok(());
|
||||
@@ -10727,6 +10729,7 @@ fn apply_managed_overrides(config: &mut Config) -> Result<()> {
|
||||
}
|
||||
let mut managed = load_single_config_file(&path)?;
|
||||
strip_external_credential_consent(&mut managed);
|
||||
managed.tool_launcher = managed_tool_launcher(&path)?;
|
||||
let prior_route = config
|
||||
.active_provider_identity()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
@@ -10786,10 +10789,171 @@ fn apply_managed_overrides(config: &mut Config) -> Result<()> {
|
||||
if managed.network.is_some() {
|
||||
merged.network_layer_is_managed = true;
|
||||
}
|
||||
if managed.tool_launcher.is_some()
|
||||
&& !matches!(
|
||||
managed.sandbox_mode.as_deref(),
|
||||
Some("workspace-write" | "read-only")
|
||||
)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"`tool_launcher` in the managed configuration {} needs `sandbox_mode = \"workspace-write\"` or `\"read-only\"` beside it: commands run through the launcher only inside the sandbox",
|
||||
codewhale_config::quote_os_path(&path)
|
||||
);
|
||||
}
|
||||
*config = merged;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tool_launcher_layer_error(path: &Path) -> anyhow::Error {
|
||||
anyhow::anyhow!(
|
||||
"`tool_launcher` in {} is not accepted: it is read only from the managed configuration (CODEWHALE_MANAGED_CONFIG_PATH, or the default managed path), in a file the Engine cannot write",
|
||||
codewhale_config::quote_os_path(path)
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_launcher_in_profiles(document: &toml::Value) -> bool {
|
||||
document
|
||||
.get("profiles")
|
||||
.and_then(toml::Value::as_table)
|
||||
.is_some_and(|profiles| {
|
||||
profiles
|
||||
.values()
|
||||
.any(|profile| profile.get("tool_launcher").is_some())
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_unmanaged_tool_launcher(raw: &str, path: &Path) -> Result<()> {
|
||||
let Ok(document) = toml::from_str::<toml::Value>(raw) else {
|
||||
return Ok(());
|
||||
};
|
||||
if document.get("tool_launcher").is_some() || tool_launcher_in_profiles(&document) {
|
||||
return Err(tool_launcher_layer_error(path));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn managed_tool_launcher(path: &Path) -> Result<Option<Vec<String>>> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
|
||||
let Ok(document) = toml::from_str::<toml::Value>(&raw) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if tool_launcher_in_profiles(&document) {
|
||||
anyhow::bail!(
|
||||
"`tool_launcher` in {} belongs at the top level of the managed configuration, not in a profile",
|
||||
codewhale_config::quote_os_path(path)
|
||||
);
|
||||
}
|
||||
let Some(value) = document.get("tool_launcher") else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !cfg!(all(target_os = "linux", not(target_env = "ohos"))) {
|
||||
anyhow::bail!(
|
||||
"`tool_launcher` in {} is supported only on Linux",
|
||||
codewhale_config::quote_os_path(path)
|
||||
);
|
||||
}
|
||||
let argv = value
|
||||
.as_array()
|
||||
.and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item.as_str().map(str::to_string))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.filter(|argv| {
|
||||
argv.first()
|
||||
.is_some_and(|program| Path::new(program).is_absolute())
|
||||
&& argv.iter().all(|arg| !arg.is_empty() && !arg.contains('\0'))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"`tool_launcher` in {} must be a list of strings whose first entry is an absolute program path",
|
||||
codewhale_config::quote_os_path(path)
|
||||
)
|
||||
})?;
|
||||
if !managed_path_is_named_by_host(path) {
|
||||
anyhow::bail!(
|
||||
"`tool_launcher` in {} is not accepted: that file is not CODEWHALE_MANAGED_CONFIG_PATH or the default managed path, and a launcher is read only from one of those",
|
||||
codewhale_config::quote_os_path(path)
|
||||
);
|
||||
}
|
||||
if managed_file_is_replaceable(path) {
|
||||
anyhow::bail!(
|
||||
"`tool_launcher` in {} is not accepted: the Engine's own user can change that file or its directory, so it is not a managed configuration",
|
||||
codewhale_config::quote_os_path(path)
|
||||
);
|
||||
}
|
||||
Ok(Some(argv))
|
||||
}
|
||||
|
||||
fn managed_path_from_env() -> Option<PathBuf> {
|
||||
std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
|
||||
.ok()
|
||||
.map(|value| expand_path(&value))
|
||||
}
|
||||
|
||||
fn managed_path_is_named_by_host(path: &Path) -> bool {
|
||||
managed_path_from_env()
|
||||
.into_iter()
|
||||
.chain(default_managed_config_path())
|
||||
.any(|named| named == path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static TEST_TREAT_MANAGED_FILE_AS_SEALED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn managed_file_is_replaceable(path: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
if TEST_TREAT_MANAGED_FILE_AS_SEALED.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
engine_can_replace(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn engine_can_replace(path: &Path) -> bool {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
fn writable(path: &Path) -> bool {
|
||||
let Ok(spelled) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
|
||||
return true;
|
||||
};
|
||||
// SAFETY: `spelled` is a valid NUL-terminated path for the call's duration.
|
||||
unsafe { libc::access(spelled.as_ptr(), libc::W_OK) == 0 }
|
||||
}
|
||||
// SAFETY: geteuid has no preconditions.
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
let replaceable = |file: &Path| {
|
||||
if writable(file) {
|
||||
return true;
|
||||
}
|
||||
let mut child = file;
|
||||
while let Some(dir) = child.parent().filter(|dir| !dir.as_os_str().is_empty()) {
|
||||
if writable(dir) {
|
||||
let sticky = std::fs::metadata(dir).is_ok_and(|meta| meta.mode() & 0o1000 != 0);
|
||||
let child_is_ours =
|
||||
std::fs::symlink_metadata(child).map_or(true, |meta| meta.uid() == euid);
|
||||
if !sticky || child_is_ours {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
child = dir;
|
||||
}
|
||||
false
|
||||
};
|
||||
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| absolute.clone());
|
||||
replaceable(&absolute) || replaceable(&canonical)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn engine_can_replace(_path: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Organization-managed overlays may constrain routing and policy, but they
|
||||
/// cannot consent on a user's behalf to credential files owned by another
|
||||
/// CLI. Only the user config/profile loaded before this layer may carry these
|
||||
|
||||
@@ -12192,3 +12192,244 @@ fn custom_provider_responses_profile_round_trips() {
|
||||
let rendered = toml::to_string(entry).expect("serialize");
|
||||
assert!(rendered.contains("responses_profile = \"standard\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_launcher_is_refused_outside_the_managed_layer() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = EnvVarGuard::set(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
temp_root.path().join("absent-managed.toml"),
|
||||
);
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
for document in [
|
||||
"tool_launcher = [\"/usr/bin/sudo\", \"-n\", \"-u\", \"agent\", \"--\"]\n",
|
||||
"[profiles.work]\ntool_launcher = [\"/usr/bin/sudo\"]\n",
|
||||
] {
|
||||
fs::write(&config_path, document)?;
|
||||
let error = Config::load(Some(config_path.clone()), None)
|
||||
.expect_err("user-layer tool_launcher is a load error");
|
||||
assert!(
|
||||
format!("{error:#}").contains("read only from the managed configuration"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
fs::write(&config_path, "prefer_bwrap = true\n")?;
|
||||
assert_eq!(Config::load(Some(config_path), None)?.tool_launcher, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_launcher_is_refused_from_a_managed_file_the_engine_can_write() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let managed_path = temp_root.path().join("managed.toml");
|
||||
fs::write(
|
||||
&managed_path,
|
||||
"sandbox_mode = \"workspace-write\"\ntool_launcher = [\"/usr/bin/sudo\", \"-n\"]\n",
|
||||
)?;
|
||||
let _managed = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &managed_path);
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, "")?;
|
||||
let error = Config::load(Some(config_path), None).expect_err("refused");
|
||||
let expected = if cfg!(all(target_os = "linux", not(target_env = "ohos"))) {
|
||||
"the Engine's own user can change that file"
|
||||
} else {
|
||||
"supported only on Linux"
|
||||
};
|
||||
assert!(format!("{error:#}").contains(expected), "{error:#}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_managed_file_is_replaceable_through_any_writable_ancestor() -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let sealed_dir = temp_root.path().join("etc");
|
||||
fs::create_dir(&sealed_dir)?;
|
||||
let file = sealed_dir.join("managed.toml");
|
||||
fs::write(&file, "")?;
|
||||
assert!(engine_can_replace(&file));
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o444))?;
|
||||
fs::set_permissions(&sealed_dir, fs::Permissions::from_mode(0o555))?;
|
||||
let through_grandparent = engine_can_replace(&file);
|
||||
fs::set_permissions(&sealed_dir, fs::Permissions::from_mode(0o755))?;
|
||||
assert!(through_grandparent);
|
||||
if !engine_can_replace(Path::new("/")) {
|
||||
assert!(!engine_can_replace(Path::new("/etc/passwd")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
#[test]
|
||||
fn tool_launcher_is_read_from_a_sealed_managed_file() -> Result<()> {
|
||||
struct Sealed;
|
||||
impl Drop for Sealed {
|
||||
fn drop(&mut self) {
|
||||
TEST_TREAT_MANAGED_FILE_AS_SEALED.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let managed_path = temp_root.path().join("managed.toml");
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, "")?;
|
||||
let _managed = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &managed_path);
|
||||
TEST_TREAT_MANAGED_FILE_AS_SEALED.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let _sealed = Sealed;
|
||||
|
||||
fs::write(
|
||||
&managed_path,
|
||||
"sandbox_mode = \"workspace-write\"\nsandbox_network_access = true\ntool_launcher = [\"/usr/bin/sudo\", \"-n\", \"-u\", \"agent\", \"--\", \"/opt/exec\"]\n",
|
||||
)?;
|
||||
let config = Config::load(Some(config_path.clone()), None)?;
|
||||
assert_eq!(
|
||||
config.tool_launcher.as_deref(),
|
||||
Some(
|
||||
["/usr/bin/sudo", "-n", "-u", "agent", "--", "/opt/exec"]
|
||||
.map(str::to_string)
|
||||
.as_slice()
|
||||
)
|
||||
);
|
||||
assert_eq!(config.sandbox_mode.as_deref(), Some("workspace-write"));
|
||||
assert_eq!(config.sandbox_network_access, Some(true));
|
||||
|
||||
for (document, expected) in [
|
||||
(
|
||||
"tool_launcher = [\"/usr/bin/sudo\"]\n",
|
||||
"needs `sandbox_mode",
|
||||
),
|
||||
(
|
||||
"sandbox_mode = \"danger-full-access\"\ntool_launcher = [\"/usr/bin/sudo\"]\n",
|
||||
"needs `sandbox_mode",
|
||||
),
|
||||
(
|
||||
"sandbox_mode = \"workspace-write\"\ntool_launcher = [\"sudo\"]\n",
|
||||
"absolute program path",
|
||||
),
|
||||
(
|
||||
"sandbox_mode = \"workspace-write\"\ntool_launcher = \"/usr/bin/sudo\"\n",
|
||||
"absolute program path",
|
||||
),
|
||||
(
|
||||
"sandbox_mode = \"workspace-write\"\n[profiles.work]\ntool_launcher = [\"/usr/bin/sudo\"]\n",
|
||||
"not in a profile",
|
||||
),
|
||||
] {
|
||||
fs::write(&managed_path, document)?;
|
||||
let error = Config::load(Some(config_path.clone()), None).expect_err(document);
|
||||
assert!(format!("{error:#}").contains(expected), "{error:#}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
#[test]
|
||||
fn the_managed_file_named_by_the_host_survives_a_provider_that_resolves_late() -> Result<()> {
|
||||
struct Sealed;
|
||||
impl Drop for Sealed {
|
||||
fn drop(&mut self) {
|
||||
TEST_TREAT_MANAGED_FILE_AS_SEALED.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let managed_path = temp_root.path().join("managed.toml");
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
let _managed = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &managed_path);
|
||||
TEST_TREAT_MANAGED_FILE_AS_SEALED.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let _sealed = Sealed;
|
||||
fs::write(
|
||||
&managed_path,
|
||||
"sandbox_mode = \"workspace-write\"\ntool_launcher = [\"/usr/bin/sudo\", \"-n\"]\n",
|
||||
)?;
|
||||
|
||||
let mut late_provider = |config: &mut Config| -> Result<()> {
|
||||
let entry: ProviderConfig = toml::from_str(
|
||||
"base_url = \"https://example.invalid/v1\"\nmodel = \"m\"\nkind = \"openai-compatible\"\n",
|
||||
)?;
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(Default::default)
|
||||
.custom
|
||||
.insert("ghostplug".to_string(), entry);
|
||||
Ok(())
|
||||
};
|
||||
for user_file in [
|
||||
"provider = \"ghostplug\"\n",
|
||||
"provider = \"ghostplug\"\nmanaged_config_path = \"/nonexistent/managed.toml\"\n",
|
||||
] {
|
||||
fs::write(&config_path, user_file)?;
|
||||
let loaded = Config::load_with(
|
||||
Some(config_path.clone()),
|
||||
None,
|
||||
ConfigEnvironmentPolicy::Runtime,
|
||||
&mut late_provider,
|
||||
);
|
||||
if let Ok(config) = loaded {
|
||||
assert_eq!(
|
||||
config.tool_launcher.as_deref(),
|
||||
Some(["/usr/bin/sudo", "-n"].map(str::to_string).as_slice()),
|
||||
"{user_file}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
#[test]
|
||||
fn tool_launcher_is_refused_from_a_managed_file_the_user_config_names() -> Result<()> {
|
||||
struct Sealed;
|
||||
impl Drop for Sealed {
|
||||
fn drop(&mut self) {
|
||||
TEST_TREAT_MANAGED_FILE_AS_SEALED.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = EnvVarGuard::remove("CODEWHALE_MANAGED_CONFIG_PATH");
|
||||
let _legacy_managed = EnvVarGuard::remove("DEEPSEEK_MANAGED_CONFIG_PATH");
|
||||
let managed_path = temp_root.path().join("elsewhere.toml");
|
||||
fs::write(
|
||||
&managed_path,
|
||||
"sandbox_mode = \"workspace-write\"\ntool_launcher = [\"/usr/bin/sudo\", \"-n\"]\n",
|
||||
)?;
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"managed_config_path = {}\n",
|
||||
toml::Value::String(managed_path.display().to_string())
|
||||
),
|
||||
)?;
|
||||
TEST_TREAT_MANAGED_FILE_AS_SEALED.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let _sealed = Sealed;
|
||||
let error = Config::load(Some(config_path.clone()), None).expect_err("refused");
|
||||
assert!(
|
||||
format!("{error:#}")
|
||||
.contains("not CODEWHALE_MANAGED_CONFIG_PATH or the default managed path"),
|
||||
"{error:#}"
|
||||
);
|
||||
|
||||
let _named = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &managed_path);
|
||||
fs::write(
|
||||
&managed_path,
|
||||
"tool_launcher = [\"/usr/bin/sudo\", \"-n\"]\n",
|
||||
)?;
|
||||
fs::write(&config_path, "sandbox_mode = \"workspace-write\"\n")?;
|
||||
let error =
|
||||
Config::load(Some(config_path), None).expect_err("user sandbox_mode does not count");
|
||||
assert!(
|
||||
format!("{error:#}").contains("needs `sandbox_mode"),
|
||||
"{error:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
+254
-4
@@ -156,6 +156,149 @@ pub fn is_available() -> bool {
|
||||
*VERDICT.get_or_init(probe)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LaunchIdentity {
|
||||
pub uid: u32,
|
||||
pub gids: Vec<u32>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LaunchIdentity {
|
||||
fn can_search(&self, dir: &Path) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let Ok(metadata) = std::fs::metadata(dir) else {
|
||||
return false;
|
||||
};
|
||||
if self.uid == 0 {
|
||||
return true;
|
||||
}
|
||||
let mode = metadata.mode();
|
||||
if metadata.uid() == self.uid {
|
||||
mode & 0o100 != 0
|
||||
} else if self.gids.contains(&metadata.gid()) {
|
||||
mode & 0o010 != 0
|
||||
} else {
|
||||
mode & 0o001 != 0
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reaches(&self, path: &Path) -> bool {
|
||||
path.ancestors().skip(1).all(|dir| self.can_search(dir))
|
||||
}
|
||||
|
||||
pub(crate) fn mask_target(&self, path: &Path) -> PathBuf {
|
||||
let mut ancestors: Vec<&Path> = path.ancestors().skip(1).collect();
|
||||
ancestors.reverse();
|
||||
ancestors
|
||||
.into_iter()
|
||||
.find(|dir| *dir != Path::new("/") && !self.can_search(dir))
|
||||
.map_or_else(|| path.to_path_buf(), Path::to_path_buf)
|
||||
}
|
||||
|
||||
fn parse(output: &str) -> Option<Self> {
|
||||
let mut lines = output.lines();
|
||||
let uid = lines.next()?.trim().parse().ok()?;
|
||||
let gids = lines
|
||||
.next()?
|
||||
.split_whitespace()
|
||||
.map(str::parse)
|
||||
.collect::<Result<Vec<u32>, _>>()
|
||||
.ok()?;
|
||||
Some(Self { uid, gids })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_probe(argv: &[String]) -> Result<String, String> {
|
||||
use std::io::Read as _;
|
||||
use wait_timeout::ChildExt as _;
|
||||
let (program, args) = argv
|
||||
.split_first()
|
||||
.ok_or_else(|| "empty probe command".to_string())?;
|
||||
let mut child = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| format!("{program} could not start: {error}"))?;
|
||||
let status = match child.wait_timeout(PROBE_DEADLINE) {
|
||||
Ok(Some(status)) => status,
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!(
|
||||
"{program} did not finish within {}s",
|
||||
PROBE_DEADLINE.as_secs()
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut pipe) = child.stdout.take() {
|
||||
let _ = pipe.read_to_string(&mut stdout);
|
||||
}
|
||||
if let Some(mut pipe) = child.stderr.take() {
|
||||
let _ = pipe.read_to_string(&mut stderr);
|
||||
}
|
||||
if status.success() {
|
||||
return Ok(stdout);
|
||||
}
|
||||
let reason = super::bwrap_setup_failure(&stderr)
|
||||
.or_else(|| stderr.lines().map(str::trim).find(|line| !line.is_empty()))
|
||||
.map_or_else(|| format!("{program} exited with {status}"), str::to_string);
|
||||
Err(reason.chars().take(400).collect())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn probe_launcher(
|
||||
launcher: &[String],
|
||||
denied_read_subpaths: &[PathBuf],
|
||||
) -> Result<LaunchIdentity, String> {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
type Key = (Vec<String>, Vec<PathBuf>);
|
||||
static READY: LazyLock<Mutex<HashMap<Key, LaunchIdentity>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
let key = (launcher.to_vec(), denied_read_subpaths.to_vec());
|
||||
if let Some(identity) = READY
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&key)
|
||||
{
|
||||
return Ok(identity.clone());
|
||||
}
|
||||
if !is_executable(Path::new(BWRAP_PATH)) {
|
||||
return Err(format!("{BWRAP_PATH} is not installed"));
|
||||
}
|
||||
let mut id_command = launcher.to_vec();
|
||||
id_command.extend(["/bin/sh", "-c", "id -u && id -G"].map(str::to_string));
|
||||
let identity = LaunchIdentity::parse(&run_probe(&id_command)?)
|
||||
.ok_or_else(|| "the launcher did not report its user and groups".to_string())?;
|
||||
let mut bwrap_command = launcher.to_vec();
|
||||
bwrap_command.extend(build_bwrap_command_with_read_paths(
|
||||
Path::new("/"),
|
||||
"/bin/true",
|
||||
&[],
|
||||
&[],
|
||||
false,
|
||||
&crate::BwrapMountExtensions::default(),
|
||||
denied_read_subpaths,
|
||||
&[],
|
||||
None,
|
||||
Some(&identity),
|
||||
));
|
||||
run_probe(&bwrap_command)?;
|
||||
READY
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert(key, identity.clone());
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn is_executable(path: &std::path::Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -211,6 +354,7 @@ pub fn build_bwrap_command(
|
||||
denied_read_subpaths,
|
||||
denied_read_exceptions,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -226,9 +370,22 @@ pub(crate) fn build_bwrap_command_with_read_paths(
|
||||
denied_read_subpaths: &[std::path::PathBuf],
|
||||
denied_read_exceptions: &[std::path::PathBuf],
|
||||
readable_paths: Option<&[PathBuf]>,
|
||||
launch_identity: Option<&LaunchIdentity>,
|
||||
) -> Vec<String> {
|
||||
let (writable_mounts, read_only_mounts) = safe_mounts(writable_roots);
|
||||
let (extra_read_only, device_mounts) = extensions.resolve();
|
||||
let reachable = |path: &PathBuf| launch_identity.is_none_or(|identity| identity.reaches(path));
|
||||
let (mut writable_mounts, mut read_only_mounts) = safe_mounts(writable_roots);
|
||||
let (mut extra_read_only, mut device_mounts) = extensions.resolve();
|
||||
writable_mounts.retain(reachable);
|
||||
read_only_mounts.retain(reachable);
|
||||
extra_read_only.retain(reachable);
|
||||
device_mounts.retain(reachable);
|
||||
let readable_paths: Option<Vec<PathBuf>> = readable_paths.map(|paths| {
|
||||
paths
|
||||
.iter()
|
||||
.filter(|path| reachable(path))
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
let mut cmd: Vec<String> =
|
||||
Vec::with_capacity(10 + args.len() + 3 * (writable_mounts.len() + read_only_mounts.len()));
|
||||
|
||||
@@ -263,7 +420,7 @@ pub(crate) fn build_bwrap_command_with_read_paths(
|
||||
cmd.push("--tmpfs".to_string());
|
||||
cmd.push("/tmp".to_string());
|
||||
|
||||
if let Some(paths) = readable_paths {
|
||||
if let Some(paths) = &readable_paths {
|
||||
for path in paths {
|
||||
cmd.push("--ro-bind".to_string());
|
||||
cmd.push(path.to_string_lossy().into_owned());
|
||||
@@ -310,8 +467,22 @@ pub(crate) fn build_bwrap_command_with_read_paths(
|
||||
// re-exposes a denied path: an existing directory is masked with an empty
|
||||
// tmpfs, an existing file with a bind of /dev/null. Non-existent paths
|
||||
// are skipped — there is nothing to deny.
|
||||
let mut lifted_masks: Vec<PathBuf> = Vec::new();
|
||||
for denied in denied_read_subpaths {
|
||||
cmd.extend(super::bwrap_mask_args(denied));
|
||||
let lifted = launch_identity.and_then(|identity| {
|
||||
let resolved = std::fs::canonicalize(denied).ok()?;
|
||||
let target = identity.mask_target(&resolved);
|
||||
(target != resolved).then_some(target)
|
||||
});
|
||||
match lifted {
|
||||
Some(target) if lifted_masks.contains(&target) => {}
|
||||
Some(target) => {
|
||||
cmd.push("--tmpfs".to_string());
|
||||
cmd.push(target.to_string_lossy().into_owned());
|
||||
lifted_masks.push(target);
|
||||
}
|
||||
None => cmd.extend(super::bwrap_mask_args(denied)),
|
||||
}
|
||||
}
|
||||
|
||||
// Exceptions inside a masked directory (the extension host's own files
|
||||
@@ -648,6 +819,85 @@ mod tests {
|
||||
assert!(!cmd.iter().any(|arg| arg.ends_with("/plugins")), "{cmd:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn a_deny_path_the_launcher_user_cannot_reach_is_masked_at_its_highest_closed_ancestor() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir_in("/tmp").expect("tempdir");
|
||||
let root = dir.path().canonicalize().expect("canonical tempdir");
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let closed = root.join("engine-home");
|
||||
let secret = closed.join(".codewhale/secrets");
|
||||
std::fs::create_dir_all(&secret).expect("secret dir");
|
||||
std::fs::create_dir_all(closed.join("work")).expect("closed work dir");
|
||||
std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let open = root.join("workspace");
|
||||
let open_secret = open.join(".env");
|
||||
std::fs::create_dir_all(&open).expect("workspace");
|
||||
std::fs::write(&open_secret, b"x").expect("open secret");
|
||||
let stranger = LaunchIdentity {
|
||||
uid: u32::MAX - 7,
|
||||
gids: vec![u32::MAX - 7],
|
||||
};
|
||||
assert_eq!(stranger.mask_target(&secret), closed);
|
||||
assert_eq!(stranger.mask_target(&open_secret), open_secret);
|
||||
assert!(stranger.reaches(&open));
|
||||
assert!(!stranger.reaches(&closed.join("work")));
|
||||
|
||||
let cmd = build_bwrap_command_with_read_paths(
|
||||
&open,
|
||||
"true",
|
||||
&[],
|
||||
&[
|
||||
WritableRoot::new(open.clone()),
|
||||
WritableRoot::new(closed.join("work")),
|
||||
],
|
||||
false,
|
||||
&crate::BwrapMountExtensions::default(),
|
||||
&[
|
||||
secret.clone(),
|
||||
closed.join(".ssh-missing"),
|
||||
open_secret.clone(),
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
Some(&stranger),
|
||||
);
|
||||
let masks: Vec<&[String]> = cmd
|
||||
.windows(2)
|
||||
.filter(|args| args[0] == "--tmpfs" && args[1] != "/tmp")
|
||||
.collect();
|
||||
assert_eq!(masks.len(), 1, "{cmd:?}");
|
||||
assert_eq!(masks[0][1], closed.to_string_lossy());
|
||||
assert!(!cmd.iter().any(|arg| arg.ends_with("/secrets")), "{cmd:?}");
|
||||
assert!(has_mount(&cmd, "--bind", &open));
|
||||
assert!(!cmd.iter().any(|arg| arg.ends_with("/work")), "{cmd:?}");
|
||||
assert!(
|
||||
cmd.windows(3).any(|args| args[0] == "--ro-bind"
|
||||
&& args[1] == "/dev/null"
|
||||
&& args[2] == open_secret.to_string_lossy()),
|
||||
"{cmd:?}"
|
||||
);
|
||||
assert!(cmd.iter().any(|arg| arg == "--die-with-parent"));
|
||||
std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_identity_parses_id_output() {
|
||||
assert_eq!(
|
||||
LaunchIdentity::parse("1001\n1001 27 100\n"),
|
||||
Some(LaunchIdentity {
|
||||
uid: 1001,
|
||||
gids: vec![1001, 27, 100],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
LaunchIdentity::parse("sudo: a password is required\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn has_mount(command: &[String], flag: &str, path: &Path) -> bool {
|
||||
let path = path.to_string_lossy();
|
||||
|
||||
+292
-18
@@ -354,25 +354,21 @@ impl ExecEnv {
|
||||
let mut command = portable_pty::CommandBuilder::new(self.program());
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
if self.sandbox_type == SandboxType::LinuxBubblewrap {
|
||||
let (bwrap_index, separator) = bwrap_target_split(&self.command)
|
||||
.ok_or_else(|| anyhow::anyhow!("sandboxed PTY command is missing its target"))?;
|
||||
anyhow::ensure!(
|
||||
self.program() == bwrap::BWRAP_PATH,
|
||||
bwrap_index < separator,
|
||||
"sandboxed PTY requires the configured bubblewrap wrapper"
|
||||
);
|
||||
let args = self.args();
|
||||
let separator = args
|
||||
.iter()
|
||||
.position(|arg| arg == "--")
|
||||
.filter(|index| index + 1 < args.len())
|
||||
.ok_or_else(|| anyhow::anyhow!("sandboxed PTY command is missing its target"))?;
|
||||
let setsid = ["/usr/bin/setsid", "/bin/setsid"]
|
||||
.into_iter()
|
||||
.find(|path| bwrap::is_executable(std::path::Path::new(path)))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("sandboxed PTY requires util-linux setsid; install util-linux")
|
||||
})?;
|
||||
command.args(&args[..=separator]);
|
||||
command.args(&self.command[1..=separator]);
|
||||
command.args([setsid, "--ctty", "--wait", "--"]);
|
||||
command.args(&args[separator + 1..]);
|
||||
command.args(&self.command[separator + 1..]);
|
||||
command.set_controlling_tty(false);
|
||||
} else {
|
||||
command.args(self.args());
|
||||
@@ -445,6 +441,17 @@ pub fn is_sandbox_available() -> bool {
|
||||
get_platform_sandbox().is_some()
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(target_os = "linux", not(target_env = "ohos"))))]
|
||||
fn bwrap_target_split(command: &[String]) -> Option<(usize, usize)> {
|
||||
let bwrap_index = command.iter().position(|arg| arg == "/usr/bin/bwrap")?;
|
||||
let separator = command[bwrap_index + 1..]
|
||||
.iter()
|
||||
.position(|arg| arg == "--")
|
||||
.map(|offset| bwrap_index + 1 + offset)
|
||||
.filter(|index| index + 1 < command.len())?;
|
||||
Some((bwrap_index, separator))
|
||||
}
|
||||
|
||||
pub fn bwrap_setup_failure(stderr: &str) -> Option<&str> {
|
||||
stderr
|
||||
.lines()
|
||||
@@ -652,6 +659,8 @@ pub struct SandboxManager {
|
||||
all(target_os = "linux", not(target_env = "ohos"))
|
||||
))]
|
||||
readable_paths: Option<Vec<PathBuf>>,
|
||||
|
||||
tool_launcher: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SandboxManager {
|
||||
@@ -751,6 +760,38 @@ impl SandboxManager {
|
||||
let _ = paths;
|
||||
}
|
||||
|
||||
/// Run every prepared command through `launcher`, an argv prefix that
|
||||
/// changes the user commands run as (for example
|
||||
/// `sudo -n -u <user> -- <exec-wrapper>`). It stands in front of
|
||||
/// bubblewrap, because every unprivileged confinement sets
|
||||
/// no-new-privileges and a setuid launcher inside it cannot switch user.
|
||||
/// Only the managed configuration layer can set it (`Config::tool_launcher`).
|
||||
///
|
||||
/// With a launcher, a command always runs under bubblewrap or not at all:
|
||||
/// a policy that would leave it unwrapped (`danger-full-access`, an
|
||||
/// external sandbox), `prefer_bwrap = false`, a failed probe through the
|
||||
/// launcher, or a non-Linux host refuses the command. Masks and binds are
|
||||
/// computed for the launcher's user on every command: a path whose
|
||||
/// ancestors that user cannot search is masked at the highest such
|
||||
/// ancestor, or dropped when it is a bind. Each command gets a private
|
||||
/// `/tmp` instead of the host's.
|
||||
///
|
||||
/// Known limitations: it covers only commands prepared here (shell
|
||||
/// commands, PTY sessions and workspace runners). In-process file tools,
|
||||
/// MCP servers, hooks, LSP servers and the extension host still run as the
|
||||
/// Engine's own user. Deny-list exceptions are not applied, so a denied
|
||||
/// directory stays masked whole. Search rights are read from mode bits and
|
||||
/// group membership; ACLs are not consulted. Processes still run after the
|
||||
/// Engine itself dies, until bubblewrap's parent exits.
|
||||
pub fn set_tool_launcher(&mut self, launcher: Option<Vec<String>>) {
|
||||
self.tool_launcher = launcher.filter(|argv| !argv.is_empty());
|
||||
self.sandbox_available = None;
|
||||
}
|
||||
|
||||
pub fn tool_launcher_active(&self) -> bool {
|
||||
self.tool_launcher.is_some()
|
||||
}
|
||||
|
||||
/// Test-only view of the resolved deny-list (post home-expansion and
|
||||
/// canonicalization).
|
||||
#[cfg(test)]
|
||||
@@ -771,9 +812,32 @@ impl SandboxManager {
|
||||
|
||||
/// Return the wrapper this manager is configured and able to apply.
|
||||
pub fn configured_sandbox(&self) -> Option<SandboxType> {
|
||||
if self.tool_launcher.is_some() {
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
return self
|
||||
.launcher_identity()
|
||||
.ok()
|
||||
.map(|_| SandboxType::LinuxBubblewrap);
|
||||
#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
|
||||
return None;
|
||||
}
|
||||
get_platform_sandbox_with_bwrap_preference(self.prefer_bwrap)
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
fn launcher_identity(&self) -> Result<bwrap::LaunchIdentity, String> {
|
||||
let launcher = self
|
||||
.tool_launcher
|
||||
.as_deref()
|
||||
.ok_or_else(|| "no tool launcher is configured".to_string())?;
|
||||
if !self.prefer_bwrap {
|
||||
return Err(
|
||||
"tool_launcher needs bubblewrap, and prefer_bwrap = false turns it off".to_string(),
|
||||
);
|
||||
}
|
||||
bwrap::probe_launcher(launcher, &self.denied_read_subpaths)
|
||||
}
|
||||
|
||||
/// Select the appropriate sandbox type for the given policy.
|
||||
pub fn select_sandbox(&self, policy: &SandboxPolicy) -> SandboxType {
|
||||
// If the policy doesn't want sandboxing, return None
|
||||
@@ -795,19 +859,72 @@ impl SandboxManager {
|
||||
/// specification and returns the actual command to run, which may
|
||||
/// include sandbox wrapper commands.
|
||||
pub fn prepare(&self, spec: &CommandSpec) -> ExecEnv {
|
||||
self.try_prepare(spec)
|
||||
.unwrap_or_else(|reason| Self::prepare_refusal(spec, &reason))
|
||||
}
|
||||
|
||||
/// Like [`Self::prepare`], but a command that must not run (a tool
|
||||
/// launcher whose sandbox cannot be applied) is an error naming why.
|
||||
pub fn try_prepare(&self, spec: &CommandSpec) -> Result<ExecEnv, String> {
|
||||
if self.tool_launcher.is_some() {
|
||||
return self.prepare_launched(spec);
|
||||
}
|
||||
let sandbox_type = self.select_sandbox(&spec.sandbox_policy);
|
||||
|
||||
match sandbox_type {
|
||||
Ok(match sandbox_type {
|
||||
SandboxType::None => Self::prepare_unsandboxed(spec),
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
SandboxType::MacosSeatbelt => self.prepare_seatbelt(spec),
|
||||
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
SandboxType::LinuxBubblewrap => self.prepare_bwrap(spec),
|
||||
SandboxType::LinuxBubblewrap => self.prepare_bwrap(spec, None),
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
SandboxType::Windows => Self::prepare_windows(spec),
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_launched(&self, spec: &CommandSpec) -> Result<ExecEnv, String> {
|
||||
if !spec.sandbox_policy.should_sandbox() {
|
||||
return Err(format!(
|
||||
"this host runs tool commands as another user, and only inside the sandbox; the {} policy would run this command without it, so nothing was run",
|
||||
spec.sandbox_policy.posture_label()
|
||||
));
|
||||
}
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
{
|
||||
let identity = self.launcher_identity().map_err(|error| {
|
||||
format!(
|
||||
"this host runs tool commands as another user, and the sandbox cannot start through its launcher, so nothing was run: {error}"
|
||||
)
|
||||
})?;
|
||||
let mut env = self.prepare_bwrap(spec, Some(&identity));
|
||||
let mut command = self.tool_launcher.clone().unwrap_or_default();
|
||||
command.append(&mut env.command);
|
||||
env.command = command;
|
||||
Ok(env)
|
||||
}
|
||||
#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
|
||||
{
|
||||
Err("tool_launcher works only on Linux, so nothing was run".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_refusal(spec: &CommandSpec, reason: &str) -> ExecEnv {
|
||||
ExecEnv {
|
||||
command: vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"printf 'codewhale: %s\\n' \"$1\" >&2; exit 126".to_string(),
|
||||
"codewhale-refusal".to_string(),
|
||||
reason.to_string(),
|
||||
],
|
||||
cwd: spec.cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
timeout: spec.timeout,
|
||||
sandbox_type: SandboxType::None,
|
||||
policy: spec.sandbox_policy.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,19 +993,45 @@ impl SandboxManager {
|
||||
/// inherited through the read-only root bind reject `open(O_WRONLY)`
|
||||
/// with `EROFS` — `foo >/dev/null` was the original report — and
|
||||
/// without `/proc`, toolchains that read process tables misbehave.
|
||||
/// `/tmp` is writable-but-isolated (tmpfs) so linkers and test
|
||||
/// harnesses have scratch space without widening the filesystem
|
||||
/// policy. User-configured extensions (extra read-only roots,
|
||||
/// writable device nodes) apply after the defaults.
|
||||
/// `/tmp` starts as a private tmpfs; a `workspace-write` policy binds the
|
||||
/// host `/tmp` and `TMPDIR` over it unless it excludes them, except under
|
||||
/// a tool launcher, where `/tmp` stays private. User-configured
|
||||
/// extensions (extra read-only roots, writable device nodes) apply after
|
||||
/// the defaults.
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
fn prepare_bwrap(&self, spec: &CommandSpec) -> ExecEnv {
|
||||
fn prepare_bwrap(
|
||||
&self,
|
||||
spec: &CommandSpec,
|
||||
launch_identity: Option<&bwrap::LaunchIdentity>,
|
||||
) -> ExecEnv {
|
||||
let writable_roots = if self.readable_paths.is_some() {
|
||||
vec![policy::WritableRoot::new(spec.cwd.clone())]
|
||||
} else {
|
||||
if matches!(spec.sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
|
||||
let _ = std::fs::create_dir(spec.cwd.join(".codewhale"));
|
||||
}
|
||||
spec.sandbox_policy.get_writable_roots(&spec.cwd)
|
||||
match (&spec.sandbox_policy, launch_identity) {
|
||||
(
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots,
|
||||
network_access,
|
||||
..
|
||||
},
|
||||
Some(_),
|
||||
) => SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: writable_roots.clone(),
|
||||
network_access: *network_access,
|
||||
exclude_tmpdir: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
.get_writable_roots(&spec.cwd),
|
||||
_ => spec.sandbox_policy.get_writable_roots(&spec.cwd),
|
||||
}
|
||||
};
|
||||
let denied_read_exceptions: &[PathBuf] = if launch_identity.is_some() {
|
||||
&[]
|
||||
} else {
|
||||
&self.denied_read_exceptions
|
||||
};
|
||||
let command = bwrap::build_bwrap_command_with_read_paths(
|
||||
&spec.cwd,
|
||||
@@ -898,8 +1041,9 @@ impl SandboxManager {
|
||||
spec.sandbox_policy.has_network_access(),
|
||||
&self.bwrap_extensions,
|
||||
&self.denied_read_subpaths,
|
||||
&self.denied_read_exceptions,
|
||||
denied_read_exceptions,
|
||||
self.readable_paths.as_deref(),
|
||||
launch_identity,
|
||||
);
|
||||
|
||||
let mut env = spec.env.clone();
|
||||
@@ -1624,4 +1768,134 @@ mod tests {
|
||||
assert_eq!(env.policy, *policy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pty_separator_is_found_after_a_launcher_prefix() {
|
||||
let command: Vec<String> = [
|
||||
"/usr/bin/sudo",
|
||||
"-n",
|
||||
"-u",
|
||||
"agent",
|
||||
"--",
|
||||
"/opt/exec",
|
||||
"/usr/bin/bwrap",
|
||||
"--unshare-all",
|
||||
"--die-with-parent",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"id",
|
||||
]
|
||||
.map(str::to_string)
|
||||
.to_vec();
|
||||
assert_eq!(bwrap_target_split(&command), Some((6, 9)));
|
||||
assert_eq!(bwrap_target_split(&command[..10]), None);
|
||||
assert_eq!(bwrap_target_split(&command[6..]), Some((0, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launcher_refuses_policies_that_skip_the_sandbox() {
|
||||
let mut manager = SandboxManager::with_bwrap_preference(true);
|
||||
manager.set_tool_launcher(Some(vec!["/usr/bin/env".to_string()]));
|
||||
for policy in [
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
SandboxPolicy::ExternalSandbox {
|
||||
network_access: true,
|
||||
},
|
||||
] {
|
||||
let spec = CommandSpec::shell("id", PathBuf::from("/tmp"), Duration::from_secs(5))
|
||||
.with_policy(policy);
|
||||
let error = manager.try_prepare(&spec).expect_err("refused");
|
||||
assert!(error.contains("nothing was run"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launcher_without_bubblewrap_refuses_instead_of_running_unwrapped() {
|
||||
let mut manager = SandboxManager::with_bwrap_preference(false);
|
||||
manager.set_tool_launcher(Some(vec!["/usr/bin/env".to_string()]));
|
||||
let spec = CommandSpec::shell("id", PathBuf::from("/tmp"), Duration::from_secs(5))
|
||||
.with_policy(SandboxPolicy::default());
|
||||
let error = manager.try_prepare(&spec).expect_err("refused");
|
||||
assert!(error.contains("nothing was run"), "{error}");
|
||||
assert_eq!(manager.configured_sandbox(), None);
|
||||
|
||||
let refusal = manager.prepare(&spec);
|
||||
assert_eq!(refusal.sandbox_type, SandboxType::None);
|
||||
assert!(!refusal.command.iter().any(|arg| arg == "id"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let output = std::process::Command::new(refusal.program())
|
||||
.args(refusal.args())
|
||||
.output()
|
||||
.expect("refusal runs");
|
||||
assert_eq!(output.status.code(), Some(126));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("nothing was run"));
|
||||
}
|
||||
|
||||
manager.set_tool_launcher(Some(vec!["/nonexistent/launcher".to_string()]));
|
||||
manager.set_prefer_bwrap(true);
|
||||
let error = manager
|
||||
.try_prepare(&spec)
|
||||
.expect_err("broken launcher refused");
|
||||
assert!(error.contains("nothing was run"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
fn a_launcher_prefixes_the_bubblewrap_command_and_runs_it_confined() {
|
||||
if !bwrap::is_available() {
|
||||
eprintln!("skipped: bubblewrap cannot run on this host");
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
let mut manager = SandboxManager::with_bwrap_preference(true);
|
||||
manager.set_tool_launcher(Some(vec!["/usr/bin/env".to_string()]));
|
||||
assert_eq!(
|
||||
manager.configured_sandbox(),
|
||||
Some(SandboxType::LinuxBubblewrap)
|
||||
);
|
||||
let spec = CommandSpec::program(
|
||||
"/bin/sh",
|
||||
vec![
|
||||
"-c".to_string(),
|
||||
"grep NoNewPrivs /proc/self/status; echo ok > written".to_string(),
|
||||
],
|
||||
workspace.path().to_path_buf(),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.with_policy(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: true,
|
||||
exclude_tmpdir: false,
|
||||
exclude_slash_tmp: false,
|
||||
});
|
||||
let env = manager.try_prepare(&spec).expect("prepared");
|
||||
assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap);
|
||||
assert_eq!(env.command[0], "/usr/bin/env");
|
||||
assert_eq!(env.command[1], bwrap::BWRAP_PATH);
|
||||
for flag in ["--unshare-all", "--die-with-parent", "--share-net"] {
|
||||
assert!(env.command.iter().any(|arg| arg == flag), "{flag}");
|
||||
}
|
||||
assert!(
|
||||
!env.command
|
||||
.windows(3)
|
||||
.any(|args| args[0] == "--bind" && args[1] == "/tmp"),
|
||||
"{:?}",
|
||||
env.command
|
||||
);
|
||||
let output = std::process::Command::new(env.program())
|
||||
.args(env.args())
|
||||
.current_dir(&env.cwd)
|
||||
.output()
|
||||
.expect("run prepared command");
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("NoNewPrivs:\t1"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.path().join("written"))
|
||||
.unwrap()
|
||||
.trim(),
|
||||
"ok"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,242 @@
|
||||
{
|
||||
"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",
|
||||
"package": "@deepseek-ai/dsh-agent-preset",
|
||||
"files": [
|
||||
{
|
||||
"path": "skills/agent-experience/SKILL.md",
|
||||
"upstream_path": "skills/agent-experience/SKILL.md",
|
||||
"sha256": "1244928710cb7e399325c416e80312037d4da249559a09fe289515cc66ee1752",
|
||||
"bytes": 2093,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-composition-reference/SKILL.md",
|
||||
"upstream_path": "skills/cordis-composition-reference/SKILL.md",
|
||||
"sha256": "6583a81d9f4de07032ebdee0d5ea1a925067277805798936a5297cf2bac4f5e3",
|
||||
"bytes": 2806,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-composition-reference/references/packages.md",
|
||||
"upstream_path": "skills/cordis-composition-reference/references/packages.md",
|
||||
"sha256": "7dfa3c48de4baf51ba353927734d0e00755e1541aaabda4fcb9e88b5497e58ab",
|
||||
"bytes": 43771,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/SKILL.md",
|
||||
"upstream_path": "skills/cordis-plugin-development/SKILL.md",
|
||||
"sha256": "314cb46e064e1aa4ecd9bffd9b0364eb3be7a3e8bd2678f06b0276bd88c09640",
|
||||
"bytes": 8131,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/references/host-plugin.md",
|
||||
"upstream_path": "skills/cordis-plugin-development/references/host-plugin.md",
|
||||
"sha256": "86f774512e83fae3457c405ffc5a3ea6299d38ac84ea456b17f7731831d871f9",
|
||||
"bytes": 6883,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/references/mcp-bundle.md",
|
||||
"upstream_path": "skills/cordis-plugin-development/references/mcp-bundle.md",
|
||||
"sha256": "944b431411ba50582d690f03aa239044556d7a1395d50d3db69cff3920f61ff3",
|
||||
"bytes": 984,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/references/practices.md",
|
||||
"upstream_path": "skills/cordis-plugin-development/references/practices.md",
|
||||
"sha256": "5a61f4e0f754eded6116e55bbf72471fd9b128e138ea3faca120cda76701a042",
|
||||
"bytes": 7825,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/references/ui-plugin.md",
|
||||
"upstream_path": "skills/cordis-plugin-development/references/ui-plugin.md",
|
||||
"sha256": "830a1710af97e3930ba5f29062e46ff78f7be3337995fbb20e85867f80339e23",
|
||||
"bytes": 1992,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/references/user-actions.md",
|
||||
"upstream_path": "skills/cordis-plugin-development/references/user-actions.md",
|
||||
"sha256": "ecc7f1226536584d781af8f1f9dee30d7262943dada67bfd997eb7902b34a9be",
|
||||
"bytes": 1375,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/references/verification.md",
|
||||
"upstream_path": "skills/cordis-plugin-development/references/verification.md",
|
||||
"sha256": "a28f7b91d0ec4592533360d11fd8a65dc2f103058e2ebf2f7e1497ad90afa9e2",
|
||||
"bytes": 1277,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/client.js",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/client.js",
|
||||
"sha256": "bb17be70ae65f05d2cb26dd18f54448d706a89a1ba4da7ee3fe46ed46ac89f69",
|
||||
"bytes": 686,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/cordis.patch.yml",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/cordis.patch.yml",
|
||||
"sha256": "542639675b704bbdfa7c6694011c6e57d05c403e4499baa35731902a9063e7fc",
|
||||
"bytes": 69,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/icon.svg",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/icon.svg",
|
||||
"sha256": "4adcba5c2950e778c213101265820e287ed6b6248ba58e22840eef7690960935",
|
||||
"bytes": 118,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/index.js",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/index.js",
|
||||
"sha256": "87edbe4014ee569545ade02f7aa1915f2d2135ffebad84016a247d4542d6f39a",
|
||||
"bytes": 108,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/locale/de.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/locale/de.json",
|
||||
"sha256": "f6789b92bc3b81c62c570b35d5ad160e5f3011af868209daa5cb7db722e24a8c",
|
||||
"bytes": 139,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/locale/en.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/locale/en.json",
|
||||
"sha256": "6768a03c91f2ebacca4dc837619dfa9b81aa3bec02ad8d4ed72e58753ba59bdf",
|
||||
"bytes": 128,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/locale/es.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/locale/es.json",
|
||||
"sha256": "fd27b119a98ab199664bc7e5056923feb59cbf2f8760fd3e311062b97d3d778f",
|
||||
"bytes": 142,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/locale/fr.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/locale/fr.json",
|
||||
"sha256": "e9cc642906d1a8511ddabae1871f583e198150813dd6d97aae034c2647fa0e2e",
|
||||
"bytes": 139,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/locale/ja.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/locale/ja.json",
|
||||
"sha256": "146163de7f067d174848d2fd0c015eb31c363815f1d5063139dc72e31af0abfd",
|
||||
"bytes": 140,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/locale/pt.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/locale/pt.json",
|
||||
"sha256": "493127e4227fdd8d3a39f98c79ab0e8fb656e0dcaa444d260942af6de1f19d2f",
|
||||
"bytes": 140,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/locale/ru.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/locale/ru.json",
|
||||
"sha256": "2ce2bfa3b3a723d347640d3c84b0761bdf2f1ec0508a1f344093243d95677486",
|
||||
"bytes": 202,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/decoration/package.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/decoration/package.json",
|
||||
"sha256": "08a8d23db9b417c706bd57084b99781631a95f9fd556558da0a3920ac7fa7690",
|
||||
"bytes": 527,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/cordis.patch.yml",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/cordis.patch.yml",
|
||||
"sha256": "9a828bb02bd1aa07a6f873d823b725e85279b7ddf4bebbba40aa391ac501e9b5",
|
||||
"bytes": 217,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/icon.svg",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/icon.svg",
|
||||
"sha256": "3afa45f14c22ab9f88075411708861bc35fc54bcffd0cbed7c508d2b4a0f6d1a",
|
||||
"bytes": 335,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/locale/de.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/locale/de.json",
|
||||
"sha256": "4d2476d6f5ce49fe5be9336be4d8220479699c24a4c1f023e749fc2c197e2afc",
|
||||
"bytes": 166,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/locale/en.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/locale/en.json",
|
||||
"sha256": "4298642447b595850c22ff2c878555877ef252aecc651f87f2318c26fa285625",
|
||||
"bytes": 148,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/locale/es.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/locale/es.json",
|
||||
"sha256": "c263c4950f346fb677f8fbd08feba6a69c5822370ea737c2c3c06664eca4fdbd",
|
||||
"bytes": 180,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/locale/fr.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/locale/fr.json",
|
||||
"sha256": "698d8899017ed6102f317d3b28f4dbb72d72cb90045817b64450d1d1e066e9cb",
|
||||
"bytes": 159,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/locale/ja.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/locale/ja.json",
|
||||
"sha256": "7d7fe99c8bc504c489fcffb5c90d3ec2f3dd4015633a9c5477da12e2efc51128",
|
||||
"bytes": 194,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/locale/pt.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/locale/pt.json",
|
||||
"sha256": "9a911263962a86c98a59ee96d75657f3531133c03bd8cac64fb4094711798d87",
|
||||
"bytes": 167,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/locale/ru.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/locale/ru.json",
|
||||
"sha256": "6fe10339d533eb4ba1a6eea4f38459046b8c420e5c2ea76160b14ee41727a885",
|
||||
"bytes": 247,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/cordis-plugin-development/templates/mcp/package.json",
|
||||
"upstream_path": "skills/cordis-plugin-development/templates/mcp/package.json",
|
||||
"sha256": "592eb1cc8b7072ab06ca96afefefe444a9dfb519877a225a076966aae37082cc",
|
||||
"bytes": 292,
|
||||
"verbatim": true
|
||||
},
|
||||
{
|
||||
"path": "skills/editing-cordis-compositions/SKILL.md",
|
||||
"upstream_path": "skills/editing-cordis-compositions/SKILL.md",
|
||||
"sha256": "974cc1e115bf07a8de7229c20a7e7aa7fa5cc5bfe0fbc22f86b5bc776a7298e6",
|
||||
"bytes": 5739,
|
||||
"verbatim": true
|
||||
}
|
||||
],
|
||||
"note": "Copied unmodified. Identical to packages/preset/agent-preset/skills in the public repository source."
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
This skill was written for DeepSeek Harness. In Codewhale:
|
||||
|
||||
- Only five DSH packages can be named in a patch row: the MCP client, the skill filesystem, the two hook bridges and the persona. Rows that name other packages are skipped, and the install result lists them. `references/packages.md` lists packages this host does not load.
|
||||
- A row that needs a Loader `!!js` expression is refused at install, with the row named.
|
||||
@@ -0,0 +1,13 @@
|
||||
This skill was written for DeepSeek Harness. In Codewhale these points replace the matching sentences above:
|
||||
|
||||
- The tool that loads skills is `load_skill`, not `skill`. Read the template and reference files from the base directory above with the file-read tool.
|
||||
- There is no profile folder and no pnpm. Never write under `~/.codewhale/plugins`; write the package in the workspace and call `plugin_manager` `install_bundle` with the folder. `DSH_PROFILE` and `DSH_PROFILE_DIR` are absent: use inspection instead of `dsh --dump-config`.
|
||||
- Codewhale's built-in plugins are switches the person owns. Enabling one by writing an overriding bundle does not work: `list_bundles` shows them as not changeable, and `set_bundle` or `set_plugin` on one answers `management-required`.
|
||||
- There is no package directory for DSH packages bridged by Codewhale. `Config.listConfigs` returns the accepted config schema and notes inline; do not look for a `README.md` or `lib/` to read.
|
||||
- A patch row that needs a Loader `!!js` expression is refused at install, with the row named.
|
||||
- `configEditor` and `credentials` do not exist here. Plugin settings are the person's `[plugins."<name>".config]` entries.
|
||||
- Replacing an installed package needs no restart: `remove_bundle` then `install_bundle` loads the new code.
|
||||
- `approvedBuilds` does not apply. Codewhale never runs a package manager or an install script; a package whose rows name a module that is not inside it is refused whole.
|
||||
- Version exemptions are not available: the list is empty and a grant is refused.
|
||||
- Connector tools from a plugin are named `mcp_plugin-...`; the install result's `warnings` list the exact new tool names to call.
|
||||
- A visual plugin appears in the desktop app's plugin seats. `Slots.listSubTree` lists each seat and whether it is drawn; in the terminal it says no client is connected. The plugin frame has no `ctx.remote.commands.execute()` channel to the Engine.
|
||||
@@ -0,0 +1 @@
|
||||
This skill was written for DeepSeek Harness. In Codewhale an agent preset bundle is refused at install: only the retired roster format is bridged. Do not create or change a preset here; tell the person it is not available.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: agent-experience
|
||||
description: Use when writing or changing a model-facing tool definition (name, description, parameter schema, or its system-prompt section), and when designing skills, context loading, or multi-step workflows, to make information discoverable and use context efficiently.
|
||||
---
|
||||
|
||||
- **Start with minimal context:** expose purpose, available actions, and constraints first; load detailed instructions when needed.
|
||||
- **Make discovery explicit:** every deferred resource needs a clear description and a reliable way to retrieve it.
|
||||
- **Keep critical constraints visible:** permissions, destructive effects, and required validation should appear before the relevant action.
|
||||
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
|
||||
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
|
||||
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
|
||||
|
||||
## Tool definitions
|
||||
|
||||
- **Delete obvious constraints.** Omit rules the model learns from the call result, such as a missing file failing to read or a failed send not being delivered.
|
||||
- **Describe behavior, not implementation.** State what the tool does and returns; omit internal mechanisms, output markers the model sees in results, and enforcement details.
|
||||
- **Put parameter rules on the parameter.** Defaults, ranges, pairing, and when-to-set rules belong in that parameter's description or schema; a description that varies with configuration usually means the varying part belongs to a parameter.
|
||||
- **Say each fact once.** Do not repeat the tool definition in its system-prompt section, a parameter description in the tool description, or one tool's rules in another tool.
|
||||
- **Measure the change.** Compare first-turn prompt tokens before and after a tool-definition change.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: cordis-composition-reference
|
||||
description: Use when writing or reading a Cordis bundle patch or agent preset for this harness and you need the Loader YAML dialect (insert, override by id, group, disabled, isolate, !!js) or the list of installable Harness plugin packages and what each provides.
|
||||
---
|
||||
|
||||
# Cordis composition reference
|
||||
|
||||
Reference material for bundle patches and preset plugin lists. Procedures live in `cordis-plugin-development` and `editing-cordis-compositions`.
|
||||
|
||||
## Loader patch dialect
|
||||
|
||||
A profile composes an ordered list of patch layers over the bundle entry lists. Each patch is a mapping:
|
||||
|
||||
- `insert: [rows]` appends rows; with an `id` naming an existing `group: true` row, the rows are appended inside that group's `config` list.
|
||||
- A patch with an `id` and no `insert` targets the existing row with that id. Supplied fields replace the row's fields; `config` is replaced wholesale, never deep-merged, so restate every field the row needs. A truthy `name` asserts the existing plugin name rather than renaming it.
|
||||
- Non-insert patches without a nonempty `id`, and targets that match no row, are warned about and skipped.
|
||||
|
||||
A row has `id`, `name` (the plugin package specifier; inserted relative paths are anchored beside their patch file), optional `config`, and optional `disabled`, `inject`, `intercept`, and `isolate`.
|
||||
|
||||
- `group: true` with `name: cordis:group` makes `config` a nested entry list and allows patches to insert into it by id. `cordis:include` loads a literal YAML or JSON entry list from `config.path`.
|
||||
- `disabled` accepts a boolean, null, or a `!!js` expression evaluated against the Loader context at every mount decision. A disabled row omits required `config` unless `group: true` forces activation.
|
||||
- `!!js` scalars are Loader expressions, never `!js`. Inside `config` they are evaluated after the row's declared injections activate, against that plugin's context (`ctx.<service>`), so `!!js dshHomePath('sessions')` and `!!js "!ctx.get('profileContext')"` are valid. Other row metadata stays literal.
|
||||
- `isolate` maps service names to `true` or a realm label; a preset plugin that provides a service isolates the provider and all consumers together. Scope controls contributions and event visibility; `isolate` controls service instances.
|
||||
|
||||
`plugin_manager` `list_plugins` reports each row's `enabled` flag and `fiberPhase`; `set_plugin` and `set_bundle` answer `overridden` when a higher-priority layer wins.
|
||||
|
||||
## Installable plugin packages
|
||||
|
||||
`references/packages.md` lists every workspace package that exports a Cordis plugin, grouped by package group, with whether its row takes `config`. Search it with `grep -n <keyword>` rather than reading it whole. Plugins outside this list come from installed bundles; `plugin_manager` `list_bundles` names them.
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
# Loadable Harness plugin packages
|
||||
|
||||
This file is GENERATED from workspace manifests (`scripts/gen-plugin-packages.ts`) and verified fresh by `pnpm run verify-plugin-packages` (part of `doc-sync`); do not edit it by hand.
|
||||
|
||||
Every package below exports a Cordis plugin that a bundle patch can name in a Loader row. `Config` marks packages whose row accepts a `config` mapping; query `Config.listConfigs` through `cordis_inspect_query` (filter by `name`, then query the `entry` id) for the mounted schema. Packages under `experimental` are pre-stable.
|
||||
|
||||
## acp
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-acp` | yes | Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio |
|
||||
|
||||
## api
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-api-account-controller` | no | Expose safe account operations over authenticated Remote |
|
||||
| `@deepseek-ai/dsh-api-gateway` | yes | Typert Remote Host dispatcher and Client API endpoint |
|
||||
| `@deepseek-ai/dsh-api-job-controller` | yes | Job Remote observation stream and the reference-counted client job-output service |
|
||||
| `@deepseek-ai/dsh-api-remote-access` | no | Method-level Remote access control for non-owner Peers and the per-Peer access snapshot |
|
||||
| `@deepseek-ai/dsh-api-remotes` | no | Remote BFF assembly for application-selected Host capabilities |
|
||||
| `@deepseek-ai/dsh-api-session-controller` | yes | Session Remote commands, cold reads, and live control transport |
|
||||
| `@deepseek-ai/dsh-api-settings-controller` | yes | Remote owner for the configuration surfaces over the settings-domain seams |
|
||||
| `@deepseek-ai/dsh-api-terminal-controller` | yes | Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control |
|
||||
| `@deepseek-ai/dsh-api-workspace-controller` | yes | Workspace Remote commands and reconnect-safe state transport |
|
||||
| `@deepseek-ai/dsh-api-workspace-files` | yes | Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace |
|
||||
|
||||
## attachment
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-attachment-local` | yes | Private content-addressed DSH_HOME attachment storage |
|
||||
|
||||
## boot
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-config-editor` | no | Persist plugin configuration through profile patches and Loader reconciliation |
|
||||
| `@deepseek-ai/dsh-hmr` | yes | Coordinated module and profile configuration hot reload |
|
||||
| `@deepseek-ai/dsh-plugin-manager` | yes | Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools |
|
||||
| `@deepseek-ai/dsh-plugin-market` | yes | Configured plugin market indexes for the Web Discover page and the plugin_market agent tool |
|
||||
|
||||
## browser-use
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-browser-use` | no | Exclusive named browser-use provider registration |
|
||||
|
||||
## bundle
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-acp-app` | no | The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base |
|
||||
| `@deepseek-ai/dsh-headless` | yes | The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer |
|
||||
| `@deepseek-ai/dsh-sdk-app` | yes | The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base |
|
||||
| `@deepseek-ai/dsh-web-app` | yes | The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, bash runtime variables, URL line) |
|
||||
|
||||
## client
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-client-connection` | yes | Authenticated RPC transport and generation lifecycle |
|
||||
| `@deepseek-ai/dsh-client-file-upload` | no | Agent-scoped browser file upload, streaming intake, and staged receipt service |
|
||||
| `@deepseek-ai/dsh-client-hmr` | yes | Web client graph synchronization and rebuilt-bundle reload transport |
|
||||
| `@deepseek-ai/dsh-client-locale` | no | Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries |
|
||||
| `@deepseek-ai/dsh-client-modules` | no | Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam |
|
||||
| `@deepseek-ai/dsh-client-product-analytics` | yes | Desktop product event collection and authenticated Host reporting |
|
||||
| `@deepseek-ai/dsh-client-resources` | no | Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook |
|
||||
| `@deepseek-ai/dsh-client-shortcuts` | yes | Application keyboard command registry and physical-key routing |
|
||||
| `@deepseek-ai/dsh-client-ui-agent-preset` | no | Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor |
|
||||
| `@deepseek-ai/dsh-client-ui-approval` | no | Approval composer takeover over the scoped Remote Event waterfall |
|
||||
| `@deepseek-ai/dsh-client-ui-attachment` | no | Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots |
|
||||
| `@deepseek-ai/dsh-client-ui-brand-official` | no | Official DeepSeek Harness brand occupants for the Web client's sidebar slots |
|
||||
| `@deepseek-ai/dsh-client-ui-chat` | no | Chat Conversation target, node definitions, renderers, and details surface |
|
||||
| `@deepseek-ai/dsh-client-ui-commands` | no | Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry |
|
||||
| `@deepseek-ai/dsh-client-ui-context-menu` | no | Typed, plugin-extensible context menus with captured targets and shared Web presentation |
|
||||
| `@deepseek-ai/dsh-client-ui-conversation` | no | Target-neutral Conversation assembly, shell, composer, queue, and view navigation |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | no | Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | no | In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | no | Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser |
|
||||
| `@deepseek-ai/dsh-client-ui-entry-customization` | no | Customize plugin entries in the left sidebar, conversation views, and right sidebar, and quickly create new plugins. |
|
||||
| `@deepseek-ai/dsh-client-ui-file-actions` | no | Original-file saving and image clipboard actions for Web and Desktop |
|
||||
| `@deepseek-ai/dsh-client-ui-file-drag` | no | Application-owned file drag transfers and localized failure feedback |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | no | Session goal surface: GoalBar docked above the composer, read from the goal session projection |
|
||||
| `@deepseek-ai/dsh-client-ui-input-trigger` | no | Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources |
|
||||
| `@deepseek-ai/dsh-client-ui-jobs` | no | Session-header background-job list with on-demand streaming record panels |
|
||||
| `@deepseek-ai/dsh-client-ui-layout` | no | Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels) |
|
||||
| `@deepseek-ai/dsh-client-ui-message-feedback` | no | The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes |
|
||||
| `@deepseek-ai/dsh-client-ui-model-selection` | no | Model selection over the shared model catalog, Session projection, and session.selectModel |
|
||||
| `@deepseek-ai/dsh-client-ui-open-in-app` | no | Web "Open In..." controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file |
|
||||
| `@deepseek-ai/dsh-client-ui-permission-presets` | no | Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection |
|
||||
| `@deepseek-ai/dsh-client-ui-plan` | no | Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews |
|
||||
| `@deepseek-ai/dsh-client-ui-plugin-manager` | yes | Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages |
|
||||
| `@deepseek-ai/dsh-client-ui-plugin-market` | no | Discover tab of the dsh web Plugins page: browse configured plugin market indexes and install their bundles |
|
||||
| `@deepseek-ai/dsh-client-ui-reference` | no | Unified Web @file and @session reference source |
|
||||
| `@deepseek-ai/dsh-client-ui-renderer` | no | Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root |
|
||||
| `@deepseek-ai/dsh-client-ui-schedule` | no | Host task management page and Session reminder catalog |
|
||||
| `@deepseek-ai/dsh-client-ui-secret-mask` | no | Web display masking for recognized credentials in sent user text |
|
||||
| `@deepseek-ai/dsh-client-ui-session` | no | Session Controller adapter for React and session-scoped slots |
|
||||
| `@deepseek-ai/dsh-client-ui-settings` | no | Settings domain base plugin: shared configuration forms and the canonical settings slot-type contract |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-account` | yes | Manage DeepSeek login and open Platform billing pages |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-agent-loop` | no | Settings page of the agent loop on the dsh web client's Plugins page: the parallel tool-call cap of the agent-loop namespace |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-general` | no | Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-models` | yes | Models settings and shared product-onboarding dialogs over existing settings and credential joins |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` | no | Read-only Cordis Loader inventory tab in Web Plugins settings |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-plugins` | no | Built-in plugins settings section for the dsh web client: the Settings navigation entry and the tab chrome feature-owned tabs register into |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-session-log` | no | General settings control for Session-log upload with DeepSeek API requests |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-shell` | no | Settings page of the shell executor on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-subagent` | no | Settings page of Subagent delegation on the dsh web client's Plugins page: recursion depth, parallel capacity, and the models agents may choose for subagents |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-web-search` | no | Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget |
|
||||
| `@deepseek-ai/dsh-client-ui-shortcuts` | no | Keyboard shortcut reference, recording, and local preference editing |
|
||||
| `@deepseek-ai/dsh-client-ui-sidebar` | no | Sidebar plugin: session multi-level tree, search, grouping, state dots |
|
||||
| `@deepseek-ai/dsh-client-ui-sidebar-browser` | no | Sandboxed Web browser tabs for the right Sidebar |
|
||||
| `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` | yes | Extensible Sidebar previews for Office documents, spreadsheets, Markdown, code, images, PDF, HTML, and plain text |
|
||||
| `@deepseek-ai/dsh-client-ui-sidebar-files` | no | The workspace files page and one workspace directory tree per pane beside docked and floating file previews, listed lazily over the workspaceFiles Remote namespace |
|
||||
| `@deepseek-ai/dsh-client-ui-sidebar-right` | no | Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it |
|
||||
| `@deepseek-ai/dsh-client-ui-sidebar-terminal` | no | Interactive shell tabs for the right Sidebar |
|
||||
| `@deepseek-ai/dsh-client-ui-skill` | no | Web skill references and the dedicated skill tool row |
|
||||
| `@deepseek-ai/dsh-client-ui-subagent` | no | Subagent conversation catalog, continuation routing UI, and '@' reference source |
|
||||
| `@deepseek-ai/dsh-client-ui-theme` | yes | Theme plugin: Host bootstrap for the pre-plugin palette; ThemeRuntime for light/dark/system state and icon replacements; --dsw-* token styles and Appearance settings row |
|
||||
| `@deepseek-ai/dsh-client-ui-tool` | no | Client Tool call-tree renderer and keyed per-tool presentation slot |
|
||||
| `@deepseek-ai/dsh-client-ui-trajectory` | yes | Trajectory event ledger and timing overview with live tool-result inspection |
|
||||
| `@deepseek-ai/dsh-client-ui-user-questions` | no | Web ask_user_question composer takeover and plan-review presentation UI |
|
||||
| `@deepseek-ai/dsh-client-ui-workflow-run` | no | Durable workflow-run Conversation Node and nested member disclosure for dsh web |
|
||||
| `@deepseek-ai/dsh-client-ui-workspace` | no | Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots |
|
||||
|
||||
## compaction
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-command-compact` | no | Human-facing slash command for explicit session compaction |
|
||||
| `@deepseek-ai/dsh-compaction-basic` | yes | Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-compaction-image-offload` | no | Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry |
|
||||
| `@deepseek-ai/dsh-compaction-tool-result-pruner` | yes | Replay-safe model-free head/middle/tail pruning for tool-result surface nodes |
|
||||
|
||||
## computer-use
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-computer-use` | no | Exclusive named computer-use provider registration |
|
||||
|
||||
## context
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-agent-instructions` | yes | Workspace context loader for AGENTS.md/CLAUDE.md instruction files |
|
||||
| `@deepseek-ai/dsh-file-reference-local` | yes | Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes |
|
||||
| `@deepseek-ai/dsh-harness-dev-context` | no | Model context naming the DeepSeek Harness checkout and Web GUI URL without letting either change the system prompt |
|
||||
| `@deepseek-ai/dsh-session-reference` | yes | Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver) |
|
||||
| `@deepseek-ai/dsh-time-context` | yes | Durable per-step context with the current time and elapsed time |
|
||||
| `@deepseek-ai/dsh-tmux-context` | yes | Opt-in durable per-step context with this agent's tmux pane and window location |
|
||||
|
||||
## core
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-agent` | no | Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-agent-default-model` | yes | Default model selection shared by Agent entry points |
|
||||
| `@deepseek-ai/dsh-agent-loop` | yes | The concrete agent loop plugin for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-agent-tool-presentation` | yes | Deprecated tool presentation selector; use dsh-tool-executor-native or dsh-tool-executor-ptc directly |
|
||||
| `@deepseek-ai/dsh-session` | no | Event-sourced session store for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-system-prompt` | yes | System prompt assembly registry for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-tool-executor-native` | no | Native tool execution plugin |
|
||||
| `@deepseek-ai/dsh-tool-executor-ptc` | yes | PTC program execution, tool bindings, and generated SDK prompts |
|
||||
| `@deepseek-ai/dsh-tools` | no | Tool registry and execution pipeline for the DeepSeek Harness |
|
||||
|
||||
## credentials
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-authorization` | no | Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human |
|
||||
| `@deepseek-ai/dsh-credentials-local` | yes | File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-deepseek-account-platform` | yes | Authorize DeepSeek accounts through browser PKCE |
|
||||
|
||||
## deliverables
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-deliverable-file-references` | no | Shared model guidance for file links and deliverables across DeepSeek Harness hosts |
|
||||
| `@deepseek-ai/dsh-tool-present` | yes | Explicit workspace file delivery declarations for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-workspace-changes` | yes | Per-turn workspace file changes recorded from git working-tree snapshots and whole-file captures, with per-file comparisons, for the DeepSeek Harness |
|
||||
|
||||
## document
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-office-to-pdf` | yes | Shared Office-to-PDF conversion with bounded queues and caching |
|
||||
|
||||
## experimental
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-action-cards` | yes | Optional action cards below answers: one shared proposal tool, card state, user actions and status notices |
|
||||
| `@deepseek-ai/dsh-client-ui-action-cards` | no | Action cards below completed answers: the shared card frame, kind bodies and the compact tool row |
|
||||
| `@deepseek-ai/dsh-client-ui-creation-recommendations` | no | Body of the personalized page card: location choice, creation progress, Keep, Undo and Restore |
|
||||
| `@deepseek-ai/dsh-client-ui-plugin-recommendations` | no | Official plugin card body with catalog text and enabling |
|
||||
| `@deepseek-ai/dsh-creation-recommendations` | no | Create persistent removable interface plugins from explicit conversation needs |
|
||||
| `@deepseek-ai/dsh-cua` | no | Shared Computer Use application objects, authorization, and logged tool execution |
|
||||
| `@deepseek-ai/dsh-experimental-agent-team` | yes | Implicit-root Agent Teams roster, direct peer messaging, and shared task DAG |
|
||||
| `@deepseek-ai/dsh-experimental-api-speech-to-text` | yes | Authenticated experimental speech transcription for browser clients |
|
||||
| `@deepseek-ai/dsh-experimental-auto-review` | no | Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset |
|
||||
| `@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp` | yes | Experimental per-Session Chromium browser tools through chrome-devtools-mcp |
|
||||
| `@deepseek-ai/dsh-experimental-browser-use-playwright-mcp` | yes | Experimental per-Session Chromium browser tools through @playwright/mcp |
|
||||
| `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | yes | Experimental Stagehand browser tools with separately configured native models |
|
||||
| `@deepseek-ai/dsh-experimental-claude-code-mods` | yes | Experimental bridge: load Claude Code mods (hooks modules) and run their hook chains on DeepSeek Harness extension points |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | no | Web Agent Teams roster, task board, and teammate navigation |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | no | Web band above the prompt for Claude Code mods: draws each session's mod tree and sends button presses back to the bridge |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-cot-translation` | yes | Optional machine translation of expanded reasoning, preserving original Session text |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-forge` | yes | Sidebar panel for a Session's change requests, with a worktree option for new Sessions and its Host Remote |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-stats` | no | Per-turn and per-step context, timing, cache, and tool-cost table contributed as an experimental conversation View |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | no | Record speech and insert editable text into the conversation draft |
|
||||
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp` | yes | Experimental computer use through an installed Cua Driver MCP executable |
|
||||
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | no | Experimental computer-use provider embedding the Cua Driver native npm SDK |
|
||||
| `@deepseek-ai/dsh-experimental-cua-macos` | yes | macOS native provider for shared Computer Use application and window objects |
|
||||
| `@deepseek-ai/dsh-experimental-forge` | yes | Provider-neutral change request registry with shared status polling |
|
||||
| `@deepseek-ai/dsh-experimental-forge-github` | yes | GitHub provider for the experimental forge registry, read through the GitHub CLI on the host |
|
||||
| `@deepseek-ai/dsh-experimental-forge-session` | yes | Binds change requests to the Session that created, pushed, or tracked them |
|
||||
| `@deepseek-ai/dsh-experimental-inspector` | yes | Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection |
|
||||
| `@deepseek-ai/dsh-experimental-ptc-runtime-python` | yes | CPython subprocess implementation of the DeepSeek Harness PTC execution seam |
|
||||
| `@deepseek-ai/dsh-experimental-session-inspector` | no | Experimental virtualized Session log and live Chat group/node inspectors |
|
||||
| `@deepseek-ai/dsh-experimental-speech-to-text` | yes | Experimental speech recognition with independently selectable providers |
|
||||
| `@deepseek-ai/dsh-experimental-speech-to-text-sensevoice` | yes | Local SenseVoice ONNX transcription with a managed sherpa-onnx process |
|
||||
| `@deepseek-ai/dsh-experimental-subagent-oar` | yes | Experimental one-shot subagents through OAR runtimes |
|
||||
| `@deepseek-ai/dsh-experimental-tool-agent-team` | yes | Scoped model-facing Agent Teams tools over ctx.agentTeams |
|
||||
| `@deepseek-ai/dsh-experimental-tool-subagent-oar` | yes | Delegate standalone tasks to configured external subagent providers |
|
||||
| `@deepseek-ai/dsh-experimental-tool-worktree` | no | Model tool that creates and enters a new Git worktree |
|
||||
| `@deepseek-ai/dsh-experimental-translator` | yes | Machine translation with reusable Session results |
|
||||
| `@deepseek-ai/dsh-experimental-worktree` | yes | Creates a named Git worktree and changes the calling Session working directory |
|
||||
| `@deepseek-ai/dsh-hooks-claude-code` | yes | Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams |
|
||||
| `@deepseek-ai/dsh-hooks-codex` | yes | Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams |
|
||||
| `@deepseek-ai/dsh-plugin-recommendations` | no | Official plugin suggestions with explicit user activation and persistent conversation decisions |
|
||||
| `@deepseek-ai/dsh-session-title-all-prompts-llm` | yes | All-user-messages LLM provider plugin for DeepSeek Harness session titles |
|
||||
| `@deepseek-ai/dsh-skill-badge` | no | Bundled dsh badge skill provider for DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-tool-ralph` | yes | Model-facing fresh-agent Ralph loop over the workflow and subagent seams |
|
||||
| `@deepseek-ai/dsh-tool-session-query` | yes | Workspace-authorized model-facing session history search, trace, and event read tools |
|
||||
| `@deepseek-ai/dsh-tool-terminal` | yes | Six model-facing persistent PTY tools with owner isolation and generic background-job integration |
|
||||
| `@deepseek-ai/dsh-webhook` | no | Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions |
|
||||
| `@deepseek-ai/dsh-webhook-github` | yes | Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime |
|
||||
|
||||
## extensions
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-client-ui-cordis` | no | Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch |
|
||||
| `@deepseek-ai/dsh-cordis-client-runner` | no | Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries |
|
||||
| `@deepseek-ai/dsh-cordis-host-runner` | yes | Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | no | Read-only runtime API inspection for Harness plugin development |
|
||||
|
||||
## feedback
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-command-feedback` | no | Log-only session feedback: the record event, the sessionFeedback Host Remote, and the human-facing slash command |
|
||||
| `@deepseek-ai/dsh-message-feedback` | yes | Canonical Session-log ratings and notes for finalized assistant messages |
|
||||
|
||||
## fs
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-fs-local` | yes | Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs) |
|
||||
| `@deepseek-ai/dsh-fs-observation-policy` | no | File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API) |
|
||||
| `@deepseek-ai/dsh-fs-sandbox` | yes | Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through |
|
||||
| `@deepseek-ai/dsh-tool-fs` | yes | Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs) |
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | yes | Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep) |
|
||||
| `@deepseek-ai/dsh-tool-str-replace-editor` | yes | Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service |
|
||||
|
||||
## goal
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-command-goal` | no | Human-facing slash command for persisted same-session goals |
|
||||
| `@deepseek-ai/dsh-goal` | yes | Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-goal-round-driver` | no | Race-fenced same-session goal-round driver |
|
||||
| `@deepseek-ai/dsh-tool-goal` | yes | Model-facing same-session goal tools with execution-time authority checks |
|
||||
|
||||
## guard
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-repeat-tool-reminder` | yes | Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls |
|
||||
| `@deepseek-ai/dsh-tool-call-timeout-policy` | no | Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins |
|
||||
|
||||
## host
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-host-directory-picker-auto` | no | Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host |
|
||||
| `@deepseek-ai/dsh-host-directory-picker-browse` | yes | In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem) |
|
||||
| `@deepseek-ai/dsh-host-directory-picker-native` | no | Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host |
|
||||
| `@deepseek-ai/dsh-host-frontend-static` | yes | SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses |
|
||||
| `@deepseek-ai/dsh-host-open-in-app` | yes | Host half of open-in-app: resolved application catalog, icons, and the launch endpoint as three webServer routes |
|
||||
| `@deepseek-ai/dsh-host-plugin-inventory` | no | Read-only Remote projection of current Cordis Loader plugin state |
|
||||
| `@deepseek-ai/dsh-host-product-telemetry-otel` | yes | Explicit product usage events exported through OpenTelemetry HTTP logs |
|
||||
| `@deepseek-ai/dsh-host-webserver` | yes | Web route-registration plugin: HTTP and upgrade routes, per-Peer index transform taps, and static dist fallback |
|
||||
|
||||
## interaction
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-commands` | no | Plugin-owned human command registry for DeepSeek Harness UIs |
|
||||
| `@deepseek-ai/dsh-permission-presets` | yes | User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | yes | Model-facing ask_user_question tool over the ctx.userQuestions seam |
|
||||
| `@deepseek-ai/dsh-user-approval` | yes | User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default |
|
||||
| `@deepseek-ai/dsh-user-questions` | no | Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs |
|
||||
|
||||
## jobs
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-jobs-local` | yes | Process-local implementation of the DeepSeek Harness background job registry seam |
|
||||
| `@deepseek-ai/dsh-tool-jobs` | yes | Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry |
|
||||
|
||||
## llm
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | no | Additive request-field registry for the official DeepSeek LLM API adapter |
|
||||
| `@deepseek-ai/dsh-llm` | no | Provider-neutral LLM service interface for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-llm-deepseek-account` | yes | DeepSeek account provider authentication and discovery |
|
||||
| `@deepseek-ai/dsh-llm-deepseek-api-key` | yes | DeepSeek api-key provider authentication and discovery |
|
||||
| `@deepseek-ai/dsh-llm-pi-ai` | yes | pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek) |
|
||||
| `@deepseek-ai/dsh-llm-retry` | yes | Provider-routed LLM request retry policy for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | yes | Active Loader-backed plugin package inventory for official DeepSeek LLM API requests |
|
||||
| `@deepseek-ai/dsh-token-meter` | yes | Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness |
|
||||
|
||||
## lsp
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-lsp` | no | Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy |
|
||||
| `@deepseek-ai/dsh-lsp-stdio` | yes | Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | yes | Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization |
|
||||
|
||||
## mcp
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-mcp-client` | yes | MCP client bridge: connects to MCP servers and registers their tools on ctx.tools |
|
||||
| `@deepseek-ai/dsh-mcp-resources` | no | Scoped MCP resource discovery and reading through shared model tools |
|
||||
|
||||
## plan
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-plan-mode` | yes | Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit |
|
||||
|
||||
## preset
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-agent-preset` | yes | Declare an Agent capability composition in Cordis YAML |
|
||||
| `@deepseek-ai/dsh-agent-preset-registry` | yes | Declarative Agent preset registry and profile-backed editing |
|
||||
| `@deepseek-ai/dsh-persona` | yes | Composition-authored deployment persona section for the DeepSeek Harness |
|
||||
|
||||
## ptc-runtime
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-ptc-runtime-node` | yes | Node PTC execution with async function, REPL and VM Script evaluators and optional retained state |
|
||||
|
||||
## sandbox
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-sandbox-local` | yes | Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed |
|
||||
| `@deepseek-ai/dsh-sandbox-policy` | yes | Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family |
|
||||
|
||||
## schedule
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-schedule` | yes | Host-wide durable reminders with shared management and delivery to existing or new Sessions |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | no | Model-facing scheduled task tools (schedule_create, schedule_list, schedule_view, schedule_edit) over the Host ctx.schedule service |
|
||||
|
||||
## sdk
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-sdk-jsonrpc-server` | yes | Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients |
|
||||
|
||||
## session
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-message-quotes` | no | Selected-text quotation and Markdown blockquote presentation |
|
||||
| `@deepseek-ai/dsh-session-checkpoint-policy` | no | Semantic session durability checkpoints before model requests and tool side effects |
|
||||
| `@deepseek-ai/dsh-session-log-deepseek` | yes | Incremental lossless session-log request extension for the official DeepSeek LLM API |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | yes | JSONL durable session persistence backend for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-session-projection` | no | Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state |
|
||||
| `@deepseek-ai/dsh-session-projection-cache` | yes | Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read |
|
||||
| `@deepseek-ai/dsh-session-stats` | no | Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-session-telemetry-otel` | yes | Feedback-authorized Session logs over byte-bounded OpenTelemetry HTTP requests |
|
||||
| `@deepseek-ai/dsh-session-title` | yes | Log-backed session title service and provider registry for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-session-title-first-prompt-llm` | yes | First-message LLM provider plugin for DeepSeek Harness session titles |
|
||||
| `@deepseek-ai/dsh-session-turn-outline` | no | Whole-log turn outline projection (turnOutline) for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-tool-working-directory` | no | Read and change the active Session working directory |
|
||||
| `@deepseek-ai/dsh-working-directory` | yes | Session working directories with durable changes and model context |
|
||||
|
||||
## session-query
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-session-log-export` | yes | Web Session-log ZIP export and Session Markdown copy |
|
||||
| `@deepseek-ai/dsh-session-query-sqlite` | yes | Concrete ctx.sessionQuery backend with SQLite FTS5 search |
|
||||
|
||||
## settings
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-settings` | no | Abstract user-settings seam (ctx.settings) for the DeepSeek Harness |
|
||||
|
||||
## shell
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-bash-local` | yes | Local-subprocess implementation of the DeepSeek Harness bash executor seam |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | yes | Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts) |
|
||||
| `@deepseek-ai/dsh-pwsh-local` | yes | Local PowerShell implementation of the DeepSeek Harness bash executor seam |
|
||||
| `@deepseek-ai/dsh-pwsh-sandbox` | yes | Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts) |
|
||||
| `@deepseek-ai/dsh-shell-env` | yes | Tool-independent managed DSH_* shell environment registry |
|
||||
| `@deepseek-ai/dsh-tool-bash` | yes | Model-facing bash tool with optional generic background-job and sandbox-escalation support |
|
||||
| `@deepseek-ai/dsh-tool-bash-persistent` | yes | Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service |
|
||||
| `@deepseek-ai/dsh-tool-pwsh` | yes | Model-facing pwsh tool over the bash executor seam |
|
||||
| `@deepseek-ai/dsh-tool-pwsh-persistent` | yes | Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service |
|
||||
|
||||
## skill
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-skill` | yes | Agent skill provider registry for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-skill-filesystem` | yes | Local filesystem skill provider for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-skill-office` | yes | Bundled Word, PowerPoint, and Excel workflows and LibreOffice Kit operations |
|
||||
| `@deepseek-ai/dsh-tool-skill` | yes | Model-facing skill loading tool for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-tool-workspace-dependencies` | yes | The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload |
|
||||
|
||||
## spill
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-spill-local` | yes | Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files) |
|
||||
| `@deepseek-ai/dsh-spill-policy` | yes | Token-budgeted tool-result retention with recoverable text and image paths |
|
||||
|
||||
## ssh
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-fs-ssh` | no | Filesystem provider over the shared POSIX SSH helper |
|
||||
| `@deepseek-ai/dsh-sandbox-ssh` | no | Remote POSIX sandbox argv provider over the shared SSH helper |
|
||||
| `@deepseek-ai/dsh-ssh` | yes | Shared OpenSSH connection and versioned POSIX remote helper |
|
||||
| `@deepseek-ai/dsh-subprocess-ssh` | no | Subprocess and terminal provider over the shared POSIX SSH helper |
|
||||
|
||||
## storage
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-storage` | no | Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-storage-domain` | yes | Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness |
|
||||
| `@deepseek-ai/dsh-storage-json` | yes | JSON file KV storage backend for the DeepSeek Harness storage hub |
|
||||
| `@deepseek-ai/dsh-storage-sqlite` | yes | SQLite storage backend (kv facet) for the DeepSeek Harness storage hub |
|
||||
|
||||
## subagent
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-subagent` | yes | Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | yes | Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol |
|
||||
| `@deepseek-ai/dsh-subagent-claude-code` | yes | One-shot Claude Code subagent provider over the official Agent SDK |
|
||||
| `@deepseek-ai/dsh-subagent-codex` | yes | One-shot Codex subagent provider over the official app-server protocol |
|
||||
| `@deepseek-ai/dsh-subagent-dsh-sdk` | yes | Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client |
|
||||
| `@deepseek-ai/dsh-subagent-fork-in-process` | yes | In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log |
|
||||
| `@deepseek-ai/dsh-subagent-spawn-in-process` | yes | In-process spawn subagent backend: runs a fresh child agent on ctx.agents |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | yes | Model-facing subagent delegation tool over the ctx.subagents seam |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | no | Shared messaging, interruption, and child discovery tools for subagents |
|
||||
|
||||
## subprocess
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-subprocess-local` | no | Local-subprocess implementation of the DeepSeek Harness subprocess seam |
|
||||
|
||||
## telemetry
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-otel` | no | Cordis service for independent ordinary-event and byte-bounded Session-log OTLP channels |
|
||||
|
||||
## terminal
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-terminal` | no | Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup |
|
||||
| `@deepseek-ai/dsh-terminal-bash` | yes | Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive |
|
||||
|
||||
## test-support
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-llm-replay` | yes | Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests) |
|
||||
|
||||
## todo
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-tool-todo` | yes | Model-facing todo_write tool over the DeepSeek Harness event-sourced session log |
|
||||
|
||||
## typert
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-typert-loader` | yes | Loader integration for generated Typert package contributions |
|
||||
|
||||
## web
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-tool-web` | yes | Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web) |
|
||||
| `@deepseek-ai/dsh-web` | yes | Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy |
|
||||
| `@deepseek-ai/dsh-web-deepseek-api` | yes | DeepSeek Search and Fetch API providers for the DeepSeek Harness web capability seam (ctx.web) |
|
||||
| `@deepseek-ai/dsh-web-fetch-http` | yes | Anonymous HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web) |
|
||||
| `@deepseek-ai/dsh-web-search-deepseek` | yes | DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web) |
|
||||
| `@deepseek-ai/dsh-web-search-exa` | yes | Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web) |
|
||||
| `@deepseek-ai/dsh-web-search-perplexity` | yes | Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web) |
|
||||
|
||||
## workflow
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-tool-workflow` | yes | Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine |
|
||||
| `@deepseek-ai/dsh-workflow-ptc` | yes | Workflow orchestration in the shared sandboxed Node PTC runtime |
|
||||
|
||||
## workspace
|
||||
|
||||
| Package | Config | Description |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-workspace` | no | Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness |
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: cordis-plugin-development
|
||||
description: Use when designing, reviewing, adding, enabling, disabling, installing, configuring, or debugging a plugin, bundle, feature, page, panel, tool, or MCP connection in the current Harness profile, including a shipped plugin that is disabled by default, and for any visual object, decoration, or widget request that names no other destination, which means an installed UI plugin rendered in the Harness Web UI.
|
||||
---
|
||||
|
||||
# Persistent Harness plugins
|
||||
|
||||
For implementation, use ordinary workspace files to author a bundle, then `plugin_manager` with `action: install_bundle` and the absolute package directory as `target` to install it in the current profile. Changes affect every session in that profile and survive restart. Load `editing-cordis-compositions` for agent preset changes.
|
||||
|
||||
Do not write the profile's `package.json` or `cordis.patch.yml`, create packages under `$DSH_HOME`, or run pnpm in the profile directory: `install_bundle` performs those steps, and each hand-made write outside the workspace needs its own approval. Every `plugin_manager` action, including `list_plugins` and `list_bundles`, also needs approval without Full access, so call it only when its result decides the next step.
|
||||
|
||||
## Design or review
|
||||
|
||||
Read the applicable references below and inspect the proposed or existing plugin against current APIs. Report design choices or findings instead of following the installation workflow. Do not write files, install bundles, or change profile state unless the user requests implementation.
|
||||
|
||||
## Enable a shipped plugin
|
||||
|
||||
A shipped bundle can resolve a plugin row and leave it `disabled`. The row id and its reason are in the shipped patch, `packages/bundle/*/cordis.patch.yml` in a source checkout. Write a workspace bundle whose patch overrides that row with `disabled: false` and inserts the Host rows it depends on; a source checkout's `apps/cli/config/examples/<feature>/cordis.yml` lists them for opt-in features. Packages shipped with dsh resolve from the dsh installation, so the bundle declares no dependencies on them. Install it with `install_bundle`.
|
||||
|
||||
## Deliver a working plugin first
|
||||
|
||||
1. Resolve the requested result and destination. An unspecified visual destination is the current Harness Web UI; a standalone image or HTML file does not complete such a request. Choose reasonable visual details and implement a small first version; install it before visual refinement.
|
||||
2. Discover only the APIs needed for that version: `cordis_inspect_list`, then targeted `cordis_inspect_query` calls. For UI, query Client `Slots.listSubTree` and the selected slot's registration options and props. For anything beyond a static decoration, such as tool policy, agent context, session-derived state, or Chat rows, read `references/practices.md` before choosing the extension point; for a user action in plugin UI, also read `references/user-actions.md`. Once the chosen slot and registration API are known, write the plugin.
|
||||
3. Read and copy the matching template files into one workspace directory, or author the package, patch, and required Host/Client files there. Before installing, complete the display checklist in `references/host-plugin.md`: meaningful localized titles/descriptions, an original or licensed icon, and exported resources with distribution checks where applicable. This applies to Host-only and configuration-only bundles too; adapt template text and artwork to the request. Check JavaScript syntax and the manifest, then install. Use the installed plugin as the first preview; do not create preview HTML, mock shells, design variants, screenshot scripts, or rasterizer tooling first.
|
||||
4. Read the installation result: `application` and `warnings` decide whether the change is live, not logs, process lists, or the page's boot payload. Confirm new rows with approval-free `cordis_inspect_query`, not `list_plugins`. After `application: applied`, exercise the capability or inspect the live Client registration. Verify installed display metadata and the icon using `references/host-plugin.md`. Use the connected page when available; follow `references/verification.md` for page/panel design checks and unavailable browser control, and `references/user-actions.md` for user actions. Restore user state changed during testing. Report verification limits; installation and slot registration alone do not establish what the user sees.
|
||||
5. Fix observed defects in the same plugin. When the requested result works, finish with its location, verification status, and the reason for any display-resource exception; never silently accept default artwork. Do not continue speculative variants, optional features, or mock previews. Close any task list.
|
||||
|
||||
## Knowledge sources, in order
|
||||
|
||||
1. Inspection: `cordis_inspect_query` answers exact Service methods and Event modes (`Service`, `Event`), a mounted plugin's Config JSON Schema (`Config.listConfigs`: filter the paged directory by `name`, then query the `entry` id), the Tools this Agent can call (`Tool`), and live Client Slots and theme tokens (`Slots`, `Theme`).
|
||||
2. Package documentation: `Config.listConfigs` with `name` set to the package finds its entries; querying one `entry` returns its `packageDir`, the resolved package directory. Read `<packageDir>/README.md`. Bundled packages resolve from the dsh installation and profile-installed bundles from the profile, so never guess the path from `$DSH_PROFILE_DIR`.
|
||||
3. Source: installed packages ship built `lib/index.js` and `lib/types/**/*.d.ts` with JSDoc under that same `packageDir`, not `src/`; a source checkout of DSH has `packages/<group>/<name>/src`. Read them when inspection and the README leave a question open, and start source-level diagnosis from a concrete installation or runtime failure.
|
||||
|
||||
`DSH_PROFILE` (profile name) and `DSH_PROFILE_DIR` (its directory, whose `node_modules` holds only profile-installed bundles) are set in every shell call of a profile-launched Harness and absent when the Harness was booted without a profile. Bash reads them as `$DSH_PROFILE`; PowerShell, which the Windows preset uses, reads them as `$env:DSH_PROFILE`. With `dsh` on the PATH, `dsh --profile "$DSH_PROFILE" --dump-config` prints the composed profile.
|
||||
|
||||
## Read next
|
||||
|
||||
The files below live in this skill's base directory, which the `skill` tool reported, and the table is the complete list: do not enumerate that directory. In every deployment, including a source checkout, read these files with the file-read tool, write copies into the workspace with the file-write tool, and verify a copy by reading it back. In Desktop the directory sits inside `app.asar`, which only the Host process's own file reads can open; shell commands (`ls`, `cat`, `cp`, `cmp`), the glob and search tools (they run a native ripgrep process), `node`, and pnpm all fail on it. Never install or syntax-check a template in place; copy its contents into the workspace first.
|
||||
|
||||
| Task | File |
|
||||
|---|---|
|
||||
| Bundle manifest, locale resources, icons, installation, Host exports and Config | `references/host-plugin.md` |
|
||||
| Web UI: Client manifest, module loader, slot registration | `references/ui-plugin.md` |
|
||||
| Connecting an MCP server through a configuration-only bundle | `references/mcp-bundle.md` |
|
||||
| Page/panel design checks and verification without browser control | `references/verification.md` |
|
||||
| UI plugin template, including display resources | `templates/decoration/package.json`, `templates/decoration/cordis.patch.yml`, `templates/decoration/index.js`, `templates/decoration/client.js`, `templates/decoration/locale`, `templates/decoration/icon.svg` |
|
||||
| MCP bundle template, including display resources | `templates/mcp/package.json`, `templates/mcp/cordis.patch.yml`, `templates/mcp/locale`, `templates/mcp/icon.svg` |
|
||||
| Loader patch dialect and the list of installable plugin packages | the `cordis-composition-reference` skill |
|
||||
| Choosing extension points, contexts, and state mechanisms for upgrade stability and performance | `references/practices.md` |
|
||||
| Sharing application operations between UI actions and agent tools | `references/user-actions.md` |
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# Bundles and Host plugins
|
||||
|
||||
A bundle is a package whose `package.json` declares `dsh.bundle.patch`; the YAML patch inserts plugin entries. Give the package and rows unique names; use the Loader's existing YAML syntax, including `!!js` where expressions are needed. Read an existing patch before editing it: a matching override replaces the complete `config`.
|
||||
|
||||
## Manifest
|
||||
|
||||
A Host-only bundle needs no dependencies, install scripts, or build tool:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@local/my-plugin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.js" },
|
||||
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
|
||||
}
|
||||
```
|
||||
|
||||
`cordis.patch.yml`:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: my-plugin
|
||||
name: '@local/my-plugin'
|
||||
config: {}
|
||||
```
|
||||
|
||||
## Display metadata and icon
|
||||
|
||||
Plugin Manager and Settings read `meta.title` and `meta.description` from exported locale JSON and an icon from exported resources without activating plugins. Complete this checklist before installation:
|
||||
|
||||
- Write a recognizable title and a description of the capability in `locale/en.json` under `meta.title` and `meta.description`. Add the same fields for the user's language and other supported locales, such as `locale/zh.json`; do not leave template copy unrelated to the delivered plugin. Write English titles and other UI labels in sentence case: capitalize only the first word and proper nouns, as in `Git worktrees`.
|
||||
- Create an original icon or use artwork whose license permits redistribution, retaining any required attribution.
|
||||
- Export `./locale/*.json` and `./icon`; retain existing runtime exports. Keep the patch, locales, icon, and every runtime file in `files`, updating it whenever you add modules or assets. For a packed or published bundle, verify the actual packed file list. Local directory installation links the checkout instead, so check the files directly; `files` does not filter a linked directory.
|
||||
- Check that the icon is a valid image of the declared format and satisfies the path and size limits below. An accepted filename alone does not establish that its bytes render.
|
||||
|
||||
For example, the locale file contains:
|
||||
|
||||
```json
|
||||
{ "meta": { "title": "My decoration", "description": "Draws a badge under the composer." } }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": { "./locale/*.json": "./locale/*.json", "./icon": "./icon.svg" },
|
||||
"files": ["index.js", "cordis.patch.yml", "locale/*.json", "icon.svg"]
|
||||
}
|
||||
```
|
||||
|
||||
A package-root plugin may instead declare a top-level `icon` in `package.json`; it takes priority over `./icon` and is a path relative to the manifest directory. A subpath plugin such as `my-plugins/search` is not a package and never reads a `package.json`; it exports `./search/locale/*.json` and `./search/icon` instead. Icons may be SVG, PNG, JPEG, or WebP up to 256 KiB and must stay inside the package; absolute paths, URLs, and symlinks leaving it are rejected. Missing fields of a package-root plugin fall back to `package.json` `name` and `description`, and missing images use the panel's default artwork; malformed metadata produces a diagnostic and keeps the valid text.
|
||||
|
||||
After installation, verify the intended title and description in bundle details and plugin rows, plus the rendered icons, in English and the user's locale where supplied. Check for metadata diagnostics and unintended fallbacks, and that each plugin row shows its own exported icon or, when it has none, the generic artwork. Without browser control, inspect the installed resources and report that rendered display remains unverified; do not claim visual success from installation alone.
|
||||
|
||||
An existing plugin's metadata may remain unchanged when it still describes the requested result. A disposable test fixture or an explicitly requested metadata-free package may omit resources, but explain each omission in the delivery report. A Host-only or configuration-only bundle still has a visible inventory entry; neither is an exception by itself.
|
||||
|
||||
## Host plugin export forms
|
||||
|
||||
`index.js` exports one of these forms; do not mix them:
|
||||
|
||||
- `export function apply(ctx, config) {}` with optional `export const inject = ['tools']` and `export const Config`.
|
||||
- A service class as the default export.
|
||||
|
||||
Register every resource inside `apply` with `ctx.effect` or `ctx.on` and return its cleanup. A plugin that declares `Config` validates the row's `config` at activation; query `Config.listConfigs` for an installed plugin's schema before writing its `config`, and follow `$defs` references in the returned document.
|
||||
|
||||
## Persist your own configuration
|
||||
|
||||
Plugin code never persists configuration by reading, filtering, and rewriting the active profile's `cordis.patch.yml`, and never locates that file by walking up from the package directory. That file is not your bundle's patch: it belongs to the user and holds other plugins' `insert` rows, comments, and `!!js` expressions. To change its own configuration after `apply` returns, a plugin uses the config editor:
|
||||
|
||||
```js
|
||||
const entry = ctx.fiber.entry
|
||||
const editor = ctx.get('configEditor')
|
||||
if (entry === undefined || editor === undefined) throw new Error('Configuration cannot be saved in this profile')
|
||||
await editor.edit(entry, current => ({ ...current, field: value }))
|
||||
```
|
||||
|
||||
`change` returns the row's complete config. The editor keeps the rest of the file, validates the candidate, serializes the write with other profile changes, and rejects values that a higher layer overrides. Report a rejection or a missing editor to the user instead of claiming the value was saved. Editing an ordinary field restarts the plugin so `apply` rebuilds its state; a field declared `.volatile()` updates in place and is read with `config.field.get()`. Store secrets, such as a token from an in-app sign-in or binding flow, as `ctx.credentials.modifyRecord()` records, not configuration; declare `credentials` in `inject` or read it with `ctx.get('credentials')`, and see the `@deepseek-ai/dsh-credentials` README for record keys.
|
||||
|
||||
## Install, enable, and observe
|
||||
|
||||
`plugin_manager` `install_bundle` performs package installation and bundle selection; do not reproduce those steps with shell commands. Only pass `approvedBuilds` after the user explicitly approves the reported pending build scripts. Preserve returned failures and pending states; report success only after observing the requested capability.
|
||||
|
||||
`list_plugins` and `list_bundles` return exact identifiers for existing installations. `set_plugin` and `set_bundle` toggle them; `remove_bundle` removes a bundle. Inspect saved-state and activation outcomes separately: `failed` requires diagnosis, `overridden` means a higher-priority layer wins, and `restart-required` means the change is not live. Installing a new bundle can activate through HMR; replacing an installed package requires restart to load a fresh JavaScript module generation. Do not infer updated browser code from an unchanged slot id.
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user