Skip to main content

trusty_console/
lib.rs

1//! trusty-console library entry point.
2//!
3//! Why: Expose the console daemon's startup sequence as a public `run()`
4//! function so bundled shim binaries inside host crates (trusty-search,
5//! trusty-memory, trusty-analyze, trusty-review, trusty-mpm) can call
6//! `trusty_console::run()` without duplicating any logic. This mirrors the
7//! exact pattern used by trusty-embedderd (bundled into trusty-search via
8//! issue #187) and trusty-bm25-daemon (bundled into trusty-memory via PR #190).
9//! What: Re-exports all public submodules and provides `run_from(argv)` as the
10//! canonical library entry point that parses an explicit argv vector and
11//! dispatches to subcommands; `run()` is a thin wrapper passing the process's
12//! global argv.
13//! Test: `cargo test -p trusty-console` exercises the CLI parsing tests defined
14//! in the submodules.
15
16// docs.rs builds a release's documentation once, from the uploaded tarball,
17// so a broken intra-doc link is baked into that version forever and only a new
18// release can correct it. Deny keeps this crate at zero rather than letting the
19// ratchet in `scripts/check_rustdoc_links.sh` absorb a new one.
20#![deny(rustdoc::broken_intra_doc_links)]
21
22use std::sync::Arc;
23use std::time::Duration;
24
25use anyhow::{Context, Result};
26use clap::{Parser, Subcommand};
27use tracing::info;
28use trusty_common::{init_tracing, shutdown_signal, write_daemon_addr};
29
30/// Default console HTTP bind address (used for both serve and `port` reporting).
31///
32/// Why: A single constant keeps the serve default and the `port` verb's
33/// fallback in lock-step so `trusty-installer` (`tctl`) never discovers a port
34/// the console would not actually bind to.
35/// What: `127.0.0.1:7788` — the canonical localhost console address.
36/// Test: `test_resolve_reported_addr_default` asserts the `port` verb falls
37/// back to this value's host/port when no discovery file is present.
38pub const DEFAULT_HTTP: &str = "127.0.0.1:7788";
39
40/// Default console port, parsed once from [`DEFAULT_HTTP`].
41///
42/// Why: The `port` verb reports this when no running console has written a
43/// discovery file yet.
44/// What: `7788`.
45/// Test: covered by the `port` verb tests below.
46pub const DEFAULT_PORT: u16 = 7788;
47
48pub mod bind;
49// #6285: the console's own SPA, split out of `server` at the 500-SLOC cap.
50pub mod connector;
51pub mod console_ui;
52pub mod detect;
53// #6517: background whole-machine host-metrics sampler + cache feeding the
54// machine-status route.
55pub mod host_status;
56pub mod mcp_handle;
57pub mod metrics_poller;
58pub mod poller;
59pub mod proxy;
60pub mod routes;
61// #6285: the console's client to trusty-search, which no longer serves HTTP.
62pub mod search_uds;
63pub mod server;
64pub mod service;
65// #6155: the trusty-search SPA, mounted under /tools/search/.
66pub mod tools_ui;
67pub mod webhook;
68
69/// How often the background sweep re-attempts pending webhook deliveries.
70///
71/// The sweep is the *recovery* mechanism, not the detection one — a stuck
72/// delivery is detected by `GET /api/console/metrics/webhooks`, which scans the
73/// spool on the request and therefore stays honest even if this loop dies.
74const WEBHOOK_RETRY_INTERVAL: Duration = Duration::from_secs(60);
75pub(crate) mod url_util;
76
77// ─── CLI ─────────────────────────────────────────────────────────────────────
78
79/// trusty-console: web dashboard for trusty services.
80///
81/// Why: Provides a single entry point for all console subcommands so future
82/// phases (status, doctor, open) can be added without breaking existing usage.
83/// What: Parses top-level arguments and delegates to subcommand handlers.
84/// Test: `cargo run -p trusty-console -- serve --help` must succeed.
85#[derive(Debug, Parser)]
86#[command(
87    name = "trusty-console",
88    version,
89    about = "Web dashboard for trusty services"
90)]
91pub struct Cli {
92    #[command(subcommand)]
93    pub command: Commands,
94}
95
96/// Available subcommands.
97///
98/// Why: `serve` runs the dashboard; `port` is a non-serving contract verb that
99/// reports the console's bound (or default) port so orchestrators like
100/// `trusty-installer` (`tctl`) can discover/launch the console without parsing logs.
101/// What: Clap enum; each variant carries its own args.
102/// Test: Subcommand selection tested via `Cli::parse_from`.
103#[derive(Debug, Subcommand)]
104pub enum Commands {
105    /// Start the HTTP server and serve the console dashboard.
106    Serve(ServeArgs),
107    /// Report the console's bound (or default) HTTP port and exit.
108    Port(PortArgs),
109    /// Manage inference provider configuration (API keys) — the universal
110    /// `config keys set/list/test/unset` surface shared by every trusty-*
111    /// binary (epic #2400 Wave 1, #2405).
112    Config(trusty_common::inference::config::ConfigCommand),
113    /// Manage the macOS launchd LaunchAgent for the console daemon (#2557).
114    ///
115    /// `install` writes `~/Library/LaunchAgents/com.trusty.trusty-console.plist`
116    /// (running `trusty-console serve`) and bootstraps it; `uninstall` unloads
117    /// and removes it; `status` / `logs` inspect the running agent. macOS-only.
118    /// `tctl install` / `tctl start` call `install` on the operator's behalf.
119    Service {
120        #[command(subcommand)]
121        action: service::ServiceAction,
122    },
123}
124
125/// Arguments for `trusty-console port`.
126///
127/// Why: `trusty-installer` (`tctl`) (the orchestrator, issue #1316) discovers
128/// the console URL by spawning `trusty-console port --json` and parsing the
129/// `{addr,port}` envelope (see trusty-installer `os_env.rs`). The verb must
130/// exist and be machine-readable for that discovery to work after the console is
131/// de-bundled from the host crates (#1318).
132/// What: A single `--json` flag selecting JSON output (default is a single
133/// human-readable port line).
134/// Test: `test_port_args_json_flag` / `test_port_args_default` below.
135#[derive(Debug, Parser)]
136pub struct PortArgs {
137    /// Emit a JSON envelope `{"addr":"<host>","port":<u16>}` instead of a bare
138    /// port number. Consumed by `trusty-installer` (`tctl`) console discovery.
139    #[arg(long, default_value_t = false)]
140    pub json: bool,
141}
142
143/// Arguments for `trusty-console serve`.
144///
145/// Why: The bind address must be configurable so users can change the port when
146/// 7788 is taken; `--open` is a convenience for developers; `--poll-interval`
147/// lets operators tune the background health-poll frequency; `--tailscale`
148/// enables durable tailnet exposure without requiring `--http 0.0.0.0`.
149/// What: Optional `--http` (default `127.0.0.1:7788`), `--open`,
150/// `--poll-interval`, and `--tailscale` flags.
151/// Env overrides: `TRUSTY_CONSOLE_BIND` sets the default bind mode so a
152/// supervised/relaunched daemon stays tailnet-reachable without extra flags.
153/// Test: Default address tested in `test_serve_args_defaults` below.
154#[derive(Debug, Parser)]
155pub struct ServeArgs {
156    /// Address to listen on (default: 127.0.0.1:7788).
157    ///
158    /// Takes precedence over --tailscale and TRUSTY_CONSOLE_BIND when set to a
159    /// non-default value.
160    #[arg(long, default_value = "127.0.0.1:7788")]
161    pub http: String,
162
163    /// Expose the console on both 127.0.0.1 and the machine's Tailscale IPv4,
164    /// enabling tailnet clients to reach the console without LAN exposure.
165    ///
166    /// The Tailscale IP is detected via `tailscale ip -4`. If Tailscale is not
167    /// running, prints a warning and falls back to localhost-only.
168    ///
169    /// Can also be set persistently via the TRUSTY_CONSOLE_BIND=tailscale env
170    /// var so a supervised/relaunched console stays tailnet-reachable without
171    /// manually passing this flag.
172    #[arg(long, default_value_t = false)]
173    pub tailscale: bool,
174
175    /// Open the console in the default browser after starting.
176    #[arg(long, default_value_t = false)]
177    pub open: bool,
178
179    /// Background poll interval in seconds (default: 15).
180    ///
181    /// Controls BOTH the health poller (`poller::start`) AND the metrics
182    /// poller (`metrics_poller::start`). Increasing this value reduces the
183    /// frequency of both the HTTP health checks against each connector AND
184    /// the stdio MCP `console_metrics` tool calls against trusty-analyze.
185    #[arg(long, default_value_t = 15u64)]
186    pub poll_interval: u64,
187}
188
189// ─── public entry point ────────────────────────────────────────────────────
190
191/// Library entry point for the trusty-console daemon, using the process argv.
192///
193/// Why: The standalone `trusty-console` crate is now the SOLE producer of the
194/// `trusty-console` binary (#1318 — de-bundled from the 5 host crates). The
195/// thin `main.rs` calls this, which simply forwards the process's global argv
196/// to `run_from`.
197/// What: Collects `std::env::args()` and delegates to [`run_from`].
198/// Test: Indirectly via the `run_from` tests below and the binary smoke test.
199pub async fn run() -> Result<()> {
200    run_from(std::env::args().collect()).await
201}
202
203/// Library entry point parameterised on an explicit argv vector.
204///
205/// Why: Decoupling argument parsing from the process's global argv (#1318)
206/// lets callers (tests, future embedders) drive the console deterministically
207/// without mutating `std::env`. Previously `run()` called `Cli::parse()`,
208/// which read global argv and could not be exercised in isolation.
209/// What: Initialises tracing, parses `argv` via `Cli::parse_from`, and
210/// dispatches to the matching subcommand handler. `argv[0]` is the program
211/// name (clap convention). Returns `Ok(())` after clean shutdown.
212/// Test: `test_run_from_port_json_outputs_envelope` drives this directly with
213/// a synthetic argv; integration via `cargo test -p trusty-console`.
214pub async fn run_from(argv: Vec<String>) -> Result<()> {
215    init_tracing(1);
216
217    let cli = Cli::parse_from(argv);
218
219    match cli.command {
220        Commands::Serve(args) => run_serve(args).await,
221        Commands::Port(args) => run_port(args),
222        Commands::Config(cmd) => cmd.run().await,
223        // `service` drives macOS launchd synchronously; no async work needed.
224        Commands::Service { action } => service::run_service_action(&action),
225    }
226}
227
228/// Resolve the console's reportable HTTP address (host, port).
229///
230/// Why: The `port` verb must report the LIVE port of a running console when
231/// one exists, falling back to the default otherwise — so `trusty-installer`
232/// (`tctl`) discovery (issue #1316) points at the real dashboard, not a guess.
233/// What: Reads the `trusty-console` discovery file via
234/// `trusty_common::read_daemon_addr`; on a parseable `host:port` returns that
235/// pair, else falls back to ([`DEFAULT_HTTP`] host, [`DEFAULT_PORT`]). Never
236/// errors — discovery failures degrade to the default.
237/// Test: `test_resolve_reported_addr_default` (no file → default).
238pub fn resolve_reported_addr() -> (String, u16) {
239    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
240        && let Ok(sa) = recorded.parse::<std::net::SocketAddr>()
241    {
242        return (sa.ip().to_string(), sa.port());
243    }
244    let default_host = DEFAULT_HTTP
245        .rsplit_once(':')
246        .map(|(h, _)| h.to_owned())
247        .unwrap_or_else(|| "127.0.0.1".to_owned());
248    (default_host, DEFAULT_PORT)
249}
250
251/// Run the `port` subcommand: print the console's bound/default port and exit.
252///
253/// Why: `trusty-installer` (`tctl`) console discovery spawns
254/// `trusty-console port --json` and parses a `{addr,port}` envelope
255/// (trusty-installer `os_env.rs`). This verb is the contract that makes that
256/// discovery work; without it the call exits non-zero and console discovery is
257/// silently broken (the latent bug fixed by #1318).
258/// What: Resolves the reportable address; with `--json` prints
259/// `{"addr":"<host>","port":<u16>}` to stdout, otherwise prints the bare port.
260/// Returns `Ok(())`.
261/// Test: `test_run_from_port_json_outputs_envelope`,
262/// `test_port_envelope_is_valid_json`.
263pub fn run_port(args: PortArgs) -> Result<()> {
264    let (addr, port) = resolve_reported_addr();
265    if args.json {
266        let envelope = serde_json::json!({ "addr": addr, "port": port });
267        println!("{envelope}");
268    } else {
269        println!("{port}");
270    }
271    Ok(())
272}
273
274/// Run the `serve` subcommand.
275///
276/// Why: Separating the serve logic from `run()` keeps `run()` thin and allows
277/// this function to be called from integration tests.
278/// What: Resolves bind addresses (respecting `--tailscale`, `--http`, and
279/// `TRUSTY_CONSOLE_BIND`), builds the router, binds TCP listener(s), writes
280/// the discovery file, starts the background health-poll task, optionally opens
281/// a browser, then serves until SIGTERM/SIGINT with graceful shutdown.
282/// Additional addresses beyond the primary get their own spawned `axum::serve`
283/// task that runs concurrently until the shared shutdown signal fires.
284/// Test: Server integration tests in `server.rs` cover the router directly
285/// without exercising this function (to avoid real TCP binding in unit tests).
286pub async fn run_serve(args: ServeArgs) -> Result<()> {
287    // ── resolve bind mode ───────────────────────────────────────────────────
288    let mode = bind::BindMode::from_env_and_flags(&args.http, DEFAULT_HTTP, args.tailscale);
289    let port = bind::port_from_addr(&args.http, DEFAULT_PORT);
290    let addrs = bind::resolve_bind_addrs(&mode, port, bind::detect_tailscale_ipv4);
291
292    // ── service setup ───────────────────────────────────────────────────────
293    let connectors = detect::all_connectors();
294    let state = server::AppState::new(connectors);
295
296    // Kick off an eager first poll so the cache is warm before the first
297    // HTTP request arrives.
298    {
299        let cache = state.poller_cache().clone();
300        let c = state.connectors();
301        cache.poll_once(c).await;
302    }
303
304    // Start the background poller that refreshes the cache on the configured
305    // interval.
306    poller::start(
307        state.poller_cache().clone(),
308        state.connectors(),
309        Duration::from_secs(args.poll_interval),
310    );
311
312    // ── metrics MCP poll (trusty-analyze) ───────────────────────────────────
313    // The analyze handle is stored in AppState so on-demand routes
314    // (/api/console/metrics/analyze/indexes, /api/console/metrics/analyze/visualize)
315    // share the same child process. Here we hand a clone of that Arc to the
316    // background metrics poller so both paths reuse one stdio connection.
317    //
318    // Why "mcp" not "serve --mcp":
319    // `serve --mcp` starts BOTH the HTTP daemon and an MCP stdio loop; it
320    // requires trusty-search to be reachable at startup and tries to open the
321    // redb facts store (which may already be locked by the running daemon).
322    // `mcp` only runs a pure stdio bridge pointing at the running HTTP daemon;
323    // if the HTTP daemon is not yet up, `ensure_mcp_daemon_up` in analyze's
324    // `mcp` subcommand starts it automatically. This is the correct invocation
325    // for a lightweight stdio-only console_metrics child.
326    metrics_poller::start(
327        state.analyze_handle(),
328        state.metrics_cache().clone(),
329        Duration::from_secs(args.poll_interval),
330    );
331
332    // ── metrics MCP poll (trusty-memory) ────────────────────────────────────
333    // trusty-memory's stdio MCP mode is `serve --stdio` (see main.rs).
334    // The bridge forwards all JSON-RPC calls to the running HTTP daemon and
335    // auto-starts it if absent. On machines without trusty-memory the handle
336    // marks it Absent immediately; the cache stays None;
337    // /api/console/metrics/memory returns 503 (graceful degradation).
338    //
339    // The handle comes from AppState::mcp_handles so the services route and
340    // the metrics poller share the same McpServiceHandle (and thus the same
341    // tools/list probe result — once the probe marks the handle Degraded,
342    // that state is visible to both paths without a second probe).
343    {
344        let handles = state.mcp_handles();
345        if let Some(h) = handles.get("trusty-memory") {
346            metrics_poller::start(
347                Arc::clone(h),
348                state.memory_metrics_cache().clone(),
349                Duration::from_secs(args.poll_interval),
350            );
351        } else {
352            tracing::warn!(
353                service = "trusty-memory",
354                "run_serve: no MCP handle registered for trusty-memory — \
355                 metrics poller will not start for this service"
356            );
357        }
358    }
359
360    // ── metrics MCP poll (trusty-search) ────────────────────────────────────
361    // trusty-search's stdio MCP mode is `serve` (see serve_stdio in main.rs).
362    // On machines without trusty-search the handle marks it Absent immediately;
363    // the cache stays None; /api/console/metrics/search returns 503.
364    //
365    // Same shared-handle pattern as trusty-memory above.
366    {
367        let handles = state.mcp_handles();
368        if let Some(h) = handles.get("trusty-search") {
369            metrics_poller::start(
370                Arc::clone(h),
371                state.search_metrics_cache().clone(),
372                Duration::from_secs(args.poll_interval),
373            );
374        } else {
375            tracing::warn!(
376                service = "trusty-search",
377                "run_serve: no MCP handle registered for trusty-search — \
378                 metrics poller will not start for this service"
379            );
380        }
381    }
382
383    // ── metrics MCP poll (trusty-review) ────────────────────────────────────
384    // trusty-review's stdio MCP mode is `serve --stdio` (see commands/serve.rs).
385    // When in stdio mode, trusty-review does NOT start an HTTP daemon — it runs
386    // a pure MCP JSON-RPC loop over stdin/stdout, connected to the LLM directly.
387    // This is the correct invocation for the console's lightweight metrics poll.
388    // On machines without trusty-review the handle marks it Absent immediately;
389    // the cache stays None; /api/console/metrics/review returns 503.
390    //
391    // Same shared-handle pattern as trusty-memory and trusty-search above.
392    {
393        let handles = state.mcp_handles();
394        if let Some(h) = handles.get("trusty-review") {
395            metrics_poller::start(
396                Arc::clone(h),
397                state.review_metrics_cache().clone(),
398                Duration::from_secs(args.poll_interval),
399            );
400        } else {
401            tracing::warn!(
402                service = "trusty-review",
403                "run_serve: no MCP handle registered for trusty-review — \
404                 metrics poller will not start for this service"
405            );
406        }
407    }
408
409    // ── metrics MCP poll (trusty-mpm) ───────────────────────────────────────
410    // trusty-mpm's stdio MCP mode is `serve --stdio` (the #1221 bridge that
411    // auto-starts the durable daemon and forwards JSON-RPC to its loopback
412    // POST /rpc). The console_metrics poll keeps the coarse session-fleet +
413    // supervisor health cache warm for /api/console/metrics/mpm; the Sessions
414    // tab itself polls /api/console/sessions live at a faster cadence (#1222).
415    // On machines without trusty-mpm the handle marks it Absent immediately; the
416    // cache stays None; /api/console/metrics/mpm returns 503 (graceful).
417    {
418        let handles = state.mcp_handles();
419        if let Some(h) = handles.get("trusty-mpm") {
420            metrics_poller::start(
421                Arc::clone(h),
422                state.mpm_metrics_cache().clone(),
423                Duration::from_secs(args.poll_interval),
424            );
425        } else {
426            tracing::warn!(
427                service = "trusty-mpm",
428                "run_serve: no MCP handle registered for trusty-mpm — \
429                 metrics poller will not start for this service"
430            );
431        }
432    }
433
434    // ── whole-machine host-metrics sampler (#6517) ──────────────────────────
435    // Runs in the background and keeps `host_metrics_cache` warm so
436    // GET /api/console/machine-status serves the host snapshot without a
437    // per-request sysinfo refresh. Sampled on the same interval as the service
438    // pollers.
439    host_status::start(
440        state.host_metrics_cache().clone(),
441        Duration::from_secs(args.poll_interval),
442    );
443
444    // #3269: trust the console's own non-loopback bind address(es) (e.g. the
445    // Tailscale CGNAT address in `--tailscale` mode) as write-origin
446    // self-origins, so the console's own write UI served from that address is
447    // not 403'd by the same-origin guard. Loopback stays trusted unconditionally
448    // regardless of bind mode.
449    let self_origins = routes::origin_guard::SelfOrigins::from_bind_addrs(&addrs);
450
451    // ── webhook ingress (#5089 step 3, ADR-0034) ────────────────────────────
452    // `?` on purpose: a console that cannot open its spool must not start and
453    // serve `/api/webhooks/{source}` anyway, because a delivery it cannot
454    // durably record is a delivery it must refuse — and an unmounted route
455    // would 404 instead of 5xx, which GitHub logs and no one reads.
456    let ingress = webhook::WebhookIngress::from_env()
457        .context("open the webhook spool under the console data directory")?;
458    info!(
459        spool = %ingress.spool().root().display(),
460        "webhook ingress ready at POST /api/webhooks/{{source}}"
461    );
462    webhook::start_retry_sweep(ingress.clone(), WEBHOOK_RETRY_INTERVAL);
463    let router = server::build_router_with_webhooks(state.clone(), self_origins, ingress);
464
465    // ── bind primary listener ───────────────────────────────────────────────
466    let primary_addr = *addrs.first().context("bind address list is empty")?;
467    let primary_listener = bind::bind_listener(primary_addr).await?;
468    let primary_local = primary_listener.local_addr().context("get local addr")?;
469    let addr_string = primary_local.to_string();
470    info!("trusty-console listening on http://{primary_local}");
471
472    // ── bind additional listeners (Tailscale mode: secondary addr) ──────────
473    for &extra_addr in addrs.get(1..).unwrap_or(&[]) {
474        let extra_listener = bind::bind_listener(extra_addr).await?;
475        let extra_local = extra_listener
476            .local_addr()
477            .context("get extra local addr")?;
478        info!("trusty-console also listening on http://{extra_local}");
479        eprintln!("trusty-console (tailnet): http://{extra_local}");
480        let r = router.clone();
481        tokio::spawn(async move {
482            if let Err(e) = axum::serve(extra_listener, r)
483                .with_graceful_shutdown(trusty_common::shutdown_signal())
484                .await
485            {
486                tracing::warn!("extra listener {extra_local} exited: {e}");
487            }
488        });
489    }
490
491    // ── write discovery file (primary address) ──────────────────────────────
492    // Best-effort: log a warning on failure but do not abort the serve.
493    if let Err(e) = write_daemon_addr("trusty-console", &addr_string) {
494        tracing::warn!("could not write trusty-console discovery file: {e}");
495    }
496
497    let console_url = format!("http://{primary_local}");
498    eprintln!("trusty-console: {console_url}");
499
500    if args.open {
501        // Best-effort browser open; ignore errors.
502        let _ = open::that(&console_url);
503    }
504
505    axum::serve(primary_listener, router)
506        .with_graceful_shutdown(shutdown_signal())
507        .await
508        .context("server error")?;
509
510    // Best-effort removal of the discovery file on clean shutdown.
511    // Only remove the file if it still points to our address; another
512    // instance may have already written a new one.
513    //
514    // RESIDUAL RACE: the read → compare → delete sequence is not atomic. A
515    // second instance could write a new address between our read and our
516    // remove_file, causing us to delete a file we should not. The window is
517    // tiny (milliseconds) and the consequence is cosmetic (a stale `port`
518    // invocation returns the default rather than the live address). No
519    // behavior change is required — this comment documents the known race.
520    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
521        && recorded == addr_string
522        && let Ok(dir) = trusty_common::resolve_data_dir("trusty-console")
523    {
524        let _ = std::fs::remove_file(dir.join("http_addr"));
525    }
526
527    Ok(())
528}
529
530// ─── tests ───────────────────────────────────────────────────────────────────
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    /// Serialises tests that mutate the `TRUSTY_DATA_DIR_OVERRIDE` env var.
537    ///
538    /// Why: `std::env::set_var`/`remove_var` are process-global; parallel test
539    /// threads racing on them cause flaky failures. A module-level mutex makes
540    /// the override-set / call / override-clear sequence atomic per test.
541    /// What: a `()` mutex acquired at the top of each env-mutating test.
542    /// Test: used by the `port` verb tests below.
543    static DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
544
545    /// Why: default http address must be 127.0.0.1:7788 and tailscale off.
546    /// What: parses `serve` with no flags and checks all defaults.
547    /// Test: this test itself.
548    #[test]
549    fn test_serve_args_defaults() {
550        let cli = Cli::parse_from(["trusty-console", "serve"]);
551        match cli.command {
552            Commands::Serve(args) => {
553                assert_eq!(args.http, "127.0.0.1:7788");
554                assert!(!args.open);
555                assert!(!args.tailscale);
556                assert_eq!(args.poll_interval, 15);
557            }
558            other => panic!("expected Serve, got {other:?}"),
559        }
560    }
561
562    /// Why: --tailscale flag must be parsed correctly.
563    /// What: parses `serve --tailscale`; asserts tailscale=true.
564    /// Test: this test itself.
565    #[test]
566    fn test_serve_args_tailscale_flag() {
567        let cli = Cli::parse_from(["trusty-console", "serve", "--tailscale"]);
568        match cli.command {
569            Commands::Serve(args) => {
570                assert!(args.tailscale);
571                assert_eq!(args.http, "127.0.0.1:7788");
572            }
573            other => panic!("expected Serve, got {other:?}"),
574        }
575    }
576
577    /// Why: custom --http flag must override the default.
578    /// What: parses `serve --http 0.0.0.0:9000`.
579    /// Test: this test itself.
580    #[test]
581    fn test_serve_args_custom_http() {
582        let cli = Cli::parse_from(["trusty-console", "serve", "--http", "0.0.0.0:9000"]);
583        match cli.command {
584            Commands::Serve(args) => {
585                assert_eq!(args.http, "0.0.0.0:9000");
586            }
587            other => panic!("expected Serve, got {other:?}"),
588        }
589    }
590
591    /// Why: --poll-interval must override the default.
592    /// What: parses `serve --poll-interval 30`.
593    /// Test: this test itself.
594    #[test]
595    fn test_serve_args_custom_poll_interval() {
596        let cli = Cli::parse_from(["trusty-console", "serve", "--poll-interval", "30"]);
597        match cli.command {
598            Commands::Serve(args) => {
599                assert_eq!(args.poll_interval, 30);
600            }
601            other => panic!("expected Serve, got {other:?}"),
602        }
603    }
604
605    /// Why: the `port` subcommand must parse with a default (non-JSON) form so
606    /// the bare-port output path is reachable.
607    /// What: parses `port` and asserts `--json` defaults to false.
608    /// Test: this test itself.
609    #[test]
610    fn test_port_args_default() {
611        let cli = Cli::parse_from(["trusty-console", "port"]);
612        match cli.command {
613            Commands::Port(args) => assert!(!args.json),
614            other => panic!("expected Port, got {other:?}"),
615        }
616    }
617
618    /// Why: `trusty-installer` (`tctl`) invokes `trusty-console port --json`; the flag must parse.
619    /// What: parses `port --json` and asserts `json == true`.
620    /// Test: this test itself.
621    #[test]
622    fn test_port_args_json_flag() {
623        let cli = Cli::parse_from(["trusty-console", "port", "--json"]);
624        match cli.command {
625            Commands::Port(args) => assert!(args.json),
626            other => panic!("expected Port, got {other:?}"),
627        }
628    }
629
630    /// Why: when no console has written a discovery file, the reported port
631    /// must fall back to the canonical default so `trusty-installer` (`tctl`)
632    /// still gets a usable address.
633    /// What: calls `resolve_reported_addr` under an isolated data dir (no
634    /// discovery file present) and asserts the default host/port.
635    /// Test: this test itself; uses the data-dir override env to avoid reading
636    /// a real running console's file.
637    #[test]
638    fn test_resolve_reported_addr_default() {
639        let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
640        let tmp = std::env::temp_dir().join(format!(
641            "trusty-console-port-test-{}-{}",
642            std::process::id(),
643            std::time::SystemTime::now()
644                .duration_since(std::time::UNIX_EPOCH)
645                .map(|d| d.as_nanos())
646                .unwrap_or(0)
647        ));
648        std::fs::create_dir_all(&tmp).expect("create temp data dir");
649        // SAFETY: guarded by data_dir_test_lock to serialise env mutation.
650        unsafe {
651            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, &tmp);
652        }
653        let (addr, port) = resolve_reported_addr();
654        unsafe {
655            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
656        }
657        assert_eq!(addr, "127.0.0.1");
658        assert_eq!(port, DEFAULT_PORT);
659    }
660
661    /// Why: the JSON envelope emitted by `run_port` must be valid JSON with the
662    /// `addr` and `port` keys that `trusty-installer` (`tctl`) `parse_console_port` consumes.
663    /// What: builds the same envelope `run_port` prints and round-trips it
664    /// through serde to assert structure.
665    /// Test: this test itself.
666    #[test]
667    fn test_port_envelope_is_valid_json() {
668        let envelope = serde_json::json!({ "addr": "127.0.0.1", "port": DEFAULT_PORT });
669        let s = envelope.to_string();
670        let v: serde_json::Value = serde_json::from_str(&s).expect("valid json");
671        assert_eq!(v.get("addr").and_then(|a| a.as_str()), Some("127.0.0.1"));
672        assert_eq!(
673            v.get("port").and_then(|p| p.as_u64()),
674            Some(DEFAULT_PORT as u64)
675        );
676    }
677
678    /// Why: the #1318 decoupling requires that an explicit argv parses to the
679    /// `Port` command and that the `port` handler runs without touching the
680    /// process's global argv. This exercises that parse → dispatch path.
681    /// What: parses `["trusty-console","port","--json"]` via `Cli::parse_from`
682    /// (the same call `run_from` makes) and runs `run_port` synchronously under
683    /// an isolated data dir; asserts the dispatch matches `Port` and the
684    /// handler returns Ok. Kept synchronous so the env-override mutex is never
685    /// held across an `await` (clippy::await_holding_lock).
686    /// Test: this test itself.
687    #[test]
688    fn test_run_from_port_json_outputs_envelope() {
689        let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
690        let tmp = std::env::temp_dir().join(format!(
691            "trusty-console-runfrom-test-{}-{}",
692            std::process::id(),
693            std::time::SystemTime::now()
694                .duration_since(std::time::UNIX_EPOCH)
695                .map(|d| d.as_nanos())
696                .unwrap_or(0)
697        ));
698        std::fs::create_dir_all(&tmp).expect("create temp data dir");
699        // SAFETY: guarded by DATA_DIR_ENV_LOCK to serialise env mutation.
700        unsafe {
701            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, &tmp);
702        }
703        let argv = [
704            "trusty-console".to_owned(),
705            "port".to_owned(),
706            "--json".to_owned(),
707        ];
708        let cli = Cli::parse_from(argv);
709        let result = match cli.command {
710            Commands::Port(args) => {
711                assert!(args.json, "argv --json should parse to json=true");
712                run_port(args)
713            }
714            other => panic!("expected Port, got {other:?}"),
715        };
716        unsafe {
717            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
718        }
719        assert!(result.is_ok(), "run_port(port --json) should succeed");
720    }
721}