Compare commits

..
10 Commits
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 4b8a300097 fix(git): pass GIT_NO_LAZY_FETCH instead of --no-lazy-fetch (CW-8055)
The Git tool's review_scope action passed --no-lazy-fetch on every
command; git older than 2.44 rejects that option, so the action failed on
older git. The same protection is available as the GIT_NO_LAZY_FETCH
environment variable, which newer git honors and older git ignores. The
argument and environment are built in apply_review_git_options.

Checks: farm nextest -p codewhale-engine --lib git_tool: 8 run, 8 passed;
farm clippy -p codewhale-engine --all-targets -D warnings: clean;
cargo fmt --all -- --check and scripts/preflight.sh --check: OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 07:23:37 -08:00
CodeWhale BotandClaude Opus 5.5 99eb5c7769 feat(extension-host): fill the dsh/broker workspace arm over the existing file and Git tools (CW-7970)
The workspace arm serves four typed methods: read, list, write and exec.
Any other method is an unknown_method refusal and unknown fields are
invalid_input. Nothing is passed through.

Each act is one nested core call through the invocation's own context:
read_file, list_dir, write_file, or the Git tool's review_scope. So the
session's permission mode decides it like any other agent action (Ask
asks, Auto-Review judges, Full Access proceeds, Plan and deny lists
refuse), the per-invocation cap of 50 nested calls is the runaway
ceiling, and every act is receipted on the tool result.

Before the card the arm refuses when the live posture's deny list or a
child's grant would refuse the direct tool, and confines every path with
the existing resolve_strict_authority_path: relative, or absolute under
the workspace; no parent traversal; no symlink at any component, even
one pointing inside. The core tool receives the canonical path.
Confinement runs on the blocking pool behind a four-permit gate and stops
waiting on cancel.

exec takes a literal argv. argv[0] must be exactly git, and the rest
(after an optional --no-optional-locks) must equal one of the six
read-only metadata shapes the Git tool already runs, with cwd at the
workspace root if given. No shell, program path, -c or extra flag. The
Git tool runs it process-tree contained under a deadline and the call's
cancel token.

write is the read-write overlay. It is refused unless the plugin holds
the reviewed overlay grant, checked before any path is read; there is
no production source for that grant yet, so it is refused outside
tests. In the overlay, the workspace root and any .git or .codewhale
segment are refused. Writes are bounded to 1 MiB.

Refusals tell the plugin only what it needs; paths and gate messages go
to the log. The arm holds no state beyond the call.

src/dsh/workspace.ts is the matching facade, with bounds checked locally.
It is not registered and nothing is advertised: no service is granted.

Two seam tests that expected every unfilled stub to answer not_available
now skip the workspace arm, as they skip the network, remote and terminal
arms. The blocking-call budget records the two canonicalize calls in the
sync helpers that run only under spawn_blocking.

Known limits: a path is checked by the arm and resolved again by the core
tool, so a symlink swapped in between by a process that can write the
workspace is not caught here. Unmodified synchronous node:fs and
child_process calls stay refused by the Native sandbox; no OS sandbox
grant is added. The Git tool passes --no-lazy-fetch, which git older
than 2.44 rejects (exit 129), so exec fails on such hosts.

Checks (Linux farm): cargo nextest run --locked -p codewhale-engine --lib
-E 'test(extension_host)': 214 run, 214 passed, 6989 skipped, including
the seven new dsh_workspace unit tests and the new workspace broker test.
cargo clippy --locked -p codewhale-engine --all-targets -- -D warnings:
clean. Both on the tree rebased onto the grant slice. Extension host:
typecheck clean, build exit 0, node 455 tests 448 pass 7 skipped, bun 455
tests 449 pass 6 skip. cargo fmt --check and scripts/preflight.sh --check
pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 06:47:26 -08:00
CodeWhale BotandClaude Opus 5.5 0e425e0f8e fix(plugins): the agent's review is decided on the record at write time, and an agent's record belongs to its project (CW-8053)
Repair round 2.

