mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 21:37:21 +00:00
329 lines
10 KiB
Rust
329 lines
10 KiB
Rust
use openworker_runtime::coding::CodingTools;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn coding_classification_keeps_file_guards_independent() {
|
|
use openworker_runtime::{
|
|
bundle::Bundle,
|
|
permissions::{self, Category},
|
|
};
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir(tmp.path().join("coworker")).unwrap();
|
|
std::fs::write(
|
|
tmp.path().join("coworker/manifest.md"),
|
|
"---\nid: coding-test\nname: Coding test\n---\nCode.\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(tmp.path().join("tools.json"),json!({"version":3,"tools":["coding.edit_file","coding.list_files","coding.process_start"]}).to_string()).unwrap();
|
|
let mut registry = openworker_tools::ToolRegistry::default();
|
|
openworker_runtime::coding::register(&mut registry).unwrap();
|
|
let bundle = Bundle::load_with_registry(tmp.path(), ®istry).unwrap();
|
|
assert_eq!(
|
|
permissions::classify(&bundle, "coding__edit_file"),
|
|
Category::WriteLocal
|
|
);
|
|
assert_eq!(
|
|
permissions::classify(&bundle, "coding__list_files"),
|
|
Category::Read
|
|
);
|
|
assert_eq!(
|
|
permissions::classify(&bundle, "coding__process_start"),
|
|
Category::Execute
|
|
);
|
|
assert!(permissions::hard_check(
|
|
&bundle,
|
|
tmp.path().to_str().unwrap(),
|
|
"coding__edit_file",
|
|
&json!({"path":"../outside"})
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
#[tokio::test]
|
|
async fn unsupported_process_platform_does_not_fall_back() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut t = CodingTools::default();
|
|
let e = t
|
|
.call(
|
|
dir.path(),
|
|
"process_start",
|
|
&json!({"command":"echo must-not-run"}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(e.to_string().contains("currently require Linux"));
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
#[ignore = "HTTP fixture child invoked only by managed_preview_works"]
|
|
fn preview_fixture() {
|
|
use std::io::{Read, Write};
|
|
let Ok(port_file) = std::env::var("OW_CODING_PREVIEW_PORT") else {
|
|
return;
|
|
};
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
std::fs::write(port_file, listener.local_addr().unwrap().port().to_string()).unwrap();
|
|
for stream in listener.incoming() {
|
|
let mut stream = stream.unwrap();
|
|
let mut input = [0; 1024];
|
|
let _ = stream.read(&mut input);
|
|
stream
|
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nplayable")
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[tokio::test]
|
|
async fn managed_preview_works_until_explicit_stop() {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut t = CodingTools::default();
|
|
let quote = |s: &str| format!("'{}'", s.replace('\'', "'\\''"));
|
|
let command = format!(
|
|
"OW_CODING_PREVIEW_PORT=preview.port {} --exact preview_fixture --ignored",
|
|
quote(std::env::current_exe().unwrap().to_str().unwrap())
|
|
);
|
|
let r = t
|
|
.call(
|
|
dir.path(),
|
|
"process_start",
|
|
&json!({"command":command,"timeout_seconds":10}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let port = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
if let Ok(p) = std::fs::read_to_string(dir.path().join("preview.port")) {
|
|
break p;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let mut socket = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
|
|
.await
|
|
.unwrap();
|
|
socket
|
|
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
|
.await
|
|
.unwrap();
|
|
let mut response = String::new();
|
|
socket.read_to_string(&mut response).await.unwrap();
|
|
assert!(response.ends_with("playable"));
|
|
t.call(
|
|
dir.path(),
|
|
"process_stop",
|
|
&json!({"process_id":r["process_id"]}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
|
|
.await
|
|
.is_err());
|
|
t.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn atomic_edits_reject_stale_ambiguous_and_escaping_paths() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let root = dir.path();
|
|
let mut t = CodingTools::default();
|
|
let v = t
|
|
.call(
|
|
root,
|
|
"write_file",
|
|
&json!({"path":"src/a.txt","content":"one two one","create_only":true}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(t
|
|
.call(
|
|
root,
|
|
"write_file",
|
|
&json!({"path":"src/a.txt","content":"lost","create_only":true})
|
|
)
|
|
.await
|
|
.is_err());
|
|
for (old, hash) in [("one", v["sha256"].clone()), ("two", json!("stale"))] {
|
|
assert!(t.call(root,"edit_file",&json!({"path":"src/a.txt","old_text":old,"new_text":"three","expected_sha256":hash})).await.is_err());
|
|
}
|
|
t.call(root,"edit_file",&json!({"path":"src/a.txt","old_text":"two","new_text":"three","expected_sha256":v["sha256"]})).await.unwrap();
|
|
assert_eq!(
|
|
std::fs::read_to_string(root.join("src/a.txt")).unwrap(),
|
|
"one three one"
|
|
);
|
|
assert!(t
|
|
.call(
|
|
root,
|
|
"write_file",
|
|
&json!({"path":"../outside","content":"no"})
|
|
)
|
|
.await
|
|
.is_err());
|
|
assert!(t
|
|
.call(
|
|
root,
|
|
"write_file",
|
|
&json!({"path":".openworker-coding.lock","content":"no"})
|
|
)
|
|
.await
|
|
.is_err());
|
|
assert!(t
|
|
.call(root, "read_file", &json!({"path":"src/a.txt","limit":0}))
|
|
.await
|
|
.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bounded_search_and_read_report_truncation() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let root = dir.path();
|
|
let mut t = CodingTools::default();
|
|
std::fs::write(root.join("a.txt"), "needle\n".repeat(600)).unwrap();
|
|
std::fs::write(root.join("b.txt"), "x".repeat(50000)).unwrap();
|
|
std::fs::write(root.join("binary"), [0, 255]).unwrap();
|
|
let r = t
|
|
.call(root, "search_files", &json!({"query":"needle","limit":2}))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r["matches"].as_array().unwrap().len(), 2);
|
|
assert_eq!(r["truncated"], true);
|
|
let r = t
|
|
.call(root, "read_file", &json!({"path":"b.txt"}))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r["long_lines_truncated"], true);
|
|
assert_eq!(r["lines"][0].as_str().unwrap().len(), 4000);
|
|
let r = t
|
|
.call(root, "list_files", &json!({"limit":1}))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r["files"][0], "a.txt");
|
|
assert_eq!(r["truncated"], true);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn symlinks_cannot_read_or_edit_outside_workspace() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let outside = tempfile::tempdir().unwrap();
|
|
std::fs::write(outside.path().join("secret"), "untouched").unwrap();
|
|
std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).unwrap();
|
|
let mut t = CodingTools::default();
|
|
for name in ["read_file", "write_file"] {
|
|
let args = if name == "read_file" {
|
|
json!({"path":"link/secret"})
|
|
} else {
|
|
json!({"path":"link/secret","content":"changed"})
|
|
};
|
|
assert!(t.call(dir.path(), name, &args).await.is_err());
|
|
}
|
|
assert_eq!(
|
|
t.call(dir.path(), "list_files", &json!({})).await.unwrap()["files"],
|
|
json!([])
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(outside.path().join("secret")).unwrap(),
|
|
"untouched"
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[tokio::test]
|
|
async fn process_handles_are_scoped_bounded_and_stoppable() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let root = dir.path();
|
|
let mut t = CodingTools::default();
|
|
let mut other = CodingTools::default();
|
|
let r = t
|
|
.call(
|
|
root,
|
|
"process_start",
|
|
&json!({"command":"printf ready; sleep 30","timeout_seconds":45}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let args = json!({"process_id":r["process_id"]});
|
|
assert!(other.call(root, "process_stop", &args).await.is_err());
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
assert_eq!(
|
|
t.call(root, "process_logs", &args).await.unwrap()["stdout"],
|
|
"ready"
|
|
);
|
|
assert_eq!(
|
|
t.call(root, "process_status", &args).await.unwrap()["status"],
|
|
"running"
|
|
);
|
|
assert_eq!(
|
|
t.call(root, "process_stop", &args).await.unwrap()["status"],
|
|
"stopped"
|
|
);
|
|
assert_eq!(
|
|
t.call(root, "process_stop", &args).await.unwrap()["status"],
|
|
"stopped"
|
|
);
|
|
t.shutdown().await;
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[tokio::test]
|
|
async fn command_timeout_and_output_limits_are_enforced() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut t = CodingTools::default();
|
|
let r = t
|
|
.call(
|
|
dir.path(),
|
|
"run_shell",
|
|
&json!({"command":"sleep 10","timeout_seconds":1,"yield_ms":3000}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r["status"], "timed_out");
|
|
let r=t.call(dir.path(),"run_shell",&json!({"command":"i=0; while [ $i -lt 9000 ]; do printf abcdefgh; i=$((i+1)); done","yield_ms":10000})).await.unwrap();
|
|
assert_eq!(r["status"], "exited");
|
|
assert_eq!(r["exit_code"], 0);
|
|
assert_eq!(r["stdout_truncated"], true);
|
|
assert!(r.to_string().len() < 50000);
|
|
assert!(t
|
|
.call(
|
|
dir.path(),
|
|
"run_shell",
|
|
&json!({"command":"true","timeout_seconds":999999})
|
|
)
|
|
.await
|
|
.is_err());
|
|
t.shutdown().await;
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[tokio::test]
|
|
async fn shutdown_kills_background_descendants() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut t = CodingTools::default();
|
|
let r = t
|
|
.call(
|
|
dir.path(),
|
|
"process_start",
|
|
&json!({"command":"(sleep 2; echo leaked > escaped) & wait"}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
t.shutdown().await;
|
|
assert_eq!(
|
|
t.call(
|
|
dir.path(),
|
|
"process_status",
|
|
&json!({"process_id":r["process_id"]})
|
|
)
|
|
.await
|
|
.unwrap()["status"],
|
|
"stopped"
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(2200)).await;
|
|
assert!(!dir.path().join("escaped").exists());
|
|
}
|