#![cfg(unix)]
use std::{
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::{Child, ChildStdin, Command, Stdio},
sync::mpsc::{self, Receiver},
time::Duration,
};
use pretty_assertions::assert_eq;
use serde_json::{json, Value};
use tempfile::TempDir;
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
struct AcpServer {
child: Child,
stdin: ChildStdin,
messages: Receiver<Value>,
next_id: u64,
}
impl AcpServer {
fn spawn(home: &Path, cwd: &Path, mode: &str, path: &Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_rho"))
.current_dir(cwd)
.env("HOME", home)
.env("RHO_HOME", home.join(".rho"))
.env("RHO_AUTOMATION_TEST_MODE", mode)
.env("RHO_AUTOMATION_TEST_PATH", path)
.env_remove("RHO_AUTOMATION_TEST_RESPONSE")
.env_remove("HERDR_ENV")
.env_remove("HERDR_SOCKET_PATH")
.env_remove("HERDR_PANE_ID")
.arg("--config")
.arg(home.join("config.toml"))
.arg("acp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn rho acp");
let stdin = child.stdin.take().expect("acp stdin");
let stdout = child.stdout.take().expect("acp stdout");
let (sender, messages) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
let Ok(line) = line else { return };
let message = serde_json::from_str(&line).expect("stdout is JSON-RPC only");
if sender.send(message).is_err() {
return;
}
}
});
Self {
child,
stdin,
messages,
next_id: 0,
}
}
fn send(&mut self, message: &Value) {
writeln!(self.stdin, "{message}").expect("write request");
self.stdin.flush().expect("flush request");
}
fn request(&mut self, method: &str, params: Value) -> (Result<Value, Value>, Vec<Value>) {
self.next_id += 1;
let id = self.next_id;
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
let mut updates = Vec::new();
loop {
let message = self
.messages
.recv_timeout(REPLY_TIMEOUT)
.unwrap_or_else(|error| panic!("no reply to {method}: {error}"));
if message["id"] == id && message.get("method").is_none() {
let reply = match message.get("error") {
Some(error) => Err(error.clone()),
None => Ok(message["result"].clone()),
};
return (reply, updates);
}
match message["method"].as_str() {
Some("session/update") => updates.push(message["params"]["update"].clone()),
Some(other) => panic!("unexpected agent message {other}: {message}"),
None => panic!("reply to an unknown request: {message}"),
}
}
}
fn prompt(&mut self, session_id: &str, text: &str) -> String {
let (reply, updates) = self.request(
"session/prompt",
json!({"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}),
);
reply.unwrap_or_else(|error| panic!("prompt failed: {error}"));
updates
.iter()
.filter(|update| update["sessionUpdate"] == "agent_message_chunk")
.filter_map(|update| update["content"]["text"].as_str())
.collect()
}
}
impl Drop for AcpServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
struct Workspace {
home: TempDir,
project: PathBuf,
extra: PathBuf,
}
impl Workspace {
fn new() -> Self {
let home = TempDir::new().unwrap();
std::fs::write(
home.path().join("config.toml"),
r#"provider = "xai"
model = "grok-fixture"
auth = "xai-oauth"
[behavior]
permission_mode = "plan"
[web_search]
hosted = false
provider = "disabled"
"#,
)
.unwrap();
let root = home.path().canonicalize().unwrap();
let project = root.join("project");
let extra = root.join("shared-lib");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&extra).unwrap();
std::fs::write(extra.join("notes.txt"), "extra root content").unwrap();
Self {
home,
project,
extra,
}
}
fn server(&self) -> AcpServer {
AcpServer::spawn(
self.home.path(),
&self.project,
"read-path",
&self.extra.join("notes.txt"),
)
}
}
fn read_the_extra_file(reply: &str) -> bool {
reply.lines().last() == Some("1:extra root content")
}
#[test]
fn additional_directories_scope_new_and_loaded_sessions() {
let workspace = Workspace::new();
let mut server = workspace.server();
let (initialized, _) = server.request("initialize", json!({"protocolVersion": 1}));
assert_eq!(
initialized.unwrap()["agentCapabilities"]["sessionCapabilities"]["additionalDirectories"],
json!({})
);
let extra = workspace.extra.to_str().unwrap();
let (created, _) = server.request(
"session/new",
json!({
"cwd": workspace.project,
"mcpServers": [],
"additionalDirectories": [extra],
}),
);
let session_id = created.unwrap()["sessionId"]
.as_str()
.expect("session id")
.to_owned();
let reply = server.prompt(&session_id, "read the shared notes");
assert!(read_the_extra_file(&reply), "{reply}");
for (label, additional, readable) in [
("load without roots", json!([]), false),
("load with roots", json!([extra]), true),
] {
let (loaded, _) = server.request(
"session/load",
json!({
"sessionId": session_id,
"cwd": workspace.project,
"mcpServers": [],
"additionalDirectories": additional,
}),
);
loaded.unwrap_or_else(|error| panic!("{label}: {error}"));
let reply = server.prompt(&session_id, "read the shared notes");
assert_eq!(read_the_extra_file(&reply), readable, "{label}: {reply}");
}
}
#[test]
fn additional_directories_reject_entries_they_cannot_grant() {
let workspace = Workspace::new();
let mut server = workspace.server();
server
.request("initialize", json!({"protocolVersion": 1}))
.0
.unwrap();
let missing = workspace.extra.join("missing");
let file = workspace.extra.join("notes.txt");
for (label, entry) in [
("relative", json!("shared-lib")),
("empty", json!("")),
("missing", json!(missing)),
("file", json!(file)),
] {
let (created, _) = server.request(
"session/new",
json!({
"cwd": workspace.project,
"mcpServers": [],
"additionalDirectories": [workspace.extra, entry],
}),
);
let error = created.expect_err(label);
assert_eq!(error["code"], -32602, "{label}: {error}");
}
}