hatchery-node 0.2.66

Hatchery node: the complete node kit over the gate4agent transport (PTY/inline sessions, file browser, local git, worktrees, mailbox, browser station, session restore)
Documentation
//! Auto-permit at spawn: pre-answer provider interactive prompts (trust workspace,
//! onboarding/theme, permission modes) so node sessions start ready and in auto/yolo.
//!
//! Default ON. Disable with `HATCHERY_AUTO_PERMIT=0` (all providers) or
//! `HATCHERY_AUTO_PERMIT_<PROVIDER>=0` (e.g. `HATCHERY_AUTO_PERMIT_CODEX=0`).
//! Auto-update is left enabled (we never set an updater-disable switch).

use gate4agent_node_protocol::SessionMode;
use serde_json::{json, Value};
use std::ffi::OsString;
use std::path::{Path, PathBuf};

fn off(v: Option<String>) -> bool {
    matches!(v.as_deref(), Some("0") | Some("false") | Some("no") | Some("off"))
}

pub fn enabled(provider: &str) -> bool {
    if off(std::env::var("HATCHERY_AUTO_PERMIT").ok()) {
        return false;
    }
    !off(std::env::var(format!("HATCHERY_AUTO_PERMIT_{}", provider.to_ascii_uppercase())).ok())
}

/// Launch flags for the auto/yolo mode (PTY and ACP).
pub fn launch_args(provider: &str, mode: SessionMode) -> Vec<OsString> {
    if !enabled(provider) {
        return Vec::new();
    }
    let v: &[&str] = match (provider, mode) {
        ("claude", SessionMode::Acp) => &[],           // ACP: permission mode set via session/set_mode
        ("claude", _) => &["--dangerously-skip-permissions"],
        ("codex", SessionMode::Acp) => &["-c", "approval_policy=\"never\"", "-c", "sandbox_mode=\"danger-full-access\""],
        ("codex", _) => &["--dangerously-bypass-approvals-and-sandbox"],
        // ACP launch overlays carry only codex `-c` args; grok/kimi ACP use mode + auto-approve.
        ("grok", SessionMode::Acp) | ("kimi", SessionMode::Acp) => &[],
        ("grok", _) => &["--always-approve"],
        ("kimi", _) => &["--auto"],
        _ => &[],
    };
    v.iter().map(OsString::from).collect()
}

/// ACP permission mode id to request right after session/new (or auto-approve).
pub fn acp_mode(provider: &str) -> Option<&'static str> {
    if !enabled(provider) {
        return None;
    }
    // `HATCHERY_ACP_MODE_<PROVIDER>` picks another mode (e.g. `default`, where the agent asks
    // for every tool and the node's auto-approve answers): a probe of the approval path.
    if let Some(m) = mode_override(provider) {
        static LEAKED: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
        return Some(LEAKED.get_or_init(|| Box::leak(m.into_boxed_str())));
    }
    match provider {
        "claude" => Some("bypassPermissions"),
        "codex" => Some("full-access"),
        "grok" | "kimi" => Some("auto"),
        _ => None,
    }
}

/// True: answer every ACP session/request_permission with the allow option.
pub fn acp_auto_approve(provider: &str) -> bool {
    enabled(provider)
}

/// Approval level for a spawn, when auto-permit must deviate from the FullAuto default:
/// claude's ACP agent offers no `bypassPermissions` under root, so FullAuto would fail the
/// spawn; Unmanaged starts it and the node then switches to its `auto` mode and approves
/// every permission request itself.
pub fn spawn_approval_level(provider: &str, mode: SessionMode) -> Option<gate4agent_types::ApprovalLevel> {
    if !(enabled(provider) && provider == "claude" && mode == SessionMode::Acp) {
        return None;
    }
    // With a mode override (approval probe) the transport defers every permission request to
    // the node, which answers it through ResolveInteraction ("acp permission auto-approved").
    if mode_override(provider).is_some() {
        return Some(gate4agent_types::ApprovalLevel::Moderate);
    }
    is_root().then_some(gate4agent_types::ApprovalLevel::Unmanaged)
}

