netsky-core 0.1.5

netsky core: agent model, prompt loader, spawner, config
Documentation
//! Claude runtime: build the `claude` CLI invocation for a spawned agent.
//!
//! Single code path for agent0, clones, and agentinfinity. Per-agent
//! differences (allowed tools, MCP servers + strict mode, dev-channel
//! flags, effort level) key off [`AgentId`].

use std::path::Path;

use crate::agent::AgentId;
use crate::consts::{
    AGENTINFINITY_EFFORT, ALLOWED_TOOLS_AGENT, ALLOWED_TOOLS_AGENTINFINITY, CLAUDE,
    CLAUDE_FLAG_ALLOWED_TOOLS, CLAUDE_FLAG_APPEND_SYSTEM_PROMPT,
    CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS, CLAUDE_FLAG_DISALLOWED_TOOLS, CLAUDE_FLAG_EFFORT,
    CLAUDE_FLAG_LOAD_DEV_CHANNELS, CLAUDE_FLAG_MCP_CONFIG, CLAUDE_FLAG_MODEL,
    CLAUDE_FLAG_PERMISSION_MODE, CLAUDE_FLAG_STRICT_MCP_CONFIG, DEFAULT_EFFORT, DEFAULT_MODEL,
    DEV_CHANNEL_AGENT, DEV_CHANNEL_IMESSAGE, DISALLOWED_TOOLS, ENV_AGENT_EFFORT_OVERRIDE,
    ENV_AGENT_MODEL_OVERRIDE, ENV_NETSKY_PROMPT_FILE, NETSKY_BIN, PERMISSION_MODE_BYPASS, TMUX_BIN,
};

/// Per-agent claude-CLI configuration.
#[derive(Debug, Clone)]
pub struct ClaudeConfig {
    pub model: String,
    pub effort: String,
}

impl ClaudeConfig {
    /// Defaults for `agent`, honoring `AGENT_MODEL` / `AGENT_EFFORT`
    /// env overrides and agentinfinity's lower effort level.
    pub fn defaults_for(agent: AgentId) -> Self {
        let model =
            std::env::var(ENV_AGENT_MODEL_OVERRIDE).unwrap_or_else(|_| DEFAULT_MODEL.to_string());
        let effort = std::env::var(ENV_AGENT_EFFORT_OVERRIDE).unwrap_or_else(|_| {
            if agent.is_agentinfinity() {
                AGENTINFINITY_EFFORT.to_string()
            } else {
                DEFAULT_EFFORT.to_string()
            }
        });
        Self { model, effort }
    }
}

pub(crate) fn required_deps() -> Vec<&'static str> {
    vec![CLAUDE, TMUX_BIN, NETSKY_BIN]
}

/// Build the shell command string tmux will run inside the detached
/// session. `claude --flag value --flag value "$(cat "$NETSKY_PROMPT_FILE")" '<startup>'`.
/// The shell `$(cat ...)` expansion reads the rendered system prompt at
/// exec time from the file the spawner wrote, avoiding the tmux
/// ARG_MAX / "command too long" limit that would otherwise hit on the
/// `tmux new-session -e NETSKY_PROMPT=<24KB>` path (the session-11 outage).
pub(super) fn build_command(
    agent: AgentId,
    cfg: &ClaudeConfig,
    mcp_config: &Path,
    startup: &str,
) -> String {
    let mut parts: Vec<String> = Vec::with_capacity(26);
    parts.push(CLAUDE.to_string());

    // model is single-quoted to suppress zsh glob on names like `opus[1m]`.
    parts.push(CLAUDE_FLAG_MODEL.to_string());
    parts.push(shell_escape(&cfg.model));

    parts.push(CLAUDE_FLAG_EFFORT.to_string());
    parts.push(shell_escape(&cfg.effort));

    parts.push(CLAUDE_FLAG_MCP_CONFIG.to_string());
    parts.push(shell_escape(&mcp_config.display().to_string()));

    // Clones + agentinfinity get strict MCP — only servers in the config
    // file load. agent0 is non-strict so user-scoped MCPs added via
    // `claude mcp add -s user` can layer on top, but every netsky-io
    // source (agent, imessage, email, calendar) is configured at project
    // scope in `.mcp.json` so all three agent classes see the same baseline.
    if matches!(agent, AgentId::Clone(_) | AgentId::Agentinfinity) {
        parts.push(CLAUDE_FLAG_STRICT_MCP_CONFIG.to_string());
    }

    parts.push(CLAUDE_FLAG_LOAD_DEV_CHANNELS.to_string());
    parts.push(DEV_CHANNEL_AGENT.to_string());
    if !matches!(agent, AgentId::Clone(_)) {
        parts.push(DEV_CHANNEL_IMESSAGE.to_string());
    }

    parts.push(CLAUDE_FLAG_ALLOWED_TOOLS.to_string());
    let tools = if agent.is_agentinfinity() {
        ALLOWED_TOOLS_AGENTINFINITY
    } else {
        ALLOWED_TOOLS_AGENT
    };
    parts.push(tools.to_string());

    parts.push(CLAUDE_FLAG_DISALLOWED_TOOLS.to_string());
    parts.push(DISALLOWED_TOOLS.to_string());

    parts.push(CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS.to_string());
    parts.push(CLAUDE_FLAG_PERMISSION_MODE.to_string());
    parts.push(PERMISSION_MODE_BYPASS.to_string());

    // System prompt delivered via a tempfile whose path is in $NETSKY_PROMPT_FILE.
    // The shell tmux runs reads it at exec time via `$(cat "$NETSKY_PROMPT_FILE")`.
    // This replaces an earlier mechanism that passed the 20KB+ prompt content
    // directly as `-e NETSKY_PROMPT=<content>` to tmux new-session, which hit
    // tmux's internal argv length limit ("command too long") and caused every
    // agent0 restart to fail silently in session-11.
    parts.push(CLAUDE_FLAG_APPEND_SYSTEM_PROMPT.to_string());
    parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));

    // Startup prompt as a single positional arg.
    parts.push(shell_escape(startup.trim_end_matches('\n')));

    parts.join(" ")
}

