diff options
| author | main <main@swarm.moe> | 2026-03-23 16:51:01 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-23 16:51:01 -0400 |
| commit | 10d4e08bc5d18daa59ddec19a3e2bf345331ccfc (patch) | |
| tree | e0a702e4abff8059dfc7a72bbef599e1e79f896b /crates | |
| parent | c3ad44cf3ec3bcd080f62c19d915ac1749576302 (diff) | |
| download | phone_opus-10d4e08bc5d18daa59ddec19a3e2bf345331ccfc.zip | |
Externalize Claude sandboxing with systemd-run
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/phone-opus/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/phone-opus/src/mcp/service.rs | 293 | ||||
| -rw-r--r-- | crates/phone-opus/tests/mcp_hardening.rs | 158 |
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", |