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// #6848: the console-hosted event bus core — UDS ingest, the bounded ring,
54// dedup and eviction counters, and the subscriber fan-out a later slice
55// (#6851) wires into an SSE route. No route surface in this crate yet.
56pub(crate) mod event_bus;
57// #6517: background whole-machine host-metrics sampler + cache feeding the
58// machine-status route.
59pub mod host_status;
60// #6641: the bounded 10-minute sample window, the per-service transition log,
61// and the SSE fan-out the Phase 3 graphs read.
62pub mod machine_history;
63pub mod mcp_handle;
64pub mod metrics_poller;
65pub mod poller;
66pub mod proxy;
67pub mod routes;
68// #6285: the console's client to trusty-search, which no longer serves HTTP.
69// #6155: the trusty-memory and trusty-analyze bridges, siblings of
70// `search_uds`. Each maps one dashboard's REST-shaped paths onto that daemon's
71// own `<domain>.*` methods.
72pub mod analyze_uds;
73pub mod memory_uds;
74pub mod search_uds;
75pub mod server;
76pub mod service;
77// #6642: per-service pid discovery + CPU sampling for the home-page graphs.
78pub mod service_metrics;
79// #9035: peer-identity gate for the `--tailscale` listener.
80pub(crate) mod tailnet_peer;
81// #6155: the trusty-search, trusty-memory and trusty-analyze SPAs, mounted
82// under /tools/<tool>/.
83pub mod tools_ui;
84pub mod webhook;
85
86/// How often the background sweep re-attempts pending webhook deliveries.
87///
88/// The sweep is the *recovery* mechanism, not the detection one — a stuck
89/// delivery is detected by `GET /api/console/metrics/webhooks`, which scans the
90/// spool on the request and therefore stays honest even if this loop dies.
91const WEBHOOK_RETRY_INTERVAL: Duration = Duration::from_secs(60);
92pub(crate) mod url_util;
93
94// ─── CLI ─────────────────────────────────────────────────────────────────────
95
96/// trusty-console: web dashboard for trusty services.
97///
98/// Why: Provides a single entry point for all console subcommands so future
99/// phases (status, doctor, open) can be added without breaking existing usage.
100/// What: Parses top-level arguments and delegates to subcommand handlers.
101/// Test: `cargo run -p trusty-console -- serve --help` must succeed.
102#[derive(Debug, Parser)]
103#[command(
104    name = "trusty-console",
105    version,
106    about = "Web dashboard for trusty services"
107)]
108pub struct Cli {
109    #[command(subcommand)]
110    pub command: Commands,
111}
112
113/// Available subcommands.
114///
115/// Why: `serve` runs the dashboard; `port` is a non-serving contract verb that
116/// reports the console's bound (or default) port so orchestrators like
117/// `trusty-installer` (`tctl`) can discover/launch the console without parsing logs.
118/// What: Clap enum; each variant carries its own args.
119/// Test: Subcommand selection tested via `Cli::parse_from`.
120#[derive(Debug, Subcommand)]
121pub enum Commands {
122    /// Start the HTTP server and serve the console dashboard.
123    Serve(ServeArgs),
124    /// Report the console's bound (or default) HTTP port and exit.
125    Port(PortArgs),
126    /// Manage inference provider configuration (API keys) — the universal
127    /// `config keys set/list/test/unset` surface shared by every trusty-*
128    /// binary (epic #2400 Wave 1, #2405).
129    Config(trusty_common::inference::config::ConfigCommand),
130    /// Manage the macOS launchd LaunchAgent for the console daemon (#2557).
131    ///
132    /// `install` writes `~/Library/LaunchAgents/com.trusty.console.plist` (#8253)
133    /// (running `trusty-console serve`) and bootstraps it; `uninstall` unloads
134    /// and removes it; `status` / `logs` inspect the running agent. macOS-only.
135    /// `tctl install` / `tctl start` call `install` on the operator's behalf.
136    Service {
137        #[command(subcommand)]
138        action: service::ServiceAction,
139    },
140}
141
142/// Arguments for `trusty-console port`.
143///
144/// Why: `trusty-installer` (`tctl`) (the orchestrator, issue #1316) discovers
145/// the console URL by spawning `trusty-console port --json` and parsing the
146/// `{addr,port}` envelope (see trusty-installer `os_env.rs`). The verb must
147/// exist and be machine-readable for that discovery to work after the console is
148/// de-bundled from the host crates (#1318).
149/// What: A single `--json` flag selecting JSON output (default is a single
150/// human-readable port line).
151/// Test: `test_port_args_json_flag` / `test_port_args_default` below.
152#[derive(Debug, Parser)]
153pub struct PortArgs {
154    /// Emit a JSON envelope `{"addr":"<host>","port":<u16>}` instead of a bare
155    /// port number. Consumed by `trusty-installer` (`tctl`) console discovery.
156    #[arg(long, default_value_t = false)]
157    pub json: bool,
158}
159
160/// Arguments for `trusty-console serve`.
161///
162/// Why: The bind address must be configurable so users can change the port when
163/// 7788 is taken; `--open` is a convenience for developers; `--poll-interval`
164/// lets operators tune the background health-poll frequency; `--tailscale`
165/// enables durable tailnet exposure without requiring `--http 0.0.0.0`.
166/// What: Optional `--http` (default `127.0.0.1:7788`), `--open`,
167/// `--poll-interval`, and `--tailscale` flags.
168/// Env overrides: `TRUSTY_CONSOLE_BIND` sets the default bind mode so a
169/// supervised/relaunched daemon stays tailnet-reachable without extra flags.
170/// Test: Default address tested in `test_serve_args_defaults` below.
171#[derive(Debug, Parser)]
172pub struct ServeArgs {
173    /// Address to listen on (default: 127.0.0.1:7788).
174    ///
175    /// Takes precedence over --tailscale and TRUSTY_CONSOLE_BIND when set to a
176    /// non-default value.
177    #[arg(long, default_value = "127.0.0.1:7788")]
178    pub http: String,
179
180    /// Expose the console on both 127.0.0.1 and the machine's Tailscale IPv4,
181    /// enabling tailnet clients to reach the console without LAN exposure.
182    ///
183    /// The Tailscale IP is detected via `tailscale ip -4`. If Tailscale is not
184    /// running, prints a warning and falls back to localhost-only.
185    ///
186    /// Can also be set persistently via the TRUSTY_CONSOLE_BIND=tailscale env
187    /// var so a supervised/relaunched console stays tailnet-reachable without
188    /// manually passing this flag.
189    #[arg(long, default_value_t = false)]
190    pub tailscale: bool,
191
192    /// Open the console in the default browser after starting.
193    #[arg(long, default_value_t = false)]
194    pub open: bool,
195
196    /// Background poll interval in seconds (default: 15).
197    ///
198    /// Controls BOTH the health poller (`poller::start`) AND the metrics
199    /// poller (`metrics_poller::start`). Increasing this value reduces the
200    /// frequency of both the HTTP health checks against each connector AND
201    /// the stdio MCP `console_metrics` tool calls against trusty-analyze.
202    #[arg(long, default_value_t = 15u64)]
203    pub poll_interval: u64,
204
205    /// Host-metrics sampling interval in seconds (default: 1).
206    ///
207    /// #6641: the cadence of the real-time graphs, kept separate from
208    /// `--poll-interval` because they answer different questions. The service
209    /// pollers each spawn a stdio-MCP round trip, so 15 s is deliberate there;
210    /// a host sample is a local sysinfo refresh.
211    ///
212    /// #6642: the owner's home-page ruling is a bar per SECOND, so the default
213    /// is 1 s and the window is 600 points. Raising this stretches the span the
214    /// same 600 points cover, and the history payload advertises the value so
215    /// the graph's x-axis follows.
216    #[arg(
217        long,
218        default_value_t = trusty_common::host_metrics::history::HOST_SAMPLE_INTERVAL_SECS
219    )]
220    pub host_sample_interval: u64,
221
222    /// Disk-metrics refresh interval in seconds (default: 15).
223    ///
224    /// The disk half of a host sample is the expensive half — on macOS
225    /// `sysinfo` walks every mounted volume — so it runs on its own slower
226    /// clock while CPU, memory and network keep `--host-sample-interval`.
227    /// Free space changes on a human timescale; lower this only to watch a
228    /// disk fill in near real time, and expect the CPU cost back.
229    #[arg(
230        long,
231        default_value_t = trusty_common::host_metrics::history::DISK_SAMPLE_INTERVAL_SECS
232    )]
233    pub disk_sample_interval: u64,
234}
235
236// ─── public entry point ────────────────────────────────────────────────────
237
238/// Library entry point for the trusty-console daemon, using the process argv.
239///
240/// Why: The standalone `trusty-console` crate is now the SOLE producer of the
241/// `trusty-console` binary (#1318 — de-bundled from the 5 host crates). The
242/// thin `main.rs` calls this, which simply forwards the process's global argv
243/// to `run_from`.
244/// What: Collects `std::env::args()` and delegates to [`run_from`].
245/// Test: Indirectly via the `run_from` tests below and the binary smoke test.
246pub async fn run() -> Result<()> {
247    run_from(std::env::args().collect()).await
248}
249
250/// Library entry point parameterised on an explicit argv vector.
251///
252/// Why: Decoupling argument parsing from the process's global argv (#1318)
253/// lets callers (tests, future embedders) drive the console deterministically
254/// without mutating `std::env`. Previously `run()` called `Cli::parse()`,
255/// which read global argv and could not be exercised in isolation.
256/// What: Initialises tracing, parses `argv` via `Cli::parse_from`, and
257/// dispatches to the matching subcommand handler. `argv[0]` is the program
258/// name (clap convention). Returns `Ok(())` after clean shutdown.
259/// Test: `test_run_from_port_json_outputs_envelope` drives this directly with
260/// a synthetic argv; integration via `cargo test -p trusty-console`.
261pub async fn run_from(argv: Vec<String>) -> Result<()> {
262    init_tracing(1);
263
264    let cli = Cli::parse_from(argv);
265
266    match cli.command {
267        Commands::Serve(args) => run_serve(args).await,
268        Commands::Port(args) => run_port(args),
269        Commands::Config(cmd) => cmd.run().await,
270        // `service` drives macOS launchd synchronously; no async work needed.
271        Commands::Service { action } => service::run_service_action(&action),
272    }
273}
274
275/// Resolve the console's reportable HTTP address (host, port).
276///
277/// Why: The `port` verb must report the LIVE port of a running console when
278/// one exists, falling back to the default otherwise — so `trusty-installer`
279/// (`tctl`) discovery (issue #1316) points at the real dashboard, not a guess.
280/// What: Reads the `trusty-console` discovery file via
281/// `trusty_common::read_daemon_addr`; on a parseable `host:port` returns that
282/// pair, else falls back to ([`DEFAULT_HTTP`] host, [`DEFAULT_PORT`]). Never
283/// errors — discovery failures degrade to the default.
284/// Test: `test_resolve_reported_addr_default` (no file → default).
285pub fn resolve_reported_addr() -> (String, u16) {
286    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
287        && let Ok(sa) = recorded.parse::<std::net::SocketAddr>()
288    {
289        return (sa.ip().to_string(), sa.port());
290    }
291    let default_host = DEFAULT_HTTP
292        .rsplit_once(':')
293        .map(|(h, _)| h.to_owned())
294        .unwrap_or_else(|| "127.0.0.1".to_owned());
295    (default_host, DEFAULT_PORT)
296}
297
298/// Run the `port` subcommand: print the console's bound/default port and exit.
299///
300/// Why: `trusty-installer` (`tctl`) console discovery spawns
301/// `trusty-console port --json` and parses a `{addr,port}` envelope
302/// (trusty-installer `os_env.rs`). This verb is the contract that makes that
303/// discovery work; without it the call exits non-zero and console discovery is
304/// silently broken (the latent bug fixed by #1318).
305/// What: Resolves the reportable address; with `--json` prints
306/// `{"addr":"<host>","port":<u16>}` to stdout, otherwise prints the bare port.
307/// Returns `Ok(())`.
308/// Test: `test_run_from_port_json_outputs_envelope`,
309/// `test_port_envelope_is_valid_json`.
310pub fn run_port(args: PortArgs) -> Result<()> {
311    let (addr, port) = resolve_reported_addr();
312    if args.json {
313        let envelope = serde_json::json!({ "addr": addr, "port": port });
314        println!("{envelope}");
315    } else {
316        println!("{port}");
317    }
318    Ok(())
319}
320
321/// Run the `serve` subcommand.
322///
323/// Why: Separating the serve logic from `run()` keeps `run()` thin and allows
324/// this function to be called from integration tests.
325/// What: Resolves bind addresses (respecting `--tailscale`, `--http`, and
326/// `TRUSTY_CONSOLE_BIND`), builds the router, binds TCP listener(s), writes
327/// the discovery file, starts the background health-poll task, optionally opens
328/// a browser, then serves until SIGTERM/SIGINT with graceful shutdown.
329/// Additional addresses beyond the primary get their own spawned `axum::serve`
330/// task that runs concurrently until the shared shutdown signal fires.
331/// Test: Server integration tests in `server.rs` cover the router directly
332/// without exercising this function (to avoid real TCP binding in unit tests).
333pub async fn run_serve(args: ServeArgs) -> Result<()> {
334    // ── resolve bind mode ───────────────────────────────────────────────────
335    let mode = bind::BindMode::from_env_and_flags(&args.http, DEFAULT_HTTP, args.tailscale);
336    let port = bind::port_from_addr(&args.http, DEFAULT_PORT);
337    let addrs = bind::resolve_bind_addrs(&mode, port, bind::detect_tailscale_ipv4);
338
339    // ── service setup ───────────────────────────────────────────────────────
340    let connectors = detect::all_connectors();
341    let state = server::AppState::new(connectors);
342
343    // Kick off an eager first poll so the cache is warm before the first
344    // HTTP request arrives.
345    {
346        let cache = state.poller_cache().clone();
347        let c = state.connectors();
348        cache.poll_once(c).await;
349    }
350
351    // Start the background poller that refreshes the cache on the configured
352    // interval.
353    poller::start(
354        state.poller_cache().clone(),
355        state.connectors(),
356        Duration::from_secs(args.poll_interval),
357    );
358
359    // ── metrics MCP poll (trusty-analyze) ───────────────────────────────────
360    // The analyze handle is stored in AppState so on-demand routes
361    // (/api/console/metrics/analyze/indexes, /api/console/metrics/analyze/visualize)
362    // share the same child process. Here we hand a clone of that Arc to the
363    // background metrics poller so both paths reuse one stdio connection.
364    //
365    // Why "mcp" not "serve --mcp":
366    // `serve --mcp` starts BOTH the HTTP daemon and an MCP stdio loop; it
367    // requires trusty-search to be reachable at startup and tries to open the
368    // redb facts store (which may already be locked by the running daemon).
369    // `mcp` only runs a pure stdio bridge pointing at the running HTTP daemon;
370    // if the HTTP daemon is not yet up, `ensure_mcp_daemon_up` in analyze's
371    // `mcp` subcommand starts it automatically. This is the correct invocation
372    // for a lightweight stdio-only console_metrics child.
373    metrics_poller::start(
374        state.analyze_handle(),
375        state.metrics_cache().clone(),
376        Duration::from_secs(args.poll_interval),
377    );
378
379    // ── metrics MCP poll (trusty-memory) ────────────────────────────────────
380    // trusty-memory's stdio MCP mode is `serve --stdio` (see main.rs).
381    // The bridge forwards all JSON-RPC calls to the running HTTP daemon and
382    // auto-starts it if absent. On machines without trusty-memory the handle
383    // marks it Absent immediately; the cache stays None;
384    // /api/console/metrics/memory returns 503 (graceful degradation).
385    //
386    // The handle comes from AppState::mcp_handles so the services route and
387    // the metrics poller share the same McpServiceHandle (and thus the same
388    // tools/list probe result — once the probe marks the handle Degraded,
389    // that state is visible to both paths without a second probe).
390    {
391        let handles = state.mcp_handles();
392        if let Some(h) = handles.get("trusty-memory") {
393            metrics_poller::start(
394                Arc::clone(h),
395                state.memory_metrics_cache().clone(),
396                Duration::from_secs(args.poll_interval),
397            );
398        } else {
399            tracing::warn!(
400                service = "trusty-memory",
401                "run_serve: no MCP handle registered for trusty-memory — \
402                 metrics poller will not start for this service"
403            );
404        }
405    }
406
407    // ── metrics MCP poll (trusty-search) ────────────────────────────────────
408    // trusty-search's stdio MCP mode is `serve` (see serve_stdio in main.rs).
409    // On machines without trusty-search the handle marks it Absent immediately;
410    // the cache stays None; /api/console/metrics/search returns 503.
411    //
412    // Same shared-handle pattern as trusty-memory above.
413    {
414        let handles = state.mcp_handles();
415        if let Some(h) = handles.get("trusty-search") {
416            metrics_poller::start(
417                Arc::clone(h),
418                state.search_metrics_cache().clone(),
419                Duration::from_secs(args.poll_interval),
420            );
421        } else {
422            tracing::warn!(
423                service = "trusty-search",
424                "run_serve: no MCP handle registered for trusty-search — \
425                 metrics poller will not start for this service"
426            );
427        }
428    }
429
430    // ── metrics MCP poll (trusty-review) ────────────────────────────────────
431    // trusty-review's stdio MCP mode is `serve --stdio` (see commands/serve.rs).
432    // When in stdio mode, trusty-review does NOT start an HTTP daemon — it runs
433    // a pure MCP JSON-RPC loop over stdin/stdout, connected to the LLM directly.
434    // This is the correct invocation for the console's lightweight metrics poll.
435    // On machines without trusty-review the handle marks it Absent immediately;
436    // the cache stays None; /api/console/metrics/review returns 503.
437    //
438    // Same shared-handle pattern as trusty-memory and trusty-search above.
439    {
440        let handles = state.mcp_handles();
441        if let Some(h) = handles.get("trusty-review") {
442            metrics_poller::start(
443                Arc::clone(h),
444                state.review_metrics_cache().clone(),
445                Duration::from_secs(args.poll_interval),
446            );
447        } else {
448            tracing::warn!(
449                service = "trusty-review",
450                "run_serve: no MCP handle registered for trusty-review — \
451                 metrics poller will not start for this service"
452            );
453        }
454    }
455
456    // ── metrics MCP poll (trusty-mpm) ───────────────────────────────────────
457    // trusty-mpm's stdio MCP mode is `serve --stdio` (the #1221 bridge that
458    // auto-starts the durable daemon and forwards JSON-RPC to its loopback
459    // POST /rpc). The console_metrics poll keeps the coarse session-fleet +
460    // supervisor health cache warm for /api/console/metrics/mpm; the Sessions
461    // tab itself polls /api/console/sessions live at a faster cadence (#1222).
462    // On machines without trusty-mpm the handle marks it Absent immediately; the
463    // cache stays None; /api/console/metrics/mpm returns 503 (graceful).
464    {
465        let handles = state.mcp_handles();
466        if let Some(h) = handles.get("trusty-mpm") {
467            metrics_poller::start(
468                Arc::clone(h),
469                state.mpm_metrics_cache().clone(),
470                Duration::from_secs(args.poll_interval),
471            );
472        } else {
473            tracing::warn!(
474                service = "trusty-mpm",
475                "run_serve: no MCP handle registered for trusty-mpm — \
476                 metrics poller will not start for this service"
477            );
478        }
479    }
480
481    // ── whole-machine host sampler (#6517) + history feed (#6641) ───────────
482    // One loop writes both the point-in-time cache
483    // (GET /api/console/machine-status) and the bounded 10-minute window
484    // (GET /api/console/machine-status/history and its SSE stream), so the two
485    // can never disagree about the newest sample. It runs on its own 1 s
486    // cadence rather than the service pollers' 15 s: a host sample is a local
487    // sysinfo refresh, not an MCP round trip. #6642: the same loop also records
488    // one per-service status + CPU sample per tick.
489    machine_history::sampler::start_with_disk_interval(
490        state.clone(),
491        Duration::from_secs(args.host_sample_interval),
492        Duration::from_secs(args.disk_sample_interval),
493    );
494
495    // #3269: trust the console's own non-loopback bind address(es) (e.g. the
496    // Tailscale CGNAT address in `--tailscale` mode) as write-origin
497    // self-origins, so the console's own write UI served from that address is
498    // not 403'd by the same-origin guard. Loopback stays trusted unconditionally
499    // regardless of bind mode.
500    let self_origins = routes::origin_guard::SelfOrigins::from_bind_addrs(&addrs);
501
502    // ── webhook ingress (#5089 step 3, ADR-0034) ────────────────────────────
503    // `?` on purpose: a console that cannot open its spool must not start and
504    // serve `/api/webhooks/{source}` anyway, because a delivery it cannot
505    // durably record is a delivery it must refuse — and an unmounted route
506    // would 404 instead of 5xx, which GitHub logs and no one reads.
507    let ingress = webhook::WebhookIngress::from_env()
508        .context("open the webhook spool under the console data directory")?;
509    info!(
510        spool = %ingress.spool().root().display(),
511        "webhook ingress ready at POST /api/webhooks/{{source}}"
512    );
513    webhook::start_retry_sweep(ingress.clone(), WEBHOOK_RETRY_INTERVAL);
514    let router = server::build_router_with_webhooks(state.clone(), self_origins, ingress);
515
516    // ── event-bus ingest (#6848, DOC-73 §4.1/§4.2) ──────────────────────────
517    // Console hosts the one event bus in the workspace; `trusty-mpm`,
518    // `trusty-code`, `trusty-agents` and `trusty-analyze` push `HarnessEvent`
519    // frames here over a UDS socket. Best-effort, unlike the webhook ingress
520    // above: no route in this crate reads the bus yet (that is #6850/#6851),
521    // and DOC-73 §4.1's "non-blocking invariant" makes the bus's own
522    // availability an observability concern, never one console's HTTP surface
523    // depends on — a console that could not bind this socket still serves
524    // everything else.
525    match event_bus::ingest_socket_path() {
526        Ok(socket) => match event_bus::bind_ingest(&socket).await {
527            Ok(listener) => {
528                info!(
529                    socket = %socket.display(),
530                    "console event-bus ingest ready"
531                );
532                // #6848 slice 3b: open the durable NDJSON log before the bus
533                // itself, so seq numbering resumes from the log's recovered
534                // high-water mark rather than restarting at 1 on every
535                // console restart (DOC-73 §4.3). Failing to open it degrades
536                // to `EventBus::new` (no durability, seq restarts at 1) per
537                // the same non-blocking invariant the ingest-bind failure
538                // above already follows — a console that cannot open its
539                // event log still serves everything else.
540                let bus = match event_bus::LogConfig::resolve_default() {
541                    Ok(log_config) => match event_bus::DurableLog::open(log_config).await {
542                        Ok((log, recovered)) => {
543                            info!(
544                                next_seq = recovered.next_seq,
545                                "console event-bus durable log ready"
546                            );
547                            event_bus::EventBus::with_log(
548                                event_bus::EventBusConfig::default(),
549                                Some(log),
550                                recovered.next_seq,
551                            )
552                        }
553                        Err(e) => {
554                            tracing::warn!(
555                                error = %e,
556                                "could not open the console event-bus durable log; \
557                                 events this run will not survive a restart"
558                            );
559                            event_bus::EventBus::new(event_bus::EventBusConfig::default())
560                        }
561                    },
562                    Err(e) => {
563                        tracing::warn!(
564                            error = %e,
565                            "could not resolve the console event-bus durable log directory; \
566                             events this run will not survive a restart"
567                        );
568                        event_bus::EventBus::new(event_bus::EventBusConfig::default())
569                    }
570                };
571                let bus = Arc::new(bus);
572                tokio::spawn(async move {
573                    event_bus::serve_ingest(listener, bus, shutdown_signal()).await;
574                });
575            }
576            Err(e) => {
577                tracing::warn!(
578                    error = %e,
579                    "could not bind the console event-bus ingest socket; \
580                     producers cannot push events this run"
581                );
582            }
583        },
584        Err(e) => {
585            tracing::warn!(error = %e, "could not resolve the console event-bus ingest socket path");
586        }
587    }
588
589    // ── bind primary listener ───────────────────────────────────────────────
590    let primary_addr = *addrs.first().context("bind address list is empty")?;
591    let primary_listener = bind::bind_listener(primary_addr).await?;
592    let primary_local = primary_listener.local_addr().context("get local addr")?;
593    let addr_string = primary_local.to_string();
594    info!("trusty-console listening on http://{primary_local}");
595
596    // ── bind additional listeners (Tailscale mode: secondary addr) ──────────
597    // #9035: each serves only nodes owned by this machine's own Tailscale
598    // login, addressed to itself by exact Host/Origin; loopback is not gated.
599    tailnet_peer::spawn_tailnet_listeners(
600        addrs.get(1..).unwrap_or(&[]),
601        &router,
602        Arc::new(tailnet_peer::TailscaleCliResolver),
603        shutdown_signal,
604    )
605    .await?;
606
607    // ── write discovery file (primary address) ──────────────────────────────
608    // Best-effort: log a warning on failure but do not abort the serve.
609    if let Err(e) = write_daemon_addr("trusty-console", &addr_string) {
610        tracing::warn!("could not write trusty-console discovery file: {e}");
611    }
612
613    let console_url = format!("http://{primary_local}");
614    eprintln!("trusty-console: {console_url}");
615
616    if args.open {
617        // Best-effort browser open; ignore errors.
618        let _ = open::that(&console_url);
619    }
620
621    axum::serve(primary_listener, router)
622        .with_graceful_shutdown(shutdown_signal())
623        .await
624        .context("server error")?;
625
626    // Best-effort removal of the discovery file on clean shutdown.
627    // Only remove the file if it still points to our address; another
628    // instance may have already written a new one.
629    //
630    // RESIDUAL RACE: the read → compare → delete sequence is not atomic. A
631    // second instance could write a new address between our read and our
632    // remove_file, causing us to delete a file we should not. The window is
633    // tiny (milliseconds) and the consequence is cosmetic (a stale `port`
634    // invocation returns the default rather than the live address). No
635    // behavior change is required — this comment documents the known race.
636    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
637        && recorded == addr_string
638        && let Ok(dir) = trusty_common::resolve_data_dir("trusty-console")
639    {
640        let _ = std::fs::remove_file(dir.join("http_addr"));
641    }
642
643    Ok(())
644}
645
646// ─── tests ───────────────────────────────────────────────────────────────────
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    // #6661: this module used to declare its own `TRUSTY_DATA_DIR_OVERRIDE`
653    // mutex. Two locks over one process-global variable serialise nothing
654    // between them, so a `remove_var` here landed inside the connector tests'
655    // critical section. `detect::ENV_LOCK` is the crate's single lock.
656    use crate::detect::ENV_LOCK as DATA_DIR_ENV_LOCK;
657
658    /// Why: default http address must be 127.0.0.1:7788 and tailscale off.
659    /// What: parses `serve` with no flags and checks all defaults.
660    /// Test: this test itself.
661    #[test]
662    fn test_serve_args_defaults() {
663        let cli = Cli::parse_from(["trusty-console", "serve"]);
664        match cli.command {
665            Commands::Serve(args) => {
666                assert_eq!(args.http, "127.0.0.1:7788");
667                assert!(!args.open);
668                assert!(!args.tailscale);
669                assert_eq!(args.poll_interval, 15);
670                // #6642: the owner's home-page cadence — 1 s, distinct from the
671                // 15 s service poll. Read from the constant so the default and
672                // the window ruling cannot drift apart.
673                assert_eq!(
674                    args.host_sample_interval,
675                    trusty_common::host_metrics::history::HOST_SAMPLE_INTERVAL_SECS
676                );
677                // The disk half keeps its own, slower default so the 1 s graph
678                // does not pay for a volume walk every tick.
679                assert_eq!(
680                    args.disk_sample_interval,
681                    trusty_common::host_metrics::history::DISK_SAMPLE_INTERVAL_SECS
682                );
683            }
684            other => panic!("expected Serve, got {other:?}"),
685        }
686    }
687
688    /// Why (#6641, #6642): the 1 s cadence is the shipped default, not a hard-coded
689    /// constant — an operator who wants a longer window must be able to say so,
690    /// and the two intervals must stay independent.
691    /// What: parses `serve --host-sample-interval 10` and asserts only that
692    /// value moved.
693    /// Test: this test itself.
694    #[test]
695    fn test_serve_args_custom_host_sample_interval() {
696        let cli = Cli::parse_from(["trusty-console", "serve", "--host-sample-interval", "10"]);
697        match cli.command {
698            Commands::Serve(args) => {
699                assert_eq!(args.host_sample_interval, 10);
700                assert_eq!(args.poll_interval, 15, "the service poll is unaffected");
701                assert_eq!(
702                    args.disk_sample_interval,
703                    trusty_common::host_metrics::history::DISK_SAMPLE_INTERVAL_SECS,
704                    "the disk cadence is unaffected"
705                );
706            }
707            other => panic!("expected Serve, got {other:?}"),
708        }
709    }
710
711    /// Why: --tailscale flag must be parsed correctly.
712    /// What: parses `serve --tailscale`; asserts tailscale=true.
713    /// Test: this test itself.
714    #[test]
715    fn test_serve_args_tailscale_flag() {
716        let cli = Cli::parse_from(["trusty-console", "serve", "--tailscale"]);
717        match cli.command {
718            Commands::Serve(args) => {
719                assert!(args.tailscale);
720                assert_eq!(args.http, "127.0.0.1:7788");
721            }
722            other => panic!("expected Serve, got {other:?}"),
723        }
724    }
725
726    /// Why: custom --http flag must override the default.
727    /// What: parses `serve --http 0.0.0.0:9000`.
728    /// Test: this test itself.
729    #[test]
730    fn test_serve_args_custom_http() {
731        let cli = Cli::parse_from(["trusty-console", "serve", "--http", "0.0.0.0:9000"]);
732        match cli.command {
733            Commands::Serve(args) => {
734                assert_eq!(args.http, "0.0.0.0:9000");
735            }
736            other => panic!("expected Serve, got {other:?}"),
737        }
738    }
739
740    /// Why: --poll-interval must override the default.
741    /// What: parses `serve --poll-interval 30`.
742    /// Test: this test itself.
743    #[test]
744    fn test_serve_args_custom_poll_interval() {
745        let cli = Cli::parse_from(["trusty-console", "serve", "--poll-interval", "30"]);
746        match cli.command {
747            Commands::Serve(args) => {
748                assert_eq!(args.poll_interval, 30);
749            }
750            other => panic!("expected Serve, got {other:?}"),
751        }
752    }
753
754    /// Why: the `port` subcommand must parse with a default (non-JSON) form so
755    /// the bare-port output path is reachable.
756    /// What: parses `port` and asserts `--json` defaults to false.
757    /// Test: this test itself.
758    #[test]
759    fn test_port_args_default() {
760        let cli = Cli::parse_from(["trusty-console", "port"]);
761        match cli.command {
762            Commands::Port(args) => assert!(!args.json),
763            other => panic!("expected Port, got {other:?}"),
764        }
765    }
766
767    /// Why: `trusty-installer` (`tctl`) invokes `trusty-console port --json`; the flag must parse.
768    /// What: parses `port --json` and asserts `json == true`.
769    /// Test: this test itself.
770    #[test]
771    fn test_port_args_json_flag() {
772        let cli = Cli::parse_from(["trusty-console", "port", "--json"]);
773        match cli.command {
774            Commands::Port(args) => assert!(args.json),
775            other => panic!("expected Port, got {other:?}"),
776        }
777    }
778
779    /// Why: when no console has written a discovery file, the reported port
780    /// must fall back to the canonical default so `trusty-installer` (`tctl`)
781    /// still gets a usable address.
782    /// What: calls `resolve_reported_addr` under an isolated data dir (no
783    /// discovery file present) and asserts the default host/port.
784    /// Test: this test itself; uses the data-dir override env to avoid reading
785    /// a real running console's file.
786    #[test]
787    fn test_resolve_reported_addr_default() {
788        let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
789        let tmp = std::env::temp_dir().join(format!(
790            "trusty-console-port-test-{}-{}",
791            std::process::id(),
792            std::time::SystemTime::now()
793                .duration_since(std::time::UNIX_EPOCH)
794                .map(|d| d.as_nanos())
795                .unwrap_or(0)
796        ));
797        std::fs::create_dir_all(&tmp).expect("create temp data dir");
798        // SAFETY: guarded by data_dir_test_lock to serialise env mutation.
799        unsafe {
800            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, &tmp);
801        }
802        let (addr, port) = resolve_reported_addr();
803        unsafe {
804            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
805        }
806        assert_eq!(addr, "127.0.0.1");
807        assert_eq!(port, DEFAULT_PORT);
808    }
809
810    /// Why: the JSON envelope emitted by `run_port` must be valid JSON with the
811    /// `addr` and `port` keys that `trusty-installer` (`tctl`) `parse_console_port` consumes.
812    /// What: builds the same envelope `run_port` prints and round-trips it
813    /// through serde to assert structure.
814    /// Test: this test itself.
815    #[test]
816    fn test_port_envelope_is_valid_json() {
817        let envelope = serde_json::json!({ "addr": "127.0.0.1", "port": DEFAULT_PORT });
818        let s = envelope.to_string();
819        let v: serde_json::Value = serde_json::from_str(&s).expect("valid json");
820        assert_eq!(v.get("addr").and_then(|a| a.as_str()), Some("127.0.0.1"));
821        assert_eq!(
822            v.get("port").and_then(|p| p.as_u64()),
823            Some(DEFAULT_PORT as u64)
824        );
825    }
826
827    /// Why: the #1318 decoupling requires that an explicit argv parses to the
828    /// `Port` command and that the `port` handler runs without touching the
829    /// process's global argv. This exercises that parse → dispatch path.
830    /// What: parses `["trusty-console","port","--json"]` via `Cli::parse_from`
831    /// (the same call `run_from` makes) and runs `run_port` synchronously under
832    /// an isolated data dir; asserts the dispatch matches `Port` and the
833    /// handler returns Ok. Kept synchronous so the env-override mutex is never
834    /// held across an `await` (clippy::await_holding_lock).
835    /// Test: this test itself.
836    #[test]
837    fn test_run_from_port_json_outputs_envelope() {
838        let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
839        let tmp = std::env::temp_dir().join(format!(
840            "trusty-console-runfrom-test-{}-{}",
841            std::process::id(),
842            std::time::SystemTime::now()
843                .duration_since(std::time::UNIX_EPOCH)
844                .map(|d| d.as_nanos())
845                .unwrap_or(0)
846        ));
847        std::fs::create_dir_all(&tmp).expect("create temp data dir");
848        // SAFETY: guarded by DATA_DIR_ENV_LOCK to serialise env mutation.
849        unsafe {
850            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, &tmp);
851        }
852        let argv = [
853            "trusty-console".to_owned(),
854            "port".to_owned(),
855            "--json".to_owned(),
856        ];
857        let cli = Cli::parse_from(argv);
858        let result = match cli.command {
859            Commands::Port(args) => {
860                assert!(args.json, "argv --json should parse to json=true");
861                run_port(args)
862            }
863            other => panic!("expected Port, got {other:?}"),
864        };
865        unsafe {
866            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
867        }
868        assert!(result.is_ok(), "run_port(port --json) should succeed");
869    }
870
871    /// Why: #8253 — the `service` clap doc comment (printed by `trusty-console
872    /// service --help`) named a plist launchd never loaded, so an operator
873    /// following it acted on a nonexistent unit.
874    /// What: requires lib.rs's `Service` help text to name
875    /// `<launchd_labels::CONSOLE>.plist` and never the pre-#4868 name.
876    /// Test: pure string check on the embedded source, no fs side effects.
877    #[test]
878    fn service_help_names_the_registry_plist() {
879        let src = include_str!("lib.rs");
880        let want = format!(
881            "~/Library/LaunchAgents/{}.plist",
882            trusty_common::launchd_labels::CONSOLE
883        );
884        // Built piecewise so this test's own text never matches itself.
885        let stale = format!("com.trusty.{}.plist", "trusty-console");
886        assert!(src.contains(&want), "`service --help` must name {want}");
887        assert!(
888            !src.contains(&stale),
889            "`service --help` names {stale}, a unit launchd never loaded"
890        );
891    }
892}