swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/phone-opus
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-23 16:51:01 -0400
committermain <main@swarm.moe>2026-03-23 16:51:01 -0400
commit10d4e08bc5d18daa59ddec19a3e2bf345331ccfc (patch)
treee0a702e4abff8059dfc7a72bbef599e1e79f896b /crates/phone-opus
parentc3ad44cf3ec3bcd080f62c19d915ac1749576302 (diff)
downloadphone_opus-10d4e08bc5d18daa59ddec19a3e2bf345331ccfc.zip
Externalize Claude sandboxing with systemd-run
Diffstat (limited to 'crates/phone-opus')
-rw-r--r--crates/phone-opus/Cargo.toml1
-rw-r--r--crates/phone-opus/src/mcp/service.rs293
-rw-r--r--crates/phone-opus/tests/mcp_hardening.rs158
3 files changed, 435 insertions, 17 deletions
diff --git a/crates/phone-opus/Cargo.toml b/crates/phone-opus/Cargo.toml
index b96bab3..c7626c6 100644
--- a/crates/phone-opus/Cargo.toml
+++ b/crates/phone-opus/Cargo.toml
@@ -17,6 +17,7 @@ libmcp.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
+users.workspace = true
uuid.workspace = true
[dev-dependencies]
diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs
index 5b38c4b..bd9b31f 100644
--- a/crates/phone-opus/src/mcp/service.rs
+++ b/crates/phone-opus/src/mcp/service.rs
@@ -1,4 +1,4 @@
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
@@ -10,6 +10,7 @@ use libmcp::{Generation, SurfaceKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use thiserror::Error;
+use users::get_current_uid;
use uuid::Uuid;
use crate::mcp::fault::{FaultRecord, FaultStage};
@@ -541,6 +542,173 @@ impl ConsultResponse {
}
}
+const SYSTEMD_RUN_BINARY: &str = "systemd-run";
+const DEFAULT_PATH: &str = "/usr/local/bin:/usr/bin:/bin";
+const PHONE_OPUS_STATE_ROOT_NAME: &str = "phone_opus";
+const CLAUDE_HOME_DIR_NAME: &str = "claude-home";
+const XDG_CONFIG_DIR_NAME: &str = "xdg-config";
+const XDG_CACHE_DIR_NAME: &str = "xdg-cache";
+const XDG_STATE_DIR_NAME: &str = "xdg-state";
+const SHARED_TMP_ROOTS: [&str; 2] = ["/tmp", "/var/tmp"];
+const CLAUDE_SEED_FILES: [&str; 5] = [
+ ".credentials.json",
+ "settings.json",
+ "settings.local.json",
+ ".claude/settings.local.json",
+ "CLAUDE.md",
+];
+const SERVICE_ENV_ALLOWLIST: [&str; 19] = [
+ "LANG",
+ "LC_ALL",
+ "LC_CTYPE",
+ "TERM",
+ "COLORTERM",
+ "USER",
+ "LOGNAME",
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "NO_PROXY",
+ "ALL_PROXY",
+ "http_proxy",
+ "https_proxy",
+ "no_proxy",
+ "all_proxy",
+ "SSL_CERT_FILE",
+ "SSL_CERT_DIR",
+ "REQUESTS_CA_BUNDLE",
+ "NODE_EXTRA_CA_CERTS",
+];
+const SERVICE_ENV_PREFIX_ALLOWLIST: [&str; 2] = ["ANTHROPIC_", "PHONE_OPUS_TEST_"];
+
+#[derive(Debug, Clone)]
+struct ClaudeSandbox {
+ source_home: PathBuf,
+ state_root: PathBuf,
+ claude_home: PathBuf,
+ xdg_config_home: PathBuf,
+ xdg_cache_home: PathBuf,
+ xdg_state_home: PathBuf,
+}
+
+impl ClaudeSandbox {
+ fn prepare() -> io::Result<Self> {
+ let source_home = caller_home_dir().ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::NotFound,
+ "failed to resolve the caller home directory",
+ )
+ })?;
+ let state_root = phone_opus_state_root()?;
+ let sandbox = Self {
+ source_home,
+ claude_home: state_root.join(CLAUDE_HOME_DIR_NAME),
+ xdg_config_home: state_root.join(XDG_CONFIG_DIR_NAME),
+ xdg_cache_home: state_root.join(XDG_CACHE_DIR_NAME),
+ xdg_state_home: state_root.join(XDG_STATE_DIR_NAME),
+ state_root,
+ };
+ sandbox.create_layout()?;
+ sandbox.sync_seed_claude_files()?;
+ Ok(sandbox)
+ }
+
+ fn create_layout(&self) -> io::Result<()> {
+ fs::create_dir_all(&self.claude_home)?;
+ fs::create_dir_all(self.claude_config_dir())?;
+ fs::create_dir_all(&self.xdg_config_home)?;
+ fs::create_dir_all(&self.xdg_cache_home)?;
+ fs::create_dir_all(&self.xdg_state_home)?;
+ Ok(())
+ }
+
+ fn claude_config_dir(&self) -> PathBuf {
+ self.claude_home.join(".claude")
+ }
+
+ fn source_claude_dir(&self) -> PathBuf {
+ self.source_home.join(".claude")
+ }
+
+ fn sync_seed_claude_files(&self) -> io::Result<()> {
+ let source_root = self.source_claude_dir();
+ let destination_root = self.claude_config_dir();
+ for relative in CLAUDE_SEED_FILES {
+ sync_optional_seed_file(
+ source_root.join(relative).as_path(),
+ destination_root.join(relative).as_path(),
+ )?;
+ }
+ Ok(())
+ }
+
+ fn read_only_paths(&self, request: &ConsultRequest) -> BTreeSet<PathBuf> {
+ let cwd = request.cwd.as_path();
+ let mut paths = BTreeSet::from([self.source_home.clone()]);
+ if self.force_read_only_cwd(cwd) {
+ let _ = paths.insert(cwd.to_path_buf());
+ }
+ paths
+ }
+
+ fn force_read_only_cwd(&self, cwd: &Path) -> bool {
+ self.read_write_paths()
+ .iter()
+ .any(|path| cwd.starts_with(path))
+ && !SHARED_TMP_ROOTS.iter().any(|root| cwd == Path::new(root))
+ }
+
+ fn read_write_paths(&self) -> BTreeSet<PathBuf> {
+ let mut paths = BTreeSet::new();
+ let _ = paths.insert(self.state_root.clone());
+ for root in SHARED_TMP_ROOTS {
+ let _ = paths.insert(PathBuf::from(root));
+ }
+ paths
+ }
+
+ fn service_environment(&self) -> BTreeMap<String, String> {
+ let runtime_dir = caller_runtime_dir();
+ let mut environment = BTreeMap::from([
+ ("HOME".to_owned(), self.claude_home.display().to_string()),
+ (
+ "XDG_CONFIG_HOME".to_owned(),
+ self.xdg_config_home.display().to_string(),
+ ),
+ (
+ "XDG_CACHE_HOME".to_owned(),
+ self.xdg_cache_home.display().to_string(),
+ ),
+ (
+ "XDG_STATE_HOME".to_owned(),
+ self.xdg_state_home.display().to_string(),
+ ),
+ (
+ "XDG_RUNTIME_DIR".to_owned(),
+ runtime_dir.display().to_string(),
+ ),
+ (
+ "DBUS_SESSION_BUS_ADDRESS".to_owned(),
+ caller_dbus_session_bus_address(runtime_dir.as_path()),
+ ),
+ ("PATH".to_owned(), caller_path()),
+ ]);
+ for name in SERVICE_ENV_ALLOWLIST {
+ if let Some(value) = caller_env(name) {
+ let _ = environment.insert(name.to_owned(), value);
+ }
+ }
+ for (name, value) in std::env::vars() {
+ if SERVICE_ENV_PREFIX_ALLOWLIST
+ .iter()
+ .any(|prefix| name.starts_with(prefix))
+ {
+ let _ = environment.insert(name, value);
+ }
+ }
+ environment
+ }
+}
+
fn deserialize<T: for<'de> Deserialize<'de>>(
value: Value,
operation: &str,
@@ -824,16 +992,7 @@ fn background_job_tool_output(
}
fn background_consult_job_root() -> io::Result<PathBuf> {
- let root = state_dir()
- .map(|root| root.join("phone_opus"))
- .or_else(|| home_dir().map(|home| home.join(".local").join("state").join("phone_opus")))
- .ok_or_else(|| {
- io::Error::new(
- io::ErrorKind::NotFound,
- "failed to resolve phone_opus state root",
- )
- })?;
- let path = root.join("mcp").join("consult_jobs");
+ let path = phone_opus_state_root()?.join("mcp").join("consult_jobs");
fs::create_dir_all(&path)?;
Ok(path)
}
@@ -901,8 +1060,46 @@ fn unix_ms_now() -> u64 {
}
fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInvocationError> {
- let mut command = Command::new(claude_binary());
+ let sandbox = ClaudeSandbox::prepare().map_err(ConsultInvocationError::Spawn)?;
+ let mut command = Command::new(SYSTEMD_RUN_BINARY);
+ let runtime_dir = caller_runtime_dir();
+ let _ = command
+ .env("XDG_RUNTIME_DIR", runtime_dir.as_os_str())
+ .env(
+ "DBUS_SESSION_BUS_ADDRESS",
+ caller_dbus_session_bus_address(runtime_dir.as_path()),
+ )
+ .arg("--user")
+ .arg("--wait")
+ .arg("--pipe")
+ .arg("--collect")
+ .arg("--quiet")
+ .arg("--working-directory")
+ .arg(request.cwd.as_path());
+ for property in [
+ "ProtectSystem=strict",
+ "NoNewPrivileges=yes",
+ "PrivateDevices=yes",
+ "RestrictSUIDSGID=yes",
+ "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
+ ] {
+ let _ = command.arg("-p").arg(property);
+ }
+ for path in sandbox.read_write_paths() {
+ let _ = command
+ .arg("-p")
+ .arg(format!("ReadWritePaths={}", path.display()));
+ }
+ for path in sandbox.read_only_paths(request) {
+ let _ = command
+ .arg("-p")
+ .arg(format!("ReadOnlyPaths={}", path.display()));
+ }
+ for (name, value) in sandbox.service_environment() {
+ let _ = command.arg(format!("--setenv={name}={value}"));
+ }
let _ = command
+ .arg(claude_binary())
.arg("-p")
.arg("--output-format")
.arg("json")
@@ -917,8 +1114,7 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv
.arg(CLAUDE_EFFORT)
.arg("--tools")
.arg(CLAUDE_TOOLSET)
- .arg("--permission-mode")
- .arg("dontAsk");
+ .arg("--dangerously-skip-permissions");
if let Some(session) = request.session.as_ref() {
let _ = command.arg("--resume").arg(session.display());
}
@@ -926,7 +1122,6 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv
let _ = command.arg("--max-turns").arg(max_turns.get().to_string());
}
let output = command
- .current_dir(request.cwd.as_path())
.arg(request.prompt.rendered())
.output()
.map_err(ConsultInvocationError::Spawn)?;
@@ -997,6 +1192,74 @@ fn claude_binary() -> PathBuf {
.unwrap_or_else(|| PathBuf::from("claude"))
}
+fn phone_opus_state_root() -> io::Result<PathBuf> {
+ let base = std::env::var_os("XDG_STATE_HOME")
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from)
+ .or_else(state_dir)
+ .or_else(|| caller_home_dir().map(|home| home.join(".local").join("state")))
+ .ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::NotFound,
+ "failed to resolve phone_opus state root",
+ )
+ })?;
+ let root = base.join(PHONE_OPUS_STATE_ROOT_NAME);
+ fs::create_dir_all(&root)?;
+ Ok(root)
+}
+
+fn caller_home_dir() -> Option<PathBuf> {
+ std::env::var_os("HOME")
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from)
+ .or_else(home_dir)
+}
+
+fn caller_env(name: &str) -> Option<String> {
+ std::env::var(name).ok().filter(|value| !value.is_empty())
+}
+
+fn caller_path() -> String {
+ caller_env("PATH").unwrap_or_else(|| DEFAULT_PATH.to_owned())
+}
+
+fn caller_runtime_dir() -> PathBuf {
+ std::env::var_os("XDG_RUNTIME_DIR")
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(format!("/run/user/{}", get_current_uid())))
+}
+
+fn caller_dbus_session_bus_address(runtime_dir: &Path) -> String {
+ caller_env("DBUS_SESSION_BUS_ADDRESS")
+ .unwrap_or_else(|| format!("unix:path={}", runtime_dir.join("bus").display()))
+}
+
+fn sync_optional_seed_file(source: &Path, destination: &Path) -> io::Result<()> {
+ if source.exists() {
+ let bytes = fs::read(source)?;
+ write_bytes_file(destination, &bytes)?;
+ } else if destination.exists() {
+ fs::remove_file(destination)?;
+ }
+ Ok(())
+}
+
+fn write_bytes_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
+ let parent = path.parent().ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!("path `{}` has no parent directory", path.display()),
+ )
+ })?;
+ fs::create_dir_all(parent)?;
+ let temp_path = path.with_extension(format!("tmp-{}", Uuid::new_v4()));
+ fs::write(&temp_path, bytes)?;
+ fs::rename(temp_path, path)?;
+ Ok(())
+}
+
fn consult_output(
request: &ConsultRequest,
response: &ConsultResponse,
diff --git a/crates/phone-opus/tests/mcp_hardening.rs b/crates/phone-opus/tests/mcp_hardening.rs
index a1fb6ae..f65c254 100644
--- a/crates/phone-opus/tests/mcp_hardening.rs
+++ b/crates/phone-opus/tests/mcp_hardening.rs
@@ -12,6 +12,7 @@ use libmcp_testkit::read_json_lines;
use serde as _;
use serde_json::{Value, json};
use thiserror as _;
+use users as _;
use uuid as _;
use phone_opus_test_support::PROMPT_PREFIX;
@@ -184,9 +185,27 @@ set -eu
if [ -n "${PHONE_OPUS_TEST_PWD_FILE:-}" ]; then
pwd >"$PHONE_OPUS_TEST_PWD_FILE"
fi
+if [ -n "${PHONE_OPUS_TEST_ENV_FILE:-}" ]; then
+ {
+ printf 'HOME=%s\n' "${HOME:-}"
+ printf 'XDG_CONFIG_HOME=%s\n' "${XDG_CONFIG_HOME:-}"
+ printf 'XDG_CACHE_HOME=%s\n' "${XDG_CACHE_HOME:-}"
+ printf 'XDG_STATE_HOME=%s\n' "${XDG_STATE_HOME:-}"
+ } >"$PHONE_OPUS_TEST_ENV_FILE"
+fi
if [ -n "${PHONE_OPUS_TEST_ARGS_FILE:-}" ]; then
printf '%s\n' "$@" >"$PHONE_OPUS_TEST_ARGS_FILE"
fi
+if [ -n "${PHONE_OPUS_TEST_CWD_WRITE_PROBE_FILE:-}" ]; then
+ probe_target="${PWD}/.phone_opus_write_probe"
+ probe_error="${PHONE_OPUS_TEST_CWD_WRITE_ERROR_FILE:-/tmp/phone-opus-write.err}"
+ if printf probe >"$probe_target" 2>"$probe_error"; then
+ printf 'write_succeeded\n' >"$PHONE_OPUS_TEST_CWD_WRITE_PROBE_FILE"
+ rm -f "$probe_target"
+ else
+ printf 'write_failed\n' >"$PHONE_OPUS_TEST_CWD_WRITE_PROBE_FILE"
+ fi
+fi
if [ -n "${PHONE_OPUS_TEST_STDERR:-}" ]; then
printf '%s\n' "$PHONE_OPUS_TEST_STDERR" >&2
fi
@@ -205,6 +224,50 @@ exit "${PHONE_OPUS_TEST_EXIT_CODE:-0}"
Ok(())
}
+fn seed_caller_claude_home(home: &Path) -> TestResult {
+ let claude_root = home.join(".claude");
+ must(
+ fs::create_dir_all(claude_root.join(".claude")),
+ "create caller .claude tree",
+ )?;
+ must(
+ fs::write(
+ claude_root.join(".credentials.json"),
+ "{\n \"auth\": \"token\"\n}\n",
+ ),
+ "write caller credentials",
+ )?;
+ must(
+ fs::write(
+ claude_root.join("settings.json"),
+ "{\n \"theme\": \"default\"\n}\n",
+ ),
+ "write caller settings",
+ )?;
+ must(
+ fs::write(
+ claude_root.join("settings.local.json"),
+ "{\n \"profile\": \"local\"\n}\n",
+ ),
+ "write caller local settings",
+ )?;
+ must(
+ fs::write(
+ claude_root.join(".claude").join("settings.local.json"),
+ "{\n \"sandbox\": \"read-only\"\n}\n",
+ ),
+ "write nested caller local settings",
+ )?;
+ must(
+ fs::write(
+ claude_root.join("CLAUDE.md"),
+ "Global Claude instructions for phone_opus tests.\n",
+ ),
+ "write caller CLAUDE.md",
+ )?;
+ Ok(())
+}
+
#[test]
fn cold_start_exposes_consult_and_ops_tools() -> TestResult {
let root = temp_root("cold_start")?;
@@ -239,13 +302,19 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki
let root = temp_root("consult_success")?;
let state_home = root.join("state-home");
let sandbox = root.join("sandbox");
+ let caller_home = root.join("caller-home");
must(fs::create_dir_all(&state_home), "create state home")?;
must(fs::create_dir_all(&sandbox), "create sandbox")?;
+ must(fs::create_dir_all(&caller_home), "create caller home")?;
+ seed_caller_claude_home(&caller_home)?;
let fake_claude = root.join("claude");
let stdout_file = root.join("stdout.json");
let args_file = root.join("args.txt");
let pwd_file = root.join("pwd.txt");
+ let env_file = root.join("env.txt");
+ let cwd_probe_file = root.join("cwd-write-probe.txt");
+ let cwd_probe_error_file = root.join("cwd-write-probe.err");
let resumed_session = "81f218eb-568b-409b-871b-f6e86d8f666f";
write_fake_claude_script(&fake_claude)?;
must(
@@ -284,11 +353,25 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki
let stdout_path = stdout_file.display().to_string();
let args_path = args_file.display().to_string();
let pwd_path = pwd_file.display().to_string();
+ let env_path = env_file.display().to_string();
+ let cwd_probe_path = cwd_probe_file.display().to_string();
+ let cwd_probe_error_path = cwd_probe_error_file.display().to_string();
+ let caller_home_path = caller_home.display().to_string();
let env = [
+ ("HOME", caller_home_path.as_str()),
("PHONE_OPUS_CLAUDE_BIN", claude_bin.as_str()),
("PHONE_OPUS_TEST_STDOUT_FILE", stdout_path.as_str()),
("PHONE_OPUS_TEST_ARGS_FILE", args_path.as_str()),
("PHONE_OPUS_TEST_PWD_FILE", pwd_path.as_str()),
+ ("PHONE_OPUS_TEST_ENV_FILE", env_path.as_str()),
+ (
+ "PHONE_OPUS_TEST_CWD_WRITE_PROBE_FILE",
+ cwd_probe_path.as_str(),
+ ),
+ (
+ "PHONE_OPUS_TEST_CWD_WRITE_ERROR_FILE",
+ cwd_probe_error_path.as_str(),
+ ),
];
let mut harness = McpHarness::spawn(&state_home, &env)?;
let _ = harness.initialize()?;
@@ -347,8 +430,9 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki
assert!(lines.contains(&"max"));
assert!(lines.contains(&"--tools"));
assert!(lines.contains(&"Bash,Read,Grep,Glob,LS,WebFetch,WebSearch"));
- assert!(lines.contains(&"--permission-mode"));
- assert!(lines.contains(&"dontAsk"));
+ assert!(lines.contains(&"--dangerously-skip-permissions"));
+ assert!(!lines.contains(&"--permission-mode"));
+ assert!(!lines.contains(&"dontAsk"));
assert!(lines.contains(&"--resume"));
assert!(lines.contains(&resumed_session));
assert!(lines.contains(&"--max-turns"));
@@ -359,6 +443,64 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki
let user_prompt_index = must_some(args.find("say oracle"), "user prompt inside args")?;
assert!(prefix_index < user_prompt_index);
+ let env_dump = must(fs::read_to_string(&env_file), "read fake env file")?;
+ let state_root = state_home.join("phone_opus");
+ let claude_home = state_root.join("claude-home");
+ let xdg_config_home = state_root.join("xdg-config");
+ let xdg_cache_home = state_root.join("xdg-cache");
+ let xdg_state_home = state_root.join("xdg-state");
+ assert!(env_dump.contains(format!("HOME={}", claude_home.display()).as_str()));
+ assert!(env_dump.contains(format!("XDG_CONFIG_HOME={}", xdg_config_home.display()).as_str()));
+ assert!(env_dump.contains(format!("XDG_CACHE_HOME={}", xdg_cache_home.display()).as_str()));
+ assert!(env_dump.contains(format!("XDG_STATE_HOME={}", xdg_state_home.display()).as_str()));
+
+ assert_eq!(
+ must(
+ fs::read_to_string(claude_home.join(".claude").join(".credentials.json")),
+ "read mirrored credentials"
+ )?,
+ "{\n \"auth\": \"token\"\n}\n"
+ );
+ assert_eq!(
+ must(
+ fs::read_to_string(claude_home.join(".claude").join("settings.json")),
+ "read mirrored settings"
+ )?,
+ "{\n \"theme\": \"default\"\n}\n"
+ );
+ assert_eq!(
+ must(
+ fs::read_to_string(claude_home.join(".claude").join("settings.local.json")),
+ "read mirrored local settings"
+ )?,
+ "{\n \"profile\": \"local\"\n}\n"
+ );
+ assert_eq!(
+ must(
+ fs::read_to_string(
+ claude_home
+ .join(".claude")
+ .join(".claude")
+ .join("settings.local.json")
+ ),
+ "read mirrored nested local settings"
+ )?,
+ "{\n \"sandbox\": \"read-only\"\n}\n"
+ );
+ assert_eq!(
+ must(
+ fs::read_to_string(claude_home.join(".claude").join("CLAUDE.md")),
+ "read mirrored CLAUDE.md"
+ )?,
+ "Global Claude instructions for phone_opus tests.\n"
+ );
+
+ let cwd_probe = must(
+ fs::read_to_string(&cwd_probe_file),
+ "read cwd write probe result",
+ )?;
+ assert_eq!(cwd_probe.trim(), "write_failed");
+
let telemetry = harness.call_tool(4, "telemetry_snapshot", json!({}))?;
assert_tool_ok(&telemetry);
let hot_methods = tool_content(&telemetry)["hot_methods"]
@@ -378,8 +520,10 @@ fn consult_can_run_in_background_and_be_polled() -> TestResult {
let root = temp_root("consult_background")?;
let state_home = root.join("state-home");
let sandbox = root.join("sandbox");
+ let caller_home = root.join("caller-home");
must(fs::create_dir_all(&state_home), "create state home")?;
must(fs::create_dir_all(&sandbox), "create sandbox")?;
+ must(fs::create_dir_all(&caller_home), "create caller home")?;
let fake_claude = root.join("claude");
let stdout_file = root.join("stdout.json");
@@ -422,7 +566,9 @@ fn consult_can_run_in_background_and_be_polled() -> TestResult {
let stdout_path = stdout_file.display().to_string();
let args_path = args_file.display().to_string();
let pwd_path = pwd_file.display().to_string();
+ let caller_home_path = caller_home.display().to_string();
let env = [
+ ("HOME", caller_home_path.as_str()),
("PHONE_OPUS_CLAUDE_BIN", claude_bin.as_str()),
("PHONE_OPUS_TEST_STDOUT_FILE", stdout_path.as_str()),
("PHONE_OPUS_TEST_ARGS_FILE", args_path.as_str()),
@@ -527,11 +673,15 @@ fn consult_surfaces_downstream_cli_failures() -> TestResult {
let root = temp_root("consult_failure")?;
let state_home = root.join("state-home");
let fake_claude = root.join("claude");
+ let caller_home = root.join("caller-home");
must(fs::create_dir_all(&state_home), "create state home")?;
+ must(fs::create_dir_all(&caller_home), "create caller home")?;
write_fake_claude_script(&fake_claude)?;
let claude_bin = fake_claude.display().to_string();
+ let caller_home_path = caller_home.display().to_string();
let env = [
+ ("HOME", caller_home_path.as_str()),
("PHONE_OPUS_CLAUDE_BIN", claude_bin.as_str()),
("PHONE_OPUS_TEST_EXIT_CODE", "17"),
("PHONE_OPUS_TEST_STDERR", "permission denied by fake claude"),
@@ -559,11 +709,15 @@ fn consult_never_replays_after_worker_transport_failure() -> TestResult {
let root = temp_root("consult_no_replay")?;
let state_home = root.join("state-home");
let fake_claude = root.join("claude");
+ let caller_home = root.join("caller-home");
must(fs::create_dir_all(&state_home), "create state home")?;
+ must(fs::create_dir_all(&caller_home), "create caller home")?;
write_fake_claude_script(&fake_claude)?;
let claude_bin = fake_claude.display().to_string();
+ let caller_home_path = caller_home.display().to_string();
let env = [
+ ("HOME", caller_home_path.as_str()),
("PHONE_OPUS_CLAUDE_BIN", claude_bin.as_str()),
(
"PHONE_OPUS_MCP_TEST_WORKER_CRASH_ONCE_KEY",