1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
//! Shared Claude Code relaunch-line builder for ad hoc (non-managed) tmux spawns.
//!
//! Why: [`crate::daemon::services::tmux_service::TmuxService::spawn_claude`]
//! (the GUI "New Session" bootstrap) and
//! [`crate::daemon::claude_config::restarter::ClaudeCodeRestarter::restart_in_session`]
//! (the config-apply restart) each send a `claude` launch line into an
//! already-created tmux pane, independently of one another. Before this module
//! existed, each call site built its own literal `"claude"` string inline —
//! two independent places a future flag/env change could land in one and be
//! forgotten in the other, silently drifting the two flows apart (#2010).
//! Routing both through one function makes that impossible: a future change
//! to the launch line is a single edit.
//!
//! This is a DIFFERENT, simpler builder than the managed-session one in
//! [`crate::runtime::claude_code`] (its private `spawn_command`, which
//! resolves an absolute `claude` binary, scrubs `ANTHROPIC_API_KEY` via
//! `env_bin_prefix`, and injects `CLAUDE_CONFIG_DIR` plus the
//! `--setting-sources` / `--dangerously-skip-permissions` isolation flags) —
//! hence the distinct name [`relaunch_command`] rather than reusing
//! `spawn_command`, to avoid two same-named-but-semantically-opposite
//! functions in the crate.
//!
//! NOTE / KNOWN LIMITATION (not fixed here): both call sites emit a bare
//! `claude` with no env-scrubbing or `CLAUDE_CONFIG_DIR` isolation, on the
//! assumption that the target pane is a non-managed, already-interactive
//! session (a freshly-created GUI host, or the operator's own attached pane).
//! That assumption is NOT currently verified: `restart_in_session` is reachable
//! from `POST /claude-config/restart`
//! (`crates/trusty-mpm/src/daemon/api/claude_config_routes.rs`,
//! `restart_claude_code` / `RestartRequest`), which accepts an arbitrary
//! caller-supplied `tmux_session` with no check that it is a non-managed
//! session. If that endpoint is ever pointed at a managed
//! (`CLAUDE_CONFIG_DIR`-isolated) session, this bare `claude` relaunch would
//! silently drop that session's auth/roster isolation and unattended-permission
//! mode. Adding registry-aware validation is out of scope for this
//! consolidation (#2010) and is tracked separately in #2020.
//! What: [`relaunch_command`] returns the shell command sent to the pane —
//! `claude` behind the #4467 inherited-session-marker `env` scrub.
//!
//! #8286: the GUI spawn no longer types [`relaunch_command`]. It types
//! [`gui_spawn_command`], the same scrub plus the profile stamp and the PM
//! prompt file; only the config-apply restarter still relaunches a bare
//! `claude`.
//! Test: `relaunch_command_scrubs_inherited_session_markers`,
//! `gui_spawn_command_carries_the_prompt_file_and_the_stamp`.
/// The shell command used to (re)launch `claude` inside an already-running,
/// already-configured tmux pane.
///
/// Why: see the module doc — both the spawn-mode bootstrap
/// ([`crate::daemon::services::tmux_service::TmuxService::spawn_claude`]) and
/// the config-restart flow
/// ([`crate::daemon::claude_config::restarter::ClaudeCodeRestarter::restart_in_session`])
/// must send the identical launch line so the two can never drift apart
/// (#2010). Named distinctly from `runtime::claude_code::spawn_command` (which
/// builds the full env/flags managed-session command) so the two are never
/// confused for one another.
/// What: returns `env <-u marker…> claude`. This still intentionally carries
/// none of the managed-session AUTH isolation in
/// [`crate::runtime::claude_code`] (no absolute-path resolution, no
/// `ANTHROPIC_API_KEY` scrub, no `CLAUDE_CONFIG_DIR`) — see the module doc's
/// KNOWN LIMITATION note for why that is currently unverified rather than
/// deliberately safe for every caller of `restart_in_session`. That half remains
/// #2020's.
///
/// Issue #4467 adds ONLY the inherited-session-marker scrub, which is orthogonal
/// to #2020's auth question: a relaunch into an already-configured pane still
/// inherits `CLAUDE_CODE_CHILD_SESSION` from the pane's environment and would
/// silently save no transcript. Scrubbing it here is what lets the
/// `transcript_saving` doctor check cover this launch line instead of returning
/// `Ok` while it leaks.
/// Test: `relaunch_command_scrubs_inherited_session_markers`.
pub(crate) fn relaunch_command() -> String {
// #4467: strip the inherited Claude Code session markers. No `NAME=VALUE`
// assignments follow, so the POSIX "-u before assignments" rule is trivially
// satisfied.
format!(
"env{} claude",
crate::core::claude_env_scrub::env_unset_flags()
)
}
/// The line the GUI "New Session" spawn types into its fresh pane (#8286).
///
/// Why: that spawn starts a PM session, and every PM launch mode delivers its
/// prompt through `--append-system-prompt-file`. [`relaunch_command`] stays
/// bare for the config-apply restarter, which relaunches an existing pane.
/// What: the same `env -u …` scrub, then the profile stamp the prompt was
/// composed for (#8453), then `claude --append-system-prompt-file '<file>'`.
/// Both values are single-quoted because the pane shell re-splits the line.
/// Test: `gui_spawn_command_carries_the_prompt_file_and_the_stamp`,
/// `pm_launch_builders_carry_the_prompt_file`.
pub(crate) fn gui_spawn_command(prompt_file: &std::path::Path, stamp: &(String, String)) -> String {
use crate::core::spawn_disclaim::pane::shell_single_quote;
format!(
"env{} {}={} claude --append-system-prompt-file {}",
crate::core::claude_env_scrub::env_unset_flags(),
stamp.0,
shell_single_quote(&stamp.1),
shell_single_quote(&prompt_file.display().to_string())
)
}
/// Compose and write the GUI spawn's PM prompt for `workdir`, then return the
/// line that hands it to `claude` (#8286).
///
/// Why: a GUI session used to start a bare `claude` with no PM instructions.
/// What: one [`crate::core::session_launch::cli_launch`] resolution gives the
/// prompt and the stamp; the prompt is written under `prompt_dir` (production:
/// the process temp dir). `Err` names the file, the I/O cause and `workdir`,
/// and the caller refuses the spawn on it.
/// Test: `gui_spawn_line_writes_the_prompt_it_names`,
/// `gui_spawn_line_refuses_when_the_prompt_file_cannot_be_written`.
pub(crate) fn gui_spawn_line(
workdir: &std::path::Path,
prompt_dir: &std::path::Path,
) -> anyhow::Result<String> {
// #9012: the PM instructions are runtime content; none refuses the spawn.
let content = crate::core::content_source::framework_content_for(workdir)
.map_err(|err| anyhow::anyhow!("cannot compose the PM instructions: {err}"))?;
let cli = crate::core::session_launch::cli_launch(&content, workdir, None);
let file = crate::core::model_inject::write_pm_prompt_file_in(
prompt_dir,
&cli.prompt,
workdir,
"spawn",
)?;
Ok(gui_spawn_command(
&file,
&crate::core::session_profile::launch_env(cli.profile),
))
}
#[cfg(test)]
mod tests {
use super::{gui_spawn_command, gui_spawn_line, relaunch_command};
/// #8286: the GUI spawn line scrubs the markers, stamps the profile and
/// hands `claude` the prompt file as one shell word.
#[test]
fn gui_spawn_command_carries_the_prompt_file_and_the_stamp() {
let stamp = ("TRUSTY_MPM_SESSION_PROFILE".to_owned(), "pm".to_owned());
let cmd = gui_spawn_command(std::path::Path::new("/t/with space/p.txt"), &stamp);
assert!(cmd.starts_with("env -u "), "{cmd}");
assert!(cmd.contains("-u CLAUDE_CODE_CHILD_SESSION"), "{cmd}");
assert!(
cmd.ends_with(
" TRUSTY_MPM_SESSION_PROFILE='pm' claude \
--append-system-prompt-file '/t/with space/p.txt'"
),
"{cmd}"
);
}
/// #8286: the line names the file the prompt was written to.
#[test]
fn gui_spawn_line_writes_the_prompt_it_names() {
let project = tempfile::tempdir().unwrap();
let dir = tempfile::tempdir().unwrap();
let line = gui_spawn_line(project.path(), dir.path()).unwrap();
let written: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(written.len(), 1, "{written:?}");
assert!(
line.contains(&format!(
"--append-system-prompt-file '{}'",
written[0].display()
)),
"{line}"
);
assert!(
!std::fs::read_to_string(&written[0])
.unwrap()
.trim()
.is_empty()
);
}
/// #8286: a prompt that cannot be written refuses the spawn.
#[test]
fn gui_spawn_line_refuses_when_the_prompt_file_cannot_be_written() {
let project = tempfile::tempdir().unwrap();
let tmp = tempfile::tempdir().unwrap();
let not_a_dir = tmp.path().join("not-a-dir");
std::fs::write(¬_a_dir, "").unwrap();
let err = gui_spawn_line(project.path(), ¬_a_dir)
.unwrap_err()
.to_string();
assert!(
err.contains(&*not_a_dir.to_string_lossy()) && err.contains("refusing to spawn"),
"{err}"
);
}
/// #4467: the relaunch line must scrub the inherited session markers, or a
/// config-restart silently loses the pane's transcript. Marker names are
/// hard-coded so this cannot go vacuous if the shared list is emptied. The
/// `claude` program itself must still terminate the line (#2010 — no
/// absolute-path resolution here; that stays #2020's).
#[test]
fn relaunch_command_scrubs_inherited_session_markers() {
let cmd = relaunch_command();
assert!(
cmd.starts_with("env -u "),
"the relaunch 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}")),
"relaunch must unset {marker}: {cmd}"
);
}
assert!(
cmd.ends_with(" claude"),
"the line must still invoke `claude` (#2010): {cmd}"
);
assert!(
!cmd.contains("-u CLAUDE_CONFIG_DIR"),
"must never unset CLAUDE_CONFIG_DIR (#4455): {cmd}"
);
}
}