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) -> 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 #[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 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 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 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 let parser_bin = find_binary(&parser_cmd_str)?;
168
169 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 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 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 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 #[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 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 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 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 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 #[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 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 #[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 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 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 assert_eq!(default_contract_version(), CONTRACT_VERSION);
509 }
510
511 #[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 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}