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
22fn 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 #[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 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 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 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 let parser_bin = find_binary(&parser_cmd_str, agent_dir)?;
181
182 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 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 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 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_AGENT_TYPE", &ctx.agent_type);
235 cmd.env("APM_TICKET_ID", &ctx.ticket_id);
236 cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
237 cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
238 cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
239 cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
240 cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
241 cmd.env("APM_MODEL", ctx.model.as_deref().unwrap_or(""));
242 cmd.env("APM_PROFILE", &ctx.profile);
243 if let Some(ref prefix) = ctx.role_prefix {
244 cmd.env("APM_ROLE_PREFIX", prefix);
245 }
246 cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
247 cmd.env("APM_BIN", apm_bin);
248 for (k, v) in &ctx.options {
249 let env_key = format!(
250 "APM_OPT_{}",
251 k.to_uppercase().replace('.', "_").replace('-', "_")
252 );
253 cmd.env(&env_key, v);
254 }
255}
256
257pub(crate) fn find_script(root: &Path, name: &str) -> Option<PathBuf> {
258 let dir = root.join(".apm").join("agents").join(name);
259 let mut candidates: Vec<PathBuf> = std::fs::read_dir(&dir)
260 .ok()?
261 .filter_map(|e| e.ok())
262 .filter_map(|e| {
263 let path = e.path();
264 let fname = path.file_name()?.to_str()?.to_owned();
265 if !fname.starts_with("wrapper.") {
266 return None;
267 }
268 #[cfg(unix)]
269 {
270 use std::os::unix::fs::PermissionsExt;
271 let meta = path.metadata().ok()?;
272 if meta.permissions().mode() & 0o111 == 0 {
273 return None;
274 }
275 }
276 Some(path)
277 })
278 .collect();
279 candidates.sort();
280 candidates.into_iter().next()
281}
282
283pub(crate) fn parse_manifest(root: &Path, name: &str) -> anyhow::Result<Option<Manifest>> {
284 let path = root.join(".apm").join("agents").join(name).join("manifest.toml");
285 if !path.exists() {
286 return Ok(None);
287 }
288 let content = std::fs::read_to_string(&path)
289 .with_context(|| format!("reading {}", path.display()))?;
290
291 #[derive(Deserialize)]
292 struct ManifestFile { wrapper: Manifest }
293
294 let file: ManifestFile = toml::from_str(&content)
295 .with_context(|| format!("parsing {}", path.display()))?;
296 Ok(Some(file.wrapper))
297}
298
299pub fn manifest_unknown_keys(root: &Path, name: &str) -> anyhow::Result<Vec<String>> {
300 let path = root.join(".apm").join("agents").join(name).join("manifest.toml");
301 if !path.exists() {
302 return Ok(vec![]);
303 }
304 let content = std::fs::read_to_string(&path)
305 .with_context(|| format!("reading {}", path.display()))?;
306 let table: toml::Value = content.parse::<toml::Value>()
307 .with_context(|| format!("parsing {}", path.display()))?;
308 let known = ["name", "contract_version", "parser", "parser_command", "enforce_worktree_isolation"];
309 let unknown = match table.get("wrapper").and_then(|v| v.as_table()) {
310 Some(t) => t.keys()
311 .filter(|k| !known.contains(&k.as_str()))
312 .cloned()
313 .collect(),
314 None => vec![],
315 };
316 Ok(unknown)
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::collections::HashMap;
323
324 fn make_ctx(wt: &std::path::Path, log: &std::path::Path) -> WrapperContext {
325 WrapperContext {
326 worker_name: "test-worker".to_string(),
327 agent_type: "test".to_string(),
328 ticket_id: "test-id".to_string(),
329 ticket_branch: "ticket/test-id".to_string(),
330 worktree_path: wt.to_path_buf(),
331 system_prompt_file: wt.join("sys.txt"),
332 user_message_file: wt.join("msg.txt"),
333 skip_permissions: false,
334 profile: "default".to_string(),
335 role_prefix: None,
336 options: HashMap::new(),
337 model: None,
338 log_path: log.to_path_buf(),
339 container: None,
340 extra_env: HashMap::new(),
341 root: wt.to_path_buf(),
342 keychain: HashMap::new(),
343 current_state: "test".to_string(),
344 command: None,
345 }
346 }
347
348 fn make_executable(path: &std::path::Path, content: &str) {
349 std::fs::write(path, content).unwrap();
350 #[cfg(unix)]
351 {
352 use std::os::unix::fs::PermissionsExt;
353 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
354 }
355 }
356
357 #[test]
360 fn resolve_wrapper_custom_shadows_builtin() {
361 let dir = tempfile::tempdir().unwrap();
362 let root = dir.path();
363 let agent_dir = root.join(".apm").join("agents").join("claude");
364 std::fs::create_dir_all(&agent_dir).unwrap();
365 make_executable(&agent_dir.join("wrapper.sh"), "#!/bin/sh\nexit 0\n");
366
367 let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
368 assert!(matches!(result, Some(WrapperKind::Custom { .. })), "expected Custom variant");
369 }
370
371 #[test]
372 fn resolve_wrapper_fallback_to_builtin() {
373 let dir = tempfile::tempdir().unwrap();
374 let root = dir.path();
375 let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
378 assert!(matches!(result, Some(WrapperKind::Builtin(ref n)) if n == "claude"),
379 "expected Builtin(claude)");
380 }
381
382 #[test]
383 fn resolve_wrapper_missing_returns_none() {
384 let dir = tempfile::tempdir().unwrap();
385 let root = dir.path();
386 let result = crate::wrapper::resolve_wrapper(root, "bogus-agent").unwrap();
389 assert!(result.is_none(), "expected None");
390 }
391
392 #[test]
393 fn resolve_wrapper_nonexecutable_invisible() {
394 let dir = tempfile::tempdir().unwrap();
395 let root = dir.path();
396 let agent_dir = root.join(".apm").join("agents").join("claude");
397 std::fs::create_dir_all(&agent_dir).unwrap();
398
399 let script = agent_dir.join("wrapper.sh");
401 std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
402 #[cfg(unix)]
403 {
404 use std::os::unix::fs::PermissionsExt;
405 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap();
406 }
407
408 let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
410 assert!(matches!(result, Some(WrapperKind::Builtin(ref n)) if n == "claude"),
411 "non-executable script should be invisible; expected fallback to Builtin(claude)");
412 }
413
414 #[test]
417 fn manifest_parse_valid() {
418 let dir = tempfile::tempdir().unwrap();
419 let root = dir.path();
420 let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
421 std::fs::create_dir_all(&agent_dir).unwrap();
422 std::fs::write(agent_dir.join("manifest.toml"),
423 "[wrapper]\nname = \"my-wrapper\"\ncontract_version = 1\nparser = \"canonical\"\n"
424 ).unwrap();
425
426 let m = parse_manifest(root, "my-wrapper").unwrap().unwrap();
427 assert_eq!(m.contract_version, 1);
428 assert_eq!(m.parser, "canonical");
429 assert_eq!(m.name.as_deref(), Some("my-wrapper"));
430 assert!(m.parser_command.is_none());
431 }
432
433 #[test]
434 fn manifest_parse_defaults() {
435 let dir = tempfile::tempdir().unwrap();
436 let root = dir.path();
437 let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
438 std::fs::create_dir_all(&agent_dir).unwrap();
439 std::fs::write(agent_dir.join("manifest.toml"), "[wrapper]\n").unwrap();
440
441 let m = parse_manifest(root, "my-wrapper").unwrap().unwrap();
442 assert_eq!(m.contract_version, 1);
443 assert_eq!(m.parser, "canonical");
444 assert!(m.parser_command.is_none());
445 }
446
447 #[test]
448 fn manifest_parse_invalid_toml() {
449 let dir = tempfile::tempdir().unwrap();
450 let root = dir.path();
451 let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
452 std::fs::create_dir_all(&agent_dir).unwrap();
453 std::fs::write(agent_dir.join("manifest.toml"), "[[[\nbad toml\n").unwrap();
454
455 assert!(parse_manifest(root, "my-wrapper").is_err(), "expected parse error");
456 }
457
458 #[test]
459 fn manifest_missing() {
460 let dir = tempfile::tempdir().unwrap();
461 let root = dir.path();
462 let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
463 std::fs::create_dir_all(&agent_dir).unwrap();
464 assert!(parse_manifest(root, "my-wrapper").unwrap().is_none());
467 }
468
469 #[test]
470 fn manifest_unknown_keys_detected() {
471 let dir = tempfile::tempdir().unwrap();
472 let root = dir.path();
473 let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
474 std::fs::create_dir_all(&agent_dir).unwrap();
475 std::fs::write(agent_dir.join("manifest.toml"),
476 "[wrapper]\ncontract_version = 1\nunknown_key = \"foo\"\n"
477 ).unwrap();
478
479 let unknown = manifest_unknown_keys(root, "my-wrapper").unwrap();
480 assert!(unknown.contains(&"unknown_key".to_string()),
481 "expected unknown_key in {unknown:?}");
482 }
483
484 #[test]
487 fn check_version_equal() {
488 let log_dir = tempfile::tempdir().unwrap();
489 let log_path = log_dir.path().join("worker.log");
490 assert!(check_contract_version(1, 1, &log_path).is_ok());
491 assert!(!log_path.exists() || std::fs::read_to_string(&log_path).unwrap().is_empty());
493 }
494
495 #[test]
496 fn check_version_older_writes_warning() {
497 let log_dir = tempfile::tempdir().unwrap();
498 let log_path = log_dir.path().join("worker.log");
499 let result = check_contract_version(1, 2, &log_path);
501 assert!(result.is_ok(), "expected Ok for older version");
502 let content = std::fs::read_to_string(&log_path).unwrap_or_default();
503 assert!(content.contains("warning"), "log must contain 'warning': {content}");
504 assert!(content.contains('1'), "log must contain declared version 1: {content}");
505 assert!(content.contains('2'), "log must contain apm version 2: {content}");
506 }
507
508 #[test]
509 fn check_version_too_high_returns_err() {
510 let log_dir = tempfile::tempdir().unwrap();
511 let log_path = log_dir.path().join("worker.log");
512 let result = check_contract_version(2, 1, &log_path);
513 assert!(result.is_err(), "expected Err for version > apm");
514 let msg = result.unwrap_err().to_string();
515 assert!(msg.contains("upgrade APM"), "error must mention 'upgrade APM': {msg}");
516 assert!(msg.contains('2'), "error must mention declared version 2: {msg}");
517 assert!(msg.contains('1'), "error must mention apm version 1: {msg}");
518 }
519
520 #[test]
521 fn default_contract_version_tracks_apm_version() {
522 assert_eq!(default_contract_version(), CONTRACT_VERSION);
525 }
526
527 #[test]
530 fn parser_strategy_defaults_to_canonical() {
531 assert_eq!(ParserStrategy::from_manifest(None), ParserStrategy::Canonical);
532 }
533
534 #[test]
535 fn parser_strategy_explicit_canonical() {
536 let m = Manifest {
537 name: None,
538 contract_version: 1,
539 parser: "canonical".to_string(),
540 parser_command: None,
541 enforce_worktree_isolation: false,
542 };
543 assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::Canonical);
544 }
545
546 #[test]
547 fn parser_strategy_external() {
548 let m = Manifest {
549 name: None,
550 contract_version: 1,
551 parser: "external".to_string(),
552 parser_command: Some("my-parser".to_string()),
553 enforce_worktree_isolation: false,
554 };
555 assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::External);
556 }
557
558 #[test]
559 fn parser_strategy_unknown_falls_back_to_canonical() {
560 let m = Manifest {
561 name: None,
562 contract_version: 1,
563 parser: "foobar".to_string(),
564 parser_command: None,
565 enforce_worktree_isolation: false,
566 };
567 assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::Canonical);
568 }
569
570 #[test]
571 fn spawn_external_missing_parser_command() {
572 use std::os::unix::fs::PermissionsExt;
573
574 let wt = tempfile::tempdir().unwrap();
575 let log_dir = tempfile::tempdir().unwrap();
576 let log_path = log_dir.path().join("worker.log");
577
578 let script = wt.path().join("wrapper.sh");
579 std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
580 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
581
582 let manifest = Manifest {
583 name: None,
584 contract_version: 1,
585 parser: "external".to_string(),
586 parser_command: None,
587 enforce_worktree_isolation: false,
588 };
589 let wrapper = CustomWrapper {
590 script_path: script,
591 manifest: Some(manifest),
592 };
593
594 let ctx = make_ctx(wt.path(), &log_path);
595 let err = wrapper.spawn(&ctx).unwrap_err();
596 let msg = err.to_string();
597 assert!(msg.contains("parser_command"), "error must mention parser_command: {msg}");
598 assert!(msg.contains("not set"), "error must mention 'not set': {msg}");
599 }
600
601 #[test]
602 fn spawn_external_binary_not_found() {
603 use std::os::unix::fs::PermissionsExt;
604
605 let wt = tempfile::tempdir().unwrap();
606 let log_dir = tempfile::tempdir().unwrap();
607 let log_path = log_dir.path().join("worker.log");
608
609 let script = wt.path().join("wrapper.sh");
610 std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
611 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
612
613 let manifest = Manifest {
614 name: None,
615 contract_version: 1,
616 parser: "external".to_string(),
617 parser_command: Some("nonexistent-binary-xyzzy-2803".to_string()),
618 enforce_worktree_isolation: false,
619 };
620 let wrapper = CustomWrapper {
621 script_path: script,
622 manifest: Some(manifest),
623 };
624
625 let ctx = make_ctx(wt.path(), &log_path);
626 let err = wrapper.spawn(&ctx).unwrap_err();
627 let msg = err.to_string();
628 assert!(
629 msg.contains("nonexistent-binary-xyzzy-2803"),
630 "error must name the missing binary: {msg}"
631 );
632 }
633
634 #[test]
635 fn spawn_external_resolves_parser_command_relative_to_agent_dir() {
636 use std::os::unix::fs::PermissionsExt;
637
638 let agent_dir = tempfile::tempdir().unwrap();
641 let wt = tempfile::tempdir().unwrap();
642 let log_dir = tempfile::tempdir().unwrap();
643 let log_path = log_dir.path().join("worker.log");
644
645 let wrapper_script = agent_dir.path().join("wrapper.sh");
646 std::fs::write(&wrapper_script, "#!/bin/sh\nexit 0\n").unwrap();
647 std::fs::set_permissions(&wrapper_script, std::fs::Permissions::from_mode(0o755)).unwrap();
648
649 let parser_script = agent_dir.path().join("parser.py");
650 std::fs::write(&parser_script, "#!/bin/sh\nexit 0\n").unwrap();
651 std::fs::set_permissions(&parser_script, std::fs::Permissions::from_mode(0o755)).unwrap();
652
653 let manifest = Manifest {
654 name: None,
655 contract_version: 1,
656 parser: "external".to_string(),
657 parser_command: Some("./parser.py".to_string()),
658 enforce_worktree_isolation: false,
659 };
660 let wrapper = CustomWrapper {
661 script_path: wrapper_script,
662 manifest: Some(manifest),
663 };
664
665 let ctx = make_ctx(wt.path(), &log_path);
666 let mut child = wrapper.spawn(&ctx)
668 .expect("spawn should resolve ./parser.py against agent dir");
669 let _ = child.wait();
670 }
671
672 #[test]
673 fn spawn_rejects_contract_version_gt_1() {
674 use std::os::unix::fs::PermissionsExt;
675
676 let wt = tempfile::tempdir().unwrap();
677 let log_dir = tempfile::tempdir().unwrap();
678
679 let script = wt.path().join("wrapper.sh");
681 std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
682 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
683
684 let manifest = Manifest {
685 name: None,
686 contract_version: 2,
687 parser: "canonical".to_string(),
688 parser_command: None,
689 enforce_worktree_isolation: false,
690 };
691
692 let wrapper = CustomWrapper {
693 script_path: script,
694 manifest: Some(manifest),
695 };
696
697 let ctx = make_ctx(wt.path(), &log_dir.path().join("worker.log"));
698 let err = wrapper.spawn(&ctx).unwrap_err();
699 let msg = err.to_string();
700 assert!(msg.contains("upgrade APM"),
701 "error message must mention 'upgrade APM': {msg}");
702 }
703}