Skip to main content

netsky_core/runtime/
claude.rs

1//! Claude runtime: build the `claude` CLI invocation for a spawned agent.
2//!
3//! Single code path for agent0, clones, and agentinfinity. Per-agent
4//! differences (allowed tools, MCP servers + strict mode, dev-channel
5//! flags, effort level) key off [`AgentId`].
6
7use std::path::Path;
8
9use crate::agent::AgentId;
10use crate::consts::{
11    AGENTINFINITY_EFFORT, ALLOWED_TOOLS_AGENT, ALLOWED_TOOLS_AGENTINFINITY, CLAUDE,
12    CLAUDE_FLAG_ALLOWED_TOOLS, CLAUDE_FLAG_APPEND_SYSTEM_PROMPT,
13    CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS, CLAUDE_FLAG_DISALLOWED_TOOLS, CLAUDE_FLAG_EFFORT,
14    CLAUDE_FLAG_LOAD_DEV_CHANNELS, CLAUDE_FLAG_MCP_CONFIG, CLAUDE_FLAG_MODEL,
15    CLAUDE_FLAG_PERMISSION_MODE, CLAUDE_FLAG_STRICT_MCP_CONFIG, DEFAULT_EFFORT, DEFAULT_MODEL,
16    DEV_CHANNEL_AGENT, DEV_CHANNEL_IMESSAGE, DISALLOWED_TOOLS, ENV_AGENT_EFFORT_OVERRIDE,
17    ENV_AGENT_MODEL_OVERRIDE, ENV_NETSKY_PROMPT_FILE, NETSKY_BIN, PERMISSION_MODE_BYPASS, TMUX_BIN,
18};
19
20/// Per-agent claude-CLI configuration.
21#[derive(Debug, Clone)]
22pub struct ClaudeConfig {
23    pub model: String,
24    pub effort: String,
25}
26
27impl ClaudeConfig {
28    /// Defaults for `agent`, honoring `AGENT_MODEL` / `AGENT_EFFORT`
29    /// env overrides and agentinfinity's lower effort level.
30    pub fn defaults_for(agent: AgentId) -> Self {
31        let model =
32            std::env::var(ENV_AGENT_MODEL_OVERRIDE).unwrap_or_else(|_| DEFAULT_MODEL.to_string());
33        let effort = std::env::var(ENV_AGENT_EFFORT_OVERRIDE).unwrap_or_else(|_| {
34            if agent.is_agentinfinity() {
35                AGENTINFINITY_EFFORT.to_string()
36            } else {
37                DEFAULT_EFFORT.to_string()
38            }
39        });
40        Self { model, effort }
41    }
42}
43
44pub(crate) fn required_deps() -> Vec<&'static str> {
45    vec![CLAUDE, TMUX_BIN, NETSKY_BIN]
46}
47
48/// Build the shell command string tmux will run inside the detached
49/// session. `claude --flag value --flag value "$(cat "$NETSKY_PROMPT_FILE")" '<startup>'`.
50/// The shell `$(cat ...)` expansion reads the rendered system prompt at
51/// exec time from the file the spawner wrote, avoiding the tmux
52/// ARG_MAX / "command too long" limit that would otherwise hit on the
53/// `tmux new-session -e NETSKY_PROMPT=<24KB>` path (the session-11 outage).
54pub(super) fn build_command(
55    agent: AgentId,
56    cfg: &ClaudeConfig,
57    mcp_config: &Path,
58    startup: &str,
59) -> String {
60    let mut parts: Vec<String> = Vec::with_capacity(26);
61    parts.push(CLAUDE.to_string());
62
63    // model is single-quoted to suppress zsh glob on names like `opus[1m]`.
64    parts.push(CLAUDE_FLAG_MODEL.to_string());
65    parts.push(shell_escape(&cfg.model));
66
67    parts.push(CLAUDE_FLAG_EFFORT.to_string());
68    parts.push(shell_escape(&cfg.effort));
69
70    parts.push(CLAUDE_FLAG_MCP_CONFIG.to_string());
71    parts.push(shell_escape(&mcp_config.display().to_string()));
72
73    // Clones + agentinfinity get strict MCP — only servers in the config
74    // file load. agent0 is non-strict so user-scoped MCPs added via
75    // `claude mcp add -s user` can layer on top, but every netsky-io
76    // source (agent, imessage, email, calendar) is configured at project
77    // scope in `.mcp.json` so all three agent classes see the same baseline.
78    if matches!(agent, AgentId::Clone(_) | AgentId::Agentinfinity) {
79        parts.push(CLAUDE_FLAG_STRICT_MCP_CONFIG.to_string());
80    }
81
82    parts.push(CLAUDE_FLAG_LOAD_DEV_CHANNELS.to_string());
83    parts.push(DEV_CHANNEL_AGENT.to_string());
84    if !matches!(agent, AgentId::Clone(_)) {
85        parts.push(DEV_CHANNEL_IMESSAGE.to_string());
86    }
87
88    parts.push(CLAUDE_FLAG_ALLOWED_TOOLS.to_string());
89    let tools = if agent.is_agentinfinity() {
90        ALLOWED_TOOLS_AGENTINFINITY
91    } else {
92        ALLOWED_TOOLS_AGENT
93    };
94    parts.push(tools.to_string());
95
96    parts.push(CLAUDE_FLAG_DISALLOWED_TOOLS.to_string());
97    parts.push(DISALLOWED_TOOLS.to_string());
98
99    parts.push(CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS.to_string());
100    parts.push(CLAUDE_FLAG_PERMISSION_MODE.to_string());
101    parts.push(PERMISSION_MODE_BYPASS.to_string());
102
103    // System prompt delivered via a tempfile whose path is in $NETSKY_PROMPT_FILE.
104    // The shell tmux runs reads it at exec time via `$(cat "$NETSKY_PROMPT_FILE")`.
105    // This replaces an earlier mechanism that passed the 20KB+ prompt content
106    // directly as `-e NETSKY_PROMPT=<content>` to tmux new-session, which hit
107    // tmux's internal argv length limit ("command too long") and caused every
108    // agent0 restart to fail silently in session-11.
109    parts.push(CLAUDE_FLAG_APPEND_SYSTEM_PROMPT.to_string());
110    parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
111
112    // Startup prompt as a single positional arg.
113    parts.push(shell_escape(startup.trim_end_matches('\n')));
114
115    parts.join(" ")
116}
117
118/// POSIX-safe single-quote escape. Wraps `s` in `'...'`, escaping
119/// embedded `'` as `'\''`. Shared with any future runtime that
120/// constructs shell command strings.
121pub fn shell_escape(s: &str) -> String {
122    let mut out = String::with_capacity(s.len() + 2);
123    out.push('\'');
124    for c in s.chars() {
125        if c == '\'' {
126            out.push_str("'\\''");
127        } else {
128            out.push(c);
129        }
130    }
131    out.push('\'');
132    out
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn shell_escape_simple() {
141        assert_eq!(shell_escape("foo"), "'foo'");
142        assert_eq!(shell_escape("opus[1m]"), "'opus[1m]'");
143    }
144
145    #[test]
146    fn shell_escape_with_single_quote() {
147        assert_eq!(shell_escape("it's"), "'it'\\''s'");
148    }
149
150    #[test]
151    fn cmd_for_clone_is_strict_and_omits_imessage_channel() {
152        let cfg = ClaudeConfig {
153            model: "opus[1m]".to_string(),
154            effort: "high".to_string(),
155        };
156        let cmd = build_command(
157            AgentId::Clone(2),
158            &cfg,
159            Path::new("/tmp/mcp-config.json"),
160            "/up",
161        );
162        assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
163        assert!(cmd.contains(DEV_CHANNEL_AGENT));
164        assert!(!cmd.contains(DEV_CHANNEL_IMESSAGE));
165        assert!(cmd.contains("'opus[1m]'"));
166    }
167
168    #[test]
169    fn cmd_for_agent0_is_lax_and_includes_imessage_channel() {
170        let cfg = ClaudeConfig::defaults_for(AgentId::Agent0);
171        let cmd = build_command(
172            AgentId::Agent0,
173            &cfg,
174            Path::new("/tmp/mcp-config.json"),
175            "/up",
176        );
177        assert!(!cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
178        assert!(cmd.contains(DEV_CHANNEL_AGENT));
179        assert!(cmd.contains(DEV_CHANNEL_IMESSAGE));
180        assert!(cmd.contains(ALLOWED_TOOLS_AGENT));
181    }
182
183    #[test]
184    fn cmd_for_agentinfinity_uses_watchdog_toolset() {
185        let cfg = ClaudeConfig {
186            model: DEFAULT_MODEL.to_string(),
187            effort: AGENTINFINITY_EFFORT.to_string(),
188        };
189        let cmd = build_command(
190            AgentId::Agentinfinity,
191            &cfg,
192            Path::new("/tmp/mcp-config.json"),
193            "startup",
194        );
195        assert!(cmd.contains(ALLOWED_TOOLS_AGENTINFINITY));
196        assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
197    }
198
199    #[test]
200    fn every_agent_disallows_the_agent_tool() {
201        let mcp = Path::new("/tmp/mcp-config.json");
202        for agent in [AgentId::Agent0, AgentId::Clone(3), AgentId::Agentinfinity] {
203            let cfg = ClaudeConfig::defaults_for(agent);
204            let cmd = build_command(agent, &cfg, mcp, "/up");
205            assert!(
206                cmd.contains(&format!(
207                    "{CLAUDE_FLAG_DISALLOWED_TOOLS} {DISALLOWED_TOOLS}"
208                )),
209                "{agent} spawn cmd must disallow {DISALLOWED_TOOLS}: {cmd}"
210            );
211        }
212    }
213
214    #[test]
215    fn effort_is_shell_escaped_against_env_injection() {
216        let cfg = ClaudeConfig {
217            model: DEFAULT_MODEL.to_string(),
218            effort: "high;touch /tmp/pwned".to_string(),
219        };
220        let cmd = build_command(
221            AgentId::Agent0,
222            &cfg,
223            Path::new("/tmp/mcp-config.json"),
224            "/up",
225        );
226        assert!(
227            cmd.contains("'high;touch /tmp/pwned'"),
228            "effort not shell-escaped: {cmd}"
229        );
230        assert!(
231            !cmd.contains(" high;touch "),
232            "effort leaked unescaped: {cmd}"
233        );
234    }
235}