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}