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