use std::path::{Path, PathBuf};
use crate::core::config::MpmConfig;
use crate::core::delegation_authority::AgentSummary;
pub fn write_prompt_file(prompt: &str) -> Option<PathBuf> {
let file = std::env::temp_dir().join(format!(
"trusty-mpm-system-prompt-{}.txt",
uuid::Uuid::new_v4()
));
match std::fs::write(&file, prompt) {
Ok(()) => Some(file),
Err(err) => {
tracing::warn!("failed to write system prompt file: {err}");
None
}
}
}
pub fn resolve_pm_model(config: &MpmConfig, explicit: Option<&str>) -> String {
crate::core::config::resolve_agent_model(config, "pm", None, explicit)
}
pub const SETTING_SOURCES_FLAG: &str = "--setting-sources project,local";
pub const SETTING_SOURCES_FLAG_RELOCATED: &str = "--setting-sources user,project,local";
pub fn setting_sources_flag(config_dir: Option<&Path>) -> &'static str {
if config_dir.is_some() {
SETTING_SOURCES_FLAG_RELOCATED
} else {
SETTING_SOURCES_FLAG
}
}
pub fn setting_source_tiers_of(flag: &'static str) -> Vec<&'static str> {
flag.split_once(' ')
.map(|(_, list)| list.split(',').map(str::trim).collect())
.unwrap_or_default()
}
pub fn setting_source_tiers() -> Vec<&'static str> {
setting_source_tiers_of(SETTING_SOURCES_FLAG)
}
pub fn relocated_setting_source_tiers() -> Vec<&'static str> {
setting_source_tiers_of(SETTING_SOURCES_FLAG_RELOCATED)
}
pub fn settings_tier_of(claude_home: &Path, harness_cwd: &Path) -> &'static str {
if claude_home == harness_cwd {
"project"
} else {
"user"
}
}
pub const PERMISSION_MODE_FLAG: &str = "--dangerously-skip-permissions";
pub fn build_claude_command(
model: Option<&str>,
prompt_file: Option<&Path>,
config_dir: Option<&Path>,
mcp_env: &[(String, String)],
) -> String {
let token = config_dir.and_then(|_| crate::core::oauth_token::resolve_oauth_token());
build_claude_command_with(model, prompt_file, config_dir, token.as_deref(), mcp_env)
}
pub fn build_claude_command_with(
model: Option<&str>,
prompt_file: Option<&Path>,
config_dir: Option<&Path>,
oauth_token: Option<&str>,
mcp_env: &[(String, String)],
) -> String {
use crate::core::spawn_disclaim::pane::shell_single_quote;
let mut cmd = format!("env{}", crate::core::claude_env_scrub::env_unset_flags());
if let Some(dir) = config_dir {
cmd.push_str(" CLAUDE_CONFIG_DIR=");
cmd.push_str(&shell_single_quote(&dir.display().to_string()));
}
if let Some(token) = oauth_token {
cmd.push(' ');
cmd.push_str(crate::core::oauth_token::OAUTH_TOKEN_ENV_VAR);
cmd.push('=');
cmd.push_str(&shell_single_quote(token));
}
for (name, value) in mcp_env {
cmd.push(' ');
cmd.push_str(name);
cmd.push('=');
cmd.push_str(&shell_single_quote(value));
}
cmd.push_str(" claude");
if let Some(m) = model {
cmd.push_str(" --model ");
cmd.push_str(m);
}
if let Some(p) = prompt_file {
cmd.push_str(" --append-system-prompt-file ");
cmd.push_str(&p.display().to_string());
}
cmd.push(' ');
cmd.push_str(setting_sources_flag(config_dir));
cmd.push(' ');
cmd.push_str(PERMISSION_MODE_FLAG);
cmd
}
pub fn build_inplace_session_command() -> String {
format!(
"env{} claude {}",
crate::core::claude_env_scrub::env_unset_flags(),
PERMISSION_MODE_FLAG
)
}
pub fn build_client_session_command(prompt_file: Option<&Path>) -> String {
let mut cmd = format!(
"env{} claude",
crate::core::claude_env_scrub::env_unset_flags()
);
if let Some(p) = prompt_file {
cmd.push_str(" --append-system-prompt-file ");
cmd.push_str(&p.display().to_string());
}
cmd
}
pub fn build_agent_command(
config: &MpmConfig,
agent: &AgentSummary,
prompt_file: Option<&Path>,
explicit: Option<&str>,
) -> String {
let model = crate::core::config::resolve_agent_model(
config,
&agent.name,
agent.model.as_deref(),
explicit,
);
build_claude_command(Some(&model), prompt_file, None, &[])
}
#[cfg(test)]
mod tests {
use super::*;
const FLAGS: &str = "--setting-sources project,local --dangerously-skip-permissions";
fn head() -> String {
format!(
"env{} claude",
crate::core::claude_env_scrub::env_unset_flags()
)
}
#[test]
fn claude_command_bare() {
assert_eq!(
build_claude_command(None, None, None, &[]),
format!("{} {FLAGS}", head())
);
}
#[test]
fn claude_command_with_model() {
let cmd = build_claude_command(Some("claude-opus-4-5"), None, None, &[]);
assert_eq!(cmd, format!("{} --model claude-opus-4-5 {FLAGS}", head()));
}
#[test]
fn claude_command_scrubs_inherited_session_markers() {
let cmd = build_claude_command(None, None, None, &[]);
assert!(
cmd.starts_with("env -u "),
"the launch line must carry an env scrub prefix: {cmd}"
);
for marker in [
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDE_CODE_SESSION_ID",
"CLAUDECODE",
"CLAUDE_PID",
"CLAUDE_EFFORT",
"CLAUDE_CODE_EXECPATH",
] {
assert!(
cmd.contains(&format!("-u {marker}")),
"the launch line must unset {marker}: {cmd}"
);
}
assert!(
!cmd.contains("CLAUDE_CONFIG_DIR"),
"this builder must not touch CLAUDE_CONFIG_DIR at all: {cmd}"
);
assert!(
!cmd.contains("-u CLAUDE_CODE_OAUTH_TOKEN"),
"must never unset the OAuth token (#2246): {cmd}"
);
}
#[test]
fn claude_command_relocated_never_scrubs_what_it_assigns() {
let dir = Path::new("/tm/claude-config");
let cmd = build_claude_command_with(None, None, Some(dir), Some("tok"), &[]);
for name in crate::core::claude_env_scrub::DELIBERATE_SPAWN_ENV {
assert!(
cmd.contains(&format!(" {name}=")),
"the relocated line must ASSIGN {name}: {cmd}"
);
assert!(
!cmd.contains(&format!("-u {name}")),
"the relocated line must never unset {name} (#4455/#2246): {cmd}"
);
}
}
#[test]
fn claude_command_relocates_the_config_dir() {
let dir = Path::new("/tm/claude-config");
let cmd = build_claude_command_with(None, None, Some(dir), Some("tok-abc"), &[]);
assert_eq!(
cmd,
format!(
"env{} CLAUDE_CONFIG_DIR='/tm/claude-config' \
CLAUDE_CODE_OAUTH_TOKEN='tok-abc' claude \
--setting-sources user,project,local --dangerously-skip-permissions",
crate::core::claude_env_scrub::env_unset_flags()
)
);
let first_assignment = cmd.find('=').expect("the line carries an assignment");
let last_unset = cmd.rfind("-u ").expect("the line carries scrub flags");
assert!(
last_unset < first_assignment,
"every -u flag must precede every NAME=VALUE assignment: {cmd}"
);
}
#[test]
fn claude_command_omits_the_oauth_token_when_absent() {
let dir = Path::new("/tm/claude-config");
let cmd = build_claude_command_with(None, None, Some(dir), None, &[]);
assert!(
cmd.contains("CLAUDE_CONFIG_DIR='/tm/claude-config'"),
"the config dir must still be relocated: {cmd}"
);
assert!(
!cmd.contains("CLAUDE_CODE_OAUTH_TOKEN"),
"no token resolved → no assignment at all: {cmd}"
);
assert!(
cmd.contains("--setting-sources user,project,local"),
"the tier list follows the config dir, not the token: {cmd}"
);
}
#[test]
fn claude_command_quotes_a_config_dir_with_a_space() {
let dir = Path::new("/Users/John Doe/.trusty-tools/claude-config");
let cmd = build_claude_command_with(None, None, Some(dir), None, &[]);
assert!(
cmd.contains("CLAUDE_CONFIG_DIR='/Users/John Doe/.trusty-tools/claude-config'"),
"the config dir must be single-quoted: {cmd}"
);
}
#[test]
fn claude_command_carries_a_non_empty_mcp_env() {
let mcp_env = vec![
(
"TRUSTY_MEMORY_PALACE".to_owned(),
"owner repo slug".to_owned(),
),
("TRUSTY_INDEX".to_owned(), "idx-42".to_owned()),
];
let cmd = build_claude_command_with(
None,
None,
Some(Path::new("/tm/claude-config")),
None,
&mcp_env,
);
assert!(
cmd.contains(" TRUSTY_MEMORY_PALACE='owner repo slug'"),
"the palace pin must be assigned and single-quoted: {cmd}"
);
assert!(
cmd.contains(" TRUSTY_INDEX='idx-42'"),
"the index pin must be assigned and single-quoted: {cmd}"
);
assert!(
cmd.find("TRUSTY_INDEX=").unwrap() < cmd.find(" claude").unwrap(),
"both pins must precede the binary, or they are argv rather than env: {cmd}"
);
}
#[test]
fn claude_command_relocated_isolates_by_relocation_not_exclusion() {
let dir = Path::new("/tm/claude-config");
let cmd = build_claude_command_with(None, None, Some(dir), None, &[]);
assert!(
cmd.contains("--setting-sources user,project,local"),
"the relocated line must load the user tier: {cmd}"
);
let tiers = setting_source_tiers_of(setting_sources_flag(Some(dir)));
assert!(tiers.contains(&"user"), "{tiers:?}");
let assignment = cmd
.find("CLAUDE_CONFIG_DIR='/tm/claude-config'")
.expect("the relocated line must redirect the user tier");
let program = cmd.find(" claude ").expect("the line spawns claude");
assert!(
assignment < program,
"CLAUDE_CONFIG_DIR must be exported by env, not passed to claude: {cmd}"
);
}
fn assert_scrubbed_launch_line(cmd: &str) {
assert!(
cmd.starts_with("env -u "),
"the launch line must carry an env scrub prefix: {cmd}"
);
for marker in [
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDE_CODE_SESSION_ID",
"CLAUDECODE",
"CLAUDE_PID",
"CLAUDE_EFFORT",
"CLAUDE_CODE_EXECPATH",
] {
assert!(
cmd.contains(&format!("-u {marker}")),
"the launch line must unset {marker}: {cmd}"
);
}
assert!(
!cmd.contains("CLAUDE_CONFIG_DIR"),
"this builder must not touch CLAUDE_CONFIG_DIR at all: {cmd}"
);
assert!(
!cmd.contains("-u CLAUDE_CODE_OAUTH_TOKEN"),
"must never unset the OAuth token (#2246): {cmd}"
);
}
#[test]
fn inplace_session_command_scrubs_inherited_session_markers() {
assert_scrubbed_launch_line(&build_inplace_session_command());
}
#[test]
fn inplace_session_command_keeps_the_permission_flag_and_nothing_else() {
assert_eq!(
build_inplace_session_command(),
format!(
"env{} claude {PERMISSION_MODE_FLAG}",
crate::core::claude_env_scrub::env_unset_flags()
)
);
assert!(
!build_inplace_session_command().contains(SETTING_SOURCES_FLAG),
"must not add --setting-sources: that would drop the user settings tier"
);
}
#[test]
fn client_session_command_scrubs_inherited_session_markers() {
assert_scrubbed_launch_line(&build_client_session_command(None));
assert_scrubbed_launch_line(&build_client_session_command(Some(Path::new("/tmp/p.txt"))));
}
#[test]
fn client_session_command_appends_the_prompt_file() {
let head = format!(
"env{} claude",
crate::core::claude_env_scrub::env_unset_flags()
);
assert_eq!(build_client_session_command(None), head);
assert_eq!(
build_client_session_command(Some(Path::new("/tmp/p.txt"))),
format!("{head} --append-system-prompt-file /tmp/p.txt")
);
}
#[test]
fn claude_command_with_prompt() {
let path = Path::new("/tmp/prompt.txt");
let cmd = build_claude_command(None, Some(path), None, &[]);
assert_eq!(
cmd,
format!(
"{} --append-system-prompt-file /tmp/prompt.txt {FLAGS}",
head()
)
);
}
#[test]
fn claude_command_with_both() {
let path = Path::new("/tmp/sys.txt");
let cmd = build_claude_command(Some("claude-haiku-4-5"), Some(path), None, &[]);
assert_eq!(
cmd,
format!(
"{} --model claude-haiku-4-5 --append-system-prompt-file /tmp/sys.txt {FLAGS}",
head()
)
);
}
#[test]
fn claude_command_includes_setting_sources() {
let cmd = build_claude_command(None, None, None, &[]);
assert!(
cmd.contains("--setting-sources project,local"),
"missing setting-sources isolation flag: {cmd}"
);
assert!(
!cmd.contains("user"),
"should not reference the user source: {cmd}"
);
}
#[test]
fn claude_command_includes_permission_mode() {
let cmd = build_claude_command(Some("sonnet"), None, None, &[]);
assert!(
cmd.contains("--dangerously-skip-permissions"),
"missing bypass-permissions flag: {cmd}"
);
assert!(
!cmd.contains("acceptEdits"),
"old acceptEdits flag must not be present: {cmd}"
);
}
#[test]
fn write_prompt_file_returns_path() {
let path = write_prompt_file("hello trusty-mpm").unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "hello trusty-mpm");
std::fs::remove_file(path).unwrap();
}
#[test]
fn pm_model_resolution() {
let cfg = MpmConfig::default();
let m = resolve_pm_model(&cfg, None);
assert_eq!(m, "claude-sonnet-4-5");
let m = resolve_pm_model(&cfg, Some("haiku"));
assert_eq!(m, "claude-haiku-4-5");
}
#[test]
fn agent_command_uses_config_model() {
let dir = tempfile::TempDir::new().unwrap();
let toml = "[models.agents]\nengineer = \"haiku\"\n";
std::fs::write(dir.path().join("config.toml"), toml).unwrap();
let cfg = MpmConfig::load(dir.path());
let agent = AgentSummary {
name: "engineer".to_string(),
role: "engineer".to_string(),
description: None,
model: Some("sonnet".to_string()),
extends_chain: vec![],
};
let cmd = build_agent_command(&cfg, &agent, None, None);
assert_eq!(cmd, format!("{} --model claude-haiku-4-5 {FLAGS}", head()));
}
#[test]
fn setting_source_tiers_parses_the_flag() {
assert_eq!(setting_source_tiers(), vec!["project", "local"]);
assert!(
!setting_source_tiers().contains(&"user"),
"on a spawn that does NOT relocate CLAUDE_CONFIG_DIR the `user` tier \
is the operator's real ~/.claude and stays excluded (issue #1269)"
);
}
#[test]
fn relocated_setting_sources_flag_loads_the_user_tier() {
assert!(
relocated_setting_source_tiers().contains(&"user"),
"the relocated spawn must load the `user` tier — that is where the \
bundled roster lives once CLAUDE_CONFIG_DIR is redirected"
);
assert_eq!(
relocated_setting_source_tiers(),
vec!["user", "project", "local"]
);
}
#[test]
fn setting_sources_flag_picks_by_config_dir() {
assert_eq!(
setting_sources_flag(Some(Path::new("/tm/claude-config"))),
SETTING_SOURCES_FLAG_RELOCATED
);
assert_eq!(setting_sources_flag(None), SETTING_SOURCES_FLAG);
}
#[test]
fn settings_tier_of_names_project_for_the_harness_cwd() {
let cwd = Path::new("/work/checkout");
assert_eq!(settings_tier_of(cwd, cwd), "project");
}
#[test]
fn settings_tier_of_names_user_for_anything_else() {
assert_eq!(
settings_tier_of(Path::new("/Users/someone"), Path::new("/work/checkout")),
"user"
);
}
}