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_AGENT_TYPE", &ctx.agent_type),
143 ("APM_TICKET_ID", &ctx.ticket_id),
144 ("APM_TICKET_BRANCH", &ctx.ticket_branch),
145 ("APM_TICKET_WORKTREE", &worktree_str),
146 ("APM_SYSTEM_PROMPT_FILE", &sys_file_str),
147 ("APM_USER_MESSAGE_FILE", &msg_file_str),
148 ("APM_SKIP_PERMISSIONS", skip_perm_val),
149 ("APM_PROFILE", &ctx.profile),
150 ("APM_WRAPPER_VERSION", &contract_version_str),
151 ("APM_BIN", apm_bin),
152 ];
153 for (k, v) in apm_env_pairs {
154 cmd.args(["--env", &format!("{k}={v}")]);
155 }
156 if let Some(ref prefix) = ctx.role_prefix {
157 cmd.args(["--env", &format!("APM_ROLE_PREFIX={prefix}")]);
158 }
159 for (k, v) in &ctx.extra_env {
160 cmd.args(["--env", &format!("{k}={v}")]);
161 }
162 for (k, v) in &ctx.options {
164 let env_key = format!(
165 "APM_OPT_{}",
166 k.to_uppercase().replace('.', "_").replace('-', "_")
167 );
168 cmd.args(["--env", &format!("{env_key}={v}")]);
169 }
170
171 cmd.arg(image);
172 cmd.arg("claude");
173 cmd.args(build_claude_args(ctx.model.as_deref(), ctx.skip_permissions, sys, msg));
174
175 let log_file = std::fs::File::create(&ctx.log_path)?;
176 let log_clone = log_file.try_clone()?;
177 cmd.stdout(log_file);
178 cmd.stderr(log_clone);
179 cmd.process_group(0);
180
181 Ok(cmd.spawn()?)
182}
183
184fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: &str) {
185 cmd.env("APM_AGENT_NAME", &ctx.worker_name);
186 cmd.env("APM_AGENT_TYPE", &ctx.agent_type);
187 cmd.env("APM_TICKET_ID", &ctx.ticket_id);
188 cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
189 cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
190 cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
191 cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
192 cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
193 cmd.env("APM_PROFILE", &ctx.profile);
194 if let Some(ref prefix) = ctx.role_prefix {
195 cmd.env("APM_ROLE_PREFIX", prefix);
196 }
197 cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
198 cmd.env("APM_BIN", apm_bin);
199 for (k, v) in &ctx.options {
201 let env_key = format!(
202 "APM_OPT_{}",
203 k.to_uppercase().replace('.', "_").replace('-', "_")
204 );
205 cmd.env(&env_key, v);
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::build_claude_args;
212
213 #[test]
214 fn args_include_model_flag_when_set() {
215 let args = build_claude_args(Some("sonnet"), false, "sys", "msg");
216 let pos = args.iter().position(|a| a == "--model").expect("--model flag must be in argv");
217 assert_eq!(args.get(pos + 1).map(String::as_str), Some("sonnet"), "value must follow --model");
218 }
219
220 #[test]
221 fn args_omit_model_flag_when_unset() {
222 let args = build_claude_args(None, false, "sys", "msg");
223 assert!(!args.iter().any(|a| a == "--model"), "--model must be absent when no model configured: {args:?}");
224 }
225
226 #[test]
227 fn args_include_skip_permissions_when_set() {
228 let args = build_claude_args(None, true, "sys", "msg");
229 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"), "{args:?}");
230 }
231
232 #[test]
233 fn args_msg_is_last() {
234 let args = build_claude_args(Some("opus"), true, "sys", "the-message");
235 assert_eq!(args.last().map(String::as_str), Some("the-message"));
236 }
237
238 #[test]
239 fn args_always_include_disable_slash_commands() {
240 for (model, skip) in [
241 (None, false), (None, true),
242 (Some("sonnet"), false), (Some("sonnet"), true),
243 ] {
244 let args = build_claude_args(model, skip, "sys", "msg");
245 assert!(
246 args.iter().any(|a| a == "--disable-slash-commands"),
247 "missing --disable-slash-commands for model={model:?} skip={skip}: {args:?}"
248 );
249 }
250 }
251
252 #[test]
253 fn installed_claude_binary_supports_disable_slash_commands() {
254 let Ok(out) = std::process::Command::new("claude").arg("--help").output() else {
255 eprintln!("claude not in PATH — skipping flag-existence check");
256 return;
257 };
258 let combined = format!(
259 "{}{}",
260 String::from_utf8_lossy(&out.stdout),
261 String::from_utf8_lossy(&out.stderr)
262 );
263 assert!(
264 combined.contains("--disable-slash-commands"),
265 "installed claude binary does not recognise --disable-slash-commands; \
266 flag may have been renamed. Update build_claude_args() to match."
267 );
268 }
269}