diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 951a21566..5efd0b0d5 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -7514,6 +7514,9 @@ pub struct CurrentWorkerWorkdirAttachmentResponse { pub alias: String, pub working_directory_id: String, pub capabilities: workdir::WorkdirSessionCapabilities, + /// Present for attachment responses, absent after detach. + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, pub attached: bool, } @@ -13286,6 +13289,7 @@ mod tests { alias: "checkout".to_string(), working_directory_id: "workdir".to_string(), capabilities: workdir::WorkdirSessionCapabilities::EMPTY, + environment: None, attached: false, }; let value = serde_json::to_value(&response).unwrap(); diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs index abb9700dc..6d42494b1 100644 --- a/crates/tools/src/bash.rs +++ b/crates/tools/src/bash.rs @@ -30,7 +30,9 @@ struct BashParams { /// Worker-local alias of the Workdir attachment to use. #[serde(default)] target_workdir: Option, - /// Workdir-relative directory in which to execute the command. + /// Directory in which to execute the command. + // Keep provider path-coordinate documentation out of the model-facing schema. + #[schemars(with = "String")] cwd: WorkdirPath, command: String, #[serde(default)] diff --git a/crates/tools/src/edit.rs b/crates/tools/src/edit.rs index 8f3e8e947..edd015acd 100644 --- a/crates/tools/src/edit.rs +++ b/crates/tools/src/edit.rs @@ -21,7 +21,7 @@ pub(crate) struct EditParams { /// Worker-local alias of the Workdir attachment to use. #[serde(default)] pub target_workdir: Option, - /// Logical path relative to the bound Workdir root. + /// Existing file in which to replace text. pub file_path: String, #[serde(flatten)] pub edit: fs_operation::text::EditArgs, diff --git a/crates/tools/src/glob.rs b/crates/tools/src/glob.rs index 509d5f3ea..440492698 100644 --- a/crates/tools/src/glob.rs +++ b/crates/tools/src/glob.rs @@ -17,7 +17,7 @@ pub(crate) struct GlobParams { pub target_workdir: Option, /// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`. pub pattern: String, - /// Logical Workdir-relative directory. Defaults to the Workdir root. + /// Directory to search. Defaults to `.`. #[serde(default)] pub path: Option, } @@ -106,7 +106,7 @@ pub(crate) fn routed_glob_tool(router: Arc) -> ToolDefinit Arc::new(move || { let schema = schemars::schema_for!(GlobParams); let meta = ToolMeta::new("Glob") - .description("Find files matching a glob pattern inside the selected Workdir. Results are sorted and capped at 1000 entries. Paths are Workdir-relative.") + .description("Find files matching a glob pattern inside the selected Workdir. Results are sorted and capped at 1000 entries.") .input_schema(serde_json::to_value(schema).expect("Glob schema serialization")); let tool: Arc = Arc::new(GlobTool { router: router.clone(), diff --git a/crates/tools/src/grep.rs b/crates/tools/src/grep.rs index 1fa48343a..813419fc7 100644 --- a/crates/tools/src/grep.rs +++ b/crates/tools/src/grep.rs @@ -27,7 +27,7 @@ pub(crate) struct GrepParams { #[serde(default)] target_workdir: Option, pattern: String, - /// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root. + /// File or directory to search. Defaults to `.`. #[serde(default)] pub path: Option, #[serde(default)] @@ -160,7 +160,7 @@ pub(crate) fn routed_grep_tool(router: Arc) -> ToolDefinit Arc::new(move || { let schema = schemars::schema_for!(GrepParams); let meta = ToolMeta::new("Grep") - .description("Search a file or directory in the selected Workdir with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Directory traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") + .description("Search a file or directory in the selected Workdir with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Directory traversal executes inside the WorkdirSession provider. Results are bounded.") .input_schema(serde_json::to_value(schema).expect("Grep schema serialization")); let tool: Arc = Arc::new(GrepTool { router: router.clone(), diff --git a/crates/tools/src/read.rs b/crates/tools/src/read.rs index e32d2389a..b669dd3ab 100644 --- a/crates/tools/src/read.rs +++ b/crates/tools/src/read.rs @@ -13,7 +13,7 @@ use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; const DESCRIPTION: &str = "Read a text file from a Workdir attachment selected by its Worker-local alias. \ Supports offset/limit for large files. Returns line-numbered output (1-based). \ Directories cannot be read. The file must be read before Write or Edit can \ -modify it. Paths are Workdir-relative unless an absolute path is explicitly readable. \ +modify it. \ When exactly one Workdir is attached, target_workdir may be omitted."; const DEFAULT_LIMIT: usize = 2000; @@ -24,7 +24,7 @@ pub(crate) struct ReadParams { /// Worker-local alias of the Workdir attachment to use. #[serde(default)] pub target_workdir: Option, - /// Workdir-relative path, or an absolute path covered by readable scope. + /// Text file to read; directories are not supported. pub file_path: String, #[serde(flatten)] pub read: fs_operation::text::LineReadArgs, diff --git a/crates/tools/src/view_image.rs b/crates/tools/src/view_image.rs index 4d2ff2214..bb775027d 100644 --- a/crates/tools/src/view_image.rs +++ b/crates/tools/src/view_image.rs @@ -20,7 +20,7 @@ pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; const IMAGE_READ_CHUNK_BYTES: usize = 1024 * 1024; const DESCRIPTION: &str = "Attach an image from the selected Workdir attachment to the next model request. \ -The path must be logical and Workdir-relative. Supported formats: PNG, JPEG, GIF, and WebP. \ +Supported formats: PNG, JPEG, GIF, and WebP. \ When exactly one Workdir is attached, target_workdir may be omitted."; #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -28,7 +28,7 @@ struct ViewImageParams { /// Worker-local alias of the Workdir attachment to use. #[serde(default)] target_workdir: Option, - /// Logical path relative to the bound Workdir root. + /// Image file to attach to the next model request. path: String, } diff --git a/crates/tools/src/write.rs b/crates/tools/src/write.rs index fefa660a6..4b5e59e59 100644 --- a/crates/tools/src/write.rs +++ b/crates/tools/src/write.rs @@ -20,7 +20,7 @@ pub(crate) struct WriteParams { /// Worker-local alias of the Workdir attachment to use. #[serde(default)] pub target_workdir: Option, - /// Logical path relative to the bound Workdir root. + /// File to create or overwrite. pub file_path: String, #[serde(flatten)] pub write: fs_operation::text::WriteArgs, diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index 50ddfd32d..5ba767655 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -3,6 +3,11 @@ //! `ToolServerHandle::register_tool` / `flush_pending` are `pub(crate)` in //! agen, so from here we exercise the factories directly — the same //! code path that `flush_pending()` runs at production time. +//! +//! Scoped routing cases connect real LocalWorkdirSession, WorkdirToolBroker::scope, +//! attachment router and Tool factories without replacing their permission or path checks. +//! They use RAII temporary roots and existing image fixtures; command cases require the +//! same local shell as the Bash suite and run in the normal `tools --test integration` target. use std::path::Path; use std::sync::Arc; @@ -80,6 +85,101 @@ fn setup_routed() -> (TempDir, TempDir, Arc, Registry) { (left, right, router, Registry::new(definitions)) } +const READ_SAME_FILE: &str = "IFS= read -r line < same.txt && printf '%s\\n' \"$line\""; + +struct ScopedRegistry { + roots: Vec, + directories: Vec, + brokers: Vec, + leases: Vec, + router: Arc, + reg: Registry, +} + +impl ScopedRegistry { + // Cwd and scope targets here are delegation declarations in provider coordinates. + async fn new(attachments: &[(&str, &str, bool)]) -> Self { + let router = Arc::new(WorkdirSessionRouter::new()); + let mut roots = Vec::new(); + let mut directories = Vec::new(); + let mut brokers = Vec::new(); + let mut leases = Vec::new(); + for &(alias, cwd, command) in attachments { + let root = TempDir::new().unwrap(); + let directory = root.path().join(cwd); + std::fs::create_dir_all(directory.join("subdir")).unwrap(); + std::fs::write(directory.join("same.txt"), format!("{alias} original\n")).unwrap(); + std::fs::write( + directory.join("subdir/same.txt"), + format!("{alias} nested needle\n"), + ) + .unwrap(); + std::fs::write( + directory.join("image.png"), + include_bytes!("fixtures/view-image/valid.png"), + ) + .unwrap(); + if cwd != "." { + // Decoys make accidental root-based resolution observable. + std::fs::write(root.path().join("same.txt"), "root decoy\n").unwrap(); + std::fs::write(root.path().join("image.png"), "not an image").unwrap(); + } + let source: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( + Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + )); + let broker = WorkdirToolBroker::new(source); + let lease = broker + .scope(WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::new(cwd).unwrap(), + permission: WorkdirToolScopePermission::Write, + recursive: true, + symlink_policy: Default::default(), + }], + cwd: WorkdirPath::new(cwd).unwrap(), + command, + }) + .await + .unwrap(); + router + .attach( + WorkdirAttachmentAlias::new(alias).unwrap(), + lease.tool_session(), + ) + .unwrap(); + roots.push(root); + directories.push(directory); + brokers.push(broker); + leases.push(lease); + } + let mut definitions = routed_builtin_tools( + router.clone(), + Tracker::new(), + tools::bash_output::BashOutputView::new(), + ); + definitions.push(routed_view_image_tool(router.clone())); + Self { + roots, + directories, + brokers, + leases, + router, + reg: Registry::new(definitions), + } + } + + async fn close(self) { + self.router.close_all().await.unwrap(); + for lease in &self.leases { + lease.close().await.unwrap(); + } + for broker in &self.brokers { + broker.close().await.unwrap(); + } + } +} + fn valid_gif_with_len(target_len: usize) -> Vec { let fixture = include_bytes!("fixtures/view-image/valid.gif"); assert_eq!( @@ -156,6 +256,71 @@ fn routed_tool_schemas_share_optional_target_workdir() { } } +#[test] +fn routed_tool_help_does_not_override_session_path_coordinates() { + fn assert_no_root_coordinates(tool: &str, description: &str) { + let normalized = description + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase(); + for obsolete in ["workdir-relative", "workdir root", "root-relative"] { + assert!( + !normalized.contains(obsolete), + "{tool} help overrides session path coordinates: {description}" + ); + } + } + + fn check_schema_descriptions(tool: &str, schema: &serde_json::Value) { + match schema { + serde_json::Value::Object(object) => { + if let Some(description) = object.get("description").and_then(|v| v.as_str()) { + assert_no_root_coordinates(tool, description); + } + for value in object.values() { + check_schema_descriptions(tool, value); + } + } + serde_json::Value::Array(array) => { + for value in array { + check_schema_descriptions(tool, value); + } + } + _ => {} + } + } + + let router = Arc::new(WorkdirSessionRouter::new()); + let mut definitions = routed_builtin_tools( + router.clone(), + Tracker::new(), + tools::bash_output::BashOutputView::new(), + ); + definitions.push(routed_view_image_tool(router)); + for definition in definitions { + let (meta, _) = definition(); + assert_no_root_coordinates(&meta.name, &meta.description); + // Referenced type definitions are model-visible too, not just properties. + check_schema_descriptions(&meta.name, &meta.input_schema); + + let path_parameter = match meta.name.as_str() { + "Read" | "Write" | "Edit" => "file_path", + "Glob" | "Grep" | "ViewImage" => "path", + "Bash" => "cwd", + other => panic!("missing path parameter contract for {other}"), + }; + let help = meta.input_schema["properties"][path_parameter]["description"] + .as_str() + .unwrap_or_default(); + assert!( + !help.trim().is_empty(), + "{}.{path_parameter} must retain argument-specific help", + meta.name + ); + } +} + #[tokio::test] async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() { let (_left, _right, router, reg) = setup_routed(); @@ -284,6 +449,427 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { router.close_all().await.unwrap(); } +#[tokio::test] +async fn scoped_cwd_file_search_and_image_tools_share_reusable_paths() { + for cwd in [".", "work"] { + let fixture = ScopedRegistry::new(&[("checkout", cwd, false)]).await; + let read = fixture.reg.get("Read"); + let original = call(&read, json!({ "file_path": "same.txt" })).await; + assert!(original.content.unwrap().contains("checkout original")); + call( + &fixture.reg.get("Write"), + json!({ "file_path": "same.txt", "content": "written marker\n" }), + ) + .await; + call( + &fixture.reg.get("Edit"), + json!({ "file_path": "same.txt", "old_string": "written", "new_string": "edited" }), + ) + .await; + let edited = call(&read, json!({ "file_path": "same.txt" })).await; + assert!(edited.content.unwrap().contains("edited marker")); + assert_eq!( + std::fs::read_to_string(fixture.directories[0].join("same.txt")).unwrap(), + "edited marker\n" + ); + + // Omitted search paths start at cwd, not at the underlying provider root. + for (name, input) in [ + ("Glob", json!({ "pattern": "same.txt" })), + ( + "Grep", + json!({ "pattern": "edited marker", "output_mode": "files_with_matches" }), + ), + ] { + let result = call(&fixture.reg.get(name), input).await; + assert_eq!( + result.content.unwrap().trim(), + "same.txt", + "{name}, cwd={cwd}" + ); + } + + // A search rooted below cwd still returns cwd-relative reusable paths. + for (name, input) in [ + ("Glob", json!({ "pattern": "*.txt", "path": "subdir" })), + ( + "Grep", + json!({ "pattern": "needle", "path": "subdir", "output_mode": "files_with_matches" }), + ), + ] { + let result = call(&fixture.reg.get(name), input).await; + let body = result.content.unwrap(); + let paths: Vec<_> = body.lines().collect(); + assert_eq!(paths, ["subdir/same.txt"], "{name}, cwd={cwd}"); + let path = paths[0]; + let found = call(&read, json!({ "file_path": path })).await; + assert!(found.content.unwrap().contains("checkout nested needle")); + call( + &fixture.reg.get("Edit"), + json!({ "file_path": path, "old_string": "needle", "new_string": format!("needle-{name}") }), + ) + .await; + } + assert_eq!( + std::fs::read_to_string(fixture.directories[0].join("subdir/same.txt")).unwrap(), + "checkout nested needle-Grep-Glob\n" + ); + let image = call( + &fixture.reg.get("ViewImage"), + json!({ "path": "image.png" }), + ) + .await; + assert_eq!(image.attachments.len(), 1); + let agen::tool::Attachment::Image(image) = &image.attachments[0]; + assert_eq!(image.mime_type(), "image/png"); + assert_eq!( + image.data(), + include_bytes!("fixtures/view-image/valid.png") + ); + if cwd != "." { + assert_eq!( + std::fs::read_to_string(fixture.roots[0].path().join("same.txt")).unwrap(), + "root decoy\n" + ); + } + fixture.close().await; + } +} + +#[tokio::test] +async fn scoped_bash_relative_cwd_and_cd_do_not_change_later_tool_cwd() { + for cwd in [".", "work"] { + let fixture = ScopedRegistry::new(&[("checkout", cwd, true)]).await; + for (command_cwd, command, expected) in [ + (".", READ_SAME_FILE, "checkout original\n"), + ("subdir", READ_SAME_FILE, "checkout nested needle\n"), + ("./subdir//.", READ_SAME_FILE, "checkout nested needle\n"), + ( + ".", + "cd subdir && IFS= read -r line < same.txt && printf '%s\\n' \"$line\"", + "checkout nested needle\n", + ), + (".", READ_SAME_FILE, "checkout original\n"), + ] { + let output = call( + &fixture.reg.get("Bash"), + json!({ "cwd": command_cwd, "command": command, "timeout": 5 }), + ) + .await; + let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["output"]["terminal"]["exit_code"], 0, "{body}"); + assert_eq!(body["stdout_preview"], expected, "cwd={cwd}, {command}"); + let subsequent = + call(&fixture.reg.get("Read"), json!({ "file_path": "same.txt" })).await; + assert!(subsequent.content.unwrap().contains("checkout original")); + } + fixture.close().await; + } +} + +#[tokio::test] +async fn scoped_aliases_with_different_cwds_keep_paths_and_observations_separate() { + let fixture = + ScopedRegistry::new(&[("left", "work", true), ("right", "other/deep", true)]).await; + let read = fixture.reg.get("Read"); + let edit = fixture.reg.get("Edit"); + call( + &read, + json!({ "target_workdir": "left", "file_path": "same.txt" }), + ) + .await; + let unread = call_err( + &edit, + json!({ "target_workdir": "right", "file_path": "same.txt", "old_string": "original", "new_string": "wrong" }), + ) + .await; + assert!(unread.to_string().contains("has not been read"), "{unread}"); + call( + &read, + json!({ "target_workdir": "right", "file_path": "same.txt" }), + ) + .await; + call( + &fixture.reg.get("Write"), + json!({ "target_workdir": "right", "file_path": "same.txt", "content": "right written\n" }), + ) + .await; + call( + &edit, + json!({ "target_workdir": "right", "file_path": "same.txt", "old_string": "written", "new_string": "edited" }), + ) + .await; + assert_eq!( + std::fs::read_to_string(fixture.directories[0].join("same.txt")).unwrap(), + "left original\n" + ); + assert_eq!( + std::fs::read_to_string(fixture.directories[1].join("same.txt")).unwrap(), + "right edited\n" + ); + + // Different valid bytes make cross-alias image reads observable too. + let png: &[u8] = include_bytes!("fixtures/view-image/valid.png"); + let gif: &[u8] = include_bytes!("fixtures/view-image/valid.gif"); + std::fs::write(fixture.directories[1].join("image.png"), gif).unwrap(); + for (alias, expected, image_bytes, mime) in [ + ("left", "left original", png, "image/png"), + ("right", "right edited", gif, "image/gif"), + ] { + let output = call( + &read, + json!({ "target_workdir": alias, "file_path": "same.txt" }), + ) + .await; + assert!(output.content.unwrap().contains(expected)); + for (name, input) in [ + ( + "Glob", + json!({ "target_workdir": alias, "pattern": "*.txt", "path": "subdir" }), + ), + ( + "Grep", + json!({ "target_workdir": alias, "pattern": alias, "path": "subdir", "output_mode": "files_with_matches" }), + ), + ] { + let output = call(&fixture.reg.get(name), input).await; + let path = output.content.unwrap(); + let path = path.trim(); + assert_eq!(path, "subdir/same.txt", "{alias}: {name}"); + let found = call(&read, json!({ "target_workdir": alias, "file_path": path })).await; + assert!( + found + .content + .unwrap() + .contains(&format!("{alias} nested needle")) + ); + } + let image = call( + &fixture.reg.get("ViewImage"), + json!({ "target_workdir": alias, "path": "image.png" }), + ) + .await; + let agen::tool::Attachment::Image(image) = &image.attachments[0]; + assert_eq!(image.mime_type(), mime); + assert_eq!(image.data(), image_bytes); + let output = call( + &fixture.reg.get("Bash"), + json!({ "target_workdir": alias, "cwd": ".", "command": READ_SAME_FILE, "timeout": 5 }), + ) + .await; + let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["output"]["terminal"]["exit_code"], 0, "{body}"); + assert_eq!(body["stdout_preview"], format!("{expected}\n")); + } + fixture.close().await; +} + +#[tokio::test] +async fn scoped_command_false_rejects_bash_without_fallback_to_another_alias() { + let fixture = + ScopedRegistry::new(&[("enabled", "work", true), ("disabled", "other", false)]).await; + for cwd in [".", "subdir"] { + let error = call_err( + &fixture.reg.get("Bash"), + json!({ "target_workdir": "disabled", "cwd": cwd, "command": "printf forbidden > blocked.txt", "timeout": 5 }), + ) + .await; + assert!( + error.to_string().contains("does not support Command"), + "{error}" + ); + } + // Write permission remains usable, but does not imply command permission. + call( + &fixture.reg.get("Write"), + json!({ "target_workdir": "disabled", "file_path": "created.txt", "content": "allowed write\n" }), + ) + .await; + assert_eq!( + std::fs::read_to_string(fixture.directories[1].join("created.txt")).unwrap(), + "allowed write\n" + ); + for (root, directory) in fixture.roots.iter().zip(&fixture.directories) { + for path in [ + root.path().to_path_buf(), + directory.clone(), + directory.join("subdir"), + ] { + assert!(!path.join("blocked.txt").exists()); + } + } + fixture.close().await; +} + +#[tokio::test] +async fn scoped_normalized_paths_share_read_before_mutation_history() { + let fixture = ScopedRegistry::new(&[("checkout", "work", false)]).await; + let read = call( + &fixture.reg.get("Read"), + json!({ "file_path": "./subdir//./same.txt" }), + ) + .await; + assert!(read.content.unwrap().contains("checkout nested needle")); + call( + &fixture.reg.get("Write"), + json!({ "file_path": "subdir/same.txt", "content": "normalized write\n" }), + ) + .await; + call( + &fixture.reg.get("Edit"), + json!({ "file_path": "./subdir/same.txt", "old_string": "write", "new_string": "edit" }), + ) + .await; + assert_eq!( + std::fs::read_to_string(fixture.directories[0].join("subdir/same.txt")).unwrap(), + "normalized edit\n" + ); + for (name, input) in [ + ("Glob", json!({ "pattern": "*.txt", "path": "./subdir//." })), + ( + "Grep", + json!({ "pattern": "normalized edit", "path": "./subdir//.", "output_mode": "files_with_matches" }), + ), + ] { + let result = call(&fixture.reg.get(name), input).await; + assert_eq!(result.content.unwrap().trim(), "subdir/same.txt", "{name}"); + } + let image = call( + &fixture.reg.get("ViewImage"), + json!({ "path": "././image.png" }), + ) + .await; + let agen::tool::Attachment::Image(image) = &image.attachments[0]; + assert_eq!( + image.data(), + include_bytes!("fixtures/view-image/valid.png") + ); + fixture.close().await; +} + +#[tokio::test] +async fn scoped_tools_reject_parent_traversal_and_paths_outside_the_grant() { + let fixture = ScopedRegistry::new(&[("checkout", "work", true)]).await; + let outside = fixture.roots[0].path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("secret.txt"), "private needle\n").unwrap(); + for invalid in [ + "../outside/secret.txt", + "subdir/../../outside/secret.txt", + "subdir\\same.txt", + ] { + for (name, input) in [ + ("Read", json!({ "file_path": invalid })), + ( + "Write", + json!({ "file_path": invalid, "content": "forbidden" }), + ), + ( + "Edit", + json!({ "file_path": invalid, "old_string": "needle", "new_string": "forbidden" }), + ), + ("Glob", json!({ "path": invalid, "pattern": "*" })), + ("Grep", json!({ "path": invalid, "pattern": "needle" })), + ("ViewImage", json!({ "path": invalid })), + ( + "Bash", + json!({ "cwd": invalid, "command": "printf forbidden > blocked.txt", "timeout": 5 }), + ), + ] { + let error = call_err(&fixture.reg.get(name), input).await; + assert!( + error.to_string().contains("path"), + "{name}: {invalid}: {error}" + ); + } + } + // These paths are inside the provider's grant, but outside the child's grant. + // Absolute readable paths must not bypass the delegated scope after cwd rebasing. + for (name, input) in [ + ("Read", json!({ "file_path": outside.join("secret.txt") })), + ("Grep", json!({ "path": outside, "pattern": "needle" })), + ( + "Bash", + json!({ "cwd": outside, "command": "printf forbidden > blocked.txt", "timeout": 5 }), + ), + ] { + let error = call_err(&fixture.reg.get(name), input).await; + let message = error.to_string(); + assert!( + matches!(error, ToolError::InvalidArgument(_)), + "{name}: {message}" + ); + assert!( + message.contains("scope") || message.contains("path"), + "{name}: {message}" + ); + } + assert_eq!( + std::fs::read_to_string(outside.join("secret.txt")).unwrap(), + "private needle\n" + ); + assert!(!outside.join("blocked.txt").exists()); + assert!(!fixture.directories[0].join("blocked.txt").exists()); + assert!(!fixture.roots[0].path().join("blocked.txt").exists()); + fixture.close().await; +} + +#[cfg(unix)] +#[tokio::test] +async fn scoped_cwd_tools_reject_resolved_symlink_escape_after_a_read() { + use std::os::unix::fs::symlink; + + let fixture = ScopedRegistry::new(&[("checkout", "work", true)]).await; + let outside = fixture.roots[0].path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + // Equal bytes avoid disguising an authority failure as a stale-hash rejection. + std::fs::write(outside.join("secret.txt"), "checkout original\n").unwrap(); + let png = include_bytes!("fixtures/view-image/valid.png"); + std::fs::write(outside.join("image.png"), png).unwrap(); + call(&fixture.reg.get("Read"), json!({ "file_path": "same.txt" })).await; + std::fs::remove_file(fixture.directories[0].join("same.txt")).unwrap(); + symlink( + "../outside/secret.txt", + fixture.directories[0].join("same.txt"), + ) + .unwrap(); + symlink("../outside", fixture.directories[0].join("escape")).unwrap(); + + for (name, input) in [ + ("Read", json!({ "file_path": "same.txt" })), + ( + "Write", + json!({ "file_path": "escape/created.txt", "content": "forbidden" }), + ), + ( + "Edit", + json!({ "file_path": "same.txt", "old_string": "original", "new_string": "forbidden" }), + ), + ("Glob", json!({ "path": "escape", "pattern": "*" })), + ("Grep", json!({ "path": "escape", "pattern": "original" })), + ("ViewImage", json!({ "path": "escape/image.png" })), + ( + "Bash", + json!({ "cwd": "escape", "command": "printf forbidden > blocked.txt", "timeout": 5 }), + ), + ] { + let error = call_err(&fixture.reg.get(name), input).await; + let message = error.to_string(); + assert!( + message.contains("scope") || message.contains("symlink"), + "{name}: {message}" + ); + } + assert_eq!( + std::fs::read_to_string(outside.join("secret.txt")).unwrap(), + "checkout original\n" + ); + assert_eq!(std::fs::read(outside.join("image.png")).unwrap(), png); + assert!(!outside.join("created.txt").exists()); + assert!(!outside.join("blocked.txt").exists()); + fixture.close().await; +} + #[tokio::test] async fn selected_attachment_capability_is_checked_without_fallback() { let left = TempDir::new().unwrap(); diff --git a/crates/workdir/src/checkout.rs b/crates/workdir/src/checkout.rs index 52c8a91ae..d140f7511 100644 --- a/crates/workdir/src/checkout.rs +++ b/crates/workdir/src/checkout.rs @@ -6,7 +6,7 @@ //! hashes alone do not accept same-content inode replacements. Callers own Read line numbering //! and per-attachment read history. //! -//! Create paths are checkout-root-relative and must be strict descendants of the observed target +//! Create paths use the target's session coordinates and must be strict descendants of the observed target //! directory. Existing destinations are never overwritten; results return the updated observation //! of the retained target directory, not the created file. Read results also return the validator //! of the state actually read, never a replacement from a later independent stat. @@ -18,7 +18,8 @@ //! //! Search delegates to shared List/Glob/Grep engines. Scope rules form unions within layers and //! intersections across layers, provider authority, and output roots. Wrappers transform coordinates -//! once; returned paths and Grep content are relative to the final session's logical root. +//! once; returned paths and Grep content are relative to the composed output root (the caller's +//! cwd by default). //! Paths are not reverse-parsed from rendered filenames; non-UTF-8 names are not lossily aliased. //! Unsupported providers reject search rather than fall back to broader trait searches. Search is //! live traversal, not an atomic snapshot of all descendants or exclusion of external editors. diff --git a/crates/workdir/src/environment.rs b/crates/workdir/src/environment.rs new file mode 100644 index 000000000..24fc259db --- /dev/null +++ b/crates/workdir/src/environment.rs @@ -0,0 +1,68 @@ +//! Model-facing session coordinates, separate from provider paths and authority. + +use crate::{WorkdirPath, WorkdirSessionCapabilities, WorkdirToolScopeRule}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A snapshot of the environment actually used by a session's operations. +/// +/// Cwd is a logical Workdir-root coordinate. Scope targets use that root or +/// explicit absolute provider paths. Scope is an additional allow ceiling, not +/// a replacement for provider checks. Providers +/// may retain further restrictions (including deny rules); this projection +/// never authorizes an operation. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkdirSessionEnvironment { + #[serde(deserialize_with = "deserialize_cwd")] + pub cwd: WorkdirPath, + pub scope: Option>, + pub capabilities: WorkdirSessionCapabilities, + pub restrictions: Vec, +} + +fn deserialize_cwd<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result { + let path = String::deserialize(deserializer)?; + WorkdirPath::new(path).map_err(serde::de::Error::custom) +} + +impl WorkdirSessionEnvironment { + pub fn root(capabilities: WorkdirSessionCapabilities) -> Self { + Self { + cwd: WorkdirPath::root(), + scope: None, + capabilities, + restrictions: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_cwd_is_normalized_and_cannot_claim_an_absolute_or_parent_base() { + let mut wire = serde_json::to_value(WorkdirSessionEnvironment::root( + WorkdirSessionCapabilities::READ_ONLY, + )) + .unwrap(); + for invalid in [ + "/absolute", + "../parent", + "nested/../parent", + "nested\\other", + ] { + wire["cwd"] = invalid.into(); + assert!( + serde_json::from_value::(wire.clone()).is_err(), + "{invalid}" + ); + } + wire["cwd"] = "./nested//child".into(); + let decoded: WorkdirSessionEnvironment = serde_json::from_value(wire).unwrap(); + assert_eq!(decoded.cwd.as_str(), "nested/child"); + } +} diff --git a/crates/workdir/src/environment_tests.rs b/crates/workdir/src/environment_tests.rs new file mode 100644 index 000000000..fedba6031 --- /dev/null +++ b/crates/workdir/src/environment_tests.rs @@ -0,0 +1,175 @@ +//! Real Local/scoped/read-only/router sessions across loopback HTTP. Only the +//! HTTP authentication boundary is a fixture; coordinate and scope checks are +//! production implementations. Runs in the normal http-client library suite. +#![cfg(all(feature = "http-client", target_os = "linux"))] + +use crate::http::*; +use crate::*; +use axum::{ + Json, Router, + extract::State, + routing::{delete, post}, +}; +use std::sync::Arc; + +struct Server(tokio::task::JoinHandle<()>); +impl Drop for Server { + fn drop(&mut self) { + self.0.abort(); + } +} + +async fn open(State(session): State) -> Json { + Json(OpenWorkdirSessionResponse { + session_id: WorkdirSessionId::new("environment-session").unwrap(), + workdir_id: session.workdir().id().clone(), + capabilities: session.capabilities(), + environment: session.environment(), + }) +} +async fn operation( + State(session): State, + Json(request): Json, +) -> Result< + Json, + (axum::http::StatusCode, Json), +> { + dispatch_workdir_session_operation(session.as_ref(), request.operation) + .await + .map(Json) + .map_err(|error| { + ( + axum::http::StatusCode::FORBIDDEN, + Json(WorkdirTransportError::from_workdir_error(&error)), + ) + }) +} + +#[tokio::test] +async fn remote_environment_matches_scoped_readonly_operations_without_rebasing_twice() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("nested")).unwrap(); + std::fs::write(root.path().join("nested/file.txt"), "nested marker").unwrap(); + std::fs::write(root.path().join("file.txt"), "root decoy").unwrap(); + let broker = WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + ))); + let lease = broker + .scope(WorkdirToolScope { + cwd: WorkdirPath::new("nested").unwrap(), + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::new("nested").unwrap(), + permission: WorkdirToolScopePermission::Write, + recursive: true, + symlink_policy: Default::default(), + }], + command: true, + }) + .await + .unwrap(); + let session: WorkdirSessionHandle = Arc::new(ReadOnlyWorkdirSession::new(lease.tool_session())); + let expected = session.environment(); + let app = Router::new() + .route("/v1/working-directories/{id}/sessions", post(open)) + .route("/v1/workdir-sessions/{id}/operations", post(operation)) + .route( + "/v1/workdir-sessions/{id}", + delete(|| async { axum::http::StatusCode::NO_CONTENT }), + ) + .with_state(session.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let _server = Server(tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + })); + let remote: WorkdirSessionHandle = Arc::new( + RemoteWorkdirSession::open( + reqwest::Client::builder().no_proxy().build().unwrap(), + format!("http://{address}").parse().unwrap(), + "fixture", + session.workdir().id().clone(), + OpenWorkdirSessionRequest::default(), + ) + .await + .unwrap(), + ); + let router = WorkdirSessionRouter::new(); + router + .attach(WorkdirAttachmentAlias::new("remote").unwrap(), remote) + .unwrap(); + let selected = router.resolve(Some("remote")).unwrap().session; + assert_eq!(selected.environment(), expected); + assert_eq!(expected.cwd, WorkdirPath::new("nested").unwrap()); + assert!( + !expected + .capabilities + .supports(WorkdirSessionCapability::Command) + ); + assert!( + expected + .scope + .unwrap() + .iter() + .all(|rule| rule.permission == WorkdirToolScopePermission::Read) + ); + let found = selected + .glob(GlobRequest { + path: WorkdirPath::root(), + pattern: "*.txt".into(), + limit: 10, + }) + .await + .unwrap(); + assert_eq!(found.paths, vec![WorkdirPath::new("file.txt").unwrap()]); + let path = found.paths[0].clone(); + let read = selected + .read(ReadRequest { + path: path.clone(), + offset: 0, + limit: 10, + max_bytes: 100, + }) + .await + .unwrap(); + assert_eq!(read.path, path); + assert_eq!(read.bytes, b"nested marker"); + assert!( + selected + .write(WriteRequest { + path, + content: b"denied".to_vec(), + expected_hash: Some(read.content_hash) + }) + .await + .is_err() + ); + assert!( + selected + .read(ReadRequest { + path: WorkdirPath::new_scoped(root.path().join("file.txt").to_str().unwrap()) + .unwrap(), + offset: 0, + limit: 10, + max_bytes: 100 + }) + .await + .is_err() + ); + assert!( + selected + .start_command(CommandRequest { + command: "touch escaped".into(), + cwd: WorkdirPath::root(), + timeout_secs: 5, + transport_byte_budget: 100, + tool_call_id: None + }) + .await + .is_err() + ); + assert!(!root.path().join("nested/escaped").exists()); + router.close_all().await.unwrap(); + lease.close().await.unwrap(); + broker.close().await.unwrap(); +} diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 7c2f66ba4..20a57a696 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -54,6 +54,7 @@ pub struct OpenWorkdirSessionResponse { pub session_id: WorkdirSessionId, pub workdir_id: WorkdirId, pub capabilities: WorkdirSessionCapabilities, + pub environment: crate::WorkdirSessionEnvironment, } /// One provider-side Workdir operation. @@ -553,6 +554,7 @@ mod client { workdir: Workdir, session_id: WorkdirSessionId, capabilities: WorkdirSessionCapabilities, + environment: crate::WorkdirSessionEnvironment, closed: AtomicBool, } @@ -612,6 +614,7 @@ mod client { workdir: Workdir::new(opened.workdir_id.as_str()), session_id: opened.session_id, capabilities: opened.capabilities, + environment: opened.environment, closed: AtomicBool::new(false), }) } @@ -676,6 +679,12 @@ mod client { self.capabilities } + fn environment(&self) -> crate::WorkdirSessionEnvironment { + let mut environment = self.environment.clone(); + environment.capabilities = self.capabilities(); + environment + } + async fn authorize_scope_path( &self, request: WorkdirScopeAuthorizationRequest, @@ -1209,6 +1218,9 @@ mod client { workdir, session_id: WorkdirSessionId::new("s").unwrap(), capabilities: WorkdirSessionCapabilities::EMPTY, + environment: crate::WorkdirSessionEnvironment::root( + WorkdirSessionCapabilities::EMPTY, + ), closed: AtomicBool::new(false), }; let stat = StatRequest { diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index c3c3d97e8..bfb9f6af2 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -5,6 +5,8 @@ pub use checkout::{ CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult, CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, }; +mod environment; +pub use environment::WorkdirSessionEnvironment; pub mod external; pub mod http; mod local; @@ -16,6 +18,8 @@ pub mod workspace; #[cfg(test)] mod checkout_search_tests; #[cfg(test)] +mod environment_tests; +#[cfg(test)] mod external_local_tests; use std::path::{Path, PathBuf}; @@ -185,6 +189,9 @@ pub type WriteOutcome = WriteResult; /// Worker-bound operation session for a persistent [`Workdir`]. /// /// Filesystem search and commands run on the host owning materialization. +/// Relative operation paths and reusable search results use the session's cwd; +/// root-coordinate providers are adapted once at attachment, not by each Tool. +/// Scope declarations retain their own root-coordinate authority contract. /// Session closure is terminal but does not delete the Workdir or files. /// Unsupported checkout/search implementations explicitly reject requests, with no ambient /// filesystem fallback. Providers own identity validators; callers own read hash history @@ -194,6 +201,13 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync { fn workdir(&self) -> &Workdir; fn capabilities(&self) -> WorkdirSessionCapabilities; + /// Describe the coordinates used by this session, not a caller's Profile + /// or filesystem authority. Root-coordinate providers use the default; + /// coordinate and authority wrappers must project their effective view. + fn environment(&self) -> WorkdirSessionEnvironment { + WorkdirSessionEnvironment::root(self.capabilities()) + } + /// Validate an attenuated filesystem rule at the provider boundary without /// exposing the resolved host path. Providers that cannot resolve symbolic /// links must reject resolved-policy checks rather than downgrade them. diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 344cab6a8..f4e8cf972 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -1340,6 +1340,52 @@ impl WorkdirSession for LocalWorkdirSession { self.inner.capabilities } + fn environment(&self) -> crate::WorkdirSessionEnvironment { + let scope = self.inner.scope.snapshot(); + let project = |rule: manifest::ScopeRule| -> Option { + let target = if self.inner.root.starts_with(&rule.target) && rule.recursive { + WorkdirPath::root() + } else { + let path = rule + .target + .strip_prefix(&self.inner.root) + .unwrap_or(&rule.target); + WorkdirPath::new_scoped(path.to_str()?).ok()? + }; + Some(crate::WorkdirToolScopeRule { + target, + permission: match rule.permission { + Permission::Read => WorkdirToolScopePermission::Read, + Permission::Write => WorkdirToolScopePermission::Write, + }, + recursive: rule.recursive, + symlink_policy: rule.symlink_policy, + }) + }; + crate::WorkdirSessionEnvironment { + cwd: WorkdirPath::root(), + scope: Some( + scope + .allow_rules() + .into_iter() + .filter_map(project) + .collect(), + ), + capabilities: self.capabilities(), + restrictions: scope + .deny_rules() + .into_iter() + .map(|rule| { + project(rule) + .map(|rule| format!("Deny: {rule:?}")) + .unwrap_or_else(|| { + "Unrepresentable provider deny rule remains enforced".into() + }) + }) + .collect(), + } + } + async fn authorize_scope_path( &self, request: WorkdirScopeAuthorizationRequest, diff --git a/crates/workdir/src/local/command_transport/tests/http.rs b/crates/workdir/src/local/command_transport/tests/http.rs index 0bed3d4dc..594b5df3f 100644 --- a/crates/workdir/src/local/command_transport/tests/http.rs +++ b/crates/workdir/src/local/command_transport/tests/http.rs @@ -134,6 +134,9 @@ async fn pipe_and_wait_faults_cross_real_http_with_prefix_and_terminal_replay() session_id: WorkdirSessionId::new("fault-session").unwrap(), workdir_id: crate::Workdir::new("fault-workdir").id().clone(), capabilities: crate::WorkdirSessionCapabilities::ALL, + environment: crate::WorkdirSessionEnvironment::root( + crate::WorkdirSessionCapabilities::ALL, + ), }) }), ) diff --git a/crates/workdir/src/router.rs b/crates/workdir/src/router.rs index 8810b4435..38e73def8 100644 --- a/crates/workdir/src/router.rs +++ b/crates/workdir/src/router.rs @@ -225,16 +225,27 @@ impl Drop for ActiveOperation<'_> { #[derive(Debug, Clone)] pub struct RoutedWorkdirSession { state: Arc, + view: Option, +} + +impl RoutedWorkdirSession { + fn inner(&self) -> &WorkdirSessionHandle { + self.view.as_ref().unwrap_or(&self.state.session) + } } #[async_trait] impl WorkdirSession for RoutedWorkdirSession { fn workdir(&self) -> &Workdir { - self.state.session.workdir() + self.inner().workdir() } fn capabilities(&self) -> WorkdirSessionCapabilities { - self.state.session.capabilities() + self.inner().capabilities() + } + + fn environment(&self) -> crate::WorkdirSessionEnvironment { + self.inner().environment() } async fn authorize_scope_path( @@ -242,7 +253,7 @@ impl WorkdirSession for RoutedWorkdirSession { request: WorkdirScopeAuthorizationRequest, ) -> Result<(), WorkdirError> { let _active = self.state.enter()?; - self.state.session.authorize_scope_path(request).await + self.inner().authorize_scope_path(request).await } async fn scope_rules_overlap( @@ -250,7 +261,7 @@ impl WorkdirSession for RoutedWorkdirSession { request: WorkdirScopeOverlapRequest, ) -> Result { let _active = self.state.enter()?; - self.state.session.scope_rules_overlap(request).await + self.inner().scope_rules_overlap(request).await } async fn checkout_search( @@ -258,7 +269,7 @@ impl WorkdirSession for RoutedWorkdirSession { request: crate::CheckoutSearchRequest, ) -> Result { let _active = self.state.enter()?; - self.state.session.checkout_search(request).await + self.inner().checkout_search(request).await } async fn checkout_observe( @@ -266,59 +277,59 @@ impl WorkdirSession for RoutedWorkdirSession { path: crate::WorkdirPath, ) -> Result { let _active = self.state.enter()?; - self.state.session.checkout_observe(path).await + self.inner().checkout_observe(path).await } async fn checkout_execute( &self, request: crate::CheckoutRequest, ) -> Result { let _active = self.state.enter()?; - self.state.session.checkout_execute(request).await + self.inner().checkout_execute(request).await } async fn stat(&self, request: StatRequest) -> Result { let _active = self.state.enter()?; - self.state.session.stat(request).await + self.inner().stat(request).await } async fn read(&self, request: ReadRequest) -> Result { let _active = self.state.enter()?; - self.state.session.read(request).await + self.inner().read(request).await } async fn read_bytes(&self, request: ReadBytesRequest) -> Result { let _active = self.state.enter()?; - self.state.session.read_bytes(request).await + self.inner().read_bytes(request).await } async fn write(&self, request: WriteRequest) -> Result { let _active = self.state.enter()?; - self.state.session.write(request).await + self.inner().write(request).await } async fn edit(&self, request: EditRequest) -> Result { let _active = self.state.enter()?; - self.state.session.edit(request).await + self.inner().edit(request).await } async fn list(&self, request: ListRequest) -> Result { let _active = self.state.enter()?; - self.state.session.list(request).await + self.inner().list(request).await } async fn glob(&self, request: GlobRequest) -> Result { let _active = self.state.enter()?; - self.state.session.glob(request).await + self.inner().glob(request).await } async fn grep(&self, request: GrepRequest) -> Result { let _active = self.state.enter()?; - self.state.session.grep(request).await + self.inner().grep(request).await } async fn start_command(&self, request: CommandRequest) -> Result { let _active = self.state.enter()?; - let handle = self.state.session.start_command(request).await?; + let handle = self.inner().start_command(request).await?; self.state .active_commands .lock() @@ -329,7 +340,7 @@ impl WorkdirSession for RoutedWorkdirSession { async fn command_status(&self, handle: CommandHandle) -> Result { let _active = self.state.enter()?; - let status = self.state.session.command_status(handle.clone()).await?; + let status = self.inner().command_status(handle.clone()).await?; self.state.mark_terminal(&handle, status); Ok(status) } @@ -340,7 +351,7 @@ impl WorkdirSession for RoutedWorkdirSession { ) -> Result { let _active = self.state.enter()?; let handle = request.handle.clone(); - let output = self.state.session.command_output(request).await?; + let output = self.inner().command_output(request).await?; if let Some(terminal) = &output.terminal { self.state.mark_terminal(&handle, terminal.status); } @@ -349,7 +360,7 @@ impl WorkdirSession for RoutedWorkdirSession { async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { let _active = self.state.enter()?; - self.state.session.cancel_command(handle.clone()).await?; + self.inner().cancel_command(handle.clone()).await?; self.state .active_commands .lock() @@ -359,11 +370,11 @@ impl WorkdirSession for RoutedWorkdirSession { } fn subscribe_command_events(&self) -> Option> { - self.state.session.subscribe_command_events() + self.inner().subscribe_command_events() } fn command_snapshot(&self) -> Vec { - let snapshots = self.state.session.command_snapshot(); + let snapshots = self.inner().command_snapshot(); self.state.reconcile_terminal_commands(&snapshots); snapshots } @@ -374,7 +385,7 @@ impl WorkdirSession for RoutedWorkdirSession { "Workdir attachment has active operations or commands".to_string(), )); } - self.state.session.close().await + self.inner().close().await } } @@ -387,6 +398,7 @@ impl WorkdirSession for RoutedWorkdirSession { pub struct WorkdirSessionRouter { sessions: RwLock>>, next_generation: AtomicU64, + coordinate_brokers: RwLock>, } impl WorkdirSessionRouter { @@ -473,9 +485,9 @@ impl WorkdirSessionRouter { } }; Ok(ResolvedWorkdirSession { + session: self.routed(&selected.0, selected.1.clone()), alias: selected.0, generation: selected.1.generation, - session: Arc::new(RoutedWorkdirSession { state: selected.1 }), }) } @@ -483,13 +495,22 @@ impl WorkdirSessionRouter { &self, alias: WorkdirAttachmentAlias, session: WorkdirSessionHandle, + ) -> Result { + self.attach_with_cwd(alias, session, None) + } + + fn attach_with_cwd( + &self, + alias: WorkdirAttachmentAlias, + session: WorkdirSessionHandle, + cwd: Option, ) -> Result { let mut sessions = self.sessions.write().map_err(|_| { - WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string()) + WorkdirError::Unavailable("Workdir attachment router is poisoned".into()) })?; if sessions.contains_key(&alias) { return Err(WorkdirError::Conflict(format!( - "Workdir attachment alias `{alias}` already exists" + "Workdir attachment alias \u{0060}{alias}\u{0060} already exists" ))); } let state = Arc::new(RoutedSessionState { @@ -499,8 +520,61 @@ impl WorkdirSessionRouter { active_operations: AtomicUsize::new(0), active_commands: Mutex::new(HashSet::new()), }); + let view = if let Some(cwd) = cwd { + let source = Arc::new(RoutedWorkdirSession { + state: state.clone(), + view: None, + }); + let broker = crate::WorkdirToolBroker::at_cwd(source, cwd)?; + let view = broker.tool_session(); + self.coordinate_brokers + .write() + .map_err(|_| { + WorkdirError::Unavailable("Workdir coordinate broker map is poisoned".into()) + })? + .insert(alias.clone(), (state.generation, broker)); + Some(view) + } else { + None + }; sessions.insert(alias, state.clone()); - Ok(Arc::new(RoutedWorkdirSession { state })) + Ok(Arc::new(RoutedWorkdirSession { state, view })) + } + + fn routed( + &self, + alias: &WorkdirAttachmentAlias, + state: Arc, + ) -> Arc { + let view = self + .coordinate_broker(alias, state.generation) + .map(|broker| broker.tool_session()); + Arc::new(RoutedWorkdirSession { state, view }) + } + + pub(crate) fn coordinate_broker( + &self, + alias: &WorkdirAttachmentAlias, + generation: u64, + ) -> Option { + self.coordinate_brokers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(alias) + .filter(|(current, _)| *current == generation) + .map(|(_, broker)| broker.clone()) + } + + /// Bind tool coordinates once while retaining root-coordinate delegation + /// and provider authorization. Both views share the route's detach fence. + pub fn attach_at_cwd( + &self, + alias: WorkdirAttachmentAlias, + source: WorkdirSessionHandle, + cwd: crate::WorkdirPath, + ) -> Result { + let cwd = crate::WorkdirPath::new(cwd.as_str())?; + self.attach_with_cwd(alias, source, Some(cwd)) } pub fn session(&self, alias: &WorkdirAttachmentAlias) -> Option { @@ -509,7 +583,7 @@ impl WorkdirSessionRouter { .ok()? .get(alias) .cloned() - .map(|state| Arc::new(RoutedWorkdirSession { state }) as WorkdirSessionHandle) + .map(|state| self.routed(alias, state) as WorkdirSessionHandle) } /// Compatibility projection used only while filesystem tool schemas have no @@ -521,10 +595,8 @@ impl WorkdirSessionRouter { return None; } sessions - .values() - .next() - .cloned() - .map(|state| Arc::new(RoutedWorkdirSession { state }) as WorkdirSessionHandle) + .first_key_value() + .map(|(alias, state)| self.routed(alias, state.clone()) as WorkdirSessionHandle) } pub fn can_detach(&self, alias: &WorkdirAttachmentAlias) -> Result<(), WorkdirError> { @@ -620,6 +692,10 @@ impl WorkdirSessionRouter { .is_some_and(|current| Arc::ptr_eq(current, &state)) { sessions.remove(alias); + self.coordinate_brokers + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(alias); } Ok(state.session.clone()) } @@ -661,6 +737,10 @@ impl WorkdirSessionRouter { .is_some_and(|current| Arc::ptr_eq(current, &state)) { sessions.remove(&alias); + self.coordinate_brokers + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&alias); } } Ok(()) @@ -684,6 +764,131 @@ mod tests { )) } + #[tokio::test] + async fn cwd_views_share_root_delegation_leases_and_attachment_fences() { + use crate::{ + WorkdirPath as P, WorkdirToolBrokerRouter, WorkdirToolScope, + WorkdirToolScopePermission as Permission, WorkdirToolScopeRule, + }; + let root = TempDir::new().unwrap(); + for directory in ["work", "other", "other/inside"] { + std::fs::create_dir_all(root.path().join(directory)).unwrap(); + std::fs::write(root.path().join(directory).join("marker"), directory).unwrap(); + } + let router = Arc::new(WorkdirSessionRouter::new()); + let alias = WorkdirAttachmentAlias::new("source").unwrap(); + let parent = router + .attach_at_cwd(alias.clone(), session("wd", &root), P::new("work").unwrap()) + .unwrap(); + assert_eq!(parent.environment().cwd, P::new("work").unwrap()); + let request = |cwd: &str| WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: P::new(cwd).unwrap(), + permission: Permission::Write, + recursive: true, + symlink_policy: Default::default(), + }], + cwd: P::new(cwd).unwrap(), + command: false, + }; + let brokers = WorkdirToolBrokerRouter::new(router.clone()); + // Declarations stay root-relative, not work/other. + let child = brokers + .scope(vec![(alias.clone(), request("other"))]) + .await + .unwrap(); + let grandchild = child + .broker_router() + .scope(vec![(alias.clone(), request("other/inside"))]) + .await + .unwrap(); + for (session, cwd) in [ + (parent.clone(), "work"), + ( + child.session_router().resolve(None).unwrap().session, + "other", + ), + ( + grandchild.session_router().resolve(None).unwrap().session, + "other/inside", + ), + ] { + assert_eq!(session.environment().cwd, P::new(cwd).unwrap()); + let result = session + .read(ReadRequest { + path: P::new("marker").unwrap(), + offset: 0, + limit: 10, + max_bytes: 100, + }) + .await + .unwrap(); + assert_eq!(result.bytes, cwd.as_bytes()); + assert_eq!(result.path, P::new("marker").unwrap()); + assert_eq!( + session + .stat(StatRequest { + path: P::new("marker").unwrap() + }) + .await + .unwrap() + .path, + P::new("marker").unwrap() + ); + } + // The model's tool view and delegation view share write-lease exclusion. + let absolute = P::new_scoped(root.path().join("other/marker").to_str().unwrap()).unwrap(); + let denied = parent + .write(WriteRequest { + path: absolute, + content: b"forbidden".to_vec(), + expected_hash: None, + }) + .await + .unwrap_err(); + assert_eq!( + denied.denial_reason(), + Some(crate::WorkdirDenialReason::ChildWriteLeaseConflict) + ); + let old_child = child.session_router().resolve(None).unwrap().session; + router.begin_detach(&alias).await.unwrap(); + assert!( + old_child + .read(ReadRequest { + path: P::new("marker").unwrap(), + offset: 0, + limit: 10, + max_bytes: 100 + }) + .await + .is_err() + ); + router.cancel_detach(&alias); + grandchild.close().await.unwrap(); + child.close().await.unwrap(); + router.detach(&alias).await.unwrap(); + router + .attach_at_cwd( + alias, + session("replacement", &root), + P::new("other").unwrap(), + ) + .unwrap(); + assert_eq!( + router.resolve(None).unwrap().session.environment().cwd, + P::new("other").unwrap() + ); + assert!( + old_child + .stat(StatRequest { + path: P::new("marker").unwrap() + }) + .await + .is_err() + ); + router.close_all().await.unwrap(); + } + #[tokio::test] async fn checkout_routes_use_active_operation_detach_fence_and_old_handles_stay_closed() { let root = TempDir::new().unwrap(); diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index 9a54e818c..993102076 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -108,17 +108,34 @@ impl WorkdirToolBroker { Self::with_capabilities(source, capabilities) } + /// Set the tool coordinate base without changing provider authority. + /// The input is relative to the source session's coordinates; delegation + /// declarations retain their existing coordinate contract. + pub fn at_cwd(source: WorkdirSessionHandle, cwd: FsPath) -> Result { + let cwd = FsPath::new(cwd.as_str())?; + let capabilities = source.capabilities(); + Ok(Self::with_capabilities_and_cwd(source, capabilities, cwd)) + } + /// Own a session behind an additional Backend-authored capability ceiling. /// The ceiling can only attenuate provider capabilities; it can never add one. pub fn with_capabilities( source: WorkdirSessionHandle, capabilities: WorkdirSessionCapabilities, + ) -> Self { + Self::with_capabilities_and_cwd(source, capabilities, FsPath::root()) + } + + fn with_capabilities_and_cwd( + source: WorkdirSessionHandle, + capabilities: WorkdirSessionCapabilities, + cwd: FsPath, ) -> Self { let capabilities = source.capabilities().intersection(capabilities); let (command_events, _) = broadcast::channel(64); let authority = Arc::new(ScopedWorkdirSession { source, - cwd: FsPath::new("").expect("empty Workdir path is valid"), + cwd, scope: None, capabilities, validity: SessionValidity::root(), @@ -416,6 +433,9 @@ impl WorkdirToolBrokerRouter { .sessions .resolve(Some(alias.as_str())) .map_err(|error| WorkdirError::InvalidArgument(error.to_string()))?; + if let Some(broker) = self.sessions.coordinate_broker(alias, selected.generation) { + return Ok(broker); + } let mut brokers = self.brokers.lock().map_err(|_| { WorkdirError::Unavailable("Workdir tool broker router is poisoned".to_string()) })?; @@ -684,7 +704,7 @@ impl ScopedWorkdirSession { } fn resolve_path(&self, path: &FsPath) -> Result { - if self.cwd.as_str().is_empty() { + if self.cwd.as_str().is_empty() || Path::new(path.as_str()).is_absolute() { return Ok(path.clone()); } let joined = Path::new(self.cwd.as_str()).join(path.as_str()); @@ -1085,6 +1105,43 @@ impl WorkdirSession for ScopedWorkdirSession { self.capabilities } + fn environment(&self) -> crate::WorkdirSessionEnvironment { + let mut environment = self.source.environment(); + let source_cwd = environment.cwd.clone(); + environment.cwd = FsPath::new( + Path::new(source_cwd.as_str()) + .join(self.cwd.as_str()) + .to_str() + .expect("validated UTF-8 paths"), + ) + .expect("validated session cwd"); + if let Some(scope) = &self.scope { + if let Some(inherited) = environment.scope.take() { + environment + .restrictions + .push(format!("Provider allow ceiling: {inherited:?}")); + } + environment.scope = Some( + scope + .iter() + .cloned() + .map(|mut rule| { + rule.target = FsPath::new( + Path::new(source_cwd.as_str()) + .join(rule.target.as_str()) + .to_str() + .expect("validated UTF-8 paths"), + ) + .expect("validated scope target"); + rule + }) + .collect(), + ); + } + environment.capabilities = self.capabilities(); + environment + } + async fn authorize_scope_path( &self, mut request: WorkdirScopeAuthorizationRequest, @@ -1273,8 +1330,11 @@ impl WorkdirSession for ScopedWorkdirSession { .resolve_operation_path(&request.path, WorkdirToolScopePermission::Read) .await?; self.ensure_read(&path, WorkdirSessionCapability::Read)?; + let caller_path = request.path.clone(); request.path = path; - self.source.stat(request).await + let mut result = self.source.stat(request).await?; + result.path = caller_path; + Ok(result) } async fn read(&self, mut request: ReadRequest) -> Result { @@ -1282,8 +1342,11 @@ impl WorkdirSession for ScopedWorkdirSession { .resolve_operation_path(&request.path, WorkdirToolScopePermission::Read) .await?; self.ensure_read(&path, WorkdirSessionCapability::Read)?; + let caller_path = request.path.clone(); request.path = path; - self.source.read(request).await + let mut result = self.source.read(request).await?; + result.path = caller_path; + Ok(result) } async fn read_bytes( @@ -1422,9 +1485,10 @@ impl WorkdirSession for ScopedWorkdirSession { ))); } let tool_call_id = request.tool_call_id.clone(); - if self.scope.is_some() { - request.cwd = self.resolve_path(&request.cwd)?; - } + request.cwd = self + .resolve_operation_path(&request.cwd, WorkdirToolScopePermission::Read) + .await?; + self.ensure_path(&request.cwd, WorkdirToolScopePermission::Read)?; if let Some(tool_call_id) = &tool_call_id { self.starting_tool_calls .lock() @@ -1601,7 +1665,20 @@ impl WorkdirSession for ReadOnlyWorkdirSession { } fn capabilities(&self) -> WorkdirSessionCapabilities { - WorkdirSessionCapabilities::READ_ONLY + self.inner + .capabilities() + .intersection(WorkdirSessionCapabilities::READ_ONLY) + } + + fn environment(&self) -> crate::WorkdirSessionEnvironment { + let mut environment = self.inner.environment(); + environment.capabilities = self.capabilities(); + if let Some(scope) = &mut environment.scope { + for rule in scope { + rule.permission = WorkdirToolScopePermission::Read; + } + } + environment } async fn authorize_scope_path( diff --git a/crates/workdir/tests/remote_denial_reasons.rs b/crates/workdir/tests/remote_denial_reasons.rs index 6769fb136..2dceead9c 100644 --- a/crates/workdir/tests/remote_denial_reasons.rs +++ b/crates/workdir/tests/remote_denial_reasons.rs @@ -14,6 +14,18 @@ use workdir::{ }, }; +fn opened(workdir_id: &str) -> String { + serde_json::to_string(&workdir::http::OpenWorkdirSessionResponse { + session_id: workdir::http::WorkdirSessionId::new("s").unwrap(), + workdir_id: Workdir::new(workdir_id).id().clone(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, + environment: workdir::WorkdirSessionEnvironment::root( + workdir::WorkdirSessionCapabilities::ALL, + ), + }) + .unwrap() +} + fn reply(listener: &TcpListener, status: &str, body: &str) { listener.set_nonblocking(true).unwrap(); let deadline = Instant::now() + Duration::from_secs(5); @@ -73,11 +85,7 @@ async fn remote_http_denials_forward_only_typed_reasons_and_old_missing_reason() error["denial_reason"] = serde_json::to_value(reason).unwrap(); } let server = thread::spawn(move || { - reply( - &listener, - "200 OK", - r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#, - ); + reply(&listener, "200 OK", &opened("w")); reply(&listener, "403 Forbidden", &error.to_string()); }); let remote = RemoteWorkdirSession::open( @@ -163,11 +171,7 @@ async fn remote_checkout_diagnostics_preserve_mutation_outcome_and_read_failures let address = listener.local_addr().unwrap(); let error = serde_json::json!({"code":code, "message":"/secret/host provider-secret", "denial_reason":reason}); let server = thread::spawn(move || { - reply( - &listener, - "200 OK", - r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#, - ); + reply(&listener, "200 OK", &opened("w")); reply( &listener, &format!("{} Fixture failure", code.http_status()), @@ -230,11 +234,11 @@ async fn mismatched_http_results_keep_cause_and_mutation_uncertainty() { reply( &listener, "200 OK", - if case == "identity" { - r#"{"session_id":"s","workdir_id":"wrong-secret","capabilities":{"bits":63}}"# + &opened(if case == "identity" { + "wrong-secret" } else { - r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"# - }, + "w" + }), ); if case != "identity" { let result = if case == "bytes" { diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 1a6aeefe9..5eb0024bb 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -869,6 +869,7 @@ async fn open_workdir_session( session_id: session_id.clone(), workdir_id: session.workdir().id().clone(), capabilities: session.capabilities(), + environment: session.environment(), }; state .workdir_sessions diff --git a/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs b/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs index fb4c3ffd7..cdaa8ce40 100644 --- a/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs +++ b/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs @@ -111,6 +111,9 @@ impl Harness { session_id: WorkdirSessionId::new("session-transport").unwrap(), workdir_id: Workdir::new("wd-transport").id().clone(), capabilities: WorkdirSessionCapabilities::ALL, + environment: workdir::WorkdirSessionEnvironment::root( + WorkdirSessionCapabilities::ALL, + ), }) }), ) diff --git a/crates/worker-runtime/src/runtime/workdir_session.rs b/crates/worker-runtime/src/runtime/workdir_session.rs index 5a3249c0b..b792557e5 100644 --- a/crates/worker-runtime/src/runtime/workdir_session.rs +++ b/crates/worker-runtime/src/runtime/workdir_session.rs @@ -55,6 +55,10 @@ impl WorkdirSession for RetainedWorkdirSession { self.inner.capabilities() } + fn environment(&self) -> workdir::WorkdirSessionEnvironment { + self.inner.environment() + } + async fn authorize_scope_path( &self, request: WorkdirScopeAuthorizationRequest, diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 9947ce5b0..a67fd9d6a 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -1127,8 +1127,17 @@ fn runtime_local_workdir_router( ), None => source, }; + let cwd = binding + .cwd() + .strip_prefix(binding.root()) + .map_err(|_| format!("Workdir attachment `{alias}` cwd is outside its logical root"))?; + let cwd = workdir::WorkdirPath::new( + cwd.to_str() + .ok_or_else(|| format!("Workdir attachment `{alias}` cwd is not UTF-8"))?, + ) + .map_err(|error| error.to_string())?; router - .attach(alias.clone(), source) + .attach_at_cwd(alias.clone(), source, cwd) .map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?; } Ok(router) @@ -10550,6 +10559,60 @@ mod tests { assert!(!binding.working_directory_root().exists()); } + #[cfg(target_os = "linux")] + #[tokio::test] + async fn local_attachment_router_keeps_each_binding_cwd_and_capability_ceiling() { + let runtime_base = tempfile::tempdir().unwrap(); + let repo = create_clean_repo(); + let materializer = RuntimeGitMaterializer::new(runtime_base.path()); + let root_binding = materializer + .create(&working_directory_request(repo.path())) + .unwrap(); + std::fs::create_dir(root_binding.root().join("nested")).unwrap(); + std::fs::write(root_binding.root().join("marker"), "root").unwrap(); + std::fs::write(root_binding.root().join("nested/marker"), "nested").unwrap(); + let mut nested_binding = root_binding.clone(); + nested_binding.cwd = root_binding.root().join("nested"); + let root_alias = WorkdirAttachmentAlias::new("root").unwrap(); + let nested_alias = WorkdirAttachmentAlias::new("nested").unwrap(); + let attachments = BTreeMap::from([ + (root_alias.clone(), root_binding), + (nested_alias.clone(), nested_binding), + ]); + let ceilings = BTreeMap::from([ + (root_alias, WorkdirSessionCapabilities::ALL), + (nested_alias, WorkdirSessionCapabilities::READ_ONLY), + ]); + let router = runtime_local_workdir_router(&attachments, &ceilings, None).unwrap(); + assert!(router.resolve(None).is_err()); + for (alias, cwd, content, command) in [ + ("root", "", "root", true), + ("nested", "nested", "nested", false), + ] { + let selected = router.resolve(Some(alias)).unwrap().session; + let environment = selected.environment(); + assert_eq!(environment.cwd.as_str(), cwd); + assert_eq!( + environment + .capabilities + .supports(workdir::WorkdirSessionCapability::Command), + command + ); + let read = selected + .read(workdir::ReadRequest { + path: workdir::WorkdirPath::new("marker").unwrap(), + offset: 0, + limit: 10, + max_bytes: 100, + }) + .await + .unwrap(); + assert_eq!(read.bytes, content.as_bytes()); + assert_eq!(read.path.as_str(), "marker"); + } + router.close_all().await.unwrap(); + } + #[test] fn spawn_failure_with_existing_working_directory_preserves_workdir() { let runtime_base = tempfile::tempdir().unwrap(); diff --git a/crates/worker-runtime/src/worker_backend/deferred_workdir.rs b/crates/worker-runtime/src/worker_backend/deferred_workdir.rs index 9bcb3962d..02b104efd 100644 --- a/crates/worker-runtime/src/worker_backend/deferred_workdir.rs +++ b/crates/worker-runtime/src/worker_backend/deferred_workdir.rs @@ -124,6 +124,18 @@ macro_rules! impl_deferred_session { self.capabilities } + fn environment(&self) -> WorkdirSessionEnvironment { + let mut environment = self.state.try_lock().ok().and_then(|state| { + state.session.as_ref().map(|session| session.environment()) + }).unwrap_or_else(|| { + let mut environment = WorkdirSessionEnvironment::root(self.capabilities); + environment.restrictions.push("Deferred provider: scope is checked on connection and every operation".into()); + environment + }); + environment.capabilities = self.capabilities; + environment + } + $( async fn $method(&self, $request: $request_type) -> Result<$result, WorkdirError> { self.session().await?.$method($request).await diff --git a/crates/worker/src/authorized_workdir.rs b/crates/worker/src/authorized_workdir.rs index 6967f0db0..27fa85c1d 100644 --- a/crates/worker/src/authorized_workdir.rs +++ b/crates/worker/src/authorized_workdir.rs @@ -206,26 +206,36 @@ impl AuthorizedWorkdirSession { .map(CheckoutSearchResult::Grep), }; }; - scoped - .authorize_scope_path(WorkdirScopeAuthorizationRequest { - rules: vec![WorkdirToolScopeRule { - target: WorkdirPath::root(), - ..rule.clone() - }], - path: relative_path(&root, operation.path())?, - permission: WorkdirToolScopePermission::Read, - }) - .await?; - // Lease cwd rebases paths. Use the same provider scoped-traversal contract - // with attachment output_root instead, retaining typed paths, cursors and - // Grep's rendered filenames without parsing or post-filtering output. - let mut request = CheckoutSearchRequest::new(operation); - request.scope_layers.push(vec![rule]); - self.session.checkout_search(request).await + self.search_with_scope(scoped, root, rule, CheckoutSearchRequest::new(operation)) + .await }) .await } + async fn search_with_scope( + &self, + scoped: WorkdirSessionHandle, + root: WorkdirPath, + rule: WorkdirToolScopeRule, + mut request: CheckoutSearchRequest, + ) -> Result { + scoped + .authorize_scope_path(WorkdirScopeAuthorizationRequest { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::root(), + ..rule.clone() + }], + path: relative_path(&root, request.operation.path())?, + permission: WorkdirToolScopePermission::Read, + }) + .await?; + // Preserve the caller's coordinate root, cursors and all existing + // attenuation layers. The provider applies the Host ceiling during + // traversal, not by filtering already-rendered filenames afterward. + request.scope_layers.push(vec![rule]); + self.session.checkout_search(request).await + } + fn authorize_command(&self) -> Result<(), WorkdirError> { // cwd is not a sandbox: arbitrary shell can access the entire attachment. self.authorize(COMMAND, &WorkdirPath::root(), true) @@ -261,6 +271,12 @@ impl WorkdirSession for AuthorizedWorkdirSession { self.session.capabilities() } + fn environment(&self) -> workdir::WorkdirSessionEnvironment { + let mut environment = self.session.environment(); + environment.capabilities = environment.capabilities.intersection(self.capabilities()); + environment + } + async fn authorize_scope_path( &self, mut request: WorkdirScopeAuthorizationRequest, @@ -296,8 +312,14 @@ impl WorkdirSession for AuthorizedWorkdirSession { &self, request: CheckoutSearchRequest, ) -> Result { - self.require_legacy(READ)?; - self.session.checkout_search(request).await + let path = request.operation.path().clone(); + self.with_authorized(READ, &path, true, |scoped, root, rule| async move { + match rule { + Some(rule) => self.search_with_scope(scoped, root, rule, request).await, + None => self.session.checkout_search(request).await, + } + }) + .await } async fn checkout_observe( @@ -490,6 +512,8 @@ mod tests { //! Component tests connect this adapter to the real LocalWorkdirSession. //! Only the Host authorization transport/policy is replaced; these are not E2E tests. + mod environment; + use super::*; use crate::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; use server_api::authorization::ScopedPermission; @@ -842,18 +866,6 @@ mod tests { .await, Err(WorkdirError::Denied(_)) )); - assert!(matches!( - session - .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( - ListRequest { - path: WorkdirPath::root(), - after: None, - limit: 10, - } - ))) - .await, - Err(WorkdirError::Denied(_)) - )); let rule = WorkdirToolScopeRule { target: WorkdirPath::root(), permission: WorkdirToolScopePermission::Read, diff --git a/crates/worker/src/authorized_workdir/tests/environment.rs b/crates/worker/src/authorized_workdir/tests/environment.rs new file mode 100644 index 000000000..95ad94f92 --- /dev/null +++ b/crates/worker/src/authorized_workdir/tests/environment.rs @@ -0,0 +1,198 @@ +//! Normal lib tests connecting live Host-grant checks to real provider traversal +//! and router cwd projection. Only the Host transport is replaced by the parent +//! module's grant fixture; no commands, network or ambient Workspace state are used. + +use super::*; + +#[tokio::test] +async fn authorization_wrapper_preserves_inner_environment_and_capability_ceiling() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + let provider = Arc::new(LocalWorkdirSession::materialized( + root.path().to_path_buf(), + root.path().to_path_buf(), + manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::READ_ONLY, + )); + let broker = WorkdirToolBroker::at_cwd(provider, WorkdirPath::new("src").unwrap()).unwrap(); + let inner = broker.tool_session(); + let expected = inner.environment(); + let client = AuthorizationClient::new(vec![permission(READ, "src", true)]); + let wrapped = authorized_workdir_session(inner, client.clone(), "attachment-id".into()); + assert_eq!(wrapped.environment(), expected); + assert_eq!(wrapped.environment().cwd.as_str(), "src"); + assert_eq!( + wrapped.environment().capabilities, + WorkdirSessionCapabilities::READ_ONLY + ); + assert!( + client.requests.lock().unwrap().is_empty(), + "environment is metadata, not a cached grant decision" + ); + wrapped.close().await.unwrap(); +} + +#[tokio::test] +async fn nonroot_routed_search_retains_host_grants_output_coordinates_and_cursors() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + std::fs::create_dir(root.path().join("private")).unwrap(); + for name in ["a.txt", "b.txt"] { + std::fs::write(root.path().join("src").join(name), "needle public marker").unwrap(); + } + std::fs::write( + root.path().join("private/secret.txt"), + "needle private marker", + ) + .unwrap(); + let client = AuthorizationClient::new(vec![permission(READ, "src", true)]); + let authorized = session(&root, client.clone()); + let router = workdir::WorkdirSessionRouter::new(); + let alias = workdir::WorkdirAttachmentAlias::new("checkout").unwrap(); + router + .attach_at_cwd(alias.clone(), authorized, WorkdirPath::new("src").unwrap()) + .unwrap(); + let routed = router.resolve(Some("checkout")).unwrap().session; + assert_eq!(routed.environment().cwd.as_str(), "src"); + let paths = routed + .glob(GlobRequest { + path: WorkdirPath::root(), + pattern: "**/*.txt".into(), + limit: 100, + }) + .await + .unwrap() + .paths; + assert_eq!( + paths, + vec![ + WorkdirPath::new("a.txt").unwrap(), + WorkdirPath::new("b.txt").unwrap() + ] + ); + // Search output is reusable as a path without adding src/ at the caller. + assert_eq!( + routed.read(read(paths[0].as_str())).await.unwrap().bytes, + b"needle public marker" + ); + for output_mode in [ + workdir::GrepOutputMode::Content, + workdir::GrepOutputMode::FilesWithMatches, + workdir::GrepOutputMode::Count, + ] { + let result = routed + .grep(GrepRequest { + path: WorkdirPath::root(), + pattern: "needle".into(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode, + limit: 100, + offset: 0, + }) + .await + .unwrap(); + let mut grep_paths = result.paths; + grep_paths.sort_by(|left, right| left.as_str().cmp(right.as_str())); + assert_eq!(grep_paths, paths); + assert!(result.output.contains("a.txt")); + assert!(!result.output.contains("src/")); + assert!(!result.output.contains("secret")); + } + let first = routed + .list(ListRequest { + path: WorkdirPath::root(), + after: None, + limit: 1, + }) + .await + .unwrap(); + assert_eq!(first.entries[0].path.as_str(), "a.txt"); + assert_eq!(first.next_after.as_ref().unwrap().path.as_str(), "a.txt"); + let second = routed + .list(ListRequest { + path: WorkdirPath::root(), + after: first.next_after, + limit: 1, + }) + .await + .unwrap(); + assert_eq!(second.entries[0].path.as_str(), "b.txt"); + assert!(second.next_after.is_none()); + + // An existing caller ceiling must survive the extra Host layer. An empty + // layer is invalid and cannot silently turn into the broader Host grant. + let mut denied = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest { + path: WorkdirPath::root(), + pattern: "**/*.txt".into(), + limit: 100, + })); + denied.scope_layers.push(Vec::new()); + assert!(routed.checkout_search(denied).await.is_err()); + assert!(client.requests.lock().unwrap().iter().all(|request| { + matches!(&request.resource, PermissionResource::Workdir { path, .. } if path == "src" || path == "src/a.txt") + })); + client.revoked.store(true, Ordering::SeqCst); + assert!( + routed + .glob(GlobRequest { + path: WorkdirPath::root(), + pattern: "**/*.txt".into(), + limit: 100, + }) + .await + .is_err() + ); + router.detach(&alias).await.unwrap(); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn scoped_checkout_search_preserves_caller_layer_and_rejects_resolved_escape() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("src/nested")).unwrap(); + std::fs::create_dir(root.path().join("private")).unwrap(); + std::fs::write(root.path().join("src/public.txt"), "needle public").unwrap(); + std::fs::write(root.path().join("src/nested/hidden.txt"), "needle hidden").unwrap(); + std::fs::write(root.path().join("private/secret.txt"), "needle secret").unwrap(); + std::os::unix::fs::symlink("../private", root.path().join("src/escape")).unwrap(); + std::os::unix::fs::symlink("../private/secret.txt", root.path().join("src/alias.txt")).unwrap(); + let client = AuthorizationClient::new(vec![permission(READ, "src", true)]); + let authorized = session(&root, client); + let request = CheckoutSearchRequest { + operation: CheckoutSearchOperation::Glob(GlobRequest { + path: WorkdirPath::new("src").unwrap(), + pattern: "**/*.txt".into(), + limit: 100, + }), + output_root: WorkdirPath::new("src").unwrap(), + scope_layers: vec![vec![WorkdirToolScopeRule { + target: WorkdirPath::new("src").unwrap(), + recursive: false, + permission: WorkdirToolScopePermission::Read, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }]], + }; + let result = authorized.checkout_search(request).await.unwrap(); + let CheckoutSearchResult::Glob(result) = result else { + panic!("expected Glob result") + }; + assert_eq!(result.paths, vec![WorkdirPath::new("public.txt").unwrap()]); + assert!( + authorized + .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob( + GlobRequest { + path: WorkdirPath::new("private").unwrap(), + pattern: "**/*.txt".into(), + limit: 100, + } + ))) + .await + .is_err() + ); + authorized.close().await.unwrap(); +} diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index 827a5b623..b1402b299 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -121,6 +121,7 @@ async fn open_session( session_id: WorkdirSessionId::new(SESSION).unwrap(), workdir_id: state.session.workdir().id().clone(), capabilities: state.session.capabilities(), + environment: state.session.environment(), }) .into_response() } @@ -251,6 +252,10 @@ impl Drop for HttpFixture { impl HttpFixture { async fn new(capabilities: WorkdirSessionCapabilities) -> Self { + Self::at_cwd(capabilities, "").await + } + + async fn at_cwd(capabilities: WorkdirSessionCapabilities, cwd: &str) -> Self { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join("src/deep")).unwrap(); std::fs::write( @@ -264,6 +269,13 @@ impl HttpFixture { manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()), capabilities, )); + let session: WorkdirSessionHandle = if cwd.is_empty() { + session + } else { + workdir::WorkdirToolBroker::at_cwd(session, WorkdirPath::new(cwd).unwrap()) + .unwrap() + .tool_session() + }; let state = Arc::new(ProviderState { session, requests: Mutex::new(Vec::new()), @@ -349,6 +361,31 @@ impl HttpFixture { } } +#[tokio::test] +async fn remote_open_keeps_provider_environment_and_nonroot_operation_coordinates() { + let fixture = HttpFixture::at_cwd(WorkdirSessionCapabilities::READ_ONLY, "src/deep").await; + let remote = fixture.open(TOKEN).await.unwrap(); + assert_eq!(remote.environment(), fixture.state.session.environment()); + assert_eq!(remote.environment().cwd.as_str(), "src/deep"); + assert_eq!( + remote.environment().capabilities, + WorkdirSessionCapabilities::READ_ONLY + ); + let result = remote + .read(workdir::ReadRequest { + path: WorkdirPath::new("a.txt").unwrap(), + offset: 0, + limit: 10, + max_bytes: 4096, + }) + .await + .unwrap(); + assert_eq!(result.bytes, b"first\nneedle needle\nlast\n"); + fixture.public_output(&serde_json::to_string(&remote.environment()).unwrap()); + remote.close().await.unwrap(); + fixture.assert_endpoints(); +} + fn native_runtime(remote: Arc) -> (WipRuntime, Arc) { let router = Arc::new(WorkdirSessionRouter::new()); router diff --git a/crates/worker/src/checkout_race_tests.rs b/crates/worker/src/checkout_race_tests.rs index 3e585db69..def2a253d 100644 --- a/crates/worker/src/checkout_race_tests.rs +++ b/crates/worker/src/checkout_race_tests.rs @@ -78,6 +78,12 @@ impl WorkdirSession for HookedSession { self.local.capabilities() } + fn environment(&self) -> workdir::WorkdirSessionEnvironment { + let mut environment = self.local.environment(); + environment.capabilities = environment.capabilities.intersection(self.capabilities()); + environment + } + async fn authorize_scope_path( &self, request: WorkdirScopeAuthorizationRequest, @@ -201,11 +207,10 @@ async fn checkout_wip_file_operations_share_core_and_keep_prior_read_boundary() let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("text.txt"), "旧 旧\n").unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); + let hooked = HookedSession::new(&dir); + assert_eq!(hooked.environment(), hooked.local.environment()); router - .attach( - WorkdirAttachmentAlias::new("main").unwrap(), - HookedSession::new(&dir), - ) + .attach(WorkdirAttachmentAlias::new("main").unwrap(), hooked) .unwrap(); let provider = provider(router); let route = format!("{}/text.txt", checkout_root("main")); diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index d8f2302f2..9158821de 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -226,6 +226,7 @@ pub struct WorkspaceAttachedWorkdirSession { target_workdir: String, workdir: Workdir, capabilities: WorkdirSessionCapabilities, + environment: workdir::WorkdirSessionEnvironment, } impl WorkspaceAttachedWorkdirSession { @@ -249,17 +250,38 @@ impl WorkspaceAttachedWorkdirSession { ) } + /// Bind a known root-coordinate attachment. Opened provider sessions must + /// instead pass their metadata to [`Self::handle_for_workdir_with_environment`]. pub fn handle_for_workdir_with_capabilities( client: Arc, target_workdir: impl Into, workdir_id: impl Into, capabilities: WorkdirSessionCapabilities, + ) -> WorkdirSessionHandle { + Self::handle_for_workdir_with_environment( + client, + target_workdir, + workdir_id, + capabilities, + workdir::WorkdirSessionEnvironment::root(capabilities), + ) + } + + /// Bind provider-authored coordinates without allowing metadata to expand + /// the Backend attachment capability ceiling. + pub fn handle_for_workdir_with_environment( + client: Arc, + target_workdir: impl Into, + workdir_id: impl Into, + capabilities: WorkdirSessionCapabilities, + environment: workdir::WorkdirSessionEnvironment, ) -> WorkdirSessionHandle { Arc::new(Self { client, target_workdir: target_workdir.into(), workdir: Workdir::new(workdir_id), - capabilities, + capabilities: capabilities.intersection(environment.capabilities), + environment, }) } @@ -362,6 +384,12 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { self.capabilities } + fn environment(&self) -> workdir::WorkdirSessionEnvironment { + let mut environment = self.environment.clone(); + environment.capabilities = environment.capabilities.intersection(self.capabilities()); + environment + } + async fn authorize_scope_path( &self, request: WorkdirScopeAuthorizationRequest, @@ -684,11 +712,12 @@ impl WorkspaceHttpWorkdirBackend { let response = self.attach_response(alias, working_directory_id)?; if let Err(error) = self.session_router.attach( alias_key.clone(), - WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities( + WorkspaceAttachedWorkdirSession::handle_for_workdir_with_environment( self.client.clone(), alias, working_directory_id, - response.capabilities, + response.attachment.capabilities, + response.environment.clone(), ), ) { let _ = self.detach(alias); @@ -706,9 +735,9 @@ impl WorkspaceHttpWorkdirBackend { &self, alias: &str, working_directory_id: &str, - ) -> Result { + ) -> Result { let workspace_id = encode_path_segment(self.workspace_id()?); - self.execute_json::(WorkspaceRequest::json( + self.execute_json::(WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!("/api/w/{workspace_id}/workers/self/workdir-attachments"), serde_json::to_string(&WorkdirAttachRequest { @@ -1192,6 +1221,13 @@ struct WorkdirAttachmentResponse { attached: bool, } +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +struct WorkdirSessionAttachmentResponse { + #[serde(flatten)] + attachment: WorkdirAttachmentResponse, + environment: workdir::WorkdirSessionEnvironment, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct WorkdirDeleteInput { @@ -1502,6 +1538,7 @@ mod tests { "alias": "checkout", "working_directory_id": "wd-created", "capabilities": {"bits": 63}, + "environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL), "attached": true })), response(json!({ @@ -2004,6 +2041,7 @@ mod tests { "alias": "checkout", "working_directory_id": "wd-attached", "capabilities": {"bits": 63}, + "environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL), "attached": true })), response(json!({ @@ -2063,16 +2101,29 @@ mod tests { ); } - #[test] - fn attach_propagates_provider_read_only_capabilities_to_the_alias_session() { + #[tokio::test] + async fn attach_projects_provider_environment_without_expanding_capability_ceiling() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("src")).unwrap(); + let provider = Arc::new(workdir::LocalWorkdirSession::materialized( + dir.path().to_path_buf(), + dir.path().to_path_buf(), + manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()), + WorkdirSessionCapabilities::ALL, + )); + let broker = + workdir::WorkdirToolBroker::at_cwd(provider, workdir::WorkdirPath::new("src").unwrap()) + .unwrap(); + let mut environment = broker.tool_session().environment(); let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ "workspace_id": "workspace/test", "alias": "external", "working_directory_id": "external-workdir", "capabilities": {"bits": 25}, + "environment": environment, "attached": true }))])); - let backend = WorkspaceHttpWorkdirBackend::new(client); + let backend = WorkspaceHttpWorkdirBackend::new(client.clone()); backend .attach(WorkdirAttachInput { alias: "external".to_string(), @@ -2084,10 +2135,33 @@ mod tests { .session_router .session(&workdir::WorkdirAttachmentAlias::new("external").unwrap()) .unwrap(); + environment.capabilities = WorkdirSessionCapabilities::READ_ONLY; + assert_eq!(session.environment(), environment); assert_eq!( session.capabilities(), WorkdirSessionCapabilities::READ_ONLY ); + assert!(matches!( + session + .checkout_execute(workdir::CheckoutRequest { + target: workdir::WorkdirPath::root(), + validator: vec![1], + operation: workdir::CheckoutOperation::Create { + path: workdir::WorkdirPath::new("new.txt").unwrap(), + content: b"denied".to_vec(), + }, + }) + .await, + Err(WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Write + )) + )); + assert_eq!( + client.requests().len(), + 1, + "denied mutation must not reach the Backend" + ); + broker.tool_session().close().await.unwrap(); } #[tokio::test] @@ -2097,6 +2171,7 @@ mod tests { "alias": "checkout", "working_directory_id": "wd-attached", "capabilities": {"bits": 63}, + "environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL), "attached": true }))])); let reopen_calls = Arc::new(AtomicUsize::new(0)); diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip.rs b/crates/worker/src/feature/builtin/manage_workdir/wip.rs index 30cf06dc2..b76941f27 100644 --- a/crates/worker/src/feature/builtin/manage_workdir/wip.rs +++ b/crates/worker/src/feature/builtin/manage_workdir/wip.rs @@ -1253,7 +1253,7 @@ pub(crate) mod tests { state.generation += 1; let raw = json!({"alias":body["alias"], "working_directory_id":body["working_directory_id"], "connection_id":format!("connection-{}", state.generation), "capabilities":{"bits":63}}); state.attachments.push(raw); - json!({"workspace_id":"test-workspace", "alias":body["alias"], "working_directory_id":body["working_directory_id"], "capabilities":{"bits":63}, "attached":true}) + json!({"workspace_id":"test-workspace", "alias":body["alias"], "working_directory_id":body["working_directory_id"], "capabilities":{"bits":63}, "environment":workdir::WorkdirSessionEnvironment::root(workdir::WorkdirSessionCapabilities::ALL), "attached":true}) } (WorkspaceRequestMethod::Delete, p) if p.starts_with("workers/self/workdir-attachments/") => diff --git a/crates/worker/src/job.rs b/crates/worker/src/job.rs index 0837c51a6..d94454141 100644 --- a/crates/worker/src/job.rs +++ b/crates/worker/src/job.rs @@ -13,7 +13,6 @@ mod result; pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL}; -use std::borrow::Cow; use std::path::Path; use std::sync::Arc; @@ -154,7 +153,7 @@ fn prepare_job_from_registry_with_grant( )?; let mut system_prompt = template.render(&SystemPromptContext { now: chrono::Utc::now(), - cwd: Cow::Borrowed("(no filesystem)"), + workdir_sessions: &workdir::WorkdirSessionRouter::new(), language: &manifest.engine.language, scope: &scope, tool_names, diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index 17415fbc6..d5e31c2ee 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -7,13 +7,12 @@ //! eagerly syntax-checks it at Worker construction. The final system //! prompt is materialised exactly once just before the first LLM turn: //! the rendered body is appended with a fixed trailing section carrying -//! the Worker's `Scope` summary, (if present) the project's `AGENTS.md` -//! contents, resident memory sections, and conditional Worker-orchestration -//! guidance, then the whole string is handed to the Engine via -//! `set_system_prompt`. Subsequent turns and compactions reuse that +//! the Worker's `Scope` summary, actual Workdir session environments, (if +//! present) the project's `AGENTS.md` contents, resident memory sections, and +//! conditional Worker-orchestration guidance, then the whole string is handed +//! to the Engine via `set_system_prompt`. Subsequent turns and compactions reuse that //! materialised string verbatim. -use std::borrow::Cow; use std::collections::BTreeMap; use std::sync::Arc; @@ -105,7 +104,8 @@ impl std::fmt::Debug for SystemPromptTemplate { /// templates cannot drop them on the floor. pub struct SystemPromptContext<'a> { pub now: DateTime, - pub cwd: Cow<'a, str>, + /// The same session router used by the Worker's filesystem tools. + pub workdir_sessions: &'a workdir::WorkdirSessionRouter, /// Language policy exposed to instruction templates as `{{ language }}`. pub language: &'a str, pub scope: &'a Scope, @@ -140,7 +140,14 @@ impl<'a> SystemPromptContext<'a> { "datetime".into(), Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)), ); - root.insert("cwd".into(), Value::from(self.cwd.as_ref())); + // A singular template cwd is meaningful only with one attachment. Never + // substitute the local Profile/Memory discovery directory for a session cwd. + let cwd = self + .workdir_sessions + .only_session() + .map(|session| display_workdir_path(&session.environment().cwd).to_owned()) + .unwrap_or_else(|| "no single Workdir session cwd; see execution environment".into()); + root.insert("cwd".into(), Value::from(cwd)); root.insert("language".into(), Value::from(self.language)); root.insert( "tools".into(), @@ -255,6 +262,70 @@ fn exact_prompt_name(reference: &str) -> Option { } } +fn display_workdir_path(path: &workdir::WorkdirPath) -> &str { + if path.as_str().is_empty() { + "." + } else { + path.as_str() + } +} + +/// Project provider-owned session metadata, rather than reconstructing authority +/// from a manifest Scope or the directory used to discover Profiles and Memory. +pub(crate) fn workdir_environment_section( + router: &workdir::WorkdirSessionRouter, +) -> Result { + use workdir::WorkdirSessionCapability::{Command, Edit, Glob, Grep, Read, Write}; + + let mut environments = Vec::new(); + for alias in router.aliases() { + let resolved = router + .resolve(Some(alias.as_str())) + .map_err(|error| SystemPromptError::Render(error.to_string()))?; + let environment = resolved.session.environment(); + let capabilities: Vec<_> = [ + (Read, "read"), + (Write, "write"), + (Edit, "edit"), + (Glob, "glob"), + (Grep, "grep"), + (Command, "command"), + ] + .into_iter() + .filter_map(|(capability, name)| { + environment + .capabilities + .supports(capability) + .then_some(name) + }) + .collect(); + environments.push(serde_json::json!({ + "alias": alias.as_str(), + "cwd": display_workdir_path(&environment.cwd), + "scope": environment.scope, + "restrictions": environment.restrictions, + "capabilities": capabilities, + "command": environment.capabilities.supports(Command), + })); + } + let json = serde_json::to_string_pretty(&environments) + .map_err(|error| SystemPromptError::Render(error.to_string()))?; + Ok(format!( + "## Workdir execution environment\n\n\ + Select a Workdir by its alias (`target_workdir`); omission is allowed only with one attachment. \ + Relative filesystem operation paths are resolved from that selected session's cwd, not its Workdir root. \ + Paths returned by Glob/Grep use the same cwd-relative coordinates and can be passed unchanged to other filesystem tools. \ + Bash starts at the session cwd; an explicit relative cwd is resolved from it, and shell cd does not change the session cwd.\n\n\ + In the metadata below, cwd and relative scope targets are Workdir-root-relative (`.` or an empty path denotes the root); explicit absolute provider scope targets stay absolute. \ + A null scope means the provider's full available scope, not unrestricted host access; an empty scope grants no paths. \ + Restrictions retain additional provider or ancestor ceilings, including deny rules. \ + Capabilities and command availability are session ceilings, not new grants. \ + Resolved targets remain subject to scope, symlink and command authorization. \ + SubWorker scope declarations remain Workdir-root-relative; other Scope declarations and Profile/Memory roots retain their own meaning and are not rebased by session cwd.\n\n\ + ```json\n{json}\n```" + )) +} + /// Build the final system prompt by appending the fixed trailing /// section to `body`. The Rust side owns the layout (blank-line /// separators, trailing-whitespace trim); each section's header + body @@ -276,8 +347,12 @@ fn append_trailing_section( } out.push('\n'); - let boundaries = prompts.working_boundaries_section(&scope.summary())?; - out.push_str(boundaries.trim_end_matches(&['\n', ' '][..])); + if ctx.workdir_sessions.is_empty() { + let boundaries = prompts.working_boundaries_section(&scope.summary())?; + out.push_str(boundaries.trim_end_matches(&['\n', ' '][..])); + out.push('\n'); + } + out.push_str(&workdir_environment_section(ctx.workdir_sessions)?); out.push('\n'); if let Some(agents) = agents_md { out.push('\n'); @@ -392,7 +467,7 @@ mod tests { now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z") .unwrap() .with_timezone(&Utc), - cwd: Cow::Borrowed("."), + workdir_sessions: &workdir::WorkdirSessionRouter::new(), language: "en", scope: &scope, tool_names: metas.iter().map(|meta| meta.name.clone()).collect(), diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index b88952ea1..9ea143520 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -1107,6 +1107,8 @@ fn sub_worker_spawn_tool_impl( #[cfg(test)] mod tests { + mod environment; + use super::*; use manifest::{DelegationScope, Permission, Scope, SharedScope}; use std::pin::Pin; diff --git a/crates/worker/src/spawn/tool/tests/environment.rs b/crates/worker/src/spawn/tool/tests/environment.rs new file mode 100644 index 000000000..2af0a9788 --- /dev/null +++ b/crates/worker/src/spawn/tool/tests/environment.rs @@ -0,0 +1,629 @@ +//! Connection tests for model-visible environments and actual routed file operations. +//! Runs the production SubWorker spawn/broker/Worker/prompt/tool path with temporary +//! LocalWorkdirSession providers; only the external LLM is scripted. Selected by +//! the normal worker lib suite, without network credentials or live Workspace data. + +use super::*; +use agen::Item; +use serde_json::{Value, json}; +use workdir::{WorkdirSessionCapability, WorkdirSessionRouter}; + +const TASK: &str = "Read marker.txt from each attached Workdir."; + +fn prompt_environments(prompt: &str) -> Vec { + let section = prompt + .split_once("## Workdir execution environment\n") + .expect("Worker must supply the execution environment independently of task text") + .1; + let json = section.split_once("```json\n").unwrap().1; + serde_json::from_str(json.split_once("\n```").unwrap().0).unwrap() +} + +#[derive(Clone, Default)] +struct EnvironmentClient { + requests: Arc>>, + read_issued: Arc, +} + +#[async_trait] +impl LlmClient for EnvironmentClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + + async fn stream( + &self, + request: Request, + ) -> Result> + Send>>, ClientError> + { + let environments = prompt_environments(request.system_prompt.as_deref().unwrap()); + self.requests.lock().unwrap().push(request); + let mut events = Vec::new(); + if !self.read_issued.swap(true, Ordering::SeqCst) { + // Use only the model-visible aliases and the same relative path in + // every session. No test-side cwd prefix is passed to the Read tool. + for (index, environment) in environments.iter().enumerate() { + let alias = environment["alias"].as_str().unwrap(); + events.extend([ + LlmEvent::tool_use_start(index, format!("read-{alias}"), "Read"), + LlmEvent::tool_input_delta( + index, + json!({"target_workdir": alias, "file_path": "marker.txt"}).to_string(), + ), + LlmEvent::tool_use_stop(index), + ]); + } + } else { + events.extend([ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "read complete"), + LlmEvent::text_block_stop(0, None), + ]); + } + events.push(LlmEvent::Status(StatusEvent { + status: ResponseStatus::Completed, + })); + Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + } +} + +struct EnvironmentFixture { + runtime: TempDir, + root: PathBuf, + manifest: WorkerManifest, + profiles: AvailableProfiles, + registry: Arc, + sessions: Arc, +} + +impl EnvironmentFixture { + fn new(aliases: &[(&str, &str)]) -> Self { + let runtime = TempDir::new().unwrap(); + let root = runtime.path().join("profile-root"); + // Profile discovery stays at this root, deliberately outside every + // attached Workdir. Session cwd must never redirect it into work/nested. + let profiles = write_project_profile_registry(&root, None, &[]); + let mut manifest = parent_manifest(&root, None); + manifest.feature.sub_worker.enabled = true; + let registry = SpawnedWorkerRegistry::new_internal( + "parent".into(), + SharedScope::new(Scope::from_config(&manifest.scope).unwrap()), + ); + let sessions = Arc::new(WorkdirSessionRouter::new()); + for (alias, cwd) in aliases { + let checkout = runtime.path().join(alias); + for path in ["", "work", "work/nested", "work/nested/deep", "other"] { + std::fs::create_dir_all(checkout.join(path)).unwrap(); + std::fs::write(checkout.join(path).join("marker.txt"), marker(alias, path)) + .unwrap(); + } + let scope = SharedScope::new(Scope::writable(&checkout).unwrap()); + let session = Arc::new(workdir::LocalWorkdirSession::materialized_bound( + workdir::Workdir::new(format!("fixture-{alias}")), + checkout.clone(), + checkout.join(cwd), + scope, + workdir::WorkdirSessionCapabilities::ALL, + )); + sessions + .attach_at_cwd( + workdir::WorkdirAttachmentAlias::new(*alias).unwrap(), + session, + workdir::WorkdirPath::new(cwd).unwrap(), + ) + .unwrap(); + } + Self { + runtime, + root, + manifest, + profiles, + registry, + sessions, + } + } + + async fn spawn( + &self, + spawner: &str, + registry: Arc, + broker: workdir::WorkdirToolBrokerRouter, + input: Value, + ) -> ( + crate::spawn::registry::InternalSpawnedWorkerRecord, + EnvironmentClient, + ) { + assert_eq!(input["task"], TASK); + let client = EnvironmentClient::default(); + let mut manifest = self.manifest.clone(); + manifest.worker.name = spawner.into(); + let tool = SubWorkerSpawnTool::new( + spawner.into(), + crate::worker::WorkerWorkspaceContext::local_filesystem(None), + ParentNotificationTarget::Durable(Arc::new(|_| {})), + self.runtime.path().to_path_buf(), + self.root.clone(), + Some(broker), + registry.clone(), + manifest, + PromptCatalogSource::builtins_only(), + self.profiles.clone(), + ) + .with_internal_client(Box::new(client.clone())); + tool.execute( + &input.to_string(), + agen::tool::ToolExecutionContext::direct(), + ) + .await + .expect("spawn through production delegation and Worker construction"); + let record = registry + .get_internal(input["name"].as_str().unwrap()) + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), record.session.wait_until_idle()) + .await + .expect("scripted read turn must terminate"), + InternalWorkerSessionStatus::Idle, + ); + (record, client) + } +} + +fn marker(alias: &str, cwd: &str) -> String { + format!( + "{alias}:{}:session-marker", + if cwd.is_empty() { "." } else { cwd } + ) +} + +fn assert_environment_and_reads( + router: &WorkdirSessionRouter, + client: &EnvironmentClient, + expected: &[(&str, &str, bool)], +) -> String { + let requests = client.requests.lock().unwrap(); + assert!( + requests.len() >= 2, + "Read must execute and return to the model" + ); + let prompt = requests[0].system_prompt.as_deref().unwrap(); + assert!( + !prompt.contains("## Working boundaries"), + "legacy Scope must not override the connected session environment" + ); + let environments = prompt_environments(prompt); + assert_eq!(environments.len(), expected.len()); + assert_eq!(router.len(), expected.len()); + for (alias, cwd, command) in expected { + let environment = router.resolve(Some(alias)).unwrap().session.environment(); + assert_eq!(environment.cwd.as_str(), *cwd); + assert_eq!( + environment + .capabilities + .supports(WorkdirSessionCapability::Command), + *command + ); + let projected = environments + .iter() + .find(|env| env["alias"] == *alias) + .unwrap(); + assert_eq!(projected["cwd"], if cwd.is_empty() { "." } else { *cwd }); + assert_eq!( + projected["scope"], + serde_json::to_value(&environment.scope).unwrap() + ); + assert_eq!( + projected["restrictions"], + serde_json::to_value(&environment.restrictions).unwrap() + ); + assert_eq!(projected["command"], *command); + for (capability, name) in [ + (WorkdirSessionCapability::Read, "read"), + (WorkdirSessionCapability::Write, "write"), + (WorkdirSessionCapability::Edit, "edit"), + (WorkdirSessionCapability::Glob, "glob"), + (WorkdirSessionCapability::Grep, "grep"), + (WorkdirSessionCapability::Command, "command"), + ] { + assert_eq!( + projected["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|value| value == name), + environment.capabilities.supports(capability), + ); + } + let expected_marker = marker(alias, cwd); + let observed_read = requests + .iter() + .flat_map(|request| &request.items) + .any(|item| { + matches!(item, Item::ToolResult { call_id, content: Some(content), .. } + if call_id == &format!("read-{alias}") && content.contains(&expected_marker)) + }); + assert!( + observed_read, + "{alias}: relative Read did not return {expected_marker}" + ); + } + prompt.to_owned() +} + +fn assert_retained_environment( + record: &crate::spawn::registry::InternalSpawnedWorkerRecord, + prompt: &str, +) { + let retained = record.session.entries().iter().any(|entry| { + matches!(entry, session_store::LogEntry::AnnotatedSegmentStart { system_prompt: Some(saved), .. } + if saved == prompt) + }); + assert!( + retained, + "the model-visible environment must also be retained in session history" + ); +} + +#[tokio::test] +async fn spawn_root_and_nonroot_cwd_are_projected_without_task_annotations() { + for (name, cwd, command, permission) in [ + ("root-child", "", false, "read"), + ("cwd-child", "work", true, "write"), + ] { + let fixture = EnvironmentFixture::new(&[("checkout", "")]); + let (record, client) = fixture + .spawn( + "parent", + fixture.registry.clone(), + workdir::WorkdirToolBrokerRouter::new(fixture.sessions.clone()), + json!({ + "name": name, "task": TASK, "cwd": if cwd.is_empty() { "." } else { cwd }, + "scope": [{"target": ".", "permission": permission}], + "command": command, + }), + ) + .await; + let router = record.workdir_tool_scope.session_router(); + let prompt = assert_environment_and_reads(&router, &client, &[("checkout", cwd, command)]); + assert_retained_environment(&record, &prompt); + assert_eq!( + record.installed_tools.iter().any(|tool| tool == "Bash"), + command + ); + fixture.registry.remove_internal(name).await.unwrap(); + } +} + +#[tokio::test] +async fn spawn_multiple_aliases_and_nested_child_keep_real_session_environment() { + let fixture = EnvironmentFixture::new(&[("alpha", ""), ("beta", "")]); + let (child, child_client) = fixture + .spawn( + "parent", + fixture.registry.clone(), + workdir::WorkdirToolBrokerRouter::new(fixture.sessions.clone()), + json!({ + "name": "child", "task": TASK, "cwd": "work", "command": true, + "scope": [ + {"target_workdir": "alpha", "target": ".", "permission": "write"}, + {"target_workdir": "beta", "target": ".", "permission": "write"}, + ], + }), + ) + .await; + let child_router = child.workdir_tool_scope.session_router(); + let child_prompt = assert_environment_and_reads( + &child_router, + &child_client, + &[("alpha", "work", true), ("beta", "work", true)], + ); + assert_retained_environment(&child, &child_prompt); + assert!( + child + .installed_tools + .iter() + .any(|tool| tool == "SubWorkerSpawn") + ); + + // Reuse the actual child's registry and attenuated broker. The nested tool + // receives the scripted LLM at the same boundary as the first spawn; session + // construction, routing, delegation and prompt materialization remain real. + let (nested, nested_client) = fixture + .spawn( + "child", + child.child_registry.clone(), + child.workdir_tool_scope.broker_router(), + json!({ + "name": "nested", "task": TASK, "cwd": "work/nested", + "scope": [ + {"target_workdir": "alpha", "target": "work/nested", "permission": "read"}, + {"target_workdir": "beta", "target": "work/nested", "permission": "read"}, + ], + }), + ) + .await; + let nested_router = nested.workdir_tool_scope.session_router(); + let nested_prompt = assert_environment_and_reads( + &nested_router, + &nested_client, + &[ + ("alpha", "work/nested", false), + ("beta", "work/nested", false), + ], + ); + assert_retained_environment(&nested, &nested_prompt); + assert!(!nested.installed_tools.iter().any(|tool| tool == "Bash")); + for alias in ["alpha", "beta"] { + let session = nested_router.resolve(Some(alias)).unwrap().session; + let environment = session.environment(); + let scope = environment + .scope + .expect("nested session must remain scoped"); + assert_eq!(scope.len(), 1); + assert_eq!(scope[0].target.as_str(), "work/nested"); + assert_eq!( + scope[0].permission, + workdir::WorkdirToolScopePermission::Read + ); + assert!( + session + .read(workdir::ReadRequest { + path: workdir::WorkdirPath::new_scoped( + fixture + .runtime + .path() + .join(alias) + .join("marker.txt") + .display() + .to_string(), + ) + .unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }) + .await + .is_err() + ); + } + // Grandchild cwd and reduced command authority must not rewrite its parent. + assert_environment_and_reads( + &child_router, + &child_client, + &[("alpha", "work", true), ("beta", "work", true)], + ); + child + .child_registry + .remove_internal("nested") + .await + .unwrap(); + fixture.registry.remove_internal("child").await.unwrap(); +} + +#[tokio::test] +async fn spawn_multiple_aliases_keep_root_scope_independent_of_parent_session_cwds() { + let fixture = EnvironmentFixture::new(&[("alpha", "work"), ("beta", "other")]); + let (record, client) = fixture + .spawn( + "parent", + fixture.registry.clone(), + workdir::WorkdirToolBrokerRouter::new(fixture.sessions.clone()), + json!({ + "name": "child", "task": TASK, "cwd": "work/nested", + "scope": [ + {"target_workdir": "alpha", "target": "work/nested", "permission": "read"}, + {"target_workdir": "beta", "target": "work/nested", "permission": "read"}, + ], + }), + ) + .await; + let prompt = assert_environment_and_reads( + &record.workdir_tool_scope.session_router(), + &client, + &[ + ("alpha", "work/nested", false), + ("beta", "work/nested", false), + ], + ); + assert_retained_environment(&record, &prompt); + for (alias, cwd) in [("alpha", "work"), ("beta", "other")] { + assert_eq!( + fixture + .sessions + .resolve(Some(alias)) + .unwrap() + .session + .environment() + .cwd + .as_str(), + cwd + ); + } + fixture.registry.remove_internal("child").await.unwrap(); +} + +#[tokio::test] +async fn nonroot_startup_then_spawn_and_nested_child_keep_root_relative_scope() { + let fixture = EnvironmentFixture::new(&[("workdir", "")]); + let checkout = fixture.runtime.path().join("workdir"); + let client = EnvironmentClient::default(); + let mut manifest = fixture.manifest.clone(); + manifest.scope.allow = vec![abs_rule(&checkout, Permission::Write)]; + let mut worker = + Worker::, EphemeralSessionStore>::from_internal_manifest_with_context( + manifest, + EphemeralSessionStore::default(), + PromptCatalogSource::builtins_only(), + crate::worker::WorkerWorkspaceContext::local_filesystem(None), + WorkerFilesystemAuthority::local(checkout.clone(), checkout.join("work")), + Some(Box::new(client.clone())), + ) + .await + .unwrap(); + // Do not bind a test router: exercise workdir_sessions_from_authority via + // the real constructor and the normal controller's broker selection. + let startup = worker.workdir_sessions(); + register_worker_tools( + &mut worker, + fixture.runtime.path().to_path_buf(), + fixture.registry.clone(), + None, + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), worker.run_text(TASK)) + .await + .expect("startup read turn must terminate") + .unwrap(); + assert_environment_and_reads(&startup, &client, &[("workdir", "work", true)]); + + let (child, child_client) = fixture + .spawn( + "parent", + fixture.registry.clone(), + workdir::WorkdirToolBrokerRouter::new(startup.clone()), + json!({ + "name": "child", "task": TASK, "cwd": "work/nested", + "scope": [{"target": "work/nested", "permission": "read"}], + }), + ) + .await; + let child_router = child.workdir_tool_scope.session_router(); + let child_prompt = assert_environment_and_reads( + &child_router, + &child_client, + &[("workdir", "work/nested", false)], + ); + assert_retained_environment(&child, &child_prompt); + let (nested, nested_client) = fixture + .spawn( + "child", + child.child_registry.clone(), + child.workdir_tool_scope.broker_router(), + json!({ + "name": "nested", "task": TASK, "cwd": "work/nested/deep", + "scope": [{"target": "work/nested/deep", "permission": "read"}], + }), + ) + .await; + let nested_router = nested.workdir_tool_scope.session_router(); + let nested_prompt = assert_environment_and_reads( + &nested_router, + &nested_client, + &[("workdir", "work/nested/deep", false)], + ); + assert_retained_environment(&nested, &nested_prompt); + for (router, target) in [ + (&child_router, "work/nested"), + (&nested_router, "work/nested/deep"), + ] { + let environment = router.resolve(None).unwrap().session.environment(); + let rules = environment.scope.unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].target.as_str(), target); + } + assert_eq!( + startup + .resolve(None) + .unwrap() + .session + .environment() + .cwd + .as_str(), + "work" + ); + let alias = workdir::WorkdirAttachmentAlias::new("workdir").unwrap(); + startup.begin_detach(&alias).await.unwrap(); + // Both the parent tool view and descendant root-coordinate broker source + // must share this fence, rather than bypassing it through a cached broker. + for router in [&startup, &child_router, &nested_router] { + let session = router.resolve(None).unwrap().session; + assert!( + session + .read(workdir::ReadRequest { + path: workdir::WorkdirPath::new("marker.txt").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }) + .await + .is_err() + ); + } + startup.cancel_detach(&alias); + child + .child_registry + .remove_internal("nested") + .await + .unwrap(); + fixture.registry.remove_internal("child").await.unwrap(); +} + +#[tokio::test] +async fn startup_rejects_cwd_outside_workdir_root_without_running_the_model() { + let fixture = EnvironmentFixture::new(&[("workdir", "")]); + let client = EnvironmentClient::default(); + let mut manifest = fixture.manifest.clone(); + manifest.scope.allow = vec![abs_rule(fixture.runtime.path(), Permission::Write)]; + let error = + Worker::, EphemeralSessionStore>::from_internal_manifest_with_context( + manifest, + EphemeralSessionStore::default(), + PromptCatalogSource::builtins_only(), + crate::worker::WorkerWorkspaceContext::local_filesystem(None), + WorkerFilesystemAuthority::local( + fixture.runtime.path().join("workdir"), + fixture.root.clone(), + ), + Some(Box::new(client.clone())), + ) + .await + .err() + .expect("a session cwd must be representable within its Workdir root"); + assert!(matches!(error, crate::WorkerError::InvalidCwd { .. })); + assert!(client.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn worker_multiple_aliases_project_distinct_session_cwds_not_discovery_root() { + for prompt_override in [None, Some("Feature-owned instruction body")] { + let fixture = EnvironmentFixture::new(&[("alpha", "work"), ("beta", "other")]); + let client = EnvironmentClient::default(); + let mut worker = Worker::, EphemeralSessionStore>::from_internal_manifest_with_context( + fixture.manifest.clone(), + EphemeralSessionStore::default(), + PromptCatalogSource::builtins_only(), + crate::worker::WorkerWorkspaceContext::local_filesystem(None), + WorkerFilesystemAuthority::None, + Some(Box::new(client.clone())), + ) + .await + .unwrap(); + worker.bind_workdir_sessions(fixture.sessions.clone()); + register_worker_tools( + &mut worker, + fixture.runtime.path().to_path_buf(), + fixture.registry.clone(), + None, + Some(workdir::WorkdirToolBrokerRouter::new( + fixture.sessions.clone(), + )), + ) + .await + .unwrap(); + worker.install_system_prompt_contribution(None, prompt_override.map(str::to_owned)); + tokio::time::timeout(Duration::from_secs(5), worker.run_text(TASK)) + .await + .expect("scripted worker turn must terminate") + .unwrap(); + let prompt = assert_environment_and_reads( + &fixture.sessions, + &client, + &[("alpha", "work", true), ("beta", "other", true)], + ); + if let Some(body) = prompt_override { + assert!(prompt.starts_with(body)); + } + } +} diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 2f609b735..f46be3d4a 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -3700,7 +3700,7 @@ impl Worker { let prompts = Arc::new(ArcSwap::from(PromptCatalog::builtins_only()?)); DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?; let scope = SharedScope::new(scope); - let workdir_sessions = workdir_sessions_from_authority(&filesystem_authority, &scope); + let workdir_sessions = workdir_sessions_from_authority(&filesystem_authority, &scope)?; let mut worker = Self { backend_job: std::sync::OnceLock::new(), manifest, @@ -4980,11 +4980,14 @@ impl Worker { let Some(template) = self.system_prompt_template.as_ref() else { return Ok(()); }; - if let Some(rendered) = self.feature_system_prompt_override.as_ref() { + if let Some(body) = self.feature_system_prompt_override.as_ref() { + let environment = + crate::prompt::system::workdir_environment_section(&self.workdir_sessions) + .map_err(|source| WorkerError::SystemPromptRender { source })?; self.engine .as_mut() .expect("worker present") - .set_system_prompt(rendered.clone()); + .set_system_prompt(format!("{body}\n\n{environment}")); return Ok(()); } let alerter = self.alerter.clone(); @@ -5029,14 +5032,10 @@ impl Worker { }; let worker_language = worker_language(&self.manifest.engine); let scope_snapshot = self.scope.snapshot(); - let cwd_for_prompt = self - .local_working_directory() - .map(|local| local.cwd.display().to_string()) - .unwrap_or_else(|| "no local working directory".to_string()); let prompt_catalog = self.prompts.load_full(); let ctx = SystemPromptContext { now: chrono::Utc::now(), - cwd: cwd_for_prompt.into(), + workdir_sessions: &self.workdir_sessions, language: worker_language, scope: &scope_snapshot, tool_names, @@ -8306,7 +8305,7 @@ where let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store)); let scope = SharedScope::new(common.scope); let workdir_sessions = - workdir_sessions_from_authority(&common.filesystem_authority, &scope); + workdir_sessions_from_authority(&common.filesystem_authority, &scope)?; let mut worker = Self { backend_job: std::sync::OnceLock::new(), @@ -8707,7 +8706,7 @@ where engine.set_cache_key(Some(segment_id.to_string())); let scope = SharedScope::new(common.scope); let workdir_sessions = - workdir_sessions_from_authority(&common.filesystem_authority, &scope); + workdir_sessions_from_authority(&common.filesystem_authority, &scope)?; let mut worker = Self { backend_job: std::sync::OnceLock::new(), manifest, @@ -9109,7 +9108,7 @@ where let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store)); let scope = SharedScope::new(common.scope); let workdir_sessions = - workdir_sessions_from_authority(&common.filesystem_authority, &scope); + workdir_sessions_from_authority(&common.filesystem_authority, &scope)?; let mut worker = Self { backend_job: std::sync::OnceLock::new(), @@ -10220,9 +10219,25 @@ pub enum WorkerError { fn workdir_sessions_from_authority( authority: &WorkerFilesystemAuthority, scope: &SharedScope, -) -> Arc { +) -> Result, WorkerError> { let router = Arc::new(WorkdirSessionRouter::new()); if let Some(local) = authority.as_local() { + let invalid_cwd = |error: String| WorkerError::InvalidCwd { + cwd: local.cwd.clone(), + source: std::io::Error::new(std::io::ErrorKind::InvalidInput, error), + }; + let relative_cwd = local.cwd.strip_prefix(&local.root).map_err(|error| { + invalid_cwd(format!( + "session cwd must be within its Workdir root: {error}" + )) + })?; + let relative_cwd = relative_cwd + .to_str() + .ok_or_else(|| invalid_cwd("session cwd must be UTF-8".into()))?; + let relative_cwd = workdir::WorkdirPath::new(relative_cwd) + .map_err(|error| invalid_cwd(error.to_string()))?; + // The provider keeps root-coordinate operations. Only the routed tool + // view uses cwd; delegation reuses the same root-coordinate broker. let session = Arc::new(LocalWorkdirSession::materialized( local.root.clone(), local.cwd.clone(), @@ -10230,13 +10245,14 @@ fn workdir_sessions_from_authority( WorkdirSessionCapabilities::ALL, )) as WorkdirSessionHandle; router - .attach( + .attach_at_cwd( WorkdirAttachmentAlias::new("workdir").expect("static alias is valid"), session, + relative_cwd, ) - .expect("fresh Workdir router accepts its only attachment"); + .map_err(|error| invalid_cwd(error.to_string()))?; } - router + Ok(router) } /// Bundle of resources that every high-level Worker constructor needs: diff --git a/crates/worker/tests/system_prompt_template_test.rs b/crates/worker/tests/system_prompt_template_test.rs index 727990c6e..4c351336d 100644 --- a/crates/worker/tests/system_prompt_template_test.rs +++ b/crates/worker/tests/system_prompt_template_test.rs @@ -187,7 +187,8 @@ async fn materialise_on_first_turn_populates_worker() { .expect("system prompt materialised") .to_string(); assert!(rendered.contains("date=")); - assert!(rendered.contains(&pwd.display().to_string())); + assert!(rendered.contains("## Workdir execution environment")); + assert!(!rendered.contains(&pwd.display().to_string())); assert!(rendered.starts_with("date=")); } @@ -206,7 +207,8 @@ async fn session_start_state_captures_rendered_prompt() { LogEntry::AnnotatedSegmentStart { system_prompt, .. } => { let sp = system_prompt.as_deref().expect("system prompt set"); assert!(sp.starts_with("hello")); - assert!(sp.contains(&pwd.display().to_string())); + assert!(sp.contains("## Workdir execution environment")); + assert!(!sp.contains(&pwd.display().to_string())); } other => panic!("expected AnnotatedSegmentStart as first entry, got {other:?}"), } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index d2fe3ee54..0f9cd24a1 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -8827,6 +8827,9 @@ mod tests { session_id: workdir::http::WorkdirSessionId::new("session-1").unwrap(), workdir_id: Workdir::new("wd-1").id().clone(), capabilities: workdir::WorkdirSessionCapabilities::ALL, + environment: workdir::WorkdirSessionEnvironment::root( + workdir::WorkdirSessionCapabilities::ALL, + ), }; let stat = workdir::http::WorkdirSessionOperationResult::Stat(StatResult { path: WorkdirPath::new("hello.txt").unwrap(), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 9461b46ea..809f7d203 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -20656,6 +20656,55 @@ async fn import_current_worker_workdir_observation( Ok(()) } +// The Backend proxy has root-coordinate operations even before its provider is +// opened. Multiple attachments must remain attachable without opening every +// provider: a pending/offline provider is checked when an operation selects it. +async fn current_worker_attachment_environment_locked( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + link: &WorkerWorkdirLinkRecord, + capabilities: workdir::WorkdirSessionCapabilities, + open_if_single: bool, + session_guard: &Arc>, +) -> Result { + if open_if_single { + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let count = api + .store + .db_executor() + .run(move || { + active_filesystem_workdir_links(&stage_api, &stage_worker).map(|links| links.len()) + }) + .await?; + if count == 1 { + return Ok(open_current_worker_workdir_session_locked( + api, + worker, + link, + session_guard, + ) + .await? + .environment()); + } + } + if let Some(mut environment) = api + .workdir_sessions + .lock() + .expect("Workdir session registry lock poisoned") + .attachments + .get(worker) + .filter(|source| source.workdir().id().as_str() == link.workdir_id) + .map(|source| source.environment()) + { + environment.capabilities = environment.capabilities.intersection(capabilities); + return Ok(environment); + } + let mut environment = workdir::WorkdirSessionEnvironment::root(capabilities); + environment.restrictions.push("Backend proxy: provider scope and availability are checked when each operation selects this attachment".into()); + Ok(environment) +} + async fn scoped_attach_current_worker_workdir( State(api): State, AxumPath(path): AxumPath, @@ -20782,17 +20831,17 @@ async fn scoped_attach_current_worker_workdir( } let effective_capabilities = existing.capabilities.intersection(capabilities); if effective_capabilities != existing.capabilities { - let mut attenuated = existing; + let mut attenuated = existing.clone(); attenuated.capabilities = effective_capabilities; api.store.attach_worker_workdir(&attenuated)?; } - Ok(Some(effective_capabilities)) + Ok(Some((existing, effective_capabilities))) } else { Ok(None) } }) .await?; - if let Some(effective_capabilities) = existing { + if let Some((link, effective_capabilities)) = existing { sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await?; api.worker_projection .refresh_async(&worker) @@ -20803,6 +20852,17 @@ async fn scoped_attach_current_worker_workdir( alias: alias.to_string(), working_directory_id: workdir_id.to_string(), capabilities: effective_capabilities, + environment: Some( + current_worker_attachment_environment_locked( + &api, + &worker, + &link, + effective_capabilities, + false, + &_session_guard, + ) + .await?, + ), attached: true, })); } @@ -20828,25 +20888,25 @@ async fn scoped_attach_current_worker_workdir( detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await; return Err(error); } - let stage_api = api.clone(); - let stage_worker = worker.clone(); - let active_count = api - .store - .db_executor() - .run(move || { - active_filesystem_workdir_links(&stage_api, &stage_worker).map(|links| links.len()) - }) - .await?; - if active_count == 1 - && let Err(error) = - open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await + let environment = match current_worker_attachment_environment_locked( + &api, + &worker, + &link, + capabilities, + true, + &_session_guard, + ) + .await { - let _ = - detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await; - let _ = - sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await; - return Err(error.into()); - } + Ok(environment) => environment, + Err(error) => { + let _ = detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard) + .await; + let _ = sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard) + .await; + return Err(error.into()); + } + }; if let Err(error) = api .worker_projection .refresh_async(&worker) @@ -20863,6 +20923,7 @@ async fn scoped_attach_current_worker_workdir( alias: alias.to_string(), working_directory_id: workdir_id.to_string(), capabilities, + environment: Some(environment), attached: true, })) } @@ -21105,6 +21166,7 @@ async fn detach_current_worker_workdir( alias: alias.to_string(), working_directory_id: link.workdir_id, capabilities: workdir::WorkdirSessionCapabilities::EMPTY, + environment: None, attached: false, }) } @@ -60835,6 +60897,12 @@ mod tests { serde_json::from_slice(&attached_body).unwrap(); assert!(attached.attached); assert_eq!(attached.working_directory_id, fixture.docs_workdir_id); + let environment = attached + .environment + .expect("attached proxy has an execution environment"); + assert!(environment.cwd.is_root()); + assert_eq!(environment.capabilities, attached.capabilities); + assert!(!environment.restrictions.is_empty()); assert_eq!( api.store .get_workdir_registry(TEST_WORKSPACE_ID, &fixture.docs_workdir_id) diff --git a/crates/workspace-server/src/server/tests/workdir_delegation_tests.rs b/crates/workspace-server/src/server/tests/workdir_delegation_tests.rs index 17240665a..b421c1b43 100644 --- a/crates/workspace-server/src/server/tests/workdir_delegation_tests.rs +++ b/crates/workspace-server/src/server/tests/workdir_delegation_tests.rs @@ -200,6 +200,7 @@ async fn backend_delegation_reaches_remote_repository_resolution() { session_id: WorkdirSessionId::new("delegation-session").unwrap(), workdir_id, capabilities: Caps::ALL, + environment: workdir::WorkdirSessionEnvironment::root(Caps::ALL), }) } }),