apm_core/wrapper/builtin/
claude.rs1use std::os::unix::process::CommandExt;
2use crate::wrapper::{Wrapper, WrapperContext, CONTRACT_VERSION};
3
4pub struct ClaudeWrapper;
5
6impl Wrapper for ClaudeWrapper {
7 fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result<std::process::Child> {
8 let sys = std::fs::read_to_string(&ctx.system_prompt_file)?;
9 let msg = std::fs::read_to_string(&ctx.user_message_file)?;
10
11 let apm_bin = super::super::resolve_apm_cli_bin();
12
13 match &ctx.container {
14 None => spawn_local(ctx, &sys, &msg, &apm_bin),
15 Some(image) => spawn_container(ctx, image, &sys, &msg, &apm_bin),
16 }
17 }
18}
19
20pub(crate) fn build_claude_args(model: Option<&str>, skip_permissions: bool, sys: &str, msg: &str) -> Vec<String> {
21 let mut args: Vec<String> = vec![
22 "--print".into(),
23 "--output-format".into(),
24 "stream-json".into(),
25 "--verbose".into(),
26 "--disable-slash-commands".into(),
27 "--system-prompt".into(),
28 sys.into(),
29 ];
30 if let Some(m) = model {
31 args.push("--model".into());
32 args.push(m.into());
33 }
34 if skip_permissions {
35 args.push("--dangerously-skip-permissions".into());
36 }
37 args.push(msg.into());
38 args
39}
40
41fn should_enforce_isolation(ctx: &WrapperContext) -> bool {
42 if ctx.skip_permissions {
43 return true;
45 }
46 crate::config::Config::load(&ctx.root)
47 .map(|c| c.isolation.enforce_worktree_isolation)
48 .unwrap_or(false)
49}
50
51fn spawn_local(
52 ctx: &WrapperContext,
53 sys: &str,
54 msg: &str,
55 apm_bin: &str,
56) -> anyhow::Result<std::process::Child> {
57 if should_enforce_isolation(ctx) {
58 crate::wrapper::hook_config::write_hook_config(&ctx.worktree_path, apm_bin)?;
59 }
60
61 let binary = ctx.command.as_deref().unwrap_or("claude");
62 let mut cmd = std::process::Command::new(binary);
63 cmd.args(build_claude_args(ctx.model.as_deref(), ctx.skip_permissions, sys, msg));
64
65 set_apm_env(&mut cmd, ctx, apm_bin);
66 for (k, v) in &ctx.extra_env {
67 cmd.env(k, v);
68 }
69
70 cmd.current_dir(&ctx.worktree_path);
71
72 let log_file = std::fs::File::create(&ctx.log_path)?;
73 let log_clone = log_file.try_clone()?;
74 cmd.stdout(log_file);
75 cmd.stderr(log_clone);
76 cmd.process_group(0);
77
78 Ok(cmd.spawn()?)
79}
80
81fn spawn_container(
82 ctx: &WrapperContext,
83 image: &str,
84 sys: &str,
85 msg: &str,
86 apm_bin: &str,
87) -> anyhow::Result<std::process::Child> {
88 if should_enforce_isolation(ctx) {
89 crate::wrapper::hook_config::write_hook_config(&ctx.worktree_path, apm_bin)?;
90 }
91 let api_key = crate::credentials::resolve(
92 "ANTHROPIC_API_KEY",
93 ctx.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()),
94 )?;
95
96 let author_name = std::env::var("GIT_AUTHOR_NAME")
97 .ok()
98 .filter(|v| !v.is_empty())
99 .or_else(|| crate::git_util::git_config_get(&ctx.root, "user.name"))
100 .unwrap_or_default();
101 let author_email = std::env::var("GIT_AUTHOR_EMAIL")
102 .ok()
103 .filter(|v| !v.is_empty())
104 .or_else(|| crate::git_util::git_config_get(&ctx.root, "user.email"))
105 .unwrap_or_default();
106 let committer_name = std::env::var("GIT_COMMITTER_NAME")
107 .ok()
108 .filter(|v| !v.is_empty())
109 .unwrap_or_else(|| author_name.clone());
110 let committer_email = std::env::var("GIT_COMMITTER_EMAIL")
111 .ok()
112 .filter(|v| !v.is_empty())
113 .unwrap_or_else(|| author_email.clone());
114
115 let mut cmd = std::process::Command::new("docker");
116 cmd.arg("run");
117 cmd.arg("--rm");
118 cmd.args(["--volume", &format!("{}:/workspace", ctx.worktree_path.display())]);
119 cmd.args(["--workdir", "/workspace"]);
120 cmd.args(["--env", &format!("ANTHROPIC_API_KEY={api_key}")]);
121 if !author_name.is_empty() {
122 cmd.args(["--env", &format!("GIT_AUTHOR_NAME={author_name}")]);
123 }
124 if !author_email.is_empty() {
125 cmd.args(["--env", &format!("GIT_AUTHOR_EMAIL={author_email}")]);
126 }
127 if !committer_name.is_empty() {
128 cmd.args(["--env", &format!("GIT_COMMITTER_NAME={committer_name}")]);
129 }
130 if !committer_email.is_empty() {
131 cmd.args(["--env", &format!("GIT_COMMITTER_EMAIL={committer_email}")]);
132 }
133
134 let skip_perm_val = if ctx.skip_permissions { "1" } else { "0" };
135 let worktree_str = ctx.worktree_path.to_string_lossy();
136 let sys_file_str = ctx.system_prompt_file.to_string_lossy();
137 let msg_file_str = ctx.user_message_file.to_string_lossy();
138 let contract_version_str = CONTRACT_VERSION.to_string();
139
140 let apm_env_pairs: &[(&str, &str)] = &[
141 ("APM_AGENT_NAME", &ctx.worker_name),
142 ("APM_TICKET_ID", &ctx.ticket_id),
143 ("APM_TICKET_BRANCH", &ctx.ticket_branch),
144 ("APM_TICKET_WORKTREE", &worktree_str),
145 ("APM_SYSTEM_PROMPT_FILE", &sys_file_str),
146 ("APM_USER_MESSAGE_FILE", &msg_file_str),
147 ("APM_SKIP_PERMISSIONS", skip_perm_val),
148 ("APM_PROFILE", &ctx.profile),
149 ("APM_WRAPPER_VERSION", &contract_version_str),
150 ("APM_BIN", apm_bin),
151 ];
152 for (k, v) in apm_env_pairs {
153 cmd.args(["--env", &format!("{k}={v}")]);
154 }
155 if let Some(ref prefix) = ctx.role_prefix {
156 cmd.args(["--env", &format!("APM_ROLE_PREFIX={prefix}")]);
157 }
158 for (k, v) in &ctx.extra_env {
159 cmd.args(["--env", &format!("{k}={v}")]);
160 }
161 for (k, v) in &ctx.options {
163 let env_key = format!(
164 "APM_OPT_{}",
165 k.to_uppercase().replace('.', "_").replace('-', "_")
166 );
167 cmd.args(["--env", &format!("{env_key}={v}")]);
168 }
169
170 cmd.arg(image);
171 cmd.arg("claude");
172 cmd.args(build_claude_args(ctx.model.as_deref(), ctx.skip_permissions, sys, msg));
173
174 let log_file = std::fs::File::create(&ctx.log_path)?;
175 let log_clone = log_file.try_clone()?;
176 cmd.stdout(log_file);
177 cmd.stderr(log_clone);
178 cmd.process_group(0);
179
180 Ok(cmd.spawn()?)
181}
182
183fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: &str) {
184 cmd.env("APM_AGENT_NAME", &ctx.worker_name);
185 cmd.env("APM_TICKET_ID", &ctx.ticket_id);
186 cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
187 cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
188 cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
189 cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
190 cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
191 cmd.env("APM_PROFILE", &ctx.profile);
192 if let Some(ref prefix) = ctx.role_prefix {
193 cmd.env("APM_ROLE_PREFIX", prefix);
194 }
195 cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
196 cmd.env("APM_BIN", apm_bin);
197 for (k, v) in &ctx.options {
199 let env_key = format!(
200 "APM_OPT_{}",
201 k.to_uppercase().replace('.', "_").replace('-', "_")
202 );
203 cmd.env(&env_key, v);
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::build_claude_args;
210
211 #[test]
212 fn args_include_model_flag_when_set() {
213 let args = build_claude_args(Some("sonnet"), false, "sys", "msg");
214 let pos = args.iter().position(|a| a == "--model").expect("--model flag must be in argv");
215 assert_eq!(args.get(pos + 1).map(String::as_str), Some("sonnet"), "value must follow --model");
216 }
217
218 #[test]
219 fn args_omit_model_flag_when_unset() {
220 let args = build_claude_args(None, false, "sys", "msg");
221 assert!(!args.iter().any(|a| a == "--model"), "--model must be absent when no model configured: {args:?}");
222 }
223
224 #[test]
225 fn args_include_skip_permissions_when_set() {
226 let args = build_claude_args(None, true, "sys", "msg");
227 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"), "{args:?}");
228 }
229
230 #[test]
231 fn args_msg_is_last() {
232 let args = build_claude_args(Some("opus"), true, "sys", "the-message");
233 assert_eq!(args.last().map(String::as_str), Some("the-message"));
234 }
235
236 #[test]
237 fn args_always_include_disable_slash_commands() {
238 for (model, skip) in [
239 (None, false), (None, true),
240 (Some("sonnet"), false), (Some("sonnet"), true),
241 ] {
242 let args = build_claude_args(model, skip, "sys", "msg");
243 assert!(
244 args.iter().any(|a| a == "--disable-slash-commands"),
245 "missing --disable-slash-commands for model={model:?} skip={skip}: {args:?}"
246 );
247 }
248 }
249
250 #[test]
251 fn installed_claude_binary_supports_disable_slash_commands() {
252 let Ok(out) = std::process::Command::new("claude").arg("--help").output() else {
253 eprintln!("claude not in PATH — skipping flag-existence check");
254 return;
255 };
256 let combined = format!(
257 "{}{}",
258 String::from_utf8_lossy(&out.stdout),
259 String::from_utf8_lossy(&out.stderr)
260 );
261 assert!(
262 combined.contains("--disable-slash-commands"),
263 "installed claude binary does not recognise --disable-slash-commands; \
264 flag may have been renamed. Update build_claude_args() to match."
265 );
266 }
267}