Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions runner/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions runner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ chrono = { version = "=0.4.45", features = ["serde"] }
uuid = { version = "=1.26.0", features = ["v4", "serde"] }
reqwest = { version = "=0.13.5", features = ["json", "stream"] }
async-trait = "=0.1.92"
rmcp = { version = "=3.2.0", default-features = false }
rmcp = { version = "=3.4.0", default-features = false }

# Shared crates, published from abnegate/crates. Caret ranges, unlike every
# other dependency here, so a patch release of a crate reaches zone on update.
Expand All @@ -55,7 +55,7 @@ metrics = "=0.24.6"
metrics-exporter-prometheus = "=0.18.3"

# CLI dependencies
dirs = "=6.0.0"
dirs = "=7.0.0"
toml = "=1.1.5"
dialoguer = "=0.12.0"
indicatif = "=0.18.6"
Expand Down
2 changes: 1 addition & 1 deletion runner/zone_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ futures = { workspace = true }
nix = { workspace = true, features = ["fs"] }

# Session storage
dirs = "=6.0.0"
dirs = { workspace = true }

abnegate-exec = { workspace = true }
abnegate-secret = { workspace = true }
Expand Down
86 changes: 85 additions & 1 deletion runner/zone_core/src/mcp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,12 @@ mod tests {
use crate::mcp::McpServerSpec;
use crate::tools::{Tier, ToolContext, ToolRegistry};
use rmcp::handler::server::wrapper::Parameters;
use rmcp::{ServerHandler, ServiceExt, schemars, tool, tool_handler, tool_router};
use rmcp::model::{
CancelledNotification, CancelledNotificationParam, ClientJsonRpcMessage, ClientRequest,
InitializeResult, JsonObject, ListToolsResult, ServerJsonRpcMessage,
};
use rmcp::transport::{IntoTransport, Transport};
use rmcp::{RoleServer, ServerHandler, ServiceExt, schemars, tool, tool_handler, tool_router};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
Expand Down Expand Up @@ -384,6 +389,85 @@ mod tests {
server_task.abort();
}

#[tokio::test]
async fn a_call_the_server_cancels_and_never_answers_times_out() {
let (server_stream, client_stream) = tokio::io::duplex(64 * 1024);

let server_task = tokio::spawn(async move {
let mut server = IntoTransport::<RoleServer, _, _>::into_transport(server_stream);
while let Some(message) = server.receive().await {
let ClientJsonRpcMessage::Request(request) = message else {
continue;
};
let reply = match request.request {
ClientRequest::InitializeRequest(_) => ServerJsonRpcMessage::response(
InitializeResult::default().into(),
request.id,
),
ClientRequest::ListToolsRequest(_) => ServerJsonRpcMessage::response(
ListToolsResult::with_all_items(vec![Tool::new(
"hang",
"Cancels every call and never answers",
JsonObject::new(),
)])
.into(),
request.id,
),
ClientRequest::CallToolRequest(_) => ServerJsonRpcMessage::notification(
CancelledNotification::new(CancelledNotificationParam::new(
Some(request.id),
Some("abandoned".to_string()),
))
.into(),
),
_ => continue,
};
server.send(reply).await.expect("server send");
}
});

let client = ().serve(client_stream).await.expect("client serve");
let remote_tools = client.list_all_tools().await.expect("list tools");
let hub = McpHub {
sessions: vec![Arc::new(McpSession {
name: "silent".to_string(),
remote_tools,
client: Mutex::new(client),
})],
};
let mut registry = ToolRegistry::new();
assert_eq!(registry.register_mcp(&hub), 1);

let result = tokio::time::timeout(
Duration::from_secs(30),
registry.execute(
"silent_hang",
serde_json::json!({}),
&ToolContext {
command_timeout: 1,
..ToolContext::default()
},
),
)
.await
.expect("the command timeout must end a call the server cancelled and never answered")
.expect("execute silent_hang");

assert!(!result.success, "{result:?}");
assert!(
result
.error
.as_deref()
.unwrap_or("")
.contains("timed out after 1 seconds"),
"a server's cancellation no longer ends the call, so the command timeout must: {result:?}"
);

drop(registry);
drop(hub);
server_task.abort();
}

/// A remote method can write files, spend money or message a stranger, and
/// nothing Zone can trust says which one it is. It is therefore gated like
/// the calls that cannot be taken back, and the card carries the call
Expand Down
28 changes: 28 additions & 0 deletions runner/zone_core/src/tools/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,34 @@ mod tests {
assert!(!output.contains("Line 4"));
}

/// Tool output is sanitized, so the model reads a file without its
/// invisible formatting characters: an emoji's variation selector and a
/// Persian word's zero-width non-joiner are gone from what it sees, and a
/// patch or rewrite built from that text does not carry them.
#[tokio::test]
async fn read_file_returns_text_without_its_invisible_formatting_characters() {
let dir = tempdir().unwrap();
let content =
"\u{2764}\u{FE0F} \u{645}\u{6CC}\u{200C}\u{62E}\u{648}\u{627}\u{647}\u{645}\n";
fs::write(dir.path().join("note.txt"), content).unwrap();
let context = create_test_context(dir.path());

let result = ReadFileTool
.execute(serde_json::json!({"path": "note.txt"}), &context)
.await
.unwrap();

assert_eq!(
result.output.as_deref(),
Some("\u{2764} \u{645}\u{6CC}\u{62E}\u{648}\u{627}\u{647}\u{645}\n")
);
assert_eq!(
fs::read_to_string(dir.path().join("note.txt")).unwrap(),
content,
"reading leaves the file itself untouched"
);
}

#[test]
fn page_text_caps_and_continues_by_character() {
let content = "α".repeat(10);
Expand Down
56 changes: 46 additions & 10 deletions runner/zone_core/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,16 @@ mod identity;
pub mod job;
mod reason;
pub mod routing;
mod sanitize;
pub mod tail;
mod tier;

pub use command::*;
pub use file::*;
pub use reason::{REASON_DESCRIPTION, REASON_PARAM, reason_property};
pub use sanitize::sanitize;
pub use tier::{CONFIRMED_FROM, Tier};

use abnegate_secret::sanitize_owned;
use async_trait::async_trait;
use sanitize::sanitize_owned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -179,7 +177,7 @@ pub struct ToolResult {
}

impl ToolResult {
/// Wrap successful tool output, [`sanitize`]d on the way in.
/// Wrap successful tool output, [`sanitize`](abnegate_secret::sanitize)d on the way in.
pub fn success(output: impl Into<String>) -> Self {
Self {
success: true,
Expand All @@ -189,7 +187,7 @@ impl ToolResult {
}
}

/// Wrap a tool failure, [`sanitize`]d on the way in.
/// Wrap a tool failure, [`sanitize`](abnegate_secret::sanitize)d on the way in.
pub fn error(error: impl Into<String>) -> Self {
Self {
success: false,
Expand Down Expand Up @@ -751,6 +749,7 @@ pub(crate) mod test_support {
#[cfg(test)]
mod tests {
use super::*;
use abnegate_secret::REDACTED;

/// A preview a reader approves has to say what will run. `sh -c` runs one
/// command per line, so two lines joined by a space showed them a single
Expand Down Expand Up @@ -805,11 +804,9 @@ mod tests {
#[test]
fn success_redacts_a_credential_in_the_output() {
let result = ToolResult::success("printenv\nGITHUB_TOKEN=ghp_0123456789abcdefghij\n");
assert_eq!(
result.output.as_deref(),
Some("printenv\nGITHUB_TOKEN=[REDACTED]\n")
);
assert_eq!(result.to_message(), "printenv\nGITHUB_TOKEN=[REDACTED]\n");
let redacted = format!("printenv\nGITHUB_TOKEN={REDACTED}\n");
assert_eq!(result.output.as_deref(), Some(redacted.as_str()));
assert_eq!(result.to_message(), redacted);
}

#[test]
Expand All @@ -819,6 +816,45 @@ mod tests {
assert_eq!(result.to_message(), "Error: command not found\n");
}

#[test]
fn success_redacts_a_credential_split_by_an_invisible_character() {
let result = ToolResult::success("fatal: ghp_0123\u{200B}456789abcdefghij rejected");
assert_eq!(result.output, Some(format!("fatal: {REDACTED} rejected")));
}

#[test]
fn success_strips_bidirectional_overrides() {
let result = ToolResult::success("access = \u{202E}nimda\u{202C} check");
assert_eq!(result.output.as_deref(), Some("access = nimda check"));
}

#[test]
fn error_strips_a_whole_two_byte_escape() {
let result = ToolResult::error("a\u{1b}7b\u{1b}cc");
assert_eq!(result.error.as_deref(), Some("abc"));
}

#[test]
fn error_strips_an_escape_with_intermediates() {
let result = ToolResult::error("a\u{1b}(Bb");
assert_eq!(result.error.as_deref(), Some("ab"));
}

#[test]
fn error_strips_a_start_of_string_with_its_payload() {
let result = ToolResult::error("a\u{1b}Xhidden\u{1b}\\b");
assert_eq!(result.error.as_deref(), Some("ab"));
}

#[test]
fn success_drops_emoji_joiners_and_variation_selectors() {
let result = ToolResult::success("\u{2764}\u{FE0F} \u{1F468}\u{200D}\u{1F469}");
assert_eq!(
result.output.as_deref(),
Some("\u{2764} \u{1F468}\u{1F469}")
);
}

#[test]
fn to_message_keeps_the_head_and_tail_of_huge_success_output() {
let body = format!("HEAD_MARKER{}TAIL_MARKER", "x".repeat(20_000));
Expand Down
Loading
Loading