diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2399799..9030127 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1091,11 +1091,8 @@ dependencies = [ "compat-harness", "crossterm", "pulldown-cmark", - "reqwest", "runtime", - "serde", "serde_json", - "sha2", "syntect", "tokio", "tools", @@ -1434,12 +1431,10 @@ dependencies = [ name = "tools" version = "0.1.0" dependencies = [ - "api", "reqwest", "runtime", "serde", "serde_json", - "tokio", ] [[package]] diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 91f40d8..a8f6dfa 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -912,7 +912,6 @@ mod tests { system: None, tools: None, tool_choice: None, - thinking: None, stream: false, }; diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index 3a415f8..c208655 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -11,7 +11,7 @@ pub use error::ApiError; pub use sse::{parse_frame, SseParser}; pub use types::{ ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, - ImageSource, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, + InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent, - ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, + ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, }; diff --git a/rust/crates/api/src/types.rs b/rust/crates/api/src/types.rs index aa3900f..45d5c08 100644 --- a/rust/crates/api/src/types.rs +++ b/rust/crates/api/src/types.rs @@ -12,8 +12,6 @@ pub struct MessageRequest { pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub thinking: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub stream: bool, } @@ -26,23 +24,6 @@ impl MessageRequest { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ThinkingConfig { - #[serde(rename = "type")] - pub kind: String, - pub budget_tokens: u32, -} - -impl ThinkingConfig { - #[must_use] - pub fn enabled(budget_tokens: u32) -> Self { - Self { - kind: "enabled".to_string(), - budget_tokens, - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct InputMessage { pub role: String, @@ -83,9 +64,6 @@ pub enum InputContentBlock { Text { text: String, }, - Image { - source: ImageSource, - }, ToolUse { id: String, name: String, @@ -99,14 +77,6 @@ pub enum InputContentBlock { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ImageSource { - #[serde(rename = "type")] - pub kind: String, - pub media_type: String, - pub data: String, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ToolResultContentBlock { @@ -160,11 +130,6 @@ pub enum OutputContentBlock { Text { text: String, }, - Thinking { - thinking: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - signature: Option, - }, ToolUse { id: String, name: String, @@ -224,8 +189,6 @@ pub struct ContentBlockDeltaEvent { #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlockDelta { TextDelta { text: String }, - ThinkingDelta { thinking: String }, - SignatureDelta { signature: String }, InputJsonDelta { partial_json: String }, } diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs index ffc2939..c37fa99 100644 --- a/rust/crates/api/tests/client_integration.rs +++ b/rust/crates/api/tests/client_integration.rs @@ -4,8 +4,8 @@ use std::time::Duration; use api::{ AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, - ImageSource, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, - OutputContentBlock, StreamEvent, ToolChoice, ToolDefinition, + InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock, + StreamEvent, ToolChoice, ToolDefinition, }; use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -75,39 +75,6 @@ async fn send_message_posts_json_and_parses_response() { assert_eq!(body["tool_choice"]["type"], json!("auto")); } -#[test] -fn image_content_blocks_serialize_with_base64_source() { - let request = MessageRequest { - model: "claude-3-7-sonnet-latest".to_string(), - max_tokens: 64, - messages: vec![InputMessage { - role: "user".to_string(), - content: vec![InputContentBlock::Image { - source: ImageSource { - kind: "base64".to_string(), - media_type: "image/png".to_string(), - data: "AQID".to_string(), - }, - }], - }], - system: None, - tools: None, - tool_choice: None, - stream: false, - }; - - let json = serde_json::to_value(request).expect("request should serialize"); - assert_eq!(json["messages"][0]["content"][0]["type"], json!("image")); - assert_eq!( - json["messages"][0]["content"][0]["source"], - json!({ - "type": "base64", - "media_type": "image/png", - "data": "AQID" - }) - ); -} - #[tokio::test] async fn stream_message_parses_sse_events_with_tool_use() { let state = Arc::new(Mutex::new(Vec::::new())); @@ -291,7 +258,6 @@ async fn live_stream_smoke_test() { system: None, tools: None, tool_choice: None, - thinking: None, stream: false, }) .await @@ -472,7 +438,6 @@ fn sample_request(stream: bool) -> MessageRequest { }), }]), tool_choice: Some(ToolChoice::Auto), - thinking: None, stream, } } diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 2182b6a..b396bb0 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -57,12 +57,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, - SlashCommandSpec { - name: "thinking", - summary: "Show or toggle extended thinking", - argument_hint: Some("[on|off]"), - resume_supported: false, - }, SlashCommandSpec { name: "model", summary: "Show or switch the active model", @@ -107,7 +101,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ }, SlashCommandSpec { name: "init", - summary: "Bootstrap Claude project files for this repo", + summary: "Create a starter CLAUDE.md for this repo", argument_hint: None, resume_supported: true, }, @@ -142,9 +136,6 @@ pub enum SlashCommand { Help, Status, Compact, - Thinking { - enabled: Option, - }, Model { model: Option, }, @@ -189,13 +180,6 @@ impl SlashCommand { "help" => Self::Help, "status" => Self::Status, "compact" => Self::Compact, - "thinking" => Self::Thinking { - enabled: match parts.next() { - Some("on") => Some(true), - Some("off") => Some(false), - Some(_) | None => None, - }, - }, "model" => Self::Model { model: parts.next().map(ToOwned::to_owned), }, @@ -295,7 +279,6 @@ pub fn handle_slash_command( session: session.clone(), }), SlashCommand::Status - | SlashCommand::Thinking { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Clear { .. } @@ -324,22 +307,6 @@ mod tests { fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); - assert_eq!( - SlashCommand::parse("/thinking on"), - Some(SlashCommand::Thinking { - enabled: Some(true), - }) - ); - assert_eq!( - SlashCommand::parse("/thinking off"), - Some(SlashCommand::Thinking { - enabled: Some(false), - }) - ); - assert_eq!( - SlashCommand::parse("/thinking"), - Some(SlashCommand::Thinking { enabled: None }) - ); assert_eq!( SlashCommand::parse("/model claude-opus"), Some(SlashCommand::Model { @@ -407,7 +374,6 @@ mod tests { assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/compact")); - assert!(help.contains("/thinking [on|off]")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); @@ -420,7 +386,7 @@ mod tests { assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); - assert_eq!(slash_command_specs().len(), 16); + assert_eq!(slash_command_specs().len(), 15); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -468,9 +434,6 @@ mod tests { let session = Session::new(); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); - assert!( - handle_slash_command("/thinking on", &session, CompactionConfig::default()).is_none() - ); assert!( handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() ); diff --git a/rust/crates/runtime/src/bash.rs b/rust/crates/runtime/src/bash.rs index 841068b..a159ec6 100644 --- a/rust/crates/runtime/src/bash.rs +++ b/rust/crates/runtime/src/bash.rs @@ -1,3 +1,4 @@ +use std::env; use std::io; use std::process::{Command, Stdio}; use std::time::Duration; @@ -7,6 +8,12 @@ use tokio::process::Command as TokioCommand; use tokio::runtime::Builder; use tokio::time::timeout; +use crate::sandbox::{ + build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode, + SandboxConfig, SandboxStatus, +}; +use crate::ConfigLoader; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BashCommandInput { pub command: String, @@ -16,6 +23,14 @@ pub struct BashCommandInput { pub run_in_background: Option, #[serde(rename = "dangerouslyDisableSandbox")] pub dangerously_disable_sandbox: Option, + #[serde(rename = "namespaceRestrictions")] + pub namespace_restrictions: Option, + #[serde(rename = "isolateNetwork")] + pub isolate_network: Option, + #[serde(rename = "filesystemMode")] + pub filesystem_mode: Option, + #[serde(rename = "allowedMounts")] + pub allowed_mounts: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -45,13 +60,17 @@ pub struct BashCommandOutput { pub persisted_output_path: Option, #[serde(rename = "persistedOutputSize")] pub persisted_output_size: Option, + #[serde(rename = "sandboxStatus")] + pub sandbox_status: Option, } pub fn execute_bash(input: BashCommandInput) -> io::Result { + let cwd = env::current_dir()?; + let sandbox_status = sandbox_status_for_input(&input, &cwd); + if input.run_in_background.unwrap_or(false) { - let child = Command::new("sh") - .arg("-lc") - .arg(&input.command) + let mut child = prepare_command(&input.command, &cwd, &sandbox_status, false); + let child = child .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -72,16 +91,20 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result { structured_content: None, persisted_output_path: None, persisted_output_size: None, + sandbox_status: Some(sandbox_status), }); } let runtime = Builder::new_current_thread().enable_all().build()?; - runtime.block_on(execute_bash_async(input)) + runtime.block_on(execute_bash_async(input, sandbox_status, cwd)) } -async fn execute_bash_async(input: BashCommandInput) -> io::Result { - let mut command = TokioCommand::new("sh"); - command.arg("-lc").arg(&input.command); +async fn execute_bash_async( + input: BashCommandInput, + sandbox_status: SandboxStatus, + cwd: std::path::PathBuf, +) -> io::Result { + let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true); let output_result = if let Some(timeout_ms) = input.timeout { match timeout(Duration::from_millis(timeout_ms), command.output()).await { @@ -102,6 +125,7 @@ async fn execute_bash_async(input: BashCommandInput) -> io::Result io::Result SandboxStatus { + let config = ConfigLoader::default_for(cwd).load().map_or_else( + |_| SandboxConfig::default(), + |runtime_config| runtime_config.sandbox().clone(), + ); + let request = config.resolve_request( + input.dangerously_disable_sandbox.map(|disabled| !disabled), + input.namespace_restrictions, + input.isolate_network, + input.filesystem_mode, + input.allowed_mounts.clone(), + ); + resolve_sandbox_status_for_request(&request, cwd) +} + +fn prepare_command( + command: &str, + cwd: &std::path::Path, + sandbox_status: &SandboxStatus, + create_dirs: bool, +) -> Command { + if create_dirs { + prepare_sandbox_dirs(cwd); + } + + if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) { + let mut prepared = Command::new(launcher.program); + prepared.args(launcher.args); + prepared.current_dir(cwd); + prepared.envs(launcher.env); + return prepared; + } + + let mut prepared = Command::new("sh"); + prepared.arg("-lc").arg(command).current_dir(cwd); + if sandbox_status.filesystem_active { + prepared.env("HOME", cwd.join(".sandbox-home")); + prepared.env("TMPDIR", cwd.join(".sandbox-tmp")); + } + prepared +} + +fn prepare_tokio_command( + command: &str, + cwd: &std::path::Path, + sandbox_status: &SandboxStatus, + create_dirs: bool, +) -> TokioCommand { + if create_dirs { + prepare_sandbox_dirs(cwd); + } + + if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) { + let mut prepared = TokioCommand::new(launcher.program); + prepared.args(launcher.args); + prepared.current_dir(cwd); + prepared.envs(launcher.env); + return prepared; + } + + let mut prepared = TokioCommand::new("sh"); + prepared.arg("-lc").arg(command).current_dir(cwd); + if sandbox_status.filesystem_active { + prepared.env("HOME", cwd.join(".sandbox-home")); + prepared.env("TMPDIR", cwd.join(".sandbox-tmp")); + } + prepared +} + +fn prepare_sandbox_dirs(cwd: &std::path::Path) { + let _ = std::fs::create_dir_all(cwd.join(".sandbox-home")); + let _ = std::fs::create_dir_all(cwd.join(".sandbox-tmp")); +} + #[cfg(test)] mod tests { use super::{execute_bash, BashCommandInput}; + use crate::sandbox::FilesystemIsolationMode; #[test] fn executes_simple_command() { @@ -151,10 +251,33 @@ mod tests { description: None, run_in_background: Some(false), dangerously_disable_sandbox: Some(false), + namespace_restrictions: Some(false), + isolate_network: Some(false), + filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly), + allowed_mounts: None, }) .expect("bash command should execute"); assert_eq!(output.stdout, "hello"); assert!(!output.interrupted); + assert!(output.sandbox_status.is_some()); + } + + #[test] + fn disables_sandbox_when_requested() { + let output = execute_bash(BashCommandInput { + command: String::from("printf 'hello'"), + timeout: Some(1_000), + description: None, + run_in_background: Some(false), + dangerously_disable_sandbox: Some(true), + namespace_restrictions: None, + isolate_network: None, + filesystem_mode: None, + allowed_mounts: None, + }) + .expect("bash command should execute"); + + assert!(!output.sandbox_status.expect("sandbox status").enabled); } } diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e593b9c..e227019 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -1,6 +1,3 @@ -use std::fs; -use std::time::{SystemTime, UNIX_EPOCH}; - use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -93,7 +90,6 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio let preserved = session.messages[keep_from..].to_vec(); let summary = summarize_messages(removed); let formatted_summary = format_compact_summary(&summary); - persist_compact_summary(&formatted_summary); let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty()); let mut compacted_messages = vec![ConversationMessage { @@ -109,41 +105,11 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio compacted_session: Session { version: session.version, messages: compacted_messages, - metadata: session.metadata.clone(), }, removed_message_count: removed.len(), } } -fn persist_compact_summary(formatted_summary: &str) { - if formatted_summary.trim().is_empty() { - return; - } - - let Ok(cwd) = std::env::current_dir() else { - return; - }; - let memory_dir = cwd.join(".claude").join("memory"); - if fs::create_dir_all(&memory_dir).is_err() { - return; - } - - let path = memory_dir.join(compact_summary_filename()); - let _ = fs::write(path, render_memory_file(formatted_summary)); -} - -fn compact_summary_filename() -> String { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - format!("summary-{timestamp}.md") -} - -fn render_memory_file(formatted_summary: &str) -> String { - format!("# Project memory\n\n{}\n", formatted_summary.trim()) -} - fn summarize_messages(messages: &[ConversationMessage]) -> String { let user_messages = messages .iter() @@ -164,7 +130,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String { .filter_map(|block| match block { ContentBlock::ToolUse { name, .. } => Some(name.as_str()), ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()), - ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None, + ContentBlock::Text { .. } => None, }) .collect::>(); tool_names.sort_unstable(); @@ -234,7 +200,6 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String { fn summarize_block(block: &ContentBlock) -> String { let raw = match block { ContentBlock::Text { text } => text.clone(), - ContentBlock::Thinking { text, .. } => format!("thinking: {text}"), ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"), ContentBlock::ToolResult { tool_name, @@ -293,7 +258,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec { .iter() .flat_map(|message| message.blocks.iter()) .map(|block| match block { - ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(), + ContentBlock::Text { text } => text.as_str(), ContentBlock::ToolUse { input, .. } => input.as_str(), ContentBlock::ToolResult { output, .. } => output.as_str(), }) @@ -315,15 +280,10 @@ fn infer_current_work(messages: &[ConversationMessage]) -> Option { fn first_text_block(message: &ConversationMessage) -> Option<&str> { message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } - if !text.trim().is_empty() => - { - Some(text.as_str()) - } + ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()), ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } - | ContentBlock::Text { .. } - | ContentBlock::Thinking { .. } => None, + | ContentBlock::Text { .. } => None, }) } @@ -368,7 +328,7 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize { .blocks .iter() .map(|block| match block { - ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1, + ContentBlock::Text { text } => text.len() / 4 + 1, ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1, ContentBlock::ToolResult { tool_name, output, .. @@ -418,21 +378,14 @@ fn collapse_blank_lines(content: &str) -> String { mod tests { use super::{ collect_key_files, compact_session, estimate_session_tokens, format_compact_summary, - infer_pending_work, render_memory_file, should_compact, CompactionConfig, + infer_pending_work, should_compact, CompactionConfig, }; - use std::fs; - use std::time::{SystemTime, UNIX_EPOCH}; - use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; #[test] fn formats_compact_summary_like_upstream() { let summary = "scratch\nKept work"; assert_eq!(format_compact_summary(summary), "Summary:\nKept work"); - assert_eq!( - render_memory_file("Summary:\nKept work"), - "# Project memory\n\nSummary:\nKept work\n" - ); } #[test] @@ -440,7 +393,6 @@ mod tests { let session = Session { version: 1, messages: vec![ConversationMessage::user_text("hello")], - metadata: None, }; let result = compact_session(&session, CompactionConfig::default()); @@ -450,63 +402,6 @@ mod tests { assert!(result.formatted_summary.is_empty()); } - #[test] - fn persists_compacted_summaries_under_dot_claude_memory() { - let _guard = crate::test_env_lock(); - let temp = std::env::temp_dir().join(format!( - "runtime-compact-memory-{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time after epoch") - .as_nanos() - )); - fs::create_dir_all(&temp).expect("temp dir"); - let previous = std::env::current_dir().expect("cwd"); - std::env::set_current_dir(&temp).expect("set cwd"); - - let session = Session { - version: 1, - messages: vec![ - ConversationMessage::user_text("one ".repeat(200)), - ConversationMessage::assistant(vec![ContentBlock::Text { - text: "two ".repeat(200), - }]), - ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false), - ConversationMessage { - role: MessageRole::Assistant, - blocks: vec![ContentBlock::Text { - text: "recent".to_string(), - }], - usage: None, - }, - ], - }; - - let result = compact_session( - &session, - CompactionConfig { - preserve_recent_messages: 2, - max_estimated_tokens: 1, - }, - ); - let memory_dir = temp.join(".claude").join("memory"); - let files = fs::read_dir(&memory_dir) - .expect("memory dir exists") - .flatten() - .map(|entry| entry.path()) - .collect::>(); - - assert_eq!(result.removed_message_count, 2); - assert_eq!(files.len(), 1); - let persisted = fs::read_to_string(&files[0]).expect("memory file readable"); - - std::env::set_current_dir(previous).expect("restore cwd"); - fs::remove_dir_all(temp).expect("cleanup temp dir"); - - assert!(persisted.contains("# Project memory")); - assert!(persisted.contains("Summary:")); - } - #[test] fn compacts_older_messages_into_a_system_summary() { let session = Session { @@ -525,7 +420,6 @@ mod tests { usage: None, }, ], - metadata: None, }; let result = compact_session( diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 9ea937e..edf1144 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -4,6 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; use crate::json::JsonValue; +use crate::sandbox::{FilesystemIsolationMode, SandboxConfig}; pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema"; @@ -40,6 +41,7 @@ pub struct RuntimeFeatureConfig { oauth: Option, model: Option, permission_mode: Option, + sandbox: SandboxConfig, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -225,6 +227,7 @@ impl ConfigLoader { oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, model: parse_optional_model(&merged_value), permission_mode: parse_optional_permission_mode(&merged_value)?, + sandbox: parse_optional_sandbox_config(&merged_value)?, }; Ok(RuntimeConfig { @@ -289,6 +292,11 @@ impl RuntimeConfig { pub fn permission_mode(&self) -> Option { self.feature_config.permission_mode } + + #[must_use] + pub fn sandbox(&self) -> &SandboxConfig { + &self.feature_config.sandbox + } } impl RuntimeFeatureConfig { @@ -311,6 +319,11 @@ impl RuntimeFeatureConfig { pub fn permission_mode(&self) -> Option { self.permission_mode } + + #[must_use] + pub fn sandbox(&self) -> &SandboxConfig { + &self.sandbox + } } impl McpConfigCollection { @@ -445,6 +458,42 @@ fn parse_permission_mode_label( } } +fn parse_optional_sandbox_config(root: &JsonValue) -> Result { + let Some(object) = root.as_object() else { + return Ok(SandboxConfig::default()); + }; + let Some(sandbox_value) = object.get("sandbox") else { + return Ok(SandboxConfig::default()); + }; + let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?; + let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")? + .map(parse_filesystem_mode_label) + .transpose()?; + Ok(SandboxConfig { + enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?, + namespace_restrictions: optional_bool( + sandbox, + "namespaceRestrictions", + "merged settings.sandbox", + )?, + network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?, + filesystem_mode, + allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")? + .unwrap_or_default(), + }) +} + +fn parse_filesystem_mode_label(value: &str) -> Result { + match value { + "off" => Ok(FilesystemIsolationMode::Off), + "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly), + "allow-list" => Ok(FilesystemIsolationMode::AllowList), + other => Err(ConfigError::Parse(format!( + "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}" + ))), + } +} + fn parse_optional_oauth_config( root: &JsonValue, context: &str, @@ -688,6 +737,7 @@ mod tests { CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; use crate::json::JsonValue; + use crate::sandbox::FilesystemIsolationMode; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; @@ -792,6 +842,44 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn parses_sandbox_config() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + + fs::write( + cwd.join(".claude").join("settings.local.json"), + r#"{ + "sandbox": { + "enabled": true, + "namespaceRestrictions": false, + "networkIsolation": true, + "filesystemMode": "allow-list", + "allowedMounts": ["logs", "tmp/cache"] + } + }"#, + ) + .expect("write local settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert_eq!(loaded.sandbox().enabled, Some(true)); + assert_eq!(loaded.sandbox().namespace_restrictions, Some(false)); + assert_eq!(loaded.sandbox().network_isolation, Some(true)); + assert_eq!( + loaded.sandbox().filesystem_mode, + Some(FilesystemIsolationMode::AllowList) + ); + assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn parses_typed_mcp_and_oauth_config() { let root = temp_dir(); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 5285412..625fb25 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -17,8 +17,6 @@ pub struct ApiRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub enum AssistantEvent { TextDelta(String), - ThinkingDelta(String), - ThinkingSignature(String), ToolUse { id: String, name: String, @@ -249,26 +247,15 @@ fn build_assistant_message( events: Vec, ) -> Result<(ConversationMessage, Option), RuntimeError> { let mut text = String::new(); - let mut thinking = String::new(); - let mut thinking_signature: Option = None; let mut blocks = Vec::new(); let mut finished = false; let mut usage = None; for event in events { match event { - AssistantEvent::TextDelta(delta) => { - flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks); - text.push_str(&delta); - } - AssistantEvent::ThinkingDelta(delta) => { - flush_text_block(&mut text, &mut blocks); - thinking.push_str(&delta); - } - AssistantEvent::ThinkingSignature(signature) => thinking_signature = Some(signature), + AssistantEvent::TextDelta(delta) => text.push_str(&delta), AssistantEvent::ToolUse { id, name, input } => { flush_text_block(&mut text, &mut blocks); - flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks); blocks.push(ContentBlock::ToolUse { id, name, input }); } AssistantEvent::Usage(value) => usage = Some(value), @@ -279,7 +266,6 @@ fn build_assistant_message( } flush_text_block(&mut text, &mut blocks); - flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks); if !finished { return Err(RuntimeError::new( @@ -304,19 +290,6 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec) { } } -fn flush_thinking_block( - thinking: &mut String, - signature: &mut Option, - blocks: &mut Vec, -) { - if !thinking.is_empty() || signature.is_some() { - blocks.push(ContentBlock::Thinking { - text: std::mem::take(thinking), - signature: signature.take(), - }); - } -} - type ToolHandler = Box Result>; #[derive(Default)] @@ -352,8 +325,8 @@ impl ToolExecutor for StaticToolExecutor { #[cfg(test)] mod tests { use super::{ - build_assistant_message, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, - RuntimeError, StaticToolExecutor, + ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, + StaticToolExecutor, }; use crate::compact::CompactionConfig; use crate::permissions::{ @@ -441,8 +414,8 @@ mod tests { cwd: PathBuf::from("/tmp/project"), current_date: "2026-03-31".to_string(), git_status: None, + git_diff: None, instruction_files: Vec::new(), - memory_files: Vec::new(), }) .with_os("linux", "6.8") .build(); @@ -530,29 +503,6 @@ mod tests { )); } - #[test] - fn thinking_blocks_are_preserved_separately_from_text() { - let (message, usage) = build_assistant_message(vec![ - AssistantEvent::ThinkingDelta("first ".to_string()), - AssistantEvent::ThinkingDelta("second".to_string()), - AssistantEvent::ThinkingSignature("sig-1".to_string()), - AssistantEvent::TextDelta("final".to_string()), - AssistantEvent::MessageStop, - ]) - .expect("assistant message should build"); - - assert_eq!(usage, None); - assert!(matches!( - &message.blocks[0], - ContentBlock::Thinking { text, signature } - if text == "first second" && signature.as_deref() == Some("sig-1") - )); - assert!(matches!( - &message.blocks[1], - ContentBlock::Text { text } if text == "final" - )); - } - #[test] fn reconstructs_usage_tracker_from_restored_session() { struct SimpleApi; diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index ebc0035..2861d47 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -12,6 +12,7 @@ mod oauth; mod permissions; mod prompt; mod remote; +pub mod sandbox; mod session; mod usage; @@ -73,9 +74,7 @@ pub use remote::{ RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, }; -pub use session::{ - ContentBlock, ConversationMessage, MessageRole, Session, SessionError, SessionMetadata, -}; +pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError}; pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, }; diff --git a/rust/crates/runtime/src/permissions.rs b/rust/crates/runtime/src/permissions.rs index 919730b..bed2eab 100644 --- a/rust/crates/runtime/src/permissions.rs +++ b/rust/crates/runtime/src/permissions.rs @@ -5,6 +5,8 @@ pub enum PermissionMode { ReadOnly, WorkspaceWrite, DangerFullAccess, + Prompt, + Allow, } impl PermissionMode { @@ -14,6 +16,8 @@ impl PermissionMode { Self::ReadOnly => "read-only", Self::WorkspaceWrite => "workspace-write", Self::DangerFullAccess => "danger-full-access", + Self::Prompt => "prompt", + Self::Allow => "allow", } } } @@ -90,7 +94,7 @@ impl PermissionPolicy { ) -> PermissionOutcome { let current_mode = self.active_mode(); let required_mode = self.required_mode_for(tool_name); - if current_mode >= required_mode { + if current_mode == PermissionMode::Allow || current_mode >= required_mode { return PermissionOutcome::Allow; } @@ -101,8 +105,9 @@ impl PermissionPolicy { required_mode, }; - if current_mode == PermissionMode::WorkspaceWrite - && required_mode == PermissionMode::DangerFullAccess + if current_mode == PermissionMode::Prompt + || (current_mode == PermissionMode::WorkspaceWrite + && required_mode == PermissionMode::DangerFullAccess) { return match prompter.as_mut() { Some(prompter) => match prompter.decide(&request) { diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index b7b675f..7192412 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -50,8 +50,8 @@ pub struct ProjectContext { pub cwd: PathBuf, pub current_date: String, pub git_status: Option, + pub git_diff: Option, pub instruction_files: Vec, - pub memory_files: Vec, } impl ProjectContext { @@ -61,13 +61,12 @@ impl ProjectContext { ) -> std::io::Result { let cwd = cwd.into(); let instruction_files = discover_instruction_files(&cwd)?; - let memory_files = discover_memory_files(&cwd)?; Ok(Self { cwd, current_date: current_date.into(), git_status: None, + git_diff: None, instruction_files, - memory_files, }) } @@ -77,6 +76,7 @@ impl ProjectContext { ) -> std::io::Result { let mut context = Self::discover(cwd, current_date)?; context.git_status = read_git_status(&context.cwd); + context.git_diff = read_git_diff(&context.cwd); Ok(context) } } @@ -147,9 +147,6 @@ impl SystemPromptBuilder { if !project_context.instruction_files.is_empty() { sections.push(render_instruction_files(&project_context.instruction_files)); } - if !project_context.memory_files.is_empty() { - sections.push(render_memory_files(&project_context.memory_files)); - } } if let Some(config) = &self.config { sections.push(render_config_section(config)); @@ -192,7 +189,7 @@ pub fn prepend_bullets(items: Vec) -> Vec { items.into_iter().map(|item| format!(" - {item}")).collect() } -fn discover_context_directories(cwd: &Path) -> Vec { +fn discover_instruction_files(cwd: &Path) -> std::io::Result> { let mut directories = Vec::new(); let mut cursor = Some(cwd); while let Some(dir) = cursor { @@ -200,11 +197,6 @@ fn discover_context_directories(cwd: &Path) -> Vec { cursor = dir.parent(); } directories.reverse(); - directories -} - -fn discover_instruction_files(cwd: &Path) -> std::io::Result> { - let directories = discover_context_directories(cwd); let mut files = Vec::new(); for dir in directories { @@ -220,26 +212,6 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { Ok(dedupe_instruction_files(files)) } -fn discover_memory_files(cwd: &Path) -> std::io::Result> { - let mut files = Vec::new(); - for dir in discover_context_directories(cwd) { - let memory_dir = dir.join(".claude").join("memory"); - let Ok(entries) = fs::read_dir(&memory_dir) else { - continue; - }; - let mut paths = entries - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.is_file()) - .collect::>(); - paths.sort(); - for path in paths { - push_context_file(&mut files, path)?; - } - } - Ok(dedupe_instruction_files(files)) -} - fn push_context_file(files: &mut Vec, path: PathBuf) -> std::io::Result<()> { match fs::read_to_string(&path) { Ok(content) if !content.trim().is_empty() => { @@ -270,6 +242,38 @@ fn read_git_status(cwd: &Path) -> Option { } } +fn read_git_diff(cwd: &Path) -> Option { + let mut sections = Vec::new(); + + let staged = read_git_output(cwd, &["diff", "--cached"])?; + if !staged.trim().is_empty() { + sections.push(format!("Staged changes:\n{}", staged.trim_end())); + } + + let unstaged = read_git_output(cwd, &["diff"])?; + if !unstaged.trim().is_empty() { + sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end())); + } + + if sections.is_empty() { + None + } else { + Some(sections.join("\n\n")) + } +} + +fn read_git_output(cwd: &Path, args: &[&str]) -> Option { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + fn render_project_context(project_context: &ProjectContext) -> String { let mut lines = vec!["# Project context".to_string()]; let mut bullets = vec![ @@ -282,31 +286,22 @@ fn render_project_context(project_context: &ProjectContext) -> String { project_context.instruction_files.len() )); } - if !project_context.memory_files.is_empty() { - bullets.push(format!( - "Project memory files discovered: {}.", - project_context.memory_files.len() - )); - } lines.extend(prepend_bullets(bullets)); if let Some(status) = &project_context.git_status { lines.push(String::new()); lines.push("Git status snapshot:".to_string()); lines.push(status.clone()); } + if let Some(diff) = &project_context.git_diff { + lines.push(String::new()); + lines.push("Git diff snapshot:".to_string()); + lines.push(diff.clone()); + } lines.join("\n") } fn render_instruction_files(files: &[ContextFile]) -> String { - render_context_file_section("# Claude instructions", files) -} - -fn render_memory_files(files: &[ContextFile]) -> String { - render_context_file_section("# Project memory", files) -} - -fn render_context_file_section(title: &str, files: &[ContextFile]) -> String { - let mut sections = vec![title.to_string()]; + let mut sections = vec!["# Claude instructions".to_string()]; let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS; for file in files { if remaining_chars == 0 { @@ -498,9 +493,8 @@ fn get_actions_section() -> String { mod tests { use super::{ collapse_blank_lines, display_context_path, normalize_instruction_content, - render_instruction_content, render_instruction_files, render_memory_files, - truncate_instruction_content, ContextFile, ProjectContext, SystemPromptBuilder, - SYSTEM_PROMPT_DYNAMIC_BOUNDARY, + render_instruction_content, render_instruction_files, truncate_instruction_content, + ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, }; use crate::config::ConfigLoader; use std::fs; @@ -565,35 +559,6 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } - #[test] - fn discovers_project_memory_files_from_ancestor_chain() { - let root = temp_dir(); - let nested = root.join("apps").join("api"); - fs::create_dir_all(root.join(".claude").join("memory")).expect("root memory dir"); - fs::create_dir_all(nested.join(".claude").join("memory")).expect("nested memory dir"); - fs::write( - root.join(".claude").join("memory").join("2026-03-30.md"), - "root memory", - ) - .expect("write root memory"); - fs::write( - nested.join(".claude").join("memory").join("2026-03-31.md"), - "nested memory", - ) - .expect("write nested memory"); - - let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load"); - let contents = context - .memory_files - .iter() - .map(|file| file.content.as_str()) - .collect::>(); - - assert_eq!(contents, vec!["root memory", "nested memory"]); - assert!(render_memory_files(&context.memory_files).contains("# Project memory")); - fs::remove_dir_all(root).expect("cleanup temp dir"); - } - #[test] fn dedupes_identical_instruction_content_across_scopes() { let root = temp_dir(); @@ -652,6 +617,49 @@ mod tests { assert!(status.contains("## No commits yet on") || status.contains("## ")); assert!(status.contains("?? CLAUDE.md")); assert!(status.contains("?? tracked.txt")); + assert!(context.git_diff.is_none()); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn discover_with_git_includes_diff_snapshot_for_tracked_changes() { + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(&root) + .status() + .expect("git init should run"); + std::process::Command::new("git") + .args(["config", "user.email", "tests@example.com"]) + .current_dir(&root) + .status() + .expect("git config email should run"); + std::process::Command::new("git") + .args(["config", "user.name", "Runtime Prompt Tests"]) + .current_dir(&root) + .status() + .expect("git config name should run"); + fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked file"); + std::process::Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(&root) + .status() + .expect("git add should run"); + std::process::Command::new("git") + .args(["commit", "-m", "init", "--quiet"]) + .current_dir(&root) + .status() + .expect("git commit should run"); + fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("rewrite tracked file"); + + let context = + ProjectContext::discover_with_git(&root, "2026-03-31").expect("context should load"); + + let diff = context.git_diff.expect("git diff should be present"); + assert!(diff.contains("Unstaged changes:")); + assert!(diff.contains("tracked.txt")); fs::remove_dir_all(root).expect("cleanup temp dir"); } diff --git a/rust/crates/runtime/src/sandbox.rs b/rust/crates/runtime/src/sandbox.rs new file mode 100644 index 0000000..3d834ed --- /dev/null +++ b/rust/crates/runtime/src/sandbox.rs @@ -0,0 +1,364 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum FilesystemIsolationMode { + Off, + #[default] + WorkspaceOnly, + AllowList, +} + +impl FilesystemIsolationMode { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Off => "off", + Self::WorkspaceOnly => "workspace-only", + Self::AllowList => "allow-list", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SandboxConfig { + pub enabled: Option, + pub namespace_restrictions: Option, + pub network_isolation: Option, + pub filesystem_mode: Option, + pub allowed_mounts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SandboxRequest { + pub enabled: bool, + pub namespace_restrictions: bool, + pub network_isolation: bool, + pub filesystem_mode: FilesystemIsolationMode, + pub allowed_mounts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ContainerEnvironment { + pub in_container: bool, + pub markers: Vec, +} + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SandboxStatus { + pub enabled: bool, + pub requested: SandboxRequest, + pub supported: bool, + pub active: bool, + pub namespace_supported: bool, + pub namespace_active: bool, + pub network_supported: bool, + pub network_active: bool, + pub filesystem_mode: FilesystemIsolationMode, + pub filesystem_active: bool, + pub allowed_mounts: Vec, + pub in_container: bool, + pub container_markers: Vec, + pub fallback_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxDetectionInputs<'a> { + pub env_pairs: Vec<(String, String)>, + pub dockerenv_exists: bool, + pub containerenv_exists: bool, + pub proc_1_cgroup: Option<&'a str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinuxSandboxCommand { + pub program: String, + pub args: Vec, + pub env: Vec<(String, String)>, +} + +impl SandboxConfig { + #[must_use] + pub fn resolve_request( + &self, + enabled_override: Option, + namespace_override: Option, + network_override: Option, + filesystem_mode_override: Option, + allowed_mounts_override: Option>, + ) -> SandboxRequest { + SandboxRequest { + enabled: enabled_override.unwrap_or(self.enabled.unwrap_or(true)), + namespace_restrictions: namespace_override + .unwrap_or(self.namespace_restrictions.unwrap_or(true)), + network_isolation: network_override.unwrap_or(self.network_isolation.unwrap_or(false)), + filesystem_mode: filesystem_mode_override + .or(self.filesystem_mode) + .unwrap_or_default(), + allowed_mounts: allowed_mounts_override.unwrap_or_else(|| self.allowed_mounts.clone()), + } + } +} + +#[must_use] +pub fn detect_container_environment() -> ContainerEnvironment { + let proc_1_cgroup = fs::read_to_string("/proc/1/cgroup").ok(); + detect_container_environment_from(SandboxDetectionInputs { + env_pairs: env::vars().collect(), + dockerenv_exists: Path::new("/.dockerenv").exists(), + containerenv_exists: Path::new("/run/.containerenv").exists(), + proc_1_cgroup: proc_1_cgroup.as_deref(), + }) +} + +#[must_use] +pub fn detect_container_environment_from( + inputs: SandboxDetectionInputs<'_>, +) -> ContainerEnvironment { + let mut markers = Vec::new(); + if inputs.dockerenv_exists { + markers.push("/.dockerenv".to_string()); + } + if inputs.containerenv_exists { + markers.push("/run/.containerenv".to_string()); + } + for (key, value) in inputs.env_pairs { + let normalized = key.to_ascii_lowercase(); + if matches!( + normalized.as_str(), + "container" | "docker" | "podman" | "kubernetes_service_host" + ) && !value.is_empty() + { + markers.push(format!("env:{key}={value}")); + } + } + if let Some(cgroup) = inputs.proc_1_cgroup { + for needle in ["docker", "containerd", "kubepods", "podman", "libpod"] { + if cgroup.contains(needle) { + markers.push(format!("/proc/1/cgroup:{needle}")); + } + } + } + markers.sort(); + markers.dedup(); + ContainerEnvironment { + in_container: !markers.is_empty(), + markers, + } +} + +#[must_use] +pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStatus { + let request = config.resolve_request(None, None, None, None, None); + resolve_sandbox_status_for_request(&request, cwd) +} + +#[must_use] +pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus { + let container = detect_container_environment(); + let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare"); + let network_supported = namespace_supported; + let filesystem_active = + request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off; + let mut fallback_reasons = Vec::new(); + + if request.enabled && request.namespace_restrictions && !namespace_supported { + fallback_reasons + .push("namespace isolation unavailable (requires Linux with `unshare`)".to_string()); + } + if request.enabled && request.network_isolation && !network_supported { + fallback_reasons + .push("network isolation unavailable (requires Linux with `unshare`)".to_string()); + } + if request.enabled + && request.filesystem_mode == FilesystemIsolationMode::AllowList + && request.allowed_mounts.is_empty() + { + fallback_reasons + .push("filesystem allow-list requested without configured mounts".to_string()); + } + + let active = request.enabled + && (!request.namespace_restrictions || namespace_supported) + && (!request.network_isolation || network_supported); + + let allowed_mounts = normalize_mounts(&request.allowed_mounts, cwd); + + SandboxStatus { + enabled: request.enabled, + requested: request.clone(), + supported: namespace_supported, + active, + namespace_supported, + namespace_active: request.enabled && request.namespace_restrictions && namespace_supported, + network_supported, + network_active: request.enabled && request.network_isolation && network_supported, + filesystem_mode: request.filesystem_mode, + filesystem_active, + allowed_mounts, + in_container: container.in_container, + container_markers: container.markers, + fallback_reason: (!fallback_reasons.is_empty()).then(|| fallback_reasons.join("; ")), + } +} + +#[must_use] +pub fn build_linux_sandbox_command( + command: &str, + cwd: &Path, + status: &SandboxStatus, +) -> Option { + if !cfg!(target_os = "linux") + || !status.enabled + || (!status.namespace_active && !status.network_active) + { + return None; + } + + let mut args = vec![ + "--user".to_string(), + "--map-root-user".to_string(), + "--mount".to_string(), + "--ipc".to_string(), + "--pid".to_string(), + "--uts".to_string(), + "--fork".to_string(), + ]; + if status.network_active { + args.push("--net".to_string()); + } + args.push("sh".to_string()); + args.push("-lc".to_string()); + args.push(command.to_string()); + + let sandbox_home = cwd.join(".sandbox-home"); + let sandbox_tmp = cwd.join(".sandbox-tmp"); + let mut env = vec![ + ("HOME".to_string(), sandbox_home.display().to_string()), + ("TMPDIR".to_string(), sandbox_tmp.display().to_string()), + ( + "CLAWD_SANDBOX_FILESYSTEM_MODE".to_string(), + status.filesystem_mode.as_str().to_string(), + ), + ( + "CLAWD_SANDBOX_ALLOWED_MOUNTS".to_string(), + status.allowed_mounts.join(":"), + ), + ]; + if let Ok(path) = env::var("PATH") { + env.push(("PATH".to_string(), path)); + } + + Some(LinuxSandboxCommand { + program: "unshare".to_string(), + args, + env, + }) +} + +fn normalize_mounts(mounts: &[String], cwd: &Path) -> Vec { + let cwd = cwd.to_path_buf(); + mounts + .iter() + .map(|mount| { + let path = PathBuf::from(mount); + if path.is_absolute() { + path + } else { + cwd.join(path) + } + }) + .map(|path| path.display().to_string()) + .collect() +} + +fn command_exists(command: &str) -> bool { + env::var_os("PATH") + .is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists())) +} + +#[cfg(test)] +mod tests { + use super::{ + build_linux_sandbox_command, detect_container_environment_from, FilesystemIsolationMode, + SandboxConfig, SandboxDetectionInputs, + }; + use std::path::Path; + + #[test] + fn detects_container_markers_from_multiple_sources() { + let detected = detect_container_environment_from(SandboxDetectionInputs { + env_pairs: vec![("container".to_string(), "docker".to_string())], + dockerenv_exists: true, + containerenv_exists: false, + proc_1_cgroup: Some("12:memory:/docker/abc"), + }); + + assert!(detected.in_container); + assert!(detected + .markers + .iter() + .any(|marker| marker == "/.dockerenv")); + assert!(detected + .markers + .iter() + .any(|marker| marker == "env:container=docker")); + assert!(detected + .markers + .iter() + .any(|marker| marker == "/proc/1/cgroup:docker")); + } + + #[test] + fn resolves_request_with_overrides() { + let config = SandboxConfig { + enabled: Some(true), + namespace_restrictions: Some(true), + network_isolation: Some(false), + filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly), + allowed_mounts: vec!["logs".to_string()], + }; + + let request = config.resolve_request( + Some(true), + Some(false), + Some(true), + Some(FilesystemIsolationMode::AllowList), + Some(vec!["tmp".to_string()]), + ); + + assert!(request.enabled); + assert!(!request.namespace_restrictions); + assert!(request.network_isolation); + assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList); + assert_eq!(request.allowed_mounts, vec!["tmp"]); + } + + #[test] + fn builds_linux_launcher_with_network_flag_when_requested() { + let config = SandboxConfig::default(); + let status = super::resolve_sandbox_status_for_request( + &config.resolve_request( + Some(true), + Some(true), + Some(true), + Some(FilesystemIsolationMode::WorkspaceOnly), + None, + ), + Path::new("/workspace"), + ); + + if let Some(launcher) = + build_linux_sandbox_command("printf hi", Path::new("/workspace"), &status) + { + assert_eq!(launcher.program, "unshare"); + assert!(launcher.args.iter().any(|arg| arg == "--mount")); + assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network_active); + } + } +} diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index bc2b06e..beaa435 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -19,10 +19,6 @@ pub enum ContentBlock { Text { text: String, }, - Thinking { - text: String, - signature: Option, - }, ToolUse { id: String, name: String, @@ -43,19 +39,10 @@ pub struct ConversationMessage { pub usage: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionMetadata { - pub started_at: String, - pub model: String, - pub message_count: u32, - pub last_prompt: Option, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { pub version: u32, pub messages: Vec, - pub metadata: Option, } #[derive(Debug)] @@ -95,7 +82,6 @@ impl Session { Self { version: 1, messages: Vec::new(), - metadata: None, } } @@ -125,9 +111,6 @@ impl Session { .collect(), ), ); - if let Some(metadata) = &self.metadata { - object.insert("metadata".to_string(), metadata.to_json()); - } JsonValue::Object(object) } @@ -148,15 +131,7 @@ impl Session { .iter() .map(ConversationMessage::from_json) .collect::, _>>()?; - let metadata = object - .get("metadata") - .map(SessionMetadata::from_json) - .transpose()?; - Ok(Self { - version, - messages, - metadata, - }) + Ok(Self { version, messages }) } } @@ -166,41 +141,6 @@ impl Default for Session { } } -impl SessionMetadata { - #[must_use] - pub fn to_json(&self) -> JsonValue { - let mut object = BTreeMap::new(); - object.insert( - "started_at".to_string(), - JsonValue::String(self.started_at.clone()), - ); - object.insert("model".to_string(), JsonValue::String(self.model.clone())); - object.insert( - "message_count".to_string(), - JsonValue::Number(i64::from(self.message_count)), - ); - if let Some(last_prompt) = &self.last_prompt { - object.insert( - "last_prompt".to_string(), - JsonValue::String(last_prompt.clone()), - ); - } - JsonValue::Object(object) - } - - fn from_json(value: &JsonValue) -> Result { - let object = value.as_object().ok_or_else(|| { - SessionError::Format("session metadata must be an object".to_string()) - })?; - Ok(Self { - started_at: required_string(object, "started_at")?, - model: required_string(object, "model")?, - message_count: required_u32(object, "message_count")?, - last_prompt: optional_string(object, "last_prompt"), - }) - } -} - impl ConversationMessage { #[must_use] pub fn user_text(text: impl Into) -> Self { @@ -317,19 +257,6 @@ impl ContentBlock { object.insert("type".to_string(), JsonValue::String("text".to_string())); object.insert("text".to_string(), JsonValue::String(text.clone())); } - Self::Thinking { text, signature } => { - object.insert( - "type".to_string(), - JsonValue::String("thinking".to_string()), - ); - object.insert("text".to_string(), JsonValue::String(text.clone())); - if let Some(signature) = signature { - object.insert( - "signature".to_string(), - JsonValue::String(signature.clone()), - ); - } - } Self::ToolUse { id, name, input } => { object.insert( "type".to_string(), @@ -376,13 +303,6 @@ impl ContentBlock { "text" => Ok(Self::Text { text: required_string(object, "text")?, }), - "thinking" => Ok(Self::Thinking { - text: required_string(object, "text")?, - signature: object - .get("signature") - .and_then(JsonValue::as_str) - .map(ToOwned::to_owned), - }), "tool_use" => Ok(Self::ToolUse { id: required_string(object, "id")?, name: required_string(object, "name")?, @@ -448,13 +368,6 @@ fn required_string( .ok_or_else(|| SessionError::Format(format!("missing {key}"))) } -fn optional_string(object: &BTreeMap, key: &str) -> Option { - object - .get(key) - .and_then(JsonValue::as_str) - .map(ToOwned::to_owned) -} - fn required_u32(object: &BTreeMap, key: &str) -> Result { let value = object .get(key) @@ -465,8 +378,7 @@ fn required_u32(object: &BTreeMap, key: &str) -> Result Self { Self { - enabled: true, - heading: Color::Blue, - emphasis: Color::Blue, + heading: Color::Cyan, + emphasis: Color::Magenta, strong: Color::Yellow, inline_code: Color::Green, link: Color::Blue, quote: Color::DarkGrey, - info: Color::Blue, - warning: Color::Yellow, - success: Color::Green, - error: Color::Red, spinner_active: Color::Blue, spinner_done: Color::Green, spinner_failed: Color::Red, @@ -52,21 +42,6 @@ impl Default for ColorTheme { } } -impl ColorTheme { - #[must_use] - pub fn without_color() -> Self { - Self { - enabled: false, - ..Self::default() - } - } - - #[must_use] - pub fn enabled(&self) -> bool { - self.enabled - } -} - #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Spinner { frame_index: usize, @@ -92,19 +67,12 @@ impl Spinner { out, SavePosition, MoveToColumn(0), - Clear(ClearType::CurrentLine) + Clear(ClearType::CurrentLine), + SetForegroundColor(theme.spinner_active), + Print(format!("{frame} {label}")), + ResetColor, + RestorePosition )?; - if theme.enabled() { - queue!( - out, - SetForegroundColor(theme.spinner_active), - Print(format!("{frame} {label}")), - ResetColor, - RestorePosition - )?; - } else { - queue!(out, Print(format!("{frame} {label}")), RestorePosition)?; - } out.flush() } @@ -115,17 +83,14 @@ impl Spinner { out: &mut impl Write, ) -> io::Result<()> { self.frame_index = 0; - execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?; - if theme.enabled() { - execute!( - out, - SetForegroundColor(theme.spinner_done), - Print(format!("✔ {label}\n")), - ResetColor - )?; - } else { - execute!(out, Print(format!("✔ {label}\n")))?; - } + execute!( + out, + MoveToColumn(0), + Clear(ClearType::CurrentLine), + SetForegroundColor(theme.spinner_done), + Print(format!("✔ {label}\n")), + ResetColor + )?; out.flush() } @@ -136,17 +101,14 @@ impl Spinner { out: &mut impl Write, ) -> io::Result<()> { self.frame_index = 0; - execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?; - if theme.enabled() { - execute!( - out, - SetForegroundColor(theme.spinner_failed), - Print(format!("✘ {label}\n")), - ResetColor - )?; - } else { - execute!(out, Print(format!("✘ {label}\n")))?; - } + execute!( + out, + MoveToColumn(0), + Clear(ClearType::CurrentLine), + SetForegroundColor(theme.spinner_failed), + Print(format!("✘ {label}\n")), + ResetColor + )?; out.flush() } } @@ -161,9 +123,6 @@ struct RenderState { impl RenderState { fn style_text(&self, text: &str, theme: &ColorTheme) -> String { - if !theme.enabled() { - return text.to_string(); - } if self.strong > 0 { format!("{}", text.bold().with(theme.strong)) } else if self.emphasis > 0 { @@ -204,70 +163,11 @@ impl TerminalRenderer { Self::default() } - #[must_use] - pub fn with_color(enabled: bool) -> Self { - if enabled { - Self::new() - } else { - Self { - color_theme: ColorTheme::without_color(), - ..Self::default() - } - } - } - #[must_use] pub fn color_theme(&self) -> &ColorTheme { &self.color_theme } - fn paint(&self, text: impl AsRef, color: Color) -> String { - let text = text.as_ref(); - if self.color_theme.enabled() { - format!("{}", text.with(color)) - } else { - text.to_string() - } - } - - fn paint_bold(&self, text: impl AsRef, color: Color) -> String { - let text = text.as_ref(); - if self.color_theme.enabled() { - format!("{}", text.bold().with(color)) - } else { - text.to_string() - } - } - - fn paint_underlined(&self, text: impl AsRef, color: Color) -> String { - let text = text.as_ref(); - if self.color_theme.enabled() { - format!("{}", text.underlined().with(color)) - } else { - text.to_string() - } - } - - #[must_use] - pub fn info(&self, text: impl AsRef) -> String { - self.paint(text, self.color_theme.info) - } - - #[must_use] - pub fn warning(&self, text: impl AsRef) -> String { - self.paint(text, self.color_theme.warning) - } - - #[must_use] - pub fn success(&self, text: impl AsRef) -> String { - self.paint(text, self.color_theme.success) - } - - #[must_use] - pub fn error(&self, text: impl AsRef) -> String { - self.paint(text, self.color_theme.error) - } - #[must_use] pub fn render_markdown(&self, markdown: &str) -> String { let mut output = String::new(); @@ -335,7 +235,7 @@ impl TerminalRenderer { let _ = write!( output, "{}", - self.paint(format!("`{code}`"), self.color_theme.inline_code) + format!("`{code}`").with(self.color_theme.inline_code) ); } Event::Rule => output.push_str("---\n"), @@ -352,14 +252,16 @@ impl TerminalRenderer { let _ = write!( output, "{}", - self.paint_underlined(format!("[{dest_url}]"), self.color_theme.link) + format!("[{dest_url}]") + .underlined() + .with(self.color_theme.link) ); } Event::Start(Tag::Image { dest_url, .. }) => { let _ = write!( output, "{}", - self.paint(format!("[image:{dest_url}]"), self.color_theme.link) + format!("[image:{dest_url}]").with(self.color_theme.link) ); } Event::Start( @@ -392,16 +294,12 @@ impl TerminalRenderer { 3 => "### ", _ => "#### ", }; - let _ = write!( - output, - "{}", - self.paint_bold(prefix, self.color_theme.heading) - ); + let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading)); } fn start_quote(&self, state: &mut RenderState, output: &mut String) { state.quote += 1; - let _ = write!(output, "{}", self.paint("│ ", self.color_theme.quote)); + let _ = write!(output, "{}", "│ ".with(self.color_theme.quote)); } fn start_item(state: &RenderState, output: &mut String) { @@ -414,7 +312,7 @@ impl TerminalRenderer { let _ = writeln!( output, "{}", - self.paint(format!("╭─ {code_language}"), self.color_theme.heading) + format!("╭─ {code_language}").with(self.color_theme.heading) ); } } @@ -422,7 +320,7 @@ impl TerminalRenderer { fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) { output.push_str(&self.highlight_code(code_buffer, code_language)); if !code_language.is_empty() { - let _ = write!(output, "{}", self.paint("╰─", self.color_theme.heading)); + let _ = write!(output, "{}", "╰─".with(self.color_theme.heading)); } output.push_str("\n\n"); } @@ -444,10 +342,6 @@ impl TerminalRenderer { #[must_use] pub fn highlight_code(&self, code: &str, language: &str) -> String { - if !self.color_theme.enabled() { - return code.to_string(); - } - let syntax = self .syntax_set .find_syntax_by_token(language) @@ -476,16 +370,6 @@ impl TerminalRenderer { } writeln!(out) } - - #[must_use] - pub fn token_usage_summary(&self, input_tokens: u64, output_tokens: u64) -> String { - format!( - "{} {} input / {} output", - self.info("Token usage:"), - input_tokens, - output_tokens - ) - } } #[cfg(test)] @@ -553,25 +437,4 @@ mod tests { let output = String::from_utf8_lossy(&out); assert!(output.contains("Working")); } - - #[test] - fn renderer_can_disable_color_output() { - let terminal_renderer = TerminalRenderer::with_color(false); - let markdown_output = terminal_renderer.render_markdown( - "# Heading\n\nThis is **bold** and `code`.\n\n```rust\nfn hi() {}\n```", - ); - - assert!(!markdown_output.contains('\u{1b}')); - assert!(markdown_output.contains("Heading")); - assert!(markdown_output.contains("fn hi() {}")); - } - - #[test] - fn token_usage_summary_uses_plain_text_without_color() { - let terminal_renderer = TerminalRenderer::with_color(false); - assert_eq!( - terminal_renderer.token_usage_summary(12, 34), - "Token usage: 12 input / 34 output" - ); - } } diff --git a/rust/crates/tools/Cargo.toml b/rust/crates/tools/Cargo.toml index dfa003d..64768f4 100644 --- a/rust/crates/tools/Cargo.toml +++ b/rust/crates/tools/Cargo.toml @@ -6,12 +6,10 @@ license.workspace = true publish.workspace = true [dependencies] -api = { path = "../api" } runtime = { path = "../runtime" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["rt-multi-thread"] } [lints] workspace = true diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index db47162..091b256 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -3,17 +3,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; -use api::{ - resolve_startup_auth_source, AnthropicClient, ContentBlockDelta, InputContentBlock, - InputMessage, MessageRequest, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, - ToolDefinition, ToolResultContentBlock, -}; use reqwest::blocking::Client; use runtime::{ - edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, - ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock, - ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, - PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput, + GrepSearchInput, PermissionMode, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -241,8 +234,7 @@ pub fn mvp_tool_specs() -> Vec { }, ToolSpec { name: "Agent", - description: - "Launch and execute a specialized child agent conversation with bounded recursion.", + description: "Launch a specialized agent task and persist its handoff metadata.", input_schema: json!({ "type": "object", "properties": { @@ -250,8 +242,7 @@ pub fn mvp_tool_specs() -> Vec { "prompt": { "type": "string" }, "subagent_type": { "type": "string" }, "name": { "type": "string" }, - "model": { "type": "string" }, - "max_depth": { "type": "integer", "minimum": 0 } + "model": { "type": "string" } }, "required": ["description", "prompt"], "additionalProperties": false @@ -588,7 +579,6 @@ struct AgentInput { subagent_type: Option, name: Option, model: Option, - max_depth: Option, } #[derive(Debug, Deserialize)] @@ -722,16 +712,6 @@ struct AgentOutput { subagent_type: Option, model: Option, status: String, - #[serde(rename = "maxDepth")] - max_depth: usize, - #[serde(rename = "depth")] - depth: usize, - #[serde(rename = "result")] - result: Option, - #[serde(rename = "assistantMessages")] - assistant_messages: Vec, - #[serde(rename = "toolResults")] - tool_results: Vec, #[serde(rename = "outputFile")] output_file: String, #[serde(rename = "manifestFile")] @@ -740,15 +720,6 @@ struct AgentOutput { created_at: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct AgentToolResult { - #[serde(rename = "toolName")] - tool_name: String, - output: String, - #[serde(rename = "isError")] - is_error: bool, -} - #[derive(Debug, Serialize)] struct ToolSearchOutput { matches: Vec, @@ -1228,9 +1199,10 @@ fn execute_todo_write(input: TodoWriteInput) -> Result validate_todos(&input.todos)?; let store_path = todo_store_path()?; let old_todos = if store_path.exists() { - parse_todo_markdown( + serde_json::from_str::>( &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?, - )? + ) + .map_err(|error| error.to_string())? } else { Vec::new() }; @@ -1248,8 +1220,11 @@ fn execute_todo_write(input: TodoWriteInput) -> Result if let Some(parent) = store_path.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - std::fs::write(&store_path, render_todo_markdown(&persisted)) - .map_err(|error| error.to_string())?; + std::fs::write( + &store_path, + serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; let verification_nudge_needed = (all_done && input.todos.len() >= 3 @@ -1307,58 +1282,7 @@ fn todo_store_path() -> Result { return Ok(std::path::PathBuf::from(path)); } let cwd = std::env::current_dir().map_err(|error| error.to_string())?; - Ok(cwd.join(".claude").join("todos.md")) -} - -fn render_todo_markdown(todos: &[TodoItem]) -> String { - let mut lines = vec!["# Todo list".to_string(), String::new()]; - for todo in todos { - let marker = match todo.status { - TodoStatus::Pending => "[ ]", - TodoStatus::InProgress => "[~]", - TodoStatus::Completed => "[x]", - }; - lines.push(format!( - "- {marker} {} :: {}", - todo.content, todo.active_form - )); - } - lines.push(String::new()); - lines.join("\n") -} - -fn parse_todo_markdown(content: &str) -> Result, String> { - let mut todos = Vec::new(); - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - let Some(rest) = trimmed.strip_prefix("- [") else { - continue; - }; - let mut chars = rest.chars(); - let status = match chars.next() { - Some(' ') => TodoStatus::Pending, - Some('~') => TodoStatus::InProgress, - Some('x' | 'X') => TodoStatus::Completed, - Some(other) => return Err(format!("unsupported todo status marker: {other}")), - None => return Err(String::from("malformed todo line")), - }; - let remainder = chars.as_str(); - let Some(body) = remainder.strip_prefix("] ") else { - return Err(String::from("malformed todo line")); - }; - let Some((content, active_form)) = body.split_once(" :: ") else { - return Err(String::from("todo line missing active form separator")); - }; - todos.push(TodoItem { - content: content.trim().to_string(), - active_form: active_form.trim().to_string(), - status, - }); - } - Ok(todos) + Ok(cwd.join(".clawd-todos.json")) } fn resolve_skill_path(skill: &str) -> Result { @@ -1407,14 +1331,6 @@ fn execute_agent(input: AgentInput) -> Result { return Err(String::from("prompt must not be empty")); } - let depth = current_agent_depth()?; - let max_depth = input.max_depth.unwrap_or(3); - if depth >= max_depth { - return Err(format!( - "Agent max_depth exceeded: current depth {depth} reached limit {max_depth}" - )); - } - let agent_id = make_agent_id(); let output_dir = agent_store_dir()?; std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; @@ -1428,31 +1344,35 @@ fn execute_agent(input: AgentInput) -> Result { .filter(|name| !name.is_empty()) .unwrap_or_else(|| slugify_agent_name(&input.description)); let created_at = iso8601_now(); - let model = input.model.clone().or_else(agent_default_model); - let child_result = with_agent_depth(depth + 1, || { - run_child_agent_conversation(&input.prompt, model.clone(), max_depth) - })?; + let output_contents = format!( + "# Agent Task + +- id: {} +- name: {} +- description: {} +- subagent_type: {} +- created_at: {} + +## Prompt + +{} +", + agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt + ); + std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?; let manifest = AgentOutput { agent_id, name: agent_name, description: input.description, subagent_type: Some(normalized_subagent_type), - model, - status: String::from("completed"), - max_depth, - depth, - result: child_result.result.clone(), - assistant_messages: child_result.assistant_messages.clone(), - tool_results: child_result.tool_results.clone(), + model: input.model, + status: String::from("queued"), output_file: output_file.display().to_string(), manifest_file: manifest_file.display().to_string(), created_at, }; - - let output_contents = render_agent_output(&manifest); - std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?; std::fs::write( &manifest_file, serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?, @@ -1462,466 +1382,6 @@ fn execute_agent(input: AgentInput) -> Result { Ok(manifest) } -#[derive(Debug, Clone)] -struct ChildConversationResult { - result: Option, - assistant_messages: Vec, - tool_results: Vec, -} - -fn run_child_agent_conversation( - prompt: &str, - model: Option, - _max_depth: usize, -) -> Result { - let mut runtime = ConversationRuntime::new( - Session::new(), - build_agent_api_client(model.unwrap_or_else(default_agent_model))?, - AgentToolExecutor, - agent_permission_policy(), - build_agent_system_prompt()?, - ) - .with_max_iterations(16); - - let summary = runtime - .run_turn(prompt, None) - .map_err(|error| error.to_string())?; - - let assistant_messages = summary - .assistant_messages - .iter() - .filter_map(extract_message_text) - .collect::>(); - let tool_results = summary - .tool_results - .iter() - .filter_map(extract_agent_tool_result) - .collect::>(); - let result = assistant_messages.last().cloned(); - - Ok(ChildConversationResult { - result, - assistant_messages, - tool_results, - }) -} - -fn render_agent_output(output: &AgentOutput) -> String { - let mut lines = vec![ - "# Agent Task".to_string(), - String::new(), - format!("- id: {}", output.agent_id), - format!("- name: {}", output.name), - format!("- description: {}", output.description), - format!( - "- subagent_type: {}", - output.subagent_type.as_deref().unwrap_or("general-purpose") - ), - format!("- status: {}", output.status), - format!("- depth: {}", output.depth), - format!("- max_depth: {}", output.max_depth), - format!("- created_at: {}", output.created_at), - String::new(), - "## Result".to_string(), - String::new(), - output - .result - .clone() - .unwrap_or_else(|| String::from("")), - ]; - - if !output.tool_results.is_empty() { - lines.push(String::new()); - lines.push("## Tool Results".to_string()); - lines.push(String::new()); - lines.extend(output.tool_results.iter().map(|result| { - format!( - "- {} [{}]: {}", - result.tool_name, - if result.is_error { "error" } else { "ok" }, - result.output - ) - })); - } - - lines.join("\n") -} - -fn current_agent_depth() -> Result { - std::env::var("CLAWD_AGENT_DEPTH") - .ok() - .map(|value| { - value - .parse::() - .map_err(|error| format!("invalid CLAWD_AGENT_DEPTH: {error}")) - }) - .transpose() - .map(|value| value.unwrap_or(0)) -} - -fn with_agent_depth(depth: usize, f: impl FnOnce() -> Result) -> Result { - let previous = std::env::var("CLAWD_AGENT_DEPTH").ok(); - std::env::set_var("CLAWD_AGENT_DEPTH", depth.to_string()); - let result = f(); - if let Some(previous) = previous { - std::env::set_var("CLAWD_AGENT_DEPTH", previous); - } else { - std::env::remove_var("CLAWD_AGENT_DEPTH"); - } - result -} - -fn agent_default_model() -> Option { - std::env::var("CLAWD_MODEL") - .ok() - .filter(|value| !value.trim().is_empty()) -} - -fn default_agent_model() -> String { - agent_default_model().unwrap_or_else(|| String::from("claude-sonnet-4-20250514")) -} - -fn build_agent_system_prompt() -> Result, String> { - let cwd = std::env::current_dir().map_err(|error| error.to_string())?; - let date = std::env::var("CLAWD_CURRENT_DATE").unwrap_or_else(|_| String::from("2026-04-01")); - load_system_prompt(cwd, &date, std::env::consts::OS, "unknown") - .map_err(|error| error.to_string()) -} - -fn agent_permission_policy() -> PermissionPolicy { - mvp_tool_specs().into_iter().fold( - PermissionPolicy::new(PermissionMode::DangerFullAccess), - |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission), - ) -} - -struct AgentToolExecutor; - -impl ToolExecutor for AgentToolExecutor { - fn execute(&mut self, tool_name: &str, input: &str) -> Result { - let value = serde_json::from_str(input) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - execute_tool(tool_name, &value).map_err(ToolError::new) - } -} - -enum AgentApiClient { - Scripted(ScriptedAgentApiClient), - Anthropic(AnthropicAgentApiClient), -} - -impl ApiClient for AgentApiClient { - fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { - match self { - Self::Scripted(client) => client.stream(request), - Self::Anthropic(client) => client.stream(request), - } - } -} - -fn build_agent_api_client(model: String) -> Result { - if let Some(script) = std::env::var("CLAWD_AGENT_TEST_SCRIPT") - .ok() - .filter(|value| !value.trim().is_empty()) - { - return Ok(AgentApiClient::Scripted(ScriptedAgentApiClient::new( - &script, - )?)); - } - - Ok(AgentApiClient::Anthropic(AnthropicAgentApiClient::new( - model, - )?)) -} - -struct AnthropicAgentApiClient { - runtime: tokio::runtime::Runtime, - client: AnthropicClient, - model: String, -} - -impl AnthropicAgentApiClient { - fn new(model: String) -> Result { - Ok(Self { - runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?, - client: AnthropicClient::from_auth(resolve_agent_auth_source()?), - model, - }) - } -} - -impl ApiClient for AnthropicAgentApiClient { - fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { - let message_request = MessageRequest { - model: self.model.clone(), - max_tokens: 32, - messages: convert_agent_messages(&request.messages), - system: (!request.system_prompt.is_empty()).then(|| { - request.system_prompt.join( - " - -", - ) - }), - tools: Some(agent_tool_definitions()), - tool_choice: Some(ToolChoice::Auto), - stream: true, - thinking: None, - }; - - self.runtime.block_on(async { - let mut stream = self - .client - .stream_message(&message_request) - .await - .map_err(|error| RuntimeError::new(error.to_string()))?; - let mut events = Vec::new(); - let mut pending_tool: Option<(String, String, String)> = None; - let mut saw_stop = false; - - while let Some(event) = stream - .next_event() - .await - .map_err(|error| RuntimeError::new(error.to_string()))? - { - match event { - ApiStreamEvent::MessageStart(start) => { - push_agent_output_blocks( - start.message.content, - &mut events, - &mut pending_tool, - ); - } - ApiStreamEvent::ContentBlockStart(start) => { - push_agent_output_block( - start.content_block, - &mut events, - &mut pending_tool, - ); - } - ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { - ContentBlockDelta::TextDelta { text } => { - if !text.is_empty() { - events.push(AssistantEvent::TextDelta(text)); - } - } - ContentBlockDelta::InputJsonDelta { partial_json } => { - if let Some((_, _, input)) = &mut pending_tool { - input.push_str(&partial_json); - } - } - ContentBlockDelta::ThinkingDelta { .. } - | ContentBlockDelta::SignatureDelta { .. } => {} - }, - ApiStreamEvent::ContentBlockStop(_) => { - if let Some((id, name, input)) = pending_tool.take() { - events.push(AssistantEvent::ToolUse { id, name, input }); - } - } - ApiStreamEvent::MessageDelta(delta) => { - events.push(AssistantEvent::Usage(TokenUsage { - input_tokens: delta.usage.input_tokens, - output_tokens: delta.usage.output_tokens, - cache_creation_input_tokens: delta.usage.cache_creation_input_tokens, - cache_read_input_tokens: delta.usage.cache_read_input_tokens, - })); - } - ApiStreamEvent::MessageStop(_) => { - saw_stop = true; - events.push(AssistantEvent::MessageStop); - } - } - } - - if !saw_stop { - events.push(AssistantEvent::MessageStop); - } - - Ok(events) - }) - } -} - -fn resolve_agent_auth_source() -> Result { - resolve_startup_auth_source(|| { - let cwd = std::env::current_dir().map_err(api::ApiError::from)?; - let config = ConfigLoader::default_for(&cwd).load().map_err(|error| { - api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}")) - })?; - Ok(config.oauth().cloned()) - }) - .map_err(|error| error.to_string()) -} - -fn agent_tool_definitions() -> Vec { - mvp_tool_specs() - .into_iter() - .map(|spec| ToolDefinition { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: spec.input_schema, - }) - .collect() -} - -fn convert_agent_messages(messages: &[ConversationMessage]) -> Vec { - messages - .iter() - .filter_map(|message| { - let role = match message.role { - MessageRole::System | MessageRole::User | MessageRole::Tool => "user", - MessageRole::Assistant => "assistant", - }; - let content = message - .blocks - .iter() - .map(|block| match block { - ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, - ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { - id: id.clone(), - name: name.clone(), - input: serde_json::from_str(input) - .unwrap_or_else(|_| serde_json::json!({ "raw": input })), - }, - ContentBlock::ToolResult { - tool_use_id, - output, - is_error, - .. - } => InputContentBlock::ToolResult { - tool_use_id: tool_use_id.clone(), - content: vec![ToolResultContentBlock::Text { - text: output.clone(), - }], - is_error: *is_error, - }, - ContentBlock::Thinking { .. } => InputContentBlock::Text { text: String::new() }, - }) - .collect::>(); - (!content.is_empty()).then(|| InputMessage { - role: role.to_string(), - content, - }) - }) - .collect() -} - -fn push_agent_output_blocks( - blocks: Vec, - events: &mut Vec, - pending_tool: &mut Option<(String, String, String)>, -) { - for block in blocks { - push_agent_output_block(block, events, pending_tool); - if let Some((id, name, input)) = pending_tool.take() { - events.push(AssistantEvent::ToolUse { id, name, input }); - } - } -} - -fn push_agent_output_block( - block: OutputContentBlock, - events: &mut Vec, - pending_tool: &mut Option<(String, String, String)>, -) { - match block { - OutputContentBlock::Text { text } => { - if !text.is_empty() { - events.push(AssistantEvent::TextDelta(text)); - } - } - OutputContentBlock::ToolUse { id, name, input } => { - *pending_tool = Some((id, name, input.to_string())); - } - OutputContentBlock::Thinking { .. } => {} - } -} - -#[derive(Debug)] -struct ScriptedAgentApiClient { - turns: Vec>, - call_count: usize, -} - -impl ScriptedAgentApiClient { - fn new(script: &str) -> Result { - let turns = serde_json::from_str(script).map_err(|error| error.to_string())?; - Ok(Self { - turns, - call_count: 0, - }) - } -} - -impl ApiClient for ScriptedAgentApiClient { - fn stream(&mut self, _request: ApiRequest) -> Result, RuntimeError> { - if self.call_count >= self.turns.len() { - return Err(RuntimeError::new("scripted agent client exhausted")); - } - let events = self.turns[self.call_count] - .iter() - .map(ScriptedAgentEvent::to_runtime_event) - .chain(std::iter::once(AssistantEvent::MessageStop)) - .collect(); - self.call_count += 1; - Ok(events) - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ScriptedAgentEvent { - Text { - text: String, - }, - ToolUse { - id: String, - name: String, - input: Value, - }, -} - -impl ScriptedAgentEvent { - fn to_runtime_event(&self) -> AssistantEvent { - match self { - Self::Text { text } => AssistantEvent::TextDelta(text.clone()), - Self::ToolUse { id, name, input } => AssistantEvent::ToolUse { - id: id.clone(), - name: name.clone(), - input: input.to_string(), - }, - } - } -} - -fn extract_message_text(message: &ConversationMessage) -> Option { - let text = message - .blocks - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::(); - (!text.is_empty()).then_some(text) -} - -fn extract_agent_tool_result(message: &ConversationMessage) -> Option { - message.blocks.iter().find_map(|block| match block { - ContentBlock::ToolResult { - tool_name, - output, - is_error, - .. - } => Some(AgentToolResult { - tool_name: tool_name.clone(), - output: output.clone(), - is_error: *is_error, - }), - _ => None, - }) -} - #[allow(clippy::needless_pass_by_value)] fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput { let deferred = deferred_tool_specs(); @@ -2754,7 +2214,8 @@ fn execute_shell_command( structured_content: None, persisted_output_path: None, persisted_output_size: None, - }); + sandbox_status: None, +}); } let mut process = std::process::Command::new(shell); @@ -2791,6 +2252,7 @@ fn execute_shell_command( structured_content: None, persisted_output_path: None, persisted_output_size: None, + sandbox_status: None, }); } if started.elapsed() >= Duration::from_millis(timeout_ms) { @@ -2821,7 +2283,8 @@ Command exceeded timeout of {timeout_ms} ms", structured_content: None, persisted_output_path: None, persisted_output_size: None, - }); + sandbox_status: None, +}); } std::thread::sleep(Duration::from_millis(10)); } @@ -2847,6 +2310,7 @@ Command exceeded timeout of {timeout_ms} ms", structured_content: None, persisted_output_path: None, persisted_output_size: None, + sandbox_status: None, }) } @@ -3178,37 +2642,6 @@ mod tests { assert!(second_output["verificationNudgeNeeded"].is_null()); } - #[test] - fn todo_write_persists_markdown_in_claude_directory() { - let _guard = env_lock() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let temp = temp_path("todos-md-dir"); - std::fs::create_dir_all(&temp).expect("temp dir"); - let previous = std::env::current_dir().expect("cwd"); - std::env::set_current_dir(&temp).expect("set cwd"); - - execute_tool( - "TodoWrite", - &json!({ - "todos": [ - {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"}, - {"content": "Run tests", "activeForm": "Running tests", "status": "pending"} - ] - }), - ) - .expect("TodoWrite should succeed"); - - let persisted = std::fs::read_to_string(temp.join(".claude").join("todos.md")) - .expect("todo markdown exists"); - std::env::set_current_dir(previous).expect("restore cwd"); - let _ = std::fs::remove_dir_all(temp); - - assert!(persisted.contains("# Todo list")); - assert!(persisted.contains("- [~] Add tool :: Adding tool")); - assert!(persisted.contains("- [ ] Run tests :: Running tests")); - } - #[test] fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() { let _guard = env_lock() @@ -3334,28 +2767,12 @@ mod tests { } #[test] - fn agent_executes_child_conversation_and_persists_results() { + fn agent_persists_handoff_metadata() { let _guard = env_lock() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let dir = temp_path("agent-store"); std::env::set_var("CLAWD_AGENT_STORE", &dir); - std::env::set_var( - "CLAWD_AGENT_TEST_SCRIPT", - serde_json::to_string(&vec![ - vec![json!({ - "type": "tool_use", - "id": "tool-1", - "name": "StructuredOutput", - "input": {"ok": true, "items": [1, 2, 3]} - })], - vec![json!({ - "type": "text", - "text": "Child agent completed successfully." - })], - ]) - .expect("script json"), - ); let result = execute_tool( "Agent", @@ -3367,35 +2784,22 @@ mod tests { }), ) .expect("Agent should succeed"); - std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT"); std::env::remove_var("CLAWD_AGENT_STORE"); let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); assert_eq!(output["name"], "ship-audit"); assert_eq!(output["subagentType"], "Explore"); - assert_eq!(output["status"], "completed"); - assert_eq!(output["depth"], 0); - assert_eq!(output["maxDepth"], 3); - assert_eq!(output["result"], "Child agent completed successfully."); - assert_eq!(output["toolResults"][0]["toolName"], "StructuredOutput"); - assert_eq!(output["toolResults"][0]["isError"], false); + assert_eq!(output["status"], "queued"); + assert!(output["createdAt"].as_str().is_some()); let manifest_file = output["manifestFile"].as_str().expect("manifest file"); let output_file = output["outputFile"].as_str().expect("output file"); let contents = std::fs::read_to_string(output_file).expect("agent file exists"); let manifest_contents = std::fs::read_to_string(manifest_file).expect("manifest file exists"); - assert!(contents.contains("Child agent completed successfully.")); - assert!(contents.contains("StructuredOutput [ok]")); + assert!(contents.contains("Audit the branch")); + assert!(contents.contains("Check tests and outstanding work.")); assert!(manifest_contents.contains("\"subagentType\": \"Explore\"")); - std::env::set_var( - "CLAWD_AGENT_TEST_SCRIPT", - serde_json::to_string(&vec![vec![json!({ - "type": "text", - "text": "Normalized alias check." - })]]) - .expect("script json"), - ); let normalized = execute_tool( "Agent", &json!({ @@ -3405,19 +2809,10 @@ mod tests { }), ) .expect("Agent should normalize built-in aliases"); - std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT"); let normalized_output: serde_json::Value = serde_json::from_str(&normalized).expect("valid json"); assert_eq!(normalized_output["subagentType"], "Explore"); - std::env::set_var( - "CLAWD_AGENT_TEST_SCRIPT", - serde_json::to_string(&vec![vec![json!({ - "type": "text", - "text": "Name normalization check." - })]]) - .expect("script json"), - ); let named = execute_tool( "Agent", &json!({ @@ -3427,14 +2822,13 @@ mod tests { }), ) .expect("Agent should normalize explicit names"); - std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT"); let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json"); assert_eq!(named_output["name"], "ship-audit"); let _ = std::fs::remove_dir_all(dir); } #[test] - fn agent_rejects_blank_required_fields_and_enforces_max_depth() { + fn agent_rejects_blank_required_fields() { let missing_description = execute_tool( "Agent", &json!({ @@ -3454,22 +2848,6 @@ mod tests { ) .expect_err("blank prompt should fail"); assert!(missing_prompt.contains("prompt must not be empty")); - - let _guard = env_lock() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - std::env::set_var("CLAWD_AGENT_DEPTH", "1"); - let depth_error = execute_tool( - "Agent", - &json!({ - "description": "Nested agent", - "prompt": "Do nested work.", - "max_depth": 1 - }), - ) - .expect_err("max depth should fail"); - std::env::remove_var("CLAWD_AGENT_DEPTH"); - assert!(depth_error.contains("max_depth exceeded")); } #[test]