- trust_reviewed now compares, inside the locked state closure, the
  generation on disk with the one the registry read, for an agent
  approver, and refuses with "state or review changed" when they differ.
  check_approver's "no review held for every project" rule read the
  registry's in-memory copy, so a registry read before a person's review
  (a long-lived terminal session, or a race) let the agent replace that
  review (re-review probe E). A plugin with no entry is generation 0 on
  both sides, so a first approval after an install still passes.
- Ruling R22: check_approver refuses the agent when the plugin's valid
  record is an agent's of another project (outside_reach); that agent
  asks the person. Before, the agent of B could take over A's agent
  record and turn the plugin off in A.

Tests: the_agent_cannot_replace_a_review_made_after_its_registry_was_read
and the_agent_of_another_project_cannot_take_over_an_agents_record. On
343a721580 both fail (farm run 20261111T132431Z-gaps-r2before-2bc61b:
15 run, 13 passed, 2 failed, unwrap_err on Ok). After: farm run
20261111T132426Z-gaps-r2test-b0f9bb, 438 run, 438 passed, 6990 skipped
(codewhale-plugins 231, engine extension_host 207). Clippy -D warnings
(20261111T132428Z-gaps-r2clippy-cadf9e) exit 0. preflight --check OK,
cargo fmt --check clean. Docs sentence names the other project's agent;
agent-docs regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:38 -08:00
CodeWhale BotandClaude Opus 5.5 cf6b8dc2af fix(plugins): the agent never replaces a review held for every project, and a linked project folder is not the agent's (CW-8053)
Repair round 1, per coordinator ruling R21 (amends R4's "a folder in the
workspace"). An install lands in the user's plugin folder, so the scope
test of dec4b298c1 refused exactly the plugin R4 is about, and it let a
linked <project>/.codewhale reach outside the project. check_approver now
holds what the crate can know:

- no workspace-folder scope test;
- refuses the agent for a built-in plugin;
- refuses the agent when the plugin's current record is valid and belongs
  to the reviewed cohort (a person, the command line, or a review from
  before 0.10.3): ask the person. This also closes the review's finding 3,
  where the agent's trust_reviewed replaced a person's record and walked
  around enable_by's refusal;
- for a Workspace-scoped plugin, requires its canonical folder to sit
  inside the canonicalized project; a canonicalize failure refuses.

enable_by keeps its record test (a record the agent made for this
project, not outside_reach) ahead of check_approver. CR-03 and the command
line check that the agent wrote and installed the plugin.

Tests: the_agent_never_replaces_a_review_held_for_every_project (probe D,
the command line, a pre-0.10.3 receipt, a built-in) and, on unix,
a_project_plugin_folder_that_resolves_outside_the_project_is_not_the_agents
(symlinked .codewhale refused; a real project folder accepted). The test
fixtures write plugins to the user folder again; the engine fixture
revokes the person's review before the agent reviews. Docs sentence
rewritten to R21; agent-docs regenerated.

Boat farm run 20261111T120302Z-gaps-r1test-7bff0d: 436 run, 436 passed,
6990 skipped (codewhale-plugins 229, engine extension_host 207).
scripts/preflight.sh --check OK; cargo fmt --check clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:34 -08:00
CodeWhale BotandClaude Opus 5.5 d22a678c31 docs(plugins): say which plugins the agent may approve or turn on, and that the command line grants no service (CW-8053)
web/data/agent-docs.generated.json regenerated with derive-facts.
scripts/preflight.sh --check: OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:30 -08:00
CodeWhale BotandClaude Opus 5.5 d69d83ad27 fix(runtime-api): record a review or turn-on from the app as the app, not the terminal (CW-8053)
The plugins route called trust and enable, which record
{person, via: terminal}. It now calls trust_reviewed and enable_by with
PersonVia::App. The receipt stays in review_history, so the label is fixed
before the tag. The native-UI route test reads the record back.

Boat farm run 20261111T102948Z-gaps-test-4768b2: app-server plugin tests
11 run, 11 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:27 -08:00
CodeWhale BotandClaude Opus 5.5 52521af86a fix(extension-host): a plugin whose review record cannot be read at the scan is not loaded (CW-8053)
desired_owners read the record with .ok(), so a read error between
active_component_sources and this read inserted the owner with an empty
grant and its cohort unknown (G6 fail-open). A read error now keeps the
plugin out with a diagnostic naming the reason, and the scan also refuses
when the in-memory snapshot says the agent approved it. The loop is split
into desired_owners_from so a test can break the record between the two
reads.

Test: a_record_that_cannot_be_read_at_the_scan_loads_no_owner (turns the
plugin off from another registry between the reads). Boat farm run
20261111T102948Z-gaps-test-4768b2: 447 run, 447 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:23 -08:00
CodeWhale BotandClaude Opus 5.5 6118b9c097 fix(plugins): the agent approves and turns on only a plugin folder in its own project, and a command line approval grants nothing (CW-8053)
Closes three gaps the grant slice's review found in the crate itself, so
they hold whatever a caller does:

- enable_by now checks an agent approver as trust_reviewed does (its own
  project, the thread, turn and tool call) and refuses when the plugin's
  record was approved for every project (by a person or the command line)
  or by the agent of another project. Before, an agent of project B could
  turn on a plugin limited to project A, and an agent with no call identity
  could turn on a plugin a person reviewed, which then ran everywhere.
  R4 and section 148.5: outside the agent's scope the caller asks the person
  through the normal card, so the approver it passes is then a person.
- check_approver refuses an agent approval unless the plugin was found in
  the workspace's own plugin folder (scope Workspace), R4's "a folder in the
  workspace". Before, the agent's review of a user-scope plugin a person had
  reviewed replaced that review.
- PluginApprover::CommandLine with a review token is refused: the command
  line shows no review, so it cannot record a host-service grant. Without a
  token it records trust as before. A command line process carrying the
  agent's mark is the agent (R14) and is checked as the agent.

The engine's grant test fixture writes its plugins into the workspace
plugin folder, because its agent-approved plugin must now live there.

Tests: four new or changed in host_service_tests (the three gaps, and the
command line test, whose agent now turns on only its own record). Boat farm
run 20261111T102948Z-gaps-test-4768b2: 447 run, 447 passed, 7837 skipped
(codewhale-plugins 228, engine extension_host 207, app-server plugin 11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:19 -08:00
CodeWhale BotandClaude Sonnet 5.5 d48d4b0883 fix(acp): a transport failure mid-turn still settles the turn and saves its checkpoint (CW-8043 repair 1)
A failed output write (including the 30 s write limit), a non-EOF read error and
an over-8 MiB non-response frame used to end the connection and drop the prompt
task, so the interrupted turn never reached drive_prompt's settle and
checkpoint save. Each now records the failure, sends the prompt Eof and
notifies shutdown exactly as a clean EOF does; the output pump keeps draining
and discarding, the read error ends the input stream, and the oversize sink
frame is dropped. serve_connection returns the recorded error after the prompt
has settled.

Tests: the guard that used a failing emit closure (unreachable in production)
is restored to a real failing writer through serve_connection, with a failing
reader, a failing writer at the permission request, and an oversize-update sink
test. The three end-to-end tests fail on the previous commit (0 passed, 3
failed) and pass now.

Farm: lib acp_server 43 passed, acp_journey 2, acp_conformance 1,
single_turn_loop 14, clippy exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 05:51:11 -08:00
CodeWhale BotandClaude Sonnet 5.5 00e3327004 refactor(acp): serve ACP on the official SDK agent connection (CW-8043 ACP-SDK-S1)
CapturedAcpFrontend::serve keeps its signature and now drives an
agent-client-protocol v1 agent over our own bounded Lines transport: the SDK
owns framing, ids, ordering and error envelopes. Replaces the handwritten
read loop (AcpServer::serve), AcpDispatch, the write_* JSON helpers,
JsonRpcResponseIdPolicy, is_jsonrpc_response and the permission id counter.

- Inbound: BoundedLines stream, blank lines skipped, an over-limit line is
  answered -32700 with the connection kept; daemon/input_closed (sent without
  a jsonrpc member) ends the stream as EOF before the SDK sees it.
- Outbound: channel sink drained into the borrowed writer; an over-8 MiB
  response becomes the same -32603, anything else ends the connection.
- Handlers are catch-alls over Value params, so today's validators, session
  cap, posture checks and Codewhale methods are unchanged and requests before
  initialize are still served. A prompt runs in cx.spawn behind a
  synchronously set busy flag; cancel, permission answers and EOF reach
  project_turn through an inbox. Every spawned task and callback returns
  Ok, prompt failures answer -32603 through the responder.
- Frames are built from the official v1 types. modelSelection and models are
  gone, list createdAt/messageCount move to _meta.codewhale, root sessionId
  stays on session/load. Terminal auth methods only for clients that enabled
  them, a permission request always carries text, notifications are never
  answered, session/list, config setters and provider methods are served
  while a turn runs, and the unused session cursor is deleted.

Tests changed: initialize_* (typed builder; modelSelection asserted absent),
the four wire-helper tests (replaced by one connection test), the image
update test (typed update), Rig::prompt and permission cases (run the real
agent over a duplex), status lists (omitted pending default counts as
pending). Added: oversize inbound, oversize outbound, session cap, mid-turn
requests. Conformance allowlist shrinks to the load sessionId row.

Farm (Linux): acp_journey 2 passed, acp_conformance 1 passed, lib acp_server
40 passed, core single_turn_loop 14 passed, clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 05:51:07 -08:00
20 changed files with 3173 additions and 957 deletions

No files matched your search

+2
View File
@@ -7,8 +7,10 @@ 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.
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(&params);
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(&params).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(&params);
}
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)
}
+147 -194
View File
@@ -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(&params)
.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(())
}
}
+11 -2
View File
@@ -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),
@@ -464,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}");
+6 -44
View File
@@ -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")
@@ -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
},
}
@@ -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, [])
})
@@ -1821,7 +1821,7 @@ async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is
for service in DshService::ALL.into_iter().filter(|s| {
!matches!(
s,
DshService::Network | DshService::Remote | DshService::Terminal
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
)
}) {
let ticket = &tickets_a[service.name()];
@@ -2100,7 +2100,7 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
}
if !matches!(
service,
DshService::Network | DshService::Remote | DshService::Terminal
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
) {
let result = f
.broker(
@@ -3040,3 +3040,261 @@ async fn dsh_terminal_arm_runs_through_the_gate_in_the_plugins_own_thread_owned_
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);
}
@@ -101,6 +101,7 @@ impl Plugins {
}
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,
@@ -371,6 +372,30 @@ fn an_agent_approved_owner_never_loads_into_the_reviewed_host() {
);
}
#[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(
&registry,
codewhale_plugins::activation::PluginActivationCapability::Native,
);
assert_eq!(sources.len(), 1, "{errors:?}");
plugins.registry().disable("alpha").unwrap();
let (desired, errors) = super::desired_owners_from(&registry, 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);
@@ -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);
}
}
+38 -11
View File
@@ -3219,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();
@@ -3263,14 +3271,34 @@ fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap<String, DesiredOwner>,
continue;
}
if !desired.contains_key(&plugin_id) {
let record =
codewhale_plugins::registry::reviewed_record(&source.authority).ok();
if record.as_ref().is_some_and(|record| {
record.approved_by.as_ref().is_some_and(|approver| {
approver.cohort()
!= codewhale_plugins::host_services::PluginCohort::Reviewed
})
}) {
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",
@@ -3286,8 +3314,7 @@ fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap<String, DesiredOwner>,
authority: source.authority.clone(),
entries: Vec::new(),
host_services: record
.map(|record| record.host_services)
.unwrap_or_default()
.host_services
.into_iter()
.filter_map(|service| protocol::DshService::parse(service.name()))
.collect(),
+77 -5
View File
@@ -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;
+350 -3
View File
@@ -23,7 +23,11 @@ fn config(root: &Path) -> DiscoveryConfig {
}
fn write_plugin(config: &DiscoveryConfig, name: &str, asks: &[&str], body: &str) {
let plugin = config.user_plugins_dir.join(name);
write_plugin_in(&config.user_plugins_dir, name, asks, body);
}
fn write_plugin_in(dir: &Path, name: &str, asks: &[&str], body: &str) {
let plugin = dir.join(name);
fs::create_dir_all(plugin.join("native")).unwrap();
fs::write(plugin.join("native/index.mjs"), body).unwrap();
let asked = if asks.is_empty() {
@@ -63,6 +67,7 @@ fn agent(config: &DiscoveryConfig, posture: AgentPosture) -> PluginApprover {
fn approve(registry: &mut PluginRegistry, name: &str, approved_by: PluginApprover) {
let shown = registry
.host_service_review_for(name, &approved_by)
.filter(|_| approved_by != PluginApprover::CommandLine)
.map(|review| review.token);
registry
.trust_reviewed(
@@ -641,8 +646,15 @@ fn a_command_line_trust_is_recorded_as_such_and_the_agents_mark_makes_it_the_age
);
let mut registry = discover_with_config(&config);
registry.enable_by("alpha", marked.clone()).unwrap();
assert_eq!(registry.get("alpha").unwrap().enabled_by, Some(marked));
assert!(
registry
.enable_by("alpha", marked.clone())
.unwrap_err()
.contains("approved for every project")
);
registry.disable("beta").unwrap();
registry.enable_by("beta", marked.clone()).unwrap();
assert_eq!(registry.get("beta").unwrap().enabled_by, Some(marked));
registry.disable("alpha").unwrap();
assert_eq!(registry.get("alpha").unwrap().enabled_by, None);
registry.enable("alpha").unwrap();
@@ -662,6 +674,341 @@ fn a_command_line_trust_is_recorded_as_such_and_the_agents_mark_makes_it_the_age
);
}
#[test]
fn the_agent_turns_on_only_a_plugin_it_approved_in_its_own_project() {
let _policy = TestPolicyGuard::extension_host(true);
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_plugin(&config, "alpha", &[], "export function apply() {}\n");
write_plugin(&config, "beta", &[], "export function apply() {}\n");
let mut registry = discover_with_config(&config);
approve(&mut registry, "alpha", person());
approve(
&mut registry,
"beta",
agent(&config, AgentPosture::FullAccess),
);
registry.disable("alpha").unwrap();
registry.disable("beta").unwrap();
let by_agent = agent(&config, AgentPosture::FullAccess);
let PluginApprover::Agent(fields) = by_agent.clone() else {
unreachable!()
};
let elsewhere = DiscoveryConfig {
workspace: tmp.path().join("other-project"),
..config.clone()
};
let mut other = discover_with_config(&elsewhere);
let before = fs::read(&config.state_path).unwrap();
let refusals = [
(
&mut registry,
"alpha",
by_agent.clone(),
"approved for every project",
),
(
&mut discover_with_config(&config),
"beta",
PluginApprover::Agent(AgentApproval {
workspace: tmp.path().join("elsewhere").to_string_lossy().into_owned(),
..fields.clone()
}),
"limited to its own project",
),
(
&mut discover_with_config(&config),
"beta",
PluginApprover::Agent(AgentApproval {
tool_call_id: None,
..fields.clone()
}),
"must name the thread, turn and tool call",
),
(
&mut other,
"beta",
agent(&elsewhere, AgentPosture::FullAccess),
"agent of another project",
),
];
for (registry, name, enabled_by, says) in refusals {
let refused = registry.enable_by(name, enabled_by).unwrap_err();
assert!(refused.contains(says), "{name}: {refused}");
assert_eq!(fs::read(&config.state_path).unwrap(), before);
}
registry.enable_by("beta", by_agent.clone()).unwrap();
let beta = registry.get("beta").unwrap();
assert!(beta.active());
assert_eq!(beta.enabled_by, Some(by_agent));
assert!(
!discover_with_config(&elsewhere)
.get("beta")
.unwrap()
.active()
);
}
#[test]
fn the_agent_never_replaces_a_review_held_for_every_project() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network]);
let tmp = tempfile::tempdir().unwrap();
let config = DiscoveryConfig {
builtin_plugin_dirs: vec![tmp.path().join("builtin")],
..config(tmp.path())
};
for name in ["alpha", "beta", "gamma"] {
write_plugin(&config, name, &["network"], "export function apply() {}\n");
}
write_plugin_in(
&config.builtin_plugin_dirs[0],
"delta",
&[],
"export function apply() {}\n",
);
let mut registry = discover_with_config(&config);
approve(&mut registry, "alpha", person());
approve(&mut registry, "beta", PluginApprover::CommandLine);
approve(&mut registry, "gamma", person());
edit_receipt(&config, "gamma", |receipt| {
receipt.as_object_mut().unwrap().remove("version");
receipt.as_object_mut().unwrap().remove("approved_by");
});
let mut registry = discover_with_config(&config);
assert_eq!(registry.get("gamma").unwrap().approved_by, None);
assert!(registry.get("gamma").unwrap().trusted());
let by_agent = agent(&config, AgentPosture::FullAccess);
let before = fs::read(&config.state_path).unwrap();
for (name, says) in [
("alpha", "holds a review for every project"),
("beta", "holds a review for every project"),
("gamma", "holds a review for every project"),
("delta", "built-in plugin"),
] {
let shown = registry
.host_service_review_for(name, &by_agent)
.map(|review| review.token);
let refused = registry
.trust_reviewed(
name,
ReviewApproval {
approved_by: by_agent.clone(),
host_services: shown,
},
)
.unwrap_err();
assert!(refused.contains(says), "{name}: {refused}");
assert_eq!(fs::read(&config.state_path).unwrap(), before, "{name}");
}
let refused = registry.enable_by("alpha", by_agent.clone()).unwrap_err();
assert!(refused.contains("approved for every project"), "{refused}");
let alpha = discover_with_config(&config);
let alpha = alpha.get("alpha").unwrap();
assert_eq!(alpha.approved_by, Some(person()));
assert!(alpha.active());
assert_eq!(
granted(&config, "alpha").unwrap(),
vec![HostService::Network]
);
}
#[test]
fn the_agent_cannot_replace_a_review_made_after_its_registry_was_read() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network]);
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_plugin(
&config,
"alpha",
&["network"],
"export function apply() {}\n",
);
let mut stale = discover_with_config(&config);
let mut current = discover_with_config(&config);
approve(&mut current, "alpha", person());
let before = fs::read(&config.state_path).unwrap();
let by_agent = agent(&config, AgentPosture::FullAccess);
let shown = stale
.host_service_review_for("alpha", &by_agent)
.map(|review| review.token);
let refused = stale
.trust_reviewed(
"alpha",
ReviewApproval {
approved_by: by_agent,
host_services: shown,
},
)
.unwrap_err();
assert!(refused.contains("state or review changed"), "{refused}");
assert_eq!(fs::read(&config.state_path).unwrap(), before);
let now = discover_with_config(&config);
let alpha = now.get("alpha").unwrap();
assert_eq!(alpha.approved_by, Some(person()));
assert!(alpha.active());
assert_eq!(
granted(&config, "alpha").unwrap(),
vec![HostService::Network]
);
}
#[test]
fn the_agent_of_another_project_cannot_take_over_an_agents_record() {
let _policy = TestPolicyGuard::extension_host(true);
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_plugin(&config, "alpha", &[], "export function apply() {}\n");
let mut registry = discover_with_config(&config);
let home_agent = agent(&config, AgentPosture::FullAccess);
approve(&mut registry, "alpha", home_agent.clone());
let before = fs::read(&config.state_path).unwrap();
let elsewhere = DiscoveryConfig {
workspace: tmp.path().join("other-project"),
..config.clone()
};
let mut other = discover_with_config(&elsewhere);
assert!(other.get("alpha").unwrap().outside_reach);
let other_agent = agent(&elsewhere, AgentPosture::FullAccess);
let shown = other
.host_service_review_for("alpha", &other_agent)
.map(|review| review.token);
let refused = other
.trust_reviewed(
"alpha",
ReviewApproval {
approved_by: other_agent,
host_services: shown,
},
)
.unwrap_err();
assert!(refused.contains("agent of another project"), "{refused}");
assert_eq!(fs::read(&config.state_path).unwrap(), before);
let home = discover_with_config(&config);
let alpha = home.get("alpha").unwrap();
assert_eq!(alpha.approved_by, Some(home_agent));
assert!(alpha.active());
}
#[cfg(unix)]
#[test]
fn a_project_plugin_folder_that_resolves_outside_the_project_is_not_the_agents() {
let _policy = TestPolicyGuard::extension_host(true);
let tmp = tempfile::tempdir().unwrap();
let project = |name: &str| {
let workspace = tmp.path().join(name);
fs::create_dir_all(&workspace).unwrap();
DiscoveryConfig {
workspace_plugins_dir: workspace.join(".codewhale/plugins"),
workspace,
..config(tmp.path())
}
};
let linked = project("linked");
let outside = tmp.path().join("outside");
fs::create_dir_all(outside.join("plugins")).unwrap();
std::os::unix::fs::symlink(&outside, linked.workspace.join(".codewhale")).unwrap();
write_plugin_in(
&linked.workspace_plugins_dir,
"alpha",
&[],
"export function apply() {}\n",
);
let own = project("own");
write_plugin_in(
&own.workspace_plugins_dir,
"beta",
&[],
"export function apply() {}\n",
);
let mut registry = discover_with_config(&linked);
let alpha = registry.get("alpha").unwrap();
assert_eq!(alpha.scope, super::types::PluginScope::Workspace);
assert!(
!alpha
.canonical_root
.starts_with(fs::canonicalize(&linked.workspace).unwrap())
);
let by_agent = agent(&linked, AgentPosture::FullAccess);
let shown = registry
.host_service_review_for("alpha", &by_agent)
.map(|review| review.token);
let refused = registry
.trust_reviewed(
"alpha",
ReviewApproval {
approved_by: by_agent,
host_services: shown,
},
)
.unwrap_err();
assert!(
refused.contains("resolves outside this project"),
"{refused}"
);
assert!(registry.get("alpha").unwrap().approved_by.is_none());
let mut registry = discover_with_config(&own);
let by_agent = agent(&own, AgentPosture::FullAccess);
let shown = registry
.host_service_review_for("beta", &by_agent)
.map(|review| review.token);
registry
.trust_reviewed(
"beta",
ReviewApproval {
approved_by: by_agent.clone(),
host_services: shown,
},
)
.unwrap();
registry.enable_by("beta", by_agent).unwrap();
assert!(registry.get("beta").unwrap().active());
}
#[test]
fn a_command_line_approval_carries_no_host_service_grant() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network]);
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_plugin(
&config,
"alpha",
&["network"],
"export function apply() {}\n",
);
let mut registry = discover_with_config(&config);
let shown = registry
.host_service_review_for("alpha", &PluginApprover::CommandLine)
.map(|review| review.token);
assert!(shown.is_some());
let refused = registry
.trust_reviewed(
"alpha",
ReviewApproval {
approved_by: PluginApprover::CommandLine,
host_services: shown,
},
)
.unwrap_err();
assert!(refused.contains("cannot grant host services"), "{refused}");
assert!(registry.get("alpha").unwrap().approved_by.is_none());
approve(&mut registry, "alpha", PluginApprover::CommandLine);
assert_eq!(
registry.get("alpha").unwrap().approved_by,
Some(PluginApprover::CommandLine)
);
assert_eq!(granted(&config, "alpha").unwrap(), Vec::new());
}
mod v0_10_2 {
use std::collections::BTreeMap;
+83 -4
View File
@@ -15,7 +15,9 @@ 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)]
@@ -158,9 +160,21 @@ fn host_service_review_in(
)
}
fn check_approver(approver: &PluginApprover, workspace: &Path, shown: bool) -> Result<(), String> {
let PluginApprover::Agent(agent) = approver else {
return Ok(());
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(
@@ -187,6 +201,40 @@ fn check_approver(approver: &PluginApprover, workspace: &Path, shown: bool) -> R
.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(())
}
@@ -925,6 +973,7 @@ impl PluginRegistry {
let id = plugin.id.clone();
check_approver(
&approval.approved_by,
&plugin,
&self.workspace,
approval.host_services.is_some(),
)?;
@@ -935,6 +984,7 @@ impl PluginRegistry {
stage_bundle(state_path, &plugin, StageIntent::Review)?;
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(),
@@ -945,6 +995,15 @@ impl PluginRegistry {
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 {
@@ -1031,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
+2
View File
@@ -7,8 +7,10 @@ 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.
+7 -1
View File
@@ -649,7 +649,7 @@ a plugin's trust shows it:
| You | the Plugins page, `/plugin trust`, or an approval card | every project |
| The agent, in Full Access or Auto-Review | the agent's plugin tool, bound to the token of the review composed for that call and to the thread, turn and tool call that made it | only the project named in the record |
| The agent, from a shell command | `codewhale plugins trust … --yes` run by a process that carries the agent's mark | only the project named in the record |
| The command line | `codewhale plugins trust … --yes` without the mark: no review was shown there | every project |
| The command line | `codewhale plugins trust … --yes` without the mark: no review was shown there, so it grants no service | every project |
The agent's mark is a label on the agent's shell commands, not a lock: in Full
Access the agent can remove it, and the record then says "the command line".
@@ -658,6 +658,12 @@ and its code does not run in the host process of the plugins you reviewed:
until it has a host of its own, its native code is refused with a diagnostic
that asks you to review it. Your own review replaces the agent's record. Who
turned a plugin on is recorded the same way, separately from who approved it.
The agent never approves a built-in plugin, never replaces a review that holds
for every project (yours, the command line's, or one from an earlier version)
or one the agent of another project made, and never approves a project plugin
whose folder resolves outside the project.
It turns on only a plugin whose record it made for this project. Everything
else is left to you.
**One process, one rule.** Extensions share one host process and cannot be
isolated from each other inside it. A service is therefore given to a tool call
+3
View File
@@ -121,6 +121,9 @@
"crates/engine/src/core/engine/tool_execution.rs": {
"std_fs": 1
},
"crates/engine/src/extension_host/dsh_workspace.rs": {
"path_canonicalize": 2
},
"crates/engine/src/extension_host/mod.rs": {
"std_fs": 7
},
+2 -2
View File
@@ -323,8 +323,8 @@
},
{
"path": "docs/EXTENSIONS.md",
"sha256": "43df456f3dc8dd67b905a719a3d5b9d9f18620c0031bbbaa99f4456cc33276f8",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person. Do not trust or enable a bundle automatically.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and session evidence. The Host default\nrequires those fixtures and the actual broker acceptance checks, plus the platform\nisolation and measured gates in Ops CURRENT_DECISIONS §26. An explicit\n`mcp_backend = \"rust\"` remains available for one release after the qualified default\nflip, before the native protocoLine truncated
"sha256": "272fafeeea6ab4fe6b27d370bc03079f6c87e81e21b960e224d926185850c784",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person. Do not trust or enable a bundle automatically.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and session evidence. The Host default\nrequires those fixtures and the actual broker acceptance checks, plus the platform\nisolation and measured gates in Ops CURRENT_DECISIONS §26. An explicit\n`mcp_backend = \"rust\"` remains available for one release after the qualified default\nflip, before the native protocoLine truncated
}
]
},