/// POSIX-safe single-quote escape. Wraps `s` in `'...'`, escaping
/// embedded `'` as `'\''`. Shared with any future runtime that
/// constructs shell command strings.
pub fn shell_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('\'');
    for c in s.chars() {
        if c == '\'' {
            out.push_str("'\\''");
        } else {
            out.push(c);
        }
    }
    out.push('\'');
    out
}

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

    #[test]
    fn shell_escape_simple() {
        assert_eq!(shell_escape("foo"), "'foo'");
        assert_eq!(shell_escape("opus[1m]"), "'opus[1m]'");
    }

    #[test]
    fn shell_escape_with_single_quote() {
        assert_eq!(shell_escape("it's"), "'it'\\''s'");
    }

    #[test]
    fn cmd_for_clone_is_strict_and_omits_imessage_channel() {
        let cfg = ClaudeConfig {
            model: "opus[1m]".to_string(),
            effort: "high".to_string(),
        };
        let cmd = build_command(
            AgentId::Clone(2),
            &cfg,
            Path::new("/tmp/mcp-config.json"),
            "/up",
        );
        assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
        assert!(cmd.contains(DEV_CHANNEL_AGENT));
        assert!(!cmd.contains(DEV_CHANNEL_IMESSAGE));
        assert!(cmd.contains("'opus[1m]'"));
    }

    #[test]
    fn cmd_for_agent0_is_lax_and_includes_imessage_channel() {
        let cfg = ClaudeConfig::defaults_for(AgentId::Agent0);
        let cmd = build_command(
            AgentId::Agent0,
            &cfg,
            Path::new("/tmp/mcp-config.json"),
            "/up",
        );
        assert!(!cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
        assert!(cmd.contains(DEV_CHANNEL_AGENT));
        assert!(cmd.contains(DEV_CHANNEL_IMESSAGE));
        assert!(cmd.contains(ALLOWED_TOOLS_AGENT));
    }

    #[test]
    fn cmd_for_agentinfinity_uses_watchdog_toolset() {
        let cfg = ClaudeConfig {
            model: DEFAULT_MODEL.to_string(),
            effort: AGENTINFINITY_EFFORT.to_string(),
        };
        let cmd = build_command(
            AgentId::Agentinfinity,
            &cfg,
            Path::new("/tmp/mcp-config.json"),
            "startup",
        );
        assert!(cmd.contains(ALLOWED_TOOLS_AGENTINFINITY));
        assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
    }

    #[test]
    fn every_agent_disallows_the_agent_tool() {
        let mcp = Path::new("/tmp/mcp-config.json");
        for agent in [AgentId::Agent0, AgentId::Clone(3), AgentId::Agentinfinity] {
            let cfg = ClaudeConfig::defaults_for(agent);
            let cmd = build_command(agent, &cfg, mcp, "/up");
            assert!(
                cmd.contains(&format!(
                    "{CLAUDE_FLAG_DISALLOWED_TOOLS} {DISALLOWED_TOOLS}"
                )),
                "{agent} spawn cmd must disallow {DISALLOWED_TOOLS}: {cmd}"
            );
        }
    }

    #[test]
    fn effort_is_shell_escaped_against_env_injection() {
        let cfg = ClaudeConfig {
            model: DEFAULT_MODEL.to_string(),
            effort: "high;touch /tmp/pwned".to_string(),
        };
        let cmd = build_command(
            AgentId::Agent0,
            &cfg,
            Path::new("/tmp/mcp-config.json"),
            "/up",
        );
        assert!(
            cmd.contains("'high;touch /tmp/pwned'"),
            "effort not shell-escaped: {cmd}"
        );
        assert!(
            !cmd.contains(" high;touch "),
            "effort leaked unescaped: {cmd}"
        );
    }
}