use crate::core::claude_env_scrub::{
DELIBERATE_SPAWN_ENV, TRANSCRIPT_SUPPRESSING_MARKER, markers_present_in_env,
parse_env_unset_vars,
};
use crate::core::doctor::{CheckStatus, DoctorCheck};
const CHECK_NAME: &str = "transcript_saving";
const BINARY_CRATE_BUILDERS: &[&str] = &[
"`bin/tm/commands/guided_inplace.rs::build_inplace_exec_command` (the bare-`tm` in-place \
relaunch; covered by `inplace_exec_command_scrubs_inherited_session_markers`)",
];
const PROBE_CONFIG_DIR: &str = "/managed/claude-config";
const PROBE_TOKEN: &str = "probe-token-not-a-credential";
const PROBE_PROMPT: &str = "/probe/prompt.txt";
struct LaunchLine {
label: &'static str,
unset: Vec<String>,
#[cfg_attr(not(test), allow(dead_code))]
pm_line: Option<String>,
}
fn launch_lines() -> Vec<LaunchLine> {
let config_dir = std::path::PathBuf::from(PROBE_CONFIG_DIR);
let probe = std::path::Path::new(PROBE_PROMPT);
let cwd = std::path::Path::new("/probe");
let managed_unset = crate::runtime::managed_env_unset(&[]);
let launch_line = crate::core::model_inject::build_claude_command_with_configured(
None,
Some(probe), Some(&config_dir),
Some(PROBE_TOKEN),
&[],
None,
false,
);
let relaunch_line = crate::daemon::spawn_command::relaunch_command();
let gui_line = crate::daemon::spawn_command::gui_spawn_command(
probe,
&crate::core::session_profile::launch_env(crate::core::session_profile::SessionProfile::Pm),
);
let inplace_line =
crate::core::model_inject::build_inplace_session_command_with_prompt(probe, false);
let client_line =
crate::core::model_inject::build_client_session_command_configured(Some(probe), false);
let run_cmd = crate::core::standalone::run::build_launch_command_with_prompt(
std::path::Path::new("/probe/repo"),
&config_dir,
None,
None,
probe,
false,
);
let stream_cmd = crate::control::backend::stream_json::build_claude_command(cwd, None);
let control_pane_line =
crate::control::backend::tmux::pane_claude_line(None, "claude", None).unwrap_or_default();
let isolated = crate::runtime::cli_launch::isolated_spec(
cwd,
&crate::runtime::cli_launch::CliLaunch {
model: None,
prompt_file: Some(probe),
config_dir: Some(&config_dir),
oauth_token: Some(PROBE_TOKEN),
mcp_env: &[],
scoped_mcp: None,
alternate_screen: false,
},
);
let inplace = crate::runtime::cli_launch::inplace_spec(cwd, probe, false);
let client = crate::runtime::cli_launch::client_spec(cwd, Some(probe), false);
let pm = |label, unset, line: String| LaunchLine {
label,
unset,
pm_line: Some(line),
};
let other = |label, unset| LaunchLine {
label,
unset,
pm_line: None,
};
vec![
other(
"runtime::managed_launch::managed_env_unset (daemon spawn + resume + attach)",
managed_unset,
),
pm(
"core::model_inject::build_claude_command (tm launch / tm connect)",
owned(parse_env_unset_vars(&launch_line)),
launch_line,
),
pm(
"core::model_inject::build_inplace_session_command_with_prompt (tm session start, in place)",
owned(parse_env_unset_vars(&inplace_line)),
inplace_line,
),
pm(
"core::model_inject::build_client_session_command (DaemonClient launch/connect)",
owned(parse_env_unset_vars(&client_line)),
client_line,
),
pm(
"runtime::cli_launch::isolated_spec (tm launch / tm connect / tm fleet)",
isolated.env_unset,
isolated.args.join(" "),
),
pm(
"runtime::cli_launch::inplace_spec (tm session start, in place)",
inplace.env_unset,
inplace.args.join(" "),
),
pm(
"runtime::cli_launch::client_spec (DaemonClient launch/connect)",
client.env_unset,
client.args.join(" "),
),
other(
"daemon::spawn_command::relaunch_command (config-apply pane relaunch)",
owned(parse_env_unset_vars(&relaunch_line)),
),
pm(
"daemon::spawn_command::gui_spawn_command (GUI New Session)",
owned(parse_env_unset_vars(&gui_line)),
gui_line,
),
pm(
"core::standalone::run::build_launch_command (tm run)",
removed_env_keys(&run_cmd),
command_line(&run_cmd),
),
other(
"control::backend::stream_json::build_claude_command (headless)",
removed_env_keys(&stream_cmd),
),
other(
"control::backend::tmux::pane_claude_line (control-plane pane)",
owned(parse_env_unset_vars(&control_pane_line)),
),
]
}
fn command_line(cmd: &std::process::Command) -> String {
std::iter::once(cmd.get_program())
.chain(cmd.get_args())
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ")
}
fn owned(names: Vec<&str>) -> Vec<String> {
names.into_iter().map(str::to_owned).collect()
}
fn removed_env_keys(cmd: &std::process::Command) -> Vec<String> {
cmd.get_envs()
.filter(|(_, value)| value.is_none())
.map(|(key, _)| key.to_string_lossy().into_owned())
.collect()
}
pub(super) fn check_transcript_saving() -> DoctorCheck {
let present = markers_present_in_env();
let lines = launch_lines();
let covered = lines.len();
for LaunchLine { label, unset, .. } in &lines {
let names: Vec<&str> = unset.iter().map(String::as_str).collect();
let check = verdict(label, &names, &present);
if check.status != CheckStatus::Ok {
return check;
}
}
let context = if present.is_empty() {
"no inherited markers in this environment".to_owned()
} else {
format!(
"inherited here and scrubbed: {} — this `tm` is itself running with the leak \
present",
present.join(", ")
)
};
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!(
"all {covered} library launch line(s) unset `{TRANSCRIPT_SUPPRESSING_MARKER}` while \
keeping [{}], so transcript saving and native --resume stay available; {context}. \
NOT covered by this check: {} launch line(s) in the `tm` BINARY crate, which a \
library check cannot reference — {}. Each is covered by its own test; \
`every_claude_launch_site_in_the_crate_is_allowlisted` is what fails when a new \
`claude` launch site appears anywhere in the crate.",
DELIBERATE_SPAWN_ENV.join(", "),
BINARY_CRATE_BUILDERS.len(),
BINARY_CRATE_BUILDERS.join("; ")
),
)
}
fn verdict(path: &str, unset: &[&str], present: &[&'static str]) -> DoctorCheck {
if let Some(deliberate) = DELIBERATE_SPAWN_ENV
.iter()
.find(|name| unset.contains(*name))
{
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Fail,
format!(
"`{path}` would UNSET `{deliberate}`, which a managed spawn sets on \
PURPOSE. For CLAUDE_CONFIG_DIR that relocation is what puts the bundled \
agent roster in the `user` settings tier the spawn's `--setting-sources` \
flag loads (#4455), so unsetting it restores issue #4451: every \
delegation degrades to `general-purpose` with `Agent type '<name>' not \
found`. For CLAUDE_CODE_OAUTH_TOKEN it restores the #2246 Keychain login \
loop. Remove `{deliberate}` from \
`core::claude_env_scrub::INHERITED_SESSION_MARKERS`."
),
);
}
if !unset.contains(&TRANSCRIPT_SUPPRESSING_MARKER) {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Fail,
format!(
"`{path}` does NOT unset `{TRANSCRIPT_SUPPRESSING_MARKER}`, so when `tm` is \
invoked from inside a Claude Code session the `claude` it spawns inherits \
it and turns session persistence OFF (\"Transcript saving is off — \
inherited {TRANSCRIPT_SUPPRESSING_MARKER} marker\"). Sessions on that path \
then have no native --resume/--continue/rewind recovery and never appear \
in `--resume`, so if one dies everything since tm's last snapshot is lost \
(issue #4467). Scrub it via `core::claude_env_scrub`. Currently unsets: \
[{}].",
unset.join(", ")
),
);
}
let context = if present.is_empty() {
"no inherited markers in this environment".to_owned()
} else {
format!(
"inherited here and scrubbed: {} — this `tm` is itself running with the leak \
present",
present.join(", ")
)
};
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!(
"`{path}` unsets [{}] while keeping [{}], so transcript saving and native \
--resume stay available; {context}",
unset.join(", "),
DELIBERATE_SPAWN_ENV.join(", ")
),
)
}
#[cfg(test)]
#[path = "doctor_transcript_saving_tests.rs"]
mod tests;