Skip to main content

apm_core/wrapper/
custom.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3use serde::Deserialize;
4use anyhow::Context;
5use super::{Wrapper, WrapperContext, CONTRACT_VERSION};
6
7#[derive(Debug, Clone, PartialEq)]
8enum ParserStrategy {
9    Canonical,
10    External,
11}
12
13impl ParserStrategy {
14    fn from_manifest(m: Option<&Manifest>) -> Self {
15        match m.and_then(|m| Some(m.parser.as_str())) {
16            Some("external") => Self::External,
17            _ => Self::Canonical,
18        }
19    }
20}
21
22/// Locate an executable binary by name or path, matching POSIX shell rules.
23/// - Absolute path: must exist as a file.
24/// - Relative path containing `/` (e.g. `./parser.py`, `bin/parser`): resolved
25///   against `base_dir` (the agent directory where manifest.toml lives), so a
26///   parser shipped next to the manifest can be referenced naturally.
27/// - Bare name (e.g. `python3`): walks PATH entries and returns the first
28///   executable match.
29fn find_binary(cmd: &str, base_dir: &Path) -> anyhow::Result<PathBuf> {
30    let p = Path::new(cmd);
31    if p.is_absolute() {
32        if p.is_file() {
33            return Ok(p.to_path_buf());
34        }
35        anyhow::bail!("parser binary not found: {}", cmd);
36    }
37    if cmd.contains('/') {
38        let candidate = base_dir.join(p);
39        if candidate.is_file() {
40            return Ok(candidate);
41        }
42        anyhow::bail!(
43            "parser binary not found: {} (resolved to {})",
44            cmd,
45            candidate.display()
46        );
47    }
48    let path_var = std::env::var("PATH").unwrap_or_default();
49    for dir in std::env::split_paths(&path_var) {
50        let candidate = dir.join(cmd);
51        if !candidate.is_file() {
52            continue;
53        }
54        #[cfg(unix)]
55        {
56            use std::os::unix::fs::PermissionsExt;
57            if let Ok(meta) = candidate.metadata() {
58                if meta.permissions().mode() & 0o111 == 0 {
59                    continue;
60                }
61            }
62        }
63        return Ok(candidate);
64    }
65    anyhow::bail!("parser binary not found: {}", cmd);
66}
67
68fn default_contract_version() -> u32 { CONTRACT_VERSION }
69fn default_parser() -> String { "canonical".to_string() }
70
71#[derive(Debug, Deserialize, Clone)]
72pub struct Manifest {
73    #[serde(default)]
74    pub name: Option<String>,
75    #[serde(default = "default_contract_version")]
76    pub contract_version: u32,
77    #[serde(default = "default_parser")]
78    pub parser: String,
79    #[serde(default)]
80    pub parser_command: Option<String>,
81    /// When true, APM installs a `PreToolUse` hook that blocks writes outside
82    /// `APM_TICKET_WORKTREE`. Only applies to `parser = "canonical"` wrappers.
83    #[serde(default)]
84    pub enforce_worktree_isolation: bool,
85}
86
87pub enum WrapperKind {
88    Custom { script_path: PathBuf, manifest: Option<Manifest> },
89    Builtin(String),
90}
91
92pub struct CustomWrapper {
93    pub script_path: PathBuf,
94    pub manifest: Option<Manifest>,
95}
96
97fn check_contract_version(declared: u32, apm_version: u32, log_path: &Path) -> anyhow::Result<()> {
98    match declared.cmp(&apm_version) {
99        std::cmp::Ordering::Greater => anyhow::bail!(
100            "wrapper targets contract version {} but this APM build supports up to \
101             version {}; upgrade APM",
102            declared,
103            apm_version,
104        ),
105        std::cmp::Ordering::Less => {
106            if let Ok(mut f) = std::fs::OpenOptions::new()
107                .append(true)
108                .create(true)
109                .open(log_path)
110            {
111                let _ = writeln!(
112                    f,
113                    "[apm] warning: wrapper targets contract version {} but this APM \
114                     build is version {}; the wrapper may not use newer env vars",
115                    declared, apm_version,
116                );
117            }
118        }
119        std::cmp::Ordering::Equal => {}
120    }
121    Ok(())
122}
123
124impl Wrapper for CustomWrapper {
125    fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result<std::process::Child> {
126        // Layer 2 spawn-time safety net: check contract_version unconditionally.
127        // Even if apm validate already passed, the manifest may have been edited
128        // between validate and this spawn call.
129        let declared = self.manifest.as_ref().map_or(1, |m| m.contract_version);
130        check_contract_version(declared, CONTRACT_VERSION, &ctx.log_path)
131            .map_err(|e| anyhow::anyhow!("wrapper '{}': {}", self.script_path.display(), e))?;
132
133        let apm_bin = super::resolve_apm_cli_bin();
134
135        // Write the path-guard hook for canonical wrappers that request isolation.
136        let enforce = self.manifest.as_ref().map_or(false, |m| m.enforce_worktree_isolation);
137        let strategy = ParserStrategy::from_manifest(self.manifest.as_ref());
138        if enforce && strategy == ParserStrategy::Canonical {
139            crate::wrapper::hook_config::write_hook_config(&ctx.worktree_path, &apm_bin)?;
140        }
141
142        let mut cmd = std::process::Command::new(&self.script_path);
143
144        set_apm_env(&mut cmd, ctx, &apm_bin);
145        for (k, v) in &ctx.extra_env {
146            cmd.env(k, v);
147        }
148        cmd.current_dir(&ctx.worktree_path);
149
150        #[cfg(unix)]
151        use std::os::unix::process::CommandExt;
152
153        match strategy {
154            ParserStrategy::Canonical => {
155                let log_file = std::fs::File::create(&ctx.log_path)?;
156                let log_clone = log_file.try_clone()?;
157                cmd.stdout(log_file);
158                cmd.stderr(log_clone);
159                #[cfg(unix)]
160                cmd.process_group(0);
161                Ok(cmd.spawn()?)
162            }
163            ParserStrategy::External => {
164                let agent_dir = self.script_path.parent().unwrap_or_else(|| Path::new("."));
165                let manifest_path = agent_dir.join("manifest.toml");
166
167                // Require parser_command
168                let parser_cmd_str = self.manifest.as_ref()
169                    .and_then(|m| m.parser_command.as_deref())
170                    .ok_or_else(|| anyhow::anyhow!(
171                        "{}: parser = \"external\" but parser_command is not set",
172                        manifest_path.display()
173                    ))?
174                    .to_owned();
175
176                // Validate binary is findable before spawning any process.
177                // Relative paths in parser_command resolve against the agent dir
178                // (where manifest.toml lives) so parsers shipped next to the
179                // wrapper can be named naturally (e.g. "./parser.py").
180                let parser_bin = find_binary(&parser_cmd_str, agent_dir)?;
181
182                // Open log file; clone for each stream that writes to it:
183                // 1. wrapper.stderr, 2. parser.stdout, 3. parser.stderr
184                let log_file_wrapper_stderr = std::fs::File::create(&ctx.log_path)?;
185                let log_file_parser_stdout = log_file_wrapper_stderr.try_clone()?;
186                let log_file_parser_stderr = log_file_wrapper_stderr.try_clone()?;
187
188                use std::process::Stdio;
189
190                // Spawn wrapper: stdout piped to feed parser stdin; stderr directly to log
191                cmd.stdout(Stdio::piped());
192                cmd.stderr(log_file_wrapper_stderr);
193                #[cfg(unix)]
194                cmd.process_group(0);
195                let mut wrapper_child = cmd.spawn()?;
196
197                let wrapper_stdout = wrapper_child.stdout.take()
198                    .ok_or_else(|| anyhow::anyhow!("failed to capture wrapper stdout pipe"))?;
199
200                // Reap wrapper in background thread; append diagnostic exit line to log
201                let log_path_clone = ctx.log_path.clone();
202                std::thread::spawn(move || {
203                    let status = wrapper_child.wait();
204                    if let Ok(mut f) = std::fs::OpenOptions::new()
205                        .append(true)
206                        .create(true)
207                        .open(&log_path_clone)
208                    {
209                        let status_str = match status {
210                            Ok(s) => format!("{s}"),
211                            Err(e) => format!("error: {e}"),
212                        };
213                        let _ = writeln!(f, "[apm] wrapper exited: {status_str}");
214                    }
215                });
216
217                // Spawn parser: stdin = wrapper stdout pipe; stdout/stderr -> log
218                let mut parser_cmd = std::process::Command::new(&parser_bin);
219                parser_cmd.stdin(Stdio::from(wrapper_stdout));
220                parser_cmd.stdout(log_file_parser_stdout);
221                parser_cmd.stderr(log_file_parser_stderr);
222                parser_cmd.current_dir(&ctx.worktree_path);
223                #[cfg(unix)]
224                parser_cmd.process_group(0);
225
226                Ok(parser_cmd.spawn()?)
227            }
228        }
229    }
230}
231
232fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: &str) {
233    cmd.env("APM_AGENT_NAME", &ctx.worker_name);
234    cmd.env("APM_TICKET_ID", &ctx.ticket_id);
235    cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
236    cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
237    cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
238    cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
239    cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
240    cmd.env("APM_MODEL", ctx.model.as_deref().unwrap_or(""));
241    cmd.env("APM_PROFILE", &ctx.profile);
242    if let Some(ref prefix) = ctx.role_prefix {
243        cmd.env("APM_ROLE_PREFIX", prefix);
244    }
245    cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
246    cmd.env("APM_BIN", apm_bin);
247    for (k, v) in &ctx.options {
248        let env_key = format!(
249            "APM_OPT_{}",
250            k.to_uppercase().replace('.', "_").replace('-', "_")
251        );
252        cmd.env(&env_key, v);
253    }
254}
255
256pub(crate) fn find_script(root: &Path, name: &str) -> Option<PathBuf> {
257    let dir = root.join(".apm").join("agents").join(name);
258    let mut candidates: Vec<PathBuf> = std::fs::read_dir(&dir)
259        .ok()?
260        .filter_map(|e| e.ok())
261        .filter_map(|e| {
262            let path = e.path();
263            let fname = path.file_name()?.to_str()?.to_owned();
264            if !fname.starts_with("wrapper.") {
265                return None;
266            }
267            #[cfg(unix)]
268            {
269                use std::os::unix::fs::PermissionsExt;
270                let meta = path.metadata().ok()?;
271                if meta.permissions().mode() & 0o111 == 0 {
272                    return None;
273                }
274            }
275            Some(path)
276        })
277        .collect();
278    candidates.sort();
279    candidates.into_iter().next()
280}
281
282pub(crate) fn parse_manifest(root: &Path, name: &str) -> anyhow::Result<Option<Manifest>> {
283    let path = root.join(".apm").join("agents").join(name).join("manifest.toml");
284    if !path.exists() {
285        return Ok(None);
286    }
287    let content = std::fs::read_to_string(&path)
288        .with_context(|| format!("reading {}", path.display()))?;
289
290    #[derive(Deserialize)]
291    struct ManifestFile { wrapper: Manifest }
292
293    let file: ManifestFile = toml::from_str(&content)
294        .with_context(|| format!("parsing {}", path.display()))?;
295    Ok(Some(file.wrapper))
296}
297
298pub fn manifest_unknown_keys(root: &Path, name: &str) -> anyhow::Result<Vec<String>> {
299    let path = root.join(".apm").join("agents").join(name).join("manifest.toml");
300    if !path.exists() {
301        return Ok(vec![]);
302    }
303    let content = std::fs::read_to_string(&path)
304        .with_context(|| format!("reading {}", path.display()))?;
305    let table: toml::Value = content.parse::<toml::Value>()
306        .with_context(|| format!("parsing {}", path.display()))?;
307    let known = ["name", "contract_version", "parser", "parser_command", "enforce_worktree_isolation"];
308    let unknown = match table.get("wrapper").and_then(|v| v.as_table()) {
309        Some(t) => t.keys()
310            .filter(|k| !known.contains(&k.as_str()))
311            .cloned()
312            .collect(),
313        None => vec![],
314    };
315    Ok(unknown)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use std::collections::HashMap;
322
323    fn make_ctx(wt: &std::path::Path, log: &std::path::Path) -> WrapperContext {
324        WrapperContext {
325            worker_name: "test-worker".to_string(),
326            ticket_id: "test-id".to_string(),
327            ticket_branch: "ticket/test-id".to_string(),
328            worktree_path: wt.to_path_buf(),
329            system_prompt_file: wt.join("sys.txt"),
330            user_message_file: wt.join("msg.txt"),
331            skip_permissions: false,
332            profile: "default".to_string(),
333            role_prefix: None,
334            options: HashMap::new(),
335            model: None,
336            log_path: log.to_path_buf(),
337            container: None,
338            extra_env: HashMap::new(),
339            root: wt.to_path_buf(),
340            keychain: HashMap::new(),
341            current_state: "test".to_string(),
342            command: None,
343        }
344    }
345
346    fn make_executable(path: &std::path::Path, content: &str) {
347        std::fs::write(path, content).unwrap();
348        #[cfg(unix)]
349        {
350            use std::os::unix::fs::PermissionsExt;
351            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
352        }
353    }
354
355    // --- resolve_wrapper tests (via wrapper::resolve_wrapper) ---
356
357    #[test]
358    fn resolve_wrapper_custom_shadows_builtin() {
359        let dir = tempfile::tempdir().unwrap();
360        let root = dir.path();
361        let agent_dir = root.join(".apm").join("agents").join("claude");
362        std::fs::create_dir_all(&agent_dir).unwrap();
363        make_executable(&agent_dir.join("wrapper.sh"), "#!/bin/sh\nexit 0\n");
364
365        let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
366        assert!(matches!(result, Some(WrapperKind::Custom { .. })), "expected Custom variant");
367    }
368
369    #[test]
370    fn resolve_wrapper_fallback_to_builtin() {
371        let dir = tempfile::tempdir().unwrap();
372        let root = dir.path();
373        // No .apm/agents/claude/ dir
374
375        let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
376        assert!(matches!(result, Some(WrapperKind::Builtin(ref n)) if n == "claude"),
377            "expected Builtin(claude)");
378    }
379
380    #[test]
381    fn resolve_wrapper_missing_returns_none() {
382        let dir = tempfile::tempdir().unwrap();
383        let root = dir.path();
384        // "bogus-agent" is neither a builtin nor a custom script
385
386        let result = crate::wrapper::resolve_wrapper(root, "bogus-agent").unwrap();
387        assert!(result.is_none(), "expected None");
388    }
389
390    #[test]
391    fn resolve_wrapper_nonexecutable_invisible() {
392        let dir = tempfile::tempdir().unwrap();
393        let root = dir.path();
394        let agent_dir = root.join(".apm").join("agents").join("claude");
395        std::fs::create_dir_all(&agent_dir).unwrap();
396
397        // Write non-executable wrapper.sh
398        let script = agent_dir.join("wrapper.sh");
399        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
400        #[cfg(unix)]
401        {
402            use std::os::unix::fs::PermissionsExt;
403            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap();
404        }
405
406        // Non-executable script is invisible; falls through to builtin
407        let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
408        assert!(matches!(result, Some(WrapperKind::Builtin(ref n)) if n == "claude"),
409            "non-executable script should be invisible; expected fallback to Builtin(claude)");
410    }
411
412    // --- manifest tests ---
413
414    #[test]
415    fn manifest_parse_valid() {
416        let dir = tempfile::tempdir().unwrap();
417        let root = dir.path();
418        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
419        std::fs::create_dir_all(&agent_dir).unwrap();
420        std::fs::write(agent_dir.join("manifest.toml"),
421            "[wrapper]\nname = \"my-wrapper\"\ncontract_version = 1\nparser = \"canonical\"\n"
422        ).unwrap();
423
424        let m = parse_manifest(root, "my-wrapper").unwrap().unwrap();
425        assert_eq!(m.contract_version, 1);
426        assert_eq!(m.parser, "canonical");
427        assert_eq!(m.name.as_deref(), Some("my-wrapper"));
428        assert!(m.parser_command.is_none());
429    }
430
431    #[test]
432    fn manifest_parse_defaults() {
433        let dir = tempfile::tempdir().unwrap();
434        let root = dir.path();
435        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
436        std::fs::create_dir_all(&agent_dir).unwrap();
437        std::fs::write(agent_dir.join("manifest.toml"), "[wrapper]\n").unwrap();
438
439        let m = parse_manifest(root, "my-wrapper").unwrap().unwrap();
440        assert_eq!(m.contract_version, 1);
441        assert_eq!(m.parser, "canonical");
442        assert!(m.parser_command.is_none());
443    }
444
445    #[test]
446    fn manifest_parse_invalid_toml() {
447        let dir = tempfile::tempdir().unwrap();
448        let root = dir.path();
449        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
450        std::fs::create_dir_all(&agent_dir).unwrap();
451        std::fs::write(agent_dir.join("manifest.toml"), "[[[\nbad toml\n").unwrap();
452
453        assert!(parse_manifest(root, "my-wrapper").is_err(), "expected parse error");
454    }
455
456    #[test]
457    fn manifest_missing() {
458        let dir = tempfile::tempdir().unwrap();
459        let root = dir.path();
460        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
461        std::fs::create_dir_all(&agent_dir).unwrap();
462        // No manifest.toml
463
464        assert!(parse_manifest(root, "my-wrapper").unwrap().is_none());
465    }
466
467    #[test]
468    fn manifest_unknown_keys_detected() {
469        let dir = tempfile::tempdir().unwrap();
470        let root = dir.path();
471        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
472        std::fs::create_dir_all(&agent_dir).unwrap();
473        std::fs::write(agent_dir.join("manifest.toml"),
474            "[wrapper]\ncontract_version = 1\nunknown_key = \"foo\"\n"
475        ).unwrap();
476
477        let unknown = manifest_unknown_keys(root, "my-wrapper").unwrap();
478        assert!(unknown.contains(&"unknown_key".to_string()),
479            "expected unknown_key in {unknown:?}");
480    }
481
482    // --- check_contract_version unit tests ---
483
484    #[test]
485    fn check_version_equal() {
486        let log_dir = tempfile::tempdir().unwrap();
487        let log_path = log_dir.path().join("worker.log");
488        assert!(check_contract_version(1, 1, &log_path).is_ok());
489        // No log file created for equal versions
490        assert!(!log_path.exists() || std::fs::read_to_string(&log_path).unwrap().is_empty());
491    }
492
493    #[test]
494    fn check_version_older_writes_warning() {
495        let log_dir = tempfile::tempdir().unwrap();
496        let log_path = log_dir.path().join("worker.log");
497        // declared=1 is older than apm_version=2 → warning, Ok
498        let result = check_contract_version(1, 2, &log_path);
499        assert!(result.is_ok(), "expected Ok for older version");
500        let content = std::fs::read_to_string(&log_path).unwrap_or_default();
501        assert!(content.contains("warning"), "log must contain 'warning': {content}");
502        assert!(content.contains('1'), "log must contain declared version 1: {content}");
503        assert!(content.contains('2'), "log must contain apm version 2: {content}");
504    }
505
506    #[test]
507    fn check_version_too_high_returns_err() {
508        let log_dir = tempfile::tempdir().unwrap();
509        let log_path = log_dir.path().join("worker.log");
510        let result = check_contract_version(2, 1, &log_path);
511        assert!(result.is_err(), "expected Err for version > apm");
512        let msg = result.unwrap_err().to_string();
513        assert!(msg.contains("upgrade APM"), "error must mention 'upgrade APM': {msg}");
514        assert!(msg.contains('2'), "error must mention declared version 2: {msg}");
515        assert!(msg.contains('1'), "error must mention apm version 1: {msg}");
516    }
517
518    #[test]
519    fn default_contract_version_tracks_apm_version() {
520        // Ensures that bumping CONTRACT_VERSION also updates the manifest serde
521        // default, so older manifests don't silently parse with a stale version.
522        assert_eq!(default_contract_version(), CONTRACT_VERSION);
523    }
524
525    // --- ParserStrategy tests ---
526
527    #[test]
528    fn parser_strategy_defaults_to_canonical() {
529        assert_eq!(ParserStrategy::from_manifest(None), ParserStrategy::Canonical);
530    }
531
532    #[test]
533    fn parser_strategy_explicit_canonical() {
534        let m = Manifest {
535            name: None,
536            contract_version: 1,
537            parser: "canonical".to_string(),
538            parser_command: None,
539            enforce_worktree_isolation: false,
540        };
541        assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::Canonical);
542    }
543
544    #[test]
545    fn parser_strategy_external() {
546        let m = Manifest {
547            name: None,
548            contract_version: 1,
549            parser: "external".to_string(),
550            parser_command: Some("my-parser".to_string()),
551            enforce_worktree_isolation: false,
552        };
553        assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::External);
554    }
555
556    #[test]
557    fn parser_strategy_unknown_falls_back_to_canonical() {
558        let m = Manifest {
559            name: None,
560            contract_version: 1,
561            parser: "foobar".to_string(),
562            parser_command: None,
563            enforce_worktree_isolation: false,
564        };
565        assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::Canonical);
566    }
567
568    #[test]
569    fn spawn_external_missing_parser_command() {
570        use std::os::unix::fs::PermissionsExt;
571
572        let wt = tempfile::tempdir().unwrap();
573        let log_dir = tempfile::tempdir().unwrap();
574        let log_path = log_dir.path().join("worker.log");
575
576        let script = wt.path().join("wrapper.sh");
577        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
578        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
579
580        let manifest = Manifest {
581            name: None,
582            contract_version: 1,
583            parser: "external".to_string(),
584            parser_command: None,
585            enforce_worktree_isolation: false,
586        };
587        let wrapper = CustomWrapper {
588            script_path: script,
589            manifest: Some(manifest),
590        };
591
592        let ctx = make_ctx(wt.path(), &log_path);
593        let err = wrapper.spawn(&ctx).unwrap_err();
594        let msg = err.to_string();
595        assert!(msg.contains("parser_command"), "error must mention parser_command: {msg}");
596        assert!(msg.contains("not set"), "error must mention 'not set': {msg}");
597    }
598
599    #[test]
600    fn spawn_external_binary_not_found() {
601        use std::os::unix::fs::PermissionsExt;
602
603        let wt = tempfile::tempdir().unwrap();
604        let log_dir = tempfile::tempdir().unwrap();
605        let log_path = log_dir.path().join("worker.log");
606
607        let script = wt.path().join("wrapper.sh");
608        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
609        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
610
611        let manifest = Manifest {
612            name: None,
613            contract_version: 1,
614            parser: "external".to_string(),
615            parser_command: Some("nonexistent-binary-xyzzy-2803".to_string()),
616            enforce_worktree_isolation: false,
617        };
618        let wrapper = CustomWrapper {
619            script_path: script,
620            manifest: Some(manifest),
621        };
622
623        let ctx = make_ctx(wt.path(), &log_path);
624        let err = wrapper.spawn(&ctx).unwrap_err();
625        let msg = err.to_string();
626        assert!(
627            msg.contains("nonexistent-binary-xyzzy-2803"),
628            "error must name the missing binary: {msg}"
629        );
630    }
631
632    #[test]
633    fn spawn_external_resolves_parser_command_relative_to_agent_dir() {
634        use std::os::unix::fs::PermissionsExt;
635
636        // Layout mirrors the real bug: wrapper.sh and parser.py both live in
637        // the agent dir, and the manifest references the parser as "./parser.py".
638        let agent_dir = tempfile::tempdir().unwrap();
639        let wt = tempfile::tempdir().unwrap();
640        let log_dir = tempfile::tempdir().unwrap();
641        let log_path = log_dir.path().join("worker.log");
642
643        let wrapper_script = agent_dir.path().join("wrapper.sh");
644        std::fs::write(&wrapper_script, "#!/bin/sh\nexit 0\n").unwrap();
645        std::fs::set_permissions(&wrapper_script, std::fs::Permissions::from_mode(0o755)).unwrap();
646
647        let parser_script = agent_dir.path().join("parser.py");
648        std::fs::write(&parser_script, "#!/bin/sh\nexit 0\n").unwrap();
649        std::fs::set_permissions(&parser_script, std::fs::Permissions::from_mode(0o755)).unwrap();
650
651        let manifest = Manifest {
652            name: None,
653            contract_version: 1,
654            parser: "external".to_string(),
655            parser_command: Some("./parser.py".to_string()),
656            enforce_worktree_isolation: false,
657        };
658        let wrapper = CustomWrapper {
659            script_path: wrapper_script,
660            manifest: Some(manifest),
661        };
662
663        let ctx = make_ctx(wt.path(), &log_path);
664        // spawn() should resolve "./parser.py" against the agent dir and succeed.
665        let mut child = wrapper.spawn(&ctx)
666            .expect("spawn should resolve ./parser.py against agent dir");
667        let _ = child.wait();
668    }
669
670    #[test]
671    fn spawn_rejects_contract_version_gt_1() {
672        use std::os::unix::fs::PermissionsExt;
673
674        let wt = tempfile::tempdir().unwrap();
675        let log_dir = tempfile::tempdir().unwrap();
676
677        // Create a script (won't be reached due to early bail)
678        let script = wt.path().join("wrapper.sh");
679        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
680        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
681
682        let manifest = Manifest {
683            name: None,
684            contract_version: 2,
685            parser: "canonical".to_string(),
686            parser_command: None,
687            enforce_worktree_isolation: false,
688        };
689
690        let wrapper = CustomWrapper {
691            script_path: script,
692            manifest: Some(manifest),
693        };
694
695        let ctx = make_ctx(wt.path(), &log_dir.path().join("worker.log"));
696        let err = wrapper.spawn(&ctx).unwrap_err();
697        let msg = err.to_string();
698        assert!(msg.contains("upgrade APM"),
699            "error message must mention 'upgrade APM': {msg}");
700    }
701}