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";
fn launch_lines() -> Vec<(&'static str, Vec<String>)> {
let config_dir = std::path::PathBuf::from(PROBE_CONFIG_DIR);
let managed_unset = crate::runtime::managed_env_unset(&[]);
let launch_line = crate::core::model_inject::build_claude_command_with_configured(
None,
None,
Some(&config_dir),
Some(PROBE_TOKEN),
&[],
None,
false,
);
let relaunch_line = crate::daemon::spawn_command::relaunch_command();
let inplace_line = crate::core::model_inject::build_inplace_session_command_configured(false);
let client_line = crate::core::model_inject::build_client_session_command_configured(
Some(std::path::Path::new("/probe/prompt.txt")),
false,
);
let run_cmd = crate::core::standalone::run::build_launch_command_configured(
std::path::Path::new("/probe/repo"),
&config_dir,
None,
None,
false,
);
let stream_cmd = crate::control::backend::stream_json::build_claude_command(
std::path::Path::new("/probe"),
None,
);
vec![
(
"runtime::managed_launch::managed_env_unset (daemon spawn + resume + attach)",
managed_unset,
),
(
"core::model_inject::build_claude_command (tm launch / tm connect)",
owned(parse_env_unset_vars(&launch_line)),
),
(
"core::model_inject::build_inplace_session_command (tm session start, in place)",
owned(parse_env_unset_vars(&inplace_line)),
),
(
"core::model_inject::build_client_session_command (DaemonClient launch/connect)",
owned(parse_env_unset_vars(&client_line)),
),
(
"daemon::spawn_command::relaunch_command (pane relaunch)",
owned(parse_env_unset_vars(&relaunch_line)),
),
(
"core::standalone::run::build_launch_command (tm run)",
removed_env_keys(&run_cmd),
),
(
"control::backend::stream_json::build_claude_command (headless)",
removed_env_keys(&stream_cmd),
),
]
}
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 (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;