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