1use crate::backend::container_image::CONTAINER_CLI_DIR;
14use crate::backend::providers;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct CliInstall {
19 pub provider_id: String,
20 pub command: String,
22 pub specs: Vec<String>,
24 pub version: String,
26}
27
28impl CliInstall {
29 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 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 pub fn cache_key(&self, container_name: &str) -> String {
62 format!("{container_name}\u{0}{}", self.marker())
63 }
64
65 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 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#[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
116const NPM_INSTALL_PATTERN: &str = "[n]pm install --prefix /home/agent/.agentmux/cli";
120
121pub 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
133const 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
143pub fn reap_says_gone(output: &str) -> bool {
145 output.lines().any(|l| l.trim() == "gone")
146}
147
148pub 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 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
170const CHECK_SCRIPT: &str = r#"if command -v "$1" >/dev/null 2>&1 || [ -f "$2/$3" ]; then echo present; else echo missing; fi"#;
172
173const 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
197pub fn cli_bin_dir() -> String {
199 format!("{CONTAINER_CLI_DIR}/node_modules/.bin")
200}
201
202pub fn check_says_present(output: &str) -> bool {
204 output.lines().any(|l| l.trim() == "present")
205}
206
207pub 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 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 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 #[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 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 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 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 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 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 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}