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