agentmux_srv\server/cli_handlers.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use crate::backend::rpc::engine::WshRpcEngine;
7use crate::backend::rpc_types::{
8 CheckCliAuthResult, CommandCheckCliAuthData, CommandResolveCliData, CommandRunCliLoginData,
9 ResolveCliResult, RunCliLoginResult, COMMAND_CHECK_CLI_AUTH, COMMAND_RESOLVE_CLI,
10};
11
12use super::AppState;
13
14/// Register CLI-related RPC handlers (resolvecli, checkcliauth, runclilogin).
15pub fn register_cli_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
16 // resolvecli → detect or install a CLI tool for an agent provider.
17 // Each AgentMux version gets its own isolated CLI install at:
18 // <agentmux_home>/instances/v<AGENTMUX_VERSION>/cli/<provider>/
19 // (shared with `install.start` / `install.check` and the frontend
20 // launch path; resolved via `DataPaths::from_env()`).
21 // Never falls back to system PATH for npm-backed providers.
22 let broker_resolve = state.broker.clone();
23 engine.register_handler(
24 COMMAND_RESOLVE_CLI,
25 Box::new(move |data, _ctx| {
26 let broker = broker_resolve.clone();
27 Box::pin(async move {
28 const AGENTMUX_VERSION: &str = env!("CARGO_PKG_VERSION");
29
30 let cmd: CommandResolveCliData = serde_json::from_value(data)
31 .map_err(|e| format!("resolvecli: {e}"))?;
32 tracing::info!(
33 provider = %cmd.provider_id,
34 cli = %cmd.cli_command,
35 block_id = %cmd.block_id,
36 agentmux_version = AGENTMUX_VERSION,
37 "ResolveCli"
38 );
39
40 // Canonical install directory — shared with
41 // `install.start` / `install.check` and the frontend's
42 // `agent-model.ts::resolveCliDir`. Resolves to
43 // `<agentmux_home>/instances/v<version>/cli/<provider>/`
44 // via `DataPaths::from_env()` so portable, installed,
45 // and `AGENTMUX_HOME_OVERRIDE` modes all agree.
46 let paths = agentmux_common::DataPaths::from_env()
47 .ok_or_else(|| "DataPaths::from_env() failed".to_string())?;
48 let provider_dir = paths
49 .home_dir
50 .join("instances")
51 .join(format!("v{AGENTMUX_VERSION}"))
52 .join("cli")
53 .join(&cmd.provider_id)
54 .to_string_lossy()
55 .to_string();
56 // npm binary path — the only valid location for installed CLIs.
57 let npm_bin = if cfg!(windows) {
58 format!("{}/node_modules/.bin/{}.cmd", provider_dir, cmd.cli_command)
59 } else {
60 format!("{}/node_modules/.bin/{}", provider_dir, cmd.cli_command)
61 };
62
63 // Step 1: Check if already installed in versioned directory
64 if std::path::Path::new(&npm_bin).exists() {
65 let version = get_cli_version(&npm_bin).await;
66 tracing::info!(
67 path = %npm_bin, version = %version,
68 "CLI found in versioned install"
69 );
70 return Ok(Some(serde_json::to_value(&ResolveCliResult {
71 cli_path: npm_bin,
72 version,
73 source: "local_install".to_string(),
74 }).unwrap()));
75 }
76
77 // Step 2: Not in versioned dir — check system PATH for non-npm CLIs.
78 if cmd.npm_package.is_empty() {
79 if let Some(path) = resolve_cli_on_path(&cmd.cli_command).await {
80 let version = get_cli_version(&path).await;
81 tracing::info!(
82 path = %path, version = %version,
83 "CLI found on system PATH"
84 );
85 return Ok(Some(serde_json::to_value(&ResolveCliResult {
86 cli_path: path,
87 version,
88 source: "system_path".to_string(),
89 }).unwrap()));
90 }
91 // This branch fires for PATH-only providers
92 // (`npm_package` empty) whose CLI isn't on the
93 // system PATH. AgentMux can't auto-install these
94 // — emit AMX-CLI-004 with a manual install hint
95 // instead of AMX-CLI-001 ("Click Install now"),
96 // which would point the user at an install
97 // affordance that doesn't apply.
98 let install_hint = if cfg!(target_os = "windows") {
99 cmd.windows_install_command.clone()
100 } else {
101 cmd.unix_install_command.clone()
102 };
103 return Err(agentmux_common::AgentMuxError::CliMissingOnPath {
104 provider: cmd.provider_id.clone(),
105 cli: cmd.cli_command.clone(),
106 install_hint,
107 }
108 .to_wire()
109 .to_string());
110 }
111
112 tracing::info!(
113 provider = %cmd.provider_id,
114 npm_package = %cmd.npm_package,
115 pinned_version = %cmd.pinned_version,
116 target_dir = %provider_dir,
117 "CLI not found locally, installing via npm"
118 );
119
120 {
121 // Verify npm is available before attempting install.
122 let npm_available = if cfg!(windows) {
123 // CREATE_NO_WINDOW (0x08000000) suppresses cmd flash —
124 // see broader fix in this file's other spawns.
125 let mut probe = tokio::process::Command::new("where");
126 probe.arg("npm");
127 #[cfg(windows)]
128 {
129 use std::os::windows::process::CommandExt;
130 probe.creation_flags(0x08000000);
131 }
132 probe.output().await.map(|o| o.status.success()).unwrap_or(false)
133 } else {
134 tokio::process::Command::new("which").arg("npm").output().await
135 .map(|o| o.status.success()).unwrap_or(false)
136 };
137 if !npm_available {
138 return Err(format!(
139 "{} requires Node.js/npm to install. \
140 Install Node.js from https://nodejs.org then restart AgentMux.",
141 cmd.cli_command
142 ));
143 }
144
145 // Use `npm install --prefix <dir> <pkg>@<ver>` to avoid cd+chaining issues.
146 // On Windows, normalize the prefix path to backslashes so npm handles it correctly.
147 // npm.cmd must be invoked via cmd /C on Windows — it's a batch script, not an exe.
148 let prefix_dir = if cfg!(windows) {
149 provider_dir.replace('/', "\\")
150 } else {
151 provider_dir.clone()
152 };
153 let package_arg = format!("{}@{}", cmd.npm_package, cmd.pinned_version);
154 tracing::info!(package = %package_arg, prefix = %prefix_dir, "running npm install");
155
156 // Collect all npm output after completion via .output().
157 // Pipe-based streaming (both async IOCP and sync blocking) does not receive
158 // data from cmd.exe /C batch script children on Windows — output only becomes
159 // available after the process exits. We run in spawn_blocking and publish all
160 // lines at once when done; users see the full install log after it completes.
161 let block_id_install = cmd.block_id.clone();
162 tracing::info!(block_id = %block_id_install, package = %package_arg, prefix = %prefix_dir, "running npm install");
163
164 let broker_npm = broker.clone();
165 let exit_status = tokio::task::spawn_blocking(move || {
166 let result = {
167 #[cfg(windows)]
168 {
169 // npm on Windows is a .cmd batch script — must be invoked via cmd.exe /C.
170 // Use raw_arg to pass the command string WITHOUT Rust's CreateProcess
171 // quoting. With .args(["/C", str]), Rust wraps str in outer quotes and
172 // escapes inner quotes as \", which cmd.exe treats as literal backslash+quote,
173 // corrupting paths: CWD + \"C:\path\" → ENOENT.
174 // raw_arg passes the string verbatim; cmd.exe sees:
175 // cmd /C npm install ... --prefix "C:\path with spaces\..." pkg
176 // and tokenizes "..." as a quoted path correctly.
177 use std::os::windows::process::CommandExt;
178 // CREATE_NO_WINDOW (0x08000000): suppress the
179 // brief cmd.exe console flash that Windows
180 // shows by default when CreateProcess is
181 // called from a GUI process. Without this
182 // flag the user sees a black console
183 // window pop and disappear during npm
184 // install — observed during workspace
185 // setup paths (e.g. tear-off triggering
186 // CLI install on first agent block).
187 const CREATE_NO_WINDOW: u32 = 0x08000000;
188 let npm_cmd_str = format!(
189 "npm install --loglevel=http --no-audit --no-fund --no-progress --prefix \"{}\" {}",
190 prefix_dir, package_arg
191 );
192 std::process::Command::new("cmd")
193 .arg("/C")
194 .raw_arg(&npm_cmd_str)
195 .creation_flags(CREATE_NO_WINDOW)
196 .env("CI", "true")
197 .env("FORCE_COLOR", "0")
198 .output()
199 }
200 #[cfg(not(windows))]
201 {
202 std::process::Command::new("npm")
203 .args(["install", "--loglevel=http", "--no-audit", "--no-fund", "--no-progress", "--prefix", &prefix_dir, &package_arg])
204 .env("CI", "true")
205 .env("FORCE_COLOR", "0")
206 .output()
207 }
208 };
209 match result {
210 Ok(out) => {
211 tracing::info!(exit_code = out.status.code().unwrap_or(-1), stdout_bytes = out.stdout.len(), stderr_bytes = out.stderr.len(), "npm install output collected");
212 // Publish stderr first (npm writes progress/errors there), then stdout
213 for line in String::from_utf8_lossy(&out.stderr).lines() {
214 if !line.trim().is_empty() {
215 tracing::info!(line = %line, "npm stderr");
216 if !block_id_install.is_empty() {
217 crate::backend::wps::publish_install_progress(&broker_npm, &block_id_install, line);
218 }
219 }
220 }
221 for line in String::from_utf8_lossy(&out.stdout).lines() {
222 if !line.trim().is_empty() {
223 tracing::info!(line = %line, "npm stdout");
224 if !block_id_install.is_empty() {
225 crate::backend::wps::publish_install_progress(&broker_npm, &block_id_install, line);
226 }
227 }
228 }
229 Ok(out.status)
230 }
231 Err(e) => Err(format!("failed to run npm install: {e}")),
232 }
233 }).await
234 .map_err(|e| format!("npm spawn_blocking panicked: {e}"))?
235 .map_err(|e| e)?;
236 tracing::info!(exit_code = exit_status.code().unwrap_or(-1), "npm install completed");
237
238 if !exit_status.success() {
239 return Err(agentmux_common::AgentMuxError::NpmInstallFailed {
240 package: format!("{}@{}", cmd.npm_package, cmd.pinned_version),
241 message: format!(
242 "exit {}; check the output above",
243 exit_status.code().unwrap_or(-1)
244 ),
245 }
246 .to_wire()
247 .to_string());
248 }
249
250 // Verify npm binary exists
251 if std::path::Path::new(&npm_bin).exists() {
252 let version = get_cli_version(&npm_bin).await;
253 tracing::info!(path = %npm_bin, version = %version, "CLI installed (npm)");
254 return Ok(Some(serde_json::to_value(&ResolveCliResult {
255 cli_path: npm_bin,
256 version,
257 source: "installed".to_string(),
258 }).unwrap()));
259 }
260
261 Err(agentmux_common::AgentMuxError::CliShimMissing {
262 provider: cmd.provider_id.clone(),
263 expected_path: npm_bin.clone(),
264 }
265 .to_wire()
266 .to_string())
267 }
268 })
269 }),
270 );
271
272 // checkcliauth → check if a CLI tool is authenticated
273 // For Claude: reads ~/.claude/.credentials.json directly (instant, no subprocess).
274 // For other providers: falls back to running the CLI auth check command.
275 engine.register_handler(
276 COMMAND_CHECK_CLI_AUTH,
277 Box::new(|data, _ctx| {
278 Box::pin(async move {
279 let cmd: CommandCheckCliAuthData = serde_json::from_value(data)
280 .map_err(|e| format!("checkcliauth: {e}"))?;
281 tracing::info!(cli = %cmd.cli_path, "CheckCliAuth");
282
283 // Two-phase auth check for Claude; single-phase for other providers.
284 //
285 // Phase 1 (fast, <1 ms): read the credentials file to determine whether
286 // tokens exist at all. If no file / no tokens → return unauthenticated
287 // immediately without spawning the CLI. This avoids a 10+ second cold-start
288 // stall when the user is definitely not logged in.
289 //
290 // Phase 2 (CLI, 10 s timeout): only when tokens ARE present, run
291 // `claude auth status --json` to validate them and obtain the real email.
292 // This catches expired/revoked tokens — the false-positive that the old
293 // file-only fast path missed.
294 //
295 // Other providers skip Phase 1 and go straight to the CLI (they don't have
296 // a predictable credentials file layout).
297 //
298 // See SPEC_AUTH_CHECK_FALSE_POSITIVE_2026_04_15.md.
299 if cmd.cli_path.to_lowercase().contains("claude") {
300 let home = std::env::var("HOME")
301 .or_else(|_| std::env::var("USERPROFILE"))
302 .unwrap_or_default();
303
304 // Build candidate paths: isolated dir first, then global ~/.claude/
305 let mut creds_candidates: Vec<String> = Vec::new();
306 if let Some(config_dir) = cmd.auth_env.get("CLAUDE_CONFIG_DIR") {
307 creds_candidates.push(format!("{}/.credentials.json", config_dir));
308 }
309 creds_candidates.push(format!("{}/.claude/.credentials.json", home));
310
311 let tokens_exist = creds_candidates.iter().any(|p| {
312 if let Ok(content) = std::fs::read_to_string(p) {
313 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
314 let oauth = json.get("claudeAiOauth");
315 let has_token = oauth.and_then(|o| o.get("accessToken"))
316 .and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false);
317 let has_refresh = oauth.and_then(|o| o.get("refreshToken"))
318 .and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false);
319 return has_token || has_refresh;
320 }
321 }
322 false
323 });
324
325 if !tokens_exist {
326 // No credentials file or empty tokens — definitely not authenticated.
327 tracing::info!("claude auth check: no credentials file, skipping CLI");
328 let result = CheckCliAuthResult {
329 authenticated: false,
330 email: None,
331 auth_method: None,
332 raw_output: "no credentials found".to_string(),
333 };
334 return Ok(Some(serde_json::to_value(&result).unwrap()));
335 }
336
337 // Tokens exist on disk — validate with the CLI (10 s timeout).
338 tracing::info!("claude auth check: credentials found, validating via CLI");
339 }
340
341 let output = tokio::time::timeout(
342 std::time::Duration::from_secs(10),
343 {
344 let mut check_cmd = make_cli_cmd(&cmd.cli_path);
345 check_cmd.args(&cmd.auth_check_args);
346 for (k, v) in &cmd.auth_env {
347 check_cmd.env(k, v);
348 }
349 // Null stdin: prevents the CLI from blocking on interactive
350 // first-run prompts (onboarding, theme selection, etc.) that
351 // only appear when stdin is a TTY or non-null pipe.
352 check_cmd.stdin(std::process::Stdio::null());
353 check_cmd.output()
354 },
355 ).await
356 .map_err(|_| "auth check timed out (10s)".to_string())?
357 .map_err(|e| format!("failed to run auth check: {e}"))?;
358
359 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
360 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
361
362 let mut email = None;
363 let mut auth_method = None;
364
365 let authenticated = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&stdout) {
366 // Claude outputs `emailAddress`; other CLIs use `email`. Check both.
367 email = json.get("emailAddress")
368 .or_else(|| json.get("email"))
369 .and_then(|v| v.as_str())
370 .map(|s| s.to_string());
371 auth_method = json.get("authMethod")
372 .and_then(|v| v.as_str())
373 .map(|s| s.to_string());
374 json.get("loggedIn")
375 .and_then(|v| v.as_bool())
376 .unwrap_or(false)
377 } else {
378 output.status.success()
379 };
380
381 let raw_output = if !stdout.is_empty() { stdout } else { stderr };
382
383 let result = CheckCliAuthResult {
384 authenticated,
385 email,
386 auth_method,
387 raw_output,
388 };
389 Ok(Some(serde_json::to_value(&result).unwrap()))
390 })
391 }),
392 );
393
394 // runclilogin → spawn CLI login flow, extract OAuth URL from output, return immediately
395 engine.register_handler(
396 "runclilogin",
397 Box::new(|data, _ctx| {
398 Box::pin(async move {
399 let cmd: CommandRunCliLoginData = serde_json::from_value(data)
400 .map_err(|e| format!("runclilogin: {e}"))?;
401 tracing::info!(cli = %cmd.cli_path, args = ?cmd.login_args, "RunCliLogin");
402
403 // Spawn the login process. On most platforms it opens the browser
404 // automatically and writes the URL to stderr. On Windows, stderr is
405 // block-buffered when piped so we can't reliably read it in real-time.
406 // Strategy: inherit stdout/stderr so the CLI can open the browser normally,
407 // then return immediately — the frontend polls auth status until done.
408 let mut child = make_cli_cmd(&cmd.cli_path)
409 .args(&cmd.login_args)
410 .envs(&cmd.auth_env)
411 .stdout(std::process::Stdio::null())
412 .stderr(std::process::Stdio::null())
413 .spawn()
414 .map_err(|e| format!("failed to spawn login: {e}"))?;
415
416 // Keep child alive in background — it waits for the user to complete OAuth
417 tokio::spawn(async move { let _ = child.wait().await; });
418
419 let result = RunCliLoginResult { auth_url: None, raw_output: String::new() };
420 Ok(Some(serde_json::to_value(&result).unwrap()))
421 })
422 }),
423 );
424}
425
426/// Re-export from shared crate for internal use.
427pub(crate) fn make_cli_cmd(cli_path: &str) -> tokio::process::Command {
428 agentmux_common::make_cli_cmd(cli_path)
429}
430
431/// Resolve a CLI command on the system PATH.
432///
433/// Uses `where` on Windows and `which` on Unix. Returns the absolute path
434/// if the command is found and exists, otherwise `None`.
435pub(crate) async fn resolve_cli_on_path(cli_command: &str) -> Option<String> {
436 let path_cmd = if cfg!(windows) {
437 format!("{}.cmd", cli_command)
438 } else {
439 cli_command.to_string()
440 };
441 let which_result = if cfg!(windows) {
442 let mut probe = tokio::process::Command::new("where");
443 probe.arg(&path_cmd);
444 #[cfg(windows)]
445 {
446 use std::os::windows::process::CommandExt;
447 probe.creation_flags(0x08000000);
448 }
449 probe.output().await
450 } else {
451 tokio::process::Command::new("which").arg(&path_cmd).output().await
452 };
453 if let Ok(out) = which_result {
454 if out.status.success() {
455 let stdout_str = String::from_utf8_lossy(&out.stdout);
456 let path = stdout_str.lines().next().unwrap_or("").trim();
457 if !path.is_empty() && std::path::Path::new(path).exists() {
458 return Some(path.to_string());
459 }
460 }
461 }
462 None
463}
464
465async fn get_cli_version(cli_path: &str) -> String {
466 let result = tokio::time::timeout(
467 std::time::Duration::from_secs(5),
468 {
469 let mut c = make_cli_cmd(cli_path);
470 c.arg("--version").stdin(std::process::Stdio::null());
471 c.output()
472 },
473 ).await;
474 match result {
475 Ok(Ok(output)) if output.status.success() => {
476 String::from_utf8_lossy(&output.stdout).trim().to_string()
477 }
478 Ok(_) => "unknown".to_string(),
479 Err(_) => {
480 tracing::warn!(cli_path = %cli_path, "get_cli_version timed out after 5s");
481 "unknown".to_string()
482 }
483 }
484}