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