diff --git a/Cargo.lock b/Cargo.lock index f609181a..4ae3c21b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1775,6 +1775,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_norway", "sha2", "tempfile", "thiserror 2.0.20", @@ -3056,6 +3057,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_norway" +version = "0.9.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml-norway", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -3852,6 +3866,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unsafe-libyaml-norway" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index bddea23e..2e9d245b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ syn = { version = "2", features = ["full"] } schemars = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" +serde_norway = "0.9" sha2 = "0.10" syntect = "5" thiserror = "2.0" diff --git a/crates/merry-cli/src/apply_patch_argument.rs b/crates/merry-cli/src/apply_patch_argument.rs new file mode 100644 index 00000000..f1e89cc4 --- /dev/null +++ b/crates/merry-cli/src/apply_patch_argument.rs @@ -0,0 +1,74 @@ +//! Shared reader for the file sections an `apply_patch` argument declares. +//! +//! The call detail and the TUI projector both need to know which files a patch +//! names, so section headers are recognized in one place and both readers agree +//! about the set of operations. `merry-tools` owns the grammar that validates +//! and applies a patch; this module only presents the same headers, and it must +//! never be used to decide what a patch does. + +/// File operation an `apply_patch` section header declares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PatchArgumentSectionKind { + /// `*** Add File:` creates the file. + Add, + /// `*** Update File:` edits the file. + Update, + /// `*** Delete File:` removes the file. + Delete, +} + +/// Splits a section header into its operation and the path that follows it. +/// +/// The path is trimmed, because a header may carry trailing model whitespace. +/// Every other line returns `None`, including another `*** ...` directive such +/// as `*** Begin Patch`, so a caller cannot mistake a directive for a file. +pub(crate) fn section_header(line: &str) -> Option<(PatchArgumentSectionKind, &str)> { + [ + ("*** Add File: ", PatchArgumentSectionKind::Add), + ("*** Update File: ", PatchArgumentSectionKind::Update), + ("*** Delete File: ", PatchArgumentSectionKind::Delete), + ] + .into_iter() + .find_map(|(marker, kind)| line.strip_prefix(marker).map(|path| (kind, path.trim()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn section_headers_name_every_supported_operation() { + assert_eq!( + section_header("*** Add File: notes/new.txt"), + Some((PatchArgumentSectionKind::Add, "notes/new.txt")) + ); + assert_eq!( + section_header("*** Update File: dir/note.txt "), + Some((PatchArgumentSectionKind::Update, "dir/note.txt")) + ); + assert_eq!( + section_header("*** Delete File: obsolete.txt"), + Some((PatchArgumentSectionKind::Delete, "obsolete.txt")) + ); + } + + #[test] + fn directives_and_hunk_lines_are_not_section_headers() { + for line in [ + "*** Begin Workspace Patch", + "*** End Workspace Patch", + "*** Begin Patch", + "*** End Patch", + "*** Add File:", + "@@ -1,2 +1,3 @@", + "+added", + " context", + ] { + assert_eq!( + section_header(line), + None, + "`{line}` must not name a file section" + ); + } + } +} diff --git a/crates/merry-cli/src/cmd.rs b/crates/merry-cli/src/cmd.rs index 51f746bc..02100784 100644 --- a/crates/merry-cli/src/cmd.rs +++ b/crates/merry-cli/src/cmd.rs @@ -104,7 +104,6 @@ pub(crate) async fn run(args: &Args, merry_config: Option<&MerryConfig>) -> Resu environment: environment.clone(), provider, model, - allow_hidden_workspace_paths: false, automatic_compaction: automatic_compaction_config(merry_config).map_err(unexpected)?, retry_policy, context_compaction, @@ -190,7 +189,6 @@ pub(crate) struct RuntimeInput<'a> { pub(crate) environment: CommandGenerationEnvironment, pub(crate) provider: Arc, pub(crate) model: ModelName, - pub(crate) allow_hidden_workspace_paths: bool, pub(crate) automatic_compaction: AutomaticCompactionConfig, pub(crate) retry_policy: Option, pub(crate) context_compaction: Option, @@ -269,7 +267,6 @@ pub(crate) fn build_runtime(input: RuntimeInput<'_>) -> Result, pub(crate) automatic_compaction: AutomaticCompactionConfig, pub(crate) retry_policy: Option, @@ -37,7 +36,6 @@ pub(crate) struct HeadlessCodingRuntimeInput<'a> { pub(crate) model: ModelName, pub(crate) process_backend: ActionProcessBackend, pub(crate) extra_tools: Vec, - pub(crate) allow_hidden_workspace_paths: bool, pub(crate) automatic_compaction: AutomaticCompactionConfig, pub(crate) retry_policy: Option, pub(crate) context_compaction: Option, @@ -128,7 +126,6 @@ pub(crate) fn build_coding_runtime( model, process_backend: options.process_backend, extra_tools: options.extra_tools, - allow_hidden_workspace_paths: options.allow_hidden_workspace_paths, automatic_compaction: options.automatic_compaction, retry_policy: options.retry_policy, context_compaction: options.context_compaction, @@ -152,7 +149,6 @@ fn build_coding_runtime_from_headless_input( input.process_backend, ) .with_extra_tools(input.extra_tools) - .with_allow_hidden_workspace_paths(input.allow_hidden_workspace_paths) .with_automatic_compaction(input.automatic_compaction) .with_skill_roots(input.skill_roots) .with_subagents(input.subagents); diff --git a/crates/merry-cli/src/coding/tests.rs b/crates/merry-cli/src/coding/tests.rs index d0ff0870..23e3b25e 100644 --- a/crates/merry-cli/src/coding/tests.rs +++ b/crates/merry-cli/src/coding/tests.rs @@ -31,7 +31,6 @@ fn headless_input<'a>( permissioned_process_runner_factory, )), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, diff --git a/crates/merry-cli/src/coding/tests/composition.rs b/crates/merry-cli/src/coding/tests/composition.rs index df220cba..e697dc46 100644 --- a/crates/merry-cli/src/coding/tests/composition.rs +++ b/crates/merry-cli/src/coding/tests/composition.rs @@ -76,7 +76,6 @@ async fn headless_runtime_uses_coding_agent_profile() { permissioned_factory, )), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, @@ -150,7 +149,6 @@ async fn headless_runtime_registers_extra_tools() { runner, permissioned_factory, )), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, diff --git a/crates/merry-cli/src/coding/tests/project_rules.rs b/crates/merry-cli/src/coding/tests/project_rules.rs index 6fe394be..af0ca8e1 100644 --- a/crates/merry-cli/src/coding/tests/project_rules.rs +++ b/crates/merry-cli/src/coding/tests/project_rules.rs @@ -35,7 +35,6 @@ async fn coding_projects_root_agents_in_the_stable_prefix() { Arc::new(provider.clone()), model_name(), CodingRuntimeOptions { - allow_hidden_workspace_paths: false, approval_review: None, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, @@ -89,7 +88,6 @@ async fn coding_omits_project_rules_when_root_agents_is_missing() { Arc::new(provider.clone()), model_name(), CodingRuntimeOptions { - allow_hidden_workspace_paths: false, approval_review: None, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, diff --git a/crates/merry-cli/src/coding/tests/skills.rs b/crates/merry-cli/src/coding/tests/skills.rs index 7cbfc00f..2fe935c4 100644 --- a/crates/merry-cli/src/coding/tests/skills.rs +++ b/crates/merry-cli/src/coding/tests/skills.rs @@ -21,7 +21,7 @@ async fn projects_skill_metadata_without_body() { std::fs::create_dir_all(skill_root.join("demo")).expect("mkdir skill"); std::fs::write( skill_root.join("demo/SKILL.md"), - "---\nname: demo-skill\ndescription: Use for demo tasks.\n---\n# Demo\nbody sentinel\n", + "---\nname: demo-skill\ndescription: Use for demo tasks.\nmetadata:\n cli_version: \">=1.2.3\"\n requires:\n bins:\n - demo-cli\n---\n# Demo\nbody sentinel\n", ) .expect("write skill"); @@ -34,7 +34,6 @@ async fn projects_skill_metadata_without_body() { Arc::new(provider.clone()), model_name(), CodingRuntimeOptions { - allow_hidden_workspace_paths: false, approval_review: None, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, @@ -110,7 +109,6 @@ async fn includes_skill_roots_in_workspace_read_tools() { Arc::new(provider.clone()), model_name(), CodingRuntimeOptions { - allow_hidden_workspace_paths: false, approval_review: None, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, @@ -156,7 +154,6 @@ async fn allows_missing_default_skill_root() { Arc::new(provider.clone()), model_name(), CodingRuntimeOptions { - allow_hidden_workspace_paths: false, approval_review: None, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, diff --git a/crates/merry-cli/src/coding/tests/subagents.rs b/crates/merry-cli/src/coding/tests/subagents.rs index 86bc32d4..24e516cd 100644 --- a/crates/merry-cli/src/coding/tests/subagents.rs +++ b/crates/merry-cli/src/coding/tests/subagents.rs @@ -30,7 +30,6 @@ async fn hides_subagent_tools_by_default() { Arc::new(provider.clone()), model_name(), CodingRuntimeOptions { - allow_hidden_workspace_paths: false, approval_review: None, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, @@ -78,7 +77,6 @@ async fn exposes_subagent_tools_when_enabled() { Arc::new(provider.clone()), model_name(), CodingRuntimeOptions { - allow_hidden_workspace_paths: false, approval_review: None, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, @@ -177,7 +175,6 @@ async fn subagent_with_narrow_tools_keeps_stable_profile_and_runtime_admission() Arc::new(provider.clone()), model_name(), CodingRuntimeOptions { - allow_hidden_workspace_paths: false, approval_review: None, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, diff --git a/crates/merry-cli/src/main.rs b/crates/merry-cli/src/main.rs index b3e55c89..389d5534 100644 --- a/crates/merry-cli/src/main.rs +++ b/crates/merry-cli/src/main.rs @@ -1,5 +1,6 @@ //! Merry terminal client and headless agent entrypoint. +mod apply_patch_argument; mod cli; mod cli_error; mod cli_exit; @@ -20,6 +21,7 @@ mod runtime_events; mod sandbox; mod session_id; mod testing; +mod text; mod tool_display; mod tui; mod web; diff --git a/crates/merry-cli/src/run.rs b/crates/merry-cli/src/run.rs index 3bde0405..5a49463b 100644 --- a/crates/merry-cli/src/run.rs +++ b/crates/merry-cli/src/run.rs @@ -239,7 +239,6 @@ pub(crate) async fn run( model, process_backend: backend, extra_tools: mcp.tools, - allow_hidden_workspace_paths: false, automatic_compaction: automatic_compaction_config(merry_config).map_err(unexpected)?, retry_policy, context_compaction, diff --git a/crates/merry-cli/src/run/output_tests.rs b/crates/merry-cli/src/run/output_tests.rs index 65905972..ecbb8681 100644 --- a/crates/merry-cli/src/run/output_tests.rs +++ b/crates/merry-cli/src/run/output_tests.rs @@ -78,7 +78,6 @@ async fn writer_prints_final_output_without_event_jsonl() { provider: Arc::new(provider), model: model_name(), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, @@ -146,7 +145,6 @@ async fn writer_streams_progress_commentary_before_final_output() { provider: Arc::new(provider), model: model_name(), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, @@ -205,7 +203,6 @@ async fn jsonl_writer_streams_agent_loop_result() { provider: Arc::new(provider), model: model_name(), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, @@ -271,7 +268,6 @@ async fn writer_returns_incomplete_when_agent_loop_blocks() { provider: Arc::new(provider), model: model_name(), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, diff --git a/crates/merry-cli/src/run/persistence_tests.rs b/crates/merry-cli/src/run/persistence_tests.rs index 3f85b805..dbc211c0 100644 --- a/crates/merry-cli/src/run/persistence_tests.rs +++ b/crates/merry-cli/src/run/persistence_tests.rs @@ -113,7 +113,6 @@ fn headless_input<'a>( model: model_name(), process_backend: fake_process_backend(), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, diff --git a/crates/merry-cli/src/run/session_tests.rs b/crates/merry-cli/src/run/session_tests.rs index 43764d4d..026b3871 100644 --- a/crates/merry-cli/src/run/session_tests.rs +++ b/crates/merry-cli/src/run/session_tests.rs @@ -76,7 +76,6 @@ fn headless_input<'a>( model: model_name(), process_backend: fake_process_backend(), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: merry_runtime::AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, diff --git a/crates/merry-cli/src/text.rs b/crates/merry-cli/src/text.rs new file mode 100644 index 00000000..9632d38a --- /dev/null +++ b/crates/merry-cli/src/text.rs @@ -0,0 +1,62 @@ +//! Presentation-safe text helpers shared by the CLI surfaces. +//! +//! Model, provider, and process text reaches the terminal as written, so +//! control characters are sanitized in one place instead of once per renderer. +//! These helpers only prepare text for display; they never decide what is +//! shown or what a tool is allowed to do. + +/// Drops control characters so text stays on a single line. +pub(crate) fn without_control_chars(value: &str) -> String { + value + .chars() + .filter(|character| !character.is_control()) + .collect() +} + +/// Replaces control characters with spaces so separate words stay separate. +/// +/// A newline or tab inside a command or path otherwise joins the words on both +/// sides of it, which reads as a different value than the one that ran. +pub(crate) fn with_control_chars_as_spaces(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect() +} + +/// Drops control characters except line breaks, which keep their lines. +pub(crate) fn without_control_chars_keeping_newlines(value: &str) -> String { + value + .chars() + .filter(|character| !character.is_control() || *character == '\n') + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_policy_sanitizes_control_characters_its_own_way() { + let text = "a\tb\nc\rd\u{1b}"; + + assert_eq!(without_control_chars(text), "abcd"); + assert_eq!(with_control_chars_as_spaces(text), "a b c d "); + assert_eq!(without_control_chars_keeping_newlines(text), "ab\ncd"); + } + + #[test] + fn printable_text_is_returned_unchanged() { + let text = "正常 text with spaces and emoji 🚀"; + + assert_eq!(without_control_chars(text), text); + assert_eq!(with_control_chars_as_spaces(text), text); + assert_eq!(without_control_chars_keeping_newlines(text), text); + } +} diff --git a/crates/merry-cli/src/tool_display.rs b/crates/merry-cli/src/tool_display.rs index 22a046d2..746c08c4 100644 --- a/crates/merry-cli/src/tool_display.rs +++ b/crates/merry-cli/src/tool_display.rs @@ -27,9 +27,7 @@ fn format_process_call_detail(arguments: &Map) -> Option Some(format!( "{} ({})", display_shell_command(command), - cwd.chars() - .filter(|character| !character.is_control()) - .collect::() + crate::text::without_control_chars(cwd) )) } @@ -59,11 +57,7 @@ fn format_apply_patch_call_detail(arguments: &Map) -> Option Vec<&str> { patch .lines() - .filter_map(|line| { - line.strip_prefix("*** Add File: ") - .or_else(|| line.strip_prefix("*** Update File: ")) - .map(str::trim) - }) + .filter_map(|line| crate::apply_patch_argument::section_header(line).map(|(_, path)| path)) .filter(|path| !path.is_empty()) .collect() } @@ -109,16 +103,7 @@ fn compact_shell_word(value: &str) -> String { } fn display_shell_command(value: &str) -> String { - let output = value - .chars() - .map(|character| { - if character.is_control() { - ' ' - } else { - character - } - }) - .collect::(); + let output = crate::text::with_control_chars_as_spaces(value); let output = output.trim(); if output.is_empty() { "\"\"".to_owned() @@ -128,11 +113,8 @@ fn display_shell_command(value: &str) -> String { } fn compact_inline(value: &str, max_chars: usize) -> String { - let mut output = value - .chars() - .filter(|character| !character.is_control()) - .take(max_chars) - .collect::(); + let value = crate::text::without_control_chars(value); + let mut output = value.chars().take(max_chars).collect::(); if value.chars().count() > max_chars { output.push_str("..."); } @@ -183,6 +165,21 @@ mod tests { assert!(detail.starts_with("patch=notes/new.txt")); } + #[test] + fn apply_patch_detail_names_delete_file_path() { + let arguments = json!({ + "patch": "*** Begin Patch\n*** Delete File: notes/obsolete.txt\n*** End Patch" + }); + + let detail = + format_tool_call_detail("apply_patch", arguments.as_object().unwrap()).unwrap(); + + assert!( + detail.starts_with("patch=notes/obsolete.txt"), + "a delete must name the file it removes, not only the payload size: {detail}" + ); + } + #[test] fn apply_patch_detail_reports_malformed_patch_payload_size() { let arguments = json!({ "patch": "not a Merry patch" }); diff --git a/crates/merry-cli/src/tui/projector/tool_output.rs b/crates/merry-cli/src/tui/projector/tool_output.rs index 52785a8e..8768a1d3 100644 --- a/crates/merry-cli/src/tui/projector/tool_output.rs +++ b/crates/merry-cli/src/tui/projector/tool_output.rs @@ -1,20 +1,24 @@ //! Decodes tool output into bounded presentation values; never owns runtime state. use crate::{ + apply_patch_argument::{PatchArgumentSectionKind, section_header}, tool_display::format_tool_call_detail, tui::{ process_output::process_output_preview, projector::StartedToolView, state::{ CommandFailure, CommandView, PatchChangeView, PatchLineKind, PatchLineView, - ProcessOutputPreview, TimelineItem, + PatchOperationView, ProcessOutputPreview, TimelineItem, }, text_wrap::truncate_chars, tool_error::compact_failed_tool_body, }, }; use merry_core::ToolOutput; -use merry_tools::APPLY_PATCH_TOOL; +use merry_tools::{ + APPLY_PATCH_TOOL, WorkspacePatchOperationKind, WorkspacePatchSuccess, + WorkspacePatchSuccessLineKind, +}; use serde::Deserialize; use serde_json::Value; use std::collections::HashMap; @@ -211,9 +215,8 @@ pub(super) fn compact_tool_output(output: &str) -> String { if output.is_empty() { return String::new(); } - let mut compact = output + let mut compact = crate::text::without_control_chars_keeping_newlines(output) .chars() - .filter(|character| !character.is_control() || *character == '\n') .take(600) .collect::(); if output.chars().count() > 600 { @@ -226,8 +229,10 @@ pub(super) fn parse_apply_patch_view( output: &str, patch_argument: Option<&str>, ) -> Option { - let output = serde_json::from_str::(output).ok()?; - if !output.ok || output.tool.as_deref() != Some(APPLY_PATCH_TOOL) { + // The envelope type is owned by the tool crate, so a field cannot drift + // between the writer and this reader. + let output = serde_json::from_str::(output).ok()?; + if !output.ok || output.tool != APPLY_PATCH_TOOL { return None; } let parsed_patch = patch_argument.map(parse_apply_patch_argument); @@ -235,38 +240,56 @@ pub(super) fn parse_apply_patch_view( .changes .into_iter() .map(|change| { + let operation = match change.operation() { + WorkspacePatchOperationKind::Add => PatchOperationView::Add, + WorkspacePatchOperationKind::Update => PatchOperationView::Update, + WorkspacePatchOperationKind::Delete => PatchOperationView::Delete, + // An operation recorded by a newer build still describes a + // change to a file, so it renders as the historical default + // instead of falling back to the raw result. + WorkspacePatchOperationKind::Unknown => PatchOperationView::Update, + }; let patch_lines = change .lines - .as_ref() - .map(|lines| { - lines - .iter() - .filter_map(WorkspacePatchOutputLine::to_patch_line_view) - .collect::>() - }) - .filter(|lines| !lines.is_empty()) - .or_else(|| { - parsed_patch - .as_ref() - .and_then(|parsed| parsed.change_lines(&change.path)) - .cloned() - }) - .unwrap_or_default(); - let added = patch_lines + .iter() + .filter_map(envelope_line_view) + .collect::>(); + // Envelopes recorded before the tool echoed hunk lines fall back to + // the lines of the pending call's own patch argument. + let patch_lines = if patch_lines.is_empty() { + parsed_patch + .as_ref() + .and_then(|parsed| parsed.change_lines(&change.path)) + .cloned() + .unwrap_or_default() + } else { + patch_lines + }; + let hunk_added = patch_lines .iter() .filter(|line| line.kind == PatchLineKind::Add) .count(); - let removed = patch_lines + let hunk_removed = patch_lines .iter() .filter(|line| line.kind == PatchLineKind::Remove) .count(); + // A delete reports no hunk lines because the whole file leaves at + // once, so the file's own line count is the honest removal count. + let (added, removed) = if operation == PatchOperationView::Delete { + (0, change.lines_before.unwrap_or(0)) + } else { + (hunk_added, hunk_removed) + }; PatchChangeView { path: change.path, + operation, added, removed, hunks: change.hunks, - bytes_before: Some(change.bytes_before), - bytes_after: Some(change.bytes_after), + lines_before: change.lines_before, + lines_after: change.lines_after, + bytes_before: change.bytes_before, + bytes_after: change.bytes_after, lines: patch_lines, } }) @@ -274,46 +297,22 @@ pub(super) fn parse_apply_patch_view( Some(TimelineItem::Patch { changes }) } -#[derive(Debug, Deserialize)] -pub(super) struct WorkspacePatchOutput { - pub(super) ok: bool, - pub(super) tool: Option, - pub(super) changes: Vec, -} - -#[derive(Debug, Deserialize)] -pub(super) struct WorkspacePatchOutputChange { - pub(super) path: String, - pub(super) hunks: usize, - pub(super) bytes_before: usize, - pub(super) bytes_after: usize, - #[serde(default)] - pub(super) lines: Option>, -} - -#[derive(Debug, Deserialize)] -pub(super) struct WorkspacePatchOutputLine { - pub(super) kind: String, - pub(super) old_line: Option, - pub(super) new_line: Option, - pub(super) text: String, -} - -impl WorkspacePatchOutputLine { - pub(super) fn to_patch_line_view(&self) -> Option { - let kind = match self.kind.as_str() { - "context" => PatchLineKind::Context, - "remove" => PatchLineKind::Remove, - "add" => PatchLineKind::Add, - _ => return None, - }; - Some(PatchLineView { - kind, - old_line: self.old_line, - new_line: self.new_line, - text: self.text.clone(), - }) - } +/// Maps one envelope line onto the timeline's line view. +fn envelope_line_view(line: &merry_tools::WorkspacePatchSuccessLine) -> Option { + let kind = match line.kind { + WorkspacePatchSuccessLineKind::Context => PatchLineKind::Context, + WorkspacePatchSuccessLineKind::Remove => PatchLineKind::Remove, + WorkspacePatchSuccessLineKind::Add => PatchLineKind::Add, + // A line kind this build does not know is left out of the preview + // instead of hiding the rest of the change. + WorkspacePatchSuccessLineKind::Unknown => return None, + }; + Some(PatchLineView { + kind, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }) } #[derive(Debug, Default, Clone)] @@ -334,21 +333,22 @@ pub(super) fn parse_apply_patch_argument(patch: &str) -> ParsedPatchArgument { let mut line_numbers = PatchLineNumbers::default(); for line in patch.lines() { - if let Some(path) = line.strip_prefix("*** Add File: ").map(str::trim) { + if let Some((kind, path)) = section_header(line) { flush_patch_change(&mut parsed, &mut current_path, &mut current_lines); current_path = Some(path.to_owned()); - line_numbers = PatchLineNumbers { - old_next: None, - new_next: Some(1), + line_numbers = match kind { + // A created file is numbered from its first line, while an + // updated or deleted file starts from the hunk headers. + PatchArgumentSectionKind::Add => PatchLineNumbers { + old_next: None, + new_next: Some(1), + }, + PatchArgumentSectionKind::Update | PatchArgumentSectionKind::Delete => { + PatchLineNumbers::default() + } }; continue; } - if let Some(path) = line.strip_prefix("*** Update File: ").map(str::trim) { - flush_patch_change(&mut parsed, &mut current_path, &mut current_lines); - current_path = Some(path.to_owned()); - line_numbers = PatchLineNumbers::default(); - continue; - } if line.starts_with("*** ") { continue; } diff --git a/crates/merry-cli/src/tui/render/timeline.rs b/crates/merry-cli/src/tui/render/timeline.rs index 3eae1db5..73814293 100644 --- a/crates/merry-cli/src/tui/render/timeline.rs +++ b/crates/merry-cli/src/tui/render/timeline.rs @@ -5,7 +5,9 @@ use crate::tui::{ keymap::KeyAction, markdown::{RenderedMarkdown, markdown_lines}, render::command_style::command_spans, - state::{CommandFailure, CommandView, PatchChangeView, TimelineItem, TuiState}, + state::{ + CommandFailure, CommandView, PatchChangeView, PatchOperationView, TimelineItem, TuiState, + }, text_interaction::TextSelection, text_wrap::{ StyledTextPart, inline_code_spans, semantic_style, truncate_chars, wrap_styled_parts, @@ -158,29 +160,56 @@ pub(super) fn compact_patch_lines( let mut lines = Vec::new(); for change in changes { lines.push(Line::from(Span::styled( - format!( - "Edited {} (+{} -{})", - change.path, change.added, change.removed - ), + patch_change_title(change), semantic_style(state, SemanticColor::Focus).add_modifier(Modifier::BOLD), ))); lines.push(Line::from(Span::styled( - format!( - " {} hunk(s), {} -> {} bytes", - change.hunks, - change - .bytes_before - .map_or_else(|| "-".to_owned(), |bytes| bytes.to_string()), - change - .bytes_after - .map_or_else(|| "-".to_owned(), |bytes| bytes.to_string()) - ), + patch_change_detail(change), semantic_style(state, SemanticColor::Muted), ))); } lines } +/// Titles a patch change with the verb that matches its file operation. +fn patch_change_title(change: &PatchChangeView) -> String { + match change.operation { + PatchOperationView::Add => format!("Created {} (+{})", change.path, change.added), + PatchOperationView::Update => format!( + "Edited {} (+{} -{})", + change.path, change.added, change.removed + ), + PatchOperationView::Delete => format!("Deleted {} (-{})", change.path, change.removed), + } +} + +/// Summarizes a patch change by line counts first, then byte counts. +/// +/// Reviewers reason about a change in lines, while byte counts describe the +/// file-size effect that a line count cannot express, so lines lead and both +/// stay visible. The hunk count only describes an update's hunks. +fn patch_change_detail(change: &PatchChangeView) -> String { + let mut segments = Vec::new(); + if change.operation == PatchOperationView::Update { + segments.push(format!("{} hunk(s)", change.hunks)); + } + segments.push(format!( + "{} -> {} lines", + optional_size(change.lines_before), + optional_size(change.lines_after) + )); + segments.push(format!( + "{} -> {} bytes", + change.bytes_before, change.bytes_after + )); + format!(" {}", segments.join(", ")) +} + +/// Renders a stored size that a replayed envelope may not carry. +fn optional_size(value: Option) -> String { + value.map_or_else(|| "-".to_owned(), |value| value.to_string()) +} + pub(super) fn expanded_title_line(state: &TuiState, title: &str) -> Line<'static> { if let Some(line) = tool_title_line(state, title) { return line; @@ -322,10 +351,7 @@ pub(super) fn expanded_timeline_lines( .filter(|line| !line.trim().is_empty()) .take(TOOL_RESULT_PREVIEW_MAX_LINES) { - let clean = line - .chars() - .filter(|character| !character.is_control()) - .collect::(); + let clean = crate::text::without_control_chars(line); let clean = clean.trim(); if clean.is_empty() { continue; @@ -391,10 +417,7 @@ pub(super) fn command_lines( if failed || (failure.is_none() && state.show_successful_command_output()) { let body_width = usize::from(region_width).saturating_sub(2).max(4); for line in &preview.lines { - let clean = line - .chars() - .filter(|character| !character.is_control()) - .collect::(); + let clean = crate::text::without_control_chars(line); lines.push(Line::from(Span::styled( format!(" {}", truncate_chars(clean.trim(), body_width)), semantic_style(state, SemanticColor::Muted), diff --git a/crates/merry-cli/src/tui/runtime.rs b/crates/merry-cli/src/tui/runtime.rs index 639d0cd5..a747a096 100644 --- a/crates/merry-cli/src/tui/runtime.rs +++ b/crates/merry-cli/src/tui/runtime.rs @@ -139,7 +139,6 @@ pub(crate) async fn start_tui_runtime_session( model, process_backend: backend, extra_tools: mcp.tools, - allow_hidden_workspace_paths: false, automatic_compaction: automatic_compaction_config_with_preferences( merry_config, preferences, diff --git a/crates/merry-cli/src/tui/state.rs b/crates/merry-cli/src/tui/state.rs index 518024e6..1c930804 100644 --- a/crates/merry-cli/src/tui/state.rs +++ b/crates/merry-cli/src/tui/state.rs @@ -18,7 +18,7 @@ use std::{ }; pub(crate) use timeline::TimelineAnchor; pub(crate) use views::{ - CommandFailure, CommandView, PatchChangeView, PatchLineKind, PatchLineView, + CommandFailure, CommandView, PatchChangeView, PatchLineKind, PatchLineView, PatchOperationView, ProcessOutputPreview, QueuePreview, QueuePreviewItem, QueuePreviewState, TimelineItem, }; diff --git a/crates/merry-cli/src/tui/state/views.rs b/crates/merry-cli/src/tui/state/views.rs index 93102c72..438a1f1d 100644 --- a/crates/merry-cli/src/tui/state/views.rs +++ b/crates/merry-cli/src/tui/state/views.rs @@ -132,6 +132,18 @@ pub(crate) enum PatchLineKind { Remove, } +/// File operation a completed `apply_patch` change reported. +/// +/// Envelopes written before the field existed only described updates, so +/// [`PatchOperationView::Update`] is the default for replayed sessions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum PatchOperationView { + Add, + #[default] + Update, + Delete, +} + #[derive(Debug, Clone, PartialEq, Eq)] #[allow(dead_code)] pub(crate) struct PatchLineView { @@ -175,11 +187,16 @@ impl PatchLineView { #[allow(dead_code)] pub(crate) struct PatchChangeView { pub(crate) path: String, + pub(crate) operation: PatchOperationView, pub(crate) added: usize, pub(crate) removed: usize, pub(crate) hunks: usize, - pub(crate) bytes_before: Option, - pub(crate) bytes_after: Option, + pub(crate) lines_before: Option, + pub(crate) lines_after: Option, + /// Byte counts are part of every recorded envelope, unlike the line counts + /// that older sessions predate. + pub(crate) bytes_before: usize, + pub(crate) bytes_after: usize, pub(crate) lines: Vec, } diff --git a/crates/merry-cli/src/tui/tests/command_runtime.rs b/crates/merry-cli/src/tui/tests/command_runtime.rs index 1b01e0b9..890b1589 100644 --- a/crates/merry-cli/src/tui/tests/command_runtime.rs +++ b/crates/merry-cli/src/tui/tests/command_runtime.rs @@ -98,7 +98,6 @@ async fn runtime_process_stays_running_and_animates_until_the_backend_completes( permissioned_factory, )), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: AutomaticCompactionConfig::disabled(), retry_policy: None, context_compaction: None, diff --git a/crates/merry-cli/src/tui/tests/patch_projection.rs b/crates/merry-cli/src/tui/tests/patch_projection.rs index d49b1359..e28bc0fa 100644 --- a/crates/merry-cli/src/tui/tests/patch_projection.rs +++ b/crates/merry-cli/src/tui/tests/patch_projection.rs @@ -2,7 +2,7 @@ use crate::tui::{ keymap::Keymap, projector::TuiProjector, render::render_to_text, - state::{PatchChangeView, PatchLineView, TimelineItem, TuiState}, + state::{PatchChangeView, PatchLineView, PatchOperationView, TimelineItem, TuiState}, tests::{pending_call, pending_call_with_args, source, text_artifact}, theme::TuiTheme, }; @@ -97,6 +97,13 @@ fn projector_projects_apply_patch_using_patch_tool_format() { assert_eq!(changes[0].path, "crates/merry-cli/src/tui/render.rs"); assert_eq!(changes[0].added, 1); assert_eq!(changes[0].removed, 1); + assert_eq!( + changes[0].operation, + PatchOperationView::Update, + "envelopes recorded before the op field describe updates" + ); + assert_eq!(changes[0].lines_before, None); + assert_eq!(changes[0].lines_after, None); assert_eq!( changes[0].lines, vec![ @@ -154,6 +161,59 @@ fn projector_projects_apply_patch_add_file_line_numbers() { ); } +#[test] +fn projector_reads_delete_and_line_counts_from_the_envelope() { + let mut state = TuiState::new( + "/repo".into(), + "gpt-test".to_owned(), + Keymap::default(), + TuiTheme::default(), + ); + let mut projector = TuiProjector::default(); + let patch = "*** Begin Patch\n*** Delete File: notes/old.md\n*** End Patch"; + + projector.apply( + RuntimeEvent::ToolCallStarted { + call: pending_call_with_args( + "call-delete-file", + APPLY_PATCH_TOOL, + json!({ "patch": patch }), + ), + source: source(), + }, + &mut state, + ); + projector.apply( + RuntimeEvent::ToolCallFinished { + result: ToolCallResult::succeeded( + ToolCallId::new("call-delete-file").unwrap(), + text_artifact("patch-output"), + ), + output: Some(ToolOutput::Json { + json: r#"{"ok":true,"tool":"apply_patch","changes":[{"path":"notes/old.md","op":"delete","hunks":0,"lines_before":4,"lines_after":0,"bytes_before":30,"bytes_after":0,"lines":[]}]}"#.to_owned(), + }), + source: source(), + }, + &mut state, + ); + + let TimelineItem::Patch { changes } = &state.timeline()[0] else { + panic!("workspace delete patch should render as a patch view"); + }; + assert_eq!(changes[0].operation, PatchOperationView::Delete); + assert_eq!(changes[0].added, 0); + assert_eq!( + changes[0].removed, 4, + "a delete removes the whole file, so its line count is the removal count" + ); + assert_eq!(changes[0].lines_before, Some(4)); + assert_eq!(changes[0].lines_after, Some(0)); + + let text = render_to_text(&state, 180, 32); + assert!(text.contains("Deleted notes/old.md (-4)")); + assert!(text.contains("4 -> 0 lines, 30 -> 0 bytes")); +} + #[test] fn projector_derives_patch_line_numbers_from_hunk_headers() { let mut state = TuiState::new( @@ -298,11 +358,14 @@ fn renderer_shows_apply_patch_as_edited_block() { state.push_timeline_item(TimelineItem::Patch { changes: vec![PatchChangeView { path: "crates/merry-cli/src/tui/render.rs".to_owned(), + operation: PatchOperationView::Update, added: 1, removed: 1, hunks: 1, - bytes_before: Some(120), - bytes_after: Some(121), + lines_before: Some(810), + lines_after: Some(911), + bytes_before: 120, + bytes_after: 121, lines: vec![ PatchLineView::context(" let old = true;", Some(20)), PatchLineView::remove(" lines.push(old);", Some(21)), @@ -313,7 +376,7 @@ fn renderer_shows_apply_patch_as_edited_block() { let text = render_to_text(&state, 180, 16); assert!(text.contains("Edited crates/merry-cli/src/tui/render.rs (+1 -1)")); - assert!(text.contains("1 hunk(s), 120 -> 121 bytes")); + assert!(text.contains("1 hunk(s), 810 -> 911 lines, 120 -> 121 bytes")); assert!(!text.contains("\"changes\"")); } @@ -398,11 +461,14 @@ fn renderer_shows_patch_summary_in_the_timeline() { state.push_timeline_item(TimelineItem::Patch { changes: vec![PatchChangeView { path: "hello_world.py".to_owned(), + operation: PatchOperationView::Update, added: 1, removed: 1, hunks: 1, - bytes_before: Some(20), - bytes_after: Some(21), + lines_before: Some(6), + lines_after: Some(6), + bytes_before: 20, + bytes_after: 21, lines: vec![ PatchLineView::remove("print('old')", Some(7)), PatchLineView::add("print('new')", Some(7)), @@ -412,5 +478,81 @@ fn renderer_shows_patch_summary_in_the_timeline() { let text = render_to_text(&state, 180, 32); assert!(text.contains("Edited hello_world.py (+1 -1)")); - assert!(text.contains("1 hunk(s), 20 -> 21 bytes")); + assert!(text.contains("1 hunk(s), 6 -> 6 lines, 20 -> 21 bytes")); +} + +#[test] +fn renderer_names_created_and_deleted_files_instead_of_edits() { + let mut state = TuiState::new( + "/repo/merry".into(), + "gpt-test".to_owned(), + Keymap::default(), + TuiTheme::default(), + ); + state.push_timeline_item(TimelineItem::Patch { + changes: vec![ + PatchChangeView { + path: "notes/new.md".to_owned(), + operation: PatchOperationView::Add, + added: 3, + removed: 0, + hunks: 1, + lines_before: Some(0), + lines_after: Some(3), + bytes_before: 0, + bytes_after: 24, + lines: vec![PatchLineView::add("new", Some(1))], + }, + PatchChangeView { + path: "notes/old.md".to_owned(), + operation: PatchOperationView::Delete, + added: 0, + removed: 4, + hunks: 0, + lines_before: Some(4), + lines_after: Some(0), + bytes_before: 30, + bytes_after: 0, + lines: vec![], + }, + ], + }); + let text = render_to_text(&state, 180, 32); + + assert!(text.contains("Created notes/new.md (+3)")); + assert!(text.contains("0 -> 3 lines, 0 -> 24 bytes")); + assert!(text.contains("Deleted notes/old.md (-4)")); + assert!(text.contains("4 -> 0 lines, 30 -> 0 bytes")); + assert!( + !text.contains("hunk(s)"), + "add and delete changes have no hunks to count: {text}" + ); +} + +#[test] +fn renderer_keeps_byte_only_envelopes_readable() { + let mut state = TuiState::new( + "/repo/merry".into(), + "gpt-test".to_owned(), + Keymap::default(), + TuiTheme::default(), + ); + state.push_timeline_item(TimelineItem::Patch { + changes: vec![PatchChangeView { + path: "hello.txt".to_owned(), + operation: PatchOperationView::Update, + added: 2, + removed: 0, + hunks: 1, + lines_before: None, + lines_after: None, + bytes_before: 12, + bytes_after: 33, + lines: vec![PatchLineView::add("hello", Some(3))], + }], + }); + let text = render_to_text(&state, 180, 32); + + assert!(text.contains("Edited hello.txt (+2 -0)")); + assert!(text.contains("1 hunk(s), - -> - lines, 12 -> 33 bytes")); } diff --git a/crates/merry-cli/src/tui/tests/text_rendering.rs b/crates/merry-cli/src/tui/tests/text_rendering.rs index 1fd4c805..a682fb60 100644 --- a/crates/merry-cli/src/tui/tests/text_rendering.rs +++ b/crates/merry-cli/src/tui/tests/text_rendering.rs @@ -1,7 +1,9 @@ use crate::tui::{ keymap::Keymap, render::{render_to_buffer, render_to_text}, - state::{PatchChangeView, PatchLineView, QueuePreview, TimelineItem, TuiState}, + state::{ + PatchChangeView, PatchLineView, PatchOperationView, QueuePreview, TimelineItem, TuiState, + }, tests::{find_cell_color, find_cell_style, find_text_position, rendered_buffer_text}, theme::{SemanticColor, TuiTheme}, }; @@ -85,11 +87,14 @@ fn renderer_applies_configured_semantic_theme_colors() { state.push_timeline_item(TimelineItem::Patch { changes: vec![PatchChangeView { path: "patch".to_owned(), + operation: PatchOperationView::Update, added: 1, removed: 1, hunks: 1, - bytes_before: Some(8), - bytes_after: Some(6), + lines_before: Some(3), + lines_after: Some(3), + bytes_before: 8, + bytes_after: 6, lines: vec![ PatchLineView::remove("removed", Some(1)), PatchLineView::add("added", Some(1)), diff --git a/crates/merry-coding/src/lib.rs b/crates/merry-coding/src/lib.rs index 252514ec..772cba8b 100644 --- a/crates/merry-coding/src/lib.rs +++ b/crates/merry-coding/src/lib.rs @@ -7,6 +7,7 @@ //! second coding policy. mod child_runtime; +mod profile_hash; mod project_capabilities; mod project_rules; mod runtime; @@ -16,6 +17,7 @@ mod workspace; #[cfg(test)] mod tests; +pub use profile_hash::CodingAgentProfileHash; pub use project_rules::{ MAX_ROOT_PROJECT_RULES_BYTES, ProjectRulesLoadError, ROOT_PROJECT_RULES_FILE, load_root_project_rules, @@ -33,14 +35,15 @@ use merry_runtime::{ AgentLoopConfig, PermissionAdmissionError, ProcessCommandToolError, ProcessRunner, ProjectRules, PromptBlock, PromptError, PromptProfile, RegisteredTool, RuntimeBuilder, RuntimeError, RuntimeProfile, RuntimeProfileError, SkillCatalog, TaskAnchor, Tool, - ToolActionKind, ToolConcurrency, ToolRunner, }; pub use merry_tools::{WorkspaceToolConfigError, WorkspaceToolLimits}; use serde_json::Error as JsonError; -use std::{fmt, path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; use thiserror::Error; use workspace::{WorkspaceCodingProfileBuildError, WorkspaceCodingProfileBuilder}; +use profile_hash::coding_agent_profile_hash; + /// Stable identity of the provider-neutral coding profile contract. pub const CODING_AGENT_PROFILE_ID: &str = "coding-agent-profile"; @@ -161,24 +164,6 @@ pub fn coding_agent(root: impl Into) -> CodingAgentProfileBuilder { CodingAgentProfileBuilder::new(root) } -/// Stable identity of a shared coding-agent composition profile. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct CodingAgentProfileHash(String); - -impl CodingAgentProfileHash { - /// Borrows the stable profile hash label. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for CodingAgentProfileHash { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) - } -} - /// One provider-neutral coding-agent composition applied to a runtime builder. /// /// The profile owns the coding tool catalog and its runtime policy settings. @@ -274,33 +259,14 @@ impl CodingAgentProfileBuilder { } } - /// Creates a coding-agent profile builder with explicit workspace roots. - pub fn with_roots(roots: I) -> Self - where - I: IntoIterator, - P: Into, - { - Self { - workspace: WorkspaceCodingProfileBuilder::with_roots(roots), - retry_policy: None, - run_policy: CodingAgentRunPolicy::default(), - allow_bridge_tools: false, - skill_catalog: None, - project_rules: None, - task_anchor: None, - tools: Vec::new(), - registered_tools: Vec::new(), - } - } - - /// Adds another workspace root. + /// Replaces the workspace root. #[must_use] pub fn root(mut self, root: impl Into) -> Self { self.workspace = self.workspace.root(root); self } - /// Adds read-only resource roots that are not writable workspace roots. + /// Adds read-only resource roots that are not the writable workspace root. #[must_use] pub fn readonly_resource_roots(mut self, roots: I) -> Self where @@ -311,13 +277,6 @@ impl CodingAgentProfileBuilder { self } - /// Controls whether hidden path components are allowed. - #[must_use] - pub fn allow_hidden(mut self, allow_hidden: bool) -> Self { - self.workspace = self.workspace.allow_hidden(allow_hidden); - self - } - /// Sets workspace tool limits. #[must_use] pub fn limits(mut self, limits: WorkspaceToolLimits) -> Self { @@ -537,249 +496,5 @@ pub enum CodingAgentProfileBuildError { Prompt(#[from] PromptError), } -fn coding_agent_profile_hash( - profile: &RuntimeProfile, - run_policy: CodingAgentRunPolicy, - workspace_hash_material: &[u8], -) -> Result { - let mut material = Vec::new(); - append_hash_field(&mut material, "profile-id", CODING_AGENT_PROFILE_ID); - material.extend_from_slice(workspace_hash_material); - append_hash_field( - &mut material, - "stable-layout", - CODING_AGENT_STABLE_PREFIX_LAYOUT, - ); - append_hash_field( - &mut material, - "dynamic-layout", - CODING_AGENT_DYNAMIC_CONTEXT_LAYOUT, - ); - append_hash_field( - &mut material, - "prompt-base", - profile.prompt_profile().base_instructions(), - ); - append_hash_field( - &mut material, - "prompt-progress", - profile.prompt_profile().progress_commentary_instructions(), - ); - for block in profile.prompt_profile().stable_blocks() { - append_hash_field(&mut material, "prompt-block-tag", block.tag()); - append_hash_field(&mut material, "prompt-block-text", block.text()); - } - append_hash_field( - &mut material, - "run-max-model-turns", - &run_policy.max_model_turns().to_string(), - ); - append_hash_field( - &mut material, - "run-final-report", - run_policy.final_report().as_str(), - ); - if let Some(retry_policy) = profile.model_retry_policy() { - append_hash_field( - &mut material, - "retry-enabled", - if retry_policy.enabled() { "on" } else { "off" }, - ); - append_hash_field( - &mut material, - "retry-max-attempts", - &retry_policy.max_attempts().to_string(), - ); - append_hash_field( - &mut material, - "retry-initial-delay-nanos", - &retry_policy.initial_delay().as_nanos().to_string(), - ); - append_hash_field( - &mut material, - "retry-max-delay-nanos", - &retry_policy.max_delay().as_nanos().to_string(), - ); - append_hash_field( - &mut material, - "retry-max-elapsed-nanos", - &retry_policy.max_elapsed().as_nanos().to_string(), - ); - append_hash_field( - &mut material, - "retry-jitter", - if retry_policy.jitter() { "on" } else { "off" }, - ); - } else { - append_hash_field(&mut material, "retry-policy", "runtime-default"); - } - append_hash_field( - &mut material, - "progress-commentary", - if profile.progress_commentary() { - "on" - } else { - "off" - }, - ); - append_hash_field( - &mut material, - "bridge-tools", - if profile.allow_bridge_tools() { - "on" - } else { - "off" - }, - ); - append_hash_field( - &mut material, - "workspace-patches", - if profile.allow_low_risk_apply_patches() { - "on" - } else { - "off" - }, - ); - append_hash_field( - &mut material, - "low-risk-process", - if profile.low_risk_process_runner().is_some() { - "on" - } else { - "off" - }, - ); - append_hash_field( - &mut material, - "read-only-process", - if profile.read_only_shell_process_runner().is_some() { - "on" - } else { - "off" - }, - ); - append_hash_field( - &mut material, - "accepted-process", - if profile.accepted_local_workspace_process_runner().is_some() { - "on" - } else { - "off" - }, - ); - append_hash_field( - &mut material, - "permissioned-process", - if profile.permissioned_process_runner_factory().is_some() { - "on" - } else { - "off" - }, - ); - - for (id, text) in profile.initial_context_summaries() { - append_hash_field(&mut material, "initial-context-id", id); - append_hash_field(&mut material, "initial-context-text", text); - } - if let Some(project_rules) = profile.project_rules() { - append_hash_field( - &mut material, - "project-rules-source", - project_rules.source_path(), - ); - append_hash_field( - &mut material, - "project-rules-hash", - project_rules.content_hash(), - ); - append_hash_field( - &mut material, - "project-rules-stable-text", - &project_rules.to_stable_prefix_message_text(), - ); - } - if let Some(skill_catalog) = profile.skill_catalog() - && let Some(text) = skill_catalog.to_stable_prefix_message_text() - { - append_hash_field(&mut material, "skill-catalog", &text); - } - - // Task anchors and checkpoints are intentionally excluded: they are dynamic - // runtime context and must not invalidate the stable profile identity. - for tool in profile.registered_tools() { - let spec = serde_json::to_string(tool.spec())?; - append_hash_field(&mut material, "tool-spec", &spec); - append_hash_field( - &mut material, - "tool-action-kind", - tool_action_kind_label(tool.action_kind()), - ); - append_hash_field( - &mut material, - "tool-runner", - tool_runner_label(tool.runner()), - ); - append_hash_field( - &mut material, - "tool-concurrency", - tool_concurrency_label(tool.concurrency()), - ); - append_hash_field( - &mut material, - "tool-proposals", - if tool.proposals_enabled() { - "on" - } else { - "off" - }, - ); - } - - Ok(CodingAgentProfileHash(format!( - "fnv1a64:{:016x}", - fnv1a64(&material) - ))) -} - -fn append_hash_field(material: &mut Vec, name: &str, value: &str) { - material.extend_from_slice(name.as_bytes()); - material.push(0); - material.extend_from_slice(&(value.len() as u64).to_be_bytes()); - material.extend_from_slice(value.as_bytes()); -} - -fn fnv1a64(bytes: &[u8]) -> u64 { - let mut hash = 0xcbf29ce484222325_u64; - for byte in bytes { - hash = (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3); - } - hash -} - -fn tool_action_kind_label(kind: ToolActionKind) -> &'static str { - match kind { - ToolActionKind::ReadOnly => "read_only", - ToolActionKind::RuntimeControl => "runtime_control", - ToolActionKind::WorkspaceWrite => "workspace_write", - ToolActionKind::CommandExec => "command_exec", - ToolActionKind::Network => "network", - ToolActionKind::TrustedExternal => "trusted_external", - } -} - -fn tool_runner_label(runner: ToolRunner) -> &'static str { - match runner { - ToolRunner::Runtime => "runtime", - ToolRunner::Bridge => "bridge", - } -} - -fn tool_concurrency_label(concurrency: ToolConcurrency) -> &'static str { - match concurrency { - ToolConcurrency::ParallelSafe => "parallel_safe", - ToolConcurrency::Exclusive => "exclusive", - } -} - /// Coding-profile name for runtime-owned process execution. pub const CODING_LOOP_PROCESS_TOOL: &str = "run_process"; diff --git a/crates/merry-coding/src/profile_hash.rs b/crates/merry-coding/src/profile_hash.rs new file mode 100644 index 00000000..988b3c5e --- /dev/null +++ b/crates/merry-coding/src/profile_hash.rs @@ -0,0 +1,267 @@ +//! Stable identity of a coding-agent composition profile. +//! +//! The hash answers one question: would two runs send the same provider-visible +//! prefix and the same tool contract? Everything that shapes that prefix is +//! folded into one byte string in a fixed field order, so a change to prompt +//! text, layouts, retry policy, workspace roots, context summaries, project +//! rules, skills, or any registered tool specification produces a different +//! identity. Dynamic context is deliberately excluded, because a task anchor or +//! checkpoint must not look like a different profile. +//! +//! Field names and their order are part of the contract: they make two +//! different values distinguishable (`ab` + `c` hashes differently from `a` + +//! `bc`), and reordering or renaming a field is a compatibility change. + +use std::fmt; + +use merry_runtime::{RuntimeProfile, ToolActionKind, ToolConcurrency, ToolRunner}; +use serde_json::Error as JsonError; + +use crate::{ + CODING_AGENT_DYNAMIC_CONTEXT_LAYOUT, CODING_AGENT_PROFILE_ID, + CODING_AGENT_STABLE_PREFIX_LAYOUT, CodingAgentRunPolicy, +}; + +/// Stable identity of a shared coding-agent composition profile. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CodingAgentProfileHash(String); + +impl CodingAgentProfileHash { + /// Borrows the stable profile hash label. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CodingAgentProfileHash { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Hashes every provider-visible part of one coding-agent composition. +/// +/// `workspace_hash_material` is produced by the workspace profile builder, +/// which owns the workspace fields and their own field names. Failures come +/// from serializing a tool specification, which is the only value here that +/// cannot be folded in as bytes directly. +pub(crate) fn coding_agent_profile_hash( + profile: &RuntimeProfile, + run_policy: CodingAgentRunPolicy, + workspace_hash_material: &[u8], +) -> Result { + let mut material = Vec::new(); + append_hash_field(&mut material, "profile-id", CODING_AGENT_PROFILE_ID); + material.extend_from_slice(workspace_hash_material); + append_hash_field( + &mut material, + "stable-layout", + CODING_AGENT_STABLE_PREFIX_LAYOUT, + ); + append_hash_field( + &mut material, + "dynamic-layout", + CODING_AGENT_DYNAMIC_CONTEXT_LAYOUT, + ); + append_hash_field( + &mut material, + "prompt-base", + profile.prompt_profile().base_instructions(), + ); + append_hash_field( + &mut material, + "prompt-progress", + profile.prompt_profile().progress_commentary_instructions(), + ); + for block in profile.prompt_profile().stable_blocks() { + append_hash_field(&mut material, "prompt-block-tag", block.tag()); + append_hash_field(&mut material, "prompt-block-text", block.text()); + } + append_hash_field( + &mut material, + "run-max-model-turns", + &run_policy.max_model_turns().to_string(), + ); + append_hash_field( + &mut material, + "run-final-report", + run_policy.final_report().as_str(), + ); + append_model_retry_policy(&mut material, profile.model_retry_policy().as_ref()); + append_hash_field( + &mut material, + "progress-commentary", + on_or_off(profile.progress_commentary()), + ); + append_hash_field( + &mut material, + "bridge-tools", + on_or_off(profile.allow_bridge_tools()), + ); + append_hash_field( + &mut material, + "workspace-patches", + on_or_off(profile.allow_low_risk_apply_patches()), + ); + append_hash_field( + &mut material, + "low-risk-process", + on_or_off(profile.low_risk_process_runner().is_some()), + ); + append_hash_field( + &mut material, + "read-only-process", + on_or_off(profile.read_only_shell_process_runner().is_some()), + ); + append_hash_field( + &mut material, + "accepted-process", + on_or_off(profile.accepted_local_workspace_process_runner().is_some()), + ); + append_hash_field( + &mut material, + "permissioned-process", + on_or_off(profile.permissioned_process_runner_factory().is_some()), + ); + + for (id, text) in profile.initial_context_summaries() { + append_hash_field(&mut material, "initial-context-id", id); + append_hash_field(&mut material, "initial-context-text", text); + } + if let Some(project_rules) = profile.project_rules() { + append_hash_field( + &mut material, + "project-rules-source", + project_rules.source_path(), + ); + append_hash_field( + &mut material, + "project-rules-hash", + project_rules.content_hash(), + ); + append_hash_field( + &mut material, + "project-rules-stable-text", + &project_rules.to_stable_prefix_message_text(), + ); + } + if let Some(skill_catalog) = profile.skill_catalog() + && let Some(text) = skill_catalog.to_stable_prefix_message_text() + { + append_hash_field(&mut material, "skill-catalog", &text); + } + + // Task anchors and checkpoints are intentionally excluded: they are dynamic + // runtime context and must not invalidate the stable profile identity. + for tool in profile.registered_tools() { + let spec = serde_json::to_string(tool.spec())?; + append_hash_field(&mut material, "tool-spec", &spec); + append_hash_field( + &mut material, + "tool-action-kind", + tool_action_kind_label(tool.action_kind()), + ); + append_hash_field( + &mut material, + "tool-runner", + tool_runner_label(tool.runner()), + ); + append_hash_field( + &mut material, + "tool-concurrency", + tool_concurrency_label(tool.concurrency()), + ); + append_hash_field( + &mut material, + "tool-proposals", + on_or_off(tool.proposals_enabled()), + ); + } + + Ok(CodingAgentProfileHash(format!( + "fnv1a64:{:016x}", + fnv1a64(&material) + ))) +} + +/// Folds the retry policy in, or records that the runtime default applies. +fn append_model_retry_policy( + material: &mut Vec, + retry_policy: Option<&merry_llm::ModelRetryPolicy>, +) { + let Some(retry_policy) = retry_policy else { + append_hash_field(material, "retry-policy", "runtime-default"); + return; + }; + append_hash_field(material, "retry-enabled", on_or_off(retry_policy.enabled())); + append_hash_field( + material, + "retry-max-attempts", + &retry_policy.max_attempts().to_string(), + ); + append_hash_field( + material, + "retry-initial-delay-nanos", + &retry_policy.initial_delay().as_nanos().to_string(), + ); + append_hash_field( + material, + "retry-max-delay-nanos", + &retry_policy.max_delay().as_nanos().to_string(), + ); + append_hash_field( + material, + "retry-max-elapsed-nanos", + &retry_policy.max_elapsed().as_nanos().to_string(), + ); + append_hash_field(material, "retry-jitter", on_or_off(retry_policy.jitter())); +} + +/// Appends one length-prefixed, name-tagged field. +/// +/// The name keeps two adjacent fields from colliding, and the length keeps a +/// value from absorbing the next field's name. +fn append_hash_field(material: &mut Vec, name: &str, value: &str) { + material.extend_from_slice(name.as_bytes()); + material.push(0); + material.extend_from_slice(&(value.len() as u64).to_be_bytes()); + material.extend_from_slice(value.as_bytes()); +} + +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in bytes { + hash = (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3); + } + hash +} + +fn on_or_off(enabled: bool) -> &'static str { + if enabled { "on" } else { "off" } +} + +fn tool_action_kind_label(kind: ToolActionKind) -> &'static str { + match kind { + ToolActionKind::ReadOnly => "read_only", + ToolActionKind::RuntimeControl => "runtime_control", + ToolActionKind::WorkspaceWrite => "workspace_write", + ToolActionKind::CommandExec => "command_exec", + ToolActionKind::Network => "network", + ToolActionKind::TrustedExternal => "trusted_external", + } +} + +fn tool_runner_label(runner: ToolRunner) -> &'static str { + match runner { + ToolRunner::Runtime => "runtime", + ToolRunner::Bridge => "bridge", + } +} + +fn tool_concurrency_label(concurrency: ToolConcurrency) -> &'static str { + match concurrency { + ToolConcurrency::ParallelSafe => "parallel_safe", + ToolConcurrency::Exclusive => "exclusive", + } +} diff --git a/crates/merry-coding/src/runtime.rs b/crates/merry-coding/src/runtime.rs index 4356923a..653030cd 100644 --- a/crates/merry-coding/src/runtime.rs +++ b/crates/merry-coding/src/runtime.rs @@ -182,7 +182,6 @@ pub struct CodingRuntimeInput { model: ModelName, process_backend: Option>, extra_tools: Vec, - allow_hidden_workspace_paths: bool, automatic_compaction: AutomaticCompactionConfig, retry_policy: Option, model_roles: Vec, @@ -208,7 +207,6 @@ impl CodingRuntimeInput { model, process_backend: Some(process_backend), extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: AutomaticCompactionConfig::default(), retry_policy: None, model_roles: Vec::new(), @@ -233,7 +231,6 @@ impl CodingRuntimeInput { model, process_backend: None, extra_tools: Vec::new(), - allow_hidden_workspace_paths: false, automatic_compaction: AutomaticCompactionConfig::default(), retry_policy: None, model_roles: Vec::new(), @@ -253,13 +250,6 @@ impl CodingRuntimeInput { self } - /// Controls whether hidden workspace path components are readable. - #[must_use] - pub fn with_allow_hidden_workspace_paths(mut self, allow: bool) -> Self { - self.allow_hidden_workspace_paths = allow; - self - } - /// Sets automatic context compaction policy. #[must_use] pub fn with_automatic_compaction(mut self, config: AutomaticCompactionConfig) -> Self { @@ -424,7 +414,6 @@ impl CodingRuntimeBuilder { model, process_backend, extra_tools, - allow_hidden_workspace_paths, automatic_compaction, retry_policy, model_roles, @@ -469,7 +458,6 @@ impl CodingRuntimeBuilder { let mut profile_builder: CodingAgentProfileBuilder = coding_agent(&root) .readonly_resource_roots(skill_roots.clone()) - .allow_hidden(allow_hidden_workspace_paths) .limits(workspace_tool_limits) .register_tools(extra_tools); if let Some(project_rules) = project_rules { diff --git a/crates/merry-coding/src/workspace.rs b/crates/merry-coding/src/workspace.rs index d6842f5c..7b8dd187 100644 --- a/crates/merry-coding/src/workspace.rs +++ b/crates/merry-coding/src/workspace.rs @@ -20,8 +20,8 @@ use thiserror::Error; const PROJECT_CAPABILITY_CONTEXT_ID: &str = "project-capabilities"; const CODING_WORKSPACE_CAPABILITY_SUMMARY: &str = concat!( "Coding file capabilities:\n", - "- `read_text` reads a bounded one-based line range from a known UTF-8 text path. Omit the range only to use the small configured default; use multiple focused reads instead of requesting a whole file. Paths are relative to configured roots, and skill/resource roots are read-only and separate from write scope.\n", - "- `apply_patch` is the only file-edit tool. Use one patch envelope with localized Add File or Update File hunks; do not submit whole-file content for a small edit. Runtime admission, write scope, forbidden paths, and current-file preimages are enforced before writes.\n", + "- `read_text` reads a bounded one-based line range from a known UTF-8 text path. Omit the range only to use the small configured default; use multiple focused reads instead of requesting a whole file. A path is relative to the workspace root or absolute, every spelling including dot-prefixed components is accepted, and a relative path also resolves under the read-only skill/resource roots. An absolute path may name a file outside the workspace, where the sandbox decides what is reachable.\n", + "- `apply_patch` is the only file-edit tool: one `*** Begin Patch` envelope with `*** Add File:`, `*** Update File:`, or `*** Delete File:` sections, at most one Add or Delete section per file (repeated `*** Update File:` sections for one file merge into a single change), and localized hunks instead of whole-file content. Every hunk must match the current file bytes exactly and uniquely; when one does not, the failure names the closest line and the first difference, so re-read that line before retrying. Runtime admission, write scope, forbidden paths, and current-file preimages are enforced before writes. A section path is relative to the workspace root or absolute: an absolute path inside the workspace root and the matching relative path address the same file, and an absolute path may name a file outside the workspace, where the sandbox decides what is reachable and writable.\n", "- `run_process` is the discovery and verification lane when configured. Prefer the modern search tools the environment facts below report: `rg --files` to list files, a focused literal `rg` search for content, and `fd`/`fdfind` for paths by name; fall back to `grep -r` and `find` only for a tool the facts say is missing. Scope each search to the directories that own the behavior instead of the repository root, and exclude build output such as `target/`, `node_modules/`, and `.venv/`. Read files with bounded `sed -n ',p'`. Avoid broad recursive output, `cat` on large files, and repeated exploratory calls.\n", "- Process execution runs through Merry runtime policy and the configured sandbox/profile, so filesystem and network access may be intentionally restricted; environment and host IPC access may also be intentionally restricted. Network is withheld from every action that does not request it, so a command that reaches a remote service needs `network: true` in the same call's `permissions`. Paths and host integrations enabled by trusted global configuration are already available to actions. If a command needs access that is still missing, such as network, a reviewed path, or an unconfigured endpoint, put all minimum required capabilities in that same `run_process` call under `permissions`; runtime reviews before executing the exact command through the permissioned backend.\n", "- If a capability is discovered only after a sandboxed failure, call `request_permissions` for that exact action before retrying it. Approved paths and host integrations remain available for later actions in this runtime session; network access must be requested again for every action that needs it.\n", @@ -38,9 +38,8 @@ pub(crate) enum WorkspaceProcessRunnerConfig { /// Workspace tool and process-lane inputs used by the coding profile. #[derive(Clone)] pub(crate) struct WorkspaceCodingProfileBuilder { - pub(crate) roots: Vec, + pub(crate) root: PathBuf, pub(crate) readonly_resource_roots: Vec, - pub(crate) allow_hidden: bool, pub(crate) limits: WorkspaceToolLimits, pub(crate) patch_write_scope: Option>, pub(crate) forbidden_paths: Vec, @@ -52,19 +51,9 @@ impl WorkspaceCodingProfileBuilder { /// Creates a profile builder with one workspace root. #[must_use] pub(crate) fn new(root: impl Into) -> Self { - Self::with_roots([root]) - } - - /// Creates a profile builder with explicit workspace roots. - pub(crate) fn with_roots(roots: I) -> Self - where - I: IntoIterator, - P: Into, - { Self { - roots: roots.into_iter().map(Into::into).collect(), + root: root.into(), readonly_resource_roots: Vec::new(), - allow_hidden: false, limits: WorkspaceToolLimits::default(), patch_write_scope: None, forbidden_paths: Vec::new(), @@ -73,8 +62,9 @@ impl WorkspaceCodingProfileBuilder { } } + /// Replaces the workspace root. pub(crate) fn root(mut self, root: impl Into) -> Self { - self.roots.push(root.into()); + self.root = root.into(); self } @@ -87,11 +77,6 @@ impl WorkspaceCodingProfileBuilder { self } - pub(crate) fn allow_hidden(mut self, allow_hidden: bool) -> Self { - self.allow_hidden = allow_hidden; - self - } - pub(crate) fn limits(mut self, limits: WorkspaceToolLimits) -> Self { self.limits = limits; self @@ -139,16 +124,12 @@ impl WorkspaceCodingProfileBuilder { self, mut builder: RuntimeProfileBuilder, ) -> Result { - let config = WorkspaceToolsConfig::new(self.roots) + let config = WorkspaceToolsConfig::new(self.root.clone()) .with_readonly_resource_roots(self.readonly_resource_roots) - .with_allow_hidden(self.allow_hidden) .with_limits(self.limits) .with_patch_write_scope(self.patch_write_scope) .with_forbidden_paths(self.forbidden_paths); - let project_summary = config - .roots() - .iter() - .find_map(|root| project_capability_summary_for_root(root)); + let project_summary = project_capability_summary_for_root(config.root()); let workspace_tools = WorkspaceTools::new(config)?; // The environment facts name what this host actually provides so the @@ -227,9 +208,11 @@ impl WorkspaceCodingProfileBuilder { pub(crate) fn hash_material(&self) -> Vec { let mut material = Vec::new(); - for root in &self.roots { - append_hash_field(&mut material, "workspace-root", &root.to_string_lossy()); - } + append_hash_field( + &mut material, + "workspace-root", + &self.root.to_string_lossy(), + ); for root in &self.readonly_resource_roots { append_hash_field( &mut material, @@ -237,11 +220,6 @@ impl WorkspaceCodingProfileBuilder { &root.to_string_lossy(), ); } - append_hash_field( - &mut material, - "allow-hidden", - if self.allow_hidden { "on" } else { "off" }, - ); append_hash_field( &mut material, "patch-tool", diff --git a/crates/merry-py/src/builder.rs b/crates/merry-py/src/builder.rs index 321dd036..c390b008 100644 --- a/crates/merry-py/src/builder.rs +++ b/crates/merry-py/src/builder.rs @@ -84,9 +84,8 @@ impl PyAgentBuilder { #[allow(clippy::too_many_arguments)] fn with_workspace( &mut self, - roots: Vec, + root: String, readonly_resource_roots: Vec, - allow_hidden: bool, enable_patch: bool, patch_write_scope: Option>, forbidden_paths: Vec, @@ -95,23 +94,19 @@ impl PyAgentBuilder { max_write_bytes: usize, max_patch_bytes: usize, ) -> PyResult<()> { - if roots.is_empty() { - return Err(error::config_message_to_py( - "workspace requires at least one root", - )); + if root.is_empty() { + return Err(error::config_message_to_py("workspace requires a root")); } - let mut profile_builder = merry::profiles::CodingAgentProfileBuilder::with_roots( - roots.into_iter().map(PathBuf::from), - ) - .readonly_resource_roots(readonly_resource_roots.into_iter().map(PathBuf::from)) - .allow_hidden(allow_hidden) - .limits(WorkspaceToolLimits { - max_read_bytes, - max_read_lines, - max_write_bytes, - max_patch_bytes, - }) - .forbidden_paths(forbidden_paths.into_iter().map(PathBuf::from)); + let mut profile_builder = + merry::profiles::CodingAgentProfileBuilder::new(PathBuf::from(root)) + .readonly_resource_roots(readonly_resource_roots.into_iter().map(PathBuf::from)) + .limits(WorkspaceToolLimits { + max_read_bytes, + max_read_lines, + max_write_bytes, + max_patch_bytes, + }) + .forbidden_paths(forbidden_paths.into_iter().map(PathBuf::from)); if enable_patch { profile_builder = profile_builder.patch_tool(); if let Some(scope) = patch_write_scope { diff --git a/crates/merry-runtime/Cargo.toml b/crates/merry-runtime/Cargo.toml index 4c7593b0..1a344d6d 100644 --- a/crates/merry-runtime/Cargo.toml +++ b/crates/merry-runtime/Cargo.toml @@ -15,6 +15,7 @@ merry-tools-macros = { path = "../merry-tools-macros", version = "0.1.0" } schemars.workspace = true serde.workspace = true serde_json.workspace = true +serde_norway.workspace = true sha2.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/merry-runtime/src/context/projection.rs b/crates/merry-runtime/src/context/projection.rs index 783829e2..64207ea9 100644 --- a/crates/merry-runtime/src/context/projection.rs +++ b/crates/merry-runtime/src/context/projection.rs @@ -8,6 +8,7 @@ use crate::{ ActivatedMemory, MemoryActivationProvenance, MemoryActivationReason, MemoryActivationScore, MemoryEvidence, MemoryId, MemoryScope, }, + text, token_estimate::estimate_text_tokens, }; use merry_core::EvidenceRef; @@ -33,11 +34,7 @@ fn format_memory_scopes(scopes: &[MemoryScope]) -> String { } fn canonicalize_memory_reason_text(value: &str) -> String { - value - .split_whitespace() - .collect::>() - .join(" ") - .to_lowercase() + text::collapse_whitespace(value).to_lowercase() } /// Compiles allowlisted structured runtime state into a deterministic context snapshot. diff --git a/crates/merry-runtime/src/lib.rs b/crates/merry-runtime/src/lib.rs index 6f2d9f73..9ca4bc55 100644 --- a/crates/merry-runtime/src/lib.rs +++ b/crates/merry-runtime/src/lib.rs @@ -59,6 +59,7 @@ mod session_store; mod skill; mod step; mod subagent; +mod text; mod token_estimate; mod tool; mod tool_admission; @@ -155,7 +156,10 @@ pub use session_projection::SessionTranscriptItem; pub use session_store::{ FileSessionStore, PlanPersistenceLocation, SessionReservation, SessionStoreError, }; -pub use skill::{SkillCatalog, SkillError, SkillLoadWarning, SkillMetadata}; +pub use skill::{ + FrontmatterError, SkillCatalog, SkillError, SkillLoadWarning, SkillLoadWarningReason, + SkillMetadata, +}; pub use step::StepContext; pub use subagent::{ CancelSubagentsInput, ChildRuntimeFactory, ChildRuntimeInput, ChildWorkspaceScope, diff --git a/crates/merry-runtime/src/memory.rs b/crates/merry-runtime/src/memory.rs index 985ea4bb..cb8b6a1e 100644 --- a/crates/merry-runtime/src/memory.rs +++ b/crates/merry-runtime/src/memory.rs @@ -7,6 +7,7 @@ // Staged internal activation types are compiled before every call path is wired. #![cfg_attr(not(test), allow(dead_code))] +use crate::text; use merry_core::EvidenceRef; use std::{cmp::Ordering, fmt}; use thiserror::Error; @@ -608,15 +609,11 @@ fn validate_non_blank(field: &'static str, value: &str) -> Result<(), MemoryErro } fn canonicalize_match_text(value: &str) -> String { - value - .split_whitespace() - .collect::>() - .join(" ") - .to_lowercase() + text::collapse_whitespace(value).to_lowercase() } fn canonicalize_label_text(value: &str) -> String { - value.split_whitespace().collect::>().join(" ") + text::collapse_whitespace(value) } fn validate_reason(reason: &MemoryActivationReason) -> Result<(), MemoryError> { diff --git a/crates/merry-runtime/src/skill.rs b/crates/merry-runtime/src/skill.rs index 5a987f19..9e556f1e 100644 --- a/crates/merry-runtime/src/skill.rs +++ b/crates/merry-runtime/src/skill.rs @@ -2,16 +2,24 @@ //! //! Skills are discovered from `SKILL.md` files, but only frontmatter metadata //! enters the cacheable stable prefix. Full skill bodies remain available -//! through normal workspace file reads. +//! through normal workspace file reads. Frontmatter is parsed as YAML by the +//! `frontmatter` submodule, which reads only `name` and `description`. use std::{ collections::{BTreeMap, btree_map::Entry}, - fs, io, + fmt, fs, path::{Path, PathBuf}, }; use thiserror::Error; +use crate::text; + +#[path = "skill/frontmatter.rs"] +mod frontmatter; + +pub use frontmatter::FrontmatterError; + const SKILLS_INTRO: &str = "A skill is a set of local instructions stored in a `SKILL.md` file. The list below is metadata for discovery only; skill bodies stay on disk until needed."; const SKILLS_HOW_TO_USE: &str = r#"- If the user explicitly names a skill, including with a `$skill-name` token, use it for that turn. - If the task clearly matches a skill description, read that skill's `SKILL.md` before relying on it. @@ -29,8 +37,8 @@ pub enum SkillError { /// Field name. field: &'static str, }, - /// A required field had unsupported control characters. - #[error("{field} must not contain control characters")] + /// A required single-line field contained control characters or line breaks. + #[error("{field} must be single-line text without control characters")] ControlCharacters { /// Field name. field: &'static str, @@ -49,12 +57,6 @@ pub enum SkillError { /// Duplicate skill path. path: String, }, - /// Configured skill root does not exist. - #[error("skill root does not exist: {root}")] - RootNotFound { - /// Configured root. - root: String, - }, /// Configured skill root is not a directory. #[error("skill root is not a directory: {root}")] RootNotDirectory { @@ -82,6 +84,11 @@ pub struct SkillMetadata { impl SkillMetadata { /// Creates validated skill metadata. + /// + /// Normalization happens here so every consumer sees the same single-line + /// values: `name` is trimmed and `description` has its whitespace + /// collapsed. Fields that stay blank or contain control characters are + /// rejected. pub fn new( name: impl Into, description: impl Into, @@ -89,12 +96,13 @@ impl SkillMetadata { root: PathBuf, ) -> Result { let name = name.into(); - validate_text("skill name", &name)?; - let description = description.into(); - validate_text("skill description", &description)?; + let name = name.trim(); + validate_single_line("skill name", name)?; + let description = text::collapse_whitespace(&description.into()); + validate_single_line("skill description", &description)?; validate_skill_path(&skill_md_path)?; Ok(Self { - name, + name: name.to_owned(), description, skill_md_path, root, @@ -183,7 +191,9 @@ impl SkillCatalog { } Entry::Occupied(_) => warnings.push(SkillLoadWarning::new( skill.skill_md_path.clone(), - format!("duplicate skill name `{}` was skipped", skill.name()), + SkillLoadWarningReason::DuplicateName { + name: skill.name().to_owned(), + }, )), } } @@ -251,17 +261,14 @@ impl SkillCatalog { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SkillLoadWarning { path: PathBuf, - message: String, + reason: SkillLoadWarningReason, } impl SkillLoadWarning { /// Creates a skill load warning. #[must_use] - pub fn new(path: PathBuf, message: impl Into) -> Self { - Self { - path, - message: message.into(), - } + pub fn new(path: PathBuf, reason: SkillLoadWarningReason) -> Self { + Self { path, reason } } /// Path that produced the warning. @@ -270,21 +277,47 @@ impl SkillLoadWarning { &self.path } - /// Human-readable warning detail. + /// Typed reason the skill was left out of the catalog. #[must_use] - pub fn message(&self) -> &str { - &self.message + pub fn reason(&self) -> &SkillLoadWarningReason { + &self.reason } } -fn validate_text(field: &'static str, value: &str) -> Result<(), SkillError> { +impl fmt::Display for SkillLoadWarning { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.path.display(), self.reason) + } +} + +/// Reason a discovered `SKILL.md` file was left out of the catalog. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SkillLoadWarningReason { + /// The file could not be read from disk. + #[error("failed to read file: {message}")] + Read { + /// IO error detail. + message: String, + }, + /// The frontmatter was missing or invalid. + #[error(transparent)] + Frontmatter(#[from] FrontmatterError), + /// The frontmatter parsed, but the resulting metadata was rejected. + #[error(transparent)] + InvalidMetadata(#[from] SkillError), + /// Another skill in the same catalog already used this name. + #[error("duplicate skill name `{name}` was skipped")] + DuplicateName { + /// Duplicate skill name. + name: String, + }, +} + +fn validate_single_line(field: &'static str, value: &str) -> Result<(), SkillError> { if value.trim().is_empty() { return Err(SkillError::Blank { field }); } - if value - .chars() - .any(|character| character.is_control() && character != '\n' && character != '\t') - { + if value.chars().any(char::is_control) { return Err(SkillError::ControlCharacters { field }); } Ok(()) @@ -322,12 +355,21 @@ fn scan_root( } let mut scanned_dirs = 0usize; - scan_dir(root, root, 0, &mut scanned_dirs, skills, warnings) + scan_dir( + root, + root, + Path::new(""), + 0, + &mut scanned_dirs, + skills, + warnings, + ) } fn scan_dir( root: &Path, dir: &Path, + relative_dir: &Path, depth: usize, scanned_dirs: &mut usize, skills: &mut Vec, @@ -340,9 +382,10 @@ fn scan_dir( let skill_md = dir.join(SKILLS_FILENAME); if skill_md.is_file() { - match parse_skill_file(root, &skill_md) { + let relative_skill_md = relative_dir.join(SKILLS_FILENAME); + match parse_skill_file(&skill_md, &relative_skill_md, root) { Ok(metadata) => skills.push(metadata), - Err(message) => warnings.push(SkillLoadWarning::new(skill_md, message)), + Err(reason) => warnings.push(SkillLoadWarning::new(skill_md, reason)), } } @@ -361,14 +404,15 @@ fn scan_dir( message: source.to_string(), })?; if file_type.is_dir() { - child_dirs.push(entry.path()); + child_dirs.push((entry.path(), relative_dir.join(entry.file_name()))); } } child_dirs.sort(); - for child_dir in child_dirs { + for (child_dir, child_relative_dir) in child_dirs { scan_dir( root, &child_dir, + &child_relative_dir, depth.saturating_add(1), scanned_dirs, skills, @@ -379,235 +423,28 @@ fn scan_dir( Ok(()) } -fn parse_skill_file(root: &Path, skill_md: &Path) -> Result { - let text = fs::read_to_string(skill_md).map_err(read_error)?; - let frontmatter = frontmatter_block(&text)?; - let fields = parse_frontmatter_fields(frontmatter)?; - let name = fields - .name - .ok_or_else(|| "missing field `name`".to_owned())?; - let description = fields - .description - .ok_or_else(|| "missing field `description`".to_owned())?; - let relative_path = skill_md - .strip_prefix(root) - .map_err(|_| "skill path is outside configured root".to_owned())? - .to_path_buf(); - SkillMetadata::new(name, description, relative_path, root.to_path_buf()) - .map_err(|error| error.to_string()) -} - -fn read_error(error: io::Error) -> String { - format!("failed to read file: {error}") -} - -fn frontmatter_block(text: &str) -> Result<&str, String> { - let Some(rest) = text.strip_prefix("---\n") else { - return Err("missing frontmatter delimited by ---".to_owned()); - }; - let Some((frontmatter, _body)) = rest.split_once("\n---") else { - return Err("missing closing frontmatter delimiter".to_owned()); - }; - Ok(frontmatter) -} - -#[derive(Default)] -struct FrontmatterFields { - name: Option, - description: Option, -} - -fn parse_frontmatter_fields(frontmatter: &str) -> Result { - let mut fields = FrontmatterFields::default(); - for line in frontmatter.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if line != trimmed { - return Err(format!("invalid frontmatter line: {line}")); - } - let Some((key, value)) = trimmed.split_once(':') else { - return Err(format!("invalid frontmatter line: {trimmed}")); - }; - let value = value.trim(); - match key.trim() { - "name" => fields.name = Some(unquote_frontmatter_value(value).to_owned()), - "description" => fields.description = Some(unquote_frontmatter_value(value).to_owned()), - _ => {} - } - } - Ok(fields) -} - -fn unquote_frontmatter_value(value: &str) -> &str { - value - .strip_prefix('"') - .and_then(|rest| rest.strip_suffix('"')) - .or_else(|| { - value - .strip_prefix('\'') - .and_then(|rest| rest.strip_suffix('\'')) - }) - .unwrap_or(value) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn metadata(name: &str, description: &str, path: &str) -> SkillMetadata { - SkillMetadata::new( - name, - description, - PathBuf::from(path), - PathBuf::from("/workspace"), - ) - .expect("valid skill metadata") - } - - #[test] - fn renders_available_skills_without_bodies() { - let catalog = SkillCatalog::from_metadata(vec![ - metadata( - "frontend-design", - "Use for polished frontend implementation.", - "skills/frontend-design/SKILL.md", - ), - metadata( - "debugging", - "Use for systematic debugging.", - "skills/debugging/SKILL.md", - ), - ]) - .expect("valid catalog"); - - let rendered = catalog - .to_stable_prefix_message_text() - .expect("catalog should render"); - - assert!(rendered.contains("## Skills")); - assert!(rendered.contains("frontend-design")); - assert!(rendered.contains("Use for polished frontend implementation.")); - assert!(rendered.contains("skills/frontend-design/SKILL.md")); - assert!(rendered.contains("read_text")); - assert!(rendered.contains("Read only the referenced files and ranges needed")); - assert!(!rendered.contains("# Frontend Design")); - assert!(!rendered.contains("full skill body sentinel")); - } - - #[test] - fn metadata_order_is_deterministic() { - let first = SkillCatalog::from_metadata(vec![ - metadata("zeta", "Last alphabetically.", "skills/zeta/SKILL.md"), - metadata("alpha", "First alphabetically.", "skills/alpha/SKILL.md"), - ]) - .expect("valid catalog"); - let second = SkillCatalog::from_metadata(vec![ - metadata("alpha", "First alphabetically.", "skills/alpha/SKILL.md"), - metadata("zeta", "Last alphabetically.", "skills/zeta/SKILL.md"), - ]) - .expect("valid catalog"); - - assert_eq!( - first.to_stable_prefix_message_text().expect("renders"), - second.to_stable_prefix_message_text().expect("renders") - ); - } - - #[test] - fn rejects_blank_or_control_metadata() { - let blank = SkillMetadata::new( - " ", - "Valid description.", - PathBuf::from("skills/blank/SKILL.md"), - PathBuf::from("/workspace"), - ) - .expect_err("blank name should be rejected"); - assert!(blank.to_string().contains("skill name")); - - let control = SkillMetadata::new( - "bad\u{7}name", - "Valid description.", - PathBuf::from("skills/bad/SKILL.md"), - PathBuf::from("/workspace"), - ) - .expect_err("control characters should be rejected"); - assert!(control.to_string().contains("skill name")); - } +/// Reads one skill file and turns its frontmatter into validated metadata. +/// +/// `relative_skill_md` is built by the directory walk, so metadata never has to +/// re-derive a root-relative path from the absolute scan path. +fn parse_skill_file( + skill_md: &Path, + relative_skill_md: &Path, + root: &Path, +) -> Result { + let text = fs::read_to_string(skill_md).map_err(|source| SkillLoadWarningReason::Read { + message: source.to_string(), + })?; + let fields = frontmatter::parse(&text)?; + SkillMetadata::new( + fields.name(), + fields.description(), + relative_skill_md.to_path_buf(), + root.to_path_buf(), + ) + .map_err(SkillLoadWarningReason::InvalidMetadata) } #[cfg(test)] -mod loader_tests { - use super::*; - - fn write(path: &Path, text: &str) { - fs::create_dir_all(path.parent().expect("test path has parent")).expect("mkdir"); - fs::write(path, text).expect("write"); - } - - #[test] - fn loads_skill_metadata_from_roots() { - let temp = tempfile::tempdir().expect("tempdir"); - let root = temp.path().join("skills"); - write( - &root.join("frontend/SKILL.md"), - r#"--- -name: frontend-design -description: Use when building polished frontend UI. ---- - -# Frontend Design - -full skill body sentinel -"#, - ); - - let catalog = SkillCatalog::load_from_roots([root.clone()]).expect("loads catalog"); - assert_eq!(catalog.skills().len(), 1); - assert_eq!(catalog.skills()[0].name(), "frontend-design"); - assert_eq!( - catalog.skills()[0].description(), - "Use when building polished frontend UI." - ); - assert_eq!( - catalog.skills()[0].skill_md_path(), - Path::new("frontend/SKILL.md") - ); - assert!(catalog.warnings().is_empty()); - } - - #[test] - fn skips_invalid_skill_frontmatter_with_warning() { - let temp = tempfile::tempdir().expect("tempdir"); - let root = temp.path().join("skills"); - write( - &root.join("valid/SKILL.md"), - "---\nname: valid\n description: bad indentation\n---\n", - ); - write( - &root.join("missing-description/SKILL.md"), - "---\nname: missing-description\n---\n", - ); - write( - &root.join("ok/SKILL.md"), - "---\nname: ok\ndescription: Valid skill.\n---\n# OK\n", - ); - - let catalog = SkillCatalog::load_from_roots([root]).expect("load should not fail"); - assert_eq!(catalog.skills().len(), 1); - assert_eq!(catalog.skills()[0].name(), "ok"); - assert_eq!(catalog.warnings().len(), 2); - } - - #[test] - fn missing_skill_root_loads_empty_catalog() { - let temp = tempfile::tempdir().expect("tempdir"); - let root = temp.path().join("missing-skills"); - - let catalog = SkillCatalog::load_from_roots([root]).expect("missing root is empty"); - - assert!(catalog.is_empty()); - assert!(catalog.warnings().is_empty()); - } -} +#[path = "skill/tests.rs"] +mod tests; diff --git a/crates/merry-runtime/src/skill/frontmatter.rs b/crates/merry-runtime/src/skill/frontmatter.rs new file mode 100644 index 00000000..0de51b3a --- /dev/null +++ b/crates/merry-runtime/src/skill/frontmatter.rs @@ -0,0 +1,259 @@ +//! `SKILL.md` frontmatter extraction and YAML parsing. +//! +//! Skill frontmatter is the YAML document between the leading `---` +//! delimiters. Merry reads only `name` and `description` and intentionally +//! ignores every other top-level key, so third-party skill files that carry +//! structured `metadata`, tool policy, or license sections still load. +//! +//! Parsing the block as YAML instead of scanning it line by line is what makes +//! block scalars (`|`, `>`), quoted values, trailing comments, plain +//! multi-line scalars, CRLF line endings, and a leading byte-order mark behave +//! the way their authors expect. + +use serde::Deserialize; +use thiserror::Error; + +/// Frontmatter values Merry requires from one `SKILL.md` file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillFrontmatter { + name: String, + description: String, +} + +impl SkillFrontmatter { + /// Skill name as written in the frontmatter. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Skill description as written in the frontmatter. + #[must_use] + pub fn description(&self) -> &str { + &self.description + } +} + +/// Errors raised while extracting and parsing `SKILL.md` frontmatter. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum FrontmatterError { + /// The document does not open with a `---` line. + #[error("missing frontmatter delimited by ---")] + MissingDelimiter, + /// The opening `---` line has no closing `---` line. + #[error("missing closing frontmatter delimiter")] + MissingClosingDelimiter, + /// A required field is absent or null. + #[error("missing frontmatter field `{field}`")] + MissingField { + /// Missing field name. + field: &'static str, + }, + /// The frontmatter is not valid YAML for the fields Merry reads. + #[error("invalid frontmatter: {message}")] + Invalid { + /// Deserialization detail. + message: String, + }, +} + +/// Frontmatter as deserialized from YAML. +/// +/// Unknown keys are ignored on purpose: real skill files carry `metadata`, +/// `license`, or tool policy sections that Merry does not model, and an +/// unsupported key must not hide the whole skill from the catalog. +#[derive(Debug, Deserialize)] +struct FrontmatterDocument { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, +} + +/// Extracts `name` and `description` from one `SKILL.md` document. +/// +/// The complete file is accepted and only its frontmatter block is parsed. +/// Failures are reported as [`FrontmatterError`] so the caller can skip this +/// skill with a typed warning instead of rejecting the configured root. +pub fn parse(text: &str) -> Result { + let block = frontmatter_block(text)?; + let document = + serde_norway::from_str::>(block).map_err(|error| { + FrontmatterError::Invalid { + message: error.to_string(), + } + })?; + let Some(document) = document else { + return Err(FrontmatterError::MissingField { field: "name" }); + }; + Ok(SkillFrontmatter { + name: document + .name + .ok_or(FrontmatterError::MissingField { field: "name" })?, + description: document.description.ok_or(FrontmatterError::MissingField { + field: "description", + })?, + }) +} + +/// Returns the YAML text between the opening and closing `---` lines. +/// +/// Delimiter lines are matched textually, which stays consistent with YAML +/// document boundaries because block scalar content is always indented. +fn frontmatter_block(text: &str) -> Result<&str, FrontmatterError> { + // Editors on Windows may save a byte-order mark before the delimiter. + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let mut offset = 0usize; + let mut block_start = None; + for line in text.split_inclusive('\n') { + if line.trim_end() == "---" { + match block_start { + None => block_start = Some(offset + line.len()), + Some(start) => return Ok(&text[start..offset]), + } + } else if block_start.is_none() { + return Err(FrontmatterError::MissingDelimiter); + } + offset += line.len(); + } + match block_start { + None => Err(FrontmatterError::MissingDelimiter), + Some(_) => Err(FrontmatterError::MissingClosingDelimiter), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_document(text: &str) -> SkillFrontmatter { + parse(text).expect("frontmatter should parse") + } + + #[test] + fn reads_name_and_description_and_ignores_structured_metadata() { + let skill = parse_document( + "---\nname: sample-tool\ndescription: Use when the task needs the sample tool.\nmetadata:\n cli_version: \">=1.2.3\"\n category: product\n requires:\n bins:\n - sample-cli\n---\n\n# Sample Tool Skill\n", + ); + + assert_eq!(skill.name(), "sample-tool"); + assert_eq!( + skill.description(), + "Use when the task needs the sample tool." + ); + } + + #[test] + fn folds_plain_multi_line_scalars() { + let skill = parse_document( + "---\nname: sample-tool\ndescription: Use when the task needs\n the sample tool.\n---\n# Body\n", + ); + + assert_eq!( + skill.description(), + "Use when the task needs the sample tool." + ); + } + + #[test] + fn parses_block_scalars_and_comments() { + let folded = parse_document( + "---\nname: folded\ndescription: >-\n Use when the task\n needs the sample tool.\n---\n", + ); + assert_eq!( + folded.description(), + "Use when the task needs the sample tool." + ); + + let literal = parse_document( + "---\nname: literal\ndescription: |\n First line.\n Second line.\n---\n", + ); + assert_eq!(literal.description(), "First line.\nSecond line.\n"); + + let commented = parse_document( + "---\n# generated skill\nname: commented\ndescription: Use when parsing comments. # trailing note\nlicense: MIT\n---\n", + ); + assert_eq!(commented.name(), "commented"); + assert_eq!(commented.description(), "Use when parsing comments."); + } + + #[test] + fn parses_quoted_values_and_crlf_and_byte_order_mark() { + let quoted = parse_document( + "---\nname: \"quoted\"\ndescription: 'Use when quoted: values matter.'\n---\n", + ); + assert_eq!(quoted.name(), "quoted"); + assert_eq!(quoted.description(), "Use when quoted: values matter."); + + let crlf = parse_document( + "---\r\nname: windows\r\ndescription: Windows-authored skill.\r\n---\r\n# Body\r\n", + ); + assert_eq!(crlf.description(), "Windows-authored skill."); + + let bom = parse_document( + "\u{feff}---\nname: bom\ndescription: BOM-authored skill.\n---\n# Body\n", + ); + assert_eq!(bom.name(), "bom"); + assert_eq!(bom.description(), "BOM-authored skill."); + } + + #[test] + fn reports_missing_delimiters() { + assert_eq!( + parse("# Body only\n"), + Err(FrontmatterError::MissingDelimiter) + ); + assert_eq!( + parse("---\nname: sample-tool\n"), + Err(FrontmatterError::MissingClosingDelimiter) + ); + } + + #[test] + fn reports_missing_and_null_fields() { + assert_eq!( + parse("---\nname: sample-tool\n---\n"), + Err(FrontmatterError::MissingField { + field: "description" + }) + ); + assert_eq!( + parse("---\ndescription: Use when needed.\n---\n"), + Err(FrontmatterError::MissingField { field: "name" }) + ); + assert_eq!( + parse("---\nname:\ndescription: Use when needed.\n---\n"), + Err(FrontmatterError::MissingField { field: "name" }) + ); + assert_eq!( + parse("---\n---\n"), + Err(FrontmatterError::MissingField { field: "name" }) + ); + } + + #[test] + fn reports_non_scalar_and_invalid_frontmatter() { + let non_scalar = parse("---\nname: sample-tool\ndescription:\n nested: value\n---\n") + .expect_err("nested description should be rejected"); + assert!( + matches!(non_scalar, FrontmatterError::Invalid { .. }), + "{non_scalar:?}" + ); + + let invalid = parse("---\nname: sample-tool\ndescription: [unclosed\n---\n") + .expect_err("invalid YAML should be rejected"); + assert!( + matches!(invalid, FrontmatterError::Invalid { .. }), + "{invalid:?}" + ); + } + + #[test] + fn stringifies_plain_scalar_field_values() { + // YAML resolves an unquoted number to a scalar; reading it as text keeps + // frontmatter written with `name: 123` loadable instead of rejected. + let skill = parse_document("---\nname: 123\ndescription: Use when needed.\n---\n"); + + assert_eq!(skill.name(), "123"); + } +} diff --git a/crates/merry-runtime/src/skill/tests.rs b/crates/merry-runtime/src/skill/tests.rs new file mode 100644 index 00000000..3333984c --- /dev/null +++ b/crates/merry-runtime/src/skill/tests.rs @@ -0,0 +1,291 @@ +//! Tests for skill metadata rendering and filesystem-based loading. +//! +//! Rendering tests build metadata directly, so they pin the stable-prefix text +//! and the single-line invariants without touching the filesystem. Loader tests +//! write real `SKILL.md` files, so they pin what a skill root accepts, what it +//! warns about, and what it skips. + +use super::*; + +/// Builds valid metadata for one skill without touching the filesystem. +fn metadata(name: &str, description: &str, path: &str) -> SkillMetadata { + SkillMetadata::new( + name, + description, + PathBuf::from(path), + PathBuf::from("/workspace"), + ) + .expect("valid skill metadata") +} + +/// Stable-prefix rendering of catalog metadata. +mod rendering { + use super::*; + + #[test] + fn renders_available_skills_without_bodies() { + let catalog = SkillCatalog::from_metadata(vec![ + metadata( + "frontend-design", + "Use for polished frontend implementation.", + "skills/frontend-design/SKILL.md", + ), + metadata( + "debugging", + "Use for systematic debugging.", + "skills/debugging/SKILL.md", + ), + ]) + .expect("valid catalog"); + + let rendered = catalog + .to_stable_prefix_message_text() + .expect("catalog should render"); + + assert!(rendered.contains("## Skills")); + assert!(rendered.contains("frontend-design")); + assert!(rendered.contains("Use for polished frontend implementation.")); + assert!(rendered.contains("skills/frontend-design/SKILL.md")); + assert!(rendered.contains("read_text")); + assert!(rendered.contains("Read only the referenced files and ranges needed")); + assert!(!rendered.contains("# Frontend Design")); + assert!(!rendered.contains("full skill body sentinel")); + } + + #[test] + fn metadata_order_is_deterministic() { + let first = SkillCatalog::from_metadata(vec![ + metadata("zeta", "Last alphabetically.", "skills/zeta/SKILL.md"), + metadata("alpha", "First alphabetically.", "skills/alpha/SKILL.md"), + ]) + .expect("valid catalog"); + let second = SkillCatalog::from_metadata(vec![ + metadata("alpha", "First alphabetically.", "skills/alpha/SKILL.md"), + metadata("zeta", "Last alphabetically.", "skills/zeta/SKILL.md"), + ]) + .expect("valid catalog"); + + assert_eq!( + first.to_stable_prefix_message_text().expect("renders"), + second.to_stable_prefix_message_text().expect("renders") + ); + } + + #[test] + fn rejects_blank_or_control_metadata() { + let blank = SkillMetadata::new( + " ", + "Valid description.", + PathBuf::from("skills/blank/SKILL.md"), + PathBuf::from("/workspace"), + ) + .expect_err("blank name should be rejected"); + assert!(blank.to_string().contains("skill name")); + + let control = SkillMetadata::new( + "bad\u{7}name", + "Valid description.", + PathBuf::from("skills/bad/SKILL.md"), + PathBuf::from("/workspace"), + ) + .expect_err("control characters should be rejected"); + assert!(control.to_string().contains("skill name")); + } + + #[test] + fn normalizes_description_to_one_line() { + let skill = metadata( + "sample-tool", + "Use when the description\n spans several lines.", + "skills/sample-tool/SKILL.md", + ); + assert_eq!( + skill.description(), + "Use when the description spans several lines." + ); + + let catalog = SkillCatalog::from_metadata(vec![skill]).expect("valid catalog"); + let rendered = catalog + .to_stable_prefix_message_text() + .expect("catalog should render"); + let entry = rendered + .lines() + .find(|line| line.contains("sample-tool")) + .expect("catalog should list the skill"); + assert_eq!( + entry, + "- sample-tool: Use when the description spans several lines. (file: skills/sample-tool/SKILL.md)" + ); + } + + #[test] + fn rejects_multi_line_names() { + let error = SkillMetadata::new( + "sample\ntool", + "Valid description.", + PathBuf::from("skills/sample/SKILL.md"), + PathBuf::from("/workspace"), + ) + .expect_err("line breaks in a name should be rejected"); + + assert!(error.to_string().contains("skill name"), "{error}"); + } +} + +/// Loading skill roots from the filesystem. +mod loader { + use super::*; + + /// Writes one test file, creating the parent directories it needs. + fn write(path: &Path, text: &str) { + fs::create_dir_all(path.parent().expect("test path has parent")).expect("mkdir"); + fs::write(path, text).expect("write"); + } + + #[test] + fn loads_skill_frontmatter_from_roots() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("skills"); + write( + &root.join("frontend/SKILL.md"), + "---\nname: frontend-design\ndescription: Use when building polished frontend UI.\n---\n\n# Frontend Design\n\nfull skill body sentinel\n", + ); + write( + &root.join("sample-tool/SKILL.md"), + "---\nname: sample-tool\ndescription: Use when the task needs\n the sample tool.\nmetadata:\n cli_version: \">=1.2.3\"\n requires:\n bins:\n - sample-cli\n---\n\n# Sample Tool Skill\n", + ); + write( + &root.join("windows/SKILL.md"), + "\u{feff}---\r\nname: windows\r\ndescription: Windows-authored skill.\r\n---\r\n# Windows\r\n", + ); + + let catalog = SkillCatalog::load_from_roots([root]).expect("loads catalog"); + assert!(catalog.warnings().is_empty(), "{:?}", catalog.warnings()); + let skills = catalog + .skills() + .iter() + .map(|skill| { + ( + skill.name(), + skill.description(), + skill.skill_md_path().to_path_buf(), + ) + }) + .collect::>(); + assert_eq!( + skills, + vec![ + ( + "frontend-design", + "Use when building polished frontend UI.", + PathBuf::from("frontend/SKILL.md"), + ), + ( + "sample-tool", + "Use when the task needs the sample tool.", + PathBuf::from("sample-tool/SKILL.md"), + ), + ( + "windows", + "Windows-authored skill.", + PathBuf::from("windows/SKILL.md"), + ), + ] + ); + + let rendered = catalog + .to_stable_prefix_message_text() + .expect("catalog should render"); + assert!(rendered.contains("frontend/SKILL.md")); + assert!(!rendered.contains("# Frontend Design")); + assert!(!rendered.contains("full skill body sentinel")); + } + + #[test] + fn skips_invalid_skill_files_with_typed_warnings() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("skills"); + write( + &root.join("bad-name/SKILL.md"), + "---\nname: |\n bad\n name\ndescription: Use when invalid.\n---\n", + ); + write( + &root.join("missing-description/SKILL.md"), + "---\nname: missing-description\n---\n", + ); + write( + &root.join("nested-description/SKILL.md"), + "---\nname: nested-description\ndescription:\n nested: value\n---\n", + ); + write(&root.join("no-frontmatter/SKILL.md"), "# Body only\n"); + write( + &root.join("ok/SKILL.md"), + "---\nname: ok\ndescription: Valid skill.\n---\n# OK\n", + ); + + let catalog = SkillCatalog::load_from_roots([root]).expect("load should not fail"); + assert_eq!(catalog.skills().len(), 1); + assert_eq!(catalog.skills()[0].name(), "ok"); + + let warnings = catalog.warnings(); + assert_eq!(warnings.len(), 4); + assert!(warnings[0].path().ends_with("bad-name/SKILL.md")); + assert!(matches!( + warnings[0].reason(), + SkillLoadWarningReason::InvalidMetadata(SkillError::ControlCharacters { + field: "skill name" + }) + )); + assert!(matches!( + warnings[1].reason(), + SkillLoadWarningReason::Frontmatter(FrontmatterError::MissingField { + field: "description" + }) + )); + assert!(matches!( + warnings[2].reason(), + SkillLoadWarningReason::Frontmatter(FrontmatterError::Invalid { .. }) + )); + assert!(matches!( + warnings[3].reason(), + SkillLoadWarningReason::Frontmatter(FrontmatterError::MissingDelimiter) + )); + } + + #[test] + fn warns_about_duplicate_skill_names() { + let temp = tempfile::tempdir().expect("tempdir"); + let first_root = temp.path().join("first"); + let second_root = temp.path().join("second"); + write( + &first_root.join("sample/SKILL.md"), + "---\nname: sample-tool\ndescription: First copy.\n---\n", + ); + write( + &second_root.join("sample/SKILL.md"), + "---\nname: Sample-Tool\ndescription: Second copy.\n---\n", + ); + + let catalog = + SkillCatalog::load_from_roots([first_root, second_root]).expect("loads catalog"); + + assert_eq!(catalog.skills().len(), 1); + assert_eq!(catalog.skills()[0].description(), "First copy."); + assert_eq!(catalog.warnings().len(), 1); + assert!(matches!( + catalog.warnings()[0].reason(), + SkillLoadWarningReason::DuplicateName { name } if name == "Sample-Tool" + )); + } + + #[test] + fn missing_skill_root_loads_empty_catalog() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("missing-skills"); + + let catalog = SkillCatalog::load_from_roots([root]).expect("missing root is empty"); + + assert!(catalog.is_empty()); + assert!(catalog.warnings().is_empty()); + } +} diff --git a/crates/merry-runtime/src/text.rs b/crates/merry-runtime/src/text.rs new file mode 100644 index 00000000..bcc1fc32 --- /dev/null +++ b/crates/merry-runtime/src/text.rs @@ -0,0 +1,24 @@ +//! Small text-normalization helpers shared by runtime records. + +/// Collapses every whitespace run into one space and trims both ends. +/// +/// Skill descriptions, memory labels, and memory reasoning text are stored and +/// compared as single-line values, so they share one normalization rule instead +/// of each module reimplementing the same `split_whitespace` conversion. +pub(crate) fn collapse_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collapses_line_breaks_and_padding() { + assert_eq!( + collapse_whitespace(" Use when\n\t the task needs the tool. \n"), + "Use when the task needs the tool." + ); + assert_eq!(collapse_whitespace("\n \t\n"), ""); + } +} diff --git a/crates/merry-runtime/src/tool/patch_evidence.rs b/crates/merry-runtime/src/tool/patch_evidence.rs index c98aab03..aa17fddd 100644 --- a/crates/merry-runtime/src/tool/patch_evidence.rs +++ b/crates/merry-runtime/src/tool/patch_evidence.rs @@ -4,8 +4,8 @@ use std::path::{Component, Path}; /// Per-file metadata for a constrained workspace patch change. /// -/// This stores only relative workspace identity, byte counts, and stable -/// non-cryptographic content fingerprints. It does not store old or new text. +/// This stores only file identity, byte counts, and stable non-cryptographic +/// content fingerprints. It does not store old or new text. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WorkspacePatchChangeEvidence { @@ -29,7 +29,7 @@ impl WorkspacePatchChangeEvidence { file_fingerprint_before: impl Into, file_fingerprint_after: impl Into, ) -> Result { - let relative_path = validate_apply_patch_relative_path(relative_path.into())?; + let relative_path = validate_apply_patch_path(relative_path.into())?; validate_apply_patch_counts( preimage_bytes, replacement_bytes, @@ -56,7 +56,10 @@ impl WorkspacePatchChangeEvidence { }) } - /// Returns the workspace-relative path using `/` separators. + /// Returns the changed path using `/` separators. + /// + /// The value stays workspace-relative while the change is below a configured + /// root, and is the absolute path the caller named when it is not. #[must_use] pub fn relative_path(&self) -> &str { &self.relative_path @@ -151,7 +154,7 @@ impl WorkspacePatchExecutionEvidence { .expect("workspace patch execution evidence always has at least one change") } - /// Returns the first workspace-relative path using `/` separators. + /// Returns the first changed path using `/` separators. #[must_use] pub fn relative_path(&self) -> &str { self.first_change().relative_path() @@ -247,7 +250,7 @@ impl WorkspacePatchProposal { .expect("workspace patch proposal always has at least one change") } - /// Returns the first workspace-relative path using `/` separators. + /// Returns the first changed path using `/` separators. #[must_use] pub fn relative_path(&self) -> &str { self.first_change().relative_path() @@ -372,9 +375,15 @@ pub(super) fn validate_apply_patch_fingerprint( pub(super) const MAX_WORKSPACE_PATCH_RELATIVE_PATH_BYTES: usize = 4096; -pub(super) fn validate_apply_patch_relative_path( - value: String, -) -> Result { +/// Validates the path a patch change reports. +/// +/// The value is the workspace-relative path of the change, or its absolute path +/// when the change is outside every configured workspace root. Both forms are +/// accepted because a patch tool may name a file the sandbox exposes rather than +/// one below a root. What is rejected is ambiguity: blank text, control +/// characters, empty segments, and dot segments that would make two different +/// spellings of one target compare unequal. +pub(super) fn validate_apply_patch_path(value: String) -> Result { if value.trim().is_empty() { return Err(ActionProposalError::InvalidWorkspacePatch { field: "relative_path", @@ -393,18 +402,20 @@ pub(super) fn validate_apply_patch_relative_path( reason: "must not contain control characters", }); } - if value.split('/').any(str::is_empty) { + + let path = Path::new(&value); + // A leading `/` is the root of an absolute path, not an empty segment. + let segments = value.strip_prefix('/').unwrap_or(&value); + if segments.split('/').any(str::is_empty) { return Err(ActionProposalError::InvalidWorkspacePatch { field: "relative_path", reason: "must not contain empty path segments", }); } - - let path = Path::new(&value); - if path.is_absolute() { + if !path.is_absolute() && value.starts_with('\\') { return Err(ActionProposalError::InvalidWorkspacePatch { field: "relative_path", - reason: "must be relative", + reason: "must use `/` separators", }); } @@ -426,12 +437,9 @@ pub(super) fn validate_apply_patch_relative_path( reason: "must not contain dot segments", }); } - Component::RootDir | Component::Prefix(_) => { - return Err(ActionProposalError::InvalidWorkspacePatch { - field: "relative_path", - reason: "must be relative", - }); - } + // An absolute anchor is how a change outside every configured root + // is named, so it is part of a valid path rather than a violation. + Component::RootDir | Component::Prefix(_) => {} } } diff --git a/crates/merry-runtime/src/tool/tests.rs b/crates/merry-runtime/src/tool/tests.rs index b7fdaed9..ade1a19f 100644 --- a/crates/merry-runtime/src/tool/tests.rs +++ b/crates/merry-runtime/src/tool/tests.rs @@ -158,6 +158,8 @@ fn apply_patch_proposal_validates_relative_path_and_sizes() { assert_eq!(new_file.file_bytes_before(), 0); assert_eq!(new_file.file_bytes_after(), 5); + // A change outside every configured root is named by its absolute path, so + // the evidence keeps that spelling instead of requiring a root-relative one. let absolute = WorkspacePatchProposal::new( "/tmp/note.txt", 3, @@ -167,9 +169,21 @@ fn apply_patch_proposal_validates_relative_path_and_sizes() { "fnv1a64:0123456789abcdef", "fnv1a64:fedcba9876543210", ) - .expect_err("absolute paths are rejected"); + .expect("absolute paths name a change outside the workspace roots"); + assert_eq!(absolute.relative_path(), "/tmp/note.txt"); + + let empty_segment = WorkspacePatchProposal::new( + "/tmp//note.txt", + 3, + 5, + 11, + 13, + "fnv1a64:0123456789abcdef", + "fnv1a64:fedcba9876543210", + ) + .expect_err("empty segments are rejected"); assert!(matches!( - absolute, + empty_segment, ActionProposalError::InvalidWorkspacePatch { field: "relative_path", .. diff --git a/crates/merry-tools/src/config.rs b/crates/merry-tools/src/config.rs index 23920920..d64dac97 100644 --- a/crates/merry-tools/src/config.rs +++ b/crates/merry-tools/src/config.rs @@ -1,4 +1,7 @@ -use std::{io, path::PathBuf}; +use std::{ + io, + path::{Path, PathBuf}, +}; use thiserror::Error; @@ -29,61 +32,51 @@ impl Default for WorkspaceToolLimits { /// Configuration for workspace tools. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceToolsConfig { - pub(crate) roots: Vec, + pub(crate) root: PathBuf, pub(crate) readonly_resource_roots: Vec, - pub(crate) allow_hidden: bool, pub(crate) limits: WorkspaceToolLimits, pub(crate) patch_write_scope: Option>, pub(crate) forbidden_paths: Vec, } impl WorkspaceToolsConfig { - /// Creates a config with explicit workspace roots. + /// Creates a config with one workspace root. + /// + /// A workspace has exactly one root, so a relative path never has to be + /// guessed between candidates. Anything outside that root is named by its + /// own absolute path, where the sandbox decides reachability. #[must_use] - pub fn new(roots: Vec) -> Self { + pub fn new(root: impl Into) -> Self { Self { - roots, + root: root.into(), readonly_resource_roots: Vec::new(), - allow_hidden: false, limits: WorkspaceToolLimits::default(), patch_write_scope: None, forbidden_paths: Vec::new(), } } - /// Returns the configured, pre-canonical roots. + /// Returns the configured, pre-canonical workspace root. #[must_use] - pub fn roots(&self) -> &[PathBuf] { - &self.roots + pub fn root(&self) -> &Path { + &self.root } - /// Returns independent read-only resource roots. Resources are addressable - /// by the same relative read APIs but are never patch targets. + /// Returns independent read-only resource roots, such as skill directories. + /// Resources are addressable by the same relative read APIs but are never + /// patch targets. #[must_use] pub fn readonly_resource_roots(&self) -> &[PathBuf] { &self.readonly_resource_roots } - /// Adds read-only resource roots without treating them as workspace roots. + /// Adds read-only resource roots without treating them as a workspace root. #[must_use] pub fn with_readonly_resource_roots(mut self, roots: Vec) -> Self { self.readonly_resource_roots = roots; self } - /// Returns whether hidden path components are allowed. - #[must_use] - pub fn allow_hidden(&self) -> bool { - self.allow_hidden - } - - /// Sets whether hidden path components are allowed. - #[must_use] - pub fn with_allow_hidden(mut self, allow_hidden: bool) -> Self { - self.allow_hidden = allow_hidden; - self - } - /// Returns the configured tool limits. #[must_use] pub fn limits(&self) -> &WorkspaceToolLimits { @@ -109,17 +102,17 @@ impl WorkspaceToolsConfig { self } - /// Sets the optional workspace-relative write scope for `apply_patch`. + /// Sets the optional root-relative write scope for `apply_patch`. /// - /// `None` preserves existing unrestricted patch behavior under configured - /// roots. `Some([])` makes the patch tool read-only by denying all writes. + /// `None` preserves existing unrestricted patch behavior. `Some([])` makes + /// the patch tool read-only by denying all writes. #[must_use] pub fn with_patch_write_scope(mut self, paths: Option>) -> Self { self.patch_write_scope = paths; self } - /// Sets workspace-relative paths forbidden to `apply_patch`. + /// Sets root-relative paths forbidden to `apply_patch`. #[must_use] pub fn with_forbidden_paths(mut self, paths: Vec) -> Self { self.forbidden_paths = paths; @@ -133,9 +126,6 @@ pub enum WorkspaceToolConfigError { /// A built-in tool declaration could not be validated. #[error("invalid built-in tool definition: {0}")] ToolDefinition(#[from] merry_runtime::ToolBuildError), - /// At least one root must be configured explicitly. - #[error("at least one workspace root must be configured")] - NoRoots, /// A configured root does not exist. #[error("workspace root does not exist: {root}")] RootNotFound { diff --git a/crates/merry-tools/src/errors.rs b/crates/merry-tools/src/errors.rs index 82d07a02..2a9827b5 100644 --- a/crates/merry-tools/src/errors.rs +++ b/crates/merry-tools/src/errors.rs @@ -2,39 +2,95 @@ use merry_core::ErrorInfo; use merry_runtime::ToolExecutionOutcome; use serde::Serialize; -pub(crate) const ERROR_INVALID_ARGUMENTS: &str = "workspace_invalid_arguments"; -pub(crate) const ERROR_PATH_DENIED: &str = "workspace_path_denied"; -pub(crate) const ERROR_FILE_NOT_FOUND: &str = "workspace_file_not_found"; -pub(crate) const ERROR_FILE_ALREADY_EXISTS: &str = "workspace_file_already_exists"; -pub(crate) const ERROR_NOT_FILE: &str = "workspace_path_not_file"; -pub(crate) const ERROR_NOT_DIRECTORY: &str = "workspace_path_not_directory"; -pub(crate) const ERROR_FILE_TOO_LARGE: &str = "workspace_file_too_large"; -pub(crate) const ERROR_NOT_UTF8: &str = "workspace_file_not_utf8"; -pub(crate) const ERROR_READ_FAILED: &str = "workspace_read_failed"; -pub(crate) const ERROR_WRITE_FAILED: &str = "workspace_write_failed"; -pub(crate) const ERROR_PROPOSAL_MISMATCH: &str = "apply_patch_approved_mismatch"; +/// Declares every workspace tool error code and registers it for coverage. +/// +/// A code is declared once here, which also puts it in +/// [`ALL_WORKSPACE_ERROR_CODES`]. That registration is what lets +/// `every_workspace_error_code_declares_its_recovery` require an explicit +/// model-facing recovery expectation for each code: a code added below without +/// one fails that test instead of silently falling back to a generic message. +macro_rules! workspace_error_codes { + ($( $(#[$attribute:meta])* $name:ident = $code:literal ),* $(,)?) => { + $( + $(#[$attribute])* + pub(crate) const $name: &str = $code; + )* + + /// Every declared workspace tool error code with its constant name. + /// + /// Only the coverage test reads this, so it exists in test builds. + #[cfg(test)] + pub(crate) const ALL_WORKSPACE_ERROR_CODES: &[DeclaredErrorCode] = &[ + $(DeclaredErrorCode { + name: stringify!($name), + code: $code, + }),* + ]; + }; +} + +/// One declared workspace tool error code. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DeclaredErrorCode { + /// Rust constant name, so a coverage failure names the code it means. + pub(crate) name: &'static str, + /// Stable code a caller receives. + pub(crate) code: &'static str, +} + +workspace_error_codes! { + ERROR_INVALID_ARGUMENTS = "workspace_invalid_arguments", + ERROR_PATH_DENIED = "workspace_path_denied", + ERROR_FILE_NOT_FOUND = "workspace_file_not_found", + ERROR_FILE_ALREADY_EXISTS = "workspace_file_already_exists", + ERROR_NOT_FILE = "workspace_path_not_file", + ERROR_NOT_DIRECTORY = "workspace_path_not_directory", + ERROR_FILE_TOO_LARGE = "workspace_file_too_large", + ERROR_NOT_UTF8 = "workspace_file_not_utf8", + ERROR_READ_FAILED = "workspace_read_failed", + ERROR_WRITE_FAILED = "workspace_write_failed", + ERROR_PROPOSAL_MISMATCH = "apply_patch_approved_mismatch", + ERROR_PATCH_SYNTAX = "apply_patch_syntax", + ERROR_PATCH_NOOP = "apply_patch_noop", + ERROR_PREIMAGE_ABSENT = "apply_patch_preimage_absent", + ERROR_PREIMAGE_AMBIGUOUS = "apply_patch_preimage_ambiguous", +} + pub(crate) const WORKSPACE_PATCH_PLAN_CHANGED_MESSAGE: &str = "workspace patch plan changed before execution"; -pub(crate) const ERROR_PREIMAGE_ABSENT: &str = "apply_patch_preimage_absent"; -pub(crate) const ERROR_PREIMAGE_AMBIGUOUS: &str = "apply_patch_preimage_ambiguous"; -pub(crate) const WORKSPACE_PATH_CONTRACT: &str = "workspace tool path values are relative to a configured workspace root; do not prefix them with a process cwd, repository root, or absolute host path"; +pub(crate) const WORKSPACE_PATH_CONTRACT: &str = "workspace tool path values are resolved under the one workspace root when they are relative and used as named when they are absolute: an absolute path inside the workspace root and the matching relative path address the same file, an absolute path may also address a file outside the workspace or inside a read-only resource root, every spelling including dot-prefixed components is accepted, and only the reachability the sandbox grants decides whether the path can be used"; -const GUIDANCE_INVALID_ARGUMENTS: &str = "Fix the workspace tool arguments before retrying. Use the tool schema exactly; path fields must be workspace-relative and must not include host absolute paths, process cwd prefixes, or parent traversal."; -const GUIDANCE_PATH_RECOVERY: &str = "Use a workspace-relative path from the configured root. If the target is unclear, use `run_process` for focused discovery when available, or ask for the exact path before retrying."; +const MAX_FAILURE_DIAGNOSTIC_CHARS: usize = 512; + +const GUIDANCE_INVALID_ARGUMENTS: &str = "Fix the workspace tool arguments before retrying. Use the tool schema exactly; a path field names a file either relative to a workspace root or as an absolute path, and no path shape is rejected."; +const GUIDANCE_PATH_RECOVERY: &str = "Use the path that names the target file: relative to a configured workspace root or absolute, including a file outside the workspace, where the sandbox decides what is reachable and writable. If the target is unclear, use `run_process` for focused discovery when available, or ask for the exact path before retrying."; const GUIDANCE_FILE_TOO_LARGE: &str = "Do not assume omitted content or rejected patch content is irrelevant. Narrow the read or patch range, split the change, use `read_text` for focused ranges, or use an authorized process command for exact inspection when available."; -const GUIDANCE_PATCH_PREIMAGE: &str = "Re-read the target file, then retry with a smaller unique preimage that matches the current file exactly. Do not guess file state from an old observation."; +const GUIDANCE_PATCH_PREIMAGE: &str = "Re-read the target file at the reported lines, then retry with a smaller unique preimage that matches the current bytes exactly. Do not guess file state from an old observation."; +const GUIDANCE_PATCH_SYNTAX: &str = "Fix the patch text itself: send exactly one `*** Begin Patch` ... `*** End Patch` envelope, at most one `*** Add File:` or `*** Delete File:` section per file, and prefix every hunk line with one space, `+`, or `-`. Repeated `*** Update File:` sections for one file merge into a single change. Use `read_text` for the exact current lines instead of guessing them."; +const GUIDANCE_PATCH_NOOP: &str = "The patch had no `+` or `-` lines, so nothing was written. Add the added or removed lines to the hunk when you mean to edit the file, or use `read_text` when you only need to inspect it."; const GUIDANCE_PATCH_PLAN_CHANGED: &str = "The approved patch no longer matches current workspace state. Re-read the target file and submit a fresh localized patch."; +/// A failure raised by a workspace tool operation, with a stable code. +/// +/// The message is owned because self-locating diagnostics embed bounded +/// previews of the patch text and the current file content. Text that never +/// depends on input stays a `&'static str` in [`PathValidationError`], and every +/// message that reaches a caller passes through [`failure_diagnostic`], which +/// sanitizes text that cannot be represented as a diagnostic. #[derive(Debug)] pub(crate) struct DomainError { pub(crate) code: &'static str, - pub(crate) message: &'static str, + pub(crate) message: String, } impl DomainError { - pub(crate) fn new(code: &'static str, message: &'static str) -> Self { - Self { code, message } + pub(crate) fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } } } @@ -72,7 +128,8 @@ struct FailureEnvelope<'a> { ok: bool, tool: &'static str, error: FailureError<'a>, - recovery: FailureRecovery, + #[serde(skip_serializing_if = "Option::is_none")] + recovery: Option, #[serde(skip_serializing_if = "Option::is_none")] guidance: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -103,6 +160,7 @@ pub(crate) fn failed_outcome( path: Option, ) -> ToolExecutionOutcome { let message = message.into(); + let class = FailureClass::of(code); let envelope = FailureEnvelope { ok: false, tool, @@ -110,44 +168,128 @@ pub(crate) fn failed_outcome( code, message: &message, }, - recovery: FailureRecovery { + recovery: class.includes_path_contract().then_some(FailureRecovery { path_contract: WORKSPACE_PATH_CONTRACT, - }, - guidance: workspace_failure_guidance(code), + }), + guidance: class.guidance(), path: path.as_deref(), }; ToolExecutionOutcome::failed_json( serde_json::to_string(&envelope).expect("workspace failure envelope serializes"), - ErrorInfo::new(code, &message).expect("workspace diagnostic is valid"), + failure_diagnostic(code, &message), ) } -fn workspace_failure_guidance(code: &str) -> Option { - match code { - ERROR_INVALID_ARGUMENTS => Some(WorkspaceGuidance { - kind: "workspace_invalid_arguments", - message: GUIDANCE_INVALID_ARGUMENTS, - }), - ERROR_PATH_DENIED - | ERROR_FILE_NOT_FOUND - | ERROR_FILE_ALREADY_EXISTS - | ERROR_NOT_FILE - | ERROR_NOT_DIRECTORY => Some(WorkspaceGuidance { - kind: "workspace_path_recovery", - message: GUIDANCE_PATH_RECOVERY, - }), - ERROR_FILE_TOO_LARGE => Some(WorkspaceGuidance { - kind: "workspace_file_too_large", - message: GUIDANCE_FILE_TOO_LARGE, - }), - ERROR_PREIMAGE_ABSENT | ERROR_PREIMAGE_AMBIGUOUS => Some(WorkspaceGuidance { - kind: "apply_patch_preimage_mismatch", - message: GUIDANCE_PATCH_PREIMAGE, - }), - ERROR_PROPOSAL_MISMATCH => Some(WorkspaceGuidance { - kind: "apply_patch_plan_changed", - message: GUIDANCE_PATCH_PLAN_CHANGED, - }), - _ => None, +/// Model-facing recovery class of a workspace failure code. +/// +/// One classification decides both whether the workspace path contract is +/// repeated and which guidance the caller receives, so a new failure code +/// cannot end up with guidance that contradicts the recovery block next to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FailureClass { + /// The arguments are unusable before any path or file is considered. + InvalidArguments, + /// The patch text does not follow the patch grammar. + PatchSyntax, + /// The patch is well formed but would change nothing. + PatchNoOp, + /// The patch is valid but its preimage no longer matches the file. + PatchPreimage, + /// The approved patch no longer matches current workspace state. + PatchPlanChanged, + /// The named path is denied, missing, or the wrong kind of entry. + PathRecovery, + /// Requested content or the resulting file exceeds a configured limit. + FileTooLarge, + /// A failure with no recovery text of its own. + Other, +} + +impl FailureClass { + /// Classifies a failure code for model-facing recovery text. + fn of(code: &str) -> Self { + match code { + ERROR_INVALID_ARGUMENTS => Self::InvalidArguments, + ERROR_PATCH_SYNTAX => Self::PatchSyntax, + ERROR_PATCH_NOOP => Self::PatchNoOp, + ERROR_PREIMAGE_ABSENT | ERROR_PREIMAGE_AMBIGUOUS => Self::PatchPreimage, + ERROR_PROPOSAL_MISMATCH => Self::PatchPlanChanged, + ERROR_PATH_DENIED + | ERROR_FILE_NOT_FOUND + | ERROR_FILE_ALREADY_EXISTS + | ERROR_NOT_FILE + | ERROR_NOT_DIRECTORY => Self::PathRecovery, + ERROR_FILE_TOO_LARGE => Self::FileTooLarge, + _ => Self::Other, + } } + + /// Reports whether the workspace path contract helps explain this failure. + /// + /// Patch-text and preimage failures are about the patch body rather than + /// about where a path points, so repeating the path contract there misleads + /// the caller. A patch that names an unwritable or missing path keeps its + /// own path code and the contract. + fn includes_path_contract(self) -> bool { + !matches!( + self, + Self::PatchSyntax | Self::PatchNoOp | Self::PatchPreimage + ) + } + + /// Returns the guidance a caller can act on, when the class has one. + fn guidance(self) -> Option { + match self { + Self::InvalidArguments => Some(WorkspaceGuidance { + kind: "workspace_invalid_arguments", + message: GUIDANCE_INVALID_ARGUMENTS, + }), + Self::PatchSyntax => Some(WorkspaceGuidance { + kind: "apply_patch_syntax", + message: GUIDANCE_PATCH_SYNTAX, + }), + Self::PatchNoOp => Some(WorkspaceGuidance { + kind: "apply_patch_noop", + message: GUIDANCE_PATCH_NOOP, + }), + Self::PatchPreimage => Some(WorkspaceGuidance { + kind: "apply_patch_preimage_mismatch", + message: GUIDANCE_PATCH_PREIMAGE, + }), + Self::PatchPlanChanged => Some(WorkspaceGuidance { + kind: "apply_patch_plan_changed", + message: GUIDANCE_PATCH_PLAN_CHANGED, + }), + Self::PathRecovery => Some(WorkspaceGuidance { + kind: "workspace_path_recovery", + message: GUIDANCE_PATH_RECOVERY, + }), + Self::FileTooLarge => Some(WorkspaceGuidance { + kind: "workspace_file_too_large", + message: GUIDANCE_FILE_TOO_LARGE, + }), + Self::Other => None, + } + } +} + +/// Builds a validated diagnostic without aborting the tool call on bad text. +/// +/// Failure messages combine fixed guidance with previews of patch and file +/// text, so an unexpected control character or an over-long message must degrade +/// into a sanitized diagnostic instead of panicking mid-call. +fn failure_diagnostic(code: &str, message: &str) -> ErrorInfo { + if let Ok(diagnostic) = ErrorInfo::new(code, message) { + return diagnostic; + } + + let sanitized = message + .chars() + .filter(|character| !character.is_control()) + .take(MAX_FAILURE_DIAGNOSTIC_CHARS) + .collect::(); + ErrorInfo::new(code, &sanitized).unwrap_or_else(|_| { + ErrorInfo::new(code, "workspace tool failure") + .expect("fallback workspace diagnostic is always valid") + }) } diff --git a/crates/merry-tools/src/file.rs b/crates/merry-tools/src/file.rs new file mode 100644 index 00000000..1050d9c8 --- /dev/null +++ b/crates/merry-tools/src/file.rs @@ -0,0 +1,85 @@ +//! Bounded whole-file reads shared by the workspace tools. +//! +//! `read_text` streams a line range and never needs the complete file, while +//! `apply_patch` needs every byte twice: once while planning a preimage and +//! once immediately before it mutates the file. Both patch reads must enforce +//! the same configured byte budget and report the same failures, so the +//! complete-file read has one owner here. + +use std::{ + fs, + io::{Read, Seek, SeekFrom}, +}; + +use crate::errors::{ + BlockingToolError, DomainError, ERROR_FILE_TOO_LARGE, ERROR_NOT_FILE, ERROR_NOT_UTF8, + ERROR_READ_FAILED, +}; + +/// Reads every byte of an open workspace file, bounded by `max_bytes`. +/// +/// The read starts at the beginning of the file, so a caller that already +/// wrote through the same handle still observes the file's current bytes. +/// Cancellation is checked before the read starts and again before the bytes +/// are returned to the caller. +/// +/// Fails when the handle does not refer to a regular file, when the file +/// exceeds the configured read limit, or when the read itself fails. +pub(crate) fn read_bounded( + file: &mut fs::File, + max_bytes: usize, + is_cancelled: &dyn Fn() -> bool, +) -> Result, BlockingToolError> { + if is_cancelled() { + return Err(BlockingToolError::Cancelled); + } + + let metadata = file.metadata().map_err(|_| { + DomainError::new( + ERROR_READ_FAILED, + "could not inspect workspace file metadata", + ) + })?; + if !metadata.is_file() { + return Err( + DomainError::new(ERROR_NOT_FILE, "workspace path is not a regular file").into(), + ); + } + let size = usize::try_from(metadata.len()).map_err(|_| { + DomainError::new( + ERROR_FILE_TOO_LARGE, + "workspace file exceeds the configured read limit", + ) + })?; + if size > max_bytes { + return Err(DomainError::new( + ERROR_FILE_TOO_LARGE, + "workspace file exceeds the configured read limit", + ) + .into()); + } + + if file.seek(SeekFrom::Start(0)).is_err() { + return Err(DomainError::new(ERROR_READ_FAILED, "could not seek workspace file").into()); + } + + if is_cancelled() { + return Err(BlockingToolError::Cancelled); + } + + // The measured length caps the read, so the returned bytes can never + // exceed the configured limit even if the file grows meanwhile. + let mut bytes = Vec::with_capacity(size); + Read::by_ref(file) + .take(metadata.len()) + .read_to_end(&mut bytes) + .map_err(|_| DomainError::new(ERROR_READ_FAILED, "could not read workspace file"))?; + + Ok(bytes) +} + +/// Decodes a bounded whole-file read as UTF-8 text. +pub(crate) fn decode_utf8(bytes: Vec) -> Result { + String::from_utf8(bytes) + .map_err(|_| DomainError::new(ERROR_NOT_UTF8, "workspace file is not valid UTF-8")) +} diff --git a/crates/merry-tools/src/lib.rs b/crates/merry-tools/src/lib.rs index f99da502..00e13126 100644 --- a/crates/merry-tools/src/lib.rs +++ b/crates/merry-tools/src/lib.rs @@ -6,21 +6,26 @@ //! access policy. The public `merry::tool` declaration macro is re-exported by //! the facade crate; this crate only contains the implementation dependency. //! -//! Path safety is scoped to trusted, stable workspace roots. The MVP rejects -//! absolute paths, parent-directory traversal, ordinary dot components except -//! exact `.` where a tool addresses the root, hidden paths unless explicitly -//! enabled, and ordinary symlink components before reading or patching. On -//! Unix, file opens also use `O_NOFOLLOW` to avoid following a -//! symlink swapped into the leaf path between validation and open. This is not -//! an OS sandbox and does not claim complete hardening against malicious -//! concurrent filesystem mutation, including replacement of intermediate -//! directories during an operation. +//! A workspace has one root. A relative path is normalized to root-relative +//! components and dot segments are resolved lexically; an absolute path is used +//! as the caller named it, and an absolute path below the workspace root is +//! rewritten to its relative spelling so one file never has two identities. A +//! path outside the workspace root is not limited to a configured root, because +//! the process sandbox, accepted process profile, and trusted path rules already +//! decide which paths exist and which of them are writable. Every spelling is +//! accepted, including dot-prefixed components. A relative path is walked +//! without following symlink components, and on Unix file opens also use +//! `O_NOFOLLOW` to avoid following a symlink swapped into the leaf path between +//! validation and open. This is not an OS sandbox and does not claim complete +//! hardening against malicious concurrent filesystem mutation, including +//! replacement of intermediate directories during an operation. use merry_core::ToolSpec; use merry_runtime::{Tool, ToolBuildError}; mod config; mod errors; +mod file; mod patch; mod path; mod read; @@ -30,6 +35,10 @@ mod state; mod trace; pub use config::{WorkspaceToolConfigError, WorkspaceToolLimits, WorkspaceToolsConfig}; +pub use patch::envelope::{ + WorkspacePatchOperationKind, WorkspacePatchSuccess, WorkspacePatchSuccessChange, + WorkspacePatchSuccessLine, WorkspacePatchSuccessLineKind, +}; pub use registry::WorkspaceTools; /// Registered tool name for bounded read-only text ranges. diff --git a/crates/merry-tools/src/patch/apply.rs b/crates/merry-tools/src/patch/apply.rs index b06064fe..14ddb0f3 100644 --- a/crates/merry-tools/src/patch/apply.rs +++ b/crates/merry-tools/src/patch/apply.rs @@ -1,6 +1,6 @@ use std::{ fs, - io::{Read, Seek, SeekFrom, Write}, + io::{self, Seek, SeekFrom, Write}, }; use merry_runtime::{ @@ -9,22 +9,23 @@ use merry_runtime::{ }; #[cfg(test)] -use crate::trace::maybe_run_patch_test_after_write_hook; +use crate::trace::{ + maybe_run_patch_test_after_write_hook, maybe_run_patch_test_before_mutation_hook, +}; use crate::{ APPLY_PATCH_TOOL, - errors::{ - BlockingToolError, DomainError, ERROR_FILE_TOO_LARGE, ERROR_NOT_FILE, ERROR_READ_FAILED, - ERROR_WRITE_FAILED, failed_outcome, - }, - path::{open_file_for_patch, open_file_for_patch_create_new}, + errors::{BlockingToolError, DomainError, ERROR_WRITE_FAILED, failed_outcome}, + file::read_bounded, + path::{open_file_for_patch, open_file_for_patch_create_new, open_file_for_read}, }; use super::{ + envelope::{WorkspacePatchSuccess, WorkspacePatchSuccessChange}, plan::{ WorkspacePatchFileMode, WorkspacePatchFilePlan, WorkspacePatchPlan, read_patch_preimage_for_path, }, - types::{WorkspacePatchSuccess, WorkspacePatchSuccessChange, stable_content_fingerprint}, + types::{count_file_lines, stable_content_fingerprint}, }; pub(super) fn execute_apply_patch_plan( @@ -43,11 +44,15 @@ pub(super) fn execute_apply_patch_plan( return Err(ToolExecutionError::Cancelled); } let relative_display = change.relative.display.clone(); + let operation = change.mode.operation_kind(); + let ignored_context_hunks = change.ignored_context_hunks; + let lines_before = change.lines_before; let content_after = match execute_apply_patch_file_plan(&change, is_cancelled) { Ok(content_after) => content_after, Err(PatchFileWriteError::Outcome(outcome)) => return Ok(*outcome), Err(PatchFileWriteError::Cancelled) => return Err(ToolExecutionError::Cancelled), }; + let lines_after = count_file_lines(&content_after); evidence_changes.push( WorkspacePatchChangeEvidence::new( relative_display.clone(), @@ -66,9 +71,13 @@ pub(super) fn execute_apply_patch_plan( ); written_changes.push(WorkspacePatchSuccessChange { path: relative_display, + op: Some(operation), hunks: change.hunks, + lines_before: Some(lines_before), + lines_after: Some(lines_after), bytes_before: change.bytes_before, bytes_after: content_after.len(), + ignored_context_hunks, lines: change.lines, }); } @@ -81,7 +90,7 @@ pub(super) fn execute_apply_patch_plan( })?; let payload = WorkspacePatchSuccess { ok: true, - tool: APPLY_PATCH_TOOL, + tool: APPLY_PATCH_TOOL.to_owned(), changes: written_changes, }; Ok(ToolExecutionOutcome::succeeded_json( @@ -95,6 +104,19 @@ enum PatchFileWriteError { Cancelled, } +/// Opens the planned file with the access mode its operation needs. +/// +/// An update rewrites the file, so it opens read-write. A delete only reads the +/// file to verify its preimage and then unlinks it, so it opens read-only: +/// removal needs write permission on the parent directory, not on the file. +fn open_planned_patch_file(plan: &WorkspacePatchFilePlan) -> Result { + match plan.mode { + WorkspacePatchFileMode::CreateNew => open_file_for_patch_create_new(&plan.path), + WorkspacePatchFileMode::UpdateExisting => open_file_for_patch(&plan.path), + WorkspacePatchFileMode::DeleteExisting => open_file_for_read(&plan.path), + } +} + fn execute_apply_patch_file_plan( plan: &WorkspacePatchFilePlan, is_cancelled: &dyn Fn() -> bool, @@ -103,11 +125,11 @@ fn execute_apply_patch_file_plan( if is_cancelled() { return Err(PatchFileWriteError::Cancelled); } - let mut file = match if plan.mode == WorkspacePatchFileMode::CreateNew { - open_file_for_patch_create_new(&plan.path) - } else { - open_file_for_patch(&plan.path) - } { + + #[cfg(test)] + maybe_run_patch_test_before_mutation_hook(&plan.path); + + let mut file = match open_planned_patch_file(plan) { Ok(file) => file, Err(error) => { return Err(PatchFileWriteError::Outcome(Box::new(failed_outcome( @@ -118,14 +140,19 @@ fn execute_apply_patch_file_plan( )))); } }; - if plan.mode == WorkspacePatchFileMode::UpdateExisting { - match read_open_patch_file_before_write(&mut file, plan.max_read_bytes, is_cancelled) { + // Re-read the preimage immediately before the mutation. This closes the + // window between planning and acting: an edit, replacement, or removal that + // landed after the plan was built must fail here instead of overwriting or + // unlinking bytes the caller never saw. Updates and deletes share it so a + // stale delete cannot report evidence for content it did not remove. + if plan.mode != WorkspacePatchFileMode::CreateNew { + match read_bounded(&mut file, plan.max_read_bytes, is_cancelled) { Ok(bytes) if bytes == plan.content_before.as_bytes() => {} Ok(_) => { return Err(PatchFileWriteError::Outcome(Box::new(failed_outcome( APPLY_PATCH_TOOL, ERROR_WRITE_FAILED, - "workspace file changed before patch write", + "workspace file changed after the patch was planned; re-read the target file and submit a fresh patch", Some(relative_display), )))); } @@ -140,6 +167,10 @@ fn execute_apply_patch_file_plan( Err(BlockingToolError::Cancelled) => return Err(PatchFileWriteError::Cancelled), } } + if plan.mode == WorkspacePatchFileMode::DeleteExisting { + drop(file); + return delete_apply_patch_file(plan, relative_display); + } if file.seek(SeekFrom::Start(0)).is_err() { return Err(PatchFileWriteError::Outcome(Box::new(failed_outcome( APPLY_PATCH_TOOL, @@ -205,58 +236,36 @@ fn execute_apply_patch_file_plan( Ok(content_after) } -fn read_open_patch_file_before_write( - file: &mut fs::File, - max_read_bytes: usize, - is_cancelled: &dyn Fn() -> bool, -) -> Result, BlockingToolError> { - if is_cancelled() { - return Err(BlockingToolError::Cancelled); - } - - let metadata = file.metadata().map_err(|_| { - DomainError::new( - ERROR_READ_FAILED, - "could not inspect workspace file metadata", - ) - })?; - if !metadata.is_file() { - return Err( - DomainError::new(ERROR_NOT_FILE, "workspace path is not a regular file").into(), - ); - } - if metadata.len() > max_read_bytes as u64 { - return Err(DomainError::new( - ERROR_FILE_TOO_LARGE, - "workspace file exceeds the configured read limit", - ) - .into()); - } - - if file.seek(SeekFrom::Start(0)).is_err() { - return Err(DomainError::new(ERROR_READ_FAILED, "could not seek workspace file").into()); +/// Unlinks a planned file whose preimage was verified immediately before this +/// call. +/// +/// Deletion is verified the same way a write is: the plan recorded the exact +/// preimage and byte count, the caller compared the current bytes against it, +/// and success requires the path to be absent afterwards. A partial or blocked +/// removal therefore reports a failure instead of claiming the file is gone. +fn delete_apply_patch_file( + plan: &WorkspacePatchFilePlan, + relative_display: String, +) -> Result { + if fs::remove_file(&plan.path).is_err() { + return Err(PatchFileWriteError::Outcome(Box::new(failed_outcome( + APPLY_PATCH_TOOL, + ERROR_WRITE_FAILED, + "could not delete workspace file", + Some(relative_display), + )))); } - if is_cancelled() { - return Err(BlockingToolError::Cancelled); - } + #[cfg(test)] + maybe_run_patch_test_after_write_hook(&plan.path); - let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).map_err(|_| { - DomainError::new( - ERROR_FILE_TOO_LARGE, - "workspace file exceeds the configured read limit", - ) - })?); - Read::by_ref(file) - .take(metadata.len()) - .read_to_end(&mut bytes) - .map_err(|_| DomainError::new(ERROR_READ_FAILED, "could not read workspace file"))?; - if bytes.len() > max_read_bytes { - return Err(DomainError::new( - ERROR_FILE_TOO_LARGE, - "workspace file exceeds the configured read limit", - ) - .into()); + match fs::symlink_metadata(&plan.path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()), + _ => Err(PatchFileWriteError::Outcome(Box::new(failed_outcome( + APPLY_PATCH_TOOL, + ERROR_WRITE_FAILED, + "workspace file still exists after delete", + Some(relative_display), + )))), } - Ok(bytes) } diff --git a/crates/merry-tools/src/patch/diagnostic.rs b/crates/merry-tools/src/patch/diagnostic.rs new file mode 100644 index 00000000..8ac4e4d7 --- /dev/null +++ b/crates/merry-tools/src/patch/diagnostic.rs @@ -0,0 +1,233 @@ +//! Model-facing explanations for failed workspace patch matching. +//! +//! The patch tool matches byte-exactly and either writes every planned change +//! or writes nothing. A hunk that does not match therefore fails the whole +//! call, and the only thing that lets the caller recover in one retry is +//! knowing where the match failed. These helpers turn that into short, +//! single-line text that is safe to embed in a failure diagnostic. + +/// Longest patch or file text preview embedded in a diagnostic message. +const PREVIEW_CHARS: usize = 96; + +/// Smallest shared prefix that makes a file line a useful "closest line" hint. +const MIN_CLOSEST_PREFIX_CHARS: usize = 12; + +/// Most match locations reported for an ambiguous preimage. +const MAX_REPORTED_MATCHES: usize = 5; + +/// Largest number of candidate start lines compared line by line. +const MAX_MATCH_CANDIDATES: usize = 64; + +/// Renders single-line diagnostic text without control characters. +/// +/// Patch and file text reaches provider-visible diagnostics, so newlines, tabs, +/// and other control characters are replaced with spaces and long text is +/// truncated with an ellipsis. +pub(super) fn single_line_preview(text: &str, max_chars: usize) -> String { + let mut preview = String::with_capacity(text.len().min(max_chars)); + for (index, character) in text.chars().enumerate() { + if index == max_chars { + preview.push('…'); + return preview; + } + preview.push(if character.is_control() { + ' ' + } else { + character + }); + } + preview +} + +/// Returns the one-based line number containing `byte_index`. +pub(super) fn line_number_at_byte(content: &str, byte_index: usize) -> usize { + content[..byte_index] + .bytes() + .filter(|byte| *byte == b'\n') + .count() + + 1 +} + +/// Explains why an exact preimage match failed. +/// +/// The result starts with `"; "` so callers can append it to their own message, +/// and is empty when the hunk has no usable lines. +pub(super) fn describe_preimage_miss(content: &str, old_text: &str) -> String { + let hunk_lines = old_text.lines().collect::>(); + let Some(first) = hunk_lines.first().copied() else { + return String::new(); + }; + let content_lines = content.lines().collect::>(); + if content_lines.is_empty() { + return "; the target file is empty".to_owned(); + } + + let mut exact_starts = Vec::new(); + let mut trailing_whitespace_start = None; + let mut whitespace_start = None; + for (index, line) in content_lines.iter().enumerate() { + if *line == first { + exact_starts.push(index); + if exact_starts.len() >= MAX_MATCH_CANDIDATES { + break; + } + continue; + } + if trailing_whitespace_start.is_none() && line.trim_end() == first.trim_end() { + trailing_whitespace_start = Some(index); + } + if whitespace_start.is_none() && line.trim() == first.trim() { + whitespace_start = Some(index); + } + } + + if !exact_starts.is_empty() { + // Compare every exact candidate so the reported line is the one that + // matches the hunk most closely, not merely the first look-alike line. + let mut best: Option<(usize, usize, Option)> = None; + for start in exact_starts { + let divergence = first_divergence(&content_lines, &hunk_lines, start); + let matched = divergence + .as_ref() + .map_or(hunk_lines.len(), |(offset, _)| *offset); + if best + .as_ref() + .is_none_or(|(best_matched, _, _)| matched > *best_matched) + { + best = Some((matched, start, divergence.map(|(_, clause)| clause))); + } + } + let (_, start, divergence) = best.expect("exact candidate list is not empty"); + return match divergence { + Some(clause) => format!( + "; the hunk's first line matches at line {}, but {clause}", + start + 1 + ), + None => format!( + "; all {} hunk line(s) match at line {}, but the file uses CRLF line endings and this tool matches bytes exactly; convert the file to LF first (for example with a process command) or edit it without apply_patch", + hunk_lines.len(), + start + 1 + ), + }; + } + + if let Some(index) = trailing_whitespace_start { + return format!( + "; a line matching the hunk's first line is at line {} but differs in trailing whitespace", + index + 1 + ); + } + if let Some(index) = whitespace_start { + return format!( + "; a line matching the hunk's first line is at line {} but differs in leading or trailing whitespace", + index + 1 + ); + } + + match closest_line(&content_lines, first) { + Some((index, line)) => format!( + "; the hunk's first line \"{}\" was not found; line {} is the closest match: \"{}\"", + single_line_preview(first, PREVIEW_CHARS), + index + 1, + single_line_preview(line, PREVIEW_CHARS), + ), + None => format!( + "; the hunk's first line \"{}\" was not found in the file", + single_line_preview(first, PREVIEW_CHARS), + ), + } +} + +/// Lists the line numbers where a preimage matched more than once. +/// +/// `first_match` is the byte offset of the match the caller already found, and +/// the scan continues exactly where the caller's ambiguity check continued. +pub(super) fn describe_preimage_ambiguity( + content: &str, + old_text: &str, + first_match: usize, +) -> String { + let mut lines = vec![line_number_at_byte(content, first_match)]; + let mut search_from = first_match + old_text.len(); + while lines.len() < MAX_REPORTED_MATCHES { + let Some(offset) = content + .get(search_from..) + .and_then(|rest| rest.find(old_text)) + else { + break; + }; + let start = search_from + offset; + lines.push(line_number_at_byte(content, start)); + search_from = start + old_text.len(); + } + + let listed = lines + .iter() + .map(usize::to_string) + .collect::>() + .join(", "); + let suffix = if lines.len() >= MAX_REPORTED_MATCHES { + "and possibly more places" + } else { + "so make the preimage unique" + }; + format!("; it matches at lines {listed} {suffix}") +} + +/// Describes the first line where a candidate start stops matching the hunk. +fn first_divergence( + content_lines: &[&str], + hunk_lines: &[&str], + start: usize, +) -> Option<(usize, String)> { + for (offset, hunk_line) in hunk_lines.iter().enumerate().skip(1) { + match content_lines.get(start + offset) { + Some(line) if line == hunk_line => {} + Some(line) => { + return Some(( + offset, + format!( + "line {} differs: the patch has \"{}\" but the file has \"{}\"", + start + offset + 1, + single_line_preview(hunk_line, PREVIEW_CHARS), + single_line_preview(line, PREVIEW_CHARS), + ), + )); + } + None => { + return Some(( + offset, + format!( + "the hunk runs past the end of the file, which has {} line(s)", + content_lines.len() + ), + )); + } + } + } + None +} + +/// Finds the file line that shares the longest prefix with the hunk's first line. +fn closest_line<'a>(content_lines: &[&'a str], first: &str) -> Option<(usize, &'a str)> { + let target = first.trim(); + let mut best: Option<(usize, &'a str, usize)> = None; + for (index, line) in content_lines.iter().enumerate() { + let shared = shared_prefix_chars(target, line.trim()); + if shared < MIN_CLOSEST_PREFIX_CHARS { + continue; + } + if best.is_none_or(|(_, _, best_shared)| shared > best_shared) { + best = Some((index, line, shared)); + } + } + best.map(|(index, line, _)| (index, line)) +} + +/// Counts the leading characters two strings share. +fn shared_prefix_chars(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(left, right)| left == right) + .count() +} diff --git a/crates/merry-tools/src/patch/envelope.rs b/crates/merry-tools/src/patch/envelope.rs new file mode 100644 index 00000000..96419af4 --- /dev/null +++ b/crates/merry-tools/src/patch/envelope.rs @@ -0,0 +1,229 @@ +//! Provider-visible result envelope of a successful `apply_patch` call. +//! +//! These types are the single definition of that payload: the tool serializes +//! them and every consumer deserializes them, so a field cannot drift between +//! the writer and a reader in another crate. Fields added after the first +//! release stay optional with `serde(default)` so a resumed session can still +//! read an envelope recorded by an older build. + +use serde::{Deserialize, Serialize}; + +/// Successful `apply_patch` result with one entry per changed file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspacePatchSuccess { + pub ok: bool, + pub tool: String, + pub changes: Vec, +} + +/// One file change inside a successful `apply_patch` result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspacePatchSuccessChange { + /// Workspace-relative path using `/` separators. + pub path: String, + /// File operation, absent in envelopes recorded before it was reported, + /// which only ever described updates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub op: Option, + pub hunks: usize, + /// Line counts, absent in envelopes recorded before line counts existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines_before: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines_after: Option, + pub bytes_before: usize, + pub bytes_after: usize, + /// Context-only hunks that were dropped from this file's update sections. + #[serde(default, skip_serializing_if = "is_zero")] + pub ignored_context_hunks: usize, + /// Hunk lines of the change, empty for an add or delete. + #[serde(default)] + pub lines: Vec, +} + +impl WorkspacePatchSuccessChange { + /// Resolves the file operation. + /// + /// An envelope recorded before the operation was reported only ever + /// described updates, so an absent operation is an update. + #[must_use] + pub fn operation(&self) -> WorkspacePatchOperationKind { + self.op.unwrap_or(WorkspacePatchOperationKind::Update) + } +} + +/// File operation that produced a successful change entry. +/// +/// A delete and an update that empties a file both end at zero bytes, so the +/// envelope names the operation instead of leaving callers to infer it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspacePatchOperationKind { + Add, + Update, + Delete, + /// An operation this build does not know. + /// + /// The tool never writes this variant, but a session recorded by a newer + /// build must still be readable, so an unknown operation is preserved + /// instead of failing the whole result. + #[serde(other)] + Unknown, +} + +/// One line of a change entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspacePatchSuccessLine { + pub kind: WorkspacePatchSuccessLineKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_line: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_line: Option, + pub text: String, +} + +/// Kind of one line inside a change entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspacePatchSuccessLineKind { + Context, + Remove, + Add, + /// A kind this build does not know. + /// + /// The serialized envelope is provider-visible output that a newer runtime + /// may extend, so an unknown kind is preserved as a line the reader ignores + /// instead of failing the whole result. + #[serde(other)] + Unknown, +} + +fn is_zero(value: &usize) -> bool { + *value == 0 +} + +#[cfg(test)] +mod tests { + use super::{ + WorkspacePatchOperationKind, WorkspacePatchSuccess, WorkspacePatchSuccessChange, + WorkspacePatchSuccessLine, WorkspacePatchSuccessLineKind, + }; + + #[test] + fn success_envelope_round_trips_every_field() { + let envelope = WorkspacePatchSuccess { + ok: true, + tool: "apply_patch".to_owned(), + changes: vec![WorkspacePatchSuccessChange { + path: "dir/note.txt".to_owned(), + op: Some(WorkspacePatchOperationKind::Update), + hunks: 1, + lines_before: Some(3), + lines_after: Some(3), + bytes_before: 22, + bytes_after: 24, + ignored_context_hunks: 2, + lines: vec![WorkspacePatchSuccessLine { + kind: WorkspacePatchSuccessLineKind::Add, + old_line: None, + new_line: Some(2), + text: "newer".to_owned(), + }], + }], + }; + + let json = serde_json::to_string(&envelope).expect("envelope should serialize"); + let restored: WorkspacePatchSuccess = + serde_json::from_str(&json).expect("serialized envelope should deserialize"); + + assert_eq!(restored, envelope); + assert_eq!( + restored.changes[0].operation(), + WorkspacePatchOperationKind::Update + ); + } + + #[test] + fn success_envelope_reads_a_legacy_entry_as_an_update() { + // Shape recorded before the operation and line counts were reported. + let json = r#"{"ok":true,"tool":"apply_patch","changes":[ + {"path":"hello.txt","hunks":1,"bytes_before":0,"bytes_after":12} + ]}"#; + + let restored: WorkspacePatchSuccess = + serde_json::from_str(json).expect("legacy envelope should deserialize"); + + let change = &restored.changes[0]; + assert_eq!(change.operation(), WorkspacePatchOperationKind::Update); + assert_eq!(change.lines_before, None); + assert_eq!(change.lines_after, None); + assert!(change.lines.is_empty()); + } + + #[test] + fn success_envelope_keeps_an_unknown_line_kind_readable() { + let json = r#"{"ok":true,"tool":"apply_patch","changes":[{"path":"note.txt","hunks":1,"bytes_before":1,"bytes_after":2, + "lines":[{"kind":"something-new","text":"line"}]}]}"#; + + let restored: WorkspacePatchSuccess = + serde_json::from_str(json).expect("future line kinds should not fail the result"); + + assert_eq!( + restored.changes[0].lines[0].kind, + WorkspacePatchSuccessLineKind::Unknown + ); + } + + #[test] + fn success_envelope_keeps_an_unknown_operation_readable() { + let json = r#"{"ok":true,"tool":"apply_patch","changes":[{"path":"note.txt","op":"something-new","hunks":1,"bytes_before":1,"bytes_after":2}]}"#; + + let restored: WorkspacePatchSuccess = + serde_json::from_str(json).expect("future operations should not fail the result"); + + assert_eq!( + restored.changes[0].operation(), + WorkspacePatchOperationKind::Unknown + ); + } + + #[test] + fn success_envelope_omits_absent_optional_fields_when_serializing() { + let envelope = WorkspacePatchSuccess { + ok: true, + tool: "apply_patch".to_owned(), + changes: vec![WorkspacePatchSuccessChange { + path: "gone.txt".to_owned(), + op: Some(WorkspacePatchOperationKind::Delete), + hunks: 0, + lines_before: Some(4), + lines_after: Some(0), + bytes_before: 30, + bytes_after: 0, + ignored_context_hunks: 0, + lines: Vec::new(), + }], + }; + + let value = serde_json::to_value(&envelope).expect("envelope should serialize"); + + assert_eq!( + value, + serde_json::json!({ + "ok": true, + "tool": "apply_patch", + "changes": [{ + "path": "gone.txt", + "op": "delete", + "hunks": 0, + "lines_before": 4, + "lines_after": 0, + "bytes_before": 30, + "bytes_after": 0, + "lines": [] + }] + }), + "the wire shape must stay stable for existing consumers" + ); + } +} diff --git a/crates/merry-tools/src/patch/input.rs b/crates/merry-tools/src/patch/input.rs index 809f5ddd..dc0e4fdf 100644 --- a/crates/merry-tools/src/patch/input.rs +++ b/crates/merry-tools/src/patch/input.rs @@ -7,13 +7,13 @@ use serde::Deserialize; #[merry_tools_macros::tool( crate = "crate", name = "apply_patch", - description = "Apply one constrained patch to UTF-8 files under configured stable roots. Use *** Add File: with + lines to create a missing file, or *** Update File: with minimal hunk context and lines prefixed with space, +, or -. Add File creates missing parent directories and never overwrites an existing path. Use exactly one patch envelope, keep the patch localized, and do not submit whole-file content for a small edit. The patch payload and resulting writes are bounded by configured limits." + description = "Apply one constrained patch to UTF-8 files. Send exactly one envelope: `*** Begin Patch` ... `*** End Patch`. Inside it, use the section that matches the intent: `*** Add File: ` with every content line prefixed `+` (creates missing parent directories, never overwrites an existing path); `*** Update File: ` with hunks started by `@@` and every line prefixed with one space for context, `+` for an added line, or `-` for a removed line; `*** Delete File: ` to remove an existing file and leave its parent directory in place. Name each file in at most one Add or Delete section, and repeat `*** Update File:` sections for one file only when that is easier than one section with several hunks, because repeated update sections merge into a single change. Hunks apply in order and each one must match the current file bytes exactly once, so keep context minimal but unique and re-read the file when it may have changed; a hunk with only context lines is dropped unless the envelope has no `+` or `-` line at all, which is rejected. The whole envelope is planned before anything is written, so one unmatched path or hunk writes nothing. A section path is relative to a workspace root or absolute: an absolute path may name a file outside the workspace, where the sandbox decides what is reachable and writable, and every spelling is accepted, including dot-prefixed components. Note the reported path when you need to refer to the file again. The patch payload and resulting writes are bounded by configured limits." )] #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct ApplyPatchInput { #[schemars( - description = "Patch envelope containing one or more workspace-relative file update sections.", + description = "Patch envelope containing one or more Add, Update, or Delete file sections.", length(min = 1) )] pub(crate) patch: String, diff --git a/crates/merry-tools/src/patch/mod.rs b/crates/merry-tools/src/patch/mod.rs index 06713037..3ad6547b 100644 --- a/crates/merry-tools/src/patch/mod.rs +++ b/crates/merry-tools/src/patch/mod.rs @@ -17,6 +17,8 @@ use crate::{ }; mod apply; +mod diagnostic; +pub(crate) mod envelope; mod input; pub(crate) use input::{ApplyPatchInput, spec}; mod parse; diff --git a/crates/merry-tools/src/patch/parse.rs b/crates/merry-tools/src/patch/parse.rs index cc552ee9..e1166c0e 100644 --- a/crates/merry-tools/src/patch/parse.rs +++ b/crates/merry-tools/src/patch/parse.rs @@ -1,32 +1,92 @@ -use std::collections::BTreeSet; +//! Parser for the `apply_patch` envelope. +//! +//! The grammar is small and deliberately strict, so most failures here are +//! patch-text mistakes. Each error therefore names the offending text and what +//! was expected instead of only reporting that the patch is invalid. -use super::types::{ - WorkspacePatch, WorkspacePatchFile, WorkspacePatchHunk, WorkspacePatchLine, - WorkspacePatchOperation, +use std::collections::BTreeMap; + +use crate::errors::{ERROR_PATCH_NOOP, ERROR_PATCH_SYNTAX}; + +use super::{ + diagnostic::single_line_preview, + types::{ + WorkspacePatch, WorkspacePatchFile, WorkspacePatchHunk, WorkspacePatchLine, + WorkspacePatchOperation, + }, }; +const BEGIN_WORKSPACE: &str = "*** Begin Workspace Patch"; +const END_WORKSPACE: &str = "*** End Workspace Patch"; +const BEGIN_STANDARD: &str = "*** Begin Patch"; +const END_STANDARD: &str = "*** End Patch"; +const ADD_PREFIX: &str = "*** Add File: "; +const UPDATE_PREFIX: &str = "*** Update File: "; +const DELETE_PREFIX: &str = "*** Delete File: "; + +/// Longest patch line preview embedded in a parse error. +const PREVIEW_CHARS: usize = 96; + +/// File section kinds accepted by the patch grammar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SectionKind { + Add, + Update, + Delete, +} + +impl SectionKind { + fn marker(self) -> &'static str { + match self { + Self::Add => ADD_PREFIX, + Self::Update => UPDATE_PREFIX, + Self::Delete => DELETE_PREFIX, + } + } + + /// Splits a `*** Add File:`, `*** Update File:`, or `*** Delete File:` + /// header into its kind and the raw path that follows it. + /// + /// Every place that decides whether a line starts a new file section uses + /// this, so adding another section kind cannot leave a body parser that + /// still swallows the new header as content. + fn from_header(line: &str) -> Option<(Self, &str)> { + [Self::Add, Self::Update, Self::Delete] + .into_iter() + .find_map(|kind| line.strip_prefix(kind.marker()).map(|path| (kind, path))) + } +} + #[derive(Debug)] pub(super) struct WorkspacePatchParseError { - pub(super) message: &'static str, + pub(super) code: &'static str, + pub(super) message: String, pub(super) path: Option, } impl WorkspacePatchParseError { - fn new(message: &'static str, path: Option) -> Self { - Self { message, path } + /// Creates a failure for a patch body that does not follow the grammar. + fn syntax(message: impl Into, path: Option) -> Self { + Self { + code: ERROR_PATCH_SYNTAX, + message: message.into(), + path, + } + } + + /// Creates a failure for a patch that would change nothing. + fn noop(message: impl Into, path: Option) -> Self { + Self { + code: ERROR_PATCH_NOOP, + message: message.into(), + path, + } } } pub(super) fn parse_apply_patch( raw_patch: &str, ) -> Result { - const BEGIN_WORKSPACE: &str = "*** Begin Workspace Patch"; - const END_WORKSPACE: &str = "*** End Workspace Patch"; - const BEGIN_STANDARD: &str = "*** Begin Patch"; - const END_STANDARD: &str = "*** End Patch"; - const ADD_PREFIX: &str = "*** Add File: "; - const UPDATE_PREFIX: &str = "*** Update File: "; - let raw_patch = raw_patch.strip_prefix('\u{feff}').unwrap_or(raw_patch); let lines = raw_patch.lines().collect::>(); let mut index = 0; @@ -35,22 +95,31 @@ pub(super) fn parse_apply_patch( let end = match patch_line(lines.get(index).copied()) { Some(BEGIN_WORKSPACE) => END_WORKSPACE, Some(BEGIN_STANDARD) => END_STANDARD, - _ => { - return Err(WorkspacePatchParseError::new( - "workspace patch must start with *** Begin Workspace Patch", + Some(first) => { + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch must start with `*** Begin Patch` (or `*** Begin Workspace Patch`); the first non-blank line is `{}`", + single_line_preview(first, PREVIEW_CHARS), + ), + None, + )); + } + None => { + return Err(WorkspacePatchParseError::syntax( + "workspace patch must not be empty; send one `*** Begin Patch` ... `*** End Patch` envelope with the file sections inside it", None, )); } }; index += 1; - let mut files = Vec::new(); - let mut seen_paths = BTreeSet::new(); + let mut files: Vec = Vec::new(); + let mut file_index_by_path: BTreeMap = BTreeMap::new(); loop { skip_blank_patch_lines(&lines, &mut index); let Some(line) = patch_line(lines.get(index).copied()) else { - return Err(WorkspacePatchParseError::new( - "workspace patch must end with *** End Workspace Patch", + return Err(WorkspacePatchParseError::syntax( + format!("workspace patch must end with `{end}`"), None, )); }; @@ -58,64 +127,126 @@ pub(super) fn parse_apply_patch( index += 1; skip_blank_patch_lines(&lines, &mut index); if index != lines.len() { - return Err(WorkspacePatchParseError::new( - "workspace patch must not contain text after *** End Workspace Patch", + let trailing = lines.get(index).copied().unwrap_or_default(); + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch must not contain text after `{end}`; found `{}`", + single_line_preview(trailing, PREVIEW_CHARS), + ), None, )); } break; } if line == BEGIN_WORKSPACE || line == BEGIN_STANDARD { - return Err(WorkspacePatchParseError::new( + return Err(WorkspacePatchParseError::syntax( "workspace patch contains a duplicate begin marker; provide exactly one patch envelope", None, )); } - let (is_add, path) = if let Some(path) = line.strip_prefix(ADD_PREFIX) { - (true, path.trim()) - } else if let Some(path) = line.strip_prefix(UPDATE_PREFIX) { - (false, path.trim()) - } else { - return Err(WorkspacePatchParseError::new( - "workspace patch expected *** Add File: or *** Update File: ", + let Some((kind, path)) = SectionKind::from_header(line) else { + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch expected `*** Add File: `, `*** Update File: `, or `*** Delete File: `; found `{}`", + single_line_preview(line, PREVIEW_CHARS), + ), None, )); }; + let path = path.trim(); if path.is_empty() { - return Err(WorkspacePatchParseError::new( - "workspace patch file path must not be empty", + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch {} section must name a path inside the workspace", + kind.marker().trim(), + ), None, )); } let path = path.to_owned(); - if !seen_paths.insert(path.clone()) { - return Err(WorkspacePatchParseError::new( - "workspace patch must not operate on the same file more than once", - Some(path), - )); - } index += 1; - let operation = if is_add { - WorkspacePatchOperation::Add { + let operation = match kind { + SectionKind::Add => WorkspacePatchOperation::Add { lines: parse_apply_patch_add_lines(&lines, &mut index, &path, end)?, - } - } else { - WorkspacePatchOperation::Update { + }, + SectionKind::Update => WorkspacePatchOperation::Update { hunks: parse_apply_patch_update_hunks(&lines, &mut index, &path, end)?, + }, + SectionKind::Delete => { + parse_apply_patch_delete_section(&lines, &mut index, &path, end)?; + WorkspacePatchOperation::Delete } }; - files.push(WorkspacePatchFile { path, operation }); + + match file_index_by_path.get(&path).copied() { + None => { + file_index_by_path.insert(path.clone(), files.len()); + files.push(WorkspacePatchFile { + path, + operation, + ignored_context_hunks: 0, + }); + } + // Repeated update sections for one file are a common shape when a + // caller edits distant regions, so they merge into a single file + // plan that still fails as a whole when any hunk misses. + Some(existing) => match (&mut files[existing].operation, operation) { + ( + WorkspacePatchOperation::Update { hunks }, + WorkspacePatchOperation::Update { hunks: additional }, + ) => hunks.extend(additional), + (WorkspacePatchOperation::Add { .. }, WorkspacePatchOperation::Add { .. }) => { + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch adds `{path}` more than once; use one {ADD_PREFIX}section per new file" + ), + Some(path), + )); + } + _ => { + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch mixes Add File, Update File, or Delete File sections for `{path}`; use one section per file and merge its hunks into that section" + ), + Some(path), + )); + } + }, + } } if files.is_empty() { - return Err(WorkspacePatchParseError::new( - "workspace patch must contain at least one file operation", + return Err(WorkspacePatchParseError::syntax( + "workspace patch must contain at least one file section (`*** Add File:`, `*** Update File:`, or `*** Delete File:`)", None, )); } + if !files.iter().any(WorkspacePatchFile::has_edit) { + return Err(WorkspacePatchParseError::noop( + "workspace patch contains no `+` or `-` lines, so nothing would change. Send the added or removed lines as `+`/`-` hunk lines to edit the file, or use `read_text` when you only need to inspect the current content", + match files.as_slice() { + [file] => Some(file.path.clone()), + _ => None, + }, + )); + } + + // Context-only hunks describe the caller's belief about unchanged lines, + // not edits. Once the envelope is known to contain a real edit they are + // anchored by the edited hunks anyway, so they are dropped and counted + // instead of silently disappearing. + for file in &mut files { + if let WorkspacePatchOperation::Update { hunks } = &mut file.operation { + let before = hunks.len(); + hunks.retain(WorkspacePatchHunk::has_edit); + file.ignored_context_hunks = before - hunks.len(); + } + } + files.retain(WorkspacePatchFile::has_edit); + Ok(WorkspacePatch { files }) } @@ -125,13 +256,10 @@ pub(super) fn parse_apply_patch_update_hunks( path: &str, end: &str, ) -> Result, WorkspacePatchParseError> { - const ADD_PREFIX: &str = "*** Add File: "; - const UPDATE_PREFIX: &str = "*** Update File: "; - let mut hunks = Vec::new(); let mut current = Vec::new(); while let Some(line) = patch_line(lines.get(*index).copied()) { - if line == end || line.starts_with(ADD_PREFIX) || line.starts_with(UPDATE_PREFIX) { + if line == end || SectionKind::from_header(line).is_some() { break; } if line.trim().is_empty() && current.is_empty() { @@ -139,13 +267,13 @@ pub(super) fn parse_apply_patch_update_hunks( continue; } if line.starts_with("@@") { - push_apply_patch_hunk(&mut hunks, &mut current, path)?; + push_apply_patch_hunk(&mut hunks, &mut current); *index += 1; continue; } let Some((prefix, text)) = line.split_at_checked(1) else { - return Err(WorkspacePatchParseError::new( - "workspace patch hunk line must start with space, +, or -", + return Err(WorkspacePatchParseError::syntax( + "workspace patch hunk line must start with a space, `+`, or `-`; found a blank line where a hunk line was expected (prefix context lines with one space)", Some(path.to_owned()), )); }; @@ -154,19 +282,24 @@ pub(super) fn parse_apply_patch_update_hunks( "-" => current.push(WorkspacePatchLine::Remove(text.to_owned())), "+" => current.push(WorkspacePatchLine::Add(text.to_owned())), _ => { - return Err(WorkspacePatchParseError::new( - "workspace patch hunk line must start with space, +, or -", + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch hunk line must start with a space, `+`, or `-`; found `{}`", + single_line_preview(line, PREVIEW_CHARS), + ), Some(path.to_owned()), )); } } *index += 1; } - push_apply_patch_hunk(&mut hunks, &mut current, path)?; + push_apply_patch_hunk(&mut hunks, &mut current); if hunks.is_empty() { - return Err(WorkspacePatchParseError::new( - "workspace patch update must contain at least one edited hunk; context-only hunks are ignored", + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch update section for `{path}` contains no hunk lines; send `@@` followed by context, `+`, or `-` lines" + ), Some(path.to_owned()), )); } @@ -179,12 +312,9 @@ fn parse_apply_patch_add_lines( path: &str, end: &str, ) -> Result, WorkspacePatchParseError> { - const ADD_PREFIX: &str = "*** Add File: "; - const UPDATE_PREFIX: &str = "*** Update File: "; - let mut contents = Vec::new(); while let Some(line) = patch_line(lines.get(*index).copied()) { - if line == end || line.starts_with(ADD_PREFIX) || line.starts_with(UPDATE_PREFIX) { + if line == end || SectionKind::from_header(line).is_some() { break; } @@ -196,14 +326,17 @@ fn parse_apply_patch_add_lines( } let Some((prefix, text)) = line.split_at_checked(1) else { - return Err(WorkspacePatchParseError::new( - "workspace patch add lines must start with +", + return Err(WorkspacePatchParseError::syntax( + "workspace patch add lines must start with `+`; found a blank line", Some(path.to_owned()), )); }; if prefix != "+" { - return Err(WorkspacePatchParseError::new( - "workspace patch add lines must start with +", + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch add lines must start with `+`; found `{}`", + single_line_preview(line, PREVIEW_CHARS), + ), Some(path.to_owned()), )); } @@ -212,8 +345,10 @@ fn parse_apply_patch_add_lines( } if contents.is_empty() { - return Err(WorkspacePatchParseError::new( - "workspace patch add must contain at least one + line", + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch add section for `{path}` contains no `+` lines; every line of the new file needs a `+` prefix" + ), Some(path.to_owned()), )); } @@ -221,22 +356,47 @@ fn parse_apply_patch_add_lines( Ok(contents) } +/// Consumes a `*** Delete File:` section, which has no hunk body. +fn parse_apply_patch_delete_section( + lines: &[&str], + index: &mut usize, + path: &str, + end: &str, +) -> Result<(), WorkspacePatchParseError> { + while let Some(line) = patch_line(lines.get(*index).copied()) { + if line == end || SectionKind::from_header(line).is_some() { + return Ok(()); + } + if line.trim().is_empty() { + *index += 1; + continue; + } + + return Err(WorkspacePatchParseError::syntax( + format!( + "workspace patch delete section for `{path}` must not contain content lines; found `{}`", + single_line_preview(line, PREVIEW_CHARS), + ), + Some(path.to_owned()), + )); + } + Ok(()) +} + +/// Moves the accumulated hunk lines into the section's hunk list. +/// +/// Context-only hunks are kept here on purpose: the caller decides whether to +/// drop them next to real edits or to report a patch that changes nothing. fn push_apply_patch_hunk( hunks: &mut Vec, current: &mut Vec, - _path: &str, -) -> Result<(), WorkspacePatchParseError> { +) { if current.is_empty() { - return Ok(()); + return; } - let hunk = WorkspacePatchHunk { + hunks.push(WorkspacePatchHunk { lines: std::mem::take(current), - }; - if !hunk.has_edit() { - return Ok(()); - } - hunks.push(hunk); - Ok(()) + }); } fn patch_line(line: Option<&str>) -> Option<&str> { diff --git a/crates/merry-tools/src/patch/plan.rs b/crates/merry-tools/src/patch/plan.rs index bcd328c8..2a3650f6 100644 --- a/crates/merry-tools/src/patch/plan.rs +++ b/crates/merry-tools/src/patch/plan.rs @@ -1,6 +1,5 @@ use std::{ fs, - io::Read, path::{Path, PathBuf}, }; @@ -19,9 +18,10 @@ use crate::{ ERROR_PATH_DENIED, ERROR_PROPOSAL_MISMATCH, ERROR_READ_FAILED, PathValidationError, WORKSPACE_PATCH_PLAN_CHANGED_MESSAGE, failed_outcome, }, + file::{decode_utf8, read_bounded}, path::{ - NewWorkspacePath, ValidatedRelativePath, open_file_for_read, resolve_existing_path, - resolve_new_file_path, validate_relative_path, + NewWorkspacePath, ValidatedToolPath, open_file_for_read, resolve_existing_path, + resolve_new_file_path, validate_workspace_path_argument, }, state::{WorkspaceToolState, matches_any_scope_path}, }; @@ -29,10 +29,12 @@ use crate::{ use super::{ ApplyPatchInput, apply::execute_apply_patch_plan, + envelope::{WorkspacePatchOperationKind, WorkspacePatchSuccessLine}, parse::parse_apply_patch, types::{ - WorkspacePatchFile, WorkspacePatchHunk, WorkspacePatchOperation, WorkspacePatchSuccessLine, - build_new_file_replacement, build_patch_replacement, stable_content_fingerprint, + WorkspacePatchFile, WorkspacePatchHunk, WorkspacePatchOperation, + build_new_file_replacement, build_patch_replacement, count_file_lines, + stable_content_fingerprint, }, }; @@ -140,7 +142,7 @@ fn plan_apply_patch_blocking_checked( Err(error) => { return Ok(WorkspacePatchPlanOutcome::Failure(failed_outcome( APPLY_PATCH_TOOL, - ERROR_INVALID_ARGUMENTS, + error.code, error.message, error.path, ))); @@ -202,10 +204,7 @@ impl WorkspacePatchPlan { fn summary(&self) -> String { match self.changes.as_slice() { - [change] => format!( - "Apply {} hunk(s) in {} ({} bytes -> {} bytes).", - change.hunks, change.relative.display, change.bytes_before, change.bytes_after - ), + [change] => change.summary(), changes => { let bytes_before = changes.iter().fold(0usize, |sum, change| { sum.saturating_add(change.bytes_before) @@ -213,9 +212,17 @@ impl WorkspacePatchPlan { let bytes_after = changes .iter() .fold(0usize, |sum, change| sum.saturating_add(change.bytes_after)); + let lines_before = changes.iter().fold(0usize, |sum, change| { + sum.saturating_add(change.lines_before) + }); + let lines_after = changes + .iter() + .fold(0usize, |sum, change| sum.saturating_add(change.lines_after)); format!( - "Apply workspace patch to {} files ({} bytes -> {} bytes).", + "Apply workspace patch to {} files ({} -> {} lines, {} -> {} bytes).", changes.len(), + lines_before, + lines_after, bytes_before, bytes_after ) @@ -228,11 +235,23 @@ impl WorkspacePatchPlan { pub(super) enum WorkspacePatchFileMode { CreateNew, UpdateExisting, + DeleteExisting, +} + +impl WorkspacePatchFileMode { + /// Names the operation recorded in the success envelope. + pub(super) fn operation_kind(self) -> WorkspacePatchOperationKind { + match self { + Self::CreateNew => WorkspacePatchOperationKind::Add, + Self::UpdateExisting => WorkspacePatchOperationKind::Update, + Self::DeleteExisting => WorkspacePatchOperationKind::Delete, + } + } } #[derive(Debug)] pub(super) struct WorkspacePatchFilePlan { - pub(super) relative: ValidatedRelativePath, + pub(super) relative: ValidatedToolPath, pub(super) path: PathBuf, pub(super) content_before: String, pub(super) replacement: String, @@ -240,13 +259,43 @@ pub(super) struct WorkspacePatchFilePlan { pub(super) replacement_bytes: usize, pub(super) bytes_before: usize, pub(super) bytes_after: usize, + /// Line counts of the planned preimage and replacement. + /// + /// They are stored beside the byte counts so every consumer of a plan + /// reports the same numbers without rescanning the file content, and so a + /// proposal summary cannot drift from the change it describes. + pub(super) lines_before: usize, + pub(super) lines_after: usize, pub(super) hunks: usize, + pub(super) ignored_context_hunks: usize, pub(super) lines: Vec, pub(super) max_read_bytes: usize, pub(super) mode: WorkspacePatchFileMode, } impl WorkspacePatchFilePlan { + /// Describes the planned change for proposal and audit text. + /// + /// Reviewers reason about a change in lines, so the summary leads with the + /// line counts and keeps byte counts as the secondary file-size measure. + fn summary(&self) -> String { + if self.mode == WorkspacePatchFileMode::DeleteExisting { + return format!( + "Delete {} ({} lines, {} bytes).", + self.relative.display, self.lines_before, self.bytes_before + ); + } + format!( + "Apply {} hunk(s) in {} ({} -> {} lines, {} -> {} bytes).", + self.hunks, + self.relative.display, + self.lines_before, + self.lines_after, + self.bytes_before, + self.bytes_after + ) + } + pub(super) fn file_fingerprint_before(&self) -> String { stable_content_fingerprint(self.content_before.as_bytes()) } @@ -299,149 +348,128 @@ fn plan_apply_patch_file( ) -> Result { match file_patch.operation { WorkspacePatchOperation::Add { lines } => { - let relative = validate_relative_path(&file_patch.path, state.allow_hidden) + let relative = validate_workspace_path_argument(&file_patch.path, &state.root) .map_err(WorkspacePatchFilePlanError::Path)?; + let display = relative.display.clone(); validate_patch_write_boundary(state, &relative).map_err(|error| { WorkspacePatchFilePlanError::Domain { error, - path: file_patch.path.clone(), + path: display.clone(), } })?; - // Match Update's first-root-wins rule: Add creates at the first - // root reporting Missing and refuses an existing target immediately. - // If every root is missing the parent, fall back to the first root; - // execution will create those parents after the same path checks. - let mut first_parent_missing = None; - for root in &state.roots { - if is_cancelled() { - return Err(WorkspacePatchFilePlanError::Cancelled); - } - - match resolve_new_file_path(root, &relative) { - Ok(NewWorkspacePath::Missing(path)) => { - return plan_new_apply_patch_file( - relative, - path, - lines, - state, - is_cancelled, - ) - .map_err(|error| match error { - BlockingToolError::Domain(error) => { - WorkspacePatchFilePlanError::Domain { - error, - path: file_patch.path.clone(), - } - } - BlockingToolError::Cancelled => WorkspacePatchFilePlanError::Cancelled, - }); - } - Ok(NewWorkspacePath::Existing) => { - return Err(WorkspacePatchFilePlanError::Domain { - error: DomainError::new( - ERROR_FILE_ALREADY_EXISTS, - "workspace file already exists", - ), - path: relative.display, - }); - } - Ok(NewWorkspacePath::ParentMissing) => { - first_parent_missing.get_or_insert_with(|| { - relative.components.iter().fold( - root.to_path_buf(), - |mut path, component| { - path.push(component); - path - }, - ) - }); - } - Err(error) => { - return Err(WorkspacePatchFilePlanError::Domain { - error, - path: relative.display, - }); - } - } + if is_cancelled() { + return Err(WorkspacePatchFilePlanError::Cancelled); } - - if let Some(path) = first_parent_missing { - return plan_new_apply_patch_file(relative, path, lines, state, is_cancelled) - .map_err(|error| match error { - BlockingToolError::Domain(error) => WorkspacePatchFilePlanError::Domain { - error, - path: file_patch.path, - }, - BlockingToolError::Cancelled => WorkspacePatchFilePlanError::Cancelled, + let path = match resolve_new_file_path(&relative, std::iter::once(&state.root)) { + Ok(NewWorkspacePath::Missing(path)) => path, + Ok(NewWorkspacePath::Existing) => { + return Err(WorkspacePatchFilePlanError::Domain { + error: DomainError::new( + ERROR_FILE_ALREADY_EXISTS, + "workspace file already exists", + ), + path: display, }); - } - - Err(WorkspacePatchFilePlanError::Domain { - error: DomainError::new( - ERROR_FILE_NOT_FOUND, - "workspace file parent was not found", - ), - path: relative.display, + } + Err(error) => { + return Err(WorkspacePatchFilePlanError::Domain { + error, + path: display, + }); + } + }; + plan_new_apply_patch_file(relative, path, lines, state, is_cancelled).map_err(|error| { + match error { + BlockingToolError::Domain(error) => WorkspacePatchFilePlanError::Domain { + error, + path: display, + }, + BlockingToolError::Cancelled => WorkspacePatchFilePlanError::Cancelled, + } }) } WorkspacePatchOperation::Update { hunks } => { - let relative = validate_relative_path(&file_patch.path, state.allow_hidden) - .map_err(WorkspacePatchFilePlanError::Path)?; - validate_patch_write_boundary(state, &relative).map_err(|error| { - WorkspacePatchFilePlanError::Domain { - error, - path: file_patch.path.clone(), - } - })?; + let (relative, path) = + resolve_existing_patch_path(state, &file_patch.path, is_cancelled)?; + let display = relative.display.clone(); + plan_resolved_apply_patch_file( + relative, + path, + hunks, + file_patch.ignored_context_hunks, + state, + is_cancelled, + ) + .map_err(|error| file_plan_error(error, display)) + } + WorkspacePatchOperation::Delete => { + let (relative, path) = + resolve_existing_patch_path(state, &file_patch.path, is_cancelled)?; + let display = relative.display.clone(); + plan_resolved_apply_patch_delete(relative, path, state, is_cancelled) + .map_err(|error| file_plan_error(error, display)) + } + } +} - for root in &state.roots { - if is_cancelled() { - return Err(WorkspacePatchFilePlanError::Cancelled); - } +/// Resolves the workspace path of a file that a patch section edits or deletes. +/// +/// Update and delete sections share the same path rules: the requested path is +/// validated against write-scope and forbidden-path policy, then resolved below +/// the workspace root, and a missing file is reported as +/// `ERROR_FILE_NOT_FOUND`. +fn resolve_existing_patch_path( + state: &WorkspaceToolState, + requested: &str, + is_cancelled: &dyn Fn() -> bool, +) -> Result<(ValidatedToolPath, PathBuf), WorkspacePatchFilePlanError> { + let relative = validate_workspace_path_argument(requested, &state.root) + .map_err(WorkspacePatchFilePlanError::Path)?; + validate_patch_write_boundary(state, &relative).map_err(|error| { + WorkspacePatchFilePlanError::Domain { + error, + path: relative.display.clone(), + } + })?; - match resolve_existing_path(root, &relative) { - Ok(Some(resolved)) => { - return plan_resolved_apply_patch_file( - relative, - resolved.path, - hunks, - state, - is_cancelled, - ) - .map_err(|error| match error { - BlockingToolError::Domain(error) => { - WorkspacePatchFilePlanError::Domain { - error, - path: file_patch.path, - } - } - BlockingToolError::Cancelled => WorkspacePatchFilePlanError::Cancelled, - }); - } - Ok(None) => {} - Err(error) => { - return Err(WorkspacePatchFilePlanError::Domain { - error, - path: relative.display, - }); - } - } - } + if is_cancelled() { + return Err(WorkspacePatchFilePlanError::Cancelled); + } + match resolve_existing_path(&relative, std::iter::once(&state.root)) { + Ok(Some(path)) => Ok((relative, path)), + Ok(None) => Err(WorkspacePatchFilePlanError::Domain { + error: DomainError::new(ERROR_FILE_NOT_FOUND, "workspace file was not found"), + path: relative.display, + }), + Err(error) => Err(WorkspacePatchFilePlanError::Domain { + error, + path: relative.display, + }), + } +} - Err(WorkspacePatchFilePlanError::Domain { - error: DomainError::new(ERROR_FILE_NOT_FOUND, "workspace file was not found"), - path: relative.display, - }) - } +/// Maps a blocking tool error onto a file-plan failure for the requested path. +fn file_plan_error(error: BlockingToolError, path: String) -> WorkspacePatchFilePlanError { + match error { + BlockingToolError::Domain(error) => WorkspacePatchFilePlanError::Domain { error, path }, + BlockingToolError::Cancelled => WorkspacePatchFilePlanError::Cancelled, } } fn validate_patch_write_boundary( state: &WorkspaceToolState, - relative: &ValidatedRelativePath, + path: &ValidatedToolPath, ) -> Result<(), DomainError> { - if matches_any_scope_path(&relative.display, &state.forbidden_paths) { + // Scope patterns are root-relative, so a target outside the workspace root + // has no scope spelling and cannot be authorized by a relative pattern. + // That keeps a child agent inside the scope its parent gave it, which is a + // deliberate narrowing rather than sandbox policy. + let scope_path = path.relative_spelling(); + + if let Some(scope_path) = scope_path + && matches_any_scope_path(scope_path, &state.forbidden_paths) + { return Err(DomainError::new( ERROR_PATH_DENIED, "workspace patch path is forbidden by the child workspace scope", @@ -451,13 +479,12 @@ fn validate_patch_write_boundary( let Some(write_scope) = &state.patch_write_scope else { return Ok(()); }; - if matches_any_scope_path(&relative.display, write_scope) { - Ok(()) - } else { - Err(DomainError::new( + match scope_path { + Some(scope_path) if matches_any_scope_path(scope_path, write_scope) => Ok(()), + _ => Err(DomainError::new( ERROR_PATH_DENIED, "workspace patch path is outside the child write scope", - )) + )), } } @@ -469,9 +496,10 @@ enum WorkspacePatchFilePlanError { } fn plan_resolved_apply_patch_file( - relative: ValidatedRelativePath, + relative: ValidatedToolPath, path: PathBuf, hunks: Vec, + ignored_context_hunks: usize, state: &WorkspaceToolState, is_cancelled: &dyn Fn() -> bool, ) -> Result { @@ -493,11 +521,14 @@ fn plan_resolved_apply_patch_file( Ok(WorkspacePatchFilePlan { relative, path, + lines_before: count_file_lines(&content), + lines_after: count_file_lines(&replacement.text), bytes_before: content.len(), bytes_after: replacement.text.len(), preimage_bytes: replacement.preimage_bytes, replacement_bytes: replacement.replacement_bytes, hunks: hunks.len(), + ignored_context_hunks, lines: replacement.lines, content_before: content, replacement: replacement.text, @@ -506,8 +537,44 @@ fn plan_resolved_apply_patch_file( }) } +/// Plans the removal of an existing file. +/// +/// The plan keeps the preimage bytes and the file size so evidence, approval, +/// and write-time verification use the same contract as an update: the file is +/// read and compared before it is unlinked. +fn plan_resolved_apply_patch_delete( + relative: ValidatedToolPath, + path: PathBuf, + state: &WorkspaceToolState, + is_cancelled: &dyn Fn() -> bool, +) -> Result { + let content = read_patch_preimage(&path, state, is_cancelled)?; + + if is_cancelled() { + return Err(BlockingToolError::Cancelled); + } + + Ok(WorkspacePatchFilePlan { + lines_before: count_file_lines(&content), + lines_after: 0, + bytes_before: content.len(), + bytes_after: 0, + preimage_bytes: content.len(), + replacement_bytes: 0, + hunks: 0, + ignored_context_hunks: 0, + lines: Vec::new(), + content_before: content, + replacement: String::new(), + relative, + path, + max_read_bytes: state.limits.max_read_bytes, + mode: WorkspacePatchFileMode::DeleteExisting, + }) +} + fn plan_new_apply_patch_file( - relative: ValidatedRelativePath, + relative: ValidatedToolPath, path: PathBuf, lines: Vec, state: &WorkspaceToolState, @@ -529,11 +596,14 @@ fn plan_new_apply_patch_file( Ok(WorkspacePatchFilePlan { relative, path, + lines_before: 0, + lines_after: count_file_lines(&replacement.text), bytes_before: 0, bytes_after: replacement.text.len(), preimage_bytes: replacement.preimage_bytes, replacement_bytes: replacement.replacement_bytes, hunks: 1, + ignored_context_hunks: 0, lines: replacement.lines, content_before: String::new(), replacement: replacement.text, @@ -575,58 +645,12 @@ pub(super) fn read_patch_preimage_for_path( } let mut file = open_file_for_read(path)?; - let metadata = file.metadata().map_err(|_| { - DomainError::new( - ERROR_READ_FAILED, - "could not inspect workspace file metadata", - ) - })?; - - if !metadata.is_file() { - return Err( - DomainError::new(ERROR_NOT_FILE, "workspace path is not a regular file").into(), - ); - } - - if metadata.len() > max_read_bytes as u64 { - return Err(DomainError::new( - ERROR_FILE_TOO_LARGE, - "workspace file exceeds the configured read limit", - ) - .into()); - } - - let file_size = usize::try_from(metadata.len()).map_err(|_| { - DomainError::new( - ERROR_FILE_TOO_LARGE, - "workspace file exceeds the configured read limit", - ) - })?; - - if is_cancelled() { - return Err(BlockingToolError::Cancelled); - } - - let mut bytes = Vec::with_capacity(file_size); - Read::by_ref(&mut file) - .take(metadata.len()) - .read_to_end(&mut bytes) - .map_err(|_| DomainError::new(ERROR_READ_FAILED, "could not read workspace file"))?; - - if bytes.len() > max_read_bytes { - return Err(DomainError::new( - ERROR_FILE_TOO_LARGE, - "workspace file exceeds the configured read limit", - ) - .into()); - } - + let bytes = read_bounded(&mut file, max_read_bytes, is_cancelled)?; + // A patch edits text, so a NUL byte means the caller is about to make + // nonsense of a binary file. That is a patch decision rather than a read + // policy, which is why `read_text` still returns such bytes. if bytes.contains(&0) { return Err(DomainError::new(ERROR_NOT_UTF8, "workspace file appears to be binary").into()); } - - let content = String::from_utf8(bytes) - .map_err(|_| DomainError::new(ERROR_NOT_UTF8, "workspace file is not valid UTF-8"))?; - - Ok(content) + Ok(decode_utf8(bytes)?) } diff --git a/crates/merry-tools/src/patch/types.rs b/crates/merry-tools/src/patch/types.rs index 6ea996bb..8c0bfc9f 100644 --- a/crates/merry-tools/src/patch/types.rs +++ b/crates/merry-tools/src/patch/types.rs @@ -1,42 +1,20 @@ -use serde::Serialize; - use crate::errors::{ - BlockingToolError, DomainError, ERROR_INVALID_ARGUMENTS, ERROR_PREIMAGE_ABSENT, + BlockingToolError, DomainError, ERROR_PATCH_SYNTAX, ERROR_PREIMAGE_ABSENT, ERROR_PREIMAGE_AMBIGUOUS, }; -#[derive(Debug, Serialize)] -pub(super) struct WorkspacePatchSuccess { - pub(super) ok: bool, - pub(super) tool: &'static str, - pub(super) changes: Vec, -} - -#[derive(Debug, Serialize)] -pub(super) struct WorkspacePatchSuccessChange { - pub(super) path: String, - pub(super) hunks: usize, - pub(super) bytes_before: usize, - pub(super) bytes_after: usize, - pub(super) lines: Vec, -} - -#[derive(Debug, Serialize, Clone, PartialEq, Eq)] -pub(super) struct WorkspacePatchSuccessLine { - pub(super) kind: WorkspacePatchSuccessLineKind, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) old_line: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) new_line: Option, - pub(super) text: String, -} +use super::{ + diagnostic::{describe_preimage_ambiguity, describe_preimage_miss, line_number_at_byte}, + envelope::{WorkspacePatchSuccessLine, WorkspacePatchSuccessLineKind}, +}; -#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub(super) enum WorkspacePatchSuccessLineKind { - Context, - Remove, - Add, +/// Counts the lines of UTF-8 file content. +/// +/// The count matches how a reader sees the file rather than how many newline +/// bytes it holds: `str::lines()` ignores a trailing `\r`, so CRLF content +/// reports the same count as LF content, and empty content reports zero lines. +pub(super) fn count_file_lines(content: &str) -> usize { + content.lines().count() } #[derive(Debug)] @@ -48,12 +26,27 @@ pub(super) struct WorkspacePatch { pub(super) struct WorkspacePatchFile { pub(super) path: String, pub(super) operation: WorkspacePatchOperation, + /// Number of context-only hunks dropped from this file's update sections. + pub(super) ignored_context_hunks: usize, +} + +impl WorkspacePatchFile { + /// Reports whether this file section changes any content. + pub(super) fn has_edit(&self) -> bool { + match &self.operation { + WorkspacePatchOperation::Add { .. } | WorkspacePatchOperation::Delete => true, + WorkspacePatchOperation::Update { hunks } => { + hunks.iter().any(WorkspacePatchHunk::has_edit) + } + } + } } #[derive(Debug)] pub(super) enum WorkspacePatchOperation { Add { lines: Vec }, Update { hunks: Vec }, + Delete, } #[derive(Debug)] @@ -123,8 +116,8 @@ pub(super) fn build_patch_replacement( let new_text = hunk.new_text(trailing_newline); if old_text.is_empty() { return Err(DomainError::new( - ERROR_INVALID_ARGUMENTS, - "workspace patch update hunks must include context or removed text", + ERROR_PATCH_SYNTAX, + "workspace patch hunk has only + lines and no anchor; include at least one context line or removed line so the insert position is unambiguous", ) .into()); } @@ -199,7 +192,10 @@ fn build_replacement( let Some(start) = content.find(old_text) else { return Err(DomainError::new( ERROR_PREIMAGE_ABSENT, - "workspace patch preimage was not found", + format!( + "workspace patch preimage was not found{}", + describe_preimage_miss(content, old_text) + ), ) .into()); }; @@ -208,7 +204,10 @@ fn build_replacement( if content[after_start..].contains(old_text) { return Err(DomainError::new( ERROR_PREIMAGE_AMBIGUOUS, - "workspace patch preimage matched more than once", + format!( + "workspace patch preimage matched more than once{}", + describe_preimage_ambiguity(content, old_text, start) + ), ) .into()); } @@ -288,14 +287,6 @@ fn line_delta_for_hunk(hunk: &WorkspacePatchHunk) -> i64 { }) } -fn line_number_at_byte(content: &str, byte_index: usize) -> usize { - content[..byte_index] - .bytes() - .filter(|byte| *byte == b'\n') - .count() - + 1 -} - pub(crate) fn stable_content_fingerprint(bytes: &[u8]) -> String { const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; diff --git a/crates/merry-tools/src/path.rs b/crates/merry-tools/src/path.rs index 37914627..91714fc4 100644 --- a/crates/merry-tools/src/path.rs +++ b/crates/merry-tools/src/path.rs @@ -1,420 +1,27 @@ -use std::{ - fs, io, - path::{Component, Path, PathBuf}, +//! Tool path validation, resolution, and file opening. +//! +//! A workspace has one root. A relative path is normalized to root-relative +//! components and dot segments are resolved lexically; an absolute path is used +//! as the caller named it, and an absolute path below the workspace root is +//! rewritten to its relative spelling so one file never has two identities. A +//! path outside the workspace root is not limited to a configured root, because +//! the process sandbox, accepted process profile, and trusted path rules already +//! decide which paths exist and which of them are writable. Every spelling is +//! accepted, including dot-prefixed components. +//! +//! `validate` owns what a caller named and never touches the filesystem. +//! `open` owns resolution against the roots and every file open, which on Unix +//! passes `O_NOFOLLOW` so a symlink swapped into the leaf path between +//! validation and open cannot redirect the operation. This is not an OS sandbox +//! and does not claim complete hardening against malicious concurrent +//! filesystem mutation, including replacement of intermediate directories +//! during an operation. + +mod open; +mod validate; + +pub(crate) use open::{ + NewWorkspacePath, open_file_for_patch, open_file_for_patch_create_new, open_file_for_read, + resolve_existing_path, resolve_new_file_path, }; - -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; - -use crate::errors::{ - DomainError, ERROR_FILE_ALREADY_EXISTS, ERROR_NOT_DIRECTORY, ERROR_PATH_DENIED, - ERROR_READ_FAILED, ERROR_WRITE_FAILED, PathValidationError, -}; - -#[derive(Debug)] -pub(crate) struct ResolvedWorkspacePath { - pub(crate) path: PathBuf, -} - -pub(crate) fn resolve_existing_path( - root: &Path, - relative: &ValidatedRelativePath, -) -> Result, DomainError> { - let mut current = root.to_path_buf(); - for component in &relative.components { - current.push(component); - let metadata = match fs::symlink_metadata(¤t) { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(_) => { - return Err(DomainError::new( - ERROR_READ_FAILED, - "could not inspect workspace path", - )); - } - }; - - if metadata.file_type().is_symlink() { - return Err(DomainError::new( - ERROR_PATH_DENIED, - "workspace path uses a symlink", - )); - } - } - - let canonical = fs::canonicalize(¤t).map_err(|_| { - DomainError::new(ERROR_READ_FAILED, "could not canonicalize workspace path") - })?; - if !canonical.starts_with(root) { - return Err(DomainError::new( - ERROR_PATH_DENIED, - "workspace path resolves outside a configured root", - )); - } - - Ok(Some(ResolvedWorkspacePath { path: current })) -} - -#[derive(Debug)] -pub(crate) enum NewWorkspacePath { - Missing(PathBuf), - Existing, - ParentMissing, -} - -pub(crate) fn resolve_new_file_path( - root: &Path, - relative: &ValidatedRelativePath, -) -> Result { - let last_index = relative.components.len().saturating_sub(1); - let mut current = root.to_path_buf(); - - for (index, component) in relative.components.iter().enumerate() { - current.push(component); - let metadata = match fs::symlink_metadata(¤t) { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - if index == last_index { - let parent = current.parent().ok_or_else(|| { - DomainError::new( - ERROR_READ_FAILED, - "could not inspect workspace file parent", - ) - })?; - let canonical_parent = fs::canonicalize(parent).map_err(|_| { - DomainError::new( - ERROR_READ_FAILED, - "could not canonicalize workspace file parent", - ) - })?; - if !canonical_parent.starts_with(root) { - return Err(DomainError::new( - ERROR_PATH_DENIED, - "workspace path resolves outside a configured root", - )); - } - return Ok(NewWorkspacePath::Missing(current)); - } - return Ok(NewWorkspacePath::ParentMissing); - } - Err(_) => { - return Err(DomainError::new( - ERROR_READ_FAILED, - "could not inspect workspace path", - )); - } - }; - - if metadata.file_type().is_symlink() { - return Err(DomainError::new( - ERROR_PATH_DENIED, - "workspace path uses a symlink", - )); - } - if index < last_index && !metadata.is_dir() { - return Err(DomainError::new( - ERROR_NOT_DIRECTORY, - "workspace path parent is not a directory", - )); - } - if index == last_index { - return Ok(NewWorkspacePath::Existing); - } - } - - Err(DomainError::new( - ERROR_READ_FAILED, - "could not resolve workspace file path", - )) -} - -pub(crate) fn open_file_for_read(path: &Path) -> Result { - open_file_for_read_impl(path).map_err(|error| { - if is_symlink_open_error(&error) { - DomainError::new(ERROR_PATH_DENIED, "workspace path uses a symlink") - } else { - DomainError::new(ERROR_READ_FAILED, "could not open workspace file") - } - }) -} - -pub(crate) fn open_file_for_patch(path: &Path) -> Result { - open_file_for_patch_impl(path).map_err(|error| { - if is_symlink_open_error(&error) { - DomainError::new(ERROR_PATH_DENIED, "workspace path uses a symlink") - } else { - DomainError::new( - ERROR_WRITE_FAILED, - "could not open workspace file for patching", - ) - } - }) -} - -pub(crate) fn open_file_for_patch_create_new(path: &Path) -> Result { - create_patch_parent_directories(path)?; - open_file_for_patch_create_new_impl(path).map_err(|error| { - if is_symlink_open_error(&error) { - DomainError::new(ERROR_PATH_DENIED, "workspace path uses a symlink") - } else if error.kind() == io::ErrorKind::AlreadyExists { - DomainError::new(ERROR_FILE_ALREADY_EXISTS, "workspace file already exists") - } else { - DomainError::new(ERROR_WRITE_FAILED, "could not create workspace file") - } - }) -} - -fn create_patch_parent_directories(path: &Path) -> Result<(), DomainError> { - let parent = path.parent().ok_or_else(|| { - DomainError::new( - ERROR_WRITE_FAILED, - "could not determine workspace file parent directory", - ) - })?; - let mut current = PathBuf::new(); - - for component in parent.components() { - current.push(component); - match fs::symlink_metadata(¤t) { - Ok(metadata) => validate_patch_parent_metadata(&metadata)?, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - match fs::create_dir(¤t) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { - let metadata = fs::symlink_metadata(¤t).map_err(|_| { - DomainError::new( - ERROR_WRITE_FAILED, - "could not inspect workspace file parent directory", - ) - })?; - validate_patch_parent_metadata(&metadata)?; - } - Err(_) => { - return Err(DomainError::new( - ERROR_WRITE_FAILED, - "could not create workspace file parent directories", - )); - } - } - } - Err(_) => { - return Err(DomainError::new( - ERROR_WRITE_FAILED, - "could not inspect workspace file parent directory", - )); - } - } - } - - Ok(()) -} - -fn validate_patch_parent_metadata(metadata: &fs::Metadata) -> Result<(), DomainError> { - if metadata.file_type().is_symlink() { - return Err(DomainError::new( - ERROR_PATH_DENIED, - "workspace path uses a symlink", - )); - } - if !metadata.is_dir() { - return Err(DomainError::new( - ERROR_NOT_DIRECTORY, - "workspace file parent is not a directory", - )); - } - Ok(()) -} - -#[cfg(unix)] -fn open_file_for_read_impl(path: &Path) -> io::Result { - let mut options = fs::OpenOptions::new(); - options - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); - options.open(path) -} - -#[cfg(not(unix))] -fn open_file_for_read_impl(path: &Path) -> io::Result { - fs::File::open(path) -} - -#[cfg(unix)] -fn open_file_for_patch_impl(path: &Path) -> io::Result { - let mut options = fs::OpenOptions::new(); - options - .read(true) - .write(true) - .create(false) - .truncate(false) - .custom_flags(libc::O_NOFOLLOW); - options.open(path) -} - -#[cfg(unix)] -fn open_file_for_patch_create_new_impl(path: &Path) -> io::Result { - let mut options = fs::OpenOptions::new(); - options - .read(true) - .write(true) - .create_new(true) - .custom_flags(libc::O_NOFOLLOW); - options.open(path) -} - -#[cfg(not(unix))] -fn open_file_for_patch_create_new_impl(path: &Path) -> io::Result { - fs::OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .open(path) -} - -#[cfg(not(unix))] -fn open_file_for_patch_impl(path: &Path) -> io::Result { - fs::OpenOptions::new() - .read(true) - .write(true) - .create(false) - .truncate(false) - .open(path) -} - -#[cfg(unix)] -fn is_symlink_open_error(error: &io::Error) -> bool { - error.raw_os_error() == Some(libc::ELOOP) -} - -#[cfg(not(unix))] -fn is_symlink_open_error(_: &io::Error) -> bool { - false -} - -#[derive(Debug, Clone)] -pub(crate) struct ValidatedRelativePath { - pub(crate) components: Vec, - pub(crate) display: String, -} - -pub(crate) fn validate_relative_path( - raw_path: &str, - allow_hidden: bool, -) -> Result { - validate_relative_path_impl(raw_path, allow_hidden, false) -} - -fn validate_relative_path_impl( - raw_path: &str, - allow_hidden: bool, - allow_root: bool, -) -> Result { - if raw_path.is_empty() { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace path must not be empty", - None, - )); - } - - if raw_path.chars().any(char::is_control) { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace path must not contain control characters", - None, - )); - } - - if allow_root && raw_path == "." { - return Ok(ValidatedRelativePath { - components: Vec::new(), - display: ".".to_owned(), - }); - } - - let path = Path::new(raw_path); - if path.is_absolute() { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace path must be relative", - None, - )); - } - - if has_forbidden_raw_dot_segment(raw_path) { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace path must not contain '.' or '..' components", - Some(raw_path.to_owned()), - )); - } - - let mut components = Vec::new(); - for component in path.components() { - match component { - Component::Normal(value) => { - let Some(value) = value.to_str() else { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace path component must be UTF-8", - None, - )); - }; - if !allow_hidden && value.starts_with('.') { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace hidden paths are not allowed", - Some(raw_path.to_owned()), - )); - } - components.push(value.to_owned()); - } - Component::CurDir => { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace path must not contain '.' components", - Some(raw_path.to_owned()), - )); - } - Component::ParentDir => { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace path must not contain '..' components", - Some(raw_path.to_owned()), - )); - } - Component::RootDir | Component::Prefix(_) => { - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - "workspace path must be relative", - None, - )); - } - } - } - - if components.is_empty() { - let message = if allow_root { - "workspace path must be exact '.' or name a relative path" - } else { - "workspace path must name a file" - }; - return Err(PathValidationError::new( - ERROR_PATH_DENIED, - message, - Some(raw_path.to_owned()), - )); - } - - let display = components.join("/"); - Ok(ValidatedRelativePath { - components, - display, - }) -} - -fn has_forbidden_raw_dot_segment(raw_path: &str) -> bool { - raw_path - .split('/') - .any(|segment| segment == "." || segment == "..") -} +pub(crate) use validate::{ValidatedToolPath, validate_workspace_path_argument}; diff --git a/crates/merry-tools/src/path/open.rs b/crates/merry-tools/src/path/open.rs new file mode 100644 index 00000000..9803428f --- /dev/null +++ b/crates/merry-tools/src/path/open.rs @@ -0,0 +1,328 @@ +//! Resolution and opening of a validated tool path. +//! +//! Everything here touches the filesystem. A relative argument is walked below +//! each candidate root in order and a symlink component is refused, while an +//! absolute argument is used exactly as the caller named it. On Unix every open +//! also passes `O_NOFOLLOW`, so a symlink swapped into the leaf between +//! resolution and open cannot redirect the operation. + +use std::{fs, io, path::Path, path::PathBuf}; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +use crate::errors::{ + DomainError, ERROR_FILE_ALREADY_EXISTS, ERROR_NOT_DIRECTORY, ERROR_PATH_DENIED, + ERROR_READ_FAILED, ERROR_WRITE_FAILED, +}; + +use super::validate::ValidatedToolPath; + +/// Resolves a validated tool path to the file it names, when that file exists. +/// +/// `roots` are the anchors a relative argument may resolve under, in order. An +/// absolute argument names exactly one path, so it is resolved as the caller +/// wrote it and can never land below a different, same-named anchor. +/// +/// Every component below an anchor is walked with `symlink_metadata`, so a +/// path inside the workspace cannot be silently redirected through a link. An +/// absolute path is resolved by the operating system, and the leaf stays +/// protected because every open in this crate uses `O_NOFOLLOW`. +pub(crate) fn resolve_existing_path<'a>( + path: &'a ValidatedToolPath, + roots: impl IntoIterator, +) -> Result, DomainError> { + for (anchor, components) in path.anchors(roots) { + if let Some(resolved) = walk_existing_path(&anchor, components, path.denies_symlinks())? { + return Ok(Some(resolved)); + } + } + + Ok(None) +} + +/// Walks `components` below `anchor` and returns the path when it exists. +fn walk_existing_path( + anchor: &Path, + components: &[String], + deny_symlinks: bool, +) -> Result, DomainError> { + let mut current = anchor.to_path_buf(); + for component in components { + current.push(component); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(_) => { + return Err(DomainError::new( + ERROR_READ_FAILED, + "could not inspect workspace path", + )); + } + }; + + if deny_symlinks && metadata.file_type().is_symlink() { + return Err(DomainError::new( + ERROR_PATH_DENIED, + "workspace path uses a symlink", + )); + } + } + + Ok(Some(current)) +} + +#[derive(Debug)] +pub(crate) enum NewWorkspacePath { + /// The target path is free; parent directories may still need creating. + Missing(PathBuf), + /// The target path already exists, so creation must not proceed. + Existing, +} + +/// Resolves the file a validated tool path would create. +/// +/// `roots` are the anchors a relative argument may resolve under, in order. An +/// absolute argument names exactly one path. A path that exists already reports +/// [`NewWorkspacePath::Existing`], and a path whose parent directories are +/// still missing reports [`NewWorkspacePath::Missing`] because the caller +/// creates those parents when it writes. +pub(crate) fn resolve_new_file_path<'a>( + path: &'a ValidatedToolPath, + roots: impl IntoIterator, +) -> Result { + for (anchor, components) in path.anchors(roots) { + if let Some(outcome) = walk_new_path(&anchor, components, path.denies_symlinks())? { + return Ok(outcome); + } + } + + Err(DomainError::new( + ERROR_READ_FAILED, + "could not resolve workspace file path", + )) +} + +/// Walks `components` below `anchor` to find whether a new file can be created. +fn walk_new_path( + anchor: &Path, + components: &[String], + deny_symlinks: bool, +) -> Result, DomainError> { + let last_index = components.len().saturating_sub(1); + // A missing component does not change the target: the file to create is + // still the last component, and the caller creates the missing parent + // directories when it writes. + let target = components + .iter() + .fold(anchor.to_path_buf(), |mut path, component| { + path.push(component); + path + }); + let mut current = anchor.to_path_buf(); + + for (index, component) in components.iter().enumerate() { + current.push(component); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(Some(NewWorkspacePath::Missing(target))); + } + Err(_) => { + return Err(DomainError::new( + ERROR_READ_FAILED, + "could not inspect workspace path", + )); + } + }; + + if deny_symlinks && metadata.file_type().is_symlink() { + return Err(DomainError::new( + ERROR_PATH_DENIED, + "workspace path uses a symlink", + )); + } + if index < last_index && !metadata.is_dir() { + return Err(DomainError::new( + ERROR_NOT_DIRECTORY, + "workspace path parent is not a directory", + )); + } + if index == last_index { + return Ok(Some(NewWorkspacePath::Existing)); + } + } + + Ok(None) +} + +/// Opens an existing file for reading. +pub(crate) fn open_file_for_read(path: &Path) -> Result { + open_file_for_read_impl(path).map_err(|error| { + if is_symlink_open_error(&error) { + DomainError::new(ERROR_PATH_DENIED, "workspace path uses a symlink") + } else { + DomainError::new(ERROR_READ_FAILED, "could not open workspace file") + } + }) +} + +/// Opens an existing file for an in-place patch. +pub(crate) fn open_file_for_patch(path: &Path) -> Result { + open_file_for_patch_impl(path).map_err(|error| { + if is_symlink_open_error(&error) { + DomainError::new(ERROR_PATH_DENIED, "workspace path uses a symlink") + } else { + DomainError::new( + ERROR_WRITE_FAILED, + "could not open workspace file for patching", + ) + } + }) +} + +/// Creates a patch target, creating its missing parent directories first. +pub(crate) fn open_file_for_patch_create_new(path: &Path) -> Result { + create_patch_parent_directories(path)?; + open_file_for_patch_create_new_impl(path).map_err(|error| { + if is_symlink_open_error(&error) { + DomainError::new(ERROR_PATH_DENIED, "workspace path uses a symlink") + } else if error.kind() == io::ErrorKind::AlreadyExists { + DomainError::new(ERROR_FILE_ALREADY_EXISTS, "workspace file already exists") + } else { + DomainError::new(ERROR_WRITE_FAILED, "could not create workspace file") + } + }) +} + +/// Creates the parent directories of a patch target, one component at a time. +/// +/// Each component is created explicitly and inspected before the next one, so a +/// symlink cannot be used to make `create_new` write outside the intended +/// directory tree. +fn create_patch_parent_directories(path: &Path) -> Result<(), DomainError> { + let parent = path.parent().ok_or_else(|| { + DomainError::new( + ERROR_WRITE_FAILED, + "could not determine workspace file parent directory", + ) + })?; + let mut current = PathBuf::new(); + + for component in parent.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) => validate_patch_parent_metadata(&metadata)?, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + match fs::create_dir(¤t) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(¤t).map_err(|_| { + DomainError::new( + ERROR_WRITE_FAILED, + "could not inspect workspace file parent directory", + ) + })?; + validate_patch_parent_metadata(&metadata)?; + } + Err(_) => { + return Err(DomainError::new( + ERROR_WRITE_FAILED, + "could not create workspace file parent directories", + )); + } + } + } + Err(_) => { + return Err(DomainError::new( + ERROR_WRITE_FAILED, + "could not inspect workspace file parent directory", + )); + } + } + } + + Ok(()) +} + +fn validate_patch_parent_metadata(metadata: &fs::Metadata) -> Result<(), DomainError> { + if metadata.file_type().is_symlink() { + return Err(DomainError::new( + ERROR_PATH_DENIED, + "workspace path uses a symlink", + )); + } + if !metadata.is_dir() { + return Err(DomainError::new( + ERROR_NOT_DIRECTORY, + "workspace file parent is not a directory", + )); + } + Ok(()) +} + +#[cfg(unix)] +fn open_file_for_read_impl(path: &Path) -> io::Result { + let mut options = fs::OpenOptions::new(); + options + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + options.open(path) +} + +#[cfg(not(unix))] +fn open_file_for_read_impl(path: &Path) -> io::Result { + fs::File::open(path) +} + +#[cfg(unix)] +fn open_file_for_patch_impl(path: &Path) -> io::Result { + let mut options = fs::OpenOptions::new(); + options + .read(true) + .write(true) + .create(false) + .truncate(false) + .custom_flags(libc::O_NOFOLLOW); + options.open(path) +} + +#[cfg(not(unix))] +fn open_file_for_patch_impl(path: &Path) -> io::Result { + fs::OpenOptions::new() + .read(true) + .write(true) + .create(false) + .truncate(false) + .open(path) +} + +#[cfg(unix)] +fn open_file_for_patch_create_new_impl(path: &Path) -> io::Result { + let mut options = fs::OpenOptions::new(); + options + .read(true) + .write(true) + .create_new(true) + .custom_flags(libc::O_NOFOLLOW); + options.open(path) +} + +#[cfg(not(unix))] +fn open_file_for_patch_create_new_impl(path: &Path) -> io::Result { + fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(path) +} + +#[cfg(unix)] +fn is_symlink_open_error(error: &io::Error) -> bool { + error.raw_os_error() == Some(libc::ELOOP) +} + +#[cfg(not(unix))] +fn is_symlink_open_error(_: &io::Error) -> bool { + false +} diff --git a/crates/merry-tools/src/path/validate.rs b/crates/merry-tools/src/path/validate.rs new file mode 100644 index 00000000..9b468d40 --- /dev/null +++ b/crates/merry-tools/src/path/validate.rs @@ -0,0 +1,264 @@ +//! Validation and lexical normalization of a tool path argument. +//! +//! Validation never touches the filesystem. It decides what the caller named, in +//! which form to report it, and whether the form is usable at all, so the same +//! argument always normalizes to the same path. + +use std::{ + borrow::Cow, + path::{Component, Path, PathBuf}, +}; + +use crate::errors::{ERROR_PATH_DENIED, PathValidationError}; + +/// A tool path argument after validation and lexical normalization. +#[derive(Debug, Clone)] +pub(crate) struct ValidatedToolPath { + /// Absolute directory an absolute argument is anchored at, or `None` when + /// the argument is relative and a configured root supplies the anchor. + /// + /// An absolute path that lies inside the workspace root keeps the relative + /// form instead, so it is reported the same way as the equivalent relative + /// path and the same scope rules apply to it. + anchored: Option, + pub(crate) components: Vec, + /// Normalized form reported in results and diagnostics. + pub(crate) display: String, +} + +impl ValidatedToolPath { + /// Returns the anchors and components this argument resolves under, in order. + /// + /// An absolute argument names exactly one path, so it has one anchor and it + /// does not matter which roots the caller passes. A relative argument names + /// one path per root, which is what lets a skill's own relative `SKILL.md` + /// path resolve below a read-only resource root. + pub(super) fn anchors<'a>( + &'a self, + roots: impl IntoIterator, + ) -> Vec<(PathBuf, &'a [String])> { + match self.anchored.as_ref() { + Some(anchor) => vec![(anchor.clone(), self.components.as_slice())], + None => roots + .into_iter() + .map(|root| (root.clone(), self.components.as_slice())) + .collect(), + } + } + + /// Returns whether the tools walk this argument themselves below a root. + /// + /// A relative argument is walked without following symlinks, so a path + /// inside the workspace cannot be redirected through a link. An absolute + /// argument is resolved by the operating system as the caller named it, + /// which is what lets a sandbox-exposed location such as a platform `/tmp` + /// link work; the leaf stays protected because every open in this crate + /// uses `O_NOFOLLOW`. + #[must_use] + pub(crate) fn denies_symlinks(&self) -> bool { + self.anchored.is_none() + } + + /// Returns the workspace-relative spelling this argument resolved to. + /// + /// Child workspace scope patterns and forbidden paths are root-relative, so + /// only an argument that resolved below the workspace root has a spelling + /// they can match. An absolute argument outside the root is `None`, which no + /// relative pattern authorizes. + #[must_use] + pub(crate) fn relative_spelling(&self) -> Option<&str> { + self.anchored.is_none().then_some(self.display.as_str()) + } +} + +/// Validates a tool path and normalizes its components. +/// +/// Every spelling a caller may reasonably produce is accepted: a path relative +/// to the workspace root, an absolute path inside it, an absolute path outside +/// it, a path that climbs above the root with `..`, and a path whose components +/// start with a dot such as `.github/workflows`. Shape is not policy here. The +/// tools are not the sandbox: the sandbox, accepted process profile, and trusted +/// path rules decide which paths exist and which of them are writable, and +/// re-deciding that inside the tool would only hide their answer. +/// +/// An absolute path below the workspace root is rewritten to its relative +/// spelling, because that is the same file, it keeps tool results free of host +/// paths, and it lets root-relative scope rules match. Every other absolute +/// path is kept as the caller wrote it instead of being matched against any +/// root, so naming one file can never resolve to a different same-named file. +/// +/// Normalization stays lexical so a result is reproducible without touching the +/// filesystem: `.` is dropped, `..` pops the component before it, and an +/// absolute argument clamps at its filesystem anchor. A relative argument that +/// climbs past its root keeps the leading `..`, which is what makes the escape +/// visible in the reported path instead of being silently rewritten. +/// +/// Child workspace scope is unaffected, because that is a deliberate narrowing +/// of one child agent rather than sandbox policy: its patterns are root-relative, +/// so a path outside the workspace root has no scope spelling to authorize it. +pub(crate) fn validate_workspace_path_argument( + raw_path: &str, + workspace_root: &Path, +) -> Result { + if raw_path.is_empty() { + return Err(PathValidationError::new( + ERROR_PATH_DENIED, + "workspace path must not be empty", + None, + )); + } + + if raw_path.chars().any(char::is_control) { + return Err(PathValidationError::new( + ERROR_PATH_DENIED, + "workspace path must not contain control characters", + None, + )); + } + + let requested = Path::new(raw_path); + let (anchored, relative_text, clamp) = if requested.is_absolute() { + match strip_workspace_root(requested, workspace_root)? { + Some(stripped) => (None, Cow::Borrowed(stripped), false), + None => { + let (anchor, remainder) = split_filesystem_root(requested)?; + (Some(anchor), Cow::Owned(remainder), true) + } + } + } else { + (None, Cow::Borrowed(raw_path), false) + }; + // Failure text reports the argument in the same form the success path + // reports: workspace-relative for a rooted argument, and the normalized + // absolute path for an absolute argument, which the caller already named. + let reported = |text: &str| -> String { + match anchored.as_ref() { + Some(anchor) => anchor + .join(text) + .to_str() + .map_or_else(|| text.to_owned(), str::to_owned), + None => text.to_owned(), + } + }; + let mut components: Vec = Vec::new(); + for component in Path::new(relative_text.as_ref()).components() { + match component { + Component::Normal(value) => { + let Some(value) = value.to_str() else { + return Err(PathValidationError::new( + ERROR_PATH_DENIED, + "workspace path component must be UTF-8", + None, + )); + }; + components.push(value.to_owned()); + } + // A `.` segment is redundant spelling for the same path, so it is + // dropped. `..` is resolved here, before any filesystem access. An + // absolute argument clamps at its anchor, so `/..` names `/`. A + // relative argument keeps a `..` that has nothing left to pop, so + // the reported path still shows that it left the root. + Component::CurDir => {} + Component::ParentDir => match components.last() { + Some(last) if last == ".." => components.push("..".to_owned()), + Some(_) => { + components.pop(); + } + // `clamp` marks an absolute argument anchored at the filesystem + // root, where `..` above the anchor still names the anchor. + None if clamp => {} + None => components.push("..".to_owned()), + }, + Component::RootDir | Component::Prefix(_) => { + return Err(PathValidationError::new( + ERROR_PATH_DENIED, + "workspace path must be relative to a workspace root or absolute", + None, + )); + } + } + } + + // The reported form keeps the anchor of an absolute argument, so a caller + // that named `/a/b` sees `/a/b` again instead of a bare `a/b`. + let display = if components.is_empty() && anchored.is_none() { + ".".to_owned() + } else { + reported(&components.join("/")) + }; + Ok(ValidatedToolPath { + anchored, + components, + display, + }) +} + +/// Converts an absolute argument inside a workspace root into a relative path. +/// +/// Returns `None` when the argument is outside the root. The root is canonical +/// and the comparison is lexical over path components, so a path that only +/// shares a prefix string with the root does not match it. +fn strip_workspace_root<'a>( + requested: &'a Path, + workspace_root: &Path, +) -> Result, PathValidationError> { + let Ok(stripped) = requested.strip_prefix(workspace_root) else { + return Ok(None); + }; + let Some(stripped) = stripped.to_str() else { + return Err(PathValidationError::new( + ERROR_PATH_DENIED, + "workspace path must be UTF-8", + None, + )); + }; + if stripped.is_empty() { + return Err(PathValidationError::new( + ERROR_PATH_DENIED, + "workspace path must name a file inside the workspace root, not the root itself", + None, + )); + } + Ok(Some(stripped)) +} + +/// Splits an absolute path into its filesystem anchor and the text below it. +/// +/// The anchor is the root or volume prefix, which is what an absolute argument +/// outside the workspace root is relative to. `..` is resolved here: a `..` +/// at the anchor names the anchor itself, so popping an empty remainder keeps +/// the anchor instead of failing. +fn split_filesystem_root(requested: &Path) -> Result<(PathBuf, String), PathValidationError> { + let mut anchor = PathBuf::new(); + let mut remainder = Vec::new(); + + for component in requested.components() { + match component { + Component::Prefix(_) | Component::RootDir => anchor.push(component.as_os_str()), + Component::Normal(value) => { + let Some(value) = value.to_str() else { + return Err(PathValidationError::new( + ERROR_PATH_DENIED, + "workspace path component must be UTF-8", + None, + )); + }; + remainder.push(value.to_owned()); + } + Component::CurDir => {} + Component::ParentDir => { + remainder.pop(); + } + } + } + + if remainder.is_empty() { + return Err(PathValidationError::new( + ERROR_PATH_DENIED, + "workspace path must name a file, not a filesystem root", + None, + )); + } + + Ok((anchor, remainder.join("/"))) +} diff --git a/crates/merry-tools/src/read/input.rs b/crates/merry-tools/src/read/input.rs index fea0f32e..27c817d7 100644 --- a/crates/merry-tools/src/read/input.rs +++ b/crates/merry-tools/src/read/input.rs @@ -7,13 +7,13 @@ use serde::Deserialize; #[merry_tools_macros::tool( crate = "crate", name = "read_text", - description = "Read a bounded one-based line range from a UTF-8 text file under a configured stable root. Omit start_line to begin at line 1 and omit max_lines to use the configured limit. Use multiple focused reads for larger files; do not request or assume complete-file content." + description = "Read a bounded one-based line range from a UTF-8 text file. Omit start_line to begin at line 1 and omit max_lines to use the configured limit. Use multiple focused reads for larger files; do not request or assume complete-file content." )] #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct ReadTextInput { #[schemars( - description = "Workspace-relative UTF-8 text file path to read. Do not use host-absolute paths or parent traversal.", + description = "UTF-8 text file path to read, relative to a workspace root or absolute. An absolute path may name a file outside the workspace, where the sandbox decides what is reachable. Any spelling is accepted, including dot-prefixed components such as `.github/workflows`.", length(min = 1) )] pub(crate) path: String, diff --git a/crates/merry-tools/src/read/mod.rs b/crates/merry-tools/src/read/mod.rs index 1e179a5c..0ef7157b 100644 --- a/crates/merry-tools/src/read/mod.rs +++ b/crates/merry-tools/src/read/mod.rs @@ -19,7 +19,8 @@ use crate::{ ERROR_INVALID_ARGUMENTS, ERROR_NOT_FILE, ERROR_READ_FAILED, failed_outcome, }, path::{ - ValidatedRelativePath, open_file_for_read, resolve_existing_path, validate_relative_path, + ValidatedToolPath, open_file_for_read, resolve_existing_path, + validate_workspace_path_argument, }, state::WorkspaceToolState, trace::{ @@ -121,7 +122,7 @@ fn read_text_blocking_checked( if is_cancelled() { return Err(ToolExecutionError::Cancelled); } - let relative = match validate_relative_path(&args.path, state.allow_hidden) { + let relative = match validate_workspace_path_argument(&args.path, &state.root) { Ok(relative) => relative, Err(error) => { return Ok(failed_outcome( @@ -142,51 +143,49 @@ fn read_text_blocking_checked( Some(relative.display), )); } - for root in state.read_roots() { - if is_cancelled() { - return Err(ToolExecutionError::Cancelled); + if is_cancelled() { + return Err(ToolExecutionError::Cancelled); + } + let resolved = match resolve_existing_path(&relative, state.read_roots()) { + Ok(Some(resolved)) => resolved, + Ok(None) => { + return Ok(failed_outcome( + READ_TEXT_TOOL, + ERROR_FILE_NOT_FOUND, + "workspace file was not found", + Some(relative.display), + )); } - match resolve_existing_path(root, &relative) { - Ok(Some(resolved)) => { - return match read_resolved_text( - &relative, - &resolved.path, - start_line, - max_lines, - &state.limits, - is_cancelled, - ) { - Ok(outcome) => Ok(outcome), - Err(BlockingToolError::Cancelled) => Err(ToolExecutionError::Cancelled), - Err(BlockingToolError::Domain(error)) => Ok(failed_outcome( - READ_TEXT_TOOL, - error.code, - error.message, - Some(relative.display), - )), - }; - } - Ok(None) => {} - Err(error) => { - return Ok(failed_outcome( - READ_TEXT_TOOL, - error.code, - error.message, - Some(relative.display), - )); - } + Err(error) => { + return Ok(failed_outcome( + READ_TEXT_TOOL, + error.code, + error.message, + Some(relative.display), + )); } + }; + match read_resolved_text( + &relative, + &resolved, + start_line, + max_lines, + &state.limits, + is_cancelled, + ) { + Ok(outcome) => Ok(outcome), + Err(BlockingToolError::Cancelled) => Err(ToolExecutionError::Cancelled), + Err(BlockingToolError::Domain(error)) => Ok(failed_outcome( + READ_TEXT_TOOL, + error.code, + error.message, + Some(relative.display), + )), } - Ok(failed_outcome( - READ_TEXT_TOOL, - ERROR_FILE_NOT_FOUND, - "workspace file was not found", - Some(relative.display), - )) } fn read_resolved_text( - relative: &ValidatedRelativePath, + relative: &ValidatedToolPath, path: &Path, start_line: usize, max_lines: usize, diff --git a/crates/merry-tools/src/state.rs b/crates/merry-tools/src/state.rs index 304372fb..4bedb790 100644 --- a/crates/merry-tools/src/state.rs +++ b/crates/merry-tools/src/state.rs @@ -8,9 +8,8 @@ use crate::config::{WorkspaceToolConfigError, WorkspaceToolLimits, WorkspaceTool #[derive(Debug)] pub(crate) struct WorkspaceToolState { - pub(crate) roots: Vec, + pub(crate) root: PathBuf, pub(crate) readonly_resource_roots: Vec, - pub(crate) allow_hidden: bool, pub(crate) limits: WorkspaceToolLimits, pub(crate) patch_write_scope: Option>, pub(crate) forbidden_paths: Vec, @@ -18,44 +17,14 @@ pub(crate) struct WorkspaceToolState { impl WorkspaceToolState { pub(crate) fn new(config: WorkspaceToolsConfig) -> Result { - if config.roots.is_empty() { - return Err(WorkspaceToolConfigError::NoRoots); - } - validate_limits(&config.limits)?; - let mut roots = Vec::with_capacity(config.roots.len()); - for root in config.roots { - if !root.exists() { - return Err(WorkspaceToolConfigError::RootNotFound { root }); - } - - let canonical = fs::canonicalize(&root).map_err(|source| { - WorkspaceToolConfigError::RootCanonicalize { - root: root.clone(), - source, - } - })?; - - if !canonical.is_dir() { - return Err(WorkspaceToolConfigError::RootNotDirectory { root }); - } - - roots.push(canonical); - } - + let root = canonical_root(config.root)?; let mut readonly_resource_roots = Vec::with_capacity(config.readonly_resource_roots.len()); - for root in config.readonly_resource_roots { - if !root.exists() { - continue; - } - let canonical = fs::canonicalize(&root).map_err(|source| { - WorkspaceToolConfigError::RootCanonicalize { - root: root.clone(), - source, - } - })?; - if canonical.is_dir() && !roots.iter().any(|workspace| workspace == &canonical) { + for resource_root in config.readonly_resource_roots { + if let Some(canonical) = canonical_optional_root(resource_root)? + && canonical != root + { readonly_resource_roots.push(canonical); } } @@ -70,17 +39,54 @@ impl WorkspaceToolState { } Ok(Self { - roots, + root, readonly_resource_roots, - allow_hidden: config.allow_hidden, limits: config.limits, patch_write_scope, forbidden_paths, }) } + /// Returns the workspace root followed by every read-only resource root. + /// + /// A relative argument is looked up in this order, so the workspace stays + /// the primary namespace and a skill's own relative `SKILL.md` path still + /// resolves. An absolute argument names one path and ignores this order. pub(crate) fn read_roots(&self) -> impl Iterator { - self.roots.iter().chain(self.readonly_resource_roots.iter()) + std::iter::once(&self.root).chain(self.readonly_resource_roots.iter()) + } +} + +/// Canonicalizes a required workspace root. +fn canonical_root(root: PathBuf) -> Result { + if !root.exists() { + return Err(WorkspaceToolConfigError::RootNotFound { root }); + } + + let canonical = + fs::canonicalize(&root).map_err(|source| WorkspaceToolConfigError::RootCanonicalize { + root: root.clone(), + source, + })?; + + if !canonical.is_dir() { + return Err(WorkspaceToolConfigError::RootNotDirectory { root }); + } + + Ok(canonical) +} + +/// Canonicalizes an optional read-only resource root. +/// +/// Resource roots are optional configuration, so a missing or non-directory +/// entry is skipped instead of failing the whole tool set. A root that exists +/// but cannot be canonicalized is still an error. +fn canonical_optional_root(root: PathBuf) -> Result, WorkspaceToolConfigError> { + match canonical_root(root) { + Ok(canonical) => Ok(Some(canonical)), + Err(WorkspaceToolConfigError::RootNotFound { .. }) + | Err(WorkspaceToolConfigError::RootNotDirectory { .. }) => Ok(None), + Err(error) => Err(error), } } diff --git a/crates/merry-tools/src/tests/config.rs b/crates/merry-tools/src/tests/config.rs index 40568162..3abc6d1e 100644 --- a/crates/merry-tools/src/tests/config.rs +++ b/crates/merry-tools/src/tests/config.rs @@ -1,10 +1,27 @@ use super::*; #[test] -fn config_rejects_missing_roots() { - let err = WorkspaceTools::new(WorkspaceToolsConfig::new(Vec::new())) - .expect_err("empty roots should be rejected"); - assert!(matches!(err, WorkspaceToolConfigError::NoRoots)); +fn config_rejects_missing_workspace_root() { + let temp = TempWorkspace::new("missing-root"); + + let err = WorkspaceTools::new(WorkspaceToolsConfig::new(temp.path().join("absent"))) + .expect_err("a missing workspace root should be rejected"); + assert!(matches!(err, WorkspaceToolConfigError::RootNotFound { .. })); +} + +#[test] +fn config_skips_missing_and_duplicate_resource_roots() { + let temp = TempWorkspace::new("resource-roots"); + let config = WorkspaceToolsConfig::new(temp.path().to_path_buf()) + .with_readonly_resource_roots(vec![temp.path().join("absent"), temp.path().to_path_buf()]); + + let tools = WorkspaceTools::new(config).expect("resource roots are optional"); + + assert!( + tools.state.readonly_resource_roots.is_empty(), + "a missing or workspace-identical resource root must be skipped: {:?}", + tools.state.readonly_resource_roots + ); } #[test] @@ -12,10 +29,8 @@ fn config_rejects_non_directory_root() { let temp = TempWorkspace::new("non-directory-root"); temp.write_text("file.txt", "content\n"); - let err = WorkspaceTools::new(WorkspaceToolsConfig::new(vec![ - temp.path().join("file.txt"), - ])) - .expect_err("file root should be rejected"); + let err = WorkspaceTools::new(WorkspaceToolsConfig::new(temp.path().join("file.txt"))) + .expect_err("file root should be rejected"); assert!(matches!( err, WorkspaceToolConfigError::RootNotDirectory { .. } @@ -25,12 +40,11 @@ fn config_rejects_non_directory_root() { #[test] fn config_rejects_zero_read_limit() { let temp = TempWorkspace::new("zero-limit"); - let config = WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits( - WorkspaceToolLimits { + let config = + WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(WorkspaceToolLimits { max_read_bytes: 0, ..WorkspaceToolLimits::default() - }, - ); + }); let err = WorkspaceTools::new(config).expect_err("zero limit should be rejected"); assert!(matches!( @@ -60,7 +74,7 @@ fn config_rejects_each_zero_limit() { other => panic!("unexpected limit name {other}"), } - let config = WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits(limits); + let config = WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(limits); let err = WorkspaceTools::new(config).expect_err("zero limit should be rejected"); assert!(matches!( err, @@ -74,9 +88,9 @@ fn config_rejects_invalid_patch_scope_paths() { let temp = TempWorkspace::new("invalid-patch-scope"); for config in [ - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]) + WorkspaceToolsConfig::new(temp.path().to_path_buf()) .with_patch_write_scope(Some(vec![PathBuf::from("../outside")])), - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]) + WorkspaceToolsConfig::new(temp.path().to_path_buf()) .with_forbidden_paths(vec![PathBuf::from("bad\npath")]), ] { let err = WorkspaceTools::new(config).expect_err("invalid scope should be rejected"); @@ -135,22 +149,25 @@ fn patch_tool_registration_is_opt_in_and_workspace_write() { } #[test] -fn hidden_paths_can_be_enabled_explicitly() { +fn hidden_path_components_are_ordinary_spelling() { let temp = TempWorkspace::new("allow-hidden"); temp.write_text(".secret", "ok\n"); - let tools = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_allow_hidden(true), - ) - .expect("workspace tools should construct"); + let tools = tools_for(temp.path()); let outcome = read_outcome(&tools, ".secret"); - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + outcome.status(), + ToolCallResultStatus::Succeeded, + "a leading dot is ordinary spelling, not a path policy the tool enforces" + ); + assert_eq!(json_content(&outcome)["content"], "ok\n"); } #[test] fn non_utf8_component_is_rejected_when_constructible() { let path = PathBuf::from(OsStr::new("plain")); let text = path.to_str().expect("plain path is utf8"); - let validated = validate_relative_path(text, false).expect("plain path validates"); + let validated = validate_workspace_path_argument(text, Path::new("/workspace")) + .expect("plain path validates"); assert_eq!(validated.display, "plain"); } diff --git a/crates/merry-tools/src/tests/mod.rs b/crates/merry-tools/src/tests/mod.rs index 88b2ceec..6a918f0c 100644 --- a/crates/merry-tools/src/tests/mod.rs +++ b/crates/merry-tools/src/tests/mod.rs @@ -1,20 +1,22 @@ use super::*; use crate::{ errors::{ - ERROR_FILE_ALREADY_EXISTS, ERROR_FILE_NOT_FOUND, ERROR_FILE_TOO_LARGE, - ERROR_INVALID_ARGUMENTS, ERROR_NOT_DIRECTORY, ERROR_NOT_FILE, ERROR_NOT_UTF8, - ERROR_PATH_DENIED, ERROR_PREIMAGE_ABSENT, ERROR_PREIMAGE_AMBIGUOUS, - ERROR_PROPOSAL_MISMATCH, WORKSPACE_PATCH_PLAN_CHANGED_MESSAGE, WORKSPACE_PATH_CONTRACT, + ALL_WORKSPACE_ERROR_CODES, ERROR_FILE_ALREADY_EXISTS, ERROR_FILE_NOT_FOUND, + ERROR_FILE_TOO_LARGE, ERROR_INVALID_ARGUMENTS, ERROR_NOT_DIRECTORY, ERROR_NOT_FILE, + ERROR_NOT_UTF8, ERROR_PATCH_NOOP, ERROR_PATCH_SYNTAX, ERROR_PATH_DENIED, + ERROR_PREIMAGE_ABSENT, ERROR_PREIMAGE_AMBIGUOUS, ERROR_PROPOSAL_MISMATCH, + ERROR_READ_FAILED, ERROR_WRITE_FAILED, WORKSPACE_PATCH_PLAN_CHANGED_MESSAGE, + WORKSPACE_PATH_CONTRACT, }, patch::{ ApplyPatchExecutor, ApplyPatchInput, apply_patch_blocking, apply_patch_blocking_checked, propose_apply_patch_blocking_checked, stable_content_fingerprint, }, - path::validate_relative_path, + path::validate_workspace_path_argument, read::{ReadTextExecutor, ReadTextInput, read_text_blocking}, trace::{ TRACE_PATH_MAX_CHARS, bounded_trace_text, install_patch_test_after_write_hook, - install_trace_start_test_hook, + install_patch_test_before_mutation_hook, install_trace_start_test_hook, }, }; use merry_core::{ @@ -151,7 +153,7 @@ impl Drop for TempWorkspace { } fn tools_for(root: &Path) -> WorkspaceTools { - WorkspaceTools::new(WorkspaceToolsConfig::new(vec![root.to_path_buf()])) + WorkspaceTools::new(WorkspaceToolsConfig::new(root.to_path_buf())) .expect("workspace tools should construct") } @@ -196,7 +198,7 @@ fn workspace_schemas_project_session_limits() { ..WorkspaceToolLimits::default() }; let tools = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits(limits), + WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(limits), ) .expect("workspace tools should construct"); let registered = tools.into_registered_tools_with_patch(); @@ -313,6 +315,22 @@ fn add_patch(path: &str, lines: &[&str]) -> String { format!("*** Begin Workspace Patch\n*** Add File: {path}\n{additions}\n*** End Workspace Patch") } +fn delete_patch(path: &str) -> String { + format!("*** Begin Workspace Patch\n*** Delete File: {path}\n*** End Workspace Patch") +} + +fn delete_preflight(tools: &WorkspaceTools, path: &str) -> ToolActionPreflight { + let patch = delete_patch(path); + let call = pending_call_for( + APPLY_PATCH_TOOL, + json!({ + "patch": patch + }), + ); + propose_apply_patch_blocking_checked(&tools.state, ApplyPatchInput { patch }, &call, &|| false) + .expect("uncancelled workspace patch proposal should not return cancellation") +} + fn add_patch_preflight(tools: &WorkspaceTools, path: &str, lines: &[&str]) -> ToolActionPreflight { let patch = add_patch(path, lines); let call = pending_call_for( @@ -359,11 +377,20 @@ fn assert_failed_json_for_tool( assert_eq!(payload["ok"], false); assert_eq!(payload["tool"], tool); assert_eq!(payload["error"]["code"], code); - assert_eq!( - payload["recovery"]["path_contract"], - WORKSPACE_PATH_CONTRACT - ); - if let Some(expected_guidance_kind) = expected_guidance_kind_for_code(code) { + let expected_recovery = expected_recovery_for_code(code) + .unwrap_or_else(|| panic!("{code} must declare its expected recovery")); + if expected_recovery.path_contract { + assert_eq!( + payload["recovery"]["path_contract"], + WORKSPACE_PATH_CONTRACT + ); + } else { + assert!( + payload.get("recovery").is_none(), + "patch-text failures must not repeat the workspace path contract" + ); + } + if let Some(expected_guidance_kind) = expected_recovery.guidance_kind { assert_eq!(payload["guidance"]["kind"], expected_guidance_kind); assert!( payload["guidance"]["message"] @@ -398,21 +425,80 @@ fn assert_failed_json_for_tool( ); } -fn expected_guidance_kind_for_code(code: &str) -> Option<&'static str> { +/// Model-facing recovery block a failure code must produce. +/// +/// The expectation lives here as data per code. Restating it instead of +/// re-running the production classifier keeps the assertion able to fail: a +/// classifier that moved a code into the wrong class would still agree with +/// itself. +struct ExpectedRecovery { + path_contract: bool, + guidance_kind: Option<&'static str>, +} + +/// Returns the declared expectation for one code, or `None` when none exists. +/// +/// The match names constants instead of literals, so an expectation for a code +/// that no longer exists cannot compile. The `None` arm is what makes +/// `every_workspace_error_code_declares_its_recovery` fail when a new code is +/// declared without its own expectation. +fn expected_recovery_for_code(code: &str) -> Option { match code { - ERROR_INVALID_ARGUMENTS => Some("workspace_invalid_arguments"), + ERROR_INVALID_ARGUMENTS => Some(ExpectedRecovery { + path_contract: true, + guidance_kind: Some("workspace_invalid_arguments"), + }), + ERROR_PATCH_SYNTAX => Some(ExpectedRecovery { + path_contract: false, + guidance_kind: Some("apply_patch_syntax"), + }), + ERROR_PATCH_NOOP => Some(ExpectedRecovery { + path_contract: false, + guidance_kind: Some("apply_patch_noop"), + }), + ERROR_PREIMAGE_ABSENT | ERROR_PREIMAGE_AMBIGUOUS => Some(ExpectedRecovery { + path_contract: false, + guidance_kind: Some("apply_patch_preimage_mismatch"), + }), + ERROR_PROPOSAL_MISMATCH => Some(ExpectedRecovery { + path_contract: true, + guidance_kind: Some("apply_patch_plan_changed"), + }), ERROR_PATH_DENIED | ERROR_FILE_NOT_FOUND | ERROR_FILE_ALREADY_EXISTS | ERROR_NOT_FILE - | ERROR_NOT_DIRECTORY => Some("workspace_path_recovery"), - ERROR_FILE_TOO_LARGE => Some("workspace_file_too_large"), - ERROR_PREIMAGE_ABSENT | ERROR_PREIMAGE_AMBIGUOUS => Some("apply_patch_preimage_mismatch"), - ERROR_PROPOSAL_MISMATCH => Some("apply_patch_plan_changed"), + | ERROR_NOT_DIRECTORY => Some(ExpectedRecovery { + path_contract: true, + guidance_kind: Some("workspace_path_recovery"), + }), + ERROR_FILE_TOO_LARGE => Some(ExpectedRecovery { + path_contract: true, + guidance_kind: Some("workspace_file_too_large"), + }), + // These codes explain the path contract without guidance text of their + // own, because a failure can be about where a path points and a failed + // read or write has no model-facing recovery step beyond retrying. + ERROR_NOT_UTF8 | ERROR_READ_FAILED | ERROR_WRITE_FAILED => Some(ExpectedRecovery { + path_contract: true, + guidance_kind: None, + }), _ => None, } } +#[test] +fn every_workspace_error_code_declares_its_recovery() { + for declared in ALL_WORKSPACE_ERROR_CODES { + assert!( + expected_recovery_for_code(declared.code).is_some(), + "{} ({}) needs an explicit expected recovery entry", + declared.name, + declared.code + ); + } +} + fn assert_no_provider_visible_patch_metadata(outcome: &ToolExecutionOutcome) { let text = outcome .content() @@ -524,6 +610,7 @@ mod patch; mod patch_policy; mod read; mod trace; +mod workspace_path; fn read_text_spec_default() -> ToolSpec { crate::read::spec(&WorkspaceToolLimits::default()).expect("valid read tool schema") diff --git a/crates/merry-tools/src/tests/patch.rs b/crates/merry-tools/src/tests/patch.rs deleted file mode 100644 index ea4b98fd..00000000 --- a/crates/merry-tools/src/tests/patch.rs +++ /dev/null @@ -1,550 +0,0 @@ -use super::*; - -#[test] -fn apply_patch_executor_replaces_one_hunk_in_existing_utf8_file() { - let temp = TempWorkspace::new("patch-success"); - temp.write_text("dir/note.txt", "alpha\nold value\nomega\n"); - let tools = tools_for(temp.path()); - let executor = ApplyPatchExecutor { - state: Arc::clone(&tools.state), - }; - let patch = update_patch("dir/note.txt", "old value", "new value"); - let call = pending_call_for(APPLY_PATCH_TOOL, json!({ "patch": patch })); - let runtime = tokio::runtime::Builder::new_current_thread() - .build() - .expect("tokio runtime should build"); - - let outcome = runtime - .block_on(executor.execute(call, ToolExecutionContext::default())) - .expect("patch executor should succeed"); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - let payload = json_content(&outcome); - assert_eq!( - payload, - json!({ - "ok": true, - "tool": APPLY_PATCH_TOOL, - "changes": [{ - "path": "dir/note.txt", - "hunks": 1, - "bytes_before": 22, - "bytes_after": 22, - "lines": [ - { "kind": "remove", "old_line": 2, "text": "old value" }, - { "kind": "add", "new_line": 2, "text": "new value" } - ] - }] - }) - ); - assert_eq!( - read_text(&temp.path().join("dir/note.txt")), - "alpha\nnew value\nomega\n" - ); - let evidence = match outcome - .execution_evidence() - .expect("successful patch should include internal execution evidence") - { - ActionExecutionEvidence::WorkspacePatch(evidence) => evidence, - ActionExecutionEvidence::ProcessAction(_) => { - panic!("workspace patch execution must not produce process action evidence") - } - }; - assert_eq!(evidence.relative_path(), "dir/note.txt"); - assert_eq!(evidence.preimage_bytes(), "old value\n".len()); - assert_eq!(evidence.replacement_bytes(), "new value\n".len()); - assert_eq!(evidence.file_bytes_before(), 22); - assert_eq!(evidence.file_bytes_after(), 22); - assert_eq!( - evidence.file_fingerprint_before(), - &stable_content_fingerprint("alpha\nold value\nomega\n".as_bytes()) - ); - assert_eq!( - evidence.file_fingerprint_after(), - &stable_content_fingerprint("alpha\nnew value\nomega\n".as_bytes()) - ); - assert!( - !outcome - .content() - .as_text() - .expect("json content") - .contains(temp.path().to_str().expect("temp path utf8")), - "tool output must not include absolute host roots" - ); -} - -#[test] -fn apply_patch_executor_adds_new_utf8_file() { - let temp = TempWorkspace::new("patch-add-success"); - let tools = tools_for(temp.path()); - - let outcome = patch_text_outcome(&tools, &add_patch("dir/nested/new.txt", &["alpha", "beta"])); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - assert_eq!( - read_text(&temp.path().join("dir/nested/new.txt")), - "alpha\nbeta\n" - ); - assert_eq!( - json_content(&outcome)["changes"][0], - json!({ - "path": "dir/nested/new.txt", - "hunks": 1, - "bytes_before": 0, - "bytes_after": "alpha\nbeta\n".len(), - "lines": [ - { "kind": "add", "new_line": 1, "text": "alpha" }, - { "kind": "add", "new_line": 2, "text": "beta" } - ] - }) - ); - - let evidence = match outcome - .execution_evidence() - .expect("successful add should include execution evidence") - { - ActionExecutionEvidence::WorkspacePatch(evidence) => evidence, - ActionExecutionEvidence::ProcessAction(_) => { - panic!("workspace patch execution must not produce process action evidence") - } - }; - assert_eq!(evidence.preimage_bytes(), 0); - assert_eq!(evidence.replacement_bytes(), "alpha\nbeta\n".len()); - assert_eq!(evidence.file_bytes_before(), 0); - assert_eq!(evidence.file_bytes_after(), "alpha\nbeta\n".len()); - assert_eq!( - evidence.file_fingerprint_before(), - &stable_content_fingerprint(b"") - ); - assert_eq!( - evidence.file_fingerprint_after(), - &stable_content_fingerprint(b"alpha\nbeta\n") - ); -} - -#[test] -fn apply_patch_add_file_proposal_and_execution_match() { - let temp = TempWorkspace::new("patch-add-proposal"); - fs::create_dir_all(temp.path().join("dir")).expect("parent directory should be created"); - let tools = tools_for(temp.path()); - let patch = add_patch("dir/new.txt", &["alpha"]); - let proposal = match add_patch_preflight(&tools, "dir/new.txt", &["alpha"]) { - ToolActionPreflight::Proposal(proposal) => proposal, - ToolActionPreflight::NoProposal | ToolActionPreflight::Outcome(_) => { - panic!("new file patch should produce a proposal") - } - }; - let proposed_patch = match proposal.evidence() { - ActionProposalEvidence::WorkspacePatch(patch) => patch, - ActionProposalEvidence::ProcessAction(_) => { - panic!("workspace patch proposal must not produce process action evidence") - } - }; - assert_eq!(proposed_patch.preimage_bytes(), 0); - assert_eq!(proposed_patch.file_bytes_before(), 0); - assert_eq!(proposed_patch.file_bytes_after(), "alpha\n".len()); - - let outcome = apply_patch_blocking_checked( - &tools.state, - ApplyPatchInput { patch }, - Some(proposed_patch), - &|| false, - ) - .expect("uncancelled workspace patch should not return cancellation"); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - assert_eq!(read_text(&temp.path().join("dir/new.txt")), "alpha\n"); -} - -#[test] -fn apply_patch_executor_combines_add_and_update_operations() { - let temp = TempWorkspace::new("patch-add-update"); - temp.write_text("existing.txt", "old\n"); - let tools = tools_for(temp.path()); - let patch = r#"*** Begin Workspace Patch -*** Add File: new.txt -+created -*** Update File: existing.txt --old -+updated -*** End Workspace Patch"#; - - let outcome = patch_text_outcome(&tools, patch); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - assert_eq!(read_text(&temp.path().join("new.txt")), "created\n"); - assert_eq!(read_text(&temp.path().join("existing.txt")), "updated\n"); - assert_eq!( - json_content(&outcome)["changes"].as_array().map(Vec::len), - Some(2) - ); -} - -#[test] -fn apply_patch_add_file_does_not_overwrite_existing_file() { - let temp = TempWorkspace::new("patch-add-existing"); - temp.write_text("note.txt", "old\n"); - let tools = tools_for(temp.path()); - - let outcome = patch_text_outcome(&tools, &add_patch("note.txt", &["new"])); - - assert_failed_json_for_tool( - &outcome, - APPLY_PATCH_TOOL, - ERROR_FILE_ALREADY_EXISTS, - Some("note.txt"), - temp.path(), - ); - assert_eq!(read_text(&temp.path().join("note.txt")), "old\n"); -} - -#[test] -fn apply_patch_add_file_requires_plus_lines() { - let temp = TempWorkspace::new("patch-add-invalid"); - let tools = tools_for(temp.path()); - let patch = - "*** Begin Workspace Patch\n*** Add File: new.txt\ncontent\n*** End Workspace Patch"; - - let outcome = patch_text_outcome(&tools, patch); - - assert_failed_json_for_tool( - &outcome, - APPLY_PATCH_TOOL, - ERROR_INVALID_ARGUMENTS, - Some("new.txt"), - temp.path(), - ); - assert!(!temp.path().join("new.txt").exists()); -} - -#[test] -fn apply_patch_add_file_tolerates_structural_blank_lines() { - let temp = TempWorkspace::new("patch-add-blank-lines"); - let tools = tools_for(temp.path()); - let patch = - "*** Begin Workspace Patch\n*** Add File: new.txt\n\n+created\n\n*** End Workspace Patch"; - - let outcome = patch_text_outcome(&tools, patch); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - assert_eq!(read_text(&temp.path().join("new.txt")), "created\n"); -} - -#[test] -fn apply_patch_add_file_rejects_non_directory_parent() { - let temp = TempWorkspace::new("patch-add-parent-file"); - temp.write_text("parent", "not a directory\n"); - let tools = tools_for(temp.path()); - - let outcome = patch_text_outcome(&tools, &add_patch("parent/new.txt", &["new"])); - - assert_failed_json_for_tool( - &outcome, - APPLY_PATCH_TOOL, - ERROR_NOT_DIRECTORY, - Some("parent/new.txt"), - temp.path(), - ); - assert_eq!(read_text(&temp.path().join("parent")), "not a directory\n"); -} - -#[test] -fn apply_patch_add_file_rejects_existing_directory() { - let temp = TempWorkspace::new("patch-add-directory"); - fs::create_dir(temp.path().join("dir")).expect("directory should be created"); - let tools = tools_for(temp.path()); - - let outcome = patch_text_outcome(&tools, &add_patch("dir", &["new"])); - - assert_failed_json_for_tool( - &outcome, - APPLY_PATCH_TOOL, - ERROR_FILE_ALREADY_EXISTS, - Some("dir"), - temp.path(), - ); -} - -#[test] -fn apply_patch_add_file_rejects_write_limit_without_creating_file() { - let temp = TempWorkspace::new("patch-add-write-limit"); - let tools = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits( - WorkspaceToolLimits { - max_write_bytes: 4, - ..WorkspaceToolLimits::default() - }, - ), - ) - .expect("workspace tools should construct"); - - let outcome = patch_text_outcome(&tools, &add_patch("new.txt", &["too large"])); - - assert_failed_json_for_tool( - &outcome, - APPLY_PATCH_TOOL, - ERROR_FILE_TOO_LARGE, - Some("new.txt"), - temp.path(), - ); - assert!(!temp.path().join("new.txt").exists()); -} - -#[test] -fn apply_patch_respects_configured_write_scope() { - let temp = TempWorkspace::new("patch-write-scope"); - temp.write_text("allowed/note.txt", "alpha\nold\nomega\n"); - temp.write_text("denied/note.txt", "alpha\nold\nomega\n"); - let tools = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]) - .with_patch_write_scope(Some(vec![PathBuf::from("allowed")])), - ) - .expect("workspace tools should construct"); - - let allowed = patch_outcome(&tools, "allowed/note.txt", "old", "new"); - assert_eq!(allowed.status(), ToolCallResultStatus::Succeeded); - - let denied = patch_outcome(&tools, "denied/note.txt", "old", "new"); - assert_failed_json_for_tool( - &denied, - APPLY_PATCH_TOOL, - ERROR_PATH_DENIED, - Some("denied/note.txt"), - temp.path(), - ); - assert_eq!( - read_text(&temp.path().join("denied/note.txt")), - "alpha\nold\nomega\n" - ); -} - -#[test] -fn apply_patch_forbidden_paths_override_write_scope() { - let temp = TempWorkspace::new("patch-forbidden-scope"); - temp.write_text("allowed/public.txt", "alpha\nold\nomega\n"); - temp.write_text("allowed/secret.txt", "alpha\nold\nomega\n"); - let tools = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]) - .with_patch_write_scope(Some(vec![PathBuf::from("allowed")])) - .with_forbidden_paths(vec![PathBuf::from("allowed/secret.txt")]), - ) - .expect("workspace tools should construct"); - - let public = patch_outcome(&tools, "allowed/public.txt", "old", "new"); - assert_eq!(public.status(), ToolCallResultStatus::Succeeded); - - let forbidden = patch_outcome(&tools, "allowed/secret.txt", "old", "new"); - assert_failed_json_for_tool( - &forbidden, - APPLY_PATCH_TOOL, - ERROR_PATH_DENIED, - Some("allowed/secret.txt"), - temp.path(), - ); - assert_eq!( - read_text(&temp.path().join("allowed/secret.txt")), - "alpha\nold\nomega\n" - ); -} - -#[test] -fn apply_patch_executor_accepts_standard_patch_envelope_alias() { - let temp = TempWorkspace::new("patch-standard-envelope-alias"); - temp.write_text("src/lib.rs", "alpha\nold value\nomega\n"); - let tools = tools_for(temp.path()); - let patch = "\ -*** Begin Patch -*** Update File: src/lib.rs --old value -+new value -*** End Patch"; - - let outcome = patch_text_outcome(&tools, patch); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - assert_eq!( - read_text(&temp.path().join("src/lib.rs")), - "alpha\nnew value\nomega\n" - ); -} - -#[test] -fn apply_patch_executor_ignores_context_only_hunks_when_editing() { - let temp = TempWorkspace::new("patch-context-only-hunk"); - temp.write_text("note.txt", "alpha\nold value\nomega\n"); - let tools = tools_for(temp.path()); - let patch = "*** Begin Workspace Patch -*** Update File: note.txt -@@ --old value -+new value -@@ - omega -*** End Workspace Patch"; - - let outcome = patch_text_outcome(&tools, patch); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - assert_eq!( - read_text(&temp.path().join("note.txt")), - "alpha\nnew value\nomega\n" - ); - assert_eq!(json_content(&outcome)["changes"][0]["hunks"], 1); -} - -#[test] -fn apply_patch_executor_rejects_an_update_with_only_context_hunks() { - let temp = TempWorkspace::new("patch-only-context-hunk"); - temp.write_text("note.txt", "alpha\nold value\nomega\n"); - let tools = tools_for(temp.path()); - let patch = "*** Begin Workspace Patch -*** Update File: note.txt -@@ - old value -*** End Workspace Patch"; - - let outcome = patch_text_outcome(&tools, patch); - - assert_failed_json_for_tool( - &outcome, - APPLY_PATCH_TOOL, - ERROR_INVALID_ARGUMENTS, - Some("note.txt"), - temp.path(), - ); - assert!( - outcome - .diagnostic() - .expect("diagnostic") - .message() - .contains("at least one edited hunk") - ); - assert_eq!( - read_text(&temp.path().join("note.txt")), - "alpha\nold value\nomega\n" - ); -} - -#[test] -fn apply_patch_executor_rejects_duplicate_begin_marker_without_mutation() { - let temp = TempWorkspace::new("patch-duplicate-begin"); - temp.write_text("note.txt", "alpha\nold value\nomega\n"); - let tools = tools_for(temp.path()); - let patch = "*** Begin Workspace Patch -*** Begin Patch -*** Update File: note.txt --old value -+new value -*** End Workspace Patch"; - - let outcome = patch_text_outcome(&tools, patch); - - assert_failed_json_for_tool( - &outcome, - APPLY_PATCH_TOOL, - ERROR_INVALID_ARGUMENTS, - None, - temp.path(), - ); - assert!( - outcome - .diagnostic() - .expect("diagnostic") - .message() - .contains("duplicate begin marker") - ); - assert_eq!( - read_text(&temp.path().join("note.txt")), - "alpha\nold value\nomega\n" - ); -} - -#[test] -fn apply_patch_executor_reports_old_and_new_line_numbers_after_prior_hunk_delta() { - let temp = TempWorkspace::new("patch-line-number-delta"); - temp.write_text( - "src/lib.rs", - "one\ninsert anchor\nmiddle\nremove anchor\nlast\n", - ); - let tools = tools_for(temp.path()); - let patch = "\ -+intro - insert anchor -@@ --remove anchor -+changed anchor"; - let patch = format!( - "*** Begin Workspace Patch -*** Update File: src/lib.rs -{patch} -*** End Workspace Patch" - ); - - let outcome = patch_text_outcome(&tools, &patch); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - assert_eq!( - read_text(&temp.path().join("src/lib.rs")), - "one\nintro\ninsert anchor\nmiddle\nchanged anchor\nlast\n" - ); - let payload = json_content(&outcome); - assert_eq!( - payload["changes"][0]["lines"], - json!([ - { "kind": "add", "new_line": 2, "text": "intro" }, - { "kind": "context", "old_line": 2, "new_line": 3, "text": "insert anchor" }, - { "kind": "remove", "old_line": 4, "text": "remove anchor" }, - { "kind": "add", "new_line": 5, "text": "changed anchor" } - ]) - ); -} - -#[test] -fn apply_patch_executor_applies_multi_file_patch_and_records_each_change() { - let temp = TempWorkspace::new("patch-multi-file-success"); - temp.write_text("src/lib.rs", "alpha\nold lib\nomega\n"); - temp.write_text("tests/smoke.rs", "alpha\nold test\nomega\n"); - let tools = tools_for(temp.path()); - let patch = "\ -*** Begin Workspace Patch -*** Update File: src/lib.rs --old lib -+new lib -*** Update File: tests/smoke.rs --old test -+new test -*** End Workspace Patch"; - - let outcome = patch_text_outcome(&tools, patch); - - assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); - assert_eq!( - read_text(&temp.path().join("src/lib.rs")), - "alpha\nnew lib\nomega\n" - ); - assert_eq!( - read_text(&temp.path().join("tests/smoke.rs")), - "alpha\nnew test\nomega\n" - ); - let payload = json_content(&outcome); - assert_eq!(payload["tool"], APPLY_PATCH_TOOL); - assert_eq!( - payload["changes"] - .as_array() - .expect("changes should be an array") - .len(), - 2 - ); - let evidence = match outcome - .execution_evidence() - .expect("successful patch should include internal execution evidence") - { - ActionExecutionEvidence::WorkspacePatch(evidence) => evidence, - ActionExecutionEvidence::ProcessAction(_) => { - panic!("workspace patch execution must not produce process action evidence") - } - }; - assert_eq!(evidence.changes().len(), 2); - assert_eq!(evidence.changes()[0].relative_path(), "src/lib.rs"); - assert_eq!(evidence.changes()[1].relative_path(), "tests/smoke.rs"); -} diff --git a/crates/merry-tools/src/tests/patch/add.rs b/crates/merry-tools/src/tests/patch/add.rs new file mode 100644 index 00000000..08294c06 --- /dev/null +++ b/crates/merry-tools/src/tests/patch/add.rs @@ -0,0 +1,197 @@ +//! Coverage for `*** Add File:` sections: creation, parent directories, and the limits that stop a bad add before it creates anything. + +use super::*; + +#[test] +fn apply_patch_executor_adds_new_utf8_file() { + let temp = TempWorkspace::new("patch-add-success"); + let tools = tools_for(temp.path()); + + let outcome = patch_text_outcome(&tools, &add_patch("dir/nested/new.txt", &["alpha", "beta"])); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + read_text(&temp.path().join("dir/nested/new.txt")), + "alpha\nbeta\n" + ); + assert_eq!( + json_content(&outcome)["changes"][0], + json!({ + "path": "dir/nested/new.txt", + "op": "add", + "hunks": 1, + "lines_before": 0, + "lines_after": 2, + "bytes_before": 0, + "bytes_after": "alpha\nbeta\n".len(), + "lines": [ + { "kind": "add", "new_line": 1, "text": "alpha" }, + { "kind": "add", "new_line": 2, "text": "beta" } + ] + }) + ); + + let evidence = match outcome + .execution_evidence() + .expect("successful add should include execution evidence") + { + ActionExecutionEvidence::WorkspacePatch(evidence) => evidence, + ActionExecutionEvidence::ProcessAction(_) => { + panic!("workspace patch execution must not produce process action evidence") + } + }; + assert_eq!(evidence.preimage_bytes(), 0); + assert_eq!(evidence.replacement_bytes(), "alpha\nbeta\n".len()); + assert_eq!(evidence.file_bytes_before(), 0); + assert_eq!(evidence.file_bytes_after(), "alpha\nbeta\n".len()); + assert_eq!( + evidence.file_fingerprint_before(), + &stable_content_fingerprint(b"") + ); + assert_eq!( + evidence.file_fingerprint_after(), + &stable_content_fingerprint(b"alpha\nbeta\n") + ); +} + +#[test] +fn apply_patch_add_file_proposal_and_execution_match() { + let temp = TempWorkspace::new("patch-add-proposal"); + fs::create_dir_all(temp.path().join("dir")).expect("parent directory should be created"); + let tools = tools_for(temp.path()); + let patch = add_patch("dir/new.txt", &["alpha"]); + let proposal = match add_patch_preflight(&tools, "dir/new.txt", &["alpha"]) { + ToolActionPreflight::Proposal(proposal) => proposal, + ToolActionPreflight::NoProposal | ToolActionPreflight::Outcome(_) => { + panic!("new file patch should produce a proposal") + } + }; + let proposed_patch = match proposal.evidence() { + ActionProposalEvidence::WorkspacePatch(patch) => patch, + ActionProposalEvidence::ProcessAction(_) => { + panic!("workspace patch proposal must not produce process action evidence") + } + }; + assert_eq!(proposed_patch.preimage_bytes(), 0); + assert_eq!(proposed_patch.file_bytes_before(), 0); + assert_eq!(proposed_patch.file_bytes_after(), "alpha\n".len()); + + let outcome = apply_patch_blocking_checked( + &tools.state, + ApplyPatchInput { patch }, + Some(proposed_patch), + &|| false, + ) + .expect("uncancelled workspace patch should not return cancellation"); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&temp.path().join("dir/new.txt")), "alpha\n"); +} + +#[test] +fn apply_patch_add_file_does_not_overwrite_existing_file() { + let temp = TempWorkspace::new("patch-add-existing"); + temp.write_text("note.txt", "old\n"); + let tools = tools_for(temp.path()); + + let outcome = patch_text_outcome(&tools, &add_patch("note.txt", &["new"])); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_FILE_ALREADY_EXISTS, + Some("note.txt"), + temp.path(), + ); + assert_eq!(read_text(&temp.path().join("note.txt")), "old\n"); +} + +#[test] +fn apply_patch_add_file_requires_plus_lines() { + let temp = TempWorkspace::new("patch-add-invalid"); + let tools = tools_for(temp.path()); + let patch = + "*** Begin Workspace Patch\n*** Add File: new.txt\ncontent\n*** End Workspace Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PATCH_SYNTAX, + Some("new.txt"), + temp.path(), + ); + assert!(!temp.path().join("new.txt").exists()); +} + +#[test] +fn apply_patch_add_file_tolerates_structural_blank_lines() { + let temp = TempWorkspace::new("patch-add-blank-lines"); + let tools = tools_for(temp.path()); + let patch = + "*** Begin Workspace Patch\n*** Add File: new.txt\n\n+created\n\n*** End Workspace Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&temp.path().join("new.txt")), "created\n"); +} + +#[test] +fn apply_patch_add_file_rejects_non_directory_parent() { + let temp = TempWorkspace::new("patch-add-parent-file"); + temp.write_text("parent", "not a directory\n"); + let tools = tools_for(temp.path()); + + let outcome = patch_text_outcome(&tools, &add_patch("parent/new.txt", &["new"])); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_NOT_DIRECTORY, + Some("parent/new.txt"), + temp.path(), + ); + assert_eq!(read_text(&temp.path().join("parent")), "not a directory\n"); +} + +#[test] +fn apply_patch_add_file_rejects_existing_directory() { + let temp = TempWorkspace::new("patch-add-directory"); + fs::create_dir(temp.path().join("dir")).expect("directory should be created"); + let tools = tools_for(temp.path()); + + let outcome = patch_text_outcome(&tools, &add_patch("dir", &["new"])); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_FILE_ALREADY_EXISTS, + Some("dir"), + temp.path(), + ); +} + +#[test] +fn apply_patch_add_file_rejects_write_limit_without_creating_file() { + let temp = TempWorkspace::new("patch-add-write-limit"); + let tools = WorkspaceTools::new( + WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(WorkspaceToolLimits { + max_write_bytes: 4, + ..WorkspaceToolLimits::default() + }), + ) + .expect("workspace tools should construct"); + + let outcome = patch_text_outcome(&tools, &add_patch("new.txt", &["too large"])); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_FILE_TOO_LARGE, + Some("new.txt"), + temp.path(), + ); + assert!(!temp.path().join("new.txt").exists()); +} diff --git a/crates/merry-tools/src/tests/patch/delete.rs b/crates/merry-tools/src/tests/patch/delete.rs new file mode 100644 index 00000000..0c4625ec --- /dev/null +++ b/crates/merry-tools/src/tests/patch/delete.rs @@ -0,0 +1,133 @@ +//! Coverage for `*** Delete File:` sections: removal, proposal/execution agreement, and the content lines a delete must reject. + +use super::*; + +#[test] +fn apply_patch_executor_deletes_existing_file() { + let temp = TempWorkspace::new("patch-delete-success"); + temp.write_text("dir/gone.txt", "alpha\nbeta\n"); + let tools = tools_for(temp.path()); + + let outcome = patch_text_outcome(&tools, &delete_patch("dir/gone.txt")); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert!(!temp.path().join("dir/gone.txt").exists()); + assert!( + temp.path().join("dir").is_dir(), + "deleting a file must leave its parent directory in place" + ); + assert_eq!( + json_content(&outcome), + json!({ + "ok": true, + "tool": APPLY_PATCH_TOOL, + "changes": [{ + "path": "dir/gone.txt", + "op": "delete", + "hunks": 0, + "lines_before": 2, + "lines_after": 0, + "bytes_before": "alpha\nbeta\n".len(), + "bytes_after": 0, + "lines": [] + }] + }) + ); + let evidence = match outcome + .execution_evidence() + .expect("successful delete should include execution evidence") + { + ActionExecutionEvidence::WorkspacePatch(evidence) => evidence, + ActionExecutionEvidence::ProcessAction(_) => { + panic!("workspace patch execution must not produce process action evidence") + } + }; + assert_eq!(evidence.preimage_bytes(), "alpha\nbeta\n".len()); + assert_eq!(evidence.replacement_bytes(), 0); + assert_eq!(evidence.file_bytes_before(), "alpha\nbeta\n".len()); + assert_eq!(evidence.file_bytes_after(), 0); + assert_eq!( + evidence.file_fingerprint_after(), + &stable_content_fingerprint(b"") + ); +} + +#[test] +fn apply_patch_delete_proposal_and_execution_match() { + let temp = TempWorkspace::new("patch-delete-proposal"); + temp.write_text("note.txt", "alpha\n"); + let tools = tools_for(temp.path()); + let patch = delete_patch("note.txt"); + let proposal = match delete_preflight(&tools, "note.txt") { + ToolActionPreflight::Proposal(proposal) => proposal, + ToolActionPreflight::NoProposal | ToolActionPreflight::Outcome(_) => { + panic!("delete patch should produce a proposal") + } + }; + let proposed = match proposal.evidence() { + ActionProposalEvidence::WorkspacePatch(patch) => patch, + ActionProposalEvidence::ProcessAction(_) => { + panic!("workspace patch proposal must not produce process action evidence") + } + }; + assert_eq!(proposed.preimage_bytes(), "alpha\n".len()); + assert_eq!(proposed.replacement_bytes(), 0); + assert_eq!(proposed.file_bytes_before(), "alpha\n".len()); + assert_eq!(proposed.file_bytes_after(), 0); + + let outcome = apply_patch_blocking_checked( + &tools.state, + ApplyPatchInput { patch }, + Some(proposed), + &|| false, + ) + .expect("uncancelled workspace patch should not return cancellation"); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert!(!temp.path().join("note.txt").exists()); +} + +#[test] +fn apply_patch_delete_requires_an_existing_file() { + let temp = TempWorkspace::new("patch-delete-missing"); + let tools = tools_for(temp.path()); + + let outcome = patch_text_outcome(&tools, &delete_patch("note.txt")); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_FILE_NOT_FOUND, + Some("note.txt"), + temp.path(), + ); +} + +#[test] +fn apply_patch_delete_rejects_content_lines() { + let temp = TempWorkspace::new("patch-delete-content"); + temp.write_text("note.txt", "alpha\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Delete File: note.txt ++unexpected +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PATCH_SYNTAX, + Some("note.txt"), + temp.path(), + ); + assert!( + outcome + .diagnostic() + .expect("diagnostic") + .message() + .contains("must not contain content lines") + ); + assert_eq!(read_text(&temp.path().join("note.txt")), "alpha\n"); +} diff --git a/crates/merry-tools/src/tests/patch/diagnostics.rs b/crates/merry-tools/src/tests/patch/diagnostics.rs new file mode 100644 index 00000000..48dbb026 --- /dev/null +++ b/crates/merry-tools/src/tests/patch/diagnostics.rs @@ -0,0 +1,142 @@ +//! Coverage for the failure text a caller recovers with: which line diverged, which lines matched twice, and CRLF as a match cause. + +use super::*; + +#[test] +fn apply_patch_preimage_miss_reports_the_first_differing_line() { + let temp = TempWorkspace::new("patch-preimage-divergence"); + temp.write_text("note.txt", "alpha\nold value\nomega\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: note.txt +@@ + alpha +-stale value ++new value +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PREIMAGE_ABSENT, + Some("note.txt"), + temp.path(), + ); + let message = outcome + .diagnostic() + .expect("diagnostic") + .message() + .to_owned(); + assert_eq!( + message, + "workspace patch preimage was not found; the hunk's first line matches at line 1, but line 2 differs: the patch has \"stale value\" but the file has \"old value\"" + ); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "alpha\nold value\nomega\n" + ); +} + +#[test] +fn apply_patch_preimage_miss_reports_an_unfindable_hunk_line() { + let temp = TempWorkspace::new("patch-preimage-unfindable"); + temp.write_text("note.txt", "alpha\nbeta\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: note.txt +@@ +-missing anchor line ++replacement +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PREIMAGE_ABSENT, + Some("note.txt"), + temp.path(), + ); + let message = outcome + .diagnostic() + .expect("diagnostic") + .message() + .to_owned(); + assert!( + message.contains("missing anchor line") && message.contains("not found in the file"), + "diagnostic should quote the missing hunk line: {message}" + ); +} + +#[test] +fn apply_patch_preimage_miss_reports_crlf_as_the_match_cause() { + let temp = TempWorkspace::new("patch-preimage-crlf"); + temp.write_text("note.txt", "alpha\r\nold\r\nomega\r\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: note.txt +@@ +-old ++new +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PREIMAGE_ABSENT, + Some("note.txt"), + temp.path(), + ); + let message = outcome + .diagnostic() + .expect("diagnostic") + .message() + .to_owned(); + assert_eq!( + message, + "workspace patch preimage was not found; all 1 hunk line(s) match at line 2, but the file uses CRLF line endings and this tool matches bytes exactly; convert the file to LF first (for example with a process command) or edit it without apply_patch" + ); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "alpha\r\nold\r\nomega\r\n" + ); +} + +#[test] +fn apply_patch_ambiguous_preimage_reports_every_match_line() { + let temp = TempWorkspace::new("patch-preimage-ambiguity-lines"); + temp.write_text("note.txt", "dup\nmid\ndup\nmid\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: note.txt +@@ +-dup +-mid ++dup ++MID +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PREIMAGE_AMBIGUOUS, + Some("note.txt"), + temp.path(), + ); + let message = outcome + .diagnostic() + .expect("diagnostic") + .message() + .to_owned(); + assert!( + message.contains("lines 1, 3"), + "diagnostic should list candidate lines: {message}" + ); +} diff --git a/crates/merry-tools/src/tests/patch/grammar.rs b/crates/merry-tools/src/tests/patch/grammar.rs new file mode 100644 index 00000000..b9d891e7 --- /dev/null +++ b/crates/merry-tools/src/tests/patch/grammar.rs @@ -0,0 +1,162 @@ +//! Coverage for envelope-level grammar: begin and end markers, the standard alias, the anchor a hunk needs, and a patch with no edit. + +use super::*; + +#[test] +fn apply_patch_executor_accepts_standard_patch_envelope_alias() { + let temp = TempWorkspace::new("patch-standard-envelope-alias"); + temp.write_text("src/lib.rs", "alpha\nold value\nomega\n"); + let tools = tools_for(temp.path()); + let patch = "\ +*** Begin Patch +*** Update File: src/lib.rs +-old value ++new value +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + read_text(&temp.path().join("src/lib.rs")), + "alpha\nnew value\nomega\n" + ); +} + +#[test] +fn apply_patch_executor_rejects_a_context_only_envelope_with_recovery_hint() { + let temp = TempWorkspace::new("patch-only-context-hunk"); + temp.write_text("note.txt", "alpha\nold value\nomega\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Workspace Patch +*** Update File: note.txt +@@ + old value +*** End Workspace Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PATCH_NOOP, + Some("note.txt"), + temp.path(), + ); + assert_eq!( + outcome.diagnostic().expect("diagnostic").message(), + "workspace patch contains no `+` or `-` lines, so nothing would change. Send the added or removed lines as `+`/`-` hunk lines to edit the file, or use `read_text` when you only need to inspect the current content" + ); + assert!( + json_content(&outcome)["guidance"]["message"] + .as_str() + .expect("guidance text") + .contains("nothing was written") + ); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "alpha\nold value\nomega\n" + ); +} + +#[test] +fn apply_patch_executor_rejects_duplicate_begin_marker_without_mutation() { + let temp = TempWorkspace::new("patch-duplicate-begin"); + temp.write_text("note.txt", "alpha\nold value\nomega\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Workspace Patch +*** Begin Patch +*** Update File: note.txt +-old value ++new value +*** End Workspace Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PATCH_SYNTAX, + None, + temp.path(), + ); + assert!( + outcome + .diagnostic() + .expect("diagnostic") + .message() + .contains("duplicate begin marker") + ); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "alpha\nold value\nomega\n" + ); +} + +#[test] +fn apply_patch_rejects_a_missing_begin_marker_with_the_offending_line() { + let temp = TempWorkspace::new("patch-missing-begin-marker"); + temp.write_text("note.txt", "alpha\nold\nomega\n"); + let tools = tools_for(temp.path()); + let patch = "*** Update File: note.txt +-old ++new +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PATCH_SYNTAX, + None, + temp.path(), + ); + let message = outcome + .diagnostic() + .expect("diagnostic") + .message() + .to_owned(); + assert!( + message.contains("*** Update File: note.txt"), + "diagnostic should quote the first non-blank line: {message}" + ); + assert_eq!( + json_content(&outcome)["guidance"]["kind"], + "apply_patch_syntax" + ); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "alpha\nold\nomega\n" + ); +} + +#[test] +fn apply_patch_rejects_a_hunk_without_an_anchor_line() { + let temp = TempWorkspace::new("patch-no-anchor"); + temp.write_text("note.txt", "alpha\nbeta\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: note.txt +@@ ++inserted +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PATCH_SYNTAX, + Some("note.txt"), + temp.path(), + ); + assert!( + outcome + .diagnostic() + .expect("diagnostic") + .message() + .contains("only + lines and no anchor") + ); + assert_eq!(read_text(&temp.path().join("note.txt")), "alpha\nbeta\n"); +} diff --git a/crates/merry-tools/src/tests/patch/mod.rs b/crates/merry-tools/src/tests/patch/mod.rs new file mode 100644 index 00000000..c0050426 --- /dev/null +++ b/crates/merry-tools/src/tests/patch/mod.rs @@ -0,0 +1,14 @@ +//! Behavior tests for the `apply_patch` tool. +//! +//! The suite is split by responsibility so a failure points at one area: +//! file operations, envelope grammar, diagnostics, and workspace scope. + +use super::*; + +mod add; +mod delete; +mod diagnostics; +mod grammar; +mod scope; +mod sections; +mod update; diff --git a/crates/merry-tools/src/tests/patch/scope.rs b/crates/merry-tools/src/tests/patch/scope.rs new file mode 100644 index 00000000..709dd777 --- /dev/null +++ b/crates/merry-tools/src/tests/patch/scope.rs @@ -0,0 +1,60 @@ +//! Coverage for the workspace write scope and forbidden paths a patch must respect. + +use super::*; + +#[test] +fn apply_patch_respects_configured_write_scope() { + let temp = TempWorkspace::new("patch-write-scope"); + temp.write_text("allowed/note.txt", "alpha\nold\nomega\n"); + temp.write_text("denied/note.txt", "alpha\nold\nomega\n"); + let tools = WorkspaceTools::new( + WorkspaceToolsConfig::new(temp.path().to_path_buf()) + .with_patch_write_scope(Some(vec![PathBuf::from("allowed")])), + ) + .expect("workspace tools should construct"); + + let allowed = patch_outcome(&tools, "allowed/note.txt", "old", "new"); + assert_eq!(allowed.status(), ToolCallResultStatus::Succeeded); + + let denied = patch_outcome(&tools, "denied/note.txt", "old", "new"); + assert_failed_json_for_tool( + &denied, + APPLY_PATCH_TOOL, + ERROR_PATH_DENIED, + Some("denied/note.txt"), + temp.path(), + ); + assert_eq!( + read_text(&temp.path().join("denied/note.txt")), + "alpha\nold\nomega\n" + ); +} + +#[test] +fn apply_patch_forbidden_paths_override_write_scope() { + let temp = TempWorkspace::new("patch-forbidden-scope"); + temp.write_text("allowed/public.txt", "alpha\nold\nomega\n"); + temp.write_text("allowed/secret.txt", "alpha\nold\nomega\n"); + let tools = WorkspaceTools::new( + WorkspaceToolsConfig::new(temp.path().to_path_buf()) + .with_patch_write_scope(Some(vec![PathBuf::from("allowed")])) + .with_forbidden_paths(vec![PathBuf::from("allowed/secret.txt")]), + ) + .expect("workspace tools should construct"); + + let public = patch_outcome(&tools, "allowed/public.txt", "old", "new"); + assert_eq!(public.status(), ToolCallResultStatus::Succeeded); + + let forbidden = patch_outcome(&tools, "allowed/secret.txt", "old", "new"); + assert_failed_json_for_tool( + &forbidden, + APPLY_PATCH_TOOL, + ERROR_PATH_DENIED, + Some("allowed/secret.txt"), + temp.path(), + ); + assert_eq!( + read_text(&temp.path().join("allowed/secret.txt")), + "alpha\nold\nomega\n" + ); +} diff --git a/crates/merry-tools/src/tests/patch/sections.rs b/crates/merry-tools/src/tests/patch/sections.rs new file mode 100644 index 00000000..a7f771a9 --- /dev/null +++ b/crates/merry-tools/src/tests/patch/sections.rs @@ -0,0 +1,229 @@ +//! Coverage for a file named by several sections: merged updates, rejected repeats, multi-file envelopes, atomic failure, and dropped context-only files. + +use super::*; + +#[test] +fn apply_patch_executor_combines_add_and_update_operations() { + let temp = TempWorkspace::new("patch-add-update"); + temp.write_text("existing.txt", "old\n"); + let tools = tools_for(temp.path()); + let patch = r#"*** Begin Workspace Patch +*** Add File: new.txt ++created +*** Update File: existing.txt +-old ++updated +*** End Workspace Patch"#; + + let outcome = patch_text_outcome(&tools, patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&temp.path().join("new.txt")), "created\n"); + assert_eq!(read_text(&temp.path().join("existing.txt")), "updated\n"); + assert_eq!( + json_content(&outcome)["changes"].as_array().map(Vec::len), + Some(2) + ); +} + +#[test] +fn apply_patch_executor_applies_multi_file_patch_and_records_each_change() { + let temp = TempWorkspace::new("patch-multi-file-success"); + temp.write_text("src/lib.rs", "alpha\nold lib\nomega\n"); + temp.write_text("tests/smoke.rs", "alpha\nold test\nomega\n"); + let tools = tools_for(temp.path()); + let patch = "\ +*** Begin Workspace Patch +*** Update File: src/lib.rs +-old lib ++new lib +*** Update File: tests/smoke.rs +-old test ++new test +*** End Workspace Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + read_text(&temp.path().join("src/lib.rs")), + "alpha\nnew lib\nomega\n" + ); + assert_eq!( + read_text(&temp.path().join("tests/smoke.rs")), + "alpha\nnew test\nomega\n" + ); + let payload = json_content(&outcome); + assert_eq!(payload["tool"], APPLY_PATCH_TOOL); + assert_eq!( + payload["changes"] + .as_array() + .expect("changes should be an array") + .len(), + 2 + ); + let evidence = match outcome + .execution_evidence() + .expect("successful patch should include internal execution evidence") + { + ActionExecutionEvidence::WorkspacePatch(evidence) => evidence, + ActionExecutionEvidence::ProcessAction(_) => { + panic!("workspace patch execution must not produce process action evidence") + } + }; + assert_eq!(evidence.changes().len(), 2); + assert_eq!(evidence.changes()[0].relative_path(), "src/lib.rs"); + assert_eq!(evidence.changes()[1].relative_path(), "tests/smoke.rs"); +} + +#[test] +fn apply_patch_merges_repeated_update_sections_for_one_file() { + let temp = TempWorkspace::new("patch-merged-sections"); + temp.write_text("note.txt", "one\ntwo\nthree\nfour\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: note.txt +@@ +-one ++ONE +*** Update File: note.txt +@@ +-four ++FOUR +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "ONE\ntwo\nthree\nFOUR\n" + ); + let payload = json_content(&outcome); + assert_eq!(payload["changes"].as_array().map(Vec::len), Some(1)); + assert_eq!(payload["changes"][0]["op"], "update"); + assert_eq!(payload["changes"][0]["hunks"], 2); +} + +#[test] +fn apply_patch_rejects_repeated_add_sections_for_one_file() { + let temp = TempWorkspace::new("patch-repeated-add"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Add File: new.txt ++first +*** Add File: new.txt ++second +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PATCH_SYNTAX, + Some("new.txt"), + temp.path(), + ); + assert!( + outcome + .diagnostic() + .expect("diagnostic") + .message() + .contains("more than once") + ); + assert!(!temp.path().join("new.txt").exists()); +} + +#[test] +fn apply_patch_rejects_mixed_sections_for_one_file() { + let temp = TempWorkspace::new("patch-mixed-sections"); + temp.write_text("note.txt", "alpha\nold\nomega\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: note.txt +@@ +-old ++new +*** Delete File: note.txt +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PATCH_SYNTAX, + Some("note.txt"), + temp.path(), + ); + assert!( + outcome + .diagnostic() + .expect("diagnostic") + .message() + .contains("mixes") + ); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "alpha\nold\nomega\n" + ); +} + +#[test] +fn apply_patch_drops_a_context_only_file_next_to_edits() { + let temp = TempWorkspace::new("patch-context-only-file"); + temp.write_text("edited.txt", "alpha\nold\n"); + temp.write_text("anchors.txt", "alpha\nold\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: edited.txt +@@ +-old ++new +*** Update File: anchors.txt +@@ + alpha +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&temp.path().join("edited.txt")), "alpha\nnew\n"); + assert_eq!(read_text(&temp.path().join("anchors.txt")), "alpha\nold\n"); + let payload = json_content(&outcome); + assert_eq!(payload["changes"].as_array().map(Vec::len), Some(1)); + assert_eq!(payload["changes"][0]["path"], "edited.txt"); +} + +#[test] +fn apply_patch_merged_sections_fail_atomically_when_one_hunk_misses() { + let temp = TempWorkspace::new("patch-merged-atomic"); + temp.write_text("note.txt", "one\ntwo\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Patch +*** Update File: note.txt +@@ +-one ++ONE +*** Update File: note.txt +@@ +-missing ++MISSING +*** End Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_PREIMAGE_ABSENT, + Some("note.txt"), + temp.path(), + ); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "one\ntwo\n", + "a merged section must not write when any hunk misses" + ); +} diff --git a/crates/merry-tools/src/tests/patch/update.rs b/crates/merry-tools/src/tests/patch/update.rs new file mode 100644 index 00000000..1c523af8 --- /dev/null +++ b/crates/merry-tools/src/tests/patch/update.rs @@ -0,0 +1,148 @@ +//! Coverage for `*** Update File:` hunks applied to one existing file. + +use super::*; + +#[test] +fn apply_patch_executor_replaces_one_hunk_in_existing_utf8_file() { + let temp = TempWorkspace::new("patch-success"); + temp.write_text("dir/note.txt", "alpha\nold value\nomega\n"); + let tools = tools_for(temp.path()); + let executor = ApplyPatchExecutor { + state: Arc::clone(&tools.state), + }; + let patch = update_patch("dir/note.txt", "old value", "new value"); + let call = pending_call_for(APPLY_PATCH_TOOL, json!({ "patch": patch })); + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("tokio runtime should build"); + + let outcome = runtime + .block_on(executor.execute(call, ToolExecutionContext::default())) + .expect("patch executor should succeed"); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + let payload = json_content(&outcome); + assert_eq!( + payload, + json!({ + "ok": true, + "tool": APPLY_PATCH_TOOL, + "changes": [{ + "path": "dir/note.txt", + "op": "update", + "hunks": 1, + "lines_before": 3, + "lines_after": 3, + "bytes_before": 22, + "bytes_after": 22, + "lines": [ + { "kind": "remove", "old_line": 2, "text": "old value" }, + { "kind": "add", "new_line": 2, "text": "new value" } + ] + }] + }) + ); + assert_eq!( + read_text(&temp.path().join("dir/note.txt")), + "alpha\nnew value\nomega\n" + ); + let evidence = match outcome + .execution_evidence() + .expect("successful patch should include internal execution evidence") + { + ActionExecutionEvidence::WorkspacePatch(evidence) => evidence, + ActionExecutionEvidence::ProcessAction(_) => { + panic!("workspace patch execution must not produce process action evidence") + } + }; + assert_eq!(evidence.relative_path(), "dir/note.txt"); + assert_eq!(evidence.preimage_bytes(), "old value\n".len()); + assert_eq!(evidence.replacement_bytes(), "new value\n".len()); + assert_eq!(evidence.file_bytes_before(), 22); + assert_eq!(evidence.file_bytes_after(), 22); + assert_eq!( + evidence.file_fingerprint_before(), + &stable_content_fingerprint("alpha\nold value\nomega\n".as_bytes()) + ); + assert_eq!( + evidence.file_fingerprint_after(), + &stable_content_fingerprint("alpha\nnew value\nomega\n".as_bytes()) + ); + assert!( + !outcome + .content() + .as_text() + .expect("json content") + .contains(temp.path().to_str().expect("temp path utf8")), + "tool output must not include absolute host roots" + ); +} + +#[test] +fn apply_patch_executor_ignores_context_only_hunks_when_editing() { + let temp = TempWorkspace::new("patch-context-only-hunk"); + temp.write_text("note.txt", "alpha\nold value\nomega\n"); + let tools = tools_for(temp.path()); + let patch = "*** Begin Workspace Patch +*** Update File: note.txt +@@ +-old value ++new value +@@ + omega +*** End Workspace Patch"; + + let outcome = patch_text_outcome(&tools, patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "alpha\nnew value\nomega\n" + ); + assert_eq!(json_content(&outcome)["changes"][0]["hunks"], 1); + assert_eq!( + json_content(&outcome)["changes"][0]["ignored_context_hunks"], + 1, + "dropped context-only hunks should stay visible in the success envelope" + ); +} + +#[test] +fn apply_patch_executor_reports_old_and_new_line_numbers_after_prior_hunk_delta() { + let temp = TempWorkspace::new("patch-line-number-delta"); + temp.write_text( + "src/lib.rs", + "one\ninsert anchor\nmiddle\nremove anchor\nlast\n", + ); + let tools = tools_for(temp.path()); + let patch = "\ ++intro + insert anchor +@@ +-remove anchor ++changed anchor"; + let patch = format!( + "*** Begin Workspace Patch +*** Update File: src/lib.rs +{patch} +*** End Workspace Patch" + ); + + let outcome = patch_text_outcome(&tools, &patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + read_text(&temp.path().join("src/lib.rs")), + "one\nintro\ninsert anchor\nmiddle\nchanged anchor\nlast\n" + ); + let payload = json_content(&outcome); + assert_eq!( + payload["changes"][0]["lines"], + json!([ + { "kind": "add", "new_line": 2, "text": "intro" }, + { "kind": "context", "old_line": 2, "new_line": 3, "text": "insert anchor" }, + { "kind": "remove", "old_line": 4, "text": "remove anchor" }, + { "kind": "add", "new_line": 5, "text": "changed anchor" } + ]) + ); +} diff --git a/crates/merry-tools/src/tests/patch_policy.rs b/crates/merry-tools/src/tests/patch_policy.rs index 9e4ef981..4b968ba6 100644 --- a/crates/merry-tools/src/tests/patch_policy.rs +++ b/crates/merry-tools/src/tests/patch_policy.rs @@ -14,10 +14,10 @@ fn apply_patch_proposal_reads_preimage_metadata_without_mutation() { assert_eq!(proposal.action_kind(), ToolActionKind::WorkspaceWrite); assert_eq!(proposal.label(), "workspace patch"); assert_eq!(proposal.subject(), "dir/note.txt"); - assert!( - proposal - .summary() - .contains("Apply 1 hunk(s) in dir/note.txt") + assert_eq!( + proposal.summary(), + "Apply 1 hunk(s) in dir/note.txt (3 -> 3 lines, 22 -> 24 bytes).", + "reviewers see line counts first, then byte counts" ); let patch = match proposal.evidence() { ActionProposalEvidence::WorkspacePatch(patch) => patch, @@ -163,7 +163,7 @@ fn apply_patch_preflight_returns_failed_outcome_for_invalid_or_stale_patch_witho assert_failed_json_for_tool( &invalid, APPLY_PATCH_TOOL, - ERROR_PATH_DENIED, + ERROR_FILE_NOT_FOUND, Some("../note.txt"), temp.path(), ); @@ -278,34 +278,39 @@ fn apply_patch_missing_or_ambiguous_after_proposal_still_does_not_write() { } #[test] -fn apply_patch_rejects_bad_hidden_missing_and_directory_paths_without_mutation() { +fn apply_patch_reports_missing_and_directory_targets_without_mutation() { let temp = TempWorkspace::new("patch-path-denied"); temp.write_text("visible.txt", "old\n"); temp.write_text(".secret", "old\n"); fs::create_dir_all(temp.path().join("dir")).expect("directory should be created"); let tools = tools_for(temp.path()); - for denied in [ - "/etc/passwd".to_owned(), - "../outside.txt".to_owned(), - ".secret".to_owned(), - "dir/./file.txt".to_owned(), - ] { - let outcome = patch_outcome(&tools, &denied, "old", "new"); - let expected_path = if denied.starts_with('/') { - None - } else { - Some(denied.as_str()) - }; - assert_failed_json_for_tool( - &outcome, - APPLY_PATCH_TOOL, - ERROR_PATH_DENIED, - expected_path, - temp.path(), - ); - } - assert_eq!(read_text(&temp.path().join(".secret")), "old\n"); + // A dot-prefixed name is ordinary spelling, so a patch may edit it, and a + // path outside the workspace is resolved as the caller named it rather than + // being rejected by a second path policy inside the tool. + let hidden = patch_outcome(&tools, ".secret", "old", "new"); + assert_eq!(hidden.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&temp.path().join(".secret")), "new\n"); + + let outside = patch_outcome(&tools, "../outside.txt", "old", "new"); + assert_failed_json_for_tool( + &outside, + APPLY_PATCH_TOOL, + ERROR_FILE_NOT_FOUND, + Some("../outside.txt"), + temp.path(), + ); + + // A redundant `.` segment is spelling, not a path error: the section is + // normalized and the reported path is the workspace-relative form. + let normalized = patch_outcome(&tools, "dir/./file.txt", "old", "new"); + assert_failed_json_for_tool( + &normalized, + APPLY_PATCH_TOOL, + ERROR_FILE_NOT_FOUND, + Some("dir/file.txt"), + temp.path(), + ); let missing = patch_outcome(&tools, "missing.txt", "old", "new"); assert_failed_json_for_tool( @@ -413,12 +418,10 @@ fn apply_patch_rejects_binary_and_limit_failures_without_mutation() { ); let read_limited = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits( - WorkspaceToolLimits { - max_read_bytes: 3, - ..WorkspaceToolLimits::default() - }, - ), + WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(WorkspaceToolLimits { + max_read_bytes: 3, + ..WorkspaceToolLimits::default() + }), ) .expect("workspace tools should construct"); let too_large_read = patch_outcome(&read_limited, "large-read.txt", "abc", "x"); @@ -432,12 +435,10 @@ fn apply_patch_rejects_binary_and_limit_failures_without_mutation() { assert_eq!(read_text(&temp.path().join("large-read.txt")), "abcdef\n"); let payload_limited = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits( - WorkspaceToolLimits { - max_patch_bytes: 3, - ..WorkspaceToolLimits::default() - }, - ), + WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(WorkspaceToolLimits { + max_patch_bytes: 3, + ..WorkspaceToolLimits::default() + }), ) .expect("workspace tools should construct"); let too_large_payload = patch_outcome(&payload_limited, "large-payload.txt", "ab", "cd"); @@ -451,12 +452,10 @@ fn apply_patch_rejects_binary_and_limit_failures_without_mutation() { assert_eq!(read_text(&temp.path().join("large-payload.txt")), "abc\n"); let write_limited = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits( - WorkspaceToolLimits { - max_write_bytes: 4, - ..WorkspaceToolLimits::default() - }, - ), + WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(WorkspaceToolLimits { + max_write_bytes: 4, + ..WorkspaceToolLimits::default() + }), ) .expect("workspace tools should construct"); let too_large_write = patch_outcome(&write_limited, "large-write.txt", "b", "bcdef"); @@ -503,6 +502,100 @@ fn mark_patch_cancelled_after_write(path: &Path) { .expect("post-write cancellation marker should be written"); } +#[test] +fn apply_patch_delete_respects_write_scope_and_forbidden_paths() { + let temp = TempWorkspace::new("patch-delete-scope"); + temp.write_text("allowed/note.txt", "alpha\n"); + temp.write_text("denied/note.txt", "alpha\n"); + temp.write_text("allowed/secret.txt", "alpha\n"); + let tools = WorkspaceTools::new( + WorkspaceToolsConfig::new(temp.path().to_path_buf()) + .with_patch_write_scope(Some(vec![PathBuf::from("allowed")])) + .with_forbidden_paths(vec![PathBuf::from("allowed/secret.txt")]), + ) + .expect("workspace tools should construct"); + + let allowed = patch_text_outcome(&tools, &delete_patch("allowed/note.txt")); + assert_eq!(allowed.status(), ToolCallResultStatus::Succeeded); + assert!(!temp.path().join("allowed/note.txt").exists()); + + let outside_scope = patch_text_outcome(&tools, &delete_patch("denied/note.txt")); + assert_failed_json_for_tool( + &outside_scope, + APPLY_PATCH_TOOL, + ERROR_PATH_DENIED, + Some("denied/note.txt"), + temp.path(), + ); + assert!(temp.path().join("denied/note.txt").exists()); + + let forbidden = patch_text_outcome(&tools, &delete_patch("allowed/secret.txt")); + assert_failed_json_for_tool( + &forbidden, + APPLY_PATCH_TOOL, + ERROR_PATH_DENIED, + Some("allowed/secret.txt"), + temp.path(), + ); + assert!(temp.path().join("allowed/secret.txt").exists()); +} + +/// Rewrites a planned target between planning and mutation. +fn rewrite_file_before_mutation(path: &Path) { + fs::write(path, "changed after planning\n").expect("test hook should rewrite the target file"); +} + +#[test] +fn apply_patch_delete_refuses_file_changed_before_mutation() { + let temp = TempWorkspace::new("patch-delete-stale-mutation"); + temp.write_text("note.txt", "alpha\nbeta\n"); + let tools = tools_for(temp.path()); + let note_path = + fs::canonicalize(temp.path().join("note.txt")).expect("note path should canonicalize"); + install_patch_test_before_mutation_hook(note_path.clone(), rewrite_file_before_mutation); + + let outcome = patch_text_outcome(&tools, &delete_patch("note.txt")); + + assert_failed_json_for_tool( + &outcome, + APPLY_PATCH_TOOL, + ERROR_WRITE_FAILED, + Some("note.txt"), + temp.path(), + ); + assert_eq!( + read_text(¬e_path), + "changed after planning\n", + "a delete must not remove content that replaced the planned preimage" + ); + assert!( + outcome.execution_evidence().is_none(), + "a refused delete must not report execution evidence for content it did not remove" + ); +} + +#[cfg(unix)] +#[test] +fn apply_patch_delete_removes_write_protected_file() { + use std::os::unix::fs::PermissionsExt; + + let temp = TempWorkspace::new("patch-delete-write-protected"); + temp.write_text("note.txt", "alpha\n"); + let tools = tools_for(temp.path()); + let note_path = temp.path().join("note.txt"); + fs::set_permissions(¬e_path, fs::Permissions::from_mode(0o444)) + .expect("note file should be made write-protected"); + + let outcome = patch_text_outcome(&tools, &delete_patch("note.txt")); + + assert_eq!( + outcome.status(), + ToolCallResultStatus::Succeeded, + "removal needs write permission on the parent directory, not the file" + ); + assert!(!note_path.exists()); +} + #[test] fn apply_patch_cancellation_after_write_returns_durable_outcome() { let temp = TempWorkspace::new("patch-cancel-after-write"); diff --git a/crates/merry-tools/src/tests/read.rs b/crates/merry-tools/src/tests/read.rs index cbaefb30..633ce386 100644 --- a/crates/merry-tools/src/tests/read.rs +++ b/crates/merry-tools/src/tests/read.rs @@ -67,13 +67,11 @@ fn read_text_reads_a_range_from_a_file_larger_than_the_read_limit() { let temp = TempWorkspace::new("read-large-file-range"); temp.write_text("large.txt", &"0123456789\n".repeat(20)); let tools = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits( - WorkspaceToolLimits { - max_read_bytes: 32, - max_read_lines: 2, - ..WorkspaceToolLimits::default() - }, - ), + WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(WorkspaceToolLimits { + max_read_bytes: 32, + max_read_lines: 2, + ..WorkspaceToolLimits::default() + }), ) .expect("workspace tools should construct"); @@ -90,12 +88,10 @@ fn read_text_rejects_ranges_outside_configured_limits() { let temp = TempWorkspace::new("read-range-validation"); temp.write_text("note.txt", "one\ntwo\n"); let tools = WorkspaceTools::new( - WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()]).with_limits( - WorkspaceToolLimits { - max_read_lines: 2, - ..WorkspaceToolLimits::default() - }, - ), + WorkspaceToolsConfig::new(temp.path().to_path_buf()).with_limits(WorkspaceToolLimits { + max_read_lines: 2, + ..WorkspaceToolLimits::default() + }), ) .expect("workspace tools should construct"); let executor = ReadTextExecutor { @@ -122,7 +118,7 @@ fn read_text_rejects_ranges_outside_configured_limits() { } #[test] -fn read_text_reports_missing_non_utf8_and_hidden_path_failures() { +fn read_text_reports_missing_and_non_utf8_failures() { let temp = TempWorkspace::new("read-failures"); temp.write_bytes("binary.bin", &[0xff, 0xfe, 0xfd]); temp.write_text("visible.txt", "ok\n"); @@ -140,9 +136,11 @@ fn read_text_reports_missing_non_utf8_and_hidden_path_failures() { Some("binary.bin"), temp.path(), ); + // A dot-prefixed name is ordinary spelling: `.secret` is a file a caller may + // read, so its absence is the only failure here. assert_failed_json( &read_outcome(&tools, ".secret"), - ERROR_PATH_DENIED, + ERROR_FILE_NOT_FOUND, Some(".secret"), temp.path(), ); diff --git a/crates/merry-tools/src/tests/trace.rs b/crates/merry-tools/src/tests/trace.rs index b6c2ac1e..41162973 100644 --- a/crates/merry-tools/src/tests/trace.rs +++ b/crates/merry-tools/src/tests/trace.rs @@ -42,7 +42,7 @@ async fn read_text_failure_trace_includes_diagnostic_code() { let call = pending_call_with_id( READ_TEXT_TOOL, "call-trace-read-failure", - json!({ "path": "../secret.txt" }), + json!({ "path": "missing.txt" }), ); let (outcome, logs) = capture_traces_for( @@ -50,16 +50,16 @@ async fn read_text_failure_trace_includes_diagnostic_code() { executor.execute(call, ToolExecutionContext::default()), ) .await; - let outcome = outcome.expect("path denial should resolve as a domain result"); + let outcome = outcome.expect("read failure should resolve as a domain result"); assert_eq!(outcome.status(), ToolCallResultStatus::Failed); assert_eq!( outcome.diagnostic().expect("diagnostic").code(), - ERROR_PATH_DENIED + ERROR_FILE_NOT_FOUND ); assert!(logs.contains("\"event\":\"runtime.workspace_tool.finish\"")); assert!(logs.contains("\"status\":\"failed\"")); - assert!(logs.contains("\"diagnostic_code\":\"workspace_path_denied\"")); + assert!(logs.contains("\"diagnostic_code\":\"workspace_file_not_found\"")); } #[tokio::test(flavor = "current_thread")] diff --git a/crates/merry-tools/src/tests/workspace_path.rs b/crates/merry-tools/src/tests/workspace_path.rs new file mode 100644 index 00000000..7fe29093 --- /dev/null +++ b/crates/merry-tools/src/tests/workspace_path.rs @@ -0,0 +1,363 @@ +//! Tests for tool path form, normalization, and scope boundaries. +//! +//! A relative tool path is resolved under the workspace root first and then +//! under each read-only resource root, so `dir/note.txt` and the absolute path +//! to that file address the same target and report the same workspace-relative +//! path. An absolute path that is not below the workspace root is the caller's +//! own way to name a file, so it is used exactly as written instead of being +//! matched against another anchor, and it is never denied by a second path +//! policy inside the tool. What remains the tools' own business is that a +//! relative path resolves below the root it was joined against, that a symlink +//! component below the workspace root is denied, and that a child agent cannot +//! leave the scope its parent gave it. + +use super::*; + +/// Returns the canonical workspace root, which is what the tools resolve against. +fn canonical_root(temp: &TempWorkspace) -> PathBuf { + fs::canonicalize(temp.path()).expect("workspace root should canonicalize") +} + +/// Returns a sibling path outside the workspace root for this test. +/// +/// The name derives from the unique temp workspace name so parallel test runs +/// cannot collide, and the caller removes what it created. +fn sibling_of(root: &Path, suffix: &str) -> PathBuf { + let name = root + .file_name() + .expect("workspace root should have a file name") + .to_string_lossy(); + root.parent() + .expect("workspace root should have a parent") + .join(format!("{name}{suffix}")) +} + +#[test] +fn read_text_absolute_resource_path_is_not_shadowed_by_a_workspace_file() { + let temp = TempWorkspace::new("path-resource-anchor"); + let tools = tools_for(temp.path()); + let root = canonical_root(&temp); + let resource = sibling_of(&root, "-resource"); + temp.write_text("demo/SKILL.md", "workspace copy\n"); + fs::create_dir_all(resource.join("demo")).expect("resource directory should be creatable"); + fs::write(resource.join("demo/SKILL.md"), "resource copy\n") + .expect("resource file should be writable"); + let absolute = resource.join("demo/SKILL.md"); + let absolute_text = absolute.to_str().expect("resource path should be utf8"); + + // The resource root is its own directory tree, so it holds a file with the + // same relative path as the workspace copy. Naming the resource file must + // read that file: resolving the components below a different root would + // silently answer with the workspace file instead. + let outcome = read_outcome(&tools, absolute_text); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + json_content(&outcome)["content"], + "resource copy\n", + "an absolute path must name the file it was written for, not a same-named file below another root" + ); + assert_eq!( + json_content(&outcome)["path"], + absolute_text, + "a file outside the workspace root keeps its absolute spelling" + ); + fs::remove_dir_all(&resource).expect("resource tree should be removable"); +} + +#[test] +fn apply_patch_absolute_path_edits_outside_file_and_not_the_workspace_copy() { + let temp = TempWorkspace::new("path-absolute-anchor"); + let tools = tools_for(temp.path()); + let root = canonical_root(&temp); + let outside = sibling_of(&root, "-outside.txt"); + temp.write_text("note.txt", "alpha\nold\nomega\n"); + fs::write(&outside, "alpha\nold\nomega\n").expect("outside file should be writable"); + let outside_text = outside.to_str().expect("outside path should be utf8"); + + // Both files share a name, so an anchored patch must touch the sibling the + // caller named and leave the same-named workspace file alone. + let outcome = patch_text_outcome(&tools, &update_patch(outside_text, "old", "new")); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&outside), "alpha\nnew\nomega\n"); + assert_eq!( + read_text(&temp.path().join("note.txt")), + "alpha\nold\nomega\n", + "a same-named workspace file must not receive the outside write" + ); + fs::remove_file(&outside).expect("outside file should be removable"); +} + +#[test] +fn read_text_accepts_absolute_path_inside_the_workspace() { + let temp = TempWorkspace::new("path-absolute-read"); + temp.write_text("dir/note.txt", "one\ntwo\n"); + let tools = tools_for(temp.path()); + let absolute = canonical_root(&temp).join("dir/note.txt"); + + let outcome = read_outcome( + &tools, + absolute.to_str().expect("absolute path should be utf8"), + ); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + json_content(&outcome)["path"], + "dir/note.txt", + "an absolute argument must be reported as a workspace-relative path" + ); + assert!( + !outcome + .content() + .as_text() + .expect("json content") + .contains(canonical_root(&temp).to_str().expect("root utf8")), + "tool output must not include absolute host roots" + ); +} + +#[test] +fn apply_patch_accepts_absolute_path_inside_the_workspace() { + let temp = TempWorkspace::new("path-absolute-patch"); + temp.write_text("dir/note.txt", "alpha\nold\nomega\n"); + let tools = tools_for(temp.path()); + let absolute = canonical_root(&temp).join("dir/note.txt"); + let patch = update_patch( + absolute.to_str().expect("absolute path should be utf8"), + "old", + "new", + ); + + let outcome = patch_text_outcome(&tools, &patch); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!( + json_content(&outcome)["changes"][0]["path"], + "dir/note.txt", + "the change must be reported relative to the workspace root" + ); + assert_eq!( + read_text(&temp.path().join("dir/note.txt")), + "alpha\nnew\nomega\n" + ); +} + +#[test] +fn read_text_reads_absolute_path_outside_the_workspace() { + let temp = TempWorkspace::new("path-absolute-outside"); + let tools = tools_for(temp.path()); + let root = canonical_root(&temp); + let outside = sibling_of(&root, "-outside.txt"); + fs::write(&outside, "outside\n").expect("outside file should be writable"); + let outside_text = outside.to_str().expect("outside path should be utf8"); + + let outcome = read_outcome(&tools, outside_text); + + assert_eq!( + outcome.status(), + ToolCallResultStatus::Succeeded, + "an absolute path outside the workspace is the caller's own reference, not a tool denial" + ); + assert_eq!(json_content(&outcome)["content"], "outside\n"); + assert_eq!( + json_content(&outcome)["path"], + outside_text, + "a target outside the workspace root has no workspace-relative spelling to report" + ); + fs::remove_file(&outside).expect("outside file should be removable"); +} + +#[test] +fn apply_patch_edits_files_outside_the_workspace() { + let temp = TempWorkspace::new("path-absolute-patch-outside"); + let tools = tools_for(temp.path()); + let root = canonical_root(&temp); + let outside_dir = sibling_of(&root, "-outside"); + fs::create_dir_all(&outside_dir).expect("outside directory should be creatable"); + let existing = outside_dir.join("note.txt"); + fs::write(&existing, "alpha\nold\nomega\n").expect("outside file should be writable"); + let existing_text = existing.to_str().expect("outside path should be utf8"); + + let update = patch_text_outcome(&tools, &update_patch(existing_text, "old", "new")); + assert_eq!(update.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&existing), "alpha\nnew\nomega\n"); + + let created = outside_dir.join("dir/created.txt"); + let created_text = created.to_str().expect("outside path should be utf8"); + let add = patch_text_outcome(&tools, &add_patch(created_text, &["one", "two"])); + assert_eq!(add.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&created), "one\ntwo\n"); + + let delete = patch_text_outcome(&tools, &delete_patch(existing_text)); + assert_eq!(delete.status(), ToolCallResultStatus::Succeeded); + assert!( + !existing.exists(), + "a delete section must remove the file it names" + ); + fs::remove_dir_all(&outside_dir).expect("outside directory should be removable"); +} + +#[test] +fn child_write_scope_denies_absolute_paths_outside_the_workspace() { + let temp = TempWorkspace::new("path-child-write-scope"); + let scoped = WorkspaceTools::new( + WorkspaceToolsConfig::new(temp.path().to_path_buf()) + .with_patch_write_scope(Some(vec![PathBuf::from("allowed")])), + ) + .expect("workspace tools should construct"); + let unscoped = tools_for(temp.path()); + let root = canonical_root(&temp); + let outside = sibling_of(&root, "-outside.txt"); + fs::write(&outside, "alpha\nold\nomega\n").expect("outside file should be writable"); + let outside_text = outside.to_str().expect("outside path should be utf8"); + + // The target is reachable and editable here, so the denial below is about the + // child scope rather than about a path the tool refuses for its own reasons. + let allowed = patch_text_outcome(&unscoped, &update_patch(outside_text, "old", "new")); + assert_eq!(allowed.status(), ToolCallResultStatus::Succeeded); + assert_eq!(read_text(&outside), "alpha\nnew\nomega\n"); + fs::write(&outside, "alpha\nold\nomega\n").expect("outside file should be restored"); + + let denied = patch_text_outcome(&scoped, &update_patch(outside_text, "old", "new")); + + assert_eq!(denied.status(), ToolCallResultStatus::Failed); + assert_eq!( + denied.diagnostic().expect("diagnostic").code(), + ERROR_PATH_DENIED + ); + assert!( + json_content(&denied)["error"]["message"] + .as_str() + .expect("message should be text") + .contains("outside the child write scope"), + "a child agent must not leave its write scope by naming an absolute path" + ); + assert_eq!(read_text(&outside), "alpha\nold\nomega\n"); + fs::remove_file(&outside).expect("outside file should be removable"); +} + +#[cfg(unix)] +#[test] +fn read_text_follows_platform_symlinks_outside_the_workspace() { + let temp = TempWorkspace::new("path-outside-symlink"); + let tools = tools_for(temp.path()); + let root = canonical_root(&temp); + let target_dir = sibling_of(&root, "-linked"); + let link_dir = sibling_of(&root, "-link"); + fs::create_dir_all(&target_dir).expect("linked directory should be creatable"); + fs::write(target_dir.join("note.txt"), "linked\n").expect("linked file should be writable"); + symlink(&target_dir, &link_dir).expect("directory symlink should be created"); + + // Platform layout routinely exposes a real directory through a link, so a + // target outside the workspace is resolved as the caller named it instead of + // being denied for a component the sandbox already allows. + let linked = link_dir.join("note.txt"); + let outcome = read_outcome(&tools, linked.to_str().expect("linked path should be utf8")); + + assert_eq!(outcome.status(), ToolCallResultStatus::Succeeded); + assert_eq!(json_content(&outcome)["content"], "linked\n"); + fs::remove_file(link_dir.as_path()).expect("directory symlink should be removable"); + fs::remove_dir_all(&target_dir).expect("linked directory should be removable"); +} + +#[test] +fn workspace_path_normalizes_dot_segments_and_resolves_escapes() { + let temp = TempWorkspace::new("path-dot-segments"); + temp.write_text("dir/note.txt", "one\ntwo\n"); + let tools = tools_for(temp.path()); + let root = canonical_root(&temp); + + let inside = read_outcome(&tools, "./dir/../dir/note.txt"); + assert_eq!( + inside.status(), + ToolCallResultStatus::Succeeded, + "redundant dot segments name the same file and must not be rejected" + ); + assert_eq!(json_content(&inside)["path"], "dir/note.txt"); + + // A relative argument may climb above its root: the target is the caller's + // own reference, and the reported path keeps the `..` so the escape stays + // visible instead of being silently rewritten as an in-root path. + let sibling = sibling_of(&root, "-climbed.txt"); + fs::write(&sibling, "climbed\n").expect("sibling file should be writable"); + let named = sibling + .strip_prefix(root.parent().expect("root parent")) + .expect("sibling is below the shared parent"); + let climbed = read_outcome(&tools, &format!("../{}", named.display())); + assert_eq!( + climbed.status(), + ToolCallResultStatus::Succeeded, + "a relative path that climbs above the root names a real file" + ); + assert_eq!(json_content(&climbed)["content"], "climbed\n"); + assert_eq!( + json_content(&climbed)["path"], + format!("../{}", named.display()), + "the reported path keeps the escape visible" + ); + fs::remove_file(&sibling).expect("sibling file should be removable"); +} + +#[test] +fn workspace_path_denies_the_root_itself_and_reads_prefix_lookalikes() { + let temp = TempWorkspace::new("path-root-lookalike"); + temp.write_text("dir/note.txt", "one\ntwo\n"); + let tools = tools_for(temp.path()); + let root = canonical_root(&temp); + let root_text = root.to_str().expect("root path should be utf8"); + + let as_root = read_outcome(&tools, root_text); + assert_failed_json(&as_root, ERROR_PATH_DENIED, None, root.as_path()); + assert!( + json_content(&as_root)["error"]["message"] + .as_str() + .expect("message should be text") + .contains("not the root itself"), + "the root is a directory to browse, never a file to read" + ); + + // A sibling directory that merely shares the root's name prefix is not + // inside the workspace, so prefix matching must compare whole components + // rather than treat the sibling as part of the root. + let lookalike_dir = sibling_of(&root, "-lookalike"); + fs::create_dir_all(lookalike_dir.join("dir")).expect("lookalike tree should be creatable"); + fs::write(lookalike_dir.join("dir/note.txt"), "sibling\n") + .expect("lookalike file should be writable"); + let lookalike = lookalike_dir.join("dir/note.txt"); + let lookalike_text = lookalike.to_str().expect("lookalike path should be utf8"); + + let sibling = read_outcome(&tools, lookalike_text); + assert_eq!( + sibling.status(), + ToolCallResultStatus::Succeeded, + "a sibling that shares the root's name prefix is its own target" + ); + assert_eq!(json_content(&sibling)["content"], "sibling\n"); + assert_eq!( + json_content(&sibling)["path"], + lookalike_text, + "the sibling is not inside the workspace root, so it is reported absolute" + ); + fs::remove_dir_all(&lookalike_dir).expect("lookalike tree should be removable"); +} + +#[test] +fn workspace_path_reads_hidden_components_after_normalization() { + let temp = TempWorkspace::new("path-hidden-after-normalization"); + temp.write_text(".git/config", "[core]\n"); + let tools = tools_for(temp.path()); + + let outcome = read_outcome(&tools, "dir/../.git/config"); + assert_eq!( + outcome.status(), + ToolCallResultStatus::Succeeded, + "a leading dot is ordinary spelling and must survive normalization" + ); + assert_eq!( + json_content(&outcome)["path"], + ".git/config", + "the reported path is the normalized workspace-relative form" + ); + assert_eq!(json_content(&outcome)["content"], "[core]\n"); +} diff --git a/crates/merry-tools/src/trace.rs b/crates/merry-tools/src/trace.rs index 4039e9af..8614c6f1 100644 --- a/crates/merry-tools/src/trace.rs +++ b/crates/merry-tools/src/trace.rs @@ -14,14 +14,20 @@ use crate::errors::{ERROR_INVALID_ARGUMENTS, failed_outcome}; pub(crate) const TRACE_PATH_MAX_CHARS: usize = 96; #[cfg(test)] -static PATCH_TEST_AFTER_WRITE_HOOK: OnceLock>> = - OnceLock::new(); +static PATCH_TEST_BEFORE_MUTATION_HOOK: OnceLock>> = OnceLock::new(); +#[cfg(test)] +static PATCH_TEST_AFTER_WRITE_HOOK: OnceLock>> = OnceLock::new(); #[cfg(test)] static TRACE_START_TEST_HOOK: OnceLock>> = OnceLock::new(); +/// A one-shot patch-execution hook bound to one file path. +/// +/// The hook runs at most once, for the path it was installed with, so a test +/// can place a filesystem or cancellation change exactly inside patch execution +/// without affecting another file or a later patch in the same test. #[cfg(test)] #[derive(Debug)] -struct PatchTestAfterWriteHook { +struct PatchTestHook { root: PathBuf, hook: fn(&Path), consumed: AtomicBool, @@ -36,7 +42,7 @@ struct TraceStartTestHook { } #[cfg(test)] -impl PatchTestAfterWriteHook { +impl PatchTestHook { fn new(root: PathBuf, hook: fn(&Path)) -> Self { Self { root, @@ -44,6 +50,16 @@ impl PatchTestAfterWriteHook { consumed: AtomicBool::new(false), } } + + fn run_once(&self, path: &Path) { + if path != self.root { + return; + } + if self.consumed.swap(true, Ordering::SeqCst) { + return; + } + (self.hook)(&self.root); + } } #[cfg(test)] @@ -57,13 +73,28 @@ impl TraceStartTestHook { } } +/// Installs the hook that runs after a patch writes or removes a file. #[cfg(test)] pub(crate) fn install_patch_test_after_write_hook(root: PathBuf, hook: fn(&Path)) { - PATCH_TEST_AFTER_WRITE_HOOK - .get_or_init(|| Mutex::new(None)) + install_patch_test_hook(&PATCH_TEST_AFTER_WRITE_HOOK, root, hook); +} + +/// Installs the hook that runs immediately before a patch mutates a file. +#[cfg(test)] +pub(crate) fn install_patch_test_before_mutation_hook(root: PathBuf, hook: fn(&Path)) { + install_patch_test_hook(&PATCH_TEST_BEFORE_MUTATION_HOOK, root, hook); +} + +#[cfg(test)] +fn install_patch_test_hook( + slot: &'static OnceLock>>, + root: PathBuf, + hook: fn(&Path), +) { + slot.get_or_init(|| Mutex::new(None)) .lock() .expect("patch test hook mutex should not be poisoned") - .replace(PatchTestAfterWriteHook::new(root, hook)); + .replace(PatchTestHook::new(root, hook)); } #[cfg(test)] @@ -75,9 +106,21 @@ pub(crate) fn install_trace_start_test_hook(tool_call_id: &str, hook: fn()) { .replace(TraceStartTestHook::new(tool_call_id.to_owned(), hook)); } +/// Runs the installed before-mutation hook for a matching path. +#[cfg(test)] +pub(crate) fn maybe_run_patch_test_before_mutation_hook(root: &Path) { + maybe_run_patch_test_hook(&PATCH_TEST_BEFORE_MUTATION_HOOK, root); +} + +/// Runs the installed after-write hook for a matching path. #[cfg(test)] pub(crate) fn maybe_run_patch_test_after_write_hook(root: &Path) { - let Some(hook_slot) = PATCH_TEST_AFTER_WRITE_HOOK.get() else { + maybe_run_patch_test_hook(&PATCH_TEST_AFTER_WRITE_HOOK, root); +} + +#[cfg(test)] +fn maybe_run_patch_test_hook(slot: &'static OnceLock>>, root: &Path) { + let Some(hook_slot) = slot.get() else { return; }; let hook_guard = hook_slot @@ -86,13 +129,7 @@ pub(crate) fn maybe_run_patch_test_after_write_hook(root: &Path) { let Some(hook) = hook_guard.as_ref() else { return; }; - if root != hook.root { - return; - } - if hook.consumed.swap(true, Ordering::SeqCst) { - return; - } - (hook.hook)(&hook.root); + hook.run_once(root); } #[cfg(test)] diff --git a/crates/merry-tools/tests/runtime_integration/patch.rs b/crates/merry-tools/tests/runtime_integration/patch.rs index 1f4db51e..a55a0bcc 100644 --- a/crates/merry-tools/tests/runtime_integration/patch.rs +++ b/crates/merry-tools/tests/runtime_integration/patch.rs @@ -236,7 +236,7 @@ async fn opt_in_patch_success_continuation_does_not_leak_internal_evidence() { async fn patch_proposal_and_audit_do_not_leak_into_sanitized_result_or_continuation() { let temp = TempWorkspace::new("patch-policy-no-leak"); temp.write_text("note.txt", "alpha\nold\nomega\n"); - let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(vec![temp.path().to_path_buf()])) + let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(temp.path().to_path_buf())) .expect("workspace tools should construct"); let provider = ScriptedModelProvider::new(vec![ vec![Ok(pending_patch_call("note.txt", "old", "new"))], @@ -297,3 +297,139 @@ async fn patch_proposal_and_audit_do_not_leak_into_sanitized_result_or_continuat ); assert_patch_denial_json_sanitized(continuation_json, APPLY_PATCH_TOOL); } + +#[tokio::test(flavor = "current_thread")] +async fn opt_in_apply_patch_tool_deletes_file_and_reports_the_operation() { + let temp = TempWorkspace::new("patch-opt-in-delete-success"); + temp.write_text("dir/gone.txt", "alpha\nbeta\n"); + let provider = ScriptedModelProvider::new(vec![ + vec![Ok(pending_delete_patch_call("dir/gone.txt"))], + vec![Ok(ModelEvent::Completed { + response: ModelResponse::new( + vec![ModelOutput::text("continued after delete")], + FinishReason::Stop, + None, + ), + })], + ]); + let provider_handle = provider.clone(); + let runtime = runtime_with_opt_in_apply_patch_tools_and_provider(temp.path(), provider); + let _pending_events = collect_step(&runtime, "delete the old note").await; + let pending = runtime + .pending_tool_calls() + .await + .into_iter() + .next() + .expect("pending call should be stored"); + + let execution_events = runtime + .execute_tool_call(pending.id(), ToolExecutionContext::default()) + .await + .expect("opted-in delete should execute"); + + assert_succeeded_json_result(&execution_events); + assert!( + !temp.path().join("dir/gone.txt").exists(), + "an executed delete must remove the file" + ); + assert!( + temp.path().join("dir").is_dir(), + "deleting a file must leave its parent directory in place" + ); + let lifecycle = lifecycle_kinds(&runtime.ledger_projection().await); + let artifact_index = lifecycle + .iter() + .position(|kind| *kind == LedgerFactKind::ArtifactRecorded) + .expect("artifact lifecycle should exist"); + let resolved_index = lifecycle + .iter() + .position(|kind| *kind == LedgerFactKind::ToolCallResolved) + .expect("resolution lifecycle should exist"); + assert!(artifact_index < resolved_index); + + // The provider-visible result is what the model sees, so it must name the + // removal instead of leaving a delete and an emptied file indistinguishable. + let _continuation_events = collect_step(&runtime, "continue after delete").await; + let requests = provider_handle.recorded_requests(); + let continuation = requests[1] + .continuations() + .first() + .expect("successful tool result should be compiled as continuation"); + assert_eq!( + continuation.result().status(), + ToolCallResultStatus::Succeeded + ); + let ModelToolResultContent::Json(json) = continuation.result().content() else { + panic!("successful delete continuation should be JSON"); + }; + let envelope: Value = serde_json::from_str(json).expect("delete result should be JSON"); + assert_eq!(envelope["ok"], true); + assert_eq!(envelope["tool"], APPLY_PATCH_TOOL); + assert_eq!(envelope["changes"][0]["path"], "dir/gone.txt"); + assert_eq!(envelope["changes"][0]["op"], "delete"); + assert_eq!(envelope["changes"][0]["lines_before"], 2); + assert_eq!(envelope["changes"][0]["lines_after"], 0); + assert_eq!(envelope["changes"][0]["bytes_after"], 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn policy_denied_delete_resolves_without_removing_the_file() { + let temp = TempWorkspace::new("patch-delete-policy-denied"); + temp.write_text("note.txt", "alpha\n"); + let runtime = + runtime_with_apply_patch_tools(temp.path(), pending_delete_patch_call("note.txt")); + + let _pending_events = collect_step(&runtime, "delete the note").await; + let pending = runtime + .pending_tool_calls() + .await + .into_iter() + .next() + .expect("pending call should be stored"); + + let execution_events = runtime + .execute_tool_call(pending.id(), ToolExecutionContext::default()) + .await + .expect("runtime policy denial should resolve pending call"); + + assert_failed_json_result(&execution_events, "action_policy_denied"); + assert_eq!( + fs::read_to_string(temp.path().join("note.txt")).expect("workspace file should read"), + "alpha\n", + "a denied delete must leave the file unchanged" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn delete_outside_the_write_scope_is_denied_and_keeps_the_file() { + let temp = TempWorkspace::new("patch-delete-scope-denied"); + temp.write_text("allowed/note.txt", "alpha\n"); + temp.write_text("denied/note.txt", "alpha\n"); + let tools = WorkspaceTools::new( + WorkspaceToolsConfig::new(temp.path().to_path_buf()) + .with_patch_write_scope(Some(vec![PathBuf::from("allowed")])), + ) + .expect("workspace tools should construct"); + let provider = FakeModelProvider::new(vec![Ok(pending_delete_patch_call("denied/note.txt"))]); + let mut builder = Runtime::builder(session_id()) + .model_provider(Arc::new(provider), model_name()) + .allow_low_risk_apply_patches(); + for tool in tools.into_registered_tools_with_patch() { + builder = builder.register_tool(tool); + } + let runtime = builder.build().expect("runtime should build"); + + let execution_events = + execute_first_pending_call(&runtime, "delete a note outside scope").await; + + assert_failed_json_result(&execution_events, "workspace_path_denied"); + assert_eq!( + fs::read_to_string(temp.path().join("denied/note.txt")).expect("denied file should read"), + "alpha\n", + "a delete outside the write scope must leave the file unchanged" + ); + assert!( + temp.path().join("allowed/note.txt").exists(), + "a denied delete must not remove an unrelated file" + ); +} diff --git a/crates/merry-tools/tests/runtime_integration/support.rs b/crates/merry-tools/tests/runtime_integration/support.rs index c0038d36..4b976d46 100644 --- a/crates/merry-tools/tests/runtime_integration/support.rs +++ b/crates/merry-tools/tests/runtime_integration/support.rs @@ -109,6 +109,12 @@ pub(super) fn pending_add_patch_call(path: &str, lines: &[&str]) -> ModelEvent { pending_workspace_call("workspace-patch-call", APPLY_PATCH_TOOL, arguments) } +pub(super) fn pending_delete_patch_call(path: &str) -> ModelEvent { + let mut arguments = Map::new(); + arguments.insert("patch".to_owned(), Value::String(delete_patch(path))); + pending_workspace_call("workspace-patch-call", APPLY_PATCH_TOOL, arguments) +} + pub(super) fn update_patch(path: &str, old_text: &str, new_text: &str) -> String { format!( "*** Begin Workspace Patch\n*** Update File: {path}\n-{old_text}\n+{new_text}\n*** End Workspace Patch" @@ -124,6 +130,10 @@ pub(super) fn add_patch(path: &str, lines: &[&str]) -> String { format!("*** Begin Workspace Patch\n*** Add File: {path}\n{additions}\n*** End Workspace Patch") } +pub(super) fn delete_patch(path: &str) -> String { + format!("*** Begin Workspace Patch\n*** Delete File: {path}\n*** End Workspace Patch") +} + type ScriptedModelStep = Vec>; type ScriptedModelSteps = Vec; type RecordedModelRequests = Vec; @@ -211,7 +221,7 @@ pub(super) fn runtime_with_workspace_tools_and_provider( root: &Path, model_event: ModelEvent, ) -> (Runtime, FakeModelProvider) { - let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(vec![root.to_path_buf()])) + let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(root.to_path_buf())) .expect("workspace tools should construct"); let provider = FakeModelProvider::new(vec![Ok(model_event)]); let provider_handle = provider.clone(); @@ -227,7 +237,7 @@ pub(super) fn runtime_with_workspace_tools_and_provider( } pub(super) fn runtime_with_apply_patch_tools(root: &Path, model_event: ModelEvent) -> Runtime { - let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(vec![root.to_path_buf()])) + let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(root.to_path_buf())) .expect("workspace tools should construct"); let provider = FakeModelProvider::new(vec![Ok(model_event)]); let mut builder = @@ -242,7 +252,7 @@ pub(super) fn runtime_with_opt_in_apply_patch_tools( root: &Path, model_event: ModelEvent, ) -> Runtime { - let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(vec![root.to_path_buf()])) + let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(root.to_path_buf())) .expect("workspace tools should construct"); let provider = FakeModelProvider::new(vec![Ok(model_event)]); let mut builder = Runtime::builder(session_id()) @@ -258,7 +268,7 @@ pub(super) fn runtime_with_opt_in_apply_patch_tools_and_provider( root: &Path, provider: ScriptedModelProvider, ) -> Runtime { - let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(vec![root.to_path_buf()])) + let tools = WorkspaceTools::new(WorkspaceToolsConfig::new(root.to_path_buf())) .expect("workspace tools should construct"); let mut builder = Runtime::builder(session_id()) .model_provider(Arc::new(provider), model_name()) diff --git a/sdks/python/README.md b/sdks/python/README.md index 83727c9b..04e1dc17 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -68,9 +68,10 @@ agent = ( ) ``` -`WorkspaceConfig` maps to the Rust coding profile. Patch and forbidden paths -are workspace-relative normalized paths; workspace roots themselves may be -absolute. Every workspace limit is positive and is enforced again by Rust. +`WorkspaceConfig` maps to the Rust coding profile. A workspace has one root, +which may be absolute or relative; patch and forbidden paths are root-relative +normalized paths. Every workspace limit is positive and is enforced again by +Rust. Anthropic Messages uses the same builder: diff --git a/sdks/python/merry/_builder.py b/sdks/python/merry/_builder.py index 75ba614e..3ce0736e 100644 --- a/sdks/python/merry/_builder.py +++ b/sdks/python/merry/_builder.py @@ -83,9 +83,8 @@ def workspace(self, config: WorkspaceConfig) -> AgentBuilder: forbidden.extend(patch_forbidden) try: self._native.with_workspace( - [str(path) for path in config.roots], + str(config.root), [str(path) for path in config.readonly_resource_roots], - config.allow_hidden, config.patch is not None, patch_scope, forbidden, diff --git a/sdks/python/merry/_config.py b/sdks/python/merry/_config.py index 7f709322..ce616ef6 100644 --- a/sdks/python/merry/_config.py +++ b/sdks/python/merry/_config.py @@ -127,47 +127,34 @@ def __init__( class WorkspaceConfig: """Rust coding-profile workspace configuration.""" - roots: tuple[Path, ...] + root: Path readonly_resource_roots: tuple[Path, ...] - allow_hidden: bool patch: PatchConfig | None forbidden_paths: tuple[Path, ...] limits: WorkspaceLimits def __init__( self, - root: PathInput | None = None, + root: PathInput, *, - roots: Sequence[PathInput] | None = None, readonly_resource_roots: Sequence[PathInput] = (), - allow_hidden: bool = False, patch: PatchConfig | None = None, forbidden_paths: Sequence[PathInput] = (), limits: WorkspaceLimits | None = None, ) -> None: - if root is not None and roots is not None: - raise ValueError("WorkspaceConfig accepts root or roots, not both") - if roots is None: - if root is None: - raise ValueError("WorkspaceConfig requires root or roots") - normalized_roots = (Path(root),) - else: - normalized_roots = _paths(roots, "roots") - if not normalized_roots: - raise ValueError("WorkspaceConfig.roots must contain at least one path") - if type(allow_hidden) is not bool: - raise TypeError("allow_hidden must be a boolean") + if isinstance(root, str) and not root.strip(): + raise ValueError("WorkspaceConfig.root must not be blank") + normalized_root = Path(root) if patch is not None and not isinstance(patch, PatchConfig): raise TypeError("patch must be a PatchConfig or None") if limits is not None and not isinstance(limits, WorkspaceLimits): raise TypeError("limits must be a WorkspaceLimits or None") - object.__setattr__(self, "roots", normalized_roots) + object.__setattr__(self, "root", normalized_root) object.__setattr__( self, "readonly_resource_roots", _paths(readonly_resource_roots, "readonly_resource_roots"), ) - object.__setattr__(self, "allow_hidden", allow_hidden) object.__setattr__(self, "patch", patch) object.__setattr__( self, diff --git a/sdks/python/merry/_merry.pyi b/sdks/python/merry/_merry.pyi index 3eababd2..bdc17549 100644 --- a/sdks/python/merry/_merry.pyi +++ b/sdks/python/merry/_merry.pyi @@ -17,9 +17,8 @@ class AgentBuilder: ) -> None: ... def with_workspace( self, - roots: list[str], + root: str, readonly_resource_roots: list[str], - allow_hidden: bool, enable_patch: bool, patch_write_scope: list[str] | None, forbidden_paths: list[str], diff --git a/sdks/python/tests/test_builder.py b/sdks/python/tests/test_builder.py index 3d3c74bd..c631b66d 100644 --- a/sdks/python/tests/test_builder.py +++ b/sdks/python/tests/test_builder.py @@ -104,7 +104,6 @@ def test_workspace_and_patch_configuration_are_explicit(tmp_path: Path) -> None: workspace = merry.WorkspaceConfig( root=tmp_path, readonly_resource_roots=["reference"], - allow_hidden=True, patch=patch, limits=merry.WorkspaceLimits(max_read_bytes=2048), ) @@ -118,16 +117,15 @@ def test_workspace_and_patch_configuration_are_explicit(tmp_path: Path) -> None: assert agent.session_id == "workspace-config" assert patch.write_scope == (Path("src"),) + assert workspace.root == tmp_path assert workspace.limits.max_read_bytes == 2048 def test_invalid_workspace_and_limits_fail_before_native_build(tmp_path: Path) -> None: with pytest.raises(ValueError, match="write_scope"): merry.PatchConfig(write_scope=[]) - with pytest.raises(ValueError, match="root or roots"): - merry.WorkspaceConfig() - with pytest.raises(ValueError, match="root or roots"): - merry.WorkspaceConfig(root=tmp_path, roots=[tmp_path]) + with pytest.raises(ValueError, match="root"): + merry.WorkspaceConfig("") builder = merry.AgentBuilder("limits") with pytest.raises(ValueError, match="max_model_turns"):