Skip to main content

agentmux_srv/server/
cli_handlers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4#[cfg(windows)]
5use agentmux_common::win32::NoWindow;
6use std::sync::Arc;
7
8use crate::backend::rpc::engine::WshRpcEngine;
9use crate::backend::rpc_types::{
10    ToolchainEnvReq, ToolchainEnvResult, ToolchainVersionsReq,
11    ToolchainPruneItem, ToolchainPruneReq, ToolchainPruneResult, ToolchainPruneSkip,
12    WidgetApiResult, WidgetHealthResult,
13    CheckCliAuthResult, CommandCheckCliAuthData, CommandResolveCliData, CommandRunCliLoginData,
14    EnsureProviderAuthDirReq, EnsureProviderAuthDirResult, ResolveCliResult, RunCliLoginResult,
15    COMMAND_CHECK_CLI_AUTH, COMMAND_ENSURE_PROVIDER_AUTH_DIR, COMMAND_RESOLVE_CLI,
16};
17
18use super::AppState;
19
20/// Register CLI-related RPC handlers (resolvecli, checkcliauth, runclilogin).
21pub fn register_cli_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
22    // resolvecli → detect or install a CLI tool for an agent provider.
23    // Installs are shared across AgentMux versions, keyed by the provider's
24    // pinned CLI version: `<shared_dir>/cli/<provider>/<pinned_version>/`,
25    // with the legacy `<home>/instances/v<AGENTMUX_VERSION>/cli/<provider>/`
26    // as a read-only fallback. One definition for every resolver
27    // (`install.start` / `install.check`, `agent.open`, the picker's
28    // read-only lookup): `backend::cli_install`.
29    // Never falls back to system PATH for npm-backed providers.
30    let broker_resolve = state.broker.clone();
31    let filestore_resolve = state.filestore.clone();
32    let mstore_resolve = state.mstore.clone();
33    engine.register_typed(
34        COMMAND_RESOLVE_CLI,
35        move |cmd: CommandResolveCliData, _ctx| {
36            let broker = broker_resolve.clone();
37            let notices = CliNotices {
38                broker: broker_resolve.clone(),
39                filestore: filestore_resolve.clone(),
40                mstore: mstore_resolve.clone(),
41                block_id: notice_block_id(&cmd),
42                agent_id: cmd.agent_id.clone().filter(|a| !a.trim().is_empty()),
43                provider: cmd.provider_id.clone(),
44            };
45            async move {
46                const AGENTMUX_VERSION: &str = env!("CARGO_PKG_VERSION");
47
48                tracing::info!(
49                    provider = %cmd.provider_id,
50                    cli = %cmd.cli_command,
51                    block_id = %cmd.block_id,
52                    agentmux_version = AGENTMUX_VERSION,
53                    "ResolveCli"
54                );
55
56                // Install location: shared across AgentMux versions, keyed
57                // by the provider's pinned CLI version, with the legacy
58                // per-version dir as a read-only fallback — see
59                // `backend::cli_install` (SPEC_AGENT_OPEN_LATENCY_2026_09_27.md
60                // F1). The backend registry's pin wins over the caller's.
61                let paths = agentmux_common::DataPaths::from_env()
62                    .ok_or_else(|| "DataPaths::from_env() failed".to_string())?;
63                let pinned_version = crate::backend::providers::get_provider(&cmd.provider_id)
64                    .map(|p| p.pinned_version.to_string())
65                    .unwrap_or_else(|| cmd.pinned_version.clone());
66                let install_dir = crate::backend::cli_install::install_dir(&paths, &cmd.provider_id, &pinned_version);
67                let provider_dir = install_dir.to_string_lossy().to_string();
68                // npm binary path — the only valid location for installed CLIs.
69                let npm_bin = crate::backend::cli_install::npm_bin(&install_dir, &cmd.cli_command)
70                    .to_string_lossy()
71                    .to_string();
72
73                // Step 1: already installed (shared dir, else legacy per-version dir)?
74                if let Some(found) = crate::backend::cli_install::find_installed(
75                    &paths,
76                    &cmd.provider_id,
77                    &pinned_version,
78                    &cmd.cli_command,
79                ) {
80                    let found = found.to_string_lossy().to_string();
81                    let version = get_cli_version(&found).await;
82                    tracing::info!(path = %found, version = %version, "CLI found in local install");
83                    notices.observe_version(&version);
84                    return Ok(ResolveCliResult {
85                        cli_path: found,
86                        version,
87                        source: "local_install".to_string(),
88                    });
89                }
90
91                // Step 2: Not in versioned dir — check system PATH for non-npm CLIs.
92                if cmd.npm_package.is_empty() {
93                    if let Some(path) = resolve_provider_cli_on_path(&cmd.provider_id, &cmd.cli_command).await {
94                        let version = get_cli_version(&path).await;
95                        tracing::info!(
96                            path = %path, version = %version,
97                            "CLI found on system PATH"
98                        );
99                        notices.observe_version(&version);
100                        return Ok(ResolveCliResult {
101                            cli_path: path,
102                            version,
103                            source: "system_path".to_string(),
104                        });
105                    }
106                    // This branch fires for PATH-only providers
107                    // (`npm_package` empty) whose CLI isn't on the
108                    // system PATH. AgentMux can't auto-install these
109                    // — emit AMX-CLI-004 with a manual install hint
110                    // instead of AMX-CLI-001 ("Click Install now"),
111                    // which would point the user at an install
112                    // affordance that doesn't apply.
113                    let install_hint = if cfg!(target_os = "windows") {
114                        cmd.windows_install_command.clone()
115                    } else {
116                        cmd.unix_install_command.clone()
117                    };
118                    return Err(agentmux_common::AgentMuxError::CliMissingOnPath {
119                        provider: cmd.provider_id.clone(),
120                        cli: cmd.cli_command.clone(),
121                        install_hint,
122                    }
123                    .to_wire()
124                    .to_string());
125                }
126
127                tracing::info!(
128                    provider = %cmd.provider_id,
129                    npm_package = %cmd.npm_package,
130                    pinned_version = %pinned_version,
131                    target_dir = %provider_dir,
132                    "CLI not found locally, installing via npm"
133                );
134
135                {
136                    // Verify npm is available before attempting install.
137                    let npm_available = if cfg!(windows) {
138                        // CREATE_NO_WINDOW (0x08000000) suppresses cmd flash —
139                        // see broader fix in this file's other spawns.
140                        let mut probe = tokio::process::Command::new("where");
141                        probe.arg("npm");
142                        #[cfg(windows)]
143                        {
144                            probe.no_window();
145                        }
146                        probe.output().await.map(|o| o.status.success()).unwrap_or(false)
147                    } else {
148                        tokio::process::Command::new("which").arg("npm").output().await
149                            .map(|o| o.status.success()).unwrap_or(false)
150                    };
151                    if !npm_available {
152                        return Err(format!(
153                            "{} requires Node.js/npm to install. \
154                            Install Node.js from https://nodejs.org then restart AgentMux.",
155                            cmd.cli_command
156                        ));
157                    }
158
159                    // One install routine for this and the startup warm-up
160                    // (`cli_install::install_pinned_cli`): cross-instance lock
161                    // held for npm's whole lifetime — inside the blocking task,
162                    // so a timed-out RPC can't release it early — reuse of an
163                    // install another instance finished while we waited, and
164                    // the completion marker only after the shim is verified.
165                    let req = crate::backend::cli_install::NpmInstallRequest {
166                        provider_id: cmd.provider_id.clone(),
167                        npm_package: cmd.npm_package.clone(),
168                        pinned_version: pinned_version.clone(),
169                        cli_command: cmd.cli_command.clone(),
170                        background: false,
171                    };
172                    let install_paths = paths.clone();
173                    let block_id_install = cmd.block_id.clone();
174                    // The pane says it's installing, then how it went: a pane
175                    // restored at startup otherwise just starts slower.
176                    let install_id = format!("{}-{}-{}", cmd.provider_id, pinned_version, agentmux_common::time::now_ms());
177                    let install_started = std::time::Instant::now();
178                    notices.install(&install_id, &pinned_version, crate::backend::cli_notice::InstallState::Installing, None, None);
179                    let broker_npm = broker.clone();
180                    let outcome = tokio::task::spawn_blocking(move || {
181                        let on_line = |stream: &'static str, line: &str| {
182                            tracing::info!(stream, line = %line, "npm output");
183                            if !block_id_install.is_empty() {
184                                crate::backend::mps::publish_install_progress(
185                                    &broker_npm,
186                                    &block_id_install,
187                                    line,
188                                );
189                            }
190                        };
191                        crate::backend::cli_install::install_pinned_cli(
192                            &install_paths,
193                            &req,
194                            &on_line,
195                        )
196                    })
197                    .await
198                    .map_err(|e| format!("npm install task panicked: {e}"))?;
199                    match &outcome {
200                        Ok(_) => notices.install(
201                            &install_id,
202                            &pinned_version,
203                            crate::backend::cli_notice::InstallState::Installed,
204                            Some(install_started.elapsed().as_secs_f64()),
205                            None,
206                        ),
207                        Err(e) => notices.install(
208                            &install_id,
209                            &pinned_version,
210                            crate::backend::cli_notice::InstallState::Failed,
211                            None,
212                            Some(&e.to_string()),
213                        ),
214                    }
215
216                    use crate::backend::cli_install::{InstallError, InstallOutcome};
217                    let (bin, source) = match outcome {
218                        Ok(InstallOutcome::Installed(bin)) => (bin, "installed"),
219                        Ok(InstallOutcome::AlreadyInstalled(bin)) => (bin, "local_install"),
220                        Err(InstallError::NpmFailed { code }) => {
221                            return Err(agentmux_common::AgentMuxError::NpmInstallFailed {
222                                package: format!("{}@{}", cmd.npm_package, pinned_version),
223                                message: format!(
224                                    "exit {}; check the output above",
225                                    code.unwrap_or(-1)
226                                ),
227                            }
228                            .to_wire()
229                            .to_string());
230                        }
231                        Err(InstallError::ShimMissing { .. }) => {
232                            return Err(agentmux_common::AgentMuxError::CliShimMissing {
233                                provider: cmd.provider_id.clone(),
234                                expected_path: npm_bin.clone(),
235                            }
236                            .to_wire()
237                            .to_string());
238                        }
239                        Err(e) => return Err(e.to_string()),
240                    };
241                    let bin = bin.to_string_lossy().to_string();
242                    let version = get_cli_version(&bin).await;
243                    tracing::info!(path = %bin, version = %version, source, "CLI installed (npm)");
244                    notices.observe_version(&version);
245                    Ok(ResolveCliResult {
246                        cli_path: bin,
247                        version,
248                        source: source.to_string(),
249                    })
250                }
251            }
252        },
253    );
254
255    // checkcliauth → check if a CLI tool is authenticated
256    // For Claude: reads ~/.claude/.credentials.json directly (instant, no subprocess).
257    // For other providers: falls back to running the CLI auth check command.
258    engine.register_typed(
259        COMMAND_CHECK_CLI_AUTH,
260        |cmd: CommandCheckCliAuthData, _ctx| {
261            async move {
262                tracing::info!(cli = %cmd.cli_path, "CheckCliAuth");
263
264                // Two-phase auth check for Claude; single-phase for other providers.
265                //
266                // Phase 1 (fast, <1 ms): read the credentials file to determine whether
267                // tokens exist at all. If no file / no tokens → return unauthenticated
268                // immediately without spawning the CLI. This avoids a 10+ second cold-start
269                // stall when the user is definitely not logged in.
270                //
271                // Phase 2 (CLI, 10 s timeout): only when tokens ARE present, run
272                // `claude auth status --json` to validate them and obtain the real email.
273                // This catches expired/revoked tokens — the false-positive that the old
274                // file-only fast path missed.
275                //
276                // Other providers skip Phase 1 and go straight to the CLI (they don't have
277                // a predictable credentials file layout).
278                //
279                // See SPEC_AUTH_CHECK_FALSE_POSITIVE_2026_04_15.md.
280                if cmd.cli_path.to_lowercase().contains("claude") {
281                    let home = std::env::var("HOME")
282                        .or_else(|_| std::env::var("USERPROFILE"))
283                        .unwrap_or_default();
284
285                    // REMOVED 2026-08-31 — the first-run bootstrap that copied
286                    // the user's global `~/.claude/.credentials.json` into the
287                    // isolated CLAUDE_CONFIG_DIR (gated on a
288                    // `.agentmux-cred-seeded` sentinel).
289                    //
290                    // It ran during a routine AUTH CHECK — no login, no user
291                    // action, no bound account — so an agent in a fresh
292                    // channel silently acquired the operator's personal
293                    // credential. That is the most direct of the four
294                    // per-channel-isolation bypasses catalogued in
295                    // `docs/analysis/ANALYSIS_PER_CHANNEL_AUTH_BYPASSES_2026_08_31.md`
296                    // (#4), and the most likely answer to the reported "agents
297                    // operate with no account" (it also explains why the
298                    // symptom varied by machine — it depended on whether
299                    // `~/.claude` happened to hold a valid credential).
300                    //
301                    // Its original justification
302                    // (`docs/retro/retro-provider-auth-isolation-regression-2026-06-05.md`)
303                    // predates the "agents never use ~/.claude" requirement
304                    // (`SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md`)
305                    // and was never revisited against it.
306                    //
307                    // An unauthenticated isolated dir must now simply report
308                    // `authenticated: false` and let the user log in FOR THIS
309                    // CHANNEL. Do not reintroduce an import here: a check must
310                    // report state, never mint credentials as a side effect.
311
312                    // §4 INVARIANT (provider-auth-isolation.md): validate the SAME dir
313                    // the agent runs in — the isolated CLAUDE_CONFIG_DIR if set, else
314                    // global ~/.claude. NEVER "isolated OR global": that "check global /
315                    // run isolated" split is the validate-spin regression's root cause
316                    // (phase 2 below runs `claude auth status --json` against this exact
317                    // dir, so phase 1 must check the same one).
318                    let creds_path = cmd
319                        .auth_env
320                        .get("CLAUDE_CONFIG_DIR")
321                        .map(|d| format!("{}/.credentials.json", d))
322                        .unwrap_or_else(|| format!("{}/.claude/.credentials.json", home));
323
324                    let tokens_exist = match std::fs::read_to_string(&creds_path) {
325                        Ok(content) => serde_json::from_str::<serde_json::Value>(&content)
326                            .ok()
327                            .map(|json| {
328                                let oauth = json.get("claudeAiOauth");
329                                let has_token = oauth
330                                    .and_then(|o| o.get("accessToken"))
331                                    .and_then(|v| v.as_str())
332                                    .map(|s| !s.is_empty())
333                                    .unwrap_or(false);
334                                let has_refresh = oauth
335                                    .and_then(|o| o.get("refreshToken"))
336                                    .and_then(|v| v.as_str())
337                                    .map(|s| !s.is_empty())
338                                    .unwrap_or(false);
339                                has_token || has_refresh
340                            })
341                            .unwrap_or(false),
342                        Err(_) => false,
343                    };
344
345                    // Login/logout robustness diagnostic: snapshot the SAME dir
346                    // we validate (token fingerprint is redacted — see
347                    // identity::auth_diag). Across login/logout rounds this
348                    // surfaces the classic bugs — a check reading a different
349                    // dir than login wrote, a token that didn't change after a
350                    // re-login, or a credential that outlived a logout. The CLI
351                    // verdict is logged separately below; compare the two.
352                    // Cover BOTH the isolated dir (CLAUDE_CONFIG_DIR) and the
353                    // global-fallback dir (~/.claude) — creds_path/tokens_exist
354                    // above use exactly this resolution, so the snapshot always
355                    // reflects the dir actually validated (not only the isolated
356                    // case). "auth.credstate:" is `muxlog auth` vocabulary.
357                    let checked_dir = cmd
358                        .auth_env
359                        .get("CLAUDE_CONFIG_DIR")
360                        .cloned()
361                        .unwrap_or_else(|| format!("{home}/.claude"));
362                    tracing::info!(
363                        "auth.credstate: check {}",
364                        crate::identity::auth_diag::snapshot(&checked_dir)
365                    );
366
367                    if !tokens_exist {
368                        // On macOS the Claude CLI stores credentials in the
369                        // Keychain ("Claude Safe Storage"), NOT in
370                        // .credentials.json — so a missing file does NOT mean
371                        // logged out. Fall through to `claude auth status`, which
372                        // reads the Keychain (and does so without a prompt — the
373                        // CLI owns that Keychain item). On Windows/Linux the file
374                        // IS the credential store, so the fast "definitely not
375                        // authenticated" short-circuit stays correct there.
376                        #[cfg(not(target_os = "macos"))]
377                        {
378                            tracing::info!("claude auth check: no credentials in provider dir, skipping CLI");
379                            let result = CheckCliAuthResult {
380                                authenticated: false,
381                                email: None,
382                                auth_method: None,
383                                raw_output: "no credentials found".to_string(),
384                            };
385                            return Ok(result);
386                        }
387                        #[cfg(target_os = "macos")]
388                        tracing::info!(
389                            "claude auth check: no credentials file — checking Keychain via CLI (macOS)"
390                        );
391                    } else {
392                        // Tokens exist in the provider dir — validate with the CLI (10 s timeout).
393                        tracing::info!("claude auth check: credentials found, validating via CLI");
394                    }
395                }
396
397                let (authenticated, email, auth_method, raw_output) =
398                    run_auth_check(&cmd.cli_path, &cmd.auth_check_args, &cmd.auth_env).await?;
399
400                // REMOVED 2026-08-31 (Codex P1 on PR #2878) — the "self-heal a
401                // stale isolated Claude credential" step. When validation
402                // failed, it copied the user's personal
403                // `~/.claude/.credentials.json` into the isolated dir (via
404                // `refresh_claude_dir_from_global_if_stale`) whenever the global
405                // access token differed, then re-validated ONCE so the agent
406                // "recovered" — meaning a channel silently acquired a credential
407                // authenticated OUTSIDE it and could then report authenticated
408                // with no channel-local login at all.
409                //
410                // Same source and same end state as the first-run bootstrap
411                // removed above, just a different trigger, so removing only that
412                // one would have left this per-channel-isolation bypass wide
413                // open. See
414                // `docs/analysis/ANALYSIS_PER_CHANNEL_AUTH_BYPASSES_2026_08_31.md`
415                // (#5).
416                //
417                // The real failure it was papering over (a global re-login
418                // rotating the token and killing the isolated refresh token —
419                // the "Pozl 401") is now surfaced honestly: validation fails, the
420                // auth card appears, and the user logs in FOR THIS CHANNEL.
421
422                let result = CheckCliAuthResult {
423                    authenticated,
424                    email,
425                    auth_method,
426                    raw_output,
427                };
428                Ok(result)
429            }
430        },
431    );
432
433    // provider.ensureauthdir → create the provider's default (shared) auth dir
434    // and apply its isolation guarantees, exactly as agent open does; returns
435    // the path for the launch env (e.g. CLAUDE_CONFIG_DIR). Replaces the CEF
436    // host's `ensure_auth_dir`, which only created the dir.
437    engine.register_typed(
438        COMMAND_ENSURE_PROVIDER_AUTH_DIR,
439        |req: EnsureProviderAuthDirReq, _ctx| async move {
440            let provider = crate::backend::providers::get_provider(&req.provider_id)
441                .ok_or_else(|| format!("provider.ensureauthdir: unknown provider {:?}", req.provider_id))?;
442            let path = crate::backend::providers::default_auth_dir(provider)?;
443            crate::backend::providers::prepare_provider_auth_dir(provider, &path)
444                .map_err(|e| format!("provider.ensureauthdir: failed to prepare {path}: {e}"))?;
445            Ok(EnsureProviderAuthDirResult { path })
446        },
447    );
448
449    // runclilogin → spawn CLI login flow, extract OAuth URL from output, return immediately
450    engine.register_typed(
451        "runclilogin",
452        |cmd: CommandRunCliLoginData, _ctx| {
453            async move {
454                tracing::info!(cli = %cmd.cli_path, args = ?cmd.login_args, "RunCliLogin");
455
456                // Dead path: the active login flow is the CEF host IPC
457                // `run_cli_login`, which owns the child's lifecycle (supersede-kill
458                // + timeout reaper). This srv-side variant previously spawned a
459                // DETACHED, unkillable `auth login` child here — a process leak if
460                // ever invoked. It has no live caller; do NOT spawn.
461                Ok(RunCliLoginResult { auth_url: None, raw_output: String::new() })
462            }
463        },
464    );
465
466    // toolchain.env — report the environment the srv resolves tools in: the
467    // effective PATH, how it was derived (set by the host/srv PATH enricher,
468    // see SPEC_TOOLCHAIN_MANAGER §3), and OS/arch. Powers the Toolchain
469    // modal's Environment section so PATH problems are diagnosable.
470    engine.register_typed(
471        "toolchain.env",
472        // `Option<_>` -- see `ToolchainEnvReq`: it is what makes both encodings
473        // of "no argument" deserialize.
474        |_req: Option<ToolchainEnvReq>, _ctx| {
475            async move {
476                Ok(ToolchainEnvResult {
477                    path: std::env::var("PATH").unwrap_or_default(),
478                    path_source: std::env::var("AGENTMUX_PATH_SOURCE")
479                        .unwrap_or_else(|_| "inherited".to_string()),
480                    os: std::env::consts::OS.to_string(),
481                    arch: std::env::consts::ARCH.to_string(),
482                })
483            }
484        },
485    );
486
487    // toolchain.prune — find (dry run, the default) or remove (an explicit
488    // `dry_run: false`, from a user action) provider CLI installs that nothing
489    // has used for 30 days and nothing is running. Never runs on its own.
490    // SPEC_RUNTIME_MENU_REMAINING_GAPS_2026_10_01.md §5.
491    let mstore_prune = state.mstore.clone();
492    engine.register_typed(
493        "toolchain.prune",
494        move |req: Option<ToolchainPruneReq>, _ctx| {
495            let mstore = mstore_prune.clone();
496            async move {
497                let dry_run = req.as_ref().and_then(|r| r.dry_run).unwrap_or(true);
498                let only = req.and_then(|r| r.only);
499                let paths = agentmux_common::DataPaths::from_env()
500                    .ok_or_else(|| "DataPaths::from_env() failed".to_string())?;
501                // The commands of every block that records one: the open panes
502                // and the ones restored from a layout, which keep the absolute CLI
503                // path until they mount.
504                let live: Vec<String> = mstore
505                    .get_all::<crate::backend::obj::Block>()
506                    .map_err(|e| format!("toolchain.prune: {e}"))?
507                    .iter()
508                    .filter_map(|b| {
509                        let mut parts: Vec<String> = Vec::new();
510                        if let Some(c) = b.meta.get("cmd").and_then(|v| v.as_str()) {
511                            parts.push(c.to_string());
512                        }
513                        if let Some(a) = b.meta.get("cmd:args").and_then(|v| v.as_array()) {
514                            parts.extend(a.iter().filter_map(|v| v.as_str().map(str::to_string)));
515                        }
516                        (!parts.is_empty()).then(|| parts.join(" "))
517                    })
518                    .collect();
519                let max_idle = crate::backend::cli_prune::DEFAULT_MAX_IDLE;
520                let now = std::time::SystemTime::now();
521                let run = tokio::task::spawn_blocking(move || {
522                    crate::backend::cli_prune::run(&paths, &live, dry_run, only.as_deref(), max_idle, now)
523                })
524                .await
525                .map_err(|e| format!("toolchain.prune: {e}"))?;
526                Ok(prune_result(run, dry_run, max_idle))
527            }
528        },
529    );
530
531    // widget.health — HTTP liveness probe for an external widget server running
532    // on localhost. The frontend passes { port, health_check_path,
533    // health_check_body_contains? } and gets back { healthy, status_code }.
534    // Connection-refused or timeout → { healthy: false } (not an RPC error).
535    // health_check_body_contains lets callers distinguish services that share
536    // a default port (e.g. Flowise and Grafana both default to 3000).
537    // Registered with `serde_json::Value` as the REQUEST type rather than a
538    // struct. `Value` deserializes from anything, so this is exactly as
539    // tolerant as the untyped handler it replaces -- and the handler needs that
540    // tolerance: a bad port or path is answered with `healthy: false`, not an
541    // RPC error, which a typed request would turn into a deserialize failure.
542    //
543    // What it buys over `register_handler` is the RESPONSE type, which is the
544    // half that was actually drifting.
545    engine.register_typed(
546        "widget.health",
547        |data: serde_json::Value, _ctx| {
548            async move {
549                let port_raw = data.get("port").and_then(|v| v.as_u64()).unwrap_or(0);
550                if port_raw == 0 || port_raw > 65535 {
551                    return Ok(WidgetHealthResult { healthy: false, status_code: None });
552                }
553                let port = port_raw as u16;
554                let path = data
555                    .get("health_check_path")
556                    .and_then(|v| v.as_str())
557                    .unwrap_or("/")
558                    .to_string();
559                let body_contains = data
560                    .get("health_check_body_contains")
561                    .and_then(|v| v.as_str())
562                    .map(|s| s.to_string());
563                let url = format!("http://127.0.0.1:{}{}", port, path);
564                let client = reqwest::Client::builder()
565                    .timeout(std::time::Duration::from_secs(3))
566                    .build()
567                    .map_err(|e| e.to_string())?;
568                match client.get(&url).send().await {
569                    Ok(resp) => {
570                        let status = resp.status().as_u16();
571                        let ok_status = resp.status().is_success();
572                        if !ok_status {
573                            return Ok(WidgetHealthResult {
574                                healthy: false,
575                                status_code: Some(status),
576                            });
577                        }
578                        // Optionally verify response body for service identity.
579                        let healthy = if let Some(needle) = body_contains {
580                            let body = resp.text().await.unwrap_or_default();
581                            body.contains(&needle)
582                        } else {
583                            true
584                        };
585                        Ok(WidgetHealthResult { healthy, status_code: Some(status) })
586                    }
587                    Err(_) => Ok(WidgetHealthResult { healthy: false, status_code: None }),
588                }
589            }
590        },
591    );
592
593    // widget.api — HTTP proxy to a widget's local server. Bypasses browser CORS
594    // restrictions: the frontend sends { port, path, method?, headers?, body? }
595    // and gets back { ok, status_code, body, error? }. Agents use this to call
596    // ComfyUI /prompt, Grafana /api/query, etc. without needing a CORS header.
597    // 30-second timeout accommodates generative tasks (image synthesis, etc.).
598    // `Value` request for the same reason as `widget.health` above: an invalid
599    // port or path is a `ok: false` ANSWER, not a rejected request.
600    engine.register_typed(
601        "widget.api",
602        |data: serde_json::Value, _ctx| {
603            async move {
604                let reject = |why: &str| WidgetApiResult {
605                    ok: false,
606                    status_code: None,
607                    body: None,
608                    error: Some(why.to_string()),
609                };
610                let port_raw = data.get("port").and_then(|v| v.as_u64()).unwrap_or(0);
611                if port_raw == 0 || port_raw > 65535 {
612                    return Ok(reject("invalid port"));
613                }
614                let port = port_raw as u16;
615                let path = data.get("path").and_then(|v| v.as_str()).unwrap_or("/").to_string();
616                // Reject paths that could escape localhost: must start with '/',
617                // no '@' (user-info injection: 127.0.0.1@evil.com), no backslash,
618                // no protocol-relative '//' prefix.
619                if !path.starts_with('/') || path.contains('@') || path.contains('\\') || path.starts_with("//") {
620                    return Ok(reject("invalid path"));
621                }
622                let method = data
623                    .get("method")
624                    .and_then(|v| v.as_str())
625                    .unwrap_or("GET")
626                    .to_uppercase();
627                let body_str = data.get("body").and_then(|v| v.as_str()).map(|s| s.to_string());
628                let headers_obj = data.get("headers").and_then(|v| v.as_object()).cloned();
629
630                let url = format!("http://127.0.0.1:{}{}", port, path);
631                let client = reqwest::Client::builder()
632                    .timeout(std::time::Duration::from_secs(30))
633                    .redirect(reqwest::redirect::Policy::none())
634                    .build()
635                    .map_err(|e| e.to_string())?;
636
637                let mut req = match method.as_str() {
638                    "POST" => client.post(&url),
639                    "PUT" => client.put(&url),
640                    "DELETE" => client.delete(&url),
641                    "PATCH" => client.patch(&url),
642                    _ => client.get(&url),
643                };
644
645                if let Some(headers) = headers_obj {
646                    for (k, v) in &headers {
647                        if let Some(vs) = v.as_str() {
648                            if let (Ok(hn), Ok(hv)) = (
649                                reqwest::header::HeaderName::from_bytes(k.as_bytes()),
650                                reqwest::header::HeaderValue::from_str(vs),
651                            ) {
652                                req = req.header(hn, hv);
653                            }
654                        }
655                    }
656                }
657
658                if let Some(body) = body_str {
659                    req = req
660                        .header(reqwest::header::CONTENT_TYPE, "application/json")
661                        .body(body);
662                }
663
664                match req.send().await {
665                    Ok(resp) => {
666                        let status = resp.status().as_u16();
667                        let body = resp.text().await.unwrap_or_default();
668                        Ok(WidgetApiResult {
669                            ok: true,
670                            status_code: Some(status),
671                            body: Some(body),
672                            error: None,
673                        })
674                    }
675                    Err(e) => Ok(reject(&e.to_string())),
676                }
677            }
678        },
679    );
680
681    // toolchain.versions — fetch the latest published version for a list of npm
682    // packages from the npm registry. Input: { packages: [{id, package}] }.
683    // Output: { id: "x.y.z" | null, ... }. Each lookup is independent — a
684    // network error for one package yields null for that entry, not a failure.
685    // 5-second per-request timeout; all lookups run concurrently.
686    engine.register_typed(
687        "toolchain.versions",
688        |req: ToolchainVersionsReq, _ctx| {
689            async move {
690                let packages = req.packages;
691
692                let client = reqwest::Client::builder()
693                    .timeout(std::time::Duration::from_secs(5))
694                    .user_agent("agentmux/toolchain-versions")
695                    .build()
696                    .map_err(|e| e.to_string())?;
697
698                let futs: Vec<_> = packages.into_iter().map(|pkg| {
699                    let c = client.clone();
700                    async move {
701                        let url = format!("https://registry.npmjs.org/{}/latest", pkg.package);
702                        let version = match c.get(&url).send().await {
703                            Ok(resp) if resp.status().is_success() => {
704                                resp.json::<serde_json::Value>().await.ok()
705                                    .and_then(|v| v.get("version").and_then(|s| s.as_str()).map(|s| s.to_string()))
706                            }
707                            _ => None,
708                        };
709                        (pkg.id, version)
710                    }
711                }).collect();
712
713                let results = futures_util::future::join_all(futs).await;
714                // A map keyed by the caller's own `id`s, so the response type
715                // is the map itself rather than a named struct -- there is no
716                // fixed set of fields to name.
717                let mut out = std::collections::HashMap::new();
718                for (id, version) in results {
719                    out.insert(id, version);
720                }
721                Ok(out)
722            }
723        },
724    );
725}
726
727/// Re-export from shared crate for internal use.
728/// Build a provider-CLI command with this instance's identity already stripped.
729///
730/// Sanitizing HERE rather than at each call site is deliberate. This factory has
731/// eight callers across srv (agent spawn, ACP, app-server, persistent
732/// controller, session probe, auth check, version probe), and none of them
733/// contains a literal `Command::new` — so a grep for spawn constructors, which
734/// is how the other sites in this codebase were audited, cannot see them. Five
735/// review rounds on PR #3326 each found a spawn the previous fix had missed;
736/// this one was invisible to the verification command the spec recommends.
737/// A factory is the one place a fix cannot be half-applied.
738///
739/// Pane policy, not strict: most callers launch an agent CLI that legitimately
740/// uses the in-pane helpers. Call sites that spawn the CLI as a plain
741/// third-party binary (auth checks, version probes) tighten this to
742/// `sanitize_external_command` on top, which only removes more.
743pub(crate) fn make_cli_cmd(cli_path: &str) -> tokio::process::Command {
744    let mut cmd = agentmux_common::make_cli_cmd(cli_path);
745    crate::backend::pane_env::sanitize_process_command(&mut cmd);
746    cmd
747}
748
749/// `pi --list-models` exits 0 either way; it is signed in when it lists
750/// models rather than "No models available".
751fn pi_lists_models(success: bool, stdout: &str, stderr: &str) -> bool {
752    success && !stdout.contains("No models available") && !stderr.contains("No models available")
753}
754
755/// Run the provider auth-check CLI against `auth_env` and parse the verdict.
756/// Returns `(authenticated, email, auth_method, raw_output)`. Extracted so the
757/// stale-credential self-heal can re-run the exact same check after refreshing.
758async fn run_auth_check(
759    cli_path: &str,
760    auth_check_args: &[String],
761    auth_env: &std::collections::HashMap<String, String>,
762) -> Result<(bool, Option<String>, Option<String>, String), String> {
763    // No check command must never read as signed in: a bare CLI with null
764    // stdin often just exits 0 (pi-acp does).
765    if auth_check_args.is_empty() {
766        return Err("this CLI has no sign-in check".to_string());
767    }
768    // pi-acp has no status command of its own; the pi beside it does:
769    // `pi --list-models` prints "No models available" until a provider is
770    // set up (SPEC_PI_HARNESS_VIA_PI_ACP_2026_10_06.md).
771    let pi = crate::backend::providers::pi_beside_pi_acp(cli_path);
772    // pi-acp itself exits 0 on any flag, so without its pi there is nothing
773    // that can answer.
774    if pi.is_none() && crate::backend::providers::is_pi_acp(cli_path) {
775        return Err("pi is not installed beside pi-acp".to_string());
776    }
777    let program = pi.as_deref().unwrap_or(cli_path);
778    let output = tokio::time::timeout(std::time::Duration::from_secs(10), {
779        let mut check_cmd = make_cli_cmd(program);
780        // An auth check is the provider CLI acting as a plain third-party
781        // binary, not as one of our agents — no endpoint, no credential.
782        // (ReAgent P0, round 5, on #3326.)
783        crate::backend::pane_env::sanitize_external_command(&mut check_cmd);
784        check_cmd.args(auth_check_args);
785        for (k, v) in auth_env {
786            check_cmd.env(k, v);
787        }
788        // Null stdin: prevents the CLI from blocking on interactive first-run
789        // prompts (onboarding, theme selection) that only appear on a TTY.
790        check_cmd.stdin(std::process::Stdio::null());
791        check_cmd.output()
792    })
793    .await
794    .map_err(|_| "auth check timed out (10s)".to_string())?
795    .map_err(|e| format!("failed to run auth check: {e}"))?;
796
797    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
798    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
799
800    let mut email = None;
801    let mut auth_method = None;
802    let authenticated = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&stdout) {
803        // Claude outputs `emailAddress`; other CLIs use `email`. Check both.
804        email = json
805            .get("emailAddress")
806            .or_else(|| json.get("email"))
807            .and_then(|v| v.as_str())
808            .map(|s| s.to_string());
809        auth_method = json
810            .get("authMethod")
811            .and_then(|v| v.as_str())
812            .map(|s| s.to_string());
813        json.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false)
814    } else if pi.is_some() {
815        pi_lists_models(output.status.success(), &stdout, &stderr)
816    } else {
817        output.status.success()
818    };
819    let raw_output = if !stdout.is_empty() { stdout } else { stderr };
820    Ok((authenticated, email, auth_method, raw_output))
821}
822
823// REMOVED 2026-08-31 (Codex P1 on PR #2878): `claude_access_token`,
824// `refresh_claude_dir_from_global_if_stale`, and `refresh_dir_from_global` —
825// the machinery behind the auth check's "self-heal from the user's global
826// ~/.claude" step, together with its `selfheal_tests` module.
827//
828// Deleted rather than left unused on purpose: a helper whose entire job is
829// copying the operator's personal credential into an isolated dir is exactly
830// the thing that gets quietly rewired back in later. There is now nothing to
831// call. See `docs/analysis/ANALYSIS_PER_CHANNEL_AUTH_BYPASSES_2026_08_31.md`
832// (#5) for the full reasoning.
833//
834// The real condition it recovered from (the "Pozl 401" — a global re-login
835// rotating the access token and invalidating the isolated copy's refresh
836// token) now surfaces honestly as a failed validation plus an actionable auth
837// card, and is fixed by logging in for THIS channel.
838
839/// A PATH-only provider's CLI: on PATH, else in the folder its own installer
840/// uses ([`crate::backend::providers::known_install_paths`]), which may not
841/// be on this process's PATH until AgentMux restarts.
842///
843/// A provider that takes its prompt on the command line
844/// (`ProviderConfig::prompt_arg_flag`) must run as the real executable: a
845/// `.cmd`/`.bat` wrapper is re-parsed by `cmd.exe`, where `&`, `|` or `%` in
846/// a prompt could turn into a command. For those the installer's own
847/// executable comes first, and a script wrapper on PATH is never used.
848pub(crate) async fn resolve_provider_cli_on_path(provider_id: &str, cli_command: &str) -> Option<String> {
849    let known = || {
850        crate::backend::providers::known_install_paths(provider_id)
851            .into_iter()
852            .find(|p| p.is_file())
853            .map(|p| p.to_string_lossy().into_owned())
854    };
855    let prompt_in_argv = crate::backend::providers::get_provider(provider_id)
856        .and_then(|p| p.prompt_arg_flag())
857        .is_some();
858    if prompt_in_argv {
859        if let Some(path) = known() {
860            return Some(path);
861        }
862        // Every match, not just the first: a `.cmd` shim ahead of the real
863        // executable on PATH must not hide it.
864        return resolve_cli_candidates_on_path(cli_command)
865            .await
866            .into_iter()
867            .find(|path| !is_script_wrapper(path));
868    }
869    if let Some(path) = resolve_cli_on_path(cli_command).await {
870        return Some(path);
871    }
872    known()
873}
874
875/// Whether `path` is a Windows batch script (`.cmd`/`.bat`), which runs
876/// through `cmd.exe` and re-parses its arguments.
877fn is_script_wrapper(path: &str) -> bool {
878    let lower = path.to_ascii_lowercase();
879    lower.ends_with(".cmd") || lower.ends_with(".bat")
880}
881
882/// Resolve a CLI command on the system PATH.
883///
884/// Uses `where` on Windows and `which` on Unix. Returns the absolute path
885/// if the command is found and exists, otherwise `None`.
886///
887/// On Windows we pass the bare command name (no `.cmd` suffix) so that
888/// `where` resolves the correct extension via PATHEXT. This correctly finds
889/// `docker.exe`, `git.exe`, `node.exe` AND `npm.cmd` — previously the
890/// hard-coded `.cmd` suffix caused all `.exe`-based tools (docker, git,
891/// node) to report as not installed even when present on PATH.
892///
893/// `where` can return multiple lines (e.g. Node.js ships both an extensionless
894/// `npm` Unix shell script and `npm.cmd` on Windows). We filter to the first
895/// line whose extension `make_cli_cmd` can actually spawn (.exe / .cmd / .bat).
896/// Taking the raw first line would yield the extensionless entry, which
897/// `Command::new` cannot run on Windows without a shell.
898async fn resolve_cli_on_path(cli_command: &str) -> Option<String> {
899    resolve_cli_candidates_on_path(cli_command).await.into_iter().next()
900}
901
902/// Every spawnable match for `cli_command` on PATH that exists, in PATH order.
903async fn resolve_cli_candidates_on_path(cli_command: &str) -> Vec<String> {
904    let which_result = if cfg!(windows) {
905        let mut probe = tokio::process::Command::new("where");
906        probe.arg(cli_command);
907        #[cfg(windows)]
908        {
909            probe.no_window();
910        }
911        probe.output().await
912    } else {
913        tokio::process::Command::new("which").arg(cli_command).output().await
914    };
915    match which_result {
916        Ok(out) if out.status.success() => spawnable_candidates(&String::from_utf8_lossy(&out.stdout), cfg!(windows))
917            .into_iter()
918            .filter(|p| std::path::Path::new(p).exists())
919            .collect(),
920        _ => Vec::new(),
921    }
922}
923
924/// The lines of `where`/`which` output a spawn can use. On Windows that is
925/// the ones `make_cli_cmd` can run (.exe / .cmd / .bat), skipping e.g. the
926/// extensionless `npm` shell script; elsewhere `which` prints one path.
927fn spawnable_candidates(stdout: &str, windows: bool) -> Vec<String> {
928    let lines = stdout.lines().map(str::trim).filter(|l| !l.is_empty());
929    if windows {
930        lines
931            .filter(|l| {
932                let lo = l.to_lowercase();
933                lo.ends_with(".exe") || lo.ends_with(".cmd") || lo.ends_with(".bat")
934            })
935            .map(str::to_string)
936            .collect()
937    } else {
938        lines.take(1).map(str::to_string).collect()
939    }
940}
941
942/// The CLI notices one `ResolveCli` call can put in its pane
943/// (`backend::cli_notice`, SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_STARTUP_FILES_2026_09_30.md
944/// §6.3). A probe with no `block_id` (the picker, the Toolchain pane) puts
945/// nothing anywhere.
946struct CliNotices {
947    broker: std::sync::Arc<crate::backend::mps::Broker>,
948    filestore: std::sync::Arc<crate::backend::storage::filestore::FileStore>,
949    mstore: std::sync::Arc<crate::backend::storage::store::Store>,
950    /// The pane being launched (`notice_block_id`, else `block_id`).
951    block_id: String,
952    /// The agent being launched, when the caller says; else the pane's
953    /// `agentId` meta.
954    agent_id: Option<String>,
955    provider: String,
956}
957
958/// The pane a resolve's notices go to: the launch target when the caller
959/// names one (a quick fork resolves from its source pane), else `block_id`.
960fn notice_block_id(cmd: &CommandResolveCliData) -> String {
961    cmd.notice_block_id
962        .as_deref()
963        .map(str::trim)
964        .filter(|b| !b.is_empty())
965        .unwrap_or(&cmd.block_id)
966        .to_string()
967}
968
969impl CliNotices {
970    fn install(
971        &self,
972        install_id: &str,
973        version: &str,
974        state: crate::backend::cli_notice::InstallState,
975        seconds: Option<f64>,
976        error: Option<&str>,
977    ) {
978        if self.block_id.is_empty() {
979            return;
980        }
981        let frame = crate::backend::cli_notice::install_frame(install_id, &self.provider, version, state, seconds, error);
982        crate::backend::cli_notice::append_to_pane(&self.broker, &self.filestore, &self.mstore, &self.block_id, &frame);
983    }
984
985    /// Record the version this pane's agent is about to run. Claude reports
986    /// the version it actually runs in its own `system/init` frame, which
987    /// covers every spawn path (`persistent/stdout_reader.rs`), so it is observed
988    /// there and not here.
989    fn observe_version(&self, raw_version: &str) {
990        if self.block_id.is_empty() || self.provider == "claude" {
991            return;
992        }
993        let Some(version) = crate::backend::cli_notice::version_token(raw_version) else { return };
994        let (broker, filestore, mstore) = (self.broker.clone(), self.filestore.clone(), self.mstore.clone());
995        let (block_id, provider, agent_id) = (self.block_id.clone(), self.provider.clone(), self.agent_id.clone());
996        tokio::task::spawn_blocking(move || {
997            crate::backend::cli_notice::observe_and_notify(
998                &broker,
999                &filestore,
1000                &mstore,
1001                &block_id,
1002                agent_id.as_deref(),
1003                &provider,
1004                &version,
1005            );
1006        });
1007    }
1008}
1009
1010async fn get_cli_version(cli_path: &str) -> String {
1011    let result = tokio::time::timeout(
1012        std::time::Duration::from_secs(5),
1013        {
1014            let mut c = make_cli_cmd(cli_path);
1015            // Version probe — same reasoning as the auth check above.
1016            crate::backend::pane_env::sanitize_external_command(&mut c);
1017            c.arg("--version").stdin(std::process::Stdio::null());
1018            c.output()
1019        },
1020    ).await;
1021    match result {
1022        Ok(Ok(output)) if output.status.success() => {
1023            String::from_utf8_lossy(&output.stdout).trim().to_string()
1024        }
1025        Ok(_) => "unknown".to_string(),
1026        Err(_) => {
1027            tracing::warn!(cli_path = %cli_path, "get_cli_version timed out after 5s");
1028            "unknown".to_string()
1029        }
1030    }
1031}
1032
1033/// The wire form of a prune [`crate::backend::cli_prune::Run`].
1034fn prune_result(
1035    run: crate::backend::cli_prune::Run,
1036    dry_run: bool,
1037    max_idle: std::time::Duration,
1038) -> ToolchainPruneResult {
1039    use crate::backend::cli_prune::Candidate;
1040    let item = |c: &Candidate| ToolchainPruneItem {
1041        dir: c.dir.to_string_lossy().into_owned(),
1042        provider: c.provider.clone(),
1043        version: c.version.clone(),
1044        legacy: c.legacy,
1045        bytes: c.bytes,
1046        idle_days: c.idle.as_secs() / 86_400,
1047    };
1048    let removed_dirs: std::collections::HashSet<&std::path::PathBuf> = run
1049        .report
1050        .iter()
1051        .flat_map(|r| r.removed.iter().map(|(d, _)| d))
1052        .collect();
1053    ToolchainPruneResult {
1054        dry_run,
1055        scan_ok: run.scan_ok,
1056        candidates: run.plan.prunable.iter().map(item).collect(),
1057        reclaimable_bytes: run.plan.reclaimable_bytes(),
1058        removed: run
1059            .plan
1060            .prunable
1061            .iter()
1062            .filter(|c| removed_dirs.contains(&c.dir))
1063            .map(item)
1064            .collect(),
1065        skipped: run
1066            .report
1067            .iter()
1068            .flat_map(|r| r.skipped.iter())
1069            .map(|(d, why)| ToolchainPruneSkip { dir: d.to_string_lossy().into_owned(), reason: why.clone() })
1070            .collect(),
1071        kept: run.plan.kept.len() as u32,
1072        max_idle_days: (max_idle.as_secs() / 86_400) as u32,
1073    }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078
1079    /// `where` lists every match; a `.cmd` shim first must not hide the
1080    /// `.exe` after it, which the prompt-argument branch then picks.
1081    #[test]
1082    fn every_spawnable_match_is_kept_in_path_order() {
1083        let out = [r"C:\Users\u\bin\agy", r"C:\Users\u\bin\agy.cmd", r"D:\tools\agy.exe", ""].join("\r\n");
1084        assert_eq!(
1085            super::spawnable_candidates(&out, true),
1086            vec![r"C:\Users\u\bin\agy.cmd".to_string(), r"D:\tools\agy.exe".to_string()]
1087        );
1088        let first_real = super::spawnable_candidates(&out, true)
1089            .into_iter()
1090            .find(|p| !super::is_script_wrapper(p));
1091        assert_eq!(first_real.as_deref(), Some(r"D:\tools\agy.exe"));
1092        assert_eq!(super::spawnable_candidates("/usr/bin/agy\n", false), vec!["/usr/bin/agy".to_string()]);
1093    }
1094
1095    #[test]
1096    fn pi_is_signed_in_only_when_it_lists_models() {
1097        let none = "No models available. Use /login to log into a provider via OAuth or API key.";
1098        assert!(!super::pi_lists_models(true, none, ""));
1099        assert!(!super::pi_lists_models(true, "", none));
1100        assert!(!super::pi_lists_models(false, "", ""));
1101        assert!(super::pi_lists_models(true, "anthropic/claude-sonnet-4-6
1102", ""));
1103    }
1104
1105    #[tokio::test]
1106    async fn pi_acp_without_its_pi_is_refused_not_read_as_signed_in() {
1107        let dir = tempfile::tempdir().unwrap();
1108        let acp = dir.path().join(if cfg!(windows) { "pi-acp.cmd" } else { "pi-acp" });
1109        std::fs::write(&acp, "").unwrap();
1110        let args = vec!["--list-models".to_string()];
1111        let err = super::run_auth_check(&acp.to_string_lossy(), &args, &Default::default()).await.unwrap_err();
1112        assert!(err.contains("not installed beside pi-acp"), "{err}");
1113    }
1114
1115    #[tokio::test]
1116    async fn an_empty_check_command_is_refused_not_read_as_signed_in() {
1117        let err = super::run_auth_check("pi-acp", &[], &Default::default()).await.unwrap_err();
1118        assert!(err.contains("no sign-in check"), "{err}");
1119    }
1120
1121    /// A prompt passed through one of these is re-parsed by `cmd.exe`.
1122    #[test]
1123    fn batch_scripts_are_script_wrappers_and_executables_are_not() {
1124        for p in [r"C:\Users\u\bin\agy.cmd", r"C:\x\AGY.CMD", r"C:\x\run.bat"] {
1125            assert!(super::is_script_wrapper(p), "{p}");
1126        }
1127        for p in [r"C:\Users\u\AppData\Local\agy\bin\agy.exe", "/home/u/.local/bin/agy", r"C:\x\cmd\agy.exe"] {
1128            assert!(!super::is_script_wrapper(p), "{p}");
1129        }
1130    }
1131    use super::*;
1132
1133    fn resolve_cmd(block_id: &str, notice_block_id: Option<&str>) -> CommandResolveCliData {
1134        CommandResolveCliData {
1135            provider_id: "qwen".to_string(),
1136            cli_command: "qwen".to_string(),
1137            npm_package: "@qwen-code/qwen-code".to_string(),
1138            pinned_version: "0.24.0".to_string(),
1139            windows_install_command: String::new(),
1140            unix_install_command: String::new(),
1141            block_id: block_id.to_string(),
1142            notice_block_id: notice_block_id.map(str::to_string),
1143            agent_id: None,
1144        }
1145    }
1146
1147    /// CLI notices go to the pane being launched: a quick fork resolves from
1148    /// its source pane (`block_id`) but names its new pane (Codex on #4128).
1149    #[test]
1150    fn cli_notices_go_to_the_launch_target() {
1151        assert_eq!(notice_block_id(&resolve_cmd("source", Some("fork"))), "fork");
1152        assert_eq!(notice_block_id(&resolve_cmd("source", None)), "source");
1153        assert_eq!(notice_block_id(&resolve_cmd("source", Some("  "))), "source");
1154    }
1155
1156    /// An unknown provider is refused before anything touches the disk.
1157    #[tokio::test]
1158    async fn ensure_provider_auth_dir_rejects_an_unknown_provider() {
1159        let state = crate::server::tests::test_state();
1160        let (engine, mut out) = WshRpcEngine::new();
1161        register_cli_handlers(&engine, &state);
1162        engine.handle_message(crate::backend::rpc_types::RpcMessage {
1163            command: COMMAND_ENSURE_PROVIDER_AUTH_DIR.to_string(),
1164            reqid: "r1".to_string(),
1165            data: Some(serde_json::json!({ "provider_id": "no-such-provider" })),
1166            ..Default::default()
1167        });
1168        let resp = tokio::time::timeout(std::time::Duration::from_secs(2), out.recv())
1169            .await
1170            .unwrap()
1171            .unwrap();
1172        assert!(resp.data.is_none(), "{resp:?}");
1173        assert!(resp.error.contains("unknown provider"), "{resp:?}");
1174    }
1175
1176    /// All seven cli/toolchain/widget commands record their request and
1177    /// response types.
1178    ///
1179    /// The two widget commands are the interesting entries. They read their
1180    /// payload field-by-field off a `serde_json::Value` and turn a bad port or
1181    /// path into an `ok: false` ANSWER rather than an error, so they cannot
1182    /// take a struct request. Earlier slices concluded such handlers had to
1183    /// stay on `register_handler` entirely -- but `Value` deserializes from
1184    /// anything, so registering it AS the request type is exactly as tolerant
1185    /// while still recording the response. That is what this asserts.
1186    #[tokio::test]
1187    async fn register_typed_records_every_cli_command() {
1188        let state = crate::server::tests::test_state();
1189        let (engine, _rx) = WshRpcEngine::new();
1190        register_cli_handlers(&engine, &state);
1191        let schema = engine.schema_json();
1192        let rows = schema.as_array().unwrap();
1193        let find = |cmd: &str| {
1194            rows.iter()
1195                .find(|r| r["command"] == cmd)
1196                .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
1197        };
1198
1199        for (cmd, req, resp) in [
1200            (COMMAND_RESOLVE_CLI, "CommandResolveCliData", "ResolveCliResult"),
1201            (COMMAND_CHECK_CLI_AUTH, "CommandCheckCliAuthData", "CheckCliAuthResult"),
1202            ("runclilogin", "CommandRunCliLoginData", "RunCliLoginResult"),
1203            (COMMAND_ENSURE_PROVIDER_AUTH_DIR, "EnsureProviderAuthDirReq", "EnsureProviderAuthDirResult"),
1204            ("toolchain.versions", "ToolchainVersionsReq", "HashMap<alloc::string::String, core::option::Option<alloc::string::String>>"),
1205        ] {
1206            let row = find(cmd);
1207            assert_eq!(row["requestName"], req, "{cmd} request");
1208            assert!(
1209                row["responseName"].as_str().unwrap().contains(resp.split('<').next().unwrap()),
1210                "{cmd} response should mention {resp}, got {:?}",
1211                row["responseName"],
1212            );
1213        }
1214
1215        // The widget pair: `Value` request, named response.
1216        for cmd in ["widget.health", "widget.api"] {
1217            let row = find(cmd);
1218            assert_eq!(
1219                row["requestName"], "Value",
1220                "{cmd} keeps a Value request so a bad port stays an answer, not an error",
1221            );
1222        }
1223        assert_eq!(find("widget.health")["responseName"], "WidgetHealthResult");
1224        assert_eq!(find("widget.api")["responseName"], "WidgetApiResult");
1225
1226        // `toolchain.env` takes no argument, so `Option<_>` of an empty struct.
1227        let env = find("toolchain.env");
1228        let env_req = env["requestName"].as_str().unwrap();
1229        assert!(
1230            env_req.starts_with("core::option::Option<") && env_req.contains("ToolchainEnvReq"),
1231            "toolchain.env should take Option<ToolchainEnvReq>, got {env_req:?}",
1232        );
1233        assert_eq!(env["responseName"], "ToolchainEnvResult");
1234    }
1235
1236    /// A `Value` request really does accept everything, which is the whole
1237    /// reason it is safe to use here. If this ever stops holding, the two
1238    /// widget commands silently start rejecting payloads they used to answer.
1239    #[test]
1240    fn a_value_request_deserializes_from_any_payload() {
1241        for payload in [
1242            serde_json::json!({ "port": 3000 }),
1243            serde_json::json!({ "port": "not a number" }),
1244            serde_json::json!({}),
1245            serde_json::json!([1, 2, 3]),
1246            serde_json::Value::Null,
1247        ] {
1248            serde_json::from_value::<serde_json::Value>(payload.clone())
1249                .unwrap_or_else(|e| panic!("{payload} should deserialize as Value, got {e}"));
1250        }
1251    }
1252
1253    /// `toolchain.env`'s `pathSource` is camelCase on the wire while every
1254    /// other field in that response is lowercase, so it needs BOTH
1255    /// `serde(rename)` and `ts(rename)` -- ts-rs does not read the serde one.
1256    /// Without the second the generated key is `path_source`, which nothing
1257    /// sends and nothing reads, and every gate still passes.
1258    #[test]
1259    fn toolchain_env_reports_path_source_as_camel_case() {
1260        let v = serde_json::to_value(ToolchainEnvResult {
1261            path: "/usr/bin".to_string(),
1262            path_source: "inherited".to_string(),
1263            os: "linux".to_string(),
1264            arch: "x86_64".to_string(),
1265        })
1266        .unwrap();
1267        assert_eq!(v["pathSource"], "inherited");
1268        assert!(v.get("path_source").is_none(), "the Rust field name must not reach the wire: {v}");
1269
1270        // Match the DECLARATION, not any mention: the doc comment on this
1271        // field names `path_source` on purpose (explaining why the rename
1272        // exists), and a bare substring check matched that instead of the
1273        // field and failed on a correct binding.
1274        let ts = <ToolchainEnvResult as ts_rs::TS>::inline(&ts_rs::Config::from_env());
1275        assert!(ts.contains("pathSource: string"), "generated TS lost the rename: {ts}");
1276        assert!(
1277            !ts.contains("path_source: string"),
1278            "generated TS kept the Rust name as a field: {ts}",
1279        );
1280    }
1281}