Skip to main content

git_worktree_manager/operations/
spawn_spec.rs

1//! Spawn-spec — safely launch AI tools without shell escape hazards.
2//!
3//! Prompts with quotes/$/backticks/newlines break when re-quoted through
4//! AppleScript/wezterm/tmux send-text layers. Instead, `materialize` writes
5//! argv+cwd to a temp file and returns `gw _spawn-ai <path>` as the launcher
6//! command. `execute` reads the spec, unlinks it, chdir's, and execvp's the
7//! real tool — the pane shell only ever parses ASCII.
8//!
9//! The emitted line intentionally does NOT use `exec` so that when it is
10//! fed into an already-running interactive shell (e.g. `wezterm cli
11//! send-text`, iTerm AppleScript `write text`, `tmux send-keys` into a
12//! session pane), the shell survives the AI tool's exit and keeps the
13//! tab/pane open at its prompt. Launchers that run the line as the pane's
14//! sole process via `bash -lc <line>` (tmux-window, tmux-pane-*, zellij-*)
15//! are unaffected either way: their pane still closes when the AI tool
16//! exits because the `bash -lc` invocation has nothing else to do.
17
18use std::fs;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21use std::time::{Duration, SystemTime};
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::{CwError, Result};
26
27pub const SPEC_VERSION: u32 = 1;
28
29#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
30pub struct SpawnSpec {
31    pub version: u32,
32    pub argv: Vec<String>,
33    pub cwd: PathBuf,
34    pub self_unlink: bool,
35}
36
37impl SpawnSpec {
38    pub fn new(argv: Vec<String>, cwd: PathBuf) -> Self {
39        Self {
40            version: SPEC_VERSION,
41            argv,
42            cwd,
43            self_unlink: true,
44        }
45    }
46}
47
48/// Write `spec` to a 0600 tempfile in the system temp dir and return
49/// `(shell_line, spec_path)`. `shell_line` is safe to hand to any launcher.
50pub fn materialize(spec: &SpawnSpec) -> Result<(String, PathBuf)> {
51    materialize_in_dir(spec, &std::env::temp_dir())
52}
53
54/// Test seam — write into an explicit directory.
55pub fn materialize_in_dir(spec: &SpawnSpec, dir: &Path) -> Result<(String, PathBuf)> {
56    fs::create_dir_all(dir)?;
57
58    // tempfile gives us a random name + O_CREAT|O_EXCL + mode 0600 on Unix.
59    let named = tempfile::Builder::new()
60        .prefix("gw-spawn-")
61        .suffix(".json")
62        .rand_bytes(16)
63        .tempfile_in(dir)?;
64
65    let json = serde_json::to_vec(spec)?;
66    {
67        let mut f = named.as_file();
68        f.write_all(&json)?;
69        f.flush()?;
70    }
71
72    // Persist — stop tempfile from auto-deleting on drop. `_spawn-ai` unlinks
73    // it after reading, and the 24h sweep handles crash residue.
74    let (_file, path) = named.keep().map_err(|e| e.error)?;
75
76    let shell_line = format!("gw _spawn-ai {}", quote_path_for_shell(&path));
77    Ok((shell_line, path))
78}
79
80/// Shell-safe rendering for a path we just created. Paths produced by
81/// `tempfile_in(temp_dir())` normally contain only [A-Za-z0-9_/.-], but some
82/// Windows `%TEMP%` expansions include spaces; in that case we wrap in double
83/// quotes. Our own filename never contains `"`, `$`, or backslash-escaped
84/// metacharacters, so double quotes are sufficient under both bash and cmd.
85fn quote_path_for_shell(path: &Path) -> String {
86    let s = path.to_string_lossy();
87    // Backslash is NOT bare-safe: bash/zsh/tmux/wezterm interpret `\X` as an
88    // escape, which would corrupt Windows paths like C:\Users\...\Temp\...
89    // Any path containing `\` (or other unsafe chars) takes the quoted branch,
90    // which is fine under both bash and cmd because our filename is ASCII.
91    let safe = s
92        .chars()
93        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':'));
94    if safe {
95        s.into_owned()
96    } else {
97        format!("\"{}\"", s)
98    }
99}
100
101/// Parse a spec file, rejecting unsupported versions and empty argv.
102/// All errors are prefixed with `spawn-ai:` so the entrypoint can print
103/// them verbatim without duplicating the prefix.
104pub fn read_spec(path: &Path) -> Result<SpawnSpec> {
105    let bytes = fs::read(path)
106        .map_err(|e| CwError::Other(format!("spawn-ai: read {} failed: {}", path.display(), e)))?;
107    let spec: SpawnSpec = serde_json::from_slice(&bytes)
108        .map_err(|e| CwError::Other(format!("spawn-ai: parse {} failed: {}", path.display(), e)))?;
109    if spec.version != SPEC_VERSION {
110        return Err(CwError::Other(format!(
111            "spawn-ai: unsupported spawn spec version: {} (expected {})",
112            spec.version, SPEC_VERSION
113        )));
114    }
115    if spec.argv.is_empty() {
116        return Err(CwError::Other("spawn-ai: spawn spec has empty argv".into()));
117    }
118    Ok(spec)
119}
120
121/// Execute a spawn spec. Never returns to the caller:
122/// - Unix: `execvp` replaces the current process. On exec failure we print a
123///   diagnostic to stderr and `exit(127)` ("command not found" convention).
124/// - Windows: spawns a child, waits, and exits with the child's code. Spawn
125///   failures also exit 127.
126///
127/// The `Result<()>` return type exists only so the caller can surface
128/// pre-spawn errors (spec read, parse, chdir) through the normal error path;
129/// once `execvp`/spawn is attempted, the process exits directly.
130pub fn execute(spec_path: &Path) -> Result<()> {
131    let spec = read_spec(spec_path)?;
132
133    if spec.self_unlink {
134        // Best-effort — proceed even if unlink fails (e.g. already gone).
135        let _ = fs::remove_file(spec_path);
136    }
137
138    std::env::set_current_dir(&spec.cwd).map_err(|e| {
139        CwError::Other(format!(
140            "spawn-ai: chdir to {} failed: {}",
141            spec.cwd.display(),
142            e
143        ))
144    })?;
145
146    let program = &spec.argv[0];
147    let args = &spec.argv[1..];
148
149    #[cfg(unix)]
150    {
151        use std::os::unix::process::CommandExt;
152        let err = std::process::Command::new(program).args(args).exec();
153        // exec only returns on failure.
154        eprintln!("spawn-ai: exec {} failed: {}", program, err);
155        std::process::exit(127);
156    }
157
158    #[cfg(windows)]
159    {
160        // Exit directly with the child's code to mirror Unix execvp semantics
161        // as closely as we can on Windows (no true process replacement).
162        let status = match std::process::Command::new(program).args(args).status() {
163            Ok(s) => s,
164            Err(e) => {
165                eprintln!("spawn-ai: spawn {} failed: {}", program, e);
166                std::process::exit(127);
167            }
168        };
169        let code = status.code().unwrap_or(1);
170        std::process::exit(code);
171    }
172}
173
174/// Best-effort removal of stale `gw-spawn-*.json` temp files from the system
175/// temp directory. Intended to run once at `gw` startup. All errors are
176/// swallowed — this is a safety net, not a correctness mechanism.
177pub fn sweep_stale() {
178    sweep_stale_in(&std::env::temp_dir(), Duration::from_secs(24 * 3600));
179}
180
181fn sweep_stale_in(dir: &Path, max_age: Duration) {
182    let entries = match fs::read_dir(dir) {
183        Ok(it) => it,
184        Err(_) => return,
185    };
186    let now = SystemTime::now();
187    for entry in entries.flatten() {
188        let name = entry.file_name();
189        let name_str = name.to_string_lossy();
190        if !name_str.starts_with("gw-spawn-") || !name_str.ends_with(".json") {
191            continue;
192        }
193        // symlink_metadata + is_file narrows the TOCTOU window: we refuse to
194        // follow symlinks or delete directories that happen to match the name.
195        let metadata = match fs::symlink_metadata(entry.path()) {
196            Ok(m) => m,
197            Err(_) => continue,
198        };
199        if !metadata.is_file() {
200            continue;
201        }
202        let mtime = match metadata.modified() {
203            Ok(t) => t,
204            Err(_) => continue,
205        };
206        if now.duration_since(mtime).unwrap_or_default() > max_age {
207            let _ = fs::remove_file(entry.path());
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn round_trip_preserves_killer_prompts() {
218        let killers = [
219            r#"Fix the bug where user can "escape" quotes"#,
220            r#"$(rm -rf /) — literal, not an expansion"#,
221            "한글 테스트 🚀 ${PATH}",
222            "multi\nline\n<<'EOF'\nnot a heredoc\nEOF\n",
223            r"C:\Users\foo\bar \\path\\with\\backslashes",
224            "`backtick` and 'single' and \"double\"",
225        ];
226        for prompt in killers {
227            let spec = SpawnSpec::new(
228                vec!["claude".into(), "--print".into(), prompt.into()],
229                PathBuf::from("/tmp/wt"),
230            );
231            let json = serde_json::to_string(&spec).unwrap();
232            let back: SpawnSpec = serde_json::from_str(&json).unwrap();
233            assert_eq!(spec, back, "round-trip mismatch for: {:?}", prompt);
234            assert_eq!(back.argv[2], prompt);
235        }
236    }
237
238    #[test]
239    fn large_prompt_round_trips() {
240        let big = "x".repeat(64 * 1024);
241        let spec = SpawnSpec::new(vec!["claude".into(), big.clone()], PathBuf::from("/tmp"));
242        let json = serde_json::to_string(&spec).unwrap();
243        let back: SpawnSpec = serde_json::from_str(&json).unwrap();
244        assert_eq!(back.argv[1], big);
245    }
246
247    #[test]
248    fn materialize_writes_spec_and_returns_shell_line() {
249        let dir = tempfile::tempdir().unwrap();
250        let spec = SpawnSpec::new(
251            vec!["/bin/echo".into(), "hello \"world\"".into()],
252            dir.path().to_path_buf(),
253        );
254        let (shell_line, spec_path) = materialize_in_dir(&spec, dir.path()).unwrap();
255
256        // No `exec` — the shell must survive after the AI tool exits so the
257        // terminal tab/pane stays open (e.g. WezTerm tab keeps the zsh prompt
258        // after claude quits).
259        assert!(shell_line.starts_with("gw _spawn-ai "));
260        // Strictly redundant with the prefix check above, but kept as a
261        // self-documenting guard: if someone ever changes the emitted prefix
262        // string in the future, the `exec` ban is load-bearing for tab
263        // lifetime and must not silently regress.
264        assert!(
265            !shell_line.starts_with("exec "),
266            "shell_line must not use exec: {:?}",
267            shell_line
268        );
269        assert!(spec_path.exists());
270
271        let loaded: SpawnSpec =
272            serde_json::from_str(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
273        assert_eq!(loaded, spec);
274    }
275
276    #[test]
277    fn materialize_filename_is_shell_safe() {
278        let dir = tempfile::tempdir().unwrap();
279        let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
280        let (line, _path) = materialize_in_dir(&spec, dir.path()).unwrap();
281
282        // "gw _spawn-ai " + path. path must contain only safe chars OR
283        // be wrapped in double quotes. Temp dir in tests may have unsafe chars;
284        // we only assert the emitted line is one of those two shapes.
285        let tail = line.strip_prefix("gw _spawn-ai ").unwrap();
286        let quoted = tail.starts_with('"') && tail.ends_with('"');
287        let bare_safe = tail
288            .chars()
289            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':' | '\\'));
290        assert!(quoted || bare_safe, "unsafe tail: {:?}", tail);
291    }
292
293    #[cfg(unix)]
294    #[test]
295    fn materialize_file_is_mode_0600() {
296        use std::os::unix::fs::PermissionsExt;
297        let dir = tempfile::tempdir().unwrap();
298        let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
299        let (_line, path) = materialize_in_dir(&spec, dir.path()).unwrap();
300
301        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
302        assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
303    }
304
305    #[test]
306    fn quote_path_for_shell_quotes_windows_backslashes() {
307        use std::path::PathBuf;
308        let win = PathBuf::from(r"C:\Users\me\AppData\Local\Temp\gw-spawn-abcdef0123456789.json");
309        let out = super::quote_path_for_shell(&win);
310        // Must be quoted — bare would let bash interpret the backslashes.
311        assert!(
312            out.starts_with('"') && out.ends_with('"'),
313            "expected quoted, got {:?}",
314            out
315        );
316    }
317
318    #[test]
319    fn quote_path_for_shell_bare_for_unix_paths() {
320        use std::path::PathBuf;
321        let unix = PathBuf::from("/tmp/gw-spawn-abcdef0123456789.json");
322        let out = super::quote_path_for_shell(&unix);
323        assert!(!out.starts_with('"'), "expected bare, got {:?}", out);
324    }
325
326    #[test]
327    fn read_spec_rejects_wrong_version() {
328        let dir = tempfile::tempdir().unwrap();
329        let path = dir.path().join("bad.json");
330        std::fs::write(
331            &path,
332            r#"{"version":999,"argv":["x"],"cwd":"/","self_unlink":false}"#,
333        )
334        .unwrap();
335        let err = read_spec(&path).unwrap_err();
336        assert!(format!("{err}").contains("unsupported spawn spec version"));
337    }
338
339    #[test]
340    fn read_spec_rejects_empty_argv() {
341        let dir = tempfile::tempdir().unwrap();
342        let path = dir.path().join("empty.json");
343        std::fs::write(
344            &path,
345            r#"{"version":1,"argv":[],"cwd":"/","self_unlink":false}"#,
346        )
347        .unwrap();
348        let err = read_spec(&path).unwrap_err();
349        assert!(format!("{err}").contains("empty argv"));
350    }
351
352    #[test]
353    fn read_spec_round_trip() {
354        let dir = tempfile::tempdir().unwrap();
355        let spec = SpawnSpec::new(
356            vec!["/bin/echo".into(), "hi".into()],
357            dir.path().to_path_buf(),
358        );
359        let path = dir.path().join("ok.json");
360        std::fs::write(&path, serde_json::to_vec(&spec).unwrap()).unwrap();
361        let loaded = read_spec(&path).unwrap();
362        assert_eq!(loaded, spec);
363    }
364
365    #[test]
366    fn sweep_stale_removes_old_spec_files_only() {
367        use std::time::{Duration, SystemTime};
368        let dir = tempfile::tempdir().unwrap();
369
370        // Old spec file — mtime far in the past.
371        let old = dir.path().join("gw-spawn-old.json");
372        std::fs::write(&old, "{}").unwrap();
373        let past = SystemTime::now() - Duration::from_secs(48 * 3600);
374        filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(past)).unwrap();
375
376        // Recent spec file — should survive.
377        let recent = dir.path().join("gw-spawn-recent.json");
378        std::fs::write(&recent, "{}").unwrap();
379
380        // Unrelated file — should survive regardless of age.
381        let unrelated = dir.path().join("something-else.json");
382        std::fs::write(&unrelated, "{}").unwrap();
383        filetime::set_file_mtime(&unrelated, filetime::FileTime::from_system_time(past)).unwrap();
384
385        sweep_stale_in(dir.path(), Duration::from_secs(24 * 3600));
386
387        assert!(!old.exists(), "old gw-spawn file should be removed");
388        assert!(recent.exists(), "recent gw-spawn file should remain");
389        assert!(unrelated.exists(), "unrelated file should be untouched");
390    }
391}