use super::*;
use super::super::managed_launch::{ManagedLaunch, resume_spec, spawn_spec};
use crate::runtime::test_helpers::FakeTmux;
fn scope_launch(config_dir: Option<&Path>) -> ManagedLaunch<'_> {
ManagedLaunch {
cwd: Path::new(SCOPE_CWD),
claude_bin: "claude",
config_dir,
session_id: SCOPE_SESSION_ID,
prompt_file: None,
oauth_token: None,
gh_env: &[],
mcp_env: &[],
mcp_config: config_dir.map(|_| Path::new(SCOPE_MCP_CONFIG)),
memory_reachable: true,
alternate_screen: false,
profile: Default::default(),
}
}
const SCOPE_MCP_CONFIG: &str = "/tm/state/session-mcp/abc123.json";
const SCOPE_SESSION_ID: &str = "99999999-8888-7777-6666-555555555555";
const SCOPE_CWD: &str = "/tmp/scope-ws";
const SCOPE_CONFIG_DIR: &str = "/tm/claude-config";
fn expected_path() -> String {
SCOPE_MCP_CONFIG.to_owned()
}
fn inplace_expected_path() -> String {
crate::core::session_mcp_scope::scoped_for(
Path::new(SCOPE_CWD),
Some(Path::new(SCOPE_CONFIG_DIR)),
)
.expect("HOME resolves in a test environment")
.display()
.to_string()
}
#[test]
#[serial_test::serial]
fn spawn_command_carries_the_mcp_config_flag_and_not_strict() {
let args = spawn_spec(&scope_launch(Some(Path::new(SCOPE_CONFIG_DIR)))).args;
let cmd = args.join(" ");
assert!(
!cmd.contains("--strict-mcp-config"),
"a relocated spawn must not narrow the operator's user scope: {cmd}"
);
assert!(
cmd.contains(&format!("--mcp-config {}", expected_path())),
"the flag must name this session's own composed file as an argv token: {cmd}"
);
}
#[test]
#[serial_test::serial]
fn spawn_command_omits_the_mcp_config_flag_without_a_config_dir() {
let cmd = spawn_spec(&scope_launch(None)).args.join(" ");
assert!(
!cmd.contains("--mcp-config"),
"a spawn reading the operator's own ~/.claude.json is not tm's to scope: {cmd}"
);
}
#[test]
#[serial_test::serial]
fn resume_command_carries_the_mcp_config_flag_and_not_strict() {
let args = resume_spec(
&scope_launch(Some(Path::new(SCOPE_CONFIG_DIR))),
Some("abc-123"),
)
.args;
let cmd = args.join(" ");
assert!(
!cmd.contains("--strict-mcp-config"),
"a resumed pane must be scoped exactly like a fresh one: {cmd}"
);
assert!(
cmd.contains(&format!("--mcp-config {}", expected_path())),
"{cmd}"
);
}
#[test]
#[serial_test::serial]
fn resume_command_omits_the_mcp_config_flag_without_a_config_dir() {
let cmd = resume_spec(&scope_launch(None), Some("abc-123"))
.args
.join(" ");
assert!(!cmd.contains("--mcp-config"), "{cmd}");
}
#[test]
#[serial_test::serial]
fn compose_inplace_args_carries_the_mcp_config_flag_unquoted() {
let args = compose_inplace_args(
Path::new(SCOPE_CWD),
Some(Path::new(SCOPE_CONFIG_DIR)),
None,
None,
);
let pos = args
.iter()
.position(|a| a == "--mcp-config")
.expect("the in-place relaunch must carry --mcp-config");
assert!(!args.iter().any(|a| a == "--strict-mcp-config"), "{args:?}");
assert_eq!(
args[pos + 1],
inplace_expected_path(),
"exec takes the token verbatim — a quoted path names a file claude cannot open"
);
}
#[test]
#[serial_test::serial]
fn compose_inplace_args_omits_the_mcp_config_flag_without_a_config_dir() {
let args = compose_inplace_args(Path::new(SCOPE_CWD), None, None, None);
assert!(!args.iter().any(|a| a == "--mcp-config"), "{args:?}");
}
fn poisoned_state_root() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join(".trusty-mpm");
let dir = crate::core::paths::FrameworkPaths::from_root(&root)
.crate_config_root()
.join(crate::core::session_mcp_scope::SESSION_MCP_DIR);
std::fs::create_dir_all(dir.parent().expect("the state dir has a parent")).unwrap();
std::fs::write(&dir, "not a directory").unwrap();
(tmp, root)
}
struct PoisonedHome {
prev: Option<std::ffi::OsString>,
_tmp: tempfile::TempDir,
}
impl PoisonedHome {
fn set() -> Self {
let tmp = tempfile::tempdir().expect("tempdir");
let prev = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", tmp.path()) };
let file = crate::core::session_mcp_scope::session_mcp_path(Path::new("/tmp"))
.expect("HOME was just set");
let dir = file.parent().expect("the composed file has a parent");
std::fs::create_dir_all(dir.parent().expect("…which has a parent")).unwrap();
std::fs::write(dir, "not a directory").unwrap();
Self { prev, _tmp: tmp }
}
}
impl Drop for PoisonedHome {
fn drop(&mut self) {
match self.prev.take() {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
fn assert_scope_failure(err: &RuntimeError) {
let text = err.to_string();
assert!(
matches!(err, RuntimeError::Spawn(_)),
"a composition failure must abandon the spawn, got {err:?}"
);
assert!(
text.contains("refusing to launch"),
"the error must say why it did not fall back to the shared map: {text}"
);
}
#[test]
fn spawn_fails_when_the_composed_file_cannot_be_written() {
let (_tmp, root) = poisoned_state_root();
let fake = FakeTmux::new();
let adapter = ClaudeCodeAdapter::new(fake.clone(), None, &root);
let err = adapter
.spawn(
"tmpm-scope",
Path::new("/tmp"),
"task",
SCOPE_SESSION_ID,
&[],
)
.expect_err("an unwritable state root must abandon the spawn");
assert_scope_failure(&err);
assert!(
fake.sends.lock().unwrap().is_empty(),
"no command may reach the pane once composition failed"
);
}
#[test]
fn spawn_resume_fails_when_the_composed_file_cannot_be_written() {
let (_tmp, root) = poisoned_state_root();
let fake = FakeTmux::new();
let adapter = ClaudeCodeAdapter::new(fake.clone(), None, &root);
let err = adapter
.spawn_resume(
"tmpm-scope",
None,
Path::new("/tmp"),
"task",
Some("abc-123"),
SCOPE_SESSION_ID,
&[],
)
.expect_err("a resumed pane gets the same fail-closed arm as a fresh one");
assert_scope_failure(&err);
assert!(
fake.sends.lock().unwrap().is_empty(),
"no command may reach the pane once composition failed"
);
}
#[test]
#[serial_test::serial]
fn build_inplace_resume_command_fails_when_the_composed_file_cannot_be_written() {
let _home = PoisonedHome::set();
let err = build_inplace_resume_command(Path::new("/tmp"), Some("abc-123"))
.expect_err("the in-place relaunch must not drop the flag and continue");
assert_scope_failure(&err);
}