mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8a300097 | ||
|
|
99eb5c7769 | ||
|
|
0e425e0f8e | ||
|
|
cf6b8dc2af | ||
|
|
d22a678c31 | ||
|
|
d69d83ad27 | ||
|
|
52521af86a | ||
|
|
6118b9c097 | ||
|
|
d48d4b0883 | ||
|
|
00e3327004 |
No files matched your search
@@ -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.
|
||||
|
||||
+848
-684
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,441 @@
|
||||
use super::*;
|
||||
use agent_client_protocol::v1::{ConnectionTo, Responder};
|
||||
use agent_client_protocol::{Agent, Client, Lines, UntypedMessage};
|
||||
use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, MAX_FRAME_BYTES};
|
||||
use futures_util::{Sink, Stream};
|
||||
use std::io;
|
||||
use std::sync::{MutexGuard, PoisonError};
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
|
||||
const PROMPT_BUSY: &str = "a session/prompt turn is already in progress";
|
||||
const OUTGOING_QUEUE_FRAMES: usize = 256;
|
||||
const OVERSIZE_RESPONSE: &str =
|
||||
"ACP response exceeds the 8 MiB transport limit; inspect canonical state without replay";
|
||||
type Cx = ConnectionTo<Client>;
|
||||
|
||||
pub(super) struct Shared {
|
||||
server: tokio::sync::Mutex<AcpServer>,
|
||||
prompt: std::sync::Mutex<Option<mpsc::UnboundedSender<Inbound>>>,
|
||||
idle: Notify,
|
||||
ending: Notify,
|
||||
failure: std::sync::Mutex<Option<anyhow::Error>>,
|
||||
}
|
||||
|
||||
struct PromptGuard(Arc<Shared>);
|
||||
|
||||
impl Drop for PromptGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.release();
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptGuard {
|
||||
fn finish(self, responder: Responder<Value>, result: std::result::Result<Value, SdkError>) {
|
||||
let mut slot = self.0.slot();
|
||||
let _ = responder.respond_with_result(result);
|
||||
*slot = None;
|
||||
drop(slot);
|
||||
self.0.idle.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub(super) fn new(server: AcpServer) -> Self {
|
||||
Self {
|
||||
server: tokio::sync::Mutex::new(server),
|
||||
prompt: std::sync::Mutex::new(None),
|
||||
idle: Notify::new(),
|
||||
ending: Notify::new(),
|
||||
failure: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn fail(&self, error: anyhow::Error) {
|
||||
self.failure
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get_or_insert(error);
|
||||
self.tell_prompt(Inbound::Eof);
|
||||
self.ending.notify_one();
|
||||
}
|
||||
|
||||
pub(super) fn take_failure(&self) -> Option<anyhow::Error> {
|
||||
self.failure
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn into_server(self) -> AcpServer {
|
||||
self.server.into_inner()
|
||||
}
|
||||
|
||||
fn slot(&self) -> MutexGuard<'_, Option<mpsc::UnboundedSender<Inbound>>> {
|
||||
self.prompt.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn prompt_active(&self) -> bool {
|
||||
self.slot().is_some()
|
||||
}
|
||||
|
||||
fn tell_prompt(&self, message: Inbound) {
|
||||
if let Some(inbox) = self.slot().as_ref() {
|
||||
let _ = inbox.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn release(&self) {
|
||||
*self.slot() = None;
|
||||
self.idle.notify_waiters();
|
||||
}
|
||||
|
||||
async fn wait_idle(&self) {
|
||||
loop {
|
||||
let notified = self.idle.notified();
|
||||
if !self.prompt_active() {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&self, params: &Value) {
|
||||
if let Some(session) = params.get("sessionId").and_then(Value::as_str) {
|
||||
self.tell_prompt(Inbound::Cancel {
|
||||
session: session.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn request(
|
||||
self: &Arc<Self>,
|
||||
method: String,
|
||||
params: Value,
|
||||
responder: Responder<Value>,
|
||||
cx: Cx,
|
||||
) {
|
||||
match method.as_str() {
|
||||
"session/cancel" => {
|
||||
self.cancel(¶ms);
|
||||
let _ = responder.respond(Value::Null);
|
||||
}
|
||||
"session/prompt" => self.start_prompt(params, responder, cx).await,
|
||||
_ if self.prompt_active() => self.during_prompt(&cx, method, params, responder),
|
||||
"shutdown" => {
|
||||
let _ = responder.respond(Value::Null);
|
||||
self.ending.notify_one();
|
||||
}
|
||||
"session/load" => self.load(params, responder, cx).await,
|
||||
_ => {
|
||||
let result = self
|
||||
.server
|
||||
.lock()
|
||||
.await
|
||||
.handle_request(&method, params)
|
||||
.await;
|
||||
let _ = responder.respond_with_result(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn during_prompt(
|
||||
self: &Arc<Self>,
|
||||
cx: &Cx,
|
||||
method: String,
|
||||
params: Value,
|
||||
responder: Responder<Value>,
|
||||
) {
|
||||
let served = matches!(
|
||||
method.as_str(),
|
||||
"session/list"
|
||||
| "session/set_config_option"
|
||||
| "session/set_mode"
|
||||
| "session/set_model"
|
||||
| "session/listProviders"
|
||||
| "session/currentModel"
|
||||
| "session/selectModel"
|
||||
);
|
||||
if !served {
|
||||
let error = if matches!(
|
||||
method.as_str(),
|
||||
"initialize" | "session/new" | "session/load" | "shutdown"
|
||||
) {
|
||||
SdkError::new(-32603, PROMPT_BUSY)
|
||||
} else {
|
||||
SdkError::method_not_found().data(Value::String(method))
|
||||
};
|
||||
let _ = responder.respond_with_error(error);
|
||||
return;
|
||||
}
|
||||
let shared = Arc::clone(self);
|
||||
let _ = cx.spawn(async move {
|
||||
let result = shared
|
||||
.server
|
||||
.lock()
|
||||
.await
|
||||
.handle_request(&method, params)
|
||||
.await;
|
||||
let _ = responder.respond_with_result(result);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
async fn load(&self, params: Value, responder: Responder<Value>, cx: Cx) {
|
||||
let mut server = self.server.lock().await;
|
||||
let result = async {
|
||||
let loaded = server.load_session(params).await.map_err(SdkError::from)?;
|
||||
if let Some(session) = loaded.get("sessionId").and_then(Value::as_str) {
|
||||
let updates = server
|
||||
.history_updates(session)
|
||||
.await
|
||||
.map_err(|error| SdkError::new(-32603, error.to_string()))?;
|
||||
for update in updates {
|
||||
cx.send_notification(acp::SessionNotification::new(
|
||||
session.to_string(),
|
||||
update,
|
||||
))?;
|
||||
}
|
||||
}
|
||||
Ok::<_, SdkError>(loaded)
|
||||
}
|
||||
.await;
|
||||
drop(server);
|
||||
let _ = responder.respond_with_result(result);
|
||||
}
|
||||
|
||||
async fn start_prompt(self: &Arc<Self>, params: Value, responder: Responder<Value>, cx: Cx) {
|
||||
if self.prompt_active() {
|
||||
let _ = responder.respond_with_error(SdkError::new(-32603, PROMPT_BUSY));
|
||||
return;
|
||||
}
|
||||
let admitted = {
|
||||
let server = self.server.lock().await;
|
||||
server.validate_prompt(¶ms).map(|(session, prompt)| {
|
||||
let thread = server.sessions[&session].thread_id.clone();
|
||||
(session, thread, prompt, server.prompt_run())
|
||||
})
|
||||
};
|
||||
let (session, thread, prompt, run) = match admitted {
|
||||
Ok(admitted) => admitted,
|
||||
Err(error) => {
|
||||
let _ = responder.respond_with_error(error.into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (answers, mut inbox) = mpsc::unbounded_channel();
|
||||
*self.slot() = Some(answers.clone());
|
||||
let guard = PromptGuard(Arc::clone(self));
|
||||
let task_cx = cx.clone();
|
||||
let _ = cx.spawn(async move {
|
||||
let result = {
|
||||
let mut emit = |outgoing: Outgoing| -> Result<()> {
|
||||
match outgoing {
|
||||
Outgoing::Update(notification) => task_cx
|
||||
.send_notification(notification)
|
||||
.map_err(|error| anyhow!("ACP update was not sent: {}", error.message)),
|
||||
Outgoing::Permission { key, request } => {
|
||||
let answers = answers.clone();
|
||||
task_cx
|
||||
.send_request(request)
|
||||
.on_receiving_result(async move |reply| {
|
||||
let gone = matches!(
|
||||
&reply,
|
||||
Err(error)
|
||||
if agent_client_protocol::is_incoming_transport_closed(error)
|
||||
|| agent_client_protocol::is_connection_terminated(error)
|
||||
);
|
||||
if !gone {
|
||||
let _ = answers.send(Inbound::Permission {
|
||||
key,
|
||||
allow: permission_allowed(&reply),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|error| {
|
||||
anyhow!("ACP permission request was not sent: {}", error.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
run.drive_prompt(&session, &thread, prompt, &mut emit, &mut inbox)
|
||||
.await
|
||||
};
|
||||
let result = match result {
|
||||
Ok(stop) => to_wire(&acp::PromptResponse::new(stop)).map_err(SdkError::from),
|
||||
Err(error) => Err(SdkError::new(
|
||||
-32603,
|
||||
codewhale_engine::client::redact_model_bound_text(&error.to_string(), &[]),
|
||||
)),
|
||||
};
|
||||
guard.finish(responder, result);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_allowed(
|
||||
reply: &std::result::Result<acp::RequestPermissionResponse, SdkError>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
reply,
|
||||
Ok(response) if matches!(
|
||||
&response.outcome,
|
||||
acp::RequestPermissionOutcome::Selected(selected)
|
||||
if &*selected.option_id.0 == "allow-once"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn closes_input(line: &str) -> bool {
|
||||
line.len() <= 256
|
||||
&& line.contains("daemon/input_closed")
|
||||
&& serde_json::from_str::<Value>(line)
|
||||
.is_ok_and(|message| crate::is_control_input_closed(&message))
|
||||
}
|
||||
|
||||
fn incoming_lines(
|
||||
input: Box<dyn AsyncBufRead + Send + Unpin>,
|
||||
shared: Arc<Shared>,
|
||||
) -> impl Stream<Item = io::Result<String>> + Send + 'static {
|
||||
futures_util::stream::unfold(
|
||||
(BoundedLines::new(input), shared),
|
||||
|(mut lines, shared)| async move {
|
||||
loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) if line.trim().is_empty() => continue,
|
||||
Ok(Some(line)) if closes_input(&line) => return None,
|
||||
Ok(Some(line)) => return Some((Ok(line), (lines, shared))),
|
||||
Ok(None) => return None,
|
||||
Err(error) if crate::stdio::is_frame_refusal(&error) => {
|
||||
return Some((Ok(error.to_string()), (lines, shared)));
|
||||
}
|
||||
Err(error) => {
|
||||
shared.fail(anyhow!("ACP input failed: {error}"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn bound_outgoing(line: String) -> io::Result<String> {
|
||||
if line.len() <= MAX_FRAME_BYTES {
|
||||
return Ok(line);
|
||||
}
|
||||
let refused = || io::Error::new(io::ErrorKind::InvalidData, FrameRefusal::TooLarge);
|
||||
let Ok(Value::Object(frame)) = serde_json::from_str::<Value>(&line) else {
|
||||
return Err(refused());
|
||||
};
|
||||
if frame.contains_key("method") || !frame.contains_key("id") {
|
||||
return Err(refused());
|
||||
}
|
||||
let failure = |id: Value| {
|
||||
json!({"jsonrpc":"2.0","id":id,"error":{"code":-32603,"message":OVERSIZE_RESPONSE}})
|
||||
.to_string()
|
||||
};
|
||||
let replaced = failure(frame["id"].clone());
|
||||
Ok(if replaced.len() <= MAX_FRAME_BYTES {
|
||||
replaced
|
||||
} else {
|
||||
failure(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn outgoing_lines(
|
||||
frames: mpsc::Sender<String>,
|
||||
shared: Arc<Shared>,
|
||||
) -> impl Sink<String, Error = io::Error> + Send + 'static {
|
||||
futures_util::sink::unfold(
|
||||
(frames, shared),
|
||||
|(frames, shared), line: String| async move {
|
||||
let line = match bound_outgoing(line) {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
shared.fail(anyhow!("ACP output refused: {error}"));
|
||||
return Ok((frames, shared));
|
||||
}
|
||||
};
|
||||
frames
|
||||
.send(line)
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ACP output closed"))?;
|
||||
Ok::<_, io::Error>((frames, shared))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn serve_connection<'a>(
|
||||
server: AcpServer,
|
||||
input: Box<dyn AsyncBufRead + Send + Unpin>,
|
||||
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
|
||||
) -> Result<Arc<Shared>> {
|
||||
let shared = Arc::new(Shared::new(server));
|
||||
let (frames, mut queued) = mpsc::channel::<String>(OUTGOING_QUEUE_FRAMES);
|
||||
let transport = Lines::new(
|
||||
outgoing_lines(frames, Arc::clone(&shared)),
|
||||
incoming_lines(input, Arc::clone(&shared)),
|
||||
);
|
||||
let requests = Arc::clone(&shared);
|
||||
let notifications = Arc::clone(&shared);
|
||||
let finishing = Arc::clone(&shared);
|
||||
let connection = Agent
|
||||
.v1()
|
||||
.on_receive_request(
|
||||
async move |request: UntypedMessage, responder, cx| {
|
||||
let (method, params) = request.into_parts();
|
||||
requests.request(method, params, responder, cx).await;
|
||||
Ok(())
|
||||
},
|
||||
agent_client_protocol::on_receive_request!(),
|
||||
)
|
||||
.on_receive_notification(
|
||||
async move |notification: UntypedMessage, _cx| {
|
||||
let (method, params) = notification.into_parts();
|
||||
if method == "session/cancel" {
|
||||
notifications.cancel(¶ms);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
agent_client_protocol::on_receive_notification!(),
|
||||
)
|
||||
.connect_with(transport, async move |cx| {
|
||||
tokio::select! {
|
||||
() = cx.incoming_closed() => {}
|
||||
() = finishing.ending.notified() => {}
|
||||
}
|
||||
finishing.tell_prompt(Inbound::Eof);
|
||||
finishing.wait_idle().await;
|
||||
Ok(())
|
||||
});
|
||||
let connection = async {
|
||||
match connection.await {
|
||||
Err(error) if agent_client_protocol::is_incoming_transport_closed(&error) => Ok(()),
|
||||
other => other.map_err(|error| anyhow!("ACP connection failed: {}", error.message)),
|
||||
}
|
||||
};
|
||||
let failing = Arc::clone(&shared);
|
||||
let pump = async {
|
||||
let mut broken = false;
|
||||
while let Some(frame) = queued.recv().await {
|
||||
if broken {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) =
|
||||
crate::stdio::write_control_line(&mut output, frame.as_bytes()).await
|
||||
{
|
||||
broken = true;
|
||||
failing.fail(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ended, ()) = tokio::join!(connection, pump);
|
||||
drop(failing);
|
||||
if let Some(error) = shared.take_failure() {
|
||||
return Err(error);
|
||||
}
|
||||
ended?;
|
||||
Ok(shared)
|
||||
}
|
||||
@@ -5,12 +5,17 @@ use codewhale_engine::runtime_threads::{
|
||||
manager::ExternalApprovalDecision, manager::StartTurnRequest,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
struct Permission {
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
approval_id: String,
|
||||
execution_id: String,
|
||||
}
|
||||
pub(super) struct PromptRun {
|
||||
pub(super) runtime: Arc<RuntimeThreadManager>,
|
||||
pub(super) sessions_dir: PathBuf,
|
||||
}
|
||||
struct Projection<'a> {
|
||||
session: &'a str,
|
||||
thread: &'a str,
|
||||
@@ -42,21 +47,20 @@ impl Drop for TurnClaim {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl AcpServer {
|
||||
pub(super) async fn drive_prompt<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
&mut self,
|
||||
params: Value,
|
||||
reader: &mut crate::stdio::BoundedLines<R>,
|
||||
writer: &mut W,
|
||||
) -> Result<&'static str> {
|
||||
let (session_id, prompt) = self
|
||||
.validate_prompt(¶ms)
|
||||
.map_err(|error| anyhow!(error.message))?;
|
||||
let binding = self
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.ok_or_else(|| anyhow!("unknown sessionId"))?;
|
||||
let thread_id = binding.thread_id.clone();
|
||||
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
|
||||
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
|
||||
}
|
||||
impl PromptRun {
|
||||
pub(super) async fn drive_prompt(
|
||||
&self,
|
||||
session_id: &str,
|
||||
thread_id: &str,
|
||||
prompt: String,
|
||||
emit: Emit<'_>,
|
||||
inbox: &mut UnboundedReceiver<Inbound>,
|
||||
) -> Result<acp::StopReason> {
|
||||
let session_id = session_id.to_string();
|
||||
let thread_id = thread_id.to_string();
|
||||
// Subscribe before snapshot/admission; replay deduplicates by seq.
|
||||
let mut receiver = self.runtime.subscribe_events();
|
||||
let cursor = self.runtime.get_thread_detail(&thread_id).await?.latest_seq;
|
||||
@@ -86,7 +90,7 @@ impl AcpServer {
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let terminal = match self
|
||||
.project_turn(&mut projection, &mut receiver, reader, writer)
|
||||
.project_turn(&mut projection, &mut receiver, inbox, emit)
|
||||
.await
|
||||
{
|
||||
Ok(terminal) => terminal,
|
||||
@@ -144,9 +148,6 @@ impl AcpServer {
|
||||
}
|
||||
};
|
||||
claim.armed = false;
|
||||
if let Some(binding) = self.sessions.get_mut(&session_id) {
|
||||
binding.cursor = projection.cursor;
|
||||
}
|
||||
self.save_checkpoint(&thread_id, &session_id).await?;
|
||||
if terminal
|
||||
.model_request_diagnostics
|
||||
@@ -154,11 +155,13 @@ impl AcpServer {
|
||||
.and_then(|facts| facts.stop_reason)
|
||||
== Some(RuntimeTurnStopReason::StepBudgetExhausted)
|
||||
{
|
||||
return Ok("max_turn_requests");
|
||||
return Ok(acp::StopReason::MaxTurnRequests);
|
||||
}
|
||||
match terminal.status {
|
||||
RuntimeTurnStatus::Completed => Ok("end_turn"),
|
||||
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => Ok("cancelled"),
|
||||
RuntimeTurnStatus::Completed => Ok(acp::StopReason::EndTurn),
|
||||
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => {
|
||||
Ok(acp::StopReason::Cancelled)
|
||||
}
|
||||
_ => Err(anyhow!(
|
||||
"{}",
|
||||
terminal.error.unwrap_or_else(
|
||||
@@ -209,12 +212,12 @@ impl AcpServer {
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
async fn project_turn<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
async fn project_turn(
|
||||
&self,
|
||||
projection: &mut Projection<'_>,
|
||||
receiver: &mut tokio::sync::broadcast::Receiver<RuntimeEventRecord>,
|
||||
reader: &mut crate::stdio::BoundedLines<R>,
|
||||
writer: &mut W,
|
||||
inbox: &mut UnboundedReceiver<Inbound>,
|
||||
emit: Emit<'_>,
|
||||
) -> Result<TurnRecord> {
|
||||
let session = projection.session;
|
||||
let thread = projection.thread;
|
||||
@@ -232,45 +235,26 @@ impl AcpServer {
|
||||
}
|
||||
tokio::select! {
|
||||
() = async { match cancel_deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, None => std::future::pending().await } } => return Err(anyhow!("Core cancellation remains nonterminal; ACP cannot report a fabricated cancelled receipt")),
|
||||
line = reader.next_line(), if !eof => {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) if crate::stdio::is_frame_refusal(&error) => {
|
||||
write_jsonrpc_error(writer, None, -32700, error.to_string()).await?;
|
||||
continue;
|
||||
message = inbox.recv(), if !eof => {
|
||||
match message {
|
||||
Some(Inbound::Cancel { session: cancelled_session }) => {
|
||||
if cancelled_session == session {
|
||||
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
|
||||
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let Some(line) = line else {
|
||||
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
|
||||
cancel_deadline = Some(tokio::time::Instant::now() + Duration::from_secs(30)); continue;
|
||||
};
|
||||
if line.trim().is_empty() { continue; }
|
||||
let message: Value = match serde_json::from_str(&line) {
|
||||
Ok(message) => message,
|
||||
Err(error) => { write_jsonrpc_error(writer, None, -32700, format!("invalid json: {error}")).await?; continue; }
|
||||
};
|
||||
if crate::is_control_input_closed(&message) {
|
||||
eof=true; cancelled=true; projection.permissions.clear();self.interrupt_claimed_turn(thread,turn).await?;
|
||||
cancel_deadline.get_or_insert(tokio::time::Instant::now()+Duration::from_secs(30));continue;
|
||||
}
|
||||
let response_id = message.get("id").cloned().map(|id| self.response_id_policy.response_id(id));
|
||||
if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { write_jsonrpc_error(writer, response_id, -32600, "jsonrpc version must be 2.0").await?; continue; }
|
||||
if is_jsonrpc_response(&message) {
|
||||
if !cancelled && let Some(id) = message.get("id").and_then(Value::as_str) && let Some(permission) = projection.permissions.remove(id) { self.answer_permission(&permission, &message).await?; }
|
||||
continue;
|
||||
}
|
||||
if message.get("method").and_then(Value::as_str) == Some("session/cancel") {
|
||||
if message.pointer("/params/sessionId").and_then(Value::as_str) == Some(session) {
|
||||
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
|
||||
Some(Inbound::Permission { key, allow }) => {
|
||||
if !cancelled && let Some(permission) = projection.permissions.remove(&key) { self.answer_permission(&permission, allow).await?; }
|
||||
}
|
||||
Some(Inbound::Eof) | None => {
|
||||
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
|
||||
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
|
||||
}
|
||||
if let Some(id) = response_id { write_jsonrpc_result(writer, id, json!(null)).await?; }
|
||||
} else if let Some(id) = response_id { write_jsonrpc_error(writer, Some(id), -32603, "a session/prompt turn is already in progress").await?; }
|
||||
}
|
||||
}
|
||||
record = async { replay_batch.pop_front().expect("nonempty bounded replay batch") }, if !replay_batch.is_empty() => {
|
||||
projection.emit = !cancelled && !eof;
|
||||
if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); }
|
||||
if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); }
|
||||
}
|
||||
batch = async { replay.as_mut().expect("active replay").batches.recv().await }, if replay.is_some() && replay_batch.is_empty() => {
|
||||
match batch {
|
||||
@@ -280,7 +264,7 @@ impl AcpServer {
|
||||
}
|
||||
event = receiver.recv(), if replay.is_none() && replay_batch.is_empty() => {
|
||||
match event {
|
||||
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); } },
|
||||
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); } },
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
let captured = self.runtime.replay_events(thread, Some(projection.cursor), None).await?;
|
||||
if captured.base_seq > projection.cursor { return Err(anyhow!("ACP replay no longer covers the cursor; projection is incomplete")); }
|
||||
@@ -292,7 +276,7 @@ impl AcpServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn answer_permission(&self, permission: &Permission, response: &Value) -> Result<()> {
|
||||
async fn answer_permission(&self, permission: &Permission, allow: bool) -> Result<()> {
|
||||
let detail = self
|
||||
.runtime
|
||||
.get_thread_detail(&permission.thread_id)
|
||||
@@ -304,15 +288,6 @@ impl AcpServer {
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
let allow = response.get("error").is_none()
|
||||
&& response
|
||||
.pointer("/result/outcome/outcome")
|
||||
.and_then(Value::as_str)
|
||||
== Some("selected")
|
||||
&& response
|
||||
.pointer("/result/outcome/optionId")
|
||||
.and_then(Value::as_str)
|
||||
== Some("allow-once");
|
||||
self.runtime
|
||||
.deliver_approval_from(
|
||||
&permission.approval_id,
|
||||
@@ -327,11 +302,11 @@ impl AcpServer {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn project_event<W: AsyncWrite + Unpin>(
|
||||
async fn project_event(
|
||||
&self,
|
||||
projection: &mut Projection<'_>,
|
||||
record: &RuntimeEventRecord,
|
||||
writer: &mut W,
|
||||
out: Emit<'_>,
|
||||
) -> Result<Option<TurnRecord>> {
|
||||
let session = projection.session;
|
||||
let thread = projection.thread;
|
||||
@@ -353,7 +328,7 @@ impl AcpServer {
|
||||
if emit && p.get("kind").and_then(Value::as_str) == Some("agent_message") =>
|
||||
{
|
||||
if let Some(text) = p.get("delta").and_then(Value::as_str) {
|
||||
write_session_update(writer, session, text.to_string()).await?;
|
||||
out(update(session, agent_text(text.to_string())))?;
|
||||
}
|
||||
}
|
||||
"item.started" => {
|
||||
@@ -373,7 +348,10 @@ impl AcpServer {
|
||||
input: tool.get("input").cloned().unwrap_or(Value::Null),
|
||||
};
|
||||
if emit {
|
||||
write_tool_call_start(writer, session, &call).await?;
|
||||
out(update(
|
||||
session,
|
||||
acp::SessionUpdate::ToolCall(tool_call_start(&call)),
|
||||
))?;
|
||||
}
|
||||
calls.insert(id, call);
|
||||
}
|
||||
@@ -386,7 +364,15 @@ impl AcpServer {
|
||||
let call = calls
|
||||
.get(id)
|
||||
.ok_or_else(|| anyhow!("Dispatch projection lacks its admitted start"))?;
|
||||
write_tool_call_update(writer, session, call, "in_progress", None).await?;
|
||||
out(update(
|
||||
session,
|
||||
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
|
||||
call,
|
||||
acp::ToolCallStatus::InProgress,
|
||||
None,
|
||||
&[],
|
||||
)),
|
||||
))?;
|
||||
}
|
||||
"item.completed" | "item.failed" | "item.interrupted" if emit => {
|
||||
if let Some(item) = p.get("item")
|
||||
@@ -403,19 +389,19 @@ impl AcpServer {
|
||||
.unwrap_or_default();
|
||||
let status = if item.get("status").and_then(Value::as_str) == Some("completed")
|
||||
{
|
||||
"completed"
|
||||
acp::ToolCallStatus::Completed
|
||||
} else {
|
||||
"failed"
|
||||
acp::ToolCallStatus::Failed
|
||||
};
|
||||
write_tool_call_update_with_blocks(
|
||||
writer,
|
||||
out(update(
|
||||
session,
|
||||
&call,
|
||||
status,
|
||||
item.get("detail").and_then(Value::as_str),
|
||||
&blocks,
|
||||
)
|
||||
.await?;
|
||||
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
|
||||
&call,
|
||||
status,
|
||||
item.get("detail").and_then(Value::as_str),
|
||||
&blocks,
|
||||
)),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
"approval.required" if emit => {
|
||||
@@ -436,11 +422,41 @@ impl AcpServer {
|
||||
let call = calls
|
||||
.get(execution)
|
||||
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
|
||||
let id = format!(
|
||||
"codewhale-permission-{}",
|
||||
NEXT_ACP_PERMISSION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let title = tool_call_title(call);
|
||||
let text = p
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map_or_else(|| title.clone(), str::to_string);
|
||||
let request = acp::RequestPermissionRequest::new(
|
||||
session.to_string(),
|
||||
acp::ToolCallUpdate::new(
|
||||
execution.to_string(),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
.title(title)
|
||||
.kind(tool_call_kind(call))
|
||||
.status(acp::ToolCallStatus::Pending)
|
||||
.raw_input((!call.input.is_null()).then(|| call.input.clone()))
|
||||
.content(vec![acp::ToolCallContent::from(text)]),
|
||||
),
|
||||
vec![
|
||||
acp::PermissionOption::new(
|
||||
"allow-once",
|
||||
"Allow once",
|
||||
acp::PermissionOptionKind::AllowOnce,
|
||||
),
|
||||
acp::PermissionOption::new(
|
||||
"reject-once",
|
||||
"Reject",
|
||||
acp::PermissionOptionKind::RejectOnce,
|
||||
),
|
||||
],
|
||||
);
|
||||
write_json_line(writer, json!({"jsonrpc":"2.0","id":id,"method":"session/request_permission","params":{"sessionId":session,"toolCall":{"toolCallId":execution,"title":tool_call_title(call),"kind":tool_call_kind(call),"status":"pending","rawInput":call.input,"content":[{"type":"content","content":{"type":"text","text":p.get("description")}}]},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}})).await?;
|
||||
out(Outgoing::Permission {
|
||||
key: id.clone(),
|
||||
request,
|
||||
})?;
|
||||
permissions.insert(
|
||||
id,
|
||||
Permission {
|
||||
@@ -489,6 +505,22 @@ impl AcpServer {
|
||||
mod tests {
|
||||
use super::super::tests::{Rig, fixture_config};
|
||||
use super::*;
|
||||
fn frame(outgoing: Outgoing) -> Value {
|
||||
match outgoing {
|
||||
Outgoing::Update(notification) => {
|
||||
json!({"jsonrpc":"2.0","method":"session/update","params":serde_json::to_value(notification).unwrap()})
|
||||
}
|
||||
Outgoing::Permission { key, request } => {
|
||||
json!({"jsonrpc":"2.0","id":key,"method":"session/request_permission","params":serde_json::to_value(request).unwrap()})
|
||||
}
|
||||
}
|
||||
}
|
||||
fn recording(out: &mut Vec<Value>) -> impl FnMut(Outgoing) -> Result<()> + Send + '_ {
|
||||
move |outgoing| {
|
||||
out.push(frame(outgoing));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn event(seq: u64, thread: &str, turn: &str, name: &str, payload: Value) -> RuntimeEventRecord {
|
||||
serde_json::from_value(json!({"seq":seq,"timestamp":chrono::Utc::now(),"thread_id":thread,"turn_id":turn,"event":name,"payload":payload})).unwrap()
|
||||
}
|
||||
@@ -606,14 +638,15 @@ mod tests {
|
||||
calls: HashMap::new(),
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut output: Vec<Value> = Vec::new();
|
||||
// Project the actual Runtime records, not synthetic ACP frames.
|
||||
for record in rig.server.runtime.events_since(&thread, None)? {
|
||||
rig.server
|
||||
.project_event(&mut projection, &record, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut projection, &record, &mut recording(&mut output))
|
||||
.await?;
|
||||
}
|
||||
let wire = super::super::tests::parse_lines(output);
|
||||
let wire = output;
|
||||
let requests: Vec<_> = wire
|
||||
.iter()
|
||||
.filter(|frame| frame["method"] == "session/request_permission")
|
||||
@@ -635,12 +668,8 @@ mod tests {
|
||||
)
|
||||
.ok_or_else(|| anyhow!("ACP permission was not retained"))?;
|
||||
rig.server
|
||||
.answer_permission(
|
||||
&permission,
|
||||
&json!({
|
||||
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}
|
||||
}),
|
||||
)
|
||||
.prompt_run()
|
||||
.answer_permission(&permission, true)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(10), engine.recv_approval_event())
|
||||
@@ -758,7 +787,7 @@ mod tests {
|
||||
})
|
||||
.await??;
|
||||
let mut terminal = None;
|
||||
let mut settled_output = Vec::new();
|
||||
let mut settled_output: Vec<Value> = Vec::new();
|
||||
for record in rig
|
||||
.server
|
||||
.runtime
|
||||
@@ -766,7 +795,12 @@ mod tests {
|
||||
{
|
||||
if let Some(completed) = rig
|
||||
.server
|
||||
.project_event(&mut projection, &record, &mut settled_output)
|
||||
.prompt_run()
|
||||
.project_event(
|
||||
&mut projection,
|
||||
&record,
|
||||
&mut recording(&mut settled_output),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
terminal = Some(completed);
|
||||
@@ -781,7 +815,7 @@ mod tests {
|
||||
assert!(projection.permissions.is_empty());
|
||||
assert!(projection.calls.is_empty());
|
||||
assert!(
|
||||
!super::super::tests::parse_lines(settled_output)
|
||||
!settled_output
|
||||
.iter()
|
||||
.any(|frame| frame["method"] == "session/request_permission")
|
||||
);
|
||||
@@ -804,7 +838,7 @@ mod tests {
|
||||
calls: HashMap::new(),
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut output: Vec<Value> = Vec::new();
|
||||
let start = event(
|
||||
1,
|
||||
&thread,
|
||||
@@ -813,11 +847,13 @@ mod tests {
|
||||
json!({"tool":{"id":"core-call","name":"read","input":{"path":"x"}}}),
|
||||
);
|
||||
rig.server
|
||||
.project_event(&mut p, &start, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &start, &mut recording(&mut output))
|
||||
.await?;
|
||||
let before = output.len();
|
||||
rig.server
|
||||
.project_event(&mut p, &start, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &start, &mut recording(&mut output))
|
||||
.await?;
|
||||
assert_eq!(output.len(), before);
|
||||
let dispatch = event(
|
||||
@@ -829,7 +865,8 @@ mod tests {
|
||||
);
|
||||
assert!(
|
||||
rig.server
|
||||
.project_event(&mut p, &dispatch, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &dispatch, &mut recording(&mut output))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -850,7 +887,7 @@ mod tests {
|
||||
calls: HashMap::new(),
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut output: Vec<Value> = Vec::new();
|
||||
for record in [
|
||||
event(
|
||||
1,
|
||||
@@ -882,7 +919,8 @@ mod tests {
|
||||
),
|
||||
] {
|
||||
rig.server
|
||||
.project_event(&mut p, &record, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &record, &mut recording(&mut output))
|
||||
.await?;
|
||||
}
|
||||
assert!(output.is_empty());
|
||||
@@ -904,7 +942,7 @@ mod tests {
|
||||
calls: HashMap::new(),
|
||||
permissions: HashMap::new(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut output: Vec<Value> = Vec::new();
|
||||
let failure = event(
|
||||
1,
|
||||
&thread,
|
||||
@@ -914,7 +952,8 @@ mod tests {
|
||||
);
|
||||
let error = rig
|
||||
.server
|
||||
.project_event(&mut p, &failure, &mut output)
|
||||
.prompt_run()
|
||||
.project_event(&mut p, &failure, &mut recording(&mut output))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("no terminal receipt"));
|
||||
@@ -922,90 +961,4 @@ mod tests {
|
||||
rig.close().await;
|
||||
Ok(())
|
||||
}
|
||||
struct FailedAnswerWriter(Vec<u8>);
|
||||
impl AsyncWrite for FailedAnswerWriter {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
bytes: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
if String::from_utf8_lossy(bytes).contains("agent_message_chunk") {
|
||||
return std::task::Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"fixture client disappeared after tool effect",
|
||||
)));
|
||||
}
|
||||
self.0.extend_from_slice(bytes);
|
||||
std::task::Poll::Ready(Ok(bytes.len()))
|
||||
}
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn failed_transport_after_completed_write_retains_actual_core_terminal_and_checkpoint()
|
||||
-> Result<()> {
|
||||
use codewhale_engine::llm_client::mock::canned;
|
||||
let mut config = fixture_config();
|
||||
config.yolo = Some(true);
|
||||
let mut rig = Rig::new(
|
||||
config,
|
||||
vec![
|
||||
canned::tool_call_turn(
|
||||
"write",
|
||||
"write",
|
||||
r#"{"path":"writer-failure.txt","content":"completed"}"#,
|
||||
),
|
||||
canned::simple_text_turn("final answer"),
|
||||
],
|
||||
)?;
|
||||
let id = rig.new_session().await;
|
||||
let (client, input) = tokio::io::duplex(1024);
|
||||
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(input));
|
||||
let mut writer = FailedAnswerWriter(Vec::new());
|
||||
let error = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
rig.server.drive_prompt(
|
||||
json!({"sessionId":id,"prompt":"write then answer"}),
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
.unwrap_err();
|
||||
drop(client);
|
||||
assert!(error.to_string().contains("partial receipts retained"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(rig.workspace.join("writer-failure.txt"))?,
|
||||
"completed"
|
||||
);
|
||||
let store = codewhale_engine::session_manager::SessionManager::new(
|
||||
rig.server.sessions_dir.clone(),
|
||||
)?;
|
||||
let saved = store.load_session(&id)?;
|
||||
assert!(
|
||||
saved
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(|m| &m.content)
|
||||
.any(|b| matches!(b, codewhale_models::ContentBlock::ToolResult { .. }))
|
||||
);
|
||||
let thread = &rig.server.sessions[&id].thread_id;
|
||||
let detail = rig.server.runtime.get_thread_detail(thread).await?;
|
||||
assert!(!matches!(
|
||||
detail.turns.last().unwrap().status,
|
||||
RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress
|
||||
));
|
||||
rig.close().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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(
|
||||
®istry,
|
||||
codewhale_plugins::activation::PluginActivationCapability::Native,
|
||||
);
|
||||
assert_eq!(sources.len(), 1, "{errors:?}");
|
||||
plugins.registry().disable("alpha").unwrap();
|
||||
let (desired, errors) = super::desired_owners_from(®istry, sources, errors);
|
||||
assert!(desired.is_empty(), "{:?}", desired.keys());
|
||||
assert_eq!(
|
||||
errors
|
||||
.iter()
|
||||
.filter(|error| error
|
||||
.contains("`alpha` was not loaded because its review record could not be read"))
|
||||
.count(),
|
||||
1,
|
||||
"{errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_broker_ticket_is_minted_for_a_plugin_whose_reviewed_record_does_not_name_the_service() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user