fn mode_override(provider: &str) -> Option<String> {
    let m = std::env::var(format!("HATCHERY_ACP_MODE_{}", provider.to_ascii_uppercase())).ok()?;
    let m = m.trim();
    (!m.is_empty() && m.len() <= 64 && m.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')).then(|| m.to_owned())
}

static ACP_SESSIONS: std::sync::Mutex<std::collections::BTreeMap<u64, &'static str>> =
    std::sync::Mutex::new(std::collections::BTreeMap::new());

/// Remember an auto-permitted ACP runtime (instance id -> provider).
pub fn register_acp(instance_id: u64, provider: &str) {
    let p: &'static str = match provider { "claude" => "claude", "codex" => "codex", "grok" => "grok", "kimi" => "kimi", _ => return };
    if enabled(p) {
        ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).insert(instance_id, p);
    }
}

pub fn forget_acp(instance_id: u64) {
    ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).remove(&instance_id);
}

pub fn acp_provider(instance_id: u64) -> Option<&'static str> {
    ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).get(&instance_id).copied()
}

/// Mode to switch to: the provider's preferred auto mode, else any advertised mode whose id
/// reads as a no-ask mode. None when already there or nothing fits.
pub fn pick_acp_mode<'a>(provider: &str, current: &str, available: impl Iterator<Item = &'a str> + Clone) -> Option<String> {
    let preferred = acp_mode(provider)?;
    let fallbacks = ["bypassPermissions", "full-access", "yolo", "auto", "agent-full-access", "dontAsk"];
    let pick = std::iter::once(preferred)
        .chain(fallbacks)
        .find(|want| available.clone().any(|a| a == *want))?;
    (pick != current).then(|| pick.to_owned())
}

/// Pre-write provider config in each candidate home so prompts never appear.
pub fn prepare(provider: &str, homes: &[PathBuf], cwd: Option<&Path>) {
    if !enabled(provider) {
        return;
    }
    for home in homes {
        let r = match provider {
            "claude" => claude(home, cwd),
            "codex" => codex(home, cwd),
            _ => Ok(()), // grok/kimi: trust+approve via flags; no workspace trust prompt
        };
        if let Err(e) = r {
            tracing::warn!(%provider, home = %home.display(), error = %e, "auto-permit config not written");
        }
    }
}

fn claude(home: &Path, cwd: Option<&Path>) -> Result<(), String> {
    let path = home.join(".claude.json");
    let mut v: Value = std::fs::read(&path)
        .ok()
        .and_then(|b| serde_json::from_slice(&b).ok())
        .unwrap_or_else(|| json!({}));
    if !v.is_object() {
        v = json!({});
    }
    patch_claude(&mut v, cwd);
    let tmp = path.with_extension("json.hatchery-tmp");
    std::fs::write(&tmp, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
    claude_settings(home)
}

/// `~/.claude/settings.json`: skip the bypass-mode warning; under root, declare the sandbox
/// (`env.IS_SANDBOX`) so claude accepts bypass mode (PTY children get a minimal env).
fn claude_settings(home: &Path) -> Result<(), String> {
    let dir = home.join(".claude");
    if !dir.is_dir() {
        return Ok(());
    }
    let path = dir.join("settings.json");
    let mut v: Value = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_else(|| json!({}));
    if !v.is_object() {
        v = json!({});
    }
    let before = v.clone();
    patch_claude_settings(&mut v, is_root());
    if v == before {
        return Ok(());
    }
    let tmp = path.with_extension("json.hatchery-tmp");
    std::fs::write(&tmp, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
}

pub(crate) fn patch_claude_settings(v: &mut Value, root: bool) {
    v["skipDangerousModePermissionPrompt"] = json!(true);
    if root {
        if !v["env"].is_object() {
            v["env"] = json!({});
        }
        if v["env"].get("IS_SANDBOX").is_none() {
            v["env"]["IS_SANDBOX"] = json!("1");
        }
    }
}

fn is_root() -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        std::fs::metadata("/proc/self").map(|m| m.uid() == 0).unwrap_or(false)
    }
    #[cfg(not(unix))]
    {
        false
    }
}

pub(crate) fn patch_claude(v: &mut Value, cwd: Option<&Path>) {
    let o = v.as_object_mut().expect("object");
    o.insert("hasCompletedOnboarding".into(), json!(true));
    o.entry("theme").or_insert(json!("dark"));
    o.insert("bypassPermissionsModeAccepted".into(), json!(true));
    o.insert("autoUpdates".into(), json!(true));
    if let Some(cwd) = cwd {
        let projects = o.entry("projects").or_insert(json!({}));
        if !projects.is_object() {
            *projects = json!({});
        }
        let p = projects
            .as_object_mut()
            .unwrap()
            .entry(cwd.display().to_string())
            .or_insert(json!({}));
        if let Some(p) = p.as_object_mut() {
            p.insert("hasTrustDialogAccepted".into(), json!(true));
            p.insert("hasCompletedProjectOnboarding".into(), json!(true));
        }
    }
}

fn codex(home: &Path, cwd: Option<&Path>) -> Result<(), String> {
    let dir = home.join(".codex");
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    let path = dir.join("config.toml");
    let cur = std::fs::read_to_string(&path).unwrap_or_default();
    let next = patch_codex(&cur, cwd);
    if next != cur {
        std::fs::write(&path, next).map_err(|e| e.to_string())?;
    }
    Ok(())
}

pub(crate) fn patch_codex(cur: &str, cwd: Option<&Path>) -> String {
    let mut out = cur.to_owned();
    // Top-level keys must precede any [table]; prepend only if missing.
    let mut head = String::new();
    for (k, val) in [("approval_policy", "\"never\""), ("sandbox_mode", "\"danger-full-access\"")] {
        let has = cur.lines().take_while(|l| !l.trim_start().starts_with('[')).any(|l| l.trim_start().starts_with(k));
        if !has {
            head.push_str(&format!("{k} = {val}\n"));
        }
    }
    if !head.is_empty() {
        out = format!("{head}{out}");
    }
    if let Some(cwd) = cwd {
        let table = format!("[projects.\"{}\"]", cwd.display());
        if !out.contains(&table) {
            if !out.ends_with('\n') && !out.is_empty() {
                out.push('\n');
            }
            out.push_str(&format!("\n{table}\ntrust_level = \"trusted\"\n"));
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn claude_patch_sets_trust_onboarding_bypass() {
        let mut v = json!({"theme":"light","projects":{"/x":{"allowedTools":[]}}});
        patch_claude(&mut v, Some(Path::new("/w")));
        assert_eq!(v["projects"]["/w"]["hasTrustDialogAccepted"], true);
        assert_eq!(v["hasCompletedOnboarding"], true);
        assert_eq!(v["bypassPermissionsModeAccepted"], true);
        assert_eq!(v["theme"], "light");
        assert_eq!(v["projects"]["/x"]["allowedTools"], json!([]));
    }

    #[test]
    fn codex_patch_idempotent() {
        let a = patch_codex("model = \"o3\"\n[x]\ny=1\n", Some(Path::new("/w")));
        assert!(a.starts_with("approval_policy = \"never\"\nsandbox_mode = \"danger-full-access\"\n"));
        assert!(a.contains("[projects.\"/w\"]\ntrust_level = \"trusted\""));
        assert_eq!(patch_codex(&a, Some(Path::new("/w"))), a);
    }

    #[test]
    fn acp_mode_pick() {
        let modes = ["default", "acceptEdits", "bypassPermissions"];
        assert_eq!(pick_acp_mode("claude", "default", modes.iter().copied()).as_deref(), Some("bypassPermissions"));
        assert_eq!(pick_acp_mode("claude", "bypassPermissions", modes.iter().copied()), None);
        assert_eq!(pick_acp_mode("kimi", "default", ["default", "yolo"].iter().copied()).as_deref(), Some("yolo"));
    }

    #[test]
    fn args_per_provider() {
        assert_eq!(launch_args("claude", SessionMode::Pty), vec![OsString::from("--dangerously-skip-permissions")]);
        assert_eq!(launch_args("kimi", SessionMode::Pty), vec![OsString::from("--auto")]);
        assert!(launch_args("grok", SessionMode::Acp).is_empty());
    }
}