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}