Skip to main content

agentmux_srv/backend/
container_cli.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Installing the provider CLI inside a container agent's container.
5//!
6//! The public base image carries no provider CLI, so the first start installs
7//! it from npm into a per-agent volume. This module is the pure part: what to
8//! install, the marker that says it is done, and the two `sh` scripts. The
9//! `docker exec` plumbing is in `ContainerManager::provision_cli`.
10//!
11//! Spec: docs/specs/SPEC_CONTAINER_AGENTS_WORK_FOR_EVERYONE_2026_10_07.md section 3.2.
12
13use crate::backend::container_image::CONTAINER_CLI_DIR;
14use crate::backend::providers;
15
16/// What to install for one container agent.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct CliInstall {
19    pub provider_id: String,
20    /// The command turns run, e.g. `claude`.
21    pub command: String,
22    /// `name@version` for the CLI and its companions.
23    pub specs: Vec<String>,
24    /// The pinned version, for the pane's "Installing Claude Code 2.1.288" row.
25    pub version: String,
26}
27
28impl CliInstall {
29    /// The install for `provider_id`, if the container runs that provider's own
30    /// CLI by its usual name and the provider is installed from npm. `None`
31    /// for a custom command or a provider that is not an npm package.
32    pub fn for_provider(provider_id: &str, container_command: &str) -> Option<Self> {
33        let provider = providers::get_provider(provider_id)?;
34        if provider.npm_package.is_empty() || provider.pinned_version.is_empty() {
35            return None;
36        }
37        if provider.cli_command != container_command {
38            return None;
39        }
40        Some(Self {
41            provider_id: provider.id.to_string(),
42            command: provider.cli_command.to_string(),
43            specs: provider.npm_install_specs(provider.pinned_version),
44            version: provider.pinned_version.to_string(),
45        })
46    }
47
48    /// File name inside [`CONTAINER_CLI_DIR`] that says this exact set of
49    /// packages is installed. A new pin changes it, which is what makes the
50    /// next start reinstall.
51    pub fn marker(&self) -> String {
52        let joined = self.specs.join("+");
53        let safe: String = joined
54            .chars()
55            .map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '+' { c } else { '-' })
56            .collect();
57        format!(".installed-{}", safe.trim_start_matches('-'))
58    }
59
60    /// Key for the "already checked" cache.
61    pub fn cache_key(&self, container_name: &str) -> String {
62        format!("{container_name}\u{0}{}", self.marker())
63    }
64
65    /// argv for the fast check; prints `present` or `missing`. The command on
66    /// the image's own `PATH` counts as present: that is a legacy image with
67    /// the CLI baked in, which must be left alone.
68    pub fn check_argv(&self) -> Vec<String> {
69        vec![
70            "sh".into(),
71            "-c".into(),
72            CHECK_SCRIPT.into(),
73            "agentmux-cli-check".into(),
74            self.command.clone(),
75            CONTAINER_CLI_DIR.into(),
76            self.marker(),
77        ]
78    }
79
80    /// argv for the install. Everything variable is a positional parameter,
81    /// never spliced into the script text.
82    pub fn install_argv(&self, limits: InstallLimits) -> Vec<String> {
83        let mut argv = vec![
84            "sh".into(),
85            "-c".into(),
86            INSTALL_SCRIPT.into(),
87            "agentmux-cli-install".into(),
88            CONTAINER_CLI_DIR.into(),
89            self.marker(),
90            self.command.clone(),
91            limits.script.as_secs().max(1).to_string(),
92        ];
93        argv.extend(self.specs.iter().cloned());
94        argv
95    }
96}
97
98/// How long an install may run. The script limits npm itself (`script`), which
99/// kills the whole process group; the host side (`run`) is the backstop for a
100/// script that does not return, and is followed by [`reap_argv`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct InstallLimits {
103    pub run: std::time::Duration,
104    pub script: std::time::Duration,
105}
106
107impl Default for InstallLimits {
108    fn default() -> Self {
109        Self {
110            run: std::time::Duration::from_secs(600),
111            script: std::time::Duration::from_secs(540),
112        }
113    }
114}
115
116/// Regex (in `pkill -f` / `pgrep -f` form) matching the install's `npm`. The
117/// bracket keeps the reap script's own command line, which contains this text,
118/// from matching itself.
119const NPM_INSTALL_PATTERN: &str = "[n]pm install --prefix /home/agent/.agentmux/cli";
120
121/// argv that kills any `npm install` into [`CONTAINER_CLI_DIR`] and waits for
122/// it to be gone. Prints `gone` and exits 0 only once none is left.
123pub fn reap_argv() -> Vec<String> {
124    vec![
125        "sh".into(),
126        "-c".into(),
127        REAP_SCRIPT.into(),
128        "agentmux-cli-reap".into(),
129        NPM_INSTALL_PATTERN.into(),
130    ]
131}
132
133/// `$1` the process pattern.
134const REAP_SCRIPT: &str = r#"pkill -KILL -f "$1" || true
135i=0
136while pgrep -f "$1" >/dev/null 2>&1; do
137    i=$((i+1))
138    if [ "$i" -gt 20 ]; then echo alive; exit 1; fi
139    sleep 0.5
140done
141echo gone"#;
142
143/// Whether the reap script's output says nothing is left running.
144pub fn reap_says_gone(output: &str) -> bool {
145    output.lines().any(|l| l.trim() == "gone")
146}
147
148/// Why an install did not finish, in words for the pane.
149///
150/// `stopped` is whether the host-side backstop confirmed the install is no
151/// longer running; `None` when the backstop did not apply.
152pub fn install_failure_detail(exit: Option<i64>, timed_out: bool, stopped: Option<bool>, output: &str) -> String {
153    if timed_out {
154        return match stopped {
155            Some(true) => "the install took too long and was stopped".to_string(),
156            _ => "the install took too long and could not be stopped; wait a minute before trying again".to_string(),
157        };
158    }
159    // `timeout` exits 124 when it had to kill npm.
160    if exit == Some(124) || exit == Some(137) {
161        return "the install took too long and was stopped".to_string();
162    }
163    let tail = output_tail(output, 600);
164    if !tail.is_empty() {
165        return tail;
166    }
167    format!("the install exited with status {}", exit.map_or("unknown".to_string(), |c| c.to_string()))
168}
169
170/// `$1` command, `$2` install dir, `$3` marker.
171const CHECK_SCRIPT: &str = r#"if command -v "$1" >/dev/null 2>&1 || [ -f "$2/$3" ]; then echo present; else echo missing; fi"#;
172
173/// `$1` install dir, `$2` marker, `$3` command, `$4` seconds npm may run, the
174/// rest `name@version`.
175///
176/// npm installs into a staging directory inside the volume, so an attempt that
177/// dies half-way never leaves a half-written `node_modules` where the CLI is
178/// looked up. Only a verified install replaces the live tree, and the marker is
179/// written last, so "marker present" always means "complete". The marker of an
180/// older pin goes before the swap for the same reason.
181const INSTALL_SCRIPT: &str = r#"set -eu
182DIR="$1"; MARKER="$2"; BIN="$3"; LIMIT="$4"; shift 4
183STAGE="$DIR/.staging"
184mkdir -p "$DIR"
185rm -rf "$STAGE"
186mkdir -p "$STAGE"
187export npm_config_cache=/tmp/agentmux-npm-cache npm_config_update_notifier=false
188trap 'rm -rf /tmp/agentmux-npm-cache "$STAGE"' EXIT
189timeout -k 15 "$LIMIT" npm install --prefix "$STAGE" --no-audit --no-fund --no-progress --loglevel=error "$@"
190"$STAGE/node_modules/.bin/$BIN" --version >/dev/null
191rm -f "$DIR"/.installed-*
192rm -rf "$DIR/node_modules"
193mv "$STAGE/node_modules" "$DIR/node_modules"
194"$DIR/node_modules/.bin/$BIN" --version >/dev/null
195: > "$DIR/$MARKER""#;
196
197/// The directory the turn wrapper appends to `PATH`.
198pub fn cli_bin_dir() -> String {
199    format!("{CONTAINER_CLI_DIR}/node_modules/.bin")
200}
201
202/// Whether the check script's output means the CLI is already there.
203pub fn check_says_present(output: &str) -> bool {
204    output.lines().any(|l| l.trim() == "present")
205}
206
207/// The last `max` characters of `output`, whole lines where possible, trimmed.
208pub fn output_tail(output: &str, max: usize) -> String {
209    let trimmed = output.trim();
210    let count = trimmed.chars().count();
211    if count <= max {
212        return trimmed.to_string();
213    }
214    let tail: String = trimmed.chars().skip(count - max).collect();
215    match tail.find('\n') {
216        Some(i) if i + 1 < tail.len() => tail[i + 1..].trim().to_string(),
217        _ => tail.trim().to_string(),
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn claude() -> CliInstall {
226        CliInstall::for_provider("claude", "claude").expect("claude installs from npm")
227    }
228
229    #[test]
230    fn claude_installs_the_version_the_provider_table_pins() {
231        let install = claude();
232        let provider = providers::get_provider("claude").unwrap();
233        assert_eq!(install.version, provider.pinned_version);
234        assert_eq!(install.specs, vec![format!("@anthropic-ai/claude-code@{}", provider.pinned_version)]);
235        assert_eq!(install.command, "claude");
236    }
237
238    #[test]
239    fn a_custom_command_or_a_non_npm_provider_is_not_installed() {
240        assert_eq!(CliInstall::for_provider("claude", "my-claude-wrapper"), None);
241        assert_eq!(CliInstall::for_provider("no-such-provider", "x"), None);
242    }
243
244    #[test]
245    fn the_marker_changes_with_the_pin_and_is_a_plain_file_name() {
246        let a = claude();
247        let mut b = a.clone();
248        b.specs = vec!["@anthropic-ai/claude-code@9.9.9".into()];
249        assert_ne!(a.marker(), b.marker());
250        let marker = a.marker();
251        assert!(marker.starts_with(".installed-"));
252        assert!(!marker.contains('/') && !marker.contains('@') && !marker.contains(' '), "{marker}");
253    }
254
255    #[test]
256    fn the_cache_key_separates_containers_and_pins() {
257        let a = claude();
258        assert_ne!(a.cache_key("agentmux-one"), a.cache_key("agentmux-two"));
259    }
260
261    #[test]
262    fn the_scripts_get_their_inputs_as_positional_parameters() {
263        let install = claude();
264        let argv = install.install_argv(InstallLimits::default());
265        assert_eq!(&argv[..2], ["sh", "-c"]);
266        assert_eq!(argv[2], INSTALL_SCRIPT);
267        assert_eq!(argv[4], CONTAINER_CLI_DIR);
268        assert_eq!(argv[5], install.marker());
269        assert_eq!(argv[6], "claude");
270        assert_eq!(argv[7], "540", "npm's own limit is the script limit");
271        assert_eq!(&argv[8..], install.specs.as_slice());
272        // Nothing variable is part of the script text.
273        assert!(!INSTALL_SCRIPT.contains("claude-code"));
274        assert!(!INSTALL_SCRIPT.contains(&install.version));
275
276        let check = install.check_argv();
277        assert_eq!(check[2], CHECK_SCRIPT);
278        assert_eq!(&check[4..], ["claude", CONTAINER_CLI_DIR, install.marker().as_str()]);
279    }
280
281    #[test]
282    fn the_install_verifies_the_binary_before_writing_the_marker() {
283        let verify = INSTALL_SCRIPT.rfind("--version").expect("verifies");
284        let marker = INSTALL_SCRIPT.find(": > ").expect("writes the marker");
285        assert!(verify < marker, "the marker is the last step, after the final check");
286        assert!(INSTALL_SCRIPT.starts_with("set -eu"), "any failed step ends the script");
287        assert!(INSTALL_SCRIPT.contains("rm -f \"$DIR\"/.installed-*"), "an older pin's marker goes");
288    }
289
290    #[test]
291    fn the_install_builds_in_a_staging_dir_and_swaps_only_after_it_verified() {
292        let npm = INSTALL_SCRIPT.find("npm install --prefix \"$STAGE\"").expect("npm installs into staging");
293        let verify = INSTALL_SCRIPT.find("\"$STAGE/node_modules/.bin/$BIN\" --version").expect("verifies the staged binary");
294        let drop_marker = INSTALL_SCRIPT.find("rm -f \"$DIR\"/.installed-*").expect("drops the old marker");
295        let drop_live = INSTALL_SCRIPT.find("rm -rf \"$DIR/node_modules\"").expect("drops the live tree");
296        let swap = INSTALL_SCRIPT.find("mv \"$STAGE/node_modules\"").expect("swaps");
297        let write_marker = INSTALL_SCRIPT.find(": > ").expect("writes the marker");
298        assert!(npm < verify && verify < drop_marker && drop_marker < drop_live && drop_live < swap && swap < write_marker);
299        assert!(INSTALL_SCRIPT.contains("rm -rf \"$STAGE\"\n"), "a half-written earlier attempt is cleared first");
300        assert!(INSTALL_SCRIPT.contains("timeout -k 15 \"$LIMIT\" npm install"), "npm limits itself");
301    }
302
303    #[test]
304    fn the_reap_script_kills_the_install_and_waits_until_it_is_gone() {
305        let argv = reap_argv();
306        assert_eq!(argv[2], REAP_SCRIPT);
307        assert!(REAP_SCRIPT.contains("pkill -KILL -f \"$1\""));
308        assert!(REAP_SCRIPT.contains("pgrep -f \"$1\""), "it checks, not just signals");
309        assert!(REAP_SCRIPT.contains("echo alive; exit 1"), "and fails when something survives");
310        // The bracket keeps the script's own command line from matching itself.
311        assert!(argv[4].starts_with("[n]pm install --prefix /home/agent/.agentmux/cli"));
312        assert!(!argv[4].replace('[', "").replace(']', "").is_empty());
313    }
314
315    #[test]
316    fn reap_output_is_read_by_line() {
317        assert!(reap_says_gone("gone\n"));
318        assert!(!reap_says_gone("alive\n"));
319        assert!(!reap_says_gone(""));
320    }
321
322    #[test]
323    fn a_timeout_reads_as_stopped_only_when_the_backstop_confirmed_it() {
324        let stopped = install_failure_detail(None, true, Some(true), "");
325        assert!(stopped.contains("was stopped"), "{stopped}");
326        for unconfirmed in [Some(false), None] {
327            let msg = install_failure_detail(None, true, unconfirmed, "");
328            assert!(msg.contains("could not be stopped"), "{msg}");
329            assert!(msg.contains("wait"), "tells the person not to retry at once: {msg}");
330        }
331    }
332
333    #[test]
334    fn the_scripts_own_timeout_and_a_kill_read_as_too_slow() {
335        for code in [124, 137] {
336            let msg = install_failure_detail(Some(code), false, None, "npm warn something");
337            assert!(msg.contains("took too long"), "{msg}");
338        }
339    }
340
341    #[test]
342    fn an_ordinary_failure_shows_npms_own_last_words_or_the_status() {
343        assert_eq!(install_failure_detail(Some(1), false, None, "npm error code ENOTFOUND\n"), "npm error code ENOTFOUND");
344        assert_eq!(install_failure_detail(Some(1), false, None, ""), "the install exited with status 1");
345        assert_eq!(install_failure_detail(None, false, None, ""), "the install exited with status unknown");
346    }
347
348    #[test]
349    fn the_install_keeps_the_npm_cache_out_of_the_container_layer() {
350        assert!(INSTALL_SCRIPT.contains("npm_config_cache=/tmp/"));
351        assert!(INSTALL_SCRIPT.contains("rm -rf /tmp/agentmux-npm-cache"));
352    }
353
354    #[test]
355    fn the_bin_dir_is_where_npm_puts_the_binary() {
356        assert_eq!(cli_bin_dir(), "/home/agent/.agentmux/cli/node_modules/.bin");
357    }
358
359    #[test]
360    fn the_check_output_is_read_by_line() {
361        assert!(check_says_present("present\n"));
362        assert!(check_says_present("noise\npresent\r\n"));
363        assert!(!check_says_present("missing\n"));
364        assert!(!check_says_present(""));
365    }
366
367    #[test]
368    fn the_output_tail_keeps_the_end_on_a_line_boundary() {
369        assert_eq!(output_tail("  short  ", 100), "short");
370        let long = format!("{}\nlast line one\nlast line two", "x".repeat(500));
371        let tail = output_tail(&long, 40);
372        assert!(tail.ends_with("last line two"), "{tail}");
373        assert!(!tail.contains('x'), "{tail}");
374    }
375
376    /// Runs the real scripts in a shell, with `npm` and the CLI faked.
377    #[cfg(unix)]
378    #[test]
379    fn the_scripts_behave_in_a_real_shell() {
380        use std::os::unix::fs::PermissionsExt;
381        use std::process::Command;
382        // The install script runs in the Linux agent container, which has
383        // coreutils `timeout`; a host without it (macOS) can't run it.
384        if !Command::new("sh").args(["-c", "command -v timeout"]).output().is_ok_and(|o| o.status.success()) {
385            eprintln!("skipped: no `timeout` on this host; the script targets the Linux agent container");
386            return;
387        }
388        let dir = std::env::temp_dir().join(format!("agentmux-cli-script-test-{}", std::process::id()));
389        let _ = std::fs::remove_dir_all(&dir);
390        std::fs::create_dir_all(&dir).unwrap();
391        let bin = dir.join("fakebin");
392        std::fs::create_dir_all(&bin).unwrap();
393        // A fake npm that "installs" a fake binary under --prefix.
394        let npm = bin.join("npm");
395        std::fs::write(
396            &npm,
397            "#!/bin/sh\nwhile [ \"$1\" != \"--prefix\" ]; do shift; done\nP=\"$2\"\nif [ -e \"$P/../.hang\" ]; then sleep 30; fi\nmkdir -p \"$P/node_modules/.bin\"\nprintf '#!/bin/sh\\necho 1.2.3\\n' > \"$P/node_modules/.bin/fakecli\"\nchmod +x \"$P/node_modules/.bin/fakecli\"\n",
398        )
399        .unwrap();
400        std::fs::set_permissions(&npm, std::fs::Permissions::from_mode(0o755)).unwrap();
401        let cli_dir = dir.join("cli");
402        let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
403        let run = |script: &str, args: &[&str]| {
404            let out = Command::new("sh")
405                .arg("-c")
406                .arg(script)
407                .arg("t")
408                .args(args)
409                .env("PATH", &path)
410                .output()
411                .unwrap();
412            (out.status.success(), String::from_utf8_lossy(&out.stdout).to_string())
413        };
414        let dir_s = cli_dir.to_string_lossy().to_string();
415
416        let (_, before) = run(CHECK_SCRIPT, &["fakecli", &dir_s, ".installed-x"]);
417        assert!(!check_says_present(&before), "{before}");
418
419        let (ok, _) = run(INSTALL_SCRIPT, &[&dir_s, ".installed-x", "fakecli", "60", "fake@1.2.3"]);
420        assert!(ok, "install should succeed");
421        assert!(cli_dir.join(".installed-x").is_file());
422
423        let (_, after) = run(CHECK_SCRIPT, &["fakecli", &dir_s, ".installed-x"]);
424        assert!(check_says_present(&after), "{after}");
425
426        // A new pin leaves the old marker behind no longer.
427        let (ok, _) = run(INSTALL_SCRIPT, &[&dir_s, ".installed-y", "fakecli", "60", "fake@2.0.0"]);
428        assert!(ok);
429        assert!(!cli_dir.join(".installed-x").exists());
430        assert!(cli_dir.join(".installed-y").is_file());
431
432        // A binary that is not produced fails the script and writes no marker.
433        let (ok, _) = run(INSTALL_SCRIPT, &[&dir_s, ".installed-z", "missingcli", "60", "fake@3.0.0"]);
434        assert!(!ok, "a missing binary must fail the install");
435        assert!(!cli_dir.join(".installed-z").exists());
436        assert!(!cli_dir.join(".staging").exists(), "staging is cleaned up on failure");
437
438        // A half-written earlier attempt (stale staging, junk in the staging tree)
439        // does not leak into a retry.
440        std::fs::create_dir_all(cli_dir.join(".staging/node_modules/junk")).unwrap();
441        let (ok, _) = run(INSTALL_SCRIPT, &[&dir_s, ".installed-w", "fakecli", "60", "fake@4.0.0"]);
442        assert!(ok);
443        assert!(cli_dir.join(".installed-w").is_file());
444        assert!(!cli_dir.join("node_modules/junk").exists(), "stale staging content is not installed");
445
446        // A hanging npm (the fake sleeps while `.hang` exists) is killed by the
447        // script's own limit: the script fails, no marker is written, and the
448        // previous good install is left as it was.
449        std::fs::write(cli_dir.join(".hang"), "").unwrap();
450        let started = std::time::Instant::now();
451        let (ok, _) = run(INSTALL_SCRIPT, &[&dir_s, ".installed-v", "fakecli", "1", "fake@5.0.0"]);
452        assert!(!ok, "the script gives up on a hanging npm");
453        assert!(started.elapsed() < std::time::Duration::from_secs(20), "it did not wait for the sleep");
454        assert!(!cli_dir.join(".installed-v").exists());
455        assert!(cli_dir.join(".installed-w").is_file(), "the previous install and its marker are intact");
456        assert!(cli_dir.join("node_modules/.bin/fakecli").exists());
457        assert!(!cli_dir.join(".staging").exists());
458        std::fs::remove_file(cli_dir.join(".hang")).unwrap();
459
460        let _ = std::fs::remove_dir_all(&dir);
461    }
462}