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, Clone, 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 a non-self-unlinking copy of `spec` to `<git-dir>/gw-spawn-last.json`
49/// so the user can recover from a corrupted launcher line by running
50/// `gw _spawn-ai` (no args) inside the worktree.
51///
52/// `git_dir` is the path returned by `git rev-parse --git-dir` — for the
53/// main checkout this is `<repo>/.git/`, for linked worktrees it is
54/// `<repo>/.git/worktrees/<name>/`. Either way the file lives per-worktree.
55///
56/// Best-effort: any IO error is returned; callers may choose to swallow it
57/// because the launcher itself still works without the persistent copy.
58fn write_last_to_git_dir(spec: &SpawnSpec, git_dir: &Path) -> Result<()> {
59    let mut persistent = spec.clone();
60    // Critical: persistent copy must survive `execute()`'s unlink so the
61    // user can re-run `gw _spawn-ai` repeatedly after a corruption event.
62    persistent.self_unlink = false;
63    let path = git_dir.join("gw-spawn-last.json");
64    let tmp = git_dir.join("gw-spawn-last.json.tmp");
65    let json = serde_json::to_vec(&persistent)?;
66    fs::write(&tmp, json)?;
67    fs::rename(&tmp, &path)?;
68    Ok(())
69}
70
71/// Look up the git-dir for `cwd` via `git rev-parse --git-dir`. Returns the
72/// absolute path to the `.git` directory (or `.git/worktrees/<name>/` for
73/// linked worktrees).
74fn git_dir_for(cwd: &Path) -> Result<PathBuf> {
75    let output = std::process::Command::new("git")
76        .args(["rev-parse", "--git-dir"])
77        .current_dir(cwd)
78        .output()
79        .map_err(|e| CwError::Other(format!("spawn-ai: git rev-parse failed: {}", e)))?;
80    if !output.status.success() {
81        return Err(CwError::Other(format!(
82            "spawn-ai: not a git worktree: {}",
83            cwd.display()
84        )));
85    }
86    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
87    let p = PathBuf::from(&raw);
88    let absolute = if p.is_absolute() { p } else { cwd.join(p) };
89    Ok(absolute)
90}
91
92/// Write `spec` to a 0600 tempfile in the system temp dir and return
93/// `(shell_line, spec_path)`. `shell_line` is safe to hand to any launcher.
94pub fn materialize(spec: &SpawnSpec) -> Result<(String, PathBuf)> {
95    materialize_in_dir(spec, &std::env::temp_dir())
96}
97
98/// Test seam — write into an explicit directory.
99pub fn materialize_in_dir(spec: &SpawnSpec, dir: &Path) -> Result<(String, PathBuf)> {
100    fs::create_dir_all(dir)?;
101
102    // tempfile gives us a random name + O_CREAT|O_EXCL + mode 0600 on Unix.
103    let named = tempfile::Builder::new()
104        .prefix("gw-spawn-")
105        .suffix(".json")
106        .rand_bytes(16)
107        .tempfile_in(dir)?;
108
109    let json = serde_json::to_vec(spec)?;
110    {
111        let mut f = named.as_file();
112        f.write_all(&json)?;
113        f.flush()?;
114    }
115
116    // Persist — stop tempfile from auto-deleting on drop. `_spawn-ai` unlinks
117    // it after reading, and the 24h sweep handles crash residue.
118    let (_file, path) = named.keep().map_err(|e| e.error)?;
119
120    // Best-effort persistent copy for `gw _spawn-ai` (no args) recovery.
121    // If `spec.cwd` is not a git worktree (unusual — AI tools always run
122    // inside one) the failure is swallowed: the launcher still works via
123    // the random temp path.
124    if let Ok(git_dir) = git_dir_for(&spec.cwd) {
125        let _ = write_last_to_git_dir(spec, &git_dir);
126    }
127
128    let shell_line = format!("gw _spawn-ai {}", quote_path_for_shell(&path));
129    Ok((shell_line, path))
130}
131
132/// Shell-safe rendering for a path we just created. Paths produced by
133/// `tempfile_in(temp_dir())` normally contain only [A-Za-z0-9_/.-], but some
134/// Windows `%TEMP%` expansions include spaces; in that case we wrap in double
135/// quotes. Our own filename never contains `"`, `$`, or backslash-escaped
136/// metacharacters, so double quotes are sufficient under both bash and cmd.
137fn quote_path_for_shell(path: &Path) -> String {
138    let s = path.to_string_lossy();
139    // Backslash is NOT bare-safe: bash/zsh/tmux/wezterm interpret `\X` as an
140    // escape, which would corrupt Windows paths like C:\Users\...\Temp\...
141    // Any path containing `\` (or other unsafe chars) takes the quoted branch,
142    // which is fine under both bash and cmd because our filename is ASCII.
143    let safe = s
144        .chars()
145        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':'));
146    if safe {
147        s.into_owned()
148    } else {
149        format!("\"{}\"", s)
150    }
151}
152
153/// Parse a spec file, rejecting unsupported versions and empty argv.
154/// All errors are prefixed with `spawn-ai:` so the entrypoint can print
155/// them verbatim without duplicating the prefix.
156pub fn read_spec(path: &Path) -> Result<SpawnSpec> {
157    let bytes = fs::read(path)
158        .map_err(|e| CwError::Other(format!("spawn-ai: read {} failed: {}", path.display(), e)))?;
159    let spec: SpawnSpec = serde_json::from_slice(&bytes)
160        .map_err(|e| CwError::Other(format!("spawn-ai: parse {} failed: {}", path.display(), e)))?;
161    if spec.version != SPEC_VERSION {
162        return Err(CwError::Other(format!(
163            "spawn-ai: unsupported spawn spec version: {} (expected {})",
164            spec.version, SPEC_VERSION
165        )));
166    }
167    if spec.argv.is_empty() {
168        return Err(CwError::Other("spawn-ai: spawn spec has empty argv".into()));
169    }
170    Ok(spec)
171}
172
173/// Execute a spawn spec. Never returns to the caller:
174/// - Unix: `execvp` replaces the current process. On exec failure we print a
175///   diagnostic to stderr and `exit(127)` ("command not found" convention).
176/// - Windows: spawns a child, waits, and exits with the child's code. Spawn
177///   failures also exit 127.
178///
179/// The `Result<()>` return type exists only so the caller can surface
180/// pre-spawn errors (spec read, parse, chdir) through the normal error path;
181/// once `execvp`/spawn is attempted, the process exits directly.
182pub fn execute(spec_path: &Path) -> Result<()> {
183    let spec = read_spec(spec_path)?;
184
185    if spec.self_unlink {
186        // Best-effort — proceed even if unlink fails (e.g. already gone).
187        let _ = fs::remove_file(spec_path);
188    }
189
190    std::env::set_current_dir(&spec.cwd).map_err(|e| {
191        CwError::Other(format!(
192            "spawn-ai: chdir to {} failed: {}",
193            spec.cwd.display(),
194            e
195        ))
196    })?;
197
198    let program = &spec.argv[0];
199    let args = &spec.argv[1..];
200
201    #[cfg(unix)]
202    {
203        use std::os::unix::process::CommandExt;
204        let err = std::process::Command::new(program).args(args).exec();
205        // exec only returns on failure.
206        eprintln!("spawn-ai: exec {} failed: {}", program, err);
207        std::process::exit(127);
208    }
209
210    #[cfg(windows)]
211    {
212        // Exit directly with the child's code to mirror Unix execvp semantics
213        // as closely as we can on Windows (no true process replacement).
214        let status = match std::process::Command::new(program).args(args).status() {
215            Ok(s) => s,
216            Err(e) => {
217                eprintln!("spawn-ai: spawn {} failed: {}", program, e);
218                std::process::exit(127);
219            }
220        };
221        let code = status.code().unwrap_or(1);
222        std::process::exit(code);
223    }
224}
225
226/// Resolve the last persisted spec path for the current working directory's
227/// worktree. Reads `<git-dir>/gw-spawn-last.json`. Errors are prefixed with
228/// `spawn-ai:` so the entrypoint can print them verbatim.
229pub fn resolve_last_for_cwd() -> Result<PathBuf> {
230    let cwd = std::env::current_dir()
231        .map_err(|e| CwError::Other(format!("spawn-ai: cannot read current directory: {}", e)))?;
232    let git_dir = git_dir_for(&cwd)?;
233    let last = git_dir.join("gw-spawn-last.json");
234    if !last.exists() {
235        return Err(CwError::Other(format!(
236            "spawn-ai: no recent spawn found for this worktree (looked for {})",
237            last.display()
238        )));
239    }
240    Ok(last)
241}
242
243/// Best-effort removal of stale `gw-spawn-*.json` temp files from the system
244/// temp directory. Intended to run once at `gw` startup. All errors are
245/// swallowed — this is a safety net, not a correctness mechanism.
246pub fn sweep_stale() {
247    sweep_stale_in(&std::env::temp_dir(), Duration::from_secs(24 * 3600));
248}
249
250fn sweep_stale_in(dir: &Path, max_age: Duration) {
251    let entries = match fs::read_dir(dir) {
252        Ok(it) => it,
253        Err(_) => return,
254    };
255    let now = SystemTime::now();
256    for entry in entries.flatten() {
257        let name = entry.file_name();
258        let name_str = name.to_string_lossy();
259        if !name_str.starts_with("gw-spawn-") || !name_str.ends_with(".json") {
260            continue;
261        }
262        // symlink_metadata + is_file narrows the TOCTOU window: we refuse to
263        // follow symlinks or delete directories that happen to match the name.
264        let metadata = match fs::symlink_metadata(entry.path()) {
265            Ok(m) => m,
266            Err(_) => continue,
267        };
268        if !metadata.is_file() {
269            continue;
270        }
271        let mtime = match metadata.modified() {
272            Ok(t) => t,
273            Err(_) => continue,
274        };
275        if now.duration_since(mtime).unwrap_or_default() > max_age {
276            let _ = fs::remove_file(entry.path());
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    struct CwdGuard(std::path::PathBuf);
286    impl Drop for CwdGuard {
287        fn drop(&mut self) {
288            let _ = std::env::set_current_dir(&self.0);
289        }
290    }
291    impl CwdGuard {
292        fn enter(target: &std::path::Path) -> Self {
293            let prev = std::env::current_dir().unwrap();
294            std::env::set_current_dir(target).unwrap();
295            Self(prev)
296        }
297    }
298
299    #[test]
300    fn round_trip_preserves_killer_prompts() {
301        let killers = [
302            r#"Fix the bug where user can "escape" quotes"#,
303            r#"$(rm -rf /) — literal, not an expansion"#,
304            "한글 테스트 🚀 ${PATH}",
305            "multi\nline\n<<'EOF'\nnot a heredoc\nEOF\n",
306            r"C:\Users\foo\bar \\path\\with\\backslashes",
307            "`backtick` and 'single' and \"double\"",
308        ];
309        for prompt in killers {
310            let spec = SpawnSpec::new(
311                vec!["claude".into(), "--print".into(), prompt.into()],
312                PathBuf::from("/tmp/wt"),
313            );
314            let json = serde_json::to_string(&spec).unwrap();
315            let back: SpawnSpec = serde_json::from_str(&json).unwrap();
316            assert_eq!(spec, back, "round-trip mismatch for: {:?}", prompt);
317            assert_eq!(back.argv[2], prompt);
318        }
319    }
320
321    #[test]
322    fn large_prompt_round_trips() {
323        let big = "x".repeat(64 * 1024);
324        let spec = SpawnSpec::new(vec!["claude".into(), big.clone()], PathBuf::from("/tmp"));
325        let json = serde_json::to_string(&spec).unwrap();
326        let back: SpawnSpec = serde_json::from_str(&json).unwrap();
327        assert_eq!(back.argv[1], big);
328    }
329
330    #[test]
331    fn materialize_writes_spec_and_returns_shell_line() {
332        let dir = tempfile::tempdir().unwrap();
333        let spec = SpawnSpec::new(
334            vec!["/bin/echo".into(), "hello \"world\"".into()],
335            dir.path().to_path_buf(),
336        );
337        let (shell_line, spec_path) = materialize_in_dir(&spec, dir.path()).unwrap();
338
339        // No `exec` — the shell must survive after the AI tool exits so the
340        // terminal tab/pane stays open (e.g. WezTerm tab keeps the zsh prompt
341        // after claude quits).
342        assert!(shell_line.starts_with("gw _spawn-ai "));
343        // Strictly redundant with the prefix check above, but kept as a
344        // self-documenting guard: if someone ever changes the emitted prefix
345        // string in the future, the `exec` ban is load-bearing for tab
346        // lifetime and must not silently regress.
347        assert!(
348            !shell_line.starts_with("exec "),
349            "shell_line must not use exec: {:?}",
350            shell_line
351        );
352        assert!(spec_path.exists());
353
354        let loaded: SpawnSpec =
355            serde_json::from_str(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
356        assert_eq!(loaded, spec);
357    }
358
359    #[test]
360    fn materialize_filename_is_shell_safe() {
361        let dir = tempfile::tempdir().unwrap();
362        let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
363        let (line, _path) = materialize_in_dir(&spec, dir.path()).unwrap();
364
365        // "gw _spawn-ai " + path. path must contain only safe chars OR
366        // be wrapped in double quotes. Temp dir in tests may have unsafe chars;
367        // we only assert the emitted line is one of those two shapes.
368        let tail = line.strip_prefix("gw _spawn-ai ").unwrap();
369        let quoted = tail.starts_with('"') && tail.ends_with('"');
370        let bare_safe = tail
371            .chars()
372            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':' | '\\'));
373        assert!(quoted || bare_safe, "unsafe tail: {:?}", tail);
374    }
375
376    #[cfg(unix)]
377    #[test]
378    fn materialize_file_is_mode_0600() {
379        use std::os::unix::fs::PermissionsExt;
380        let dir = tempfile::tempdir().unwrap();
381        let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
382        let (_line, path) = materialize_in_dir(&spec, dir.path()).unwrap();
383
384        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
385        assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
386    }
387
388    #[test]
389    fn quote_path_for_shell_quotes_windows_backslashes() {
390        use std::path::PathBuf;
391        let win = PathBuf::from(r"C:\Users\me\AppData\Local\Temp\gw-spawn-abcdef0123456789.json");
392        let out = super::quote_path_for_shell(&win);
393        // Must be quoted — bare would let bash interpret the backslashes.
394        assert!(
395            out.starts_with('"') && out.ends_with('"'),
396            "expected quoted, got {:?}",
397            out
398        );
399    }
400
401    #[test]
402    fn quote_path_for_shell_bare_for_unix_paths() {
403        use std::path::PathBuf;
404        let unix = PathBuf::from("/tmp/gw-spawn-abcdef0123456789.json");
405        let out = super::quote_path_for_shell(&unix);
406        assert!(!out.starts_with('"'), "expected bare, got {:?}", out);
407    }
408
409    #[test]
410    fn read_spec_rejects_wrong_version() {
411        let dir = tempfile::tempdir().unwrap();
412        let path = dir.path().join("bad.json");
413        std::fs::write(
414            &path,
415            r#"{"version":999,"argv":["x"],"cwd":"/","self_unlink":false}"#,
416        )
417        .unwrap();
418        let err = read_spec(&path).unwrap_err();
419        assert!(format!("{err}").contains("unsupported spawn spec version"));
420    }
421
422    #[test]
423    fn read_spec_rejects_empty_argv() {
424        let dir = tempfile::tempdir().unwrap();
425        let path = dir.path().join("empty.json");
426        std::fs::write(
427            &path,
428            r#"{"version":1,"argv":[],"cwd":"/","self_unlink":false}"#,
429        )
430        .unwrap();
431        let err = read_spec(&path).unwrap_err();
432        assert!(format!("{err}").contains("empty argv"));
433    }
434
435    #[test]
436    fn read_spec_round_trip() {
437        let dir = tempfile::tempdir().unwrap();
438        let spec = SpawnSpec::new(
439            vec!["/bin/echo".into(), "hi".into()],
440            dir.path().to_path_buf(),
441        );
442        let path = dir.path().join("ok.json");
443        std::fs::write(&path, serde_json::to_vec(&spec).unwrap()).unwrap();
444        let loaded = read_spec(&path).unwrap();
445        assert_eq!(loaded, spec);
446    }
447
448    #[test]
449    fn materialize_writes_last_copy_into_git_dir() {
450        let dir = tempfile::tempdir().unwrap();
451        let worktree = dir.path();
452        let status = std::process::Command::new("git")
453            .args(["init", "-q"])
454            .current_dir(worktree)
455            .status()
456            .unwrap();
457        assert!(status.success());
458        let git_dir = worktree.join(".git");
459
460        let spec = SpawnSpec::new(
461            vec!["claude".into(), "--print".into(), "hello".into()],
462            worktree.to_path_buf(),
463        );
464
465        let temp = tempfile::tempdir().unwrap();
466        let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
467
468        let last = git_dir.join("gw-spawn-last.json");
469        assert!(
470            last.exists(),
471            "expected persistent copy at {}",
472            last.display()
473        );
474
475        let loaded: SpawnSpec =
476            serde_json::from_str(&std::fs::read_to_string(&last).unwrap()).unwrap();
477        assert!(
478            !loaded.self_unlink,
479            "persistent copy must have self_unlink=false"
480        );
481        assert_eq!(loaded.argv, spec.argv);
482        assert_eq!(loaded.cwd, spec.cwd);
483    }
484
485    #[test]
486    fn read_spec_does_not_observe_unlink_for_persistent_copy() {
487        // Sanity check: the persistent copy is written with self_unlink=false,
488        // and `execute()`'s unlink branch only fires when self_unlink=true.
489        // Locks the on-disk default in via the public `read_spec` API so
490        // a future refactor that flips the default is caught here.
491        let dir = tempfile::tempdir().unwrap();
492        let worktree = dir.path();
493        let status = std::process::Command::new("git")
494            .args(["init", "-q"])
495            .current_dir(worktree)
496            .status()
497            .unwrap();
498        assert!(status.success());
499
500        let spec = SpawnSpec::new(vec!["/bin/true".into()], worktree.to_path_buf());
501        let temp = tempfile::tempdir().unwrap();
502        let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
503
504        let last = worktree.join(".git").join("gw-spawn-last.json");
505        let loaded = read_spec(&last).expect("read_spec should succeed");
506        assert!(
507            !loaded.self_unlink,
508            "persistent copy must keep self_unlink=false"
509        );
510    }
511
512    #[test]
513    fn sweep_stale_removes_old_spec_files_only() {
514        use std::time::{Duration, SystemTime};
515        let dir = tempfile::tempdir().unwrap();
516
517        // Old spec file — mtime far in the past.
518        let old = dir.path().join("gw-spawn-old.json");
519        std::fs::write(&old, "{}").unwrap();
520        let past = SystemTime::now() - Duration::from_secs(48 * 3600);
521        filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(past)).unwrap();
522
523        // Recent spec file — should survive.
524        let recent = dir.path().join("gw-spawn-recent.json");
525        std::fs::write(&recent, "{}").unwrap();
526
527        // Unrelated file — should survive regardless of age.
528        let unrelated = dir.path().join("something-else.json");
529        std::fs::write(&unrelated, "{}").unwrap();
530        filetime::set_file_mtime(&unrelated, filetime::FileTime::from_system_time(past)).unwrap();
531
532        sweep_stale_in(dir.path(), Duration::from_secs(24 * 3600));
533
534        assert!(!old.exists(), "old gw-spawn file should be removed");
535        assert!(recent.exists(), "recent gw-spawn file should remain");
536        assert!(unrelated.exists(), "unrelated file should be untouched");
537    }
538
539    #[test]
540    #[serial_test::serial]
541    fn resolve_last_for_cwd_finds_persisted_spec() {
542        let dir = tempfile::tempdir().unwrap();
543        let worktree = dir.path();
544        let status = std::process::Command::new("git")
545            .args(["init", "-q"])
546            .current_dir(worktree)
547            .status()
548            .unwrap();
549        assert!(status.success());
550
551        let spec = SpawnSpec::new(
552            vec!["/bin/echo".into(), "hi".into()],
553            worktree.to_path_buf(),
554        );
555        let temp = tempfile::tempdir().unwrap();
556        let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
557
558        let _guard = CwdGuard::enter(worktree);
559        let resolved = resolve_last_for_cwd().expect("resolve_last_for_cwd should succeed");
560        assert!(
561            resolved.exists(),
562            "resolved path must exist: {}",
563            resolved.display()
564        );
565        assert!(
566            resolved.ends_with("gw-spawn-last.json"),
567            "unexpected resolved path: {}",
568            resolved.display()
569        );
570    }
571
572    #[test]
573    #[serial_test::serial]
574    fn resolve_last_for_cwd_errors_when_no_spec_persisted() {
575        let dir = tempfile::tempdir().unwrap();
576        let worktree = dir.path();
577        let status = std::process::Command::new("git")
578            .args(["init", "-q"])
579            .current_dir(worktree)
580            .status()
581            .unwrap();
582        assert!(status.success());
583
584        let _guard = CwdGuard::enter(worktree);
585        let result = resolve_last_for_cwd();
586
587        let err = result.unwrap_err();
588        let msg = format!("{err}");
589        assert!(
590            msg.contains("no recent spawn") || msg.contains("gw-spawn-last.json"),
591            "unexpected error: {}",
592            msg
593        );
594        assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
595    }
596
597    #[test]
598    #[serial_test::serial]
599    fn resolve_last_for_cwd_errors_outside_a_git_worktree() {
600        let dir = tempfile::tempdir().unwrap();
601
602        let _guard = CwdGuard::enter(dir.path());
603        let result = resolve_last_for_cwd();
604
605        let err = result.unwrap_err();
606        let msg = format!("{err}");
607        assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
608    }
609}