1use gate4agent_node_protocol::SessionMode;
9use serde_json::{json, Value};
10use std::ffi::OsString;
11use std::path::{Path, PathBuf};
12
13fn off(v: Option<String>) -> bool {
14 matches!(v.as_deref(), Some("0") | Some("false") | Some("no") | Some("off"))
15}
16
17pub fn enabled(provider: &str) -> bool {
18 if off(std::env::var("HATCHERY_AUTO_PERMIT").ok()) {
19 return false;
20 }
21 !off(std::env::var(format!("HATCHERY_AUTO_PERMIT_{}", provider.to_ascii_uppercase())).ok())
22}
23
24pub fn launch_args(provider: &str, mode: SessionMode) -> Vec<OsString> {
26 if !enabled(provider) {
27 return Vec::new();
28 }
29 let v: &[&str] = match (provider, mode) {
30 ("claude", SessionMode::Acp) => &[], ("claude", _) => &["--dangerously-skip-permissions"],
32 ("codex", SessionMode::Acp) => &["-c", "approval_policy=\"never\"", "-c", "sandbox_mode=\"danger-full-access\""],
33 ("codex", _) => &["--dangerously-bypass-approvals-and-sandbox"],
34 ("grok", SessionMode::Acp) | ("kimi", SessionMode::Acp) => &[],
36 ("grok", _) => &["--always-approve"],
37 ("kimi", _) => &["--auto"],
38 _ => &[],
39 };
40 v.iter().map(OsString::from).collect()
41}
42
43pub fn acp_mode(provider: &str) -> Option<&'static str> {
45 if !enabled(provider) {
46 return None;
47 }
48 if let Some(m) = mode_override(provider) {
51 static LEAKED: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
52 return Some(LEAKED.get_or_init(|| Box::leak(m.into_boxed_str())));
53 }
54 match provider {
55 "claude" => Some("bypassPermissions"),
56 "codex" => Some("full-access"),
57 "grok" | "kimi" => Some("auto"),
58 _ => None,
59 }
60}
61
62pub fn acp_auto_approve(provider: &str) -> bool {
64 enabled(provider)
65}
66
67pub fn spawn_approval_level(provider: &str, mode: SessionMode) -> Option<gate4agent_types::ApprovalLevel> {
72 if !(enabled(provider) && provider == "claude" && mode == SessionMode::Acp) {
73 return None;
74 }
75 if mode_override(provider).is_some() {
78 return Some(gate4agent_types::ApprovalLevel::Moderate);
79 }
80 is_root().then_some(gate4agent_types::ApprovalLevel::Unmanaged)
81}
82
83fn mode_override(provider: &str) -> Option<String> {
84 let m = std::env::var(format!("HATCHERY_ACP_MODE_{}", provider.to_ascii_uppercase())).ok()?;
85 let m = m.trim();
86 (!m.is_empty() && m.len() <= 64 && m.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')).then(|| m.to_owned())
87}
88
89static ACP_SESSIONS: std::sync::Mutex<std::collections::BTreeMap<u64, &'static str>> =
90 std::sync::Mutex::new(std::collections::BTreeMap::new());
91
92pub fn register_acp(instance_id: u64, provider: &str) {
94 let p: &'static str = match provider { "claude" => "claude", "codex" => "codex", "grok" => "grok", "kimi" => "kimi", _ => return };
95 if enabled(p) {
96 ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).insert(instance_id, p);
97 }
98}
99
100pub fn forget_acp(instance_id: u64) {
101 ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).remove(&instance_id);
102}
103
104pub fn acp_provider(instance_id: u64) -> Option<&'static str> {
105 ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).get(&instance_id).copied()
106}
107
108pub fn pick_acp_mode<'a>(provider: &str, current: &str, available: impl Iterator<Item = &'a str> + Clone) -> Option<String> {
111 let preferred = acp_mode(provider)?;
112 let fallbacks = ["bypassPermissions", "full-access", "yolo", "auto", "agent-full-access", "dontAsk"];
113 let pick = std::iter::once(preferred)
114 .chain(fallbacks)
115 .find(|want| available.clone().any(|a| a == *want))?;
116 (pick != current).then(|| pick.to_owned())
117}
118
119pub fn prepare(provider: &str, homes: &[PathBuf], cwd: Option<&Path>) {
121 if !enabled(provider) {
122 return;
123 }
124 for home in homes {
125 let r = match provider {
126 "claude" => claude(home, cwd),
127 "codex" => codex(home, cwd),
128 _ => Ok(()), };
130 if let Err(e) = r {
131 tracing::warn!(%provider, home = %home.display(), error = %e, "auto-permit config not written");
132 }
133 }
134}
135
136fn claude(home: &Path, cwd: Option<&Path>) -> Result<(), String> {
137 let path = home.join(".claude.json");
138 let mut v: Value = std::fs::read(&path)
139 .ok()
140 .and_then(|b| serde_json::from_slice(&b).ok())
141 .unwrap_or_else(|| json!({}));
142 if !v.is_object() {
143 v = json!({});
144 }
145 patch_claude(&mut v, cwd);
146 let tmp = path.with_extension("json.hatchery-tmp");
147 std::fs::write(&tmp, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
148 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
149 claude_settings(home)
150}
151
152fn claude_settings(home: &Path) -> Result<(), String> {
155 let dir = home.join(".claude");
156 if !dir.is_dir() {
157 return Ok(());
158 }
159 let path = dir.join("settings.json");
160 let mut v: Value = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_else(|| json!({}));
161 if !v.is_object() {
162 v = json!({});
163 }
164 let before = v.clone();
165 patch_claude_settings(&mut v, is_root());
166 if v == before {
167 return Ok(());
168 }
169 let tmp = path.with_extension("json.hatchery-tmp");
170 std::fs::write(&tmp, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
171 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
172}
173
174pub(crate) fn patch_claude_settings(v: &mut Value, root: bool) {
175 v["skipDangerousModePermissionPrompt"] = json!(true);
176 if root {
177 if !v["env"].is_object() {
178 v["env"] = json!({});
179 }
180 if v["env"].get("IS_SANDBOX").is_none() {
181 v["env"]["IS_SANDBOX"] = json!("1");
182 }
183 }
184}
185
186fn is_root() -> bool {
187 #[cfg(unix)]
188 {
189 use std::os::unix::fs::MetadataExt;
190 std::fs::metadata("/proc/self").map(|m| m.uid() == 0).unwrap_or(false)
191 }
192 #[cfg(not(unix))]
193 {
194 false
195 }
196}
197
198pub(crate) fn patch_claude(v: &mut Value, cwd: Option<&Path>) {
199 let o = v.as_object_mut().expect("object");
200 o.insert("hasCompletedOnboarding".into(), json!(true));
201 o.entry("theme").or_insert(json!("dark"));
202 o.insert("bypassPermissionsModeAccepted".into(), json!(true));
203 o.insert("autoUpdates".into(), json!(true));
204 if let Some(cwd) = cwd {
205 let projects = o.entry("projects").or_insert(json!({}));
206 if !projects.is_object() {
207 *projects = json!({});
208 }
209 let p = projects
210 .as_object_mut()
211 .unwrap()
212 .entry(cwd.display().to_string())
213 .or_insert(json!({}));
214 if let Some(p) = p.as_object_mut() {
215 p.insert("hasTrustDialogAccepted".into(), json!(true));
216 p.insert("hasCompletedProjectOnboarding".into(), json!(true));
217 }
218 }
219}
220
221fn codex(home: &Path, cwd: Option<&Path>) -> Result<(), String> {
222 let dir = home.join(".codex");
223 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
224 let path = dir.join("config.toml");
225 let cur = std::fs::read_to_string(&path).unwrap_or_default();
226 let next = patch_codex(&cur, cwd);
227 if next != cur {
228 std::fs::write(&path, next).map_err(|e| e.to_string())?;
229 }
230 Ok(())
231}
232
233pub(crate) fn patch_codex(cur: &str, cwd: Option<&Path>) -> String {
234 let mut out = cur.to_owned();
235 let mut head = String::new();
237 for (k, val) in [("approval_policy", "\"never\""), ("sandbox_mode", "\"danger-full-access\"")] {
238 let has = cur.lines().take_while(|l| !l.trim_start().starts_with('[')).any(|l| l.trim_start().starts_with(k));
239 if !has {
240 head.push_str(&format!("{k} = {val}\n"));
241 }
242 }
243 if !head.is_empty() {
244 out = format!("{head}{out}");
245 }
246 if let Some(cwd) = cwd {
247 let table = format!("[projects.\"{}\"]", cwd.display());
248 if !out.contains(&table) {
249 if !out.ends_with('\n') && !out.is_empty() {
250 out.push('\n');
251 }
252 out.push_str(&format!("\n{table}\ntrust_level = \"trusted\"\n"));
253 }
254 }
255 out
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn claude_patch_sets_trust_onboarding_bypass() {
264 let mut v = json!({"theme":"light","projects":{"/x":{"allowedTools":[]}}});
265 patch_claude(&mut v, Some(Path::new("/w")));
266 assert_eq!(v["projects"]["/w"]["hasTrustDialogAccepted"], true);
267 assert_eq!(v["hasCompletedOnboarding"], true);
268 assert_eq!(v["bypassPermissionsModeAccepted"], true);
269 assert_eq!(v["theme"], "light");
270 assert_eq!(v["projects"]["/x"]["allowedTools"], json!([]));
271 }
272
273 #[test]
274 fn codex_patch_idempotent() {
275 let a = patch_codex("model = \"o3\"\n[x]\ny=1\n", Some(Path::new("/w")));
276 assert!(a.starts_with("approval_policy = \"never\"\nsandbox_mode = \"danger-full-access\"\n"));
277 assert!(a.contains("[projects.\"/w\"]\ntrust_level = \"trusted\""));
278 assert_eq!(patch_codex(&a, Some(Path::new("/w"))), a);
279 }
280
281 #[test]
282 fn acp_mode_pick() {
283 let modes = ["default", "acceptEdits", "bypassPermissions"];
284 assert_eq!(pick_acp_mode("claude", "default", modes.iter().copied()).as_deref(), Some("bypassPermissions"));
285 assert_eq!(pick_acp_mode("claude", "bypassPermissions", modes.iter().copied()), None);
286 assert_eq!(pick_acp_mode("kimi", "default", ["default", "yolo"].iter().copied()).as_deref(), Some("yolo"));
287 }
288
289 #[test]
290 fn args_per_provider() {
291 assert_eq!(launch_args("claude", SessionMode::Pty), vec![OsString::from("--dangerously-skip-permissions")]);
292 assert_eq!(launch_args("kimi", SessionMode::Pty), vec![OsString::from("--auto")]);
293 assert!(launch_args("grok", SessionMode::Acp).is_empty());
294 }
295}
296