Skip to main content

apm_core/wrapper/builtin/
claude.rs

1use 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        // -P workers: mandatory enforcement regardless of config
44        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    // APM_OPT_<KEY> for each option entry
163    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    // APM_OPT_<KEY> for each option entry
200    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}