fix: align Workdir session cwd and model environment
Project alias, cwd, scope and command ceilings from actual sessions. Bind initial attachment cwd once in the shared broker/router while keeping root-relative delegation and detach generations. Preserve caller coordinates across reads, searches, checked checkout and commands; keep provider authorization and symlink boundaries. Connect real SubWorkers, all filesystem Tools, scoped aliases, HTTP providers and limited Host grants in permanent regressions. Validated related normal suites (workdir http-client, tools, worker, worker-runtime, server-api typescript, fs-operation), workspace-server normal suite (1272 passed, 3 existing ignores), root check, product binary builds, rustdoc/doctests, generated contract checks, format and state-contract names. Test roots are checkout-local; Git parent discovery requires GIT_CEILING_DIRECTORIES at TMPDIR, confirmed against exact b860 baseline.
This commit is contained in:
@@ -30,7 +30,9 @@ struct BashParams {
|
||||
/// Worker-local alias of the Workdir attachment to use.
|
||||
#[serde(default)]
|
||||
target_workdir: Option<String>,
|
||||
/// 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)]
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(crate) struct EditParams {
|
||||
/// Worker-local alias of the Workdir attachment to use.
|
||||
#[serde(default)]
|
||||
pub target_workdir: Option<String>,
|
||||
/// 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,
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(crate) struct GlobParams {
|
||||
pub target_workdir: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
@@ -106,7 +106,7 @@ pub(crate) fn routed_glob_tool(router: Arc<WorkdirSessionRouter>) -> 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<dyn Tool> = Arc::new(GlobTool {
|
||||
router: router.clone(),
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(crate) struct GrepParams {
|
||||
#[serde(default)]
|
||||
target_workdir: Option<String>,
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
@@ -160,7 +160,7 @@ pub(crate) fn routed_grep_tool(router: Arc<WorkdirSessionRouter>) -> 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<dyn Tool> = Arc::new(GrepTool {
|
||||
router: router.clone(),
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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,
|
||||
|
||||
@@ -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<String>,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
/// Image file to attach to the next model request.
|
||||
path: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ pub(crate) struct WriteParams {
|
||||
/// Worker-local alias of the Workdir attachment to use.
|
||||
#[serde(default)]
|
||||
pub target_workdir: Option<String>,
|
||||
/// 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,
|
||||
|
||||
@@ -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<WorkdirSessionRouter>, 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<TempDir>,
|
||||
directories: Vec<std::path::PathBuf>,
|
||||
brokers: Vec<WorkdirToolBroker>,
|
||||
leases: Vec<workdir::WorkdirScopeLease>,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
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<u8> {
|
||||
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::<Vec<_>>()
|
||||
.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();
|
||||
|
||||
Reference in New Issue
Block a user