#[cfg(test)]
mod local_tests {
use super::super::*;
use agent_workspace_contract::Workspace;
#[tokio::test]
async fn local_workspace_read_write_and_delete() {
let root =
std::env::temp_dir().join(format!("agent-ws-test-{}", uuid::Uuid::new_v4().simple()));
let _ = std::fs::remove_dir_all(&root);
let ws = LocalWorkspace::new_with_host_exec(root.clone(), true);
ws.write_file("hello.txt", "world").await.unwrap();
assert!(ws.exists("hello.txt").await.unwrap());
assert_eq!(ws.read_file("hello.txt").await.unwrap(), "world");
ws.remove_file("hello.txt").await.unwrap();
assert!(!ws.exists("hello.txt").await.unwrap());
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_workspace_list_dir() {
let root =
std::env::temp_dir().join(format!("agent-ws-list-{}", uuid::Uuid::new_v4().simple()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), "a").unwrap();
std::fs::write(root.join("b.txt"), "b").unwrap();
std::fs::create_dir(root.join("sub")).unwrap();
let ws = LocalWorkspace::new(root.clone());
let entries = ws.list_dir(".").await.unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].name, "a.txt");
assert_eq!(entries[1].name, "b.txt");
assert_eq!(entries[2].name, "sub");
assert!(entries[2].is_dir);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_workspace_exec() {
let root =
std::env::temp_dir().join(format!("agent-ws-exec-{}", uuid::Uuid::new_v4().simple()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let ws = LocalWorkspace::new_with_host_exec(root.clone(), true);
let output = ws.exec("echo hello", None).await.unwrap();
assert_eq!(output.exit_code, 0);
assert!(output.stdout.trim().contains("hello"));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_workspace_bounds_command_output() {
let root =
std::env::temp_dir().join(format!("agent-ws-output-{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&root).unwrap();
let ws = LocalWorkspace::new_with_host_exec(root.clone(), true);
let output = ws
.exec(
"python3 -c 'import sys; sys.stdout.write(\"x\" * 1100000)'",
None,
)
.await
.unwrap();
assert_eq!(output.stdout.len(), 1024 * 1024);
assert!(output.stdout_truncated);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn conditional_writes_are_atomic_under_concurrency() {
let root =
std::env::temp_dir().join(format!("agent-ws-cas-{}", uuid::Uuid::new_v4().simple()));
let ws = LocalWorkspace::new(root.clone());
ws.write_file("state.txt", "base").await.unwrap();
let revision = ws.read_file_versioned("state.txt").await.unwrap().revision;
assert!(revision.starts_with("sha256:"));
assert_eq!(revision.len(), 71);
assert!(ws
.write_file_if_match("state.txt", b"legacy", Some("fnv1a64:0000000000000000"))
.await
.is_err());
let left_ws = LocalWorkspace::new(root.clone());
let left_revision = revision.clone();
let left = tokio::spawn(async move {
left_ws
.write_file_if_match("state.txt", b"left", Some(&left_revision))
.await
});
let right_ws = LocalWorkspace::new(root.clone());
let right = tokio::spawn(async move {
right_ws
.write_file_if_match("state.txt", b"right", Some(&revision))
.await
});
let outcomes = [left.await.unwrap(), right.await.unwrap()];
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(outcomes.iter().filter(|result| result.is_err()).count(), 1);
assert!(matches!(
ws.read_file("state.txt").await.unwrap().as_str(),
"left" | "right"
));
assert!(std::fs::read_dir(&root).unwrap().all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.contains(".agent-write-")));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn grep_rejects_results_above_the_total_output_budget() {
let root = std::env::temp_dir().join(format!(
"agent-ws-search-limit-{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("large.txt"),
format!("needle{}", "x".repeat(WORKSPACE_MAX_SEARCH_OUTPUT_BYTES)),
)
.unwrap();
let ws = LocalWorkspace::new(root.clone());
let error = ws.grep("needle", ".", None).await.unwrap_err();
assert!(error.to_string().contains("output limit"));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn command_input_has_a_hard_limit() {
let root = std::env::temp_dir().join(format!(
"agent-ws-command-limit-{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&root).unwrap();
let ws = LocalWorkspace::new_with_host_exec(root.clone(), true);
let command = "x".repeat(WORKSPACE_MAX_COMMAND_BYTES + 1);
assert!(ws
.exec(&command, None)
.await
.unwrap_err()
.to_string()
.contains("exceeds"));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_workspace_is_dir() {
let root =
std::env::temp_dir().join(format!("agent-ws-isdir-{}", uuid::Uuid::new_v4().simple()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("file.txt"), "x").unwrap();
std::fs::create_dir(root.join("subdir")).unwrap();
let ws = LocalWorkspace::new(root.clone());
assert!(ws.is_dir("subdir").await.unwrap());
assert!(!ws.is_dir("file.txt").await.unwrap());
assert!(!ws.is_dir("nonexistent").await.unwrap());
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_workspace_rejects_paths_outside_root() {
let root = std::env::temp_dir().join(format!(
"agent-ws-boundary-{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&root).unwrap();
let ws = LocalWorkspace::new(root.clone());
assert!(ws.read_file("../outside.txt").await.is_err());
assert!(ws.read_file("/etc/passwd").await.is_err());
assert!(ws.write_file("../outside.txt", "bad").await.is_err());
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn host_exec_is_disabled_by_default() {
let root = std::env::temp_dir().join(format!(
"agent-ws-no-exec-{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&root).unwrap();
let ws = LocalWorkspace::new(root.clone());
let error = ws.exec("echo unsafe", None).await.unwrap_err();
assert!(error.to_string().contains("disabled"));
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[tokio::test]
async fn local_workspace_rejects_symlinks_outside_root() {
use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!(
"agent-ws-symlink-{}",
uuid::Uuid::new_v4().simple()
));
let outside = std::env::temp_dir().join(format!(
"agent-ws-outside-{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret.txt"), "secret").unwrap();
symlink(&outside, root.join("escape")).unwrap();
let ws = LocalWorkspace::new(root.clone());
assert!(ws.read_file("escape/secret.txt").await.is_err());
assert!(ws.write_file("escape/new.txt", "bad").await.is_err());
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
#[cfg(unix)]
#[tokio::test]
async fn local_workspace_rejects_all_symlink_components() {
use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!(
"agent-ws-symlink-component-{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(root.join("real")).unwrap();
std::fs::write(root.join("real/file.txt"), "content").unwrap();
symlink(root.join("real"), root.join("alias")).unwrap();
let ws = LocalWorkspace::new(root.clone());
assert!(ws.read_file("alias/file.txt").await.is_err());
assert!(ws.write_file("alias/new.txt", "bad").await.is_err());
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn descriptor_relative_write_resists_parent_symlink_rename_race() {
use std::os::unix::fs::symlink;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("safe")).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let attacker_root = root.path().to_path_buf();
let attacker_outside = outside.path().to_path_buf();
let attacker_stop = stop.clone();
let attacker = std::thread::spawn(move || {
let safe = attacker_root.join("safe");
let held = attacker_root.join("held");
while !attacker_stop.load(Ordering::Relaxed) {
if std::fs::rename(&safe, &held).is_ok() {
let _ = symlink(&attacker_outside, &safe);
let _ = std::fs::remove_file(&safe);
let _ = std::fs::rename(&held, &safe);
}
}
});
let workspace = LocalWorkspace::new(root.path().to_path_buf());
for _ in 0..200 {
let _ = workspace.write_file("safe/value.txt", "safe").await;
}
stop.store(true, Ordering::Relaxed);
attacker.join().unwrap();
assert!(
!outside.path().join("value.txt").exists(),
"descriptor-relative write must never follow a raced symlink"
);
}
}