diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index d501dc9af..1e344bcb3 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -162,25 +162,26 @@ snapshot, only counts and nothing else. ## 2. First-run onboarding -Shown on first launch and from the Doctor row ("Run setup again"). One card per +Shown when fresh launch checks find unresolved setup, and from the Doctor row +("Run setup again"). No persisted first-run flag. One card per check, in order. Every check is read-only; every card offers **Check again**. ![Onboarding](diagrams/onboarding.svg) ([source](diagrams/onboarding.mmd)) | Step | Read-only check | Fix | Admin | Skipped / unknown shows | |---|---|---|---|---| -| CLI | `hack --version` (text; R1 for JSON) | `brew install hack-dance/tap/hack` (copy) | no | Cannot skip | +| CLI | `hack --version` (text; R1 for JSON) | v5 install documentation; select the supported executable for the app | no | Cannot skip | | Docker (Compose projects) | `runtime_ok` from `/v1/projects` or `hack projects --json` (Docker only) | open Docker Desktop / Docker docs | no | "Docker not reachable" on Compose projects only | | Native runtime (native projects) | native `ps` `data.status` per native project (`runtime_not_running` etc.) | native setup doc (HACK-2193) | no | that native project shows Unknown; Compose projects unaffected | | hackd | `hack daemon status --json` | `hack daemon start` | no | Status bar "hackd off"; slower refresh | | Routing and DNS (Compose) | macOS: doctor `resolver:`, `dnsmasq.conf:`, `dnsmasq:53` and `coredns forwarding` (all must pass). Linux: doctor `linux dns route`. Never `hack global status` (can start Docker) | `hack global install` | **yes** | URLs greyed "DNS not set up"; "unknown" when a check is missing | | Routing and DNS (native) | `hack doctor --browser-url ` → `native host dns` (absent without `--browser-url`) | none from the app: `hack global install` refuses native; link to the native setup doc (HACK-2193) | — | "unknown" | -| Certificate trust (Compose) | **Unknown** today: doctor `caddy local ca` checks the exported CA, not system or browser trust (R16) | `hack global trust` | **yes** | Lock icon on URLs: "browser may warn" | +| Certificate trust (Compose) | doctor `local ca trust` when available (system and Linux NSS); **Unknown** on older CLIs. `caddy local ca` checks only the exported CA, never trust (R16) | `hack global trust` | **yes** | Lock icon on URLs: "browser may warn" | | Certificate trust (native) | doctor `native root trust` | none from the app: `hack global trust` refuses native; doc link | — | "unknown" | -| Login service | `daemon status` `service.installed` | `hack daemon install` | no | Always-on switch disabled | +| Login service | `daemon status` `service.installed` | `hack daemon install` only with explicit manager availability, ordinary home and guards permitting it; otherwise docs | no | Always-on switch disabled | -Skips are not stored: the next check finds the step still open and Doctor lists -it. Non-admin **Run** executes the command and shows its output, then re-checks. +Skips are memory-only for the app session and survive checks. The next launch +finds the step still open and Doctor lists it. Non-admin **Run** executes the command and shows its output, then re-checks. Admin steps offer **Copy command** and **Open Terminal**; neither is assumed to have succeeded until **Check again** (also run when the window regains focus). diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6d59b70cc..ee79f2c2b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -215,10 +215,177 @@ observations, not locks against concurrent same-user changes. ## What v0 does not do yet -The Activity tab, CPU and memory, onboarding, always-on, daemon actions and +The Activity tab, CPU and memory, always-on, broader daemon controls and upgrades come in later units. The design and the CLI/daemon requests they need are in `DESIGN.md` (PR #800). +## Setup + +![Setup evidence flow](diagrams/setup-evidence.svg) ([source](diagrams/setup-evidence.mmd)) + +On launch, fresh background checks determine whether setup is needed. The app +supports Hack CLI 5.x; the CLI card cannot be skipped. Doctor offers **Run setup +again**. Every card offers **Check again**. Skips last for this app session, survive rechecks, +and never count as passed. Continue permits unresolved optional steps once the +CLI is available; the next launch checks everything again. No first-run flag, +setup results or command output are saved. + +Cards cover CLI, Compose Docker, each native runtime, hackd, routing, trust and +the login service. Docker readiness comes from the machine Doctor run's `docker +daemon` check, which uses the app's checked Docker selectors; a running hackd's +own `runtime_ok` describes the engine in the daemon's environment and is not used. An empty registry checks Compose prerequisites. A native-only +registry offers native documentation instead of global install/trust. Native DNS +stays Unknown when doctor did not receive a browser URL. Compose macOS DNS needs +all built-in resolver/dnsmasq pairs, any reported/custom host domain, port 53 and +CoreDNS forwarding. Linux requires `linux dns route`. Missing checks stay Unknown. +Only the explicit `local ca trust` check can prove Compose trust; `caddy local ca` +only proves export. Older CLIs without R16 leave trust Unknown. + +Setup observes with a version-matched hackd snapshot, native `ps`, `daemon status +--json`, socket status and bounded Doctor reads. Every automatic `daemon status` +read (Setup, project polling, the install pre-check) runs with Doctor's private +tool PATH, interpreter startup variables removed and no `docker` at all: the +CLI's status command would otherwise run `docker info` and the service manager +from the ordinary PATH, although the JSON answer uses neither Docker result. CLI project listing is refused +until R18 because it writes the registry; unavailable project coverage stays Unknown. It never observes through +`global status`. A setup refresh has a 120-second budget (plus the bounded socket +read); individual commands and Doctor cleanup also have deadlines. Native checks +not reached within the budget remain Unknown. `running` plus the socket version +matching the CLI proves hackd; bare status exit zero does not. `service.installed` +proves installation only; known disabled/drifted/failed service metadata stays +Needs attention. Missing service-manager availability never permits Install. + +Start and Install, and the project pane's Start/Restart hackd recovery, pass the +selected CLI as the CLI's self-invocation selector (`HACK_MCP_COMMAND`), so the daemon they start or install runs that CLI, not +whatever `hack` is first on PATH. Only the fixed `daemon start` and supported `daemon install` actions offer **Run**, with +bounded, memory-only output followed by fresh checks. Install requires explicit +`service.available: true`, an ordinary home, and no launchd/systemd-disable guard; +otherwise the card explains why and links to documentation. Availability is checked +again immediately before install; an unavailable or unconfirmed manager is refused +before an install command is submitted. Admin cards offer **Copy +command** and **Open Terminal**. Both run later, outside the app, so the bound +command's PATH keeps only directories no other user can change (owner- or +root-owned, not group- or other-writable, with Doctor's ancestry and ACL rule; +`/usr/bin:/bin` when none remain). The private one-use `.command` script quotes every +argument, prints the command and waits for Enter. Afterward it shows the exit code +and waits for Enter to close, preserving output and the command’s exit code. macOS requests a new window +of the system Terminal through `open -g -a /System/Applications/Utilities/Terminal.app` +(by path, not by application name; the bundle and every directory above it must be +root-owned and not group- or other-writable, otherwise Copy only); Linux uses `xdg-terminal-exec` when available, +otherwise Copy. The adapter must pass the same ownership, write and `#!` rules +as the CLI before it runs. The script's temporary directory must not be changeable by +another user (owned by this user, or by root and sticky like `/tmp`, with the +same rule for every directory above it); otherwise the hand-off is refused. No existing pane is typed into and no password is requested by +the app. The script removes itself and its private directory after use or +cancellation; a terminal that never executes the script may leave that private +temp directory behind. Launching a terminal never proves a fix succeeded. +GPUI's window activation observer rechecks only when Setup is shown or an explicit +fix is pending; +visible setup also refreshes every ten minutes. Actual host focus and terminal +behavior still need macOS/Linux live qualification; tests use injected launchers. + +Offline examples (no CLI, terminal or services): + +```sh +cargo run -p hack-desktop -- --setup-fixture partial --theme dark +cargo run -p hack-desktop-core --bin hack-desktop-state -- --setup-fixture native-unknown +``` + +Other setup fixtures: `missing-cli`, `ready`, `service-unavailable`. `--screenshots` renders all five +in light and dark offscreen. Evidence: [light](screenshots/setup-light.png), +[dark](screenshots/setup-dark.png). + +Reusable terminal API (`core::terminal`): `CommandSpec { program, args }` provides +`display()` with shell quoting; `Script::create(temp_root, &spec)` owns the private +script; `open(Platform, linux_adapter, &dyn Launcher)` transfers cleanup to the +script after launch. `Launcher::launch(program, args)` is the injected boundary; +`SystemLauncher` is used only on explicit live clicks. `linux_adapter()` discovers +an absolute `xdg-terminal-exec`. Build upgrade commands from authored argv, never +from arbitrary CLI finding text. `Report::command(args)` uses the checked absolute +CLI and its captured context. It never rediscovers PATH at click time. + +The CLI setup card links to [v5 installation](../../docs/versions/v5/install.md), +not the stable v4 Homebrew command. A terminal shell function does not change the +app's executable. Select a prepared supported CLI explicitly when launching: + +```sh +HACK_DESKTOP_CLI="$HOME/.hack-next/bin/hack-next" HACK_RUNTIME_BACKEND=compose hack-desktop +``` + +`HACK_DESKTOP_CLI` must be an absolute executable path. This selects the CLI for +checks, safe actions and terminal hand-offs; it does not install anything or +change PATH. Without it, the app finds `hack` on absolute PATH entries once and +pins that executable for every later read and action; only setup checks +(startup and **Check again**) search PATH again, so installing into an existing +PATH directory recovers through **Check again** without restarting. A `hack` +whose file or any directory above it is writable by group or others, or owned +by another user, is refused before it runs. An explicit `HACK_DESKTOP_CLI` selection gets the same +check before every use; a missing one is a missing CLI. A script CLI's `#!` interpreter must +pass the same rule with no arguments (`#!/usr/bin/env` is refused here because +the CLI uses the ordinary PATH). The app inherits PATH at launch; a changed PATH still needs a +relaunch. Follow the installation page's platform limits and existing-v4 migration +steps before using a candidate against a shared home. +On Linux, this preview requires a separately prepared source-built 5.x CLI; the +Apple Silicon prerelease download is not a Linux installer. + +Execution context (`core::execution::ExecutionContext`) reads only the allowlisted +non-secret HOME, HACK_HOME, PATH, backend, launchd/systemd-disable guards, +daemon-autostart guard, optional XDG_RUNTIME_DIR and an absolute XDG_CONFIG_HOME +(which selects the systemd user unit; a relative one is ignored, as the CLI does). Docker +selectors are kept when supported (`DOCKER_CONTEXT` as a plain name, an absolute +`DOCKER_CONFIG`, and a `unix://` absolute, `tcp://` or `ssh://` `DOCKER_HOST`); +any other value refuses the checked context rather than silently addressing a +different Docker engine. Absolute `HACK_NATIVE_HOME`, `HACK_NATIVE_BINARY` and `HACK_GLOBAL_CONFIG_PATH` are kept +the same way (relative values refuse), so native reads and actions address one +runtime. An absent HACK_HOME stays +absent so a candidate launcher can keep its own private-home default. Context is +captured before version checking. `runner(checked_program)` pins that absolute +executable, clears the child environment and applies the context for all reads +and actions. `bind(&CommandSpec)` wraps authored argv in `/usr/bin/env -i` with +individually quoted assignments, so a macOS Terminal login environment cannot +select another home/backend or weaken a guard. Unknown keys, invalid paths, +control characters and unsupported backends are refused. Wrapper-specific or +credential environment is never forwarded; such integrations need an explicitly +supported context or a manual documented workflow. `CommandSpec` and the +single-command `Script::create` API remain compatible; a sequence consumer can +bind each authored spec before composing it. The launcher also clears its inherited environment, carrying only fixed non-secret +desktop routing metadata needed to open a terminal. Both the script and private directory +are 0700 because the launch adapter executes the script. + +Machine effects share `HackApp::action_busy()` (including +`AppModel::has_in_flight()` for every project); project effects check +`machine_action_busy()`. New daemon/upgrade controls must extend that shared gate. +Checks clear an older action receipt; the immediate post-action check retains its +receipt once. A transient version timeout remains Unknown and does not force a +project pane back into setup as though the CLI were missing. + +Remaining evidence limits: an installed unit without newer operational metadata +proves installation, not enabled/healthy service operation. Native DNS remains +Unknown without a checked browser URL. macOS custom/localhost suffixes without +matching Doctor resolver evidence remain Unknown, even if OS routing works; +setup does not infer trust or routing success from absence. These conservative +limits do not justify offering an unsupported in-app fix. Live macOS Terminal, +focus, trust and service effects still require host qualification. + +The offscreen generator writes every `setup--.png` and also writes +`setup-light.png`/`setup-dark.png` aliases for the partial fixture used above. + +Unavailable login-manager fixture (no Run offered): +[light](screenshots/setup-service-unavailable-light.png), +[dark](screenshots/setup-service-unavailable-dark.png). + +Daemon socket discovery uses the selected CLI’s bounded, read-only bare +`daemon status --json` response. Its absolute `socket_path` is authoritative, +including candidate launchers with a private default home. Missing or invalid +paths leave daemon evidence unavailable; the app never falls back to a guessed +parent-home socket or a registry-writing project listing. Socket version and +project reads still have to succeed before project state is accepted. +Compose routing/trust observations and their fixed `global install`/`global trust` +remedies explicitly select Compose while retaining the checked executable and +home. Daemon remedies retain the original backend. Login-service installation +alone does not pass when available evidence says inactive, unloaded, or failed +launchd exit status; these states need attention even if hackd runs separately. + ## Logs tab `hack logs --json` for the selected project, following by default: @@ -294,7 +461,10 @@ On macOS, the CLI runner uses `posix_spawn` with `POSIX_SPAWN_CLOEXEC_DEFAULT` and explicit stdin/stdout/stderr actions. This prevents a CLI child from holding an unrelated descriptor during another thread's non-atomic close-on-exec setup. The process group, selected environment, cwd, timeout and retirement contracts -are preserved. Linux retains the standard process implementation. This product +are preserved. A clean-environment runner (setup's checked execution context) +passes that mode to the boundary explicitly, so its child starts from an empty +environment on macOS as on Linux. Linux retains the standard process +implementation. This product boundary is needed independently of test isolation; a mutex around runner calls would not cover descriptors opened by other GUI/library threads. diff --git a/apps/desktop/app/src/doctor_view.rs b/apps/desktop/app/src/doctor_view.rs index 9656ba83c..eb2ed20eb 100644 --- a/apps/desktop/app/src/doctor_view.rs +++ b/apps/desktop/app/src/doctor_view.rs @@ -27,6 +27,7 @@ pub enum Pane { #[default] Project, Doctor, + Setup, } pub fn render(pane: &DoctorPane, cx: &mut Context) -> AnyElement { @@ -46,6 +47,13 @@ pub fn render(pane: &DoctorPane, cx: &mut Context) -> AnyElement { .child("Doctor"), ) .child(div().flex_1()) + .child( + Button::new("run-setup-again") + .label("Run setup again") + .small() + .outline() + .on_click(cx.listener(|this, _, _, cx| this.open_setup(cx))), + ) .children( pane.age_label(Instant::now()) .map(|a| div().text_sm().text_color(t.muted_foreground).child(a)), diff --git a/apps/desktop/app/src/main.rs b/apps/desktop/app/src/main.rs index 27740b634..47c6762ea 100644 --- a/apps/desktop/app/src/main.rs +++ b/apps/desktop/app/src/main.rs @@ -13,6 +13,7 @@ mod doctor_view; mod logs_view; #[cfg(feature = "screenshots")] mod screenshots; +mod setup_view; mod view; use std::sync::Arc; @@ -24,6 +25,7 @@ use hack_desktop_core::source::{FixtureSource, LiveSource, Source}; pub(crate) struct Args { pub fixture: Option, + pub setup_fixture: Option, pub logs_fixture: Option, pub theme: Option, pub screenshots: Option, @@ -31,7 +33,7 @@ pub(crate) struct Args { fn usage() -> ! { eprintln!( - "usage: hack-desktop [--fixtures | --logs-fixture ] [--theme light|dark] [--list-fixtures] [--screenshots ]" + "usage: hack-desktop [--setup-fixture missing-cli|partial|ready|native-unknown|service-unavailable] [--fixtures | --logs-fixture ] [--theme light|dark] [--list-fixtures] [--screenshots ]" ); std::process::exit(2) } @@ -39,6 +41,7 @@ fn usage() -> ! { fn parse_args() -> Args { let mut a = Args { fixture: None, + setup_fixture: None, logs_fixture: None, theme: None, screenshots: None, @@ -46,6 +49,13 @@ fn parse_args() -> Args { let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { match arg.as_str() { + "--setup-fixture" => { + let name = it.next().unwrap_or_else(|| usage()); + if hack_desktop_core::setup_fixtures::load(&name).is_none() { + usage(); + } + a.setup_fixture = Some(name); + } "--fixtures" => { let name = it.next().unwrap_or_else(|| usage()); a.fixture = Some(Fixture::parse(&name).unwrap_or_else(|| { @@ -120,13 +130,17 @@ fn main() { } gpui_kit::application().run(move |cx| { gpui_kit::init(cx); - let fixture = args.fixture.or(args.logs_fixture.map(|l| { - if l == hack_desktop_core::logs::fixtures::LogsFixture::Stopped { - Fixture::Stopped - } else { - Fixture::Running - } - })); + let setup_fixture = args.setup_fixture; + let fixture = args + .fixture + .or(args.logs_fixture.map(|l| { + if l == hack_desktop_core::logs::fixtures::LogsFixture::Stopped { + Fixture::Stopped + } else { + Fixture::Running + } + })) + .or(setup_fixture.as_ref().map(|_| Fixture::Running)); let logs_fixture = args.logs_fixture; let theme = args.theme; let source = source_for(fixture); @@ -153,6 +167,12 @@ fn main() { app.simulate_daemon_unavailable(f == Fixture::DaemonMismatch); } } + if let Some(report) = setup_fixture + .as_deref() + .and_then(hack_desktop_core::setup_fixtures::load) + { + app.show_setup_now(report); + } if let Some(a) = fixture.and_then(|f| fixtures::local(f).in_flight) { app.simulate_in_flight(a); } diff --git a/apps/desktop/app/src/screenshots.rs b/apps/desktop/app/src/screenshots.rs index 686a92ca2..1016afa88 100644 --- a/apps/desktop/app/src/screenshots.rs +++ b/apps/desktop/app/src/screenshots.rs @@ -35,6 +35,52 @@ pub fn run(dir: &Path, fixture: Option) { shots.push((l.name(), base, Some(l))); } for mode in [ThemeMode::Light, ThemeMode::Dark] { + for name in hack_desktop_core::setup_fixtures::NAMES { + cx.update(|cx| Theme::change(mode, None, cx)); + let mut opts = crate::window_options(); + opts.focus = false; + opts.show = false; + opts.window_bounds = Some(gpui_kit::WindowBounds::Windowed(gpui_kit::Bounds { + origin: Default::default(), + size: gpui_kit::size(gpui_kit::px(1040.), gpui_kit::px(1200.)), + })); + let (handle, _) = cx + .update(|cx| { + gpui_kit::open_window(opts, cx, |window, cx| { + cx.new(|cx| { + let mut app = HackApp::new( + crate::source_for(Some(Fixture::Running)), + false, + window, + cx, + ); + app.show_setup_now( + hack_desktop_core::setup_fixtures::load(name) + .expect("known fixture"), + ); + app + }) + }) + }) + .expect("offscreen setup window"); + cx.update_window(handle, |_, w, cx| w.render_frame(cx)) + .expect("render setup"); + let img = cx + .capture_screenshot(handle) + .expect("offscreen setup renderer"); + let suffix = if mode == ThemeMode::Dark { + "dark" + } else { + "light" + }; + img.save(dir.join(format!("setup-{name}-{suffix}.png"))) + .expect("save setup"); + if name == "partial" { + img.save(dir.join(format!("setup-{suffix}.png"))) + .expect("save documented setup alias"); + } + cx.update_window(handle, |_, w, _| w.remove_window()).ok(); + } for &(name, f, logs) in shots .iter() .filter(|(_, f, logs)| fixture.is_none_or(|selected| selected == *f && logs.is_none())) diff --git a/apps/desktop/app/src/setup_controller.rs b/apps/desktop/app/src/setup_controller.rs new file mode 100644 index 000000000..f8d047bd5 --- /dev/null +++ b/apps/desktop/app/src/setup_controller.rs @@ -0,0 +1,160 @@ +//! Setup controller; blocking adapters always run off the UI thread. +use super::*; + +impl HackApp { + pub fn show_setup_now(&mut self, report: hack_desktop_core::setup::Report) { + self.pane = Pane::Setup; + let generation = self.setup.begin(); + self.setup.finish(generation, report); + } + pub fn open_setup(&mut self, cx: &mut Context) { + self.pane = Pane::Setup; + self.load_setup(cx); + } + pub fn load_setup(&mut self, cx: &mut Context) { + if self.setup.loading { + self.setup.refresh_again = true; + return; + } + if self.setup.action_busy || self.recovery_busy { + return; + } + let generation = self.setup.begin(); + if !self.live { + self.setup.finish(generation, self.source.setup()); + cx.notify(); + return; + } + let source = self.source.clone(); + cx.spawn(async move |this, cx| { + let report = cx + .background_executor() + .spawn(async move { source.setup() }) + .await; + let _ = this.update(cx, |this, cx| { + let needed = report.needed(); + this.setup.finish(generation, report); + let refresh_again = this.setup.refresh_again; + if refresh_again { + this.setup.refresh_again = false; + this.load_setup(cx); + } + if !refresh_again { + this.setup.pending_fix = false; + } + if this.setup_initial { + this.setup_initial = false; + this.pane = if needed { Pane::Setup } else { Pane::Project }; + } + // Losing the CLI always restores the required setup gate. + if this.setup.report.force_cli_gate && !this.setup.report.cli_ready() { + this.pane = Pane::Setup; + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + pub fn skip_setup(&mut self, id: &str, cx: &mut Context) { + self.setup.skip(id); + cx.notify(); + } + pub fn finish_setup(&mut self, cx: &mut Context) { + if self.setup.report.cli_ready() + && !self.setup.loading + && !self.setup.action_busy + && !self.recovery_busy + { + self.pane = Pane::Project; + cx.notify(); + } + } + pub fn run_setup_action( + &mut self, + action: hack_desktop_core::setup::SafeAction, + cx: &mut Context, + ) { + use hack_desktop_core::setup::{Remedy, Status}; + if self.action_busy() + || self.setup.loading + || !self.setup.report.cli_ready() + || !self + .setup + .report + .cards + .iter() + .any(|c| c.remedy == Remedy::Run(action) && c.status != Status::Passed) + { + return; + } + if !self.live { + self.setup.result = Some("Fixture: no action executed".into()); + cx.notify(); + return; + } + self.setup.result = None; + self.setup.action_busy = true; + let source = self.source.clone(); + let checked = self.setup.report.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { source.setup_action(action, &checked) }) + .await; + let _ = this.update(cx, |this, cx| { + this.setup.action_busy = false; + this.setup.result = Some(result); + this.setup.pending_fix = true; + this.refresh_now_async(cx); + this.load_setup(cx); + }); + }) + .detach(); + cx.notify(); + } + pub fn open_setup_terminal(&mut self, args: Vec, cx: &mut Context) { + use hack_desktop_core::terminal::{Platform, Script, SystemLauncher, linux_adapter}; + if !self.live { + self.setup.result = Some("Fixture: no terminal opened".into()); + cx.notify(); + return; + } + if self.action_busy() + || self.setup.loading + || !self.setup.report.cli_ready() + || !self + .setup + .report + .cards + .iter() + .any(|c| c.remedy == hack_desktop_core::setup::Remedy::Admin(args.clone())) + { + return; + } + let command = self.setup.report.command(args); + self.setup.result = None; + self.setup.action_busy = true; + cx.spawn(async move |this, cx| { + let result = cx.background_executor().spawn(async move { + let spec = command?; + let adapter = linux_adapter(); + if !cfg!(target_os = "macos") && adapter.is_none() { + return Err("xdg-terminal-exec unavailable; use Copy command".into()); + } + let platform = if cfg!(target_os = "macos") { Platform::Mac } else { Platform::Linux }; + Script::create(&std::env::temp_dir(), &spec)?.open(platform, adapter.as_deref(), &SystemLauncher) + }).await; + let _ = this.update(cx, |this, cx| { + this.setup.action_busy = false; + this.setup.pending_fix=result.is_ok(); + this.setup.result = Some(match result { + Ok(()) => "Terminal hand-off requested. Press Enter there, then Check again; launch does not prove setup.".into(), + Err(e) => e, + }); + cx.notify(); + }); + }).detach(); + cx.notify(); + } +} diff --git a/apps/desktop/app/src/setup_view.rs b/apps/desktop/app/src/setup_view.rs new file mode 100644 index 000000000..6a53c35da --- /dev/null +++ b/apps/desktop/app/src/setup_view.rs @@ -0,0 +1,174 @@ +//! Ordered setup cards. All effects require an explicit click. +use crate::view::HackApp; +use gpui_kit::component::{ + ActiveTheme, Disableable as _, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, v_flex, +}; +use gpui_kit::{ + AnyElement, ClipboardItem, Context, FontWeight, IntoElement, ParentElement, Styled, div, + prelude::*, +}; +use hack_desktop_core::setup::{Pane, Remedy, Status}; + +pub fn render(pane: &Pane, recovery_busy: bool, cx: &mut Context) -> AnyElement { + let t = cx.theme().clone(); + let busy = pane.action_busy || recovery_busy; + let mut body = v_flex() + .id("setup-cards") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px_5() + .py_3() + .gap_3(); + if pane.report.cards.is_empty() { + body = body.child(div().child("Checking setup… Reads never start services.")); + } + if let Some(result) = &pane.result { + body = body.child( + div() + .text_sm() + .p_3() + .border_1() + .border_color(t.border) + .child(result.clone()), + ); + } + for (i, card) in pane.report.cards.iter().enumerate() { + let skipped = pane.skipped.contains(&card.id); + let label = if skipped { + "Skipped for this session · unresolved" + } else { + match card.status { + Status::Passed => "Passed", + Status::NeedsAttention => "Needs attention", + Status::Unknown => "Unknown", + } + }; + let color = if card.status == Status::Passed && !skipped { + t.success + } else { + t.warning + }; + let mut actions = h_flex().gap_2().flex_wrap().child( + Button::new(("setup-recheck", i)) + .label("Check again") + .small() + .outline() + .disabled(pane.loading || busy) + .on_click(cx.listener(|this, _, _, cx| this.load_setup(cx))), + ); + // Passed cards need no fix. CLI cannot be skipped and gates departure. + if card.status != Status::Passed { + match &card.remedy { + Remedy::Copy(command) => { + let command = command.clone(); + actions = actions.child( + Button::new(("setup-copy", i)) + .label("Copy command") + .small() + .ghost() + .disabled(!pane.report.cli_ready()) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(command.clone())) + }), + ); + } + Remedy::Doc(url) => { + let url = url.clone(); + actions = actions.child( + Button::new(("setup-doc", i)) + .label("Open documentation") + .small() + .ghost() + .on_click(move |_, _, cx| cx.open_url(&url)), + ); + } + Remedy::Run(action) => { + let action = *action; + actions = + actions.child( + Button::new(("setup-run", i)) + .label("Run") + .small() + .outline() + .disabled(pane.loading || busy || !pane.report.cli_ready()) + .on_click(cx.listener(move |this, _, _, cx| { + this.run_setup_action(action, cx) + })), + ); + } + Remedy::Admin(args) => { + let args = args.clone(); + let command = pane.report.command(args.clone()).and_then(|c| c.display()); + let handoff_available = command.is_ok(); + let command = + command.unwrap_or_else(|_| "Command unavailable; check setup again".into()); + actions = actions.child( + Button::new(("setup-copy", i)) + .label("Copy command") + .small() + .ghost() + .disabled(!pane.report.cli_ready() || !handoff_available) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(command.clone())) + }), + ); + actions = actions.child( + Button::new(("setup-terminal", i)) + .label("Open Terminal") + .small() + .outline() + .disabled( + busy || pane.loading + || !pane.report.cli_ready() + || !handoff_available, + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_setup_terminal(args.clone(), cx) + })), + ); + } + } + if card.id != "cli" { + let id = card.id.clone(); + actions = actions.child( + Button::new(("setup-skip", i)) + .label("Skip for now") + .small() + .ghost() + .disabled(pane.loading || busy) + .on_click(cx.listener(move |this, _, _, cx| this.skip_setup(&id, cx))), + ); + } + } + body = body.child( + v_flex() + .p_3() + .gap_2() + .border_1() + .border_color(t.border) + .rounded_lg() + .child( + h_flex() + .gap_2() + .child(div().font_weight(FontWeight::SEMIBOLD).child(format!( + "{}. {}", + i + 1, + card.title + ))) + .child(div().flex_1()) + .child(div().text_xs().text_color(color).child(label)), + ) + .child( + div() + .text_sm() + .text_color(t.muted_foreground) + .child(card.detail.clone()), + ) + .child(actions), + ); + } + v_flex().flex_1().h_full().min_w_0().child(h_flex().px_5().py_3().gap_3().border_b_1().border_color(t.border).child(div().text_xl().font_weight(FontWeight::BOLD).child("Set up Hack")).child(div().flex_1()).child(Button::new("setup-continue").label("Continue").small().outline().disabled(!pane.report.cli_ready() || pane.loading || busy).on_click(cx.listener(|this,_,_,cx|this.finish_setup(cx))))).child(div().px_5().pt_2().text_sm().text_color(t.muted_foreground).child("Checks observe only. Skipped and unknown steps stay unresolved; admin commands run in your terminal after Enter.")).child(body).into_any_element() +} diff --git a/apps/desktop/app/src/view.rs b/apps/desktop/app/src/view.rs index 422425f08..d21425519 100644 --- a/apps/desktop/app/src/view.rs +++ b/apps/desktop/app/src/view.rs @@ -36,6 +36,9 @@ use hack_desktop_core::viewmodel::{ AppModel, DetailView, Event, ListView, Phase, RefreshToken, Row, }; +#[path = "setup_controller.rs"] +mod setup_controller; + const TAIL_LINES: u32 = 20; pub struct HackApp { @@ -49,6 +52,9 @@ pub struct HackApp { live: bool, pane: Pane, doctor: DoctorPane, + pub(crate) setup: hack_desktop_core::setup::Pane, + setup_initial: bool, + _activation: Option, /// Is the window visible (not minimised or hidden)? Automatic doctor /// runs wait for it. window_visible: bool, @@ -76,6 +82,9 @@ impl HackApp { tab: 0, pane: Pane::Project, doctor: DoctorPane::default(), + setup: Default::default(), + setup_initial: true, + _activation: None, window_visible: true, _visibility: None, env: Default::default(), @@ -87,8 +96,10 @@ impl HackApp { action_started: std::collections::HashMap::new(), }; if live { + this.pane = Pane::Setup; this.start_polling(cx); this.start_doctor_refresh(cx); + this.load_setup(cx); } else { this.refresh_now(); } @@ -220,6 +231,18 @@ impl HackApp { /// missing, old or stale doctor result. pub fn watch_window(&mut self, window: &mut Window, cx: &mut Context) { self.window_visible = window.is_visible(); + self._activation = Some(cx.observe_window_activation(window, |this, window, cx| { + if window.is_window_active() + && hack_desktop_core::setup::should_recheck_on_focus( + this.pane == Pane::Setup, + this.setup.pending_fix, + this.setup.loading, + this.machine_action_busy(), + ) + { + this.load_setup(cx); + } + })); self._visibility = Some(cx.observe_window_visibility( window, |this, visibility, _window, cx| { @@ -237,6 +260,17 @@ impl HackApp { .timer(std::time::Duration::from_secs(60)) .await; let alive = this.update(cx, |this, cx| { + if this.pane == Pane::Setup + && this.window_visible + && !this.setup.loading + && !this.setup.action_busy + && this + .setup + .checked_at + .is_none_or(|t| t.elapsed() >= Duration::from_secs(600)) + { + this.load_setup(cx); + } if !this.maybe_auto_refresh(cx) && this.pane == Pane::Doctor && this.window_visible @@ -427,7 +461,7 @@ impl HackApp { } fn run_action(&mut self, action: Action, cx: &mut Context) { - if self.recovery_busy { + if self.machine_action_busy() { return; } let Some(row) = self.model.selected_row() else { @@ -488,8 +522,16 @@ impl HackApp { .detach(); } + /// Shared by setup, recovery and the daemon/upgrade machine controls. + pub(crate) fn action_busy(&self) -> bool { + self.machine_action_busy() || self.model.has_in_flight() + } + pub(crate) fn machine_action_busy(&self) -> bool { + self.recovery_busy || self.setup.action_busy + } + fn recover_daemon(&mut self, action: DaemonRecovery, cx: &mut Context) { - if self.recovery_busy || !self.live { + if self.action_busy() || !self.live { return; } self.recovery_busy = true; @@ -507,6 +549,7 @@ impl HackApp { // A command exit is not evidence that a compatible daemon // is ready. Only the next ordered snapshot can restore rows. this.refresh_now_async(cx); + this.load_setup(cx); cx.notify(); }); }) @@ -766,7 +809,7 @@ impl HackApp { Button::new("recover-hackd") .label(action.label()) .small() - .disabled(self.recovery_busy) + .disabled(self.action_busy()) .loading(self.recovery_busy) .on_click( cx.listener(move |this, _, _, cx| this.recover_daemon(action, cx)), @@ -792,7 +835,7 @@ impl HackApp { let mut b = Button::new(a.label()) .label(a.label()) .small() - .disabled(d.busy || self.recovery_busy); + .disabled(d.busy || self.machine_action_busy()); b = match a { Action::Start => b.primary(), Action::Stop => b.outline(), @@ -1302,6 +1345,9 @@ impl Render for HackApp { let t = cx.theme().clone(); let (bg, fg, font) = (t.background, t.foreground, t.font_family.clone()); let main: AnyElement = match &list.phase { + _ if self.pane == Pane::Setup => { + crate::setup_view::render(&self.setup, self.action_busy(), cx) + } // Machine checks matter most before any project exists, so the // Doctor pane renders for an empty registry too. // Diagnostics are most useful when the project list fails. @@ -1346,16 +1392,37 @@ impl Render for HackApp { .flex_1() .min_h_0() .items_start() - .child(self.sidebar(&list, cx)) + .children( + (self.pane != Pane::Setup || self.setup.report.cli_ready()) + .then(|| self.sidebar(&list, cx)), + ) .child( v_flex() .flex_1() .h_full() .min_w_0() - .child(self.banners(&list, cx)) + .when(self.pane != Pane::Setup, |el| { + el.child(self.banners(&list, cx)) + }) .child(main), ), ) - .child(self.status_bar(&list, cx)) + .child(if self.pane == Pane::Setup { + div() + .px_3() + .py_1() + .border_t_1() + .border_color(t.border) + .text_xs() + .text_color(t.muted_foreground) + .child(if self.setup.loading { + "Checking setup…" + } else { + "Setup evidence is kept only for this session" + }) + .into_any_element() + } else { + self.status_bar(&list, cx).into_any_element() + }) } } diff --git a/apps/desktop/core/src/bin/hack-desktop-state.rs b/apps/desktop/core/src/bin/hack-desktop-state.rs index bc2114e70..92c3bff24 100644 --- a/apps/desktop/core/src/bin/hack-desktop-state.rs +++ b/apps/desktop/core/src/bin/hack-desktop-state.rs @@ -10,7 +10,7 @@ //! invoking today's registry-writing `hack projects` fallback. //! //! ```text -//! hack-desktop-state [--project ] [--compact] +//! hack-desktop-state [--project ] [--compact] [--setup-fixture missing-cli|partial|ready|native-unknown|service-unavailable] //! ``` use std::time::Instant; @@ -20,22 +20,34 @@ use hack_desktop_core::viewmodel::{AppModel, Event}; use serde_json::{Value, json}; fn usage() -> ! { - eprintln!("usage: hack-desktop-state [--project ] [--compact]"); + eprintln!( + "usage: hack-desktop-state [--project ] [--compact] [--setup-fixture missing-cli|partial|ready|native-unknown|service-unavailable]" + ); std::process::exit(2) } fn main() { let mut project: Option = None; let mut compact = false; + let mut setup_fixture = None; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { "--project" => project = Some(args.next().unwrap_or_else(|| usage())), "--compact" => compact = true, + "--setup-fixture" => setup_fixture = Some(args.next().unwrap_or_else(|| usage())), _ => usage(), } } + if let Some(name) = setup_fixture { + let report = hack_desktop_core::setup_fixtures::load(&name).unwrap_or_else(|| usage()); + println!( + "{}", + serde_json::to_string_pretty(&report).expect("fixture JSON") + ); + return; + } let source = LiveSource::from_env(); let started = Instant::now(); let snap = source.snapshot(); diff --git a/apps/desktop/core/src/doctor_plugins.rs b/apps/desktop/core/src/doctor_plugins.rs index 64238f8db..d7e739788 100644 --- a/apps/desktop/core/src/doctor_plugins.rs +++ b/apps/desktop/core/src/doctor_plugins.rs @@ -20,18 +20,18 @@ pub(crate) struct Discovery { impl Discovery { pub fn from_runner(runner: &CliRunner) -> Self { let config_dir = runner - .env_value("DOCKER_CONFIG") + .effective_env("DOCKER_CONFIG") .filter(|p| !p.is_empty()) .map(PathBuf::from) .or_else(|| { runner - .env_value("HOME") + .effective_env("HOME") .filter(|p| !p.is_empty()) .map(|p| PathBuf::from(p).join(".docker")) }); let text = |key| { runner - .env_value(key) + .effective_env(key) .and_then(|v| v.into_string().ok()) .filter(|v| !v.is_empty()) }; @@ -42,7 +42,7 @@ impl Discovery { // This selector's encoding/precedence is not part of the Unix // configfile contract below. Do not guess or silently ignore it. unsupported_extra_selector: runner - .env_value("DOCKER_CLI_PLUGIN_EXTRA_DIRS") + .effective_env("DOCKER_CLI_PLUGIN_EXTRA_DIRS") .is_some_and(|v| !v.is_empty()), // docker/cli cli-plugins/manager/manager_unix.go (standard Unix // builds). Custom discovery requires a separate qualified adapter. @@ -205,6 +205,18 @@ fn guarded_dir(path: &Path, uid: u32) -> Option { trusted_tool_path(path, uid, true) } +/// Only the PATH entries that are absolute, owner- or root-controlled +/// directories (with the same ancestry and ACL rule as Docker plugin +/// directories). A copied command runs later, after the app's private wrapper +/// PATH is gone, so an entry another user can write must not reach it. +pub(crate) fn controlled_path_entries(path: &str) -> Vec<&str> { + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + path.split(':') + .filter(|entry| guarded_dir(Path::new(entry), uid).is_some_and(|real| real.is_dir())) + .collect() +} + /// Missing candidates are proven absent only below a protected existing /// ancestor. A dangling link, unreadable path or writable ancestor is unknown. fn present(path: &Path, uid: u32) -> Result { @@ -264,7 +276,7 @@ fn config_file(path: &Path, uid: u32) -> Result { pub(crate) fn validate_helpers(runner: &CliRunner) -> Result { let selector = |key| -> Result, &'static str> { runner - .env_value(key) + .effective_env(key) .map(|v| { v.into_string() .map(|v| v.trim().to_owned()) @@ -491,7 +503,7 @@ mod tests { .with_env("DOCKER_CLI_PLUGIN_EXTRA_DIRS", "/fixture/opaque"); assert!(Discovery::from_runner(&opaque).unsupported_extra_selector); assert_eq!( - runner.env_value("DOCKER_CONTEXT").unwrap(), + runner.effective_env("DOCKER_CONTEXT").unwrap(), "fixture-selected-context" ); } diff --git a/apps/desktop/core/src/execution.rs b/apps/desktop/core/src/execution.rs new file mode 100644 index 000000000..af2251589 --- /dev/null +++ b/apps/desktop/core/src/execution.rs @@ -0,0 +1,247 @@ +//! Non-secret execution context shared by checked CLI reads and terminal fixes. +//! No environment enumeration: only these authored keys may cross the boundary. +use crate::{runner::CliRunner, terminal::CommandSpec}; +use serde::Serialize; +use std::path::{Component, Path, PathBuf}; +const KEYS: &[&str] = &[ + "HOME", + "HACK_HOME", + "PATH", + "HACK_RUNTIME_BACKEND", + "HACK_LAUNCHD_DISABLE", + "HACK_SYSTEMD_DISABLE", + "HACK_DAEMON_AUTOSTART_DISABLE", + "XDG_RUNTIME_DIR", + "XDG_CONFIG_HOME", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_CONFIG", + "HACK_NATIVE_HOME", + "HACK_NATIVE_BINARY", + "HACK_GLOBAL_CONFIG_PATH", +]; +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExecutionContext { + entries: Vec<(String, String)>, +} +impl ExecutionContext { + pub fn capture(runner: &CliRunner) -> Result { + let mut values = Vec::new(); + for key in KEYS { + if let Some(value) = runner.effective_env(key) { + let value = value + .into_string() + .map_err(|_| format!("{key} is not UTF-8; hand-off unavailable"))?; + values.push(((*key).to_owned(), value)); + } + } + Self::from_values(&values) + } + /// Public fixture/adapter seam. Unknown keys are refused, never forwarded. + pub fn from_values(values: &[(String, String)]) -> Result { + if values + .iter() + .any(|(k, v)| !KEYS.contains(&k.as_str()) || v.chars().any(char::is_control)) + { + return Err("Unsupported execution context; use documented terminal setup".into()); + } + let get = |key: &str| { + values + .iter() + .rev() + .find(|(k, _)| k == key) + .map(|(_, v)| v.trim()) + .filter(|v| !v.is_empty()) + }; + let home = get("HOME").ok_or("HOME is unavailable; hand-off refused")?; + if !Path::new(home).is_absolute() { + return Err("HOME must be absolute; hand-off refused".into()); + } + let home = normalized(Path::new(home)); + let hack = match get("HACK_HOME") { + Some(p) if Path::new(p).is_absolute() => normalized(Path::new(p)), + Some(_) => return Err("HACK_HOME must be absolute; hand-off refused".into()), + None => home.join(".hack"), + }; + let path = crate::runner::sanitize_path(std::ffi::OsStr::new( + get("PATH").unwrap_or("/usr/bin:/bin"), + )) + .into_string() + .map_err(|_| "PATH is not UTF-8")?; + let mut entries = vec![ + ("HOME".into(), home.to_string_lossy().into_owned()), + ("PATH".into(), path), + ]; + // Keep an absent override absent: candidate launchers may choose a + // private default home. Both reads and hand-offs see the same absence. + if get("HACK_HOME").is_some() { + entries.push(("HACK_HOME".into(), hack.to_string_lossy().into_owned())); + } + if let Some(backend) = get("HACK_RUNTIME_BACKEND") { + if !["compose", "native"].contains(&backend) { + return Err("Unsupported backend context; hand-off refused".into()); + } + entries.push(("HACK_RUNTIME_BACKEND".into(), backend.into())); + } + for key in ["HACK_LAUNCHD_DISABLE", "HACK_SYSTEMD_DISABLE"] { + entries.push(( + key.into(), + if get(key) == Some("1") { "1" } else { "0" }.into(), + )); + } + // Observers and fixes never opt into incidental daemon autostart. + entries.push(("HACK_DAEMON_AUTOSTART_DISABLE".into(), "1".into())); + if let Some(dir) = get("XDG_RUNTIME_DIR") { + if !Path::new(dir).is_absolute() { + return Err("XDG_RUNTIME_DIR must be absolute; hand-off refused".into()); + } + entries.push(("XDG_RUNTIME_DIR".into(), dir.into())); + } + // The CLI places the systemd user unit under an absolute + // XDG_CONFIG_HOME and ignores a relative one (XDG spec); keep the same + // selection so setup checks and installs the user's actual unit. + if let Some(dir) = get("XDG_CONFIG_HOME").filter(|d| Path::new(d).is_absolute()) { + entries.push(( + "XDG_CONFIG_HOME".into(), + normalized(Path::new(dir)).to_string_lossy().into_owned(), + )); + } + // Native runtime selectors decide which runtime native reads and + // actions address; the CLI requires absolute paths. + // HACK_GLOBAL_CONFIG_PATH selects managed DNS domains and the proxy + // bind address for checks and repairs alike. + for key in [ + "HACK_NATIVE_HOME", + "HACK_NATIVE_BINARY", + "HACK_GLOBAL_CONFIG_PATH", + ] { + if let Some(path) = get(key) { + if !Path::new(path).is_absolute() { + return Err(format!("{key} must be absolute; hand-off refused")); + } + entries.push(( + key.into(), + normalized(Path::new(path)).to_string_lossy().into_owned(), + )); + } + } + // Docker selectors decide which engine Compose checks and repairs + // address. Keep validated ones; refuse anything else rather than + // silently falling back to the default engine. + if let Some(context) = get("DOCKER_CONTEXT") { + if !context + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return Err("Unsupported DOCKER_CONTEXT; hand-off refused".into()); + } + entries.push(("DOCKER_CONTEXT".into(), context.into())); + } + if let Some(config) = get("DOCKER_CONFIG") { + if !Path::new(config).is_absolute() { + return Err("DOCKER_CONFIG must be absolute; hand-off refused".into()); + } + entries.push(( + "DOCKER_CONFIG".into(), + normalized(Path::new(config)).to_string_lossy().into_owned(), + )); + } + if let Some(host) = get("DOCKER_HOST") { + let supported = match host.split_once("://") { + Some(("unix", path)) => Path::new(path).is_absolute(), + Some(("tcp" | "ssh", rest)) => !rest.is_empty(), + _ => false, + }; + if !supported || host.chars().any(char::is_whitespace) { + return Err("Unsupported DOCKER_HOST; hand-off refused".into()); + } + entries.push(("DOCKER_HOST".into(), host.into())); + } + Ok(Self { entries }) + } + /// Preserve the checked home/program context while selecting an authored + /// backend for observations and fixes belonging exclusively to that backend. + pub fn with_backend(&self, backend: &str) -> Result { + let mut values = self.entries.clone(); + values.retain(|(key, _)| key != "HACK_RUNTIME_BACKEND"); + values.push(("HACK_RUNTIME_BACKEND".into(), backend.into())); + Self::from_values(&values) + } + /// The same context with `PATH` reduced to directories no other user can + /// change; with none left, the context's system default PATH applies. + pub fn with_controlled_path(&self) -> Result { + let kept = self + .value("PATH") + .map(|p| crate::doctor_plugins::controlled_path_entries(p).join(":")) + .unwrap_or_default(); + let mut values = self.entries.clone(); + values.retain(|(key, _)| key != "PATH"); + if !kept.is_empty() { + values.push(("PATH".into(), kept)); + } + Self::from_values(&values) + } + pub fn value(&self, key: &str) -> Option<&str> { + self.entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + pub fn install_refusal(&self) -> Option<&'static str> { + if self.value("HACK_LAUNCHD_DISABLE") == Some("1") { + return Some("Login-service installation is disabled by the launchd guard"); + } + if self.value("HACK_SYSTEMD_DISABLE") == Some("1") { + return Some("Login-service installation is disabled by the systemd guard"); + } + if self.value("HACK_HOME").is_some() + && self.value("HACK_HOME").map(PathBuf::from) + != self.value("HOME").map(|h| Path::new(h).join(".hack")) + { + return Some("This isolated Hack home does not own the login service"); + } + None + } + /// Compose with a single spec or each authored spec in Upgrade's sequence. + /// env -i prevents a terminal login environment from selecting another home, + /// backend or guard. Every assignment remains an independently quoted argv. + pub fn bind(&self, spec: &CommandSpec) -> Result { + if !Path::new(&spec.program).is_absolute() { + return Err("A checked absolute CLI executable is required".into()); + } + spec.display()?; + let mut args = vec!["-i".into()]; + args.extend(self.entries.iter().map(|(k, v)| format!("{k}={v}"))); + args.push(spec.program.clone()); + args.extend(spec.args.clone()); + Ok(CommandSpec { + program: "/usr/bin/env".into(), + args, + }) + } + /// Bound runner for all reads/actions: pinned program, clean environment, + /// and only this non-secret allowlist (plus the runner's no-prompt guard). + pub fn runner(&self, program: &str) -> Result { + if !Path::new(program).is_absolute() { + return Err("A checked absolute CLI executable is required".into()); + } + let mut runner = CliRunner::verified(program).with_clean_env(); + for (k, v) in &self.entries { + runner = runner.with_env(k, v); + } + Ok(runner) + } +} +fn normalized(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for part in path.components() { + match part { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 8dca100f9..52d4911a9 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -20,11 +20,18 @@ pub mod logs; pub mod models; mod process; pub mod runner; +pub mod setup; +pub mod setup_fixtures; +pub mod setup_io; pub mod source; pub mod state; +pub mod terminal; pub mod viewmodel; pub mod env; +pub mod execution; + +mod setup_login; #[cfg(test)] mod test_support; diff --git a/apps/desktop/core/src/process.rs b/apps/desktop/core/src/process.rs index e2b5b050d..99a09171a 100644 --- a/apps/desktop/core/src/process.rs +++ b/apps/desktop/core/src/process.rs @@ -4,8 +4,15 @@ #[cfg(not(target_os = "macos"))] pub(crate) use std::process::Child; +/// `inherit_env` must be false exactly when the caller applied `env_clear`: +/// `Command` has no accessor for that flag, and the Apple boundary builds the +/// child environment itself. #[cfg(not(target_os = "macos"))] -pub(crate) fn spawn(command: &mut std::process::Command) -> std::io::Result { +pub(crate) fn spawn( + command: &mut std::process::Command, + inherit_env: bool, +) -> std::io::Result { + let _ = inherit_env; // std carries env_clear on this platform. command.spawn() } diff --git a/apps/desktop/core/src/process/apple.rs b/apps/desktop/core/src/process/apple.rs index 5dfaabbeb..2bf2dbf19 100644 --- a/apps/desktop/core/src/process/apple.rs +++ b/apps/desktop/core/src/process/apple.rs @@ -130,16 +130,20 @@ impl Child { /// The runner always supplies null stdin, piped outputs, and its own process /// group. Environment overrides/removals and cwd come from the authored Command. -/// This boundary inherits the parent environment like CliRunner. It does not -/// accept env_clear: the caller must use only Command options built by the -/// current runner. Future clear-environment support must be carried explicitly -/// here; Command offers no accessor for its environment-clear flag. -pub(crate) fn spawn(command: &mut Command) -> io::Result { +/// With `inherit_env` the child starts from the parent environment, like +/// CliRunner's default; without it (the runner's clean-environment mode, which +/// also calls env_clear) it starts empty. Command offers no accessor for its +/// environment-clear flag, so the caller must pass it explicitly. +pub(crate) fn spawn(command: &mut Command, inherit_env: bool) -> io::Result { let args: Vec = std::iter::once(command.get_program()) .chain(command.get_args()) .map(cstring) .collect::>()?; - let mut environment: BTreeMap = std::env::vars_os().collect(); + let mut environment: BTreeMap = if inherit_env { + std::env::vars_os().collect() + } else { + BTreeMap::new() + }; for (key, value) in command.get_envs() { if let Some(value) = value { environment.insert(key.into(), value.into()); @@ -297,7 +301,7 @@ mod tests { control.stdout, b"inherited", "probe detects the old boundary" ); - let mut child = spawn(&mut command).unwrap(); + let mut child = spawn(&mut command, true).unwrap(); let mut output = String::new(); child .stdout @@ -337,7 +341,7 @@ mod contract_tests { "-c", "printf '%s\\n' \"$1\" \"$HACK_DESKTOP_PROCESS_TEST\" \"$PWD\"; if read -r unused; then printf unexpected; else printf 'eof\\n'; fi; /bin/ps -o pgid= -p $$; printf diagnostic >&2", "fixture", "space and ' quote", ]); - let mut child = spawn(&mut command).unwrap(); + let mut child = spawn(&mut command, true).unwrap(); let mut stdout = String::new(); let mut stderr = String::new(); child @@ -411,7 +415,7 @@ mod signal_tests { ); let mut command = Command::new("/bin/sh"); command.args(["-c", "kill -TERM $$; sleep 5; echo survived"]); - let mut child = spawn(&mut command).unwrap(); + let mut child = spawn(&mut command, true).unwrap(); assert_eq!(child.wait().unwrap().signal(), Some(libc::SIGTERM)); } }) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 1039b80cf..e47ce99d8 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -72,8 +72,16 @@ pub struct CliRunner { program: PathBuf, env: Vec<(String, String)>, cwd: Option, - /// `on_path` found no executable: spawn reports `NotFound`. + /// An invalid explicit selection: spawn reports `NotFound`. missing: bool, + path_name: Option, + /// For PATH discovery: the last validated executable, shared by clones so + /// automatic reads reuse it instead of searching PATH on every use. + pinned: Option>>>, + /// An explicit `HACK_DESKTOP_CLI` selection: checked like PATH discovery + /// (ownership, write, links, ACL and `#!` interpreter) before every use. + verify_explicit: bool, + clear_env: bool, /// How long a timed-out command gets after SIGTERM before SIGKILL. term_grace: Duration, /// Remove inherited variables that make interpreters load code. @@ -122,6 +130,26 @@ fn is_startup_var(name: &std::ffi::OsStr) -> bool { .any(|p| bytes.starts_with(p.as_bytes())) } +/// A CLI executable may run only if it and every directory above it are owned +/// by this user or root and not writable by group or others (links and ACLs +/// included), and its `#!` interpreter passes the same rule without the env +/// form. Refused before it ever runs. +fn trusted_cli(program: &Path) -> Result<(), RunError> { + if std::fs::symlink_metadata(program).is_err() { + return Err(RunError::NotFound); + } + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + crate::source::trusted_tool_path(program, uid, false) + .filter(|real| crate::source::trusted_interpreter(real, uid, 4, false)) + .map(|_| ()) + .ok_or_else(|| { + RunError::Spawn( + "the selected hack is in a location other users can change; it was not run".into(), + ) + }) +} + /// Default grace after SIGTERM. The CLI forwards SIGTERM to the detached /// subprocess groups it owns (for example a Compose build) and waits for /// them; SIGKILL alone would orphan those and kill the CLI's own timer. @@ -136,11 +164,24 @@ impl CliRunner { env: vec![("HACK_NO_INTERACTIVE".into(), "1".into())], cwd: None, missing: false, + path_name: None, + pinned: None, + verify_explicit: false, + clear_env: false, term_grace: TERM_GRACE, strip_startup: false, } } + /// Run `program` only after [`trusted_cli`]-style checks on every use: + /// ownership, write permissions, links, ACLs and its `#!` interpreter. + #[must_use] + pub fn verified(program: impl Into) -> Self { + let mut runner = Self::new(program); + runner.verify_explicit = true; + runner + } + /// Change the SIGTERM-to-SIGKILL grace for timed-out commands. #[must_use] pub fn with_term_grace(mut self, grace: Duration) -> Self { @@ -148,26 +189,115 @@ impl CliRunner { self } - /// Find `name` on `PATH` and run it by absolute path. + /// Discover `name` on `PATH` for each invocation and run its absolute path. /// /// Only absolute `PATH` entries are searched: an empty or relative entry - /// (such as `.`) would resolve against the working directory, which may - /// differ from the user's checkout for machine checks. When nothing is - /// found the runner reports `NotFound` without + /// (such as `.`) would resolve against the working directory, which for + /// machine checks is a shared temp directory another local user may + /// write. When nothing is found the runner reports `NotFound` without /// spawning anything. #[must_use] pub fn on_path(name: &str) -> Self { - let path = std::env::var_os("PATH"); - match resolve_on_path(name, path.as_deref()) { - Some(p) => Self::new(p), - None => { - let mut r = Self::new(name); - r.missing = true; - r + let mut runner = Self::new(name); + runner.path_name = Some(name.into()); + runner.pinned = Some(std::sync::Arc::default()); + runner + } + + /// Select an explicit absolute CLI path, or discover `hack` on each call. + /// Relative selections fail closed, rather than falling back to another CLI. + pub fn selected(value: Option<&std::ffi::OsStr>) -> Self { + match value { + None => Self::on_path("hack"), + Some(path) if Path::new(path).is_absolute() => { + let mut runner = Self::new(PathBuf::from(path)); + runner.verify_explicit = true; + runner + } + Some(_) => { + let mut runner = Self::new("hack"); + runner.missing = true; + runner + } + } + } + + /// The executable to run. Explicit selections are fixed. PATH discovery + /// reuses the last validated result; only the first use and explicit setup + /// checks ([`Self::rediscover`]) search PATH, so a CLI added later to an + /// earlier PATH directory is never picked up by an automatic read. + pub fn resolve_program(&self) -> Result { + if self.missing { + return Err(RunError::NotFound); + } + match (&self.path_name, &self.pinned) { + (Some(_), Some(pinned)) => { + let current = pinned.lock().map_err(|_| RunError::NotFound)?.clone(); + match current { + Some(path) => Ok(path), + None => self.rediscover(), + } } + _ if self.verify_explicit => trusted_cli(&self.program).map(|()| self.program.clone()), + _ => Ok(self.program.clone()), } } + /// This runner bound to its current executable by absolute path, so a later + /// `with_env("PATH", ..)` (Doctor's private tool PATH) cannot switch CLIs. + /// + /// # Errors + /// As [`Self::resolve_program`]. + pub fn fixed(&self) -> Result { + let program = self.resolve_program()?; + Ok(Self { + program, + path_name: None, + pinned: None, + ..self.clone() + }) + } + + /// Search PATH again (explicit setup checks, so Check again recovers after + /// an install), validate the result and pin it for later reads and + /// actions. The executable and every directory above it must be owned by + /// this user or root and not writable by group or others; otherwise it is + /// refused before it ever runs. A failed search clears the pin. + /// + /// # Errors + /// `NotFound`, or `Spawn` for an untrusted location. + pub fn rediscover(&self) -> Result { + let (Some(name), Some(pinned)) = (&self.path_name, &self.pinned) else { + return self.resolve_program(); + }; + let inherited = std::env::var_os("PATH"); + let path = self + .env + .iter() + .rev() + .find(|(key, _)| key == "PATH") + .map(|(_, value)| std::ffi::OsStr::new(value)) + .or(inherited.as_deref()); + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + let found = resolve_on_path(name, path).ok_or(RunError::NotFound).and_then(|p| { + // The CLI's own #! interpreter must pass the same rule; its + // children use the ordinary PATH, so the env form is refused. + crate::source::trusted_tool_path(&p, uid, false) + .filter(|real| crate::source::trusted_interpreter(real, uid, 4, false)) + .map(|_| p) + .ok_or_else(|| { + RunError::Spawn( + "the hack found on PATH is in a location other users can change; it was not run" + .into(), + ) + }) + }); + let mut slot = pinned.lock().map_err(|_| RunError::NotFound)?; + *slot = found.as_ref().ok().cloned(); + found + } + /// Run every command from `dir` (for checks that must run outside any /// project, such as the machine-level doctor run). #[must_use] @@ -180,11 +310,14 @@ impl CliRunner { /// a temporary `HACK_HOME`). #[must_use] pub fn with_env(mut self, key: &str, value: &str) -> Self { + // A different search path is a different discovery: start a new pin. + if key == "PATH" && self.pinned.is_some() { + self.pinned = Some(std::sync::Arc::default()); + } self.env.push((key.into(), value.into())); self } - /// Effective selector without reading any configuration or secret value. /// Remove inherited interpreter startup and preload variables /// ([`INTERPRETER_STARTUP_VARS`] and exported Bash functions) from every /// child, so a tool script cannot be made to load code from the @@ -197,28 +330,35 @@ impl CliRunner { /// The absolute executable this runner would start, without starting it. pub(crate) fn program_path(&self) -> Option { - if self.missing { - return None; - } - if self.program.is_absolute() { - return Some(self.program.clone()); - } - let name = self.program.to_str()?; - resolve_on_path(name, self.env_value("PATH").as_deref()) + self.resolve_program().ok() } - pub(crate) fn env_value(&self, key: &str) -> Option { + /// Read one authored non-secret key without enumerating the environment. + pub fn effective_env(&self, key: &str) -> Option { self.env .iter() .rev() .find(|(k, _)| k == key) .map(|(_, v)| v.into()) - .or_else(|| std::env::var_os(key)) + .or_else(|| { + if self.clear_env { + None + } else { + std::env::var_os(key) + } + }) + } + pub fn with_clean_env(mut self) -> Self { + self.clear_env = true; + self } - fn command(&self, args: &[&str], path: Option<&Path>) -> Command { + fn command(&self, args: &[&str], path: Option<&Path>, program: &Path) -> Command { use std::os::unix::process::CommandExt; - let mut cmd = Command::new(&self.program); + let mut cmd = Command::new(program); + if self.clear_env { + cmd.env_clear(); + } cmd.args(args); if let Some(p) = path { cmd.arg("--path").arg(p); @@ -257,15 +397,13 @@ impl CliRunner { } fn spawn(&self, args: &[&str], path: Option<&Path>) -> Result { - if self.missing { - return Err(RunError::NotFound); - } + let program = self.resolve_program()?; // ETXTBSY: the executable was just written (a fresh upgrade, or a test // script whose write descriptor a concurrent fork still holds). Retry // briefly instead of reporting a spawn failure. let mut attempt = 0; loop { - match crate::process::spawn(&mut self.command(args, path)) { + match crate::process::spawn(&mut self.command(args, path, &program), !self.clear_env) { Ok(c) => return Ok(c), Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) && attempt < 20 => { attempt += 1; @@ -1180,6 +1318,106 @@ mod tests { assert_retired(pid); } + #[test] + fn clean_environment_reaches_the_child_on_every_platform() { + // The Apple spawn boundary builds the child environment itself, so a + // clean runner must say so explicitly; env_clear alone is not visible. + assert!( + std::env::var_os("HOME").is_some(), + "precondition: parent HOME" + ); + let f = FakeHack::new(r#"printf '%s|%s' "${HOME-unset}" "${HACK_HOME-unset}""#); + let (inherited, _, code) = f.runner().run_raw(&["x"], None, T).unwrap(); + assert_eq!(code, Some(0)); + assert!( + !inherited.starts_with("unset|"), + "positive control: {inherited}" + ); + // Only HACK_HOME is authored here, so HOME must not reach the child. + let (clean, _, code) = CliRunner::new(f.dir.join("hack")) + .with_env("HACK_HOME", &f.dir.to_string_lossy()) + .with_clean_env() + .run_raw(&["x"], None, T) + .unwrap(); + assert_eq!(code, Some(0)); + assert_eq!(clean, format!("unset|{}", f.dir.to_string_lossy())); + } + + #[test] + fn path_discovery_is_pinned_and_untrusted_locations_never_run() { + use std::os::unix::fs::PermissionsExt; + let root = std::env::temp_dir().join(format!( + "hack-desktop-pin-{}-{}", + std::process::id(), + rand_suffix() + )); + let (early, late) = (root.join("early"), root.join("late")); + for dir in [&early, &late] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let marker = root.join("ran"); + let fake = |dir: &std::path::Path| { + crate::test_support::script( + &dir.join("hack"), + format!("#!/bin/sh\n: > '{}'\necho '{{}}'\n", marker.display()), + ); + }; + fake(&late); + let path = format!("{}:{}", early.display(), late.display()); + let runner = CliRunner::on_path("hack").with_env("PATH", &path); + let clone = runner.clone(); + assert_eq!(runner.resolve_program().unwrap(), late.join("hack")); + // A CLI added later to an earlier PATH directory is not picked up by + // ordinary reads (clones share the pin) ... + fake(&early); + assert_eq!(clone.resolve_program().unwrap(), late.join("hack")); + // Doctor swaps PATH for its private tool wrappers on a fixed runner, + // which keeps the pinned CLI instead of searching the new PATH. + assert_eq!( + runner + .fixed() + .unwrap() + .with_env("PATH", early.to_str().unwrap()) + .resolve_program() + .unwrap(), + late.join("hack") + ); + // ... only by an explicit rediscovery, which validates it first. + assert_eq!(runner.rediscover().unwrap(), early.join("hack")); + std::fs::set_permissions(&early, std::fs::Permissions::from_mode(0o777)).unwrap(); + let _ = std::fs::remove_file(&marker); + assert!(matches!(runner.rediscover(), Err(RunError::Spawn(_)))); + assert!( + runner.run_raw(&["x"], None, T).is_err(), + "no pin remains after a refused discovery" + ); + assert!(!marker.exists(), "an untrusted CLI never runs"); + // A trusted CLI script whose interpreter another user can replace, + // or that uses env with the ordinary PATH, is refused too. + std::fs::set_permissions(&early, std::fs::Permissions::from_mode(0o755)).unwrap(); + let shared = root.join("shared"); + std::fs::create_dir(&shared).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + std::fs::copy("/bin/sh", shared.join("interp")).unwrap(); + for shebang in [ + shared.join("interp").display().to_string(), + "/usr/bin/env sh".into(), + ] { + std::fs::remove_file(early.join("hack")).unwrap(); + crate::test_support::script( + &early.join("hack"), + format!("#!{shebang}\n: > '{}'\n", marker.display()), + ); + assert!( + matches!(runner.rediscover(), Err(RunError::Spawn(_))), + "{shebang}" + ); + assert!(!marker.exists()); + } + let _ = std::fs::remove_dir_all(root); + } + #[test] fn interpreter_startup_variables_never_reach_doctor_children() { // A run-time check with variables SIP-protected macOS shells keep; @@ -1218,7 +1456,7 @@ mod tests { runner = runner.with_env(name, "/example/value"); } let removed = |runner: &CliRunner| { - let command = runner.command(&["x"], None); + let command = runner.command(&["x"], None, std::path::Path::new("/example/hack")); names .iter() .filter(|name| { diff --git a/apps/desktop/core/src/setup.rs b/apps/desktop/core/src/setup.rs new file mode 100644 index 000000000..85ee030a2 --- /dev/null +++ b/apps/desktop/core/src/setup.rs @@ -0,0 +1,497 @@ +//! Fresh setup evidence. Skips only affect this view; they never satisfy a check. +use crate::{ + doctor::DoctorRun, + models::{DoctorCheck, ProjectsPayload}, + source::Snapshot, +}; +use serde::Serialize; + +pub const NATIVE_DOC: &str = + "https://github.com/hack-dance/hack/blob/next/docs/guides/native-candidate.md"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Status { + Passed, + NeedsAttention, + Unknown, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum SafeAction { + StartDaemon, + InstallDaemon, +} +impl SafeAction { + pub fn args(self) -> &'static [&'static str] { + match self { + Self::StartDaemon => &["daemon", "start"], + Self::InstallDaemon => &["daemon", "install"], + } + } +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum Remedy { + Copy(String), + Admin(Vec), + Run(SafeAction), + Doc(String), +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Card { + pub id: String, + pub title: String, + pub status: Status, + pub detail: String, + pub remedy: Remedy, +} +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct Report { + pub cards: Vec, + pub cli_program: Option, + pub context: Option, + pub force_cli_gate: bool, +} +impl Report { + pub fn needed(&self) -> bool { + self.cards.is_empty() || self.cards.iter().any(|c| c.status != Status::Passed) + } + /// Bind authored argv to the executable and non-secret context actually checked. + pub fn command(&self, args: Vec) -> Result { + if !self.cli_ready() { + return Err("The CLI has not been checked".into()); + } + // A bound command runs later in a terminal or from the clipboard, so + // its PATH keeps only directories no other user can change. + let context = &self + .context + .as_ref() + .ok_or("Execution context has not been checked")? + .with_controlled_path()?; + let compose = if args.first().is_some_and(|s| s == "global") + && args.get(1).is_some_and(|s| s == "install" || s == "trust") + { + Some(context.with_backend("compose")?) + } else { + None + }; + let program = self + .cli_program + .clone() + .ok_or("CLI executable has not been checked")?; + let mut spec = compose + .as_ref() + .unwrap_or(context) + .bind(&crate::terminal::CommandSpec { + program: program.clone(), + args, + })?; + // The CLI restarts or starts the daemon by invoking itself and would + // otherwise prefer whatever `hack` is first on PATH: pin the checked one. + let at = spec + .args + .iter() + .position(|a| *a == program) + .ok_or("Bound command lost its executable")?; + spec.args.insert(at, format!("HACK_MCP_COMMAND={program}")); + Ok(spec) + } + pub fn cli_ready(&self) -> bool { + self.cards + .first() + .is_some_and(|c| c.id == "cli" && c.status == Status::Passed) + } +} +#[derive(Debug, Clone, Default)] +pub struct Pane { + pub report: Report, + pub loading: bool, + pub action_busy: bool, + pub refresh_again: bool, + pub pending_fix: bool, + pub result: Option, + pub skipped: std::collections::BTreeSet, + generation: u64, + pub checked_at: Option, +} +impl Pane { + pub fn begin(&mut self) -> u64 { + self.generation += 1; + self.loading = true; + if !self.pending_fix { + self.result = None; + } + self.generation + } + pub fn finish(&mut self, generation: u64, report: Report) { + if generation == self.generation { + self.report = report; + self.loading = false; + self.checked_at = Some(std::time::Instant::now()); + } + } + pub fn skip(&mut self, id: &str) { + if id != "cli" && self.report.cards.iter().any(|c| c.id == id) { + self.skipped.insert(id.into()); + } + } +} +#[derive(Debug, Clone, Copy)] +pub enum Platform { + Mac, + Linux, + Other, +} +impl Platform { + pub fn current() -> Self { + if cfg!(target_os = "macos") { + Self::Mac + } else if cfg!(target_os = "linux") { + Self::Linux + } else { + Self::Other + } + } +} + +fn evidence(check: Option<&DoctorCheck>) -> (Status, String) { + match check { + Some(c) => ( + match c.status.as_str() { + "ok" => Status::Passed, + "warn" | "error" => Status::NeedsAttention, + _ => Status::Unknown, + }, + c.detail.chars().take(2048).collect(), + ), + None => (Status::Unknown, "Check missing; could not confirm".into()), + } +} +fn check(runs: &[DoctorRun], project: Option<&str>, id: &str) -> (Status, String) { + // Never borrow another project's evidence. Duplicate conflicting reports fail closed. + let found: Vec<_> = runs + .iter() + .filter(|r| r.project.as_deref() == project) + .filter_map(|r| r.result.as_ref().ok()) + .flat_map(|r| &r.checks) + .filter(|c| c.id == id) + .collect(); + if found.len() == 1 { + evidence(found.first().copied()) + } else { + (Status::Unknown, format!("{id}: missing or ambiguous check")) + } +} +pub(crate) fn card(id: &str, title: &str, evidence: (Status, String), remedy: Remedy) -> Card { + Card { + id: id.into(), + title: title.into(), + status: evidence.0, + detail: evidence.1, + remedy, + } +} +/// Docker readiness for the engine Setup itself checks: the machine Doctor +/// run's `docker daemon` check, which runs with the app's checked Docker +/// selectors. A running hackd's `runtime_ok` is not used: it describes the +/// engine selected by the daemon's own environment, which may differ. +fn docker_evidence(runs: &[DoctorRun]) -> (Status, String) { + match check(runs, None, "docker daemon") { + (Status::Passed, _) => (Status::Passed, "Docker reachable".into()), + (Status::NeedsAttention, detail) => ( + Status::NeedsAttention, + format!("Docker not reachable; Compose projects only. {detail}"), + ), + (_, _) => ( + Status::Unknown, + "Could not confirm Docker for the selected engine".into(), + ), + } +} +pub(crate) fn boolean(value: Option, yes: &str, no: &str) -> (Status, String) { + match value { + Some(true) => (Status::Passed, yes.into()), + Some(false) => (Status::NeedsAttention, no.into()), + None => (Status::Unknown, "Could not confirm".into()), + } +} + +/// Derive setup from fresh observations; exit codes alone never enter this decision. +/// `daemon` is the bare `daemon status --json`, `socket_version` is `/v1/status`. +pub fn derive( + cli: Result<&str, &str>, + projects: Option<&ProjectsPayload>, + socket_version: Option<&str>, + daemon: Option<&serde_json::Value>, + runs: &[DoctorRun], + platform: Platform, +) -> Report { + let cli_evidence = match cli { + Ok(v) if supported_cli(v) => (Status::Passed, format!("Hack {v}")), + Err(why) => ( + Status::Unknown, + format!("{why}. Check again to confirm the required Hack CLI (5.x)"), + ), + Ok(version) => ( + Status::Unknown, + format!("Unsupported CLI {version}; this preview requires 5.x"), + ), + }; + let mut cards = vec![card( + "cli", + "Hack CLI · required", + cli_evidence, + Remedy::Doc( + "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/install.md".into(), + ), + )]; + let native: Vec<_> = projects + .into_iter() + .flat_map(|p| &p.projects) + .filter(|p| p.is_native()) + .collect(); + // Empty registry still needs the Compose machine setup. A confirmed native-only + // registry must never offer global install/trust, which refuse native projects. + let compose = + projects.is_none_or(|p| p.projects.is_empty() || p.projects.iter().any(|p| !p.is_native())); + if compose { + cards.push(card( + "docker", + "Docker · Compose", + docker_evidence(runs), + Remedy::Doc("https://docs.docker.com/engine/install/".into()), + )); + } + if projects.is_none() + && let Some(docker) = cards + .iter_mut() + .find(|c| c.id == "docker" && c.status == Status::Unknown) + { + docker.detail = "Could not read project or Docker status. Start a matching version of hackd, then check again.".into(); + } + for p in &native { + let state = p + .native + .as_ref() + .filter(|_| p.status != "missing" && p.repo_root.is_some()) + .map(|n| n.status.as_str()) + .unwrap_or("unreadable or missing project"); + cards.push(card( + &format!("native:{}", p.name), + &format!("Native runtime · {}", p.name), + ( + if state == "observed" { + Status::Passed + } else { + Status::Unknown + }, + format!("{state}; native setup documentation"), + ), + Remedy::Doc(NATIVE_DOC.into()), + )); + } + let running = daemon + .and_then(|d| d.get("running")) + .and_then(serde_json::Value::as_bool); + let inconsistent = daemon.is_some_and(|d| { + ["api_ok", "api_reachable", "api_compatible"] + .iter() + .any(|key| d.get(key).and_then(serde_json::Value::as_bool) == Some(false)) + || d.get("stale").and_then(serde_json::Value::as_bool) == Some(true) + || d.get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|s| s != "running") + }); + let daemon_evidence = match (running, cli, socket_version) { + (Some(true), _, _) if inconsistent => ( + Status::Unknown, + "Daemon reports starting, stale or incompatible; check again".into(), + ), + (Some(true), Ok(c), Some(v)) if !v.is_empty() && c == v => { + (Status::Passed, format!("hackd {v}")) + } + (Some(true), Ok(c), Some(v)) => ( + Status::NeedsAttention, + format!("hackd {v}, CLI {c}; restart from your terminal"), + ), + (Some(false), _, _) => ( + Status::NeedsAttention, + "hackd off; status refreshes slowly".into(), + ), + _ => ( + Status::Unknown, + "Could not confirm hackd and socket version".into(), + ), + }; + cards.push(card( + "daemon", + "hackd", + daemon_evidence, + if running == Some(false) { + Remedy::Run(SafeAction::StartDaemon) + } else { + Remedy::Copy("hack daemon restart".into()) + }, + )); + if compose { + cards.push(card( + "routing", + "Routing and DNS · Compose", + routing(runs, projects, platform), + Remedy::Admin(vec!["global".into(), "install".into()]), + )); + } + for p in &native { + cards.push(card( + &format!("dns:{}", p.name), + &format!("Routing and DNS · {}", p.name), + check(runs, Some(&p.name), "native host dns"), + Remedy::Doc(NATIVE_DOC.into()), + )); + } + if compose { + let mut trust = check(runs, None, "local ca trust"); + if trust.0 != Status::Passed { + trust.1 = format!( + "{}; browser may warn. An exported CA alone does not prove browser trust. Older CLIs may not be able to check trust.", + trust.1 + ); + } + cards.push(card( + "trust", + "Certificate trust · Compose", + trust, + Remedy::Admin(vec!["global".into(), "trust".into()]), + )); + } + for p in &native { + cards.push(card( + &format!("trust:{}", p.name), + &format!("Certificate trust · {}", p.name), + check(runs, Some(&p.name), "native root trust"), + Remedy::Doc(NATIVE_DOC.into()), + )); + } + cards.push(crate::setup_login::login_card(daemon, None)); + Report { + cards, + cli_program: cli + .ok() + .filter(|v| supported_cli(v)) + .map(|_| "/usr/local/bin/hack".into()), + context: None, + force_cli_gate: cli.is_ok_and(|v| !supported_cli(v)), + } +} +fn routing( + runs: &[DoctorRun], + projects: Option<&ProjectsPayload>, + platform: Platform, +) -> (Status, String) { + let mut ids: Vec = match platform { + Platform::Linux => vec!["linux dns route".into()], + Platform::Other => return (Status::Unknown, "Unsupported platform".into()), + Platform::Mac => { + let mut domains = std::collections::BTreeSet::from([ + "hack".to_owned(), + "hack.local".into(), + "hack.gy".into(), + ]); + // Include custom domains reported by doctor, never accept just one of + // a resolver/dnsmasq pair. Project host suffixes add missing checks. + for r in runs.iter().filter(|r| r.project.is_none()) { + if let Ok(report) = &r.result { + for c in &report.checks { + if let Some(d) = + c.id.strip_prefix("resolver:") + .or_else(|| c.id.strip_prefix("dnsmasq.conf:")) + { + domains.insert(d.into()); + } + } + } + } + for p in projects + .into_iter() + .flat_map(|p| &p.projects) + .filter(|p| !p.is_native()) + { + if let Some(h) = &p.dev_host + && !domains.iter().any(|d| h.ends_with(&format!(".{d}"))) + && let Some((_, suffix)) = h.split_once('.') + { + domains.insert(suffix.into()); + } + } + let mut v = vec!["dnsmasq:53".into(), "coredns forwarding".into()]; + for d in domains { + v.push(format!("resolver:{d}")); + v.push(format!("dnsmasq.conf:{d}")); + } + v + } + }; + ids.sort(); + let results: Vec<_> = ids.iter().map(|id| (id, check(runs, None, id))).collect(); + if results.iter().all(|(_, (s, _))| *s == Status::Passed) { + return (Status::Passed, "All required DNS checks passed".into()); + } + let status = if results.iter().any(|(_, (s, _))| *s == Status::Unknown) { + Status::Unknown + } else { + Status::NeedsAttention + }; + ( + status, + results + .into_iter() + .filter(|(_, (s, _))| *s != Status::Passed) + .map(|(id, (_, d))| format!("{id}: {d}")) + .collect::>() + .join("; "), + ) +} + +/// Default fixture adapter; no machine reads or actions. +pub fn fixture( + snapshot: Result, + runs: &[DoctorRun], +) -> Report { + let daemon = serde_json::json!({"running":false,"service":{"installed":false}}); + derive( + snapshot + .as_ref() + .map(|s| s.cli_version.as_str()) + .map_err(|_| "unreadable"), + snapshot.as_ref().ok().map(|s| &s.projects), + snapshot + .as_ref() + .ok() + .and_then(|s| s.daemon.as_ref()) + .map(|d| d.version.as_str()), + Some(&daemon), + runs, + Platform::Linux, + ) +} + +/// Explicit preview compatibility range: semver 5.x (including prereleases). +fn supported_cli(version: &str) -> bool { + let core = version.split(['-', '+']).next().unwrap_or(""); + let parts: Vec<_> = core.split('.').collect(); + parts.len() == 3 + && parts[0] == "5" + && parts + .iter() + .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) +} + +/// Focus checks are scoped to visible setup or an outstanding explicit fix. +pub fn should_recheck_on_focus( + setup_shown: bool, + pending_fix: bool, + loading: bool, + busy: bool, +) -> bool { + (setup_shown || pending_fix) && !loading && !busy +} + +pub use crate::setup_login::{installation_refusal, qualify_login}; diff --git a/apps/desktop/core/src/setup_fixtures.rs b/apps/desktop/core/src/setup_fixtures.rs new file mode 100644 index 000000000..aff027b90 --- /dev/null +++ b/apps/desktop/core/src/setup_fixtures.rs @@ -0,0 +1,86 @@ +//! Offline setup examples, shared by the state CLI and offscreen UI. +use crate::{ + doctor::DoctorRun, + models::{DoctorCheck, DoctorReport, ProjectsPayload}, + setup::{self, Platform, Report}, +}; +pub const NAMES: [&str; 5] = [ + "missing-cli", + "partial", + "ready", + "native-unknown", + "service-unavailable", +]; +pub fn load(name: &str) -> Option { + if !NAMES.contains(&name) { + return None; + } + if name == "missing-cli" { + return Some(setup::derive( + Err("CLI unavailable"), + None, + None, + None, + &[], + Platform::Linux, + )); + } + let native = name == "native-unknown"; + let projects = ProjectsPayload { + runtime_ok: Some(true), + projects: if native { + vec![crate::models::ProjectRow { + name: "sample".into(), + repo_root: Some("/work/sample".into()), + backend: Some("native".into()), + ..Default::default() + }] + } else { + vec![] + }, + ..Default::default() + }; + let machine = DoctorRun::machine(Ok(DoctorReport { + checks: vec![ + DoctorCheck { + id: "docker daemon".into(), + status: "ok".into(), + detail: "Docker is running".into(), + }, + DoctorCheck { + id: "linux dns route".into(), + status: "ok".into(), + detail: "Host DNS configured".into(), + }, + DoctorCheck { + id: if name == "partial" { + "caddy local ca" + } else { + "local ca trust" + } + .into(), + status: "ok".into(), + detail: "CA check completed".into(), + }, + ], + ..Default::default() + })); + let daemon = serde_json::json!({"running":name!="partial","socket_path":"/work/user/.hack/daemon/hackd.sock","service":{"available":name!="service-unavailable","installed":name!="partial" && name!="service-unavailable"}}); + let mut report = setup::derive( + if name == "missing-cli" { + Err("missing") + } else { + Ok("5.0.0") + }, + Some(&projects), + Some("5.0.0"), + Some(&daemon), + &[machine], + Platform::Linux, + ); + report.context = + crate::execution::ExecutionContext::from_values(&[("HOME".into(), "/work/user".into())]) + .ok(); + setup::qualify_login(&mut report, Some(&daemon)); + Some(report) +} diff --git a/apps/desktop/core/src/setup_io.rs b/apps/desktop/core/src/setup_io.rs new file mode 100644 index 000000000..658970735 --- /dev/null +++ b/apps/desktop/core/src/setup_io.rs @@ -0,0 +1,203 @@ +//! Setup I/O, separate from the pure evidence model. No observer starts a service. +use crate::{ + setup::{self, Report, SafeAction}, + source::{DoctorTarget, LiveSource}, +}; +use std::time::{Duration, Instant}; + +impl LiveSource { + /// One bounded setup refresh, including native observations and doctor. + pub fn read_setup(&self) -> Report { + self.read_setup_within(Duration::from_secs(120)) + } + + /// Budget seam for bounded adapter tests; live callers use `read_setup`. + pub fn read_setup_within(&self, budget: Duration) -> Report { + let deadline = Instant::now() + budget; + let left = || { + deadline + .saturating_duration_since(Instant::now()) + .saturating_sub(Duration::from_secs(2)) + }; + // Setup checks are the explicit refresh: search PATH again, validate + // and pin, so later automatic reads reuse this executable. + let selected = self.runner.rediscover().and_then(|p| { + p.into_os_string() + .into_string() + .map_err(|_| crate::runner::RunError::Spawn("CLI path is not UTF-8".into())) + }); + let context = crate::execution::ExecutionContext::capture(&self.runner); + let runner = match (&selected, &context) { + (Ok(program), Ok(context)) => context + .runner(program) + .map(|r| r.with_term_grace(Duration::from_secs(2))), + (Err(e), _) => Err(e.to_string()), + (_, Err(e)) => Err(e.clone()), + }; + let runner = match runner { + Ok(r) => r, + Err(reason) => { + let mut report = setup::derive( + Err(&reason), + None, + None, + None, + &[], + setup::Platform::current(), + ); + report.force_cli_gate = matches!(selected, Err(crate::runner::RunError::NotFound)); + return report; + } + }; + let cli = if left().is_zero() { + Err(crate::runner::RunError::Timeout { after: budget }) + } else { + runner.version(Duration::from_secs(5).min(left())) + }; + // Missing CLI is decisive; no reason to attempt other subprocesses. + if cli.is_err() { + let reason = cli + .as_ref() + .err() + .map(ToString::to_string) + .unwrap_or_default(); + let mut report = setup::derive( + Err(&reason), + None, + None, + None, + &[], + setup::Platform::current(), + ); + report.force_cli_gate = matches!(cli, Err(crate::runner::RunError::NotFound)); + return report; + } + let daemon = if left().is_zero() { + None + } else { + crate::source::read_daemon_status(&runner, Duration::from_secs(5).min(left())).ok() + }; + // CLI projects writes the registry even with --no-daemon. Until R18, + // only a matching daemon snapshot is allowed, including during setup. + let client = self.effective_daemon(daemon.as_ref()); + let socket = client.as_ref().and_then(|d| d.status().ok()); + let mut projects = socket + .as_ref() + .filter(|s| cli.as_ref().is_ok_and(|v| v == &s.version)) + .and(client.as_ref()) + .and_then(|d| d.projects().ok()); + if let Some(p) = &mut projects { + for row in p.projects.iter_mut().filter(|p| p.is_native()) { + if row.status != "missing" + && let Some(root) = &row.repo_root + && !left().is_zero() + { + row.native = runner + .clone() + .with_env("HACK_RUNTIME_BACKEND", "native") + .run_json( + &["ps", "--json"], + Some(std::path::Path::new(root)), + Duration::from_secs(10).min(left()), + ) + .ok(); + } + } + } + let targets = projects + .as_ref() + .map(DoctorTarget::from_projects) + .unwrap_or_default(); + // These machine routing/trust cards describe Compose exclusively; + // project observers below still choose each project's own backend. + let observer = LiveSource { + runner: runner.with_env("HACK_RUNTIME_BACKEND", "compose"), + daemon: None, + }; + let runs = observer.doctor_runs_within( + &targets, + Duration::from_secs(90).min(left()), + Duration::from_secs(60), + ); + let mut report = setup::derive( + cli.as_deref().map_err(|_| "CLI unavailable"), + projects.as_ref(), + socket.as_ref().map(|d| d.version.as_str()), + daemon.as_ref(), + &runs, + setup::Platform::current(), + ); + report.cli_program = selected.ok(); + report.context = context.ok(); + setup::qualify_login(&mut report, daemon.as_ref()); + let restart = report + .command(vec!["daemon".into(), "restart".into()]) + .and_then(|c| c.display()) + .ok(); + for card in &mut report.cards { + if card.remedy == setup::Remedy::Copy("hack daemon restart".into()) { + card.remedy = restart.clone().map(setup::Remedy::Copy).unwrap_or_else(|| { + setup::Remedy::Doc( + "https://github.com/hack-dance/hack/blob/next/docs/architecture.md#daemon" + .into(), + ) + }); + } + } + report + } +} + +/// Only these two fixed non-admin actions may run inside setup. Output is bounded +/// and memory-only. Callers must refresh evidence even after an exit of zero. +pub fn run_safe(action: SafeAction, checked: &Report) -> String { + let execute = || -> Result<(String, String, Option), String> { + if !checked.cli_ready() + || !checked.cards.iter().any(|c| { + c.remedy == setup::Remedy::Run(action) && c.status != setup::Status::Passed + }) + { + return Err("Action is not supported by the checked setup report".into()); + } + let context = checked + .context + .as_ref() + .ok_or("Execution context has not been checked")?; + if action == SafeAction::InstallDaemon + && let Some(why) = context.install_refusal() + { + return Err(why.into()); + } + let program = checked + .cli_program + .as_deref() + .ok_or("CLI has not been checked")?; + // The CLI starts or installs the daemon by invoking itself; without + // this selector it would prefer whatever `hack` is first on PATH. + let runner = context + .runner(program)? + .with_env("HACK_MCP_COMMAND", program); + if action == SafeAction::InstallDaemon { + let status = crate::source::read_daemon_status(&runner, Duration::from_secs(5))?; + if let Some(why) = setup::installation_refusal(Some(&status), Some(context)) { + return Err(format!("{why}; no install was attempted")); + } + } + let mut args = action.args().to_vec(); + args.push("--no-interactive"); + runner + .run_raw(&args, None, Duration::from_secs(120)) + .map_err(|e| e.to_string()) + }; + match execute() { + Ok((out, err, code)) => format!( + "Command exit: {code:?}. Checking fresh status.\n{}\n{}", + bounded(&out), + bounded(&err) + ), + Err(e) => format!("No supported action completed: {e}. Check again."), + } +} +fn bounded(s: &str) -> String { + s.chars().take(2048).collect() +} diff --git a/apps/desktop/core/src/setup_login.rs b/apps/desktop/core/src/setup_login.rs new file mode 100644 index 000000000..6d14579b5 --- /dev/null +++ b/apps/desktop/core/src/setup_login.rs @@ -0,0 +1,111 @@ +//! Login service evidence and admission; unavailable managers must never reach install. +use crate::setup::{Card, Remedy, Report, SafeAction, Status, boolean, card}; + +/// Installation is a supported effect only with both manager availability and +/// ownership of the ordinary home. Missing capability is never permission. +pub fn qualify_login(report: &mut Report, daemon: Option<&serde_json::Value>) { + if let Some(card) = report.cards.iter_mut().find(|c| c.id == "login") { + *card = login_card(daemon, report.context.as_ref()); + } +} +pub(crate) fn login_card( + daemon: Option<&serde_json::Value>, + context: Option<&crate::execution::ExecutionContext>, +) -> Card { + let service = daemon + .and_then(|d| d.get("service")) + .filter(|s| s.is_object()); + let bool_field = |key: &str| { + service + .and_then(|s| s.get(key)) + .and_then(serde_json::Value::as_bool) + }; + let next = service + .and_then(|s| s.get("next_step")) + .and_then(serde_json::Value::as_str); + let mut state = boolean( + bool_field("installed"), + "Login service installed", + "Always-on needs the login service", + ); + if bool_field("installed") == Some(true) + && (bool_field("enabled") == Some(false) + || bool_field("active") == Some(false) + || daemon + .and_then(|s| s.pointer("/launchd/loaded")) + .and_then(serde_json::Value::as_bool) + == Some(false) + || daemon + .and_then(|s| s.pointer("/launchd/exit_status")) + .and_then(serde_json::Value::as_i64) + .is_some_and(|code| code != 0) + || next.is_some() + || service + .and_then(|s| s.get("severity")) + .and_then(serde_json::Value::as_str) + == Some("warn")) + { + state = ( + Status::NeedsAttention, + "Login service needs attention".into(), + ); + } + if let Some(summary) = service + .and_then(|s| s.get("summary")) + .and_then(serde_json::Value::as_str) + { + state.1 = summary.chars().take(2048).collect(); + } + let refusal = installation_refusal(daemon, context); + if state.0 == Status::Passed && refusal.is_some() { + state.0 = Status::Unknown; + } + let fix_is_install = + bool_field("installed") != Some(true) || next == Some("hack daemon install"); + let remedy = if refusal.is_none() && fix_is_install { + Remedy::Run(SafeAction::InstallDaemon) + } else { + Remedy::Doc( + "https://github.com/hack-dance/hack/blob/next/docs/architecture.md#daemon".into(), + ) + }; + if let Some(why) = refusal { + state.1 = format!("{}; {why}", state.1); + } + card("login", "Login service", state, remedy) +} +/// Recheck both explicit context and the CLI's effective socket home. A launcher +/// can choose a private home even when HACK_HOME was absent in the app. +pub fn installation_refusal( + daemon: Option<&serde_json::Value>, + context: Option<&crate::execution::ExecutionContext>, +) -> Option { + let Some(context) = context else { + return Some("Execution context has not been checked".into()); + }; + if let Some(why) = context.install_refusal() { + return Some(why.into()); + } + let available = daemon + .and_then(|d| d.get("service")) + .and_then(|s| s.get("available")) + .and_then(serde_json::Value::as_bool); + if available != Some(true) { + return Some("Login service manager is unavailable or could not be confirmed".into()); + } + let expected = context + .value("HOME") + .map(|h| std::path::Path::new(h).join(".hack/daemon/hackd.sock")); + match daemon + .and_then(|d| d.get("socket_path")) + .and_then(serde_json::Value::as_str) + { + Some(path) if Some(std::path::PathBuf::from(path)) != expected => { + Some("The checked daemon uses a different or isolated Hack home".into()) + } + None if context.value("HACK_HOME").is_none() => { + Some("The login-service home could not be confirmed".into()) + } + _ => None, + } +} diff --git a/apps/desktop/core/src/source.rs b/apps/desktop/core/src/source.rs index d080ce1cd..35dd3adc5 100644 --- a/apps/desktop/core/src/source.rs +++ b/apps/desktop/core/src/source.rs @@ -180,6 +180,26 @@ pub trait Source: Send + Sync { fn log_tail(&self, repo_root: &Path, native: bool, lines: u32) -> Result, String>; + /// Fresh setup checks. Fixture implementations stay fully offline. + fn setup(&self) -> crate::setup::Report { + crate::setup::fixture(self.snapshot(), &self.doctor_runs(&[])) + } + + /// Discovery only, not checked-version evidence. Fixes must retain the + /// checked Report/ExecutionContext instead of rediscovering on click. Fixtures refuse. + fn cli_program(&self) -> Result { + Err("Fixture: terminal disabled".into()) + } + + /// Explicit, fixed non-admin setup action; never inferred from Doctor text. + fn setup_action( + &self, + _action: crate::setup::SafeAction, + _checked: &crate::setup::Report, + ) -> String { + "Fixture: no action executed".into() + } + /// Doctor runs: one outside any project, then one per project /// (DESIGN.md section 3). Each run fails on its own. fn doctor_runs(&self, projects: &[DoctorTarget]) -> Vec; @@ -384,6 +404,50 @@ impl Drop for ProbeTools { } } +/// `hack daemon status` is read automatically (Setup, polling, the daemon +/// card, the install pre-check). The CLI's status command also probes Docker +/// (`docker info`) and the service manager from its PATH, though the JSON +/// answer uses neither Docker result. `read` runs with Doctor's private +/// wrapper PATH (only tools no other user can change), interpreter startup +/// variables removed, and no `docker` at all, so an automatic read never +/// starts Docker or its helpers. The wrapper directory outlives `read`. +pub(crate) fn with_status_tools( + runner: &CliRunner, + read: impl FnOnce(&CliRunner) -> T, +) -> Result { + let tools = trusted_probe_path(runner.effective_env("PATH").unwrap_or_default().as_os_str())?; + match std::fs::remove_file(tools.dir.join("docker")) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("Could not prepare the daemon status read".into()), + } + let path = tools + .path() + .to_str() + .ok_or("The private tool PATH is not UTF-8")? + .to_owned(); + let value = read( + &runner + .clone() + .with_env("PATH", &path) + .without_interpreter_startup(), + ); + drop(tools); + Ok(value) +} + +/// `hack daemon status --json` through [`with_status_tools`]. +pub(crate) fn read_daemon_status( + runner: &CliRunner, + timeout: Duration, +) -> Result { + with_status_tools(runner, |runner| { + runner + .run_json::(&["daemon", "status", "--json"], None, timeout) + .map_err(|e| e.to_string()) + })? +} + fn trusted_probe_path(path: &std::ffi::OsStr) -> Result { trusted_probe_path_with_limits(path, 64, 8192) } @@ -630,11 +694,26 @@ impl LiveSource { .collect(); } }; + // Keep the CLI pinned by setup: bind its absolute path before the + // private tool PATH replaces PATH, which would otherwise rediscover. + let pinned = match self.runner.fixed() { + Ok(runner) => LiveSource { + runner, + daemon: self.daemon.clone(), + }, + Err(e) => { + let why = e.to_string(); + return std::iter::once(DoctorRun::machine(Err(why.clone()))) + .chain(projects.iter().map(|t| t.run(Err(why.clone())))) + .collect(); + } + }; + let pinned = &pinned; // Resolve once per refresh, not once per project. The private PATH // stays alive until every bounded child has completed. let tools = match trusted_probe_path( self.runner - .env_value("PATH") + .effective_env("PATH") .unwrap_or_default() .as_os_str(), ) { @@ -675,7 +754,7 @@ impl LiveSource { let machine = match crate::doctor::neutral_dir(&candidates) { Some(dir) if !left().is_zero() => read( &native_pin - .apply(self.runner.clone(), &tools.dir) + .apply(pinned.runner.clone(), &tools.dir) .with_env("PATH", tool_path) .without_interpreter_startup() .with_cwd(dir) @@ -693,7 +772,7 @@ impl LiveSource { .iter() .map(|t| { let runner = native_pin - .apply(self.runner_for(t.native), &tools.dir) + .apply(pinned.runner_for(t.native), &tools.dir) .with_env("PATH", tool_path) .without_interpreter_startup() .with_term_grace(DOCTOR_TERM_GRACE); @@ -724,7 +803,7 @@ impl LiveSource { { let timeout = per_call.min(left()); if !timeout.is_zero() { - let compose = self + let compose = pinned .runner_for(false) .with_env("PATH", tool_path) .without_interpreter_startup() @@ -760,21 +839,34 @@ impl LiveSource { runs } - /// The live source exactly as the app builds it: `hack` resolved on - /// absolute PATH entries, and the hackd socket under `HACK_HOME` - /// (else `~/.hack`). Used by the window and by `hack-desktop-state`. + /// Resolve the selected CLI; its read-only status response owns socket + /// discovery, including candidate launchers with a private default home. #[must_use] pub fn from_env() -> Self { - let socket = crate::daemon::default_socket_path( - std::env::var("HACK_HOME").ok().as_deref(), - std::env::var("HOME").ok().as_deref(), - ); Self { - runner: CliRunner::on_path("hack"), - daemon: socket.map(|s| DaemonClient::new(s, Duration::from_secs(3))), + runner: CliRunner::selected(std::env::var_os("HACK_DESKTOP_CLI").as_deref()), + daemon: None, } } + /// An explicitly injected client is a test seam. Production never guesses + /// a socket from parent environment when the selected CLI omits its path. + pub(crate) fn effective_daemon( + &self, + status: Option<&serde_json::Value>, + ) -> Option { + self.daemon.clone().or_else(|| { + let path = Path::new(status?.get("socket_path")?.as_str()?); + if !path.is_absolute() { + return None; + } + Some(DaemonClient::new( + path.to_path_buf(), + Duration::from_secs(3), + )) + }) + } + fn runner_for(&self, native: bool) -> CliRunner { if native { self.runner @@ -799,6 +891,26 @@ impl LiveSource { } impl Source for LiveSource { + fn cli_program(&self) -> Result { + self.runner + .resolve_program() + .map_err(|e| e.to_string())? + .into_os_string() + .into_string() + .map_err(|_| "CLI path is not UTF-8".into()) + } + + fn setup(&self) -> crate::setup::Report { + self.read_setup() + } + fn setup_action( + &self, + action: crate::setup::SafeAction, + checked: &crate::setup::Report, + ) -> String { + crate::setup_io::run_safe(action, checked) + } + fn open_logs( &self, repo_root: &Path, @@ -844,8 +956,19 @@ impl Source for LiveSource { } fn recover_daemon(&self, action: DaemonRecovery) -> Result<(), String> { + // The CLI starts the daemon by invoking itself and would otherwise + // prefer whatever `hack` is first on PATH: pin the selected one. + let program = self + .runner + .resolve_program() + .map_err(|e| e.to_string())? + .into_os_string() + .into_string() + .map_err(|_| "CLI path is not UTF-8".to_owned())?; let (_, stderr, code) = self .runner + .clone() + .with_env("HACK_MCP_COMMAND", &program) .run_raw( &["daemon", action.command()], None, @@ -867,12 +990,28 @@ impl Source for LiveSource { } } fn snapshot(&self) -> Result { - let cli_version = self.runner.version(Duration::from_secs(5))?; - let daemon = self.daemon.as_ref().and_then(|d| d.status().ok()); + let program = self.runner.resolve_program()?; + let context = + crate::execution::ExecutionContext::capture(&self.runner).map_err(RunError::Spawn)?; + let runner = context + .runner( + program + .to_str() + .ok_or_else(|| RunError::Spawn("CLI path is not UTF-8".into()))?, + ) + .map_err(RunError::Spawn)?; + let cli_version = runner.version(Duration::from_secs(5))?; + let raw = if self.daemon.is_none() { + read_daemon_status(&runner, Duration::from_secs(5)).ok() + } else { + None + }; + let client = self.effective_daemon(raw.as_ref()); + let daemon = client.as_ref().and_then(|d| d.status().ok()); let from_daemon = daemon .as_ref() .filter(|d| d.version == cli_version) - .and(self.daemon.as_ref()) + .and(client.as_ref()) .and_then(|d| d.projects().ok()); let mut projects = from_daemon.ok_or_else(|| SourceError::DaemonUnavailable { cli: cli_version.clone(), @@ -889,7 +1028,7 @@ impl Source for LiveSource { }, })?; // Project views do not read native projects; ask native ps for each. - let native = self.runner_for(true); + let native = runner.with_env("HACK_RUNTIME_BACKEND", "native"); for row in projects.projects.iter_mut().filter(|r| r.is_native()) { if let Some(root) = row.repo_root.clone() { row.native = native @@ -1216,6 +1355,29 @@ impl Source for FixtureSource { mod tests { use super::*; + #[test] + fn effective_socket_requires_explicit_absolute_cli_evidence() { + let source = LiveSource { + runner: CliRunner::new("/missing/hack-next"), + daemon: None, + }; + for status in [ + serde_json::json!({}), + serde_json::json!({"socket_path":"relative/hackd.sock"}), + serde_json::json!({"socket_path":false}), + ] { + assert!(source.effective_daemon(Some(&status)).is_none()); + } + assert!(source.effective_daemon(None).is_none()); + assert!( + source + .effective_daemon(Some( + &serde_json::json!({"socket_path":"/private/candidate/daemon/hackd.sock"}) + )) + .is_some() + ); + } + fn fixture_runner(hack: &Path) -> CliRunner { let root = hack.parent().unwrap().to_str().unwrap(); let bin = hack.parent().unwrap().join("fixture-tools"); @@ -1509,6 +1671,50 @@ esac .collect() } + /// Automatic status reads never reach a `docker` on PATH (the CLI's + /// status command would run `docker info`), and see only private + /// wrappers with startup variables removed. + #[test] + fn daemon_status_reads_never_see_docker() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack( + "status-no-docker", + r#"if command -v docker >/dev/null 2>&1; then d=yes; else d=no; fi +printf '{"docker":"%s","path":"%s","bash_env":"%s","git":"%s"}\n' "$d" "$PATH" "${BASH_ENV-unset}" "$(command -v git >/dev/null 2>&1 && echo yes || echo no)""#, + ); + let root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let tools = root.join("tools"); + std::fs::create_dir(&tools).unwrap(); + std::fs::set_permissions(&tools, std::fs::Permissions::from_mode(0o700)).unwrap(); + let docker = tools.join("docker"); + std::fs::write(&docker, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = format!( + "{}:{}", + tools.display(), + root.join("fixture-tools").display() + ); + let runner = fixture_runner(&hack) + .with_env("PATH", &path) + .with_env("BASH_ENV", "/example/startup"); + // Positive control: the same runner would find docker. + let (plain, _, _) = runner + .run_raw(&["x"], None, Duration::from_secs(10)) + .unwrap(); + assert!(plain.contains(r#""docker":"yes""#), "{plain}"); + let seen = read_daemon_status(&runner, Duration::from_secs(10)).unwrap(); + assert_eq!(seen["docker"], "no"); + assert_eq!(seen["git"], "yes", "other checked tools stay available"); + assert_eq!(seen["bash_env"], "unset"); + let dir = seen["path"].as_str().unwrap(); + assert!(!dir.contains(':'), "{dir}"); + assert!( + !Path::new(dir).exists(), + "the private directory is removed after the read" + ); + let _ = std::fs::remove_dir_all(root); + } + fn fake_hack(tag: &str, body: &str) -> std::path::PathBuf { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!( @@ -1918,7 +2124,7 @@ echo '{"ok":true,"data":{"checks":[{"id":"docker","status":"ok","detail":""}]}}' let private = root.join("private-tools"); let pinned = |runner: CliRunner| { NativePin::apply(&validate_helpers(&runner).unwrap(), runner, &private) - .env_value("HACK_NATIVE_BINARY") + .effective_env("HACK_NATIVE_BINARY") }; let base = |hack: &Path| fixture_runner(hack).with_env("HACK_HOME", root.to_str().unwrap()); // A #! launcher: its eventual executable is unknown. @@ -1945,7 +2151,7 @@ echo '{"ok":true,"data":{"checks":[{"id":"docker","status":"ok","detail":""}]}}' assert!(wrappers.join("native-unavailable").is_file()); let pin = NativePin::Unavailable .apply(base(&launcher), wrappers) - .env_value("HACK_NATIVE_BINARY") + .effective_env("HACK_NATIVE_BINARY") .unwrap(); assert_eq!( Path::new(&pin), @@ -2790,7 +2996,7 @@ echo '{"checks":[{"id":"docker","status":"ok","detail":"healthy"}]}' assert!(!root.join("hack-ran").exists() && !root.join("plugin-ran").exists()); } assert_eq!( - source.runner.env_value("DOCKER_CONTEXT").unwrap(), + source.runner.effective_env("DOCKER_CONTEXT").unwrap(), "fixture-context" ); let _ = std::fs::remove_dir_all(root); diff --git a/apps/desktop/core/src/terminal.rs b/apps/desktop/core/src/terminal.rs new file mode 100644 index 000000000..9faa36852 --- /dev/null +++ b/apps/desktop/core/src/terminal.rs @@ -0,0 +1,357 @@ +//! Explicit terminal hand-off. No shell interpolation, existing panes or passwords. +//! `CommandSpec` is authored by the app, never constructed from a CLI fix string. +use std::io::Write; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone)] +pub struct CommandSpec { + pub program: String, + pub args: Vec, +} +impl CommandSpec { + /// All arguments are quoted independently, including the executable. + pub fn display(&self) -> Result { + std::iter::once(&self.program) + .chain(&self.args) + .map(|s| quote(s)) + .collect::, _>>() + .map(|v| v.join(" ")) + } +} +pub fn quote(s: &str) -> Result { + if s.is_empty() || s.chars().any(char::is_control) { + return Err("Empty arguments or control characters are not supported".into()); + } + Ok(format!("'{}'", s.replace('\'', "'\\''"))) +} +#[derive(Debug, Clone, Copy)] +pub enum Platform { + Mac, + Linux, +} +/// Injectable OS boundary. A fake records argv; tests never open a real terminal. +pub trait Launcher { + fn launch(&self, program: &Path, args: &[OsString]) -> Result<(), String>; +} +pub struct SystemLauncher; +impl Launcher for SystemLauncher { + fn launch(&self, program: &Path, args: &[OsString]) -> Result<(), String> { + // The shared spawn boundary closes every unlisted descriptor on macOS, + // so the launcher cannot inherit a pipe or file another thread has + // not yet marked close-on-exec. `launch_command` cleared the + // environment, so the boundary must not rebuild it from the parent. + let mut child = crate::process::spawn( + &mut launch_command(program, args, |key| std::env::var_os(key)), + false, + ) + .map_err(|e| e.to_string())?; + // Terminal launchers can wait for the new terminal to close. Reap our + // child without blocking GPUI, and never signal the terminal itself. + // On macOS the boundary always pipes stdout/stderr: discard them so a + // talkative launcher can never block on a full pipe. + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + std::thread::spawn(move || { + let errors = std::thread::spawn(move || discard(stderr)); + discard(stdout); + let _ = errors.join(); + let _ = child.wait(); + }); + Ok(()) + } +} +fn discard(output: Option) { + if let Some(mut output) = output { + let _ = std::io::copy(&mut output, &mut std::io::sink()); + } +} +/// UI routing metadata only, separate from the checked CLI context embedded in +/// the script. Never pass the app's credential/wrapper environment to a terminal. +const LAUNCH_ENV: &[&str] = &[ + "HOME", + "PATH", + "DISPLAY", + "WAYLAND_DISPLAY", + "XDG_RUNTIME_DIR", + "XDG_CURRENT_DESKTOP", + "DBUS_SESSION_BUS_ADDRESS", +]; +fn launch_command( + program: &Path, + args: &[OsString], + environment: impl Fn(&str) -> Option, +) -> std::process::Command { + let mut command = std::process::Command::new(program); + command + .env_clear() + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + for key in LAUNCH_ENV { + if let Some(value) = environment(key) { + command.env( + key, + if *key == "PATH" { + crate::runner::sanitize_path(&value) + } else { + value + }, + ); + } + } + command +} + +/// A private, one-use script. Drop cleans failed/unlaunched handoffs. After a +/// successful launch the script owns cleanup via EXIT/HUP/INT/TERM traps. If the +/// terminal never executes it, the private OS temp directory can remain. +pub struct Script { + path: PathBuf, + handed_off: bool, +} +impl Script { + pub fn create(temp_root: &Path, spec: &CommandSpec) -> Result { + let command = spec.display()?; + if !temp_root.is_absolute() || temp_root.to_str().is_none() { + return Err("Terminal temp root must be an absolute UTF-8 path".into()); + } + // Use the validated canonical root from here on: an alias path could + // be replaced after validation. + let temp_root = &safe_temp_root(temp_root)?; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos(); + let dir = temp_root.join(format!("hack-terminal-{}-{nonce}", std::process::id())); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&dir) + .map_err(|e| e.to_string())?; + let script = Self { + path: dir.join("setup.command"), + handed_off: false, + }; + let cleanup = format!( + "/bin/rm -f -- {}; /bin/rmdir -- {}", + quote(&script.path.to_string_lossy())?, + quote(&dir.to_string_lossy())? + ); + let text = format!( + "#!/bin/sh\ntrap {} EXIT\ntrap 'exit 130' HUP INT TERM\nprintf '%s\\n' {} 'Press Enter to run this command, or close this window to cancel.'\nIFS= read -r answer || exit 1\n{command}\nresult=$?\nprintf 'Command finished (exit %s). Press Enter to close.\\n' \"$result\"\nIFS= read -r answer || :\nexit \"$result\"\n", + quote(&cleanup)?, + quote(&command)? + ); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o700) + .open(&script.path) + .map_err(|e| e.to_string())?; + file.write_all(text.as_bytes()).map_err(|e| e.to_string())?; + // Preserve executable mode even under a restrictive umask. + file.set_permissions(std::fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + Ok(script) + } + pub fn path(&self) -> &Path { + &self.path + } + /// Linux requires a discovered absolute xdg-terminal-exec path; absent means + /// Copy command. macOS opens the .command in a new window of the system + /// Terminal bundle, in the background. + pub fn open( + self, + platform: Platform, + linux_terminal: Option<&Path>, + launcher: &dyn Launcher, + ) -> Result<(), String> { + self.open_with( + platform, + linux_terminal, + Path::new(MAC_TERMINAL_APP), + launcher, + ) + } + /// `open` with the macOS Terminal bundle path supplied (a test seam). The + /// bundle is passed to `open -a` by path, never by application name, so + /// another account cannot register a competing "Terminal" that would run + /// as this user before the script's Enter prompt. + pub fn open_with( + mut self, + platform: Platform, + linux_terminal: Option<&Path>, + mac_terminal: &Path, + launcher: &dyn Launcher, + ) -> Result<(), String> { + let (program, args) = match platform { + Platform::Mac => ( + Path::new("/usr/bin/open"), + vec![ + "-g".into(), + "-a".into(), + system_bundle(mac_terminal)?.into_os_string(), + self.path.as_os_str().into(), + ], + ), + Platform::Linux => { + let p = linux_terminal + .filter(|p| p.is_absolute()) + .ok_or("xdg-terminal-exec unavailable; use Copy command")?; + (p, vec![self.path.as_os_str().into()]) + } + }; + launcher.launch(program, &args)?; + self.handed_off = true; + Ok(()) + } +} +impl Drop for Script { + fn drop(&mut self) { + if !self.handed_off { + let _ = std::fs::remove_file(&self.path); + if let Some(p) = self.path.parent() { + let _ = std::fs::remove_dir(p); + } + } + } +} +/// The system Terminal bundle (sealed system volume on supported macOS). +pub const MAC_TERMINAL_APP: &str = "/System/Applications/Utilities/Terminal.app"; + +/// The bundle must be a real directory at exactly this path (no symlink), +/// owned by root and not writable by group or others, with the same rule for +/// every directory above it. Otherwise the hand-off fails closed to Copy. +fn system_bundle(bundle: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + const REFUSED: &str = "The system Terminal app is unavailable; use Copy command"; + let real = std::fs::canonicalize(bundle).map_err(|_| REFUSED.to_owned())?; + if real != bundle || !std::fs::metadata(&real).is_ok_and(|m| m.is_dir()) { + return Err(REFUSED.into()); + } + for dir in real.ancestors() { + let ok = std::fs::metadata(dir).is_ok_and(|m| m.uid() == 0 && m.mode() & 0o022 == 0); + if !ok || !crate::doctor_acl::controlled(dir) { + return Err(REFUSED.into()); + } + } + Ok(real) +} + +/// Another user must not be able to rename the private script directory +/// out of `root` and substitute a script before the terminal runs it. The +/// root must be owned by this user and not writable by group or others, or +/// owned by root and either not writable by others or sticky (as `/tmp` is); +/// every directory above it follows the same rule, and ACLs may not grant +/// changes (Doctor's ACL check). +fn safe_temp_root(root: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + const REFUSED: &str = + "The temporary directory can be changed by other users; terminal hand-off refused"; + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + let real = std::fs::canonicalize(root).map_err(|_| REFUSED.to_owned())?; + if !std::fs::metadata(&real).is_ok_and(|m| m.is_dir()) { + return Err(REFUSED.into()); + } + for dir in real.ancestors() { + let Ok(meta) = std::fs::metadata(dir) else { + return Err(REFUSED.into()); + }; + let writable_by_others = meta.mode() & 0o022 != 0; + let sticky = meta.mode() & 0o1000 != 0; + let ok = (meta.uid() == uid && !writable_by_others) + || (meta.uid() == 0 && (!writable_by_others || sticky)); + if !ok || !crate::doctor_acl::controlled(dir) { + return Err(REFUSED.into()); + } + } + Ok(real) +} + +/// Discover only the Linux adapter; never fall back to typing into a pane. +/// It runs as this user before any confirmation, so it must pass the same +/// ownership, write and `#!` interpreter rules as the CLI (no env form); +/// otherwise the app offers Copy only. +pub fn linux_adapter() -> Option { + adapter_on(std::env::var_os("PATH").as_deref()) +} + +fn adapter_on(path: Option<&std::ffi::OsStr>) -> Option { + let found = crate::runner::resolve_on_path("xdg-terminal-exec", path)?; + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + crate::source::trusted_tool_path(&found, uid, false) + .filter(|real| crate::source::trusted_interpreter(real, uid, 4, false)) + .map(|_| found) +} + +#[cfg(test)] +mod launch_tests { + use super::*; + #[test] + fn linux_adapter_must_be_in_a_trusted_location() { + use std::os::unix::fs::PermissionsExt; + let root = std::env::temp_dir().join(format!( + "terminal-adapter-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + let adapter = bin.join("xdg-terminal-exec"); + std::fs::write(&adapter, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(adapter_on(Some(bin.as_os_str())), Some(adapter.clone())); + std::fs::write(&adapter, "#!/usr/bin/env sh\n").unwrap(); + assert_eq!(adapter_on(Some(bin.as_os_str())), None, "env form refused"); + std::fs::write(&adapter, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert_eq!( + adapter_on(Some(bin.as_os_str())), + None, + "writable directory refused" + ); + let _ = std::fs::remove_dir_all(root); + } + #[test] + fn launcher_reads_only_authored_ui_metadata_without_forwarding_app_environment() { + let queried = std::cell::RefCell::new(Vec::new()); + let command = launch_command( + Path::new("/fake/terminal"), + &["/private/setup.command".into()], + |key| { + queried.borrow_mut().push(key.to_owned()); + Some("fixture".into()) + }, + ); + assert_eq!(queried.borrow().len(), LAUNCH_ENV.len()); + assert!( + queried + .borrow() + .iter() + .all(|key| LAUNCH_ENV.contains(&key.as_str())) + ); + assert!(command.get_envs().all(|(key, _)| { + LAUNCH_ENV + .iter() + .any(|allowed| key == std::ffi::OsStr::new(allowed)) + })); + assert_eq!( + command.get_program(), + std::ffi::OsStr::new("/fake/terminal") + ); + assert_eq!( + command.get_args().collect::>(), + vec![std::ffi::OsStr::new("/private/setup.command")] + ); + } +} diff --git a/apps/desktop/core/src/viewmodel.rs b/apps/desktop/core/src/viewmodel.rs index a8c6372bb..8dc947ae4 100644 --- a/apps/desktop/core/src/viewmodel.rs +++ b/apps/desktop/core/src/viewmodel.rs @@ -299,6 +299,12 @@ impl AppModel { } } + /// Machine effects must wait for every project effect to settle. + #[must_use] + pub fn has_in_flight(&self) -> bool { + self.local.values().any(|c| c.in_flight.is_some()) + } + /// Can `action` start for `project` now? #[must_use] pub fn can_start(&self, project: &str) -> bool { @@ -915,3 +921,28 @@ mod tests { } } } + +#[cfg(test)] +mod machine_gate_tests { + use super::*; + #[test] + fn machine_gate_observes_every_project_until_each_action_settles() { + let mut model = AppModel::new(); + assert!(!model.has_in_flight()); + for project in ["alpha", "beta"] { + model.apply(Event::ActionStarted { + project: project.into(), + action: Action::Stop, + }); + } + assert!(model.has_in_flight()); + for project in ["alpha", "beta"] { + model.apply(Event::ActionFinished { + project: project.into(), + action: Action::Stop, + result: Err(SourceError::CliMissing), + }); + assert_eq!(model.has_in_flight(), project == "alpha"); + } + } +} diff --git a/apps/desktop/core/tests/dump_state.rs b/apps/desktop/core/tests/dump_state.rs index 9eaf80abd..70202f948 100644 --- a/apps/desktop/core/tests/dump_state.rs +++ b/apps/desktop/core/tests/dump_state.rs @@ -67,12 +67,13 @@ fn serve_snapshot(home: &std::path::Path) -> std::thread::JoinHandle<()> { fn dump_state_reads_the_cli_like_the_app_and_prints_view_models() { let bin = temp_dir("bin"); let home = temp_dir("home"); - let hack = bin.join("hack"); + let hack = bin.join("hack-next"); test_support::script( &hack, r#"#!/bin/sh case "$1" in version) echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}' ;; + daemon) printf '{"socket_path":"%s/private/daemon/hackd.sock"}\n' "$HOME" ;; projects) echo called > "$HACK_HOME/unsafe-listing"; exit 2 ;; *) echo "unexpected: $*" >&2; exit 2 ;; esac @@ -86,13 +87,16 @@ esac .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") .version(std::time::Duration::from_secs(60)) .unwrap(); - let server = serve_snapshot(&home); + let private = home.join("private"); + std::fs::create_dir(&private).unwrap(); + let server = serve_snapshot(&private); let out = Command::new(env!("CARGO_BIN_EXE_hack-desktop-state")) .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) .env("HACK_HOME", &home) .env("HOME", &home) .env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .env("HACK_DESKTOP_CLI", &hack) .output() .unwrap(); server.join().unwrap(); @@ -132,6 +136,7 @@ fn dump_state_without_hack_reports_the_missing_cli() { .env("HACK_HOME", &empty) .env("HOME", &empty) .env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .env_remove("HACK_DESKTOP_CLI") .output() .unwrap(); let _ = std::fs::remove_dir_all(&empty); @@ -147,13 +152,14 @@ fn missing_daemon_never_runs_the_registry_writing_cli_listing() { let hack = home.join("hack"); test_support::script( &hack, - "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}';; *) echo called > \"$HACK_HOME/unexpected\"; exit 1;; esac\n", + "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}';; daemon) echo '{}';; *) echo called > \"$HACK_HOME/unexpected\"; exit 1;; esac\n", ); let out = Command::new(env!("CARGO_BIN_EXE_hack-desktop-state")) .env("PATH", &home) .env("HOME", &home) .env("HACK_HOME", &home) .env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .env_remove("HACK_DESKTOP_CLI") .output() .unwrap(); assert!(out.status.success()); @@ -178,10 +184,10 @@ fn daemon_recovery_runs_only_the_requested_command_and_checks_its_exit() { let hack = home.join("hack"); test_support::script( &hack, - "#!/bin/sh\nprintf '%s' \"$*\" > \"$HACK_HOME/called\"\nif [ \"$2\" = restart ]; then echo 'daemon could not restart' >&2; exit 1; fi\n", + "#!/bin/sh\nprintf '%s' \"$*\" > \"$HACK_HOME/called\"\nprintf '%s' \"${HACK_MCP_COMMAND-unset}\" > \"$HACK_HOME/self\"\nif [ \"$2\" = restart ]; then echo 'daemon could not restart' >&2; exit 1; fi\n", ); let source = LiveSource { - runner: CliRunner::new(hack) + runner: CliRunner::new(hack.clone()) .with_env("HOME", home.to_str().unwrap()) .with_env("HACK_HOME", home.to_str().unwrap()) .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1"), @@ -193,6 +199,11 @@ fn daemon_recovery_runs_only_the_requested_command_and_checks_its_exit() { std::fs::read_to_string(home.join("called")).unwrap(), "daemon start" ); + // The daemon the CLI starts runs the selected CLI, not PATH's `hack`. + assert_eq!( + std::fs::read_to_string(home.join("self")).unwrap(), + hack.to_str().unwrap() + ); let error = source.recover_daemon(DaemonRecovery::Restart).unwrap_err(); assert!(error.contains("daemon could not restart")); assert_eq!( @@ -201,3 +212,27 @@ fn daemon_recovery_runs_only_the_requested_command_and_checks_its_exit() { ); std::fs::remove_dir_all(home).unwrap(); } + +#[test] +fn setup_fixture_cli_never_resolves_hack() { + let home = temp_dir("setup-offline"); + let output = Command::new(env!("CARGO_BIN_EXE_hack-desktop-state")) + .args(["--setup-fixture", "native-unknown"]) + .env("PATH", &home) + .env("HOME", &home) + .env("HACK_HOME", &home) + .env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .env_remove("HACK_DESKTOP_CLI") + .output() + .unwrap(); + let _ = std::fs::remove_dir_all(&home); + assert!(output.status.success()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!( + report["cards"] + .as_array() + .unwrap() + .iter() + .any(|c| c["id"] == "native:sample" && c["status"] == "Unknown") + ); +} diff --git a/apps/desktop/core/tests/execution.rs b/apps/desktop/core/tests/execution.rs new file mode 100644 index 000000000..d8bf6248f --- /dev/null +++ b/apps/desktop/core/tests/execution.rs @@ -0,0 +1,167 @@ +//! Context adapters never inspect or forward an unbounded environment. +use hack_desktop_core::{execution::ExecutionContext, terminal::CommandSpec}; +#[test] +fn unsupported_keys_paths_backends_and_controls_are_refused() { + let home = ("HOME".to_owned(), "/work/user".to_owned()); + for extra in [ + ("UNLISTED_VALUE", "fixture"), + ("HACK_HOME", "relative"), + ("HACK_RUNTIME_BACKEND", "other"), + ("XDG_RUNTIME_DIR", "relative"), + ("PATH", "/bin\n/unwanted"), + ] { + assert!( + ExecutionContext::from_values(&[home.clone(), (extra.0.into(), extra.1.into())]) + .is_err() + ); + } +} +#[test] +fn absent_home_override_stays_absent_and_context_does_not_rediscover_program() { + let context = ExecutionContext::from_values(&[ + ("HOME".into(), "/work/user".into()), + ("PATH".into(), "/one:/two".into()), + ]) + .unwrap(); + assert_eq!(context.value("HACK_HOME"), None); + assert!(context.install_refusal().is_none()); + let runner = context.runner("/checked/hack").unwrap(); + // Checked-context runners verify the pinned executable on every use; a + // missing one is a missing CLI, never a rediscovered one. + assert!(matches!( + runner.resolve_program(), + Err(hack_desktop_core::runner::RunError::NotFound) + )); + assert!(runner.effective_env("HACK_HOME").is_none()); + let rebound = ExecutionContext::capture(&runner).unwrap(); + assert_eq!(context, rebound); + let command = context + .bind(&CommandSpec { + program: "/checked/hack".into(), + args: vec!["global".into(), "trust".into()], + }) + .unwrap(); + assert_eq!(command.program, "/usr/bin/env"); + assert_eq!(command.args[0], "-i"); + assert!(command.args.contains(&"/checked/hack".into())); + assert!(!command.args.iter().any(|a| a.starts_with("HACK_HOME="))); + assert!( + context + .bind(&CommandSpec { + program: "hack".into(), + args: vec![] + }) + .is_err() + ); +} +#[test] +fn absolute_xdg_config_home_is_kept_and_relative_is_ignored_like_the_cli() { + let base = [ + ("HOME".to_owned(), "/work/user".to_owned()), + ("PATH".to_owned(), "/usr/bin:/bin".to_owned()), + ]; + let with = |value: &str| { + let mut values = base.to_vec(); + values.push(("XDG_CONFIG_HOME".into(), value.into())); + ExecutionContext::from_values(&values).unwrap() + }; + assert_eq!( + with("/work/user/cfg").value("XDG_CONFIG_HOME"), + Some("/work/user/cfg") + ); + assert_eq!(with("relative/cfg").value("XDG_CONFIG_HOME"), None); + let runner = with("/work/user/cfg").runner("/checked/hack").unwrap(); + assert_eq!( + runner.effective_env("XDG_CONFIG_HOME").as_deref(), + Some(std::ffi::OsStr::new("/work/user/cfg")) + ); +} +#[test] +fn docker_selectors_are_kept_when_supported_and_refused_otherwise() { + let with = |extra: &[(&str, &str)]| { + let mut values = vec![ + ("HOME".to_owned(), "/work/user".to_owned()), + ("PATH".to_owned(), "/usr/bin:/bin".to_owned()), + ]; + values.extend( + extra + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())), + ); + ExecutionContext::from_values(&values) + }; + let context = with(&[ + ("DOCKER_CONTEXT", "fixture-context"), + ("DOCKER_CONFIG", "/work/user/docker"), + ("DOCKER_HOST", "unix:///work/user/docker.sock"), + ]) + .unwrap(); + assert_eq!(context.value("DOCKER_CONTEXT"), Some("fixture-context")); + assert_eq!(context.value("DOCKER_CONFIG"), Some("/work/user/docker")); + assert_eq!( + context.value("DOCKER_HOST"), + Some("unix:///work/user/docker.sock") + ); + let runner = context.runner("/checked/hack").unwrap(); + assert_eq!( + runner.effective_env("DOCKER_CONTEXT").as_deref(), + Some(std::ffi::OsStr::new("fixture-context")) + ); + for bad in [ + ("DOCKER_CONTEXT", "bad context"), + ("DOCKER_CONFIG", "relative/docker"), + ("DOCKER_HOST", "unix://relative.sock"), + ("DOCKER_HOST", "fd://"), + ] { + assert!(with(&[bad]).is_err(), "{bad:?}"); + } +} +#[test] +fn native_runtime_selectors_are_kept_when_absolute() { + let with = |key: &str, value: &str| { + ExecutionContext::from_values(&[ + ("HOME".to_owned(), "/work/user".to_owned()), + ("PATH".to_owned(), "/usr/bin:/bin".to_owned()), + (key.to_owned(), value.to_owned()), + ]) + }; + for key in [ + "HACK_NATIVE_HOME", + "HACK_NATIVE_BINARY", + "HACK_GLOBAL_CONFIG_PATH", + ] { + let context = with(key, "/work/user/native").unwrap(); + assert_eq!(context.value(key), Some("/work/user/native")); + assert!(with(key, "relative/native").is_err(), "{key}"); + } +} +#[test] +fn checked_runner_refuses_a_cli_whose_location_became_writable_after_the_check() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "execution-verified-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + let program = dir.join("hack"); + std::fs::write(&program, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + let context = ExecutionContext::from_values(&[ + ("HOME".into(), "/work/user".into()), + ("PATH".into(), "/usr/bin:/bin".into()), + ]) + .unwrap(); + let runner = context.runner(program.to_str().unwrap()).unwrap(); + assert_eq!(runner.resolve_program().unwrap(), program, "checked"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(matches!( + runner.resolve_program(), + Err(hack_desktop_core::runner::RunError::Spawn(_)) + )); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/apps/desktop/core/tests/setup.rs b/apps/desktop/core/tests/setup.rs new file mode 100644 index 000000000..c3cc3dd9e --- /dev/null +++ b/apps/desktop/core/tests/setup.rs @@ -0,0 +1,510 @@ +//! Negative evidence and fixed-action contracts. No real CLI or services. +use hack_desktop_core::{ + doctor::DoctorRun, + models::{DoctorCheck, DoctorReport, ProjectsPayload}, + setup::{self, Platform, Remedy, SafeAction, Status}, +}; +fn machine(ids: &[(&str, &str)]) -> DoctorRun { + DoctorRun::machine(Ok(DoctorReport { + checks: ids + .iter() + .map(|(id, status)| DoctorCheck { + id: (*id).into(), + status: (*status).into(), + detail: String::new(), + }) + .collect(), + ..Default::default() + })) +} +fn derive( + runs: &[DoctorRun], + platform: Platform, + daemon: serde_json::Value, + socket: Option<&str>, +) -> setup::Report { + setup::derive( + Ok("5.0.0"), + Some(&ProjectsPayload { + runtime_ok: Some(true), + ..Default::default() + }), + socket, + Some(&daemon), + runs, + platform, + ) +} +/// Docker readiness comes from the machine Doctor run with the app's +/// checked selectors, never from a running hackd's own `runtime_ok`. +#[test] +fn docker_card_uses_the_checked_doctor_run_not_the_daemon() { + let docker = |runs: &[DoctorRun]| { + let r = derive(runs, Platform::Linux, serde_json::json!({}), None); + let card = r.cards.into_iter().find(|c| c.id == "docker").unwrap(); + (card.status, card.detail) + }; + // The daemon says runtime_ok, but the selected engine is down. + let (status, detail) = docker(&[machine(&[("docker daemon", "error")])]); + assert_eq!(status, Status::NeedsAttention, "{detail}"); + assert_eq!( + docker(&[machine(&[("docker daemon", "ok")])]).0, + Status::Passed + ); + // No Doctor evidence: the daemon's runtime_ok alone proves nothing. + assert_eq!(docker(&[machine(&[])]).0, Status::Unknown); + assert_eq!(docker(&[]).0, Status::Unknown); +} +#[test] +fn export_is_not_trust_and_absence_not_pass() { + let r = derive( + &[machine(&[("caddy local ca", "ok")])], + Platform::Linux, + serde_json::json!({}), + None, + ); + assert_eq!( + r.cards.iter().find(|c| c.id == "trust").unwrap().status, + Status::Unknown + ); + assert!(r.needed()); + assert_eq!( + r.cards.iter().find(|c| c.id == "login").unwrap().status, + Status::Unknown + ); +} +#[test] +fn all_mac_dns_checks_required_including_custom_pairs() { + let ids = [ + ("resolver:hack", "ok"), + ("resolver:hack.local", "ok"), + ("resolver:hack.gy", "ok"), + ("dnsmasq.conf:hack", "ok"), + ("dnsmasq.conf:hack.local", "ok"), + ("dnsmasq.conf:hack.gy", "ok"), + ("dnsmasq:53", "ok"), + ("coredns forwarding", "ok"), + ]; + let state = |pairs: &[(&str, &str)]| { + derive( + &[machine(pairs)], + Platform::Mac, + serde_json::json!({}), + None, + ) + .cards + .into_iter() + .find(|c| c.id == "routing") + .unwrap() + .status + }; + assert_eq!(state(&ids), Status::Passed); + for i in 0..ids.len() { + let mut missing = ids.to_vec(); + missing.remove(i); + assert_eq!(state(&missing), Status::Unknown, "missing {}", ids[i].0); + } + for status in ["skipped", "unexpected", ""] { + let mut unknown = ids; + unknown[0].1 = status; + assert_eq!(state(&unknown), Status::Unknown); + } + let mut custom = ids.to_vec(); + custom.push(("resolver:custom.test", "ok")); + assert_eq!(state(&custom), Status::Unknown); +} +#[test] +fn daemon_requires_running_and_socket_version_not_exit_zero() { + for d in [ + serde_json::json!({}), + serde_json::json!({"running":false}), + serde_json::json!({"running":true}), + ] { + let r = derive(&[], Platform::Linux, d, None); + assert_ne!( + r.cards.iter().find(|c| c.id == "daemon").unwrap().status, + Status::Passed + ); + } + let d = serde_json::json!({"running":true,"service":{"installed":false}}); + for (version, want) in [ + (Some("5.0.0"), Status::Passed), + (Some("4.0.0"), Status::NeedsAttention), + (None, Status::Unknown), + ] { + let r = derive(&[], Platform::Linux, d.clone(), version); + assert_eq!( + r.cards.iter().find(|c| c.id == "daemon").unwrap().status, + want + ); + assert_eq!(r.cards.last().unwrap().status, Status::NeedsAttention); + } +} +#[test] +fn native_only_keeps_unknown_and_never_global_fixes() { + let r = hack_desktop_core::setup_fixtures::load("native-unknown").unwrap(); + assert!( + r.cards + .iter() + .filter(|c| c.id.contains("sample")) + .all(|c| c.status == Status::Unknown && matches!(c.remedy, Remedy::Doc(_))) + ); + assert!(!r.cards.iter().any(|c| matches!(c.remedy, Remedy::Admin(_)))); +} +#[test] +fn skips_never_pass_cli_is_required_and_checks_preserve_session_skips() { + let mut p = setup::Pane::default(); + let g = p.begin(); + p.finish( + g, + hack_desktop_core::setup_fixtures::load("partial").unwrap(), + ); + p.skip("cli"); + assert!(p.skipped.is_empty()); + p.skip("trust"); + assert!(p.report.needed()); + assert!(p.skipped.contains("trust")); + let g = p.begin(); + assert!(p.skipped.contains("trust")); + p.finish(g - 1, Default::default()); + assert!(p.loading); + p.finish( + g, + hack_desktop_core::setup_fixtures::load("missing-cli").unwrap(), + ); + assert!(!p.report.cli_ready()); +} +#[test] +fn only_explicit_trust_check_is_evidence() { + for status in ["skipped", "warn", "error", ""] { + let r = derive( + &[machine(&[("local ca trust", status)])], + Platform::Linux, + serde_json::json!({}), + None, + ); + assert_ne!( + r.cards.iter().find(|c| c.id == "trust").unwrap().status, + Status::Passed + ); + } + let r = derive( + &[machine(&[("local ca trust", "ok")])], + Platform::Linux, + serde_json::json!({}), + None, + ); + assert_eq!( + r.cards.iter().find(|c| c.id == "trust").unwrap().status, + Status::Passed + ); +} +#[test] +fn fixed_safe_action_argv() { + assert_eq!(SafeAction::StartDaemon.args(), ["daemon", "start"]); + assert_eq!(SafeAction::InstallDaemon.args(), ["daemon", "install"]); +} +#[test] +fn unsupported_cli_cannot_continue() { + for version in [ + "4.9.0", + "6.0.0", + "", + "unexpected", + "5.bad", + "5.0", + "5.0.0garbage", + ] { + let r = setup::derive(Ok(version), None, None, None, &[], Platform::Linux); + assert!(!r.cli_ready()); + assert!(r.needed()); + } +} +#[test] +fn inconsistent_daemon_payload_is_not_healthy_even_with_matching_socket() { + for d in [ + serde_json::json!({"running":true,"status":"starting"}), + serde_json::json!({"running":true,"api_compatible":false}), + serde_json::json!({"running":true,"stale":true}), + ] { + let r = derive(&[], Platform::Linux, d, Some("5.0.0")); + assert_eq!( + r.cards.iter().find(|c| c.id == "daemon").unwrap().status, + Status::Unknown + ); + } +} +#[test] +fn native_project_evidence_never_leaks_between_projects() { + use hack_desktop_core::models::{NativePs, ProjectRow}; + let projects = ProjectsPayload { + projects: vec![ + ProjectRow { + name: "alpha".into(), + repo_root: Some("/work/alpha".into()), + backend: Some("native".into()), + native: Some(NativePs { + status: "observed".into(), + ..Default::default() + }), + ..Default::default() + }, + ProjectRow { + name: "beta".into(), + repo_root: Some("/work/beta".into()), + backend: Some("native".into()), + ..Default::default() + }, + ], + ..Default::default() + }; + let runs = vec![DoctorRun::project( + "alpha", + "/work/alpha", + Ok(DoctorReport { + checks: vec![DoctorCheck { + id: "native root trust".into(), + status: "ok".into(), + detail: String::new(), + }], + ..Default::default() + }), + )]; + let r = setup::derive( + Ok("5.0.0"), + Some(&projects), + None, + None, + &runs, + Platform::Linux, + ); + assert_eq!( + r.cards + .iter() + .find(|c| c.id == "native:alpha") + .unwrap() + .status, + Status::Passed + ); + assert_eq!( + r.cards + .iter() + .find(|c| c.id == "native:beta") + .unwrap() + .status, + Status::Unknown + ); + assert_eq!( + r.cards + .iter() + .find(|c| c.id == "trust:alpha") + .unwrap() + .status, + Status::Passed + ); + assert_eq!( + r.cards + .iter() + .find(|c| c.id == "trust:beta") + .unwrap() + .status, + Status::Unknown + ); + assert_eq!( + r.cards.iter().find(|c| c.id == "dns:alpha").unwrap().status, + Status::Unknown + ); +} +#[test] +fn missing_native_project_cannot_pass_from_cached_runtime_evidence() { + use hack_desktop_core::models::{NativePs, ProjectRow}; + let projects = ProjectsPayload { + projects: vec![ProjectRow { + name: "sample".into(), + repo_root: Some("/work/sample".into()), + status: "missing".into(), + backend: Some("native".into()), + native: Some(NativePs { + status: "observed".into(), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + let report = setup::derive( + Ok("5.0.0"), + Some(&projects), + None, + None, + &[], + Platform::Linux, + ); + assert_eq!( + report + .cards + .iter() + .find(|c| c.id == "native:sample") + .unwrap() + .status, + Status::Unknown + ); +} +#[test] +fn confirmed_warnings_need_attention_and_focus_is_scoped() { + let report = derive( + &[machine(&[ + ("linux dns route", "warn"), + ("local ca trust", "warn"), + ])], + Platform::Linux, + serde_json::json!({}), + None, + ); + for id in ["routing", "trust"] { + assert_eq!( + report.cards.iter().find(|c| c.id == id).unwrap().status, + Status::NeedsAttention + ); + } + assert!(!setup::should_recheck_on_focus(false, false, false, false)); + assert!(setup::should_recheck_on_focus(true, false, false, false)); + assert!(setup::should_recheck_on_focus(false, true, false, false)); + assert!(!setup::should_recheck_on_focus(true, true, true, false)); + assert!(!setup::should_recheck_on_focus(true, true, false, true)); +} +#[test] +fn installed_but_disabled_or_drifted_login_is_not_passed() { + for service in [ + serde_json::json!({"installed":true,"enabled":false}), + serde_json::json!({"installed":true,"active":false}), + serde_json::json!({"installed":true,"next_step":"hack daemon install"}), + serde_json::json!({"installed":true,"severity":"warn"}), + ] { + let report = derive( + &[], + Platform::Linux, + serde_json::json!({"service":service}), + None, + ); + assert_eq!(report.cards.last().unwrap().status, Status::NeedsAttention); + } +} +#[test] +fn check_clears_old_output_but_preserves_immediate_action_receipt_and_skips() { + let mut p = setup::Pane::default(); + p.result = Some("old result".into()); + p.begin(); + assert!(p.result.is_none()); + p.result = Some("just finished".into()); + p.pending_fix = true; + p.begin(); + assert_eq!(p.result.as_deref(), Some("just finished")); +} + +/// Bound commands run later, outside the app: a PATH directory another +/// user can write is dropped, and a controlled one is kept. +#[test] +fn bound_commands_keep_only_controlled_path_directories() { + use std::os::unix::fs::PermissionsExt; + let root = std::env::temp_dir().join(format!( + "setup-path-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let shared = root.join("shared"); + let own = root.join("own"); + std::fs::create_dir_all(&shared).unwrap(); + std::fs::create_dir_all(&own).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&own, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + let mut report = setup::Report::default(); + report.cards.push(setup::Card { + id: "cli".into(), + title: "CLI".into(), + status: Status::Passed, + detail: String::new(), + remedy: setup::Remedy::Doc(String::new()), + }); + report.cli_program = Some("/checked/hack-next".into()); + let path = format!("{}:{}", shared.display(), own.display()); + report.context = Some( + hack_desktop_core::execution::ExecutionContext::from_values(&[ + ("HOME".into(), "/private/home".into()), + ("PATH".into(), path), + ]) + .unwrap(), + ); + for args in [ + vec!["daemon".to_owned(), "restart".to_owned()], + vec!["global".to_owned(), "install".to_owned()], + ] { + let spec = report.command(args).unwrap(); + assert!( + spec.args.contains(&format!("PATH={}", own.display())), + "{:?}", + spec.args + ); + } + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn compose_remedies_override_native_but_keep_checked_home_and_program() { + let mut report = setup::Report::default(); + report.cards.push(setup::Card { + id: "cli".into(), + title: "CLI".into(), + status: Status::Passed, + detail: String::new(), + remedy: setup::Remedy::Doc(String::new()), + }); + report.cli_program = Some("/checked/hack-next".into()); + report.context = Some( + hack_desktop_core::execution::ExecutionContext::from_values(&[ + ("HOME".into(), "/private/home".into()), + ("HACK_RUNTIME_BACKEND".into(), "native".into()), + ]) + .unwrap(), + ); + for action in ["install", "trust"] { + let spec = report + .command(vec!["global".into(), action.into()]) + .unwrap(); + assert!(spec.args.contains(&"HACK_RUNTIME_BACKEND=compose".into())); + assert!(spec.args.contains(&"HOME=/private/home".into())); + assert!(spec.args.contains(&"/checked/hack-next".into())); + assert!(!spec.args.contains(&"HACK_RUNTIME_BACKEND=native".into())); + } + let restart = report + .command(vec!["daemon".into(), "restart".into()]) + .unwrap(); + assert!(restart.args.contains(&"HACK_RUNTIME_BACKEND=native".into())); + // The restarted daemon runs the checked CLI, not the first hack on PATH. + let pin = restart + .args + .iter() + .position(|a| a == "HACK_MCP_COMMAND=/checked/hack-next") + .expect("self-invocation pinned"); + assert_eq!(restart.args[pin + 1], "/checked/hack-next"); +} + +#[test] +fn failed_or_unloaded_launchd_never_passes_login_even_with_a_live_daemon() { + for launchd in [ + serde_json::json!({"loaded":false}), + serde_json::json!({"loaded":true,"exit_status":78}), + ] { + let report = derive( + &[], + Platform::Mac, + serde_json::json!({"running":true,"service":{"installed":true,"available":true,"enabled":null,"active":true,"next_step":null}, "launchd":launchd}), + Some("5.0.0"), + ); + assert_eq!(report.cards.last().unwrap().status, Status::NeedsAttention); + } +} diff --git a/apps/desktop/core/tests/setup_io.rs b/apps/desktop/core/tests/setup_io.rs new file mode 100644 index 000000000..32c1925a2 --- /dev/null +++ b/apps/desktop/core/tests/setup_io.rs @@ -0,0 +1,432 @@ +//! Live adapter exercised with fake hack only, isolated homes and no socket. +use hack_desktop_core::{ + runner::CliRunner, + setup::{SafeAction, Status}, + source::{LiveSource, Source}, +}; +use std::os::unix::fs::PermissionsExt; +use std::{path::PathBuf, time::Duration}; +struct Tree(PathBuf); +impl Tree { + fn new() -> Self { + let p = std::env::temp_dir().join(format!( + "setup-io-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&p).unwrap(); + Self(p) + } +} +impl Drop for Tree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +/// Keep platform first-launch scanning outside setup's product read deadline. +fn warm_version(program: &std::path::Path, home: &std::path::Path) { + CliRunner::new(program) + .with_env("HOME", home.to_str().unwrap()) + .with_env("HACK_HOME", home.join(".hack").to_str().unwrap()) + .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .version(Duration::from_secs(60)) + .unwrap(); +} +#[test] +fn checks_are_observe_only_and_safe_action_does_not_prove_health() { + let t = Tree::new(); + let program = t.0.join("fake-hack"); + let calls = t.0.join("calls"); + std::fs::write(&program,r#"#!/bin/sh +[ "$HACK_DAEMON_AUTOSTART_DISABLE" = 1 ] || exit 99 +printf '%s\n' "$* backend=$HACK_RUNTIME_BACKEND self=${HACK_MCP_COMMAND-unset}" >> "$HOME/calls" +case "$*" in +'version --json') printf '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}\n';; +'projects --json --no-daemon') printf '{"projects":[],"runtime_ok":true}';; +'daemon status --json') printf '{"running":false,"service":{"installed":false}}';; +'doctor --json') printf '{"ok":true,"data":{"checks":[{"id":"linux dns route","status":"ok"},{"id":"caddy local ca","status":"ok"}]}}'; exit 1;; +'daemon start --no-interactive'|'daemon install --no-interactive') printf 'accepted';; +*) exit 98;; +esac +"#).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + warm_version(&program, &t.0); + let runner = CliRunner::new(program) + .with_env("HACK_RUNTIME_BACKEND", "native") + .with_env("CALLS", calls.to_str().unwrap()) + .with_env("HOME", t.0.to_str().unwrap()) + .with_env("HACK_HOME", t.0.join("hack").to_str().unwrap()) + .with_term_grace(Duration::from_millis(20)); + let source = LiveSource { + runner, + daemon: None, + }; + let r = source.setup(); + assert_eq!( + r.cards.iter().find(|c| c.id == "daemon").unwrap().status, + Status::NeedsAttention + ); + assert_eq!( + r.cards.iter().find(|c| c.id == "trust").unwrap().status, + Status::Unknown + ); + let read_calls = std::fs::read_to_string(&calls).unwrap(); + assert!( + !read_calls.contains("projects"), + "registry-writing listing is refused" + ); + assert!(read_calls.contains("doctor --json backend=compose")); + assert!(!read_calls.contains("global")); + assert!(!read_calls.contains("daemon start")); + let out = source.setup_action(SafeAction::StartDaemon, &r); + assert!(out.contains("Some(0)")); + // The started daemon runs the checked CLI, not whatever is first on PATH. + let started = std::fs::read_to_string(t.0.join("calls")).unwrap(); + assert!( + started.contains(&format!( + "daemon start --no-interactive backend=native self={}", + r.cli_program.as_deref().unwrap() + )), + "{started}" + ); + let r = source.setup(); + assert_eq!( + r.cards.iter().find(|c| c.id == "daemon").unwrap().status, + Status::NeedsAttention + ); + source.setup_action(SafeAction::InstallDaemon, &r); + let calls = std::fs::read_to_string(calls).unwrap(); + assert!(!calls.contains("daemon install --no-interactive")); + assert!(!calls.contains("sudo")); +} +#[test] +fn exhausted_budget_never_spawns_fake_cli() { + let t = Tree::new(); + let marker = t.0.join("unexpected"); + let program = t.0.join("fake-hack"); + std::fs::write( + &program, + format!("#!/bin/sh\nprintf unexpected > '{}'\n", marker.display()), + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + let source = LiveSource { + runner: CliRunner::new(program) + .with_env("HOME", t.0.to_str().unwrap()) + .with_env("HACK_HOME", t.0.join("hack").to_str().unwrap()), + daemon: None, + }; + let r = source.read_setup_within(Duration::ZERO); + assert!(!r.cli_ready()); + assert!(!r.force_cli_gate, "budget exhaustion is not a missing CLI"); + assert!(!marker.exists()); + assert!(r.cards.iter().all(|c| c.status != Status::Passed)); +} +#[test] +fn shared_source_recovers_after_cli_install_and_actions_use_same_executable() { + let t = Tree::new(); + let hack = t.0.join("hack"); + let marker = t.0.join("action"); + let runner = CliRunner::on_path("hack") + .with_env("PATH", t.0.to_str().unwrap()) + .with_env("HOME", t.0.to_str().unwrap()) + .with_env("HACK_HOME", t.0.join("home").to_str().unwrap()) + .with_env("MARKER", marker.to_str().unwrap()); + let source = LiveSource { + runner, + daemon: None, + }; + assert!(!source.setup().cli_ready()); + std::fs::write( + &hack, + r#"#!/bin/sh +case "$1 $2" in +'version --json') printf '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0-next.16"}}';; +'daemon status') printf '{"running":false,"service":{"installed":false}}';; +'doctor --json') printf '{"ok":true,"data":{"checks":[]}}';; +'daemon start') printf action > "$HOME/action";; +*) exit 98;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o700)).unwrap(); + warm_version(&hack, &t.0); + let report = source.setup(); + assert!(report.cli_ready()); + assert_eq!(report.cli_program.as_deref(), hack.to_str()); + assert_eq!(source.cli_program().unwrap(), hack.to_str().unwrap()); + source.setup_action(SafeAction::StartDaemon, &report); + assert!(marker.exists()); +} +#[test] +fn explicit_cli_selection_is_absolute_and_never_falls_back() { + use std::ffi::OsStr; + assert!( + CliRunner::selected(Some(OsStr::new("relative/hack-next"))) + .resolve_program() + .is_err() + ); + let t = Tree::new(); + let selected = t.0.join("candidate"); + // A missing explicit selection is a missing CLI, never a fallback. + assert!(matches!( + CliRunner::selected(Some(selected.as_os_str())).resolve_program(), + Err(hack_desktop_core::runner::RunError::NotFound) + )); + std::fs::write(&selected, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + CliRunner::selected(Some(selected.as_os_str())) + .resolve_program() + .unwrap(), + selected + ); + // Writable by others, or an interpreter others can replace: refused. + std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(matches!( + CliRunner::selected(Some(selected.as_os_str())).resolve_program(), + Err(hack_desktop_core::runner::RunError::Spawn(_)) + )); + std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o755)).unwrap(); + let shared = t.0.join("shared"); + std::fs::create_dir(&shared).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + std::fs::copy("/bin/sh", shared.join("interp")).unwrap(); + std::fs::write( + &selected, + format!("#!{}\n", shared.join("interp").display()), + ) + .unwrap(); + assert!(matches!( + CliRunner::selected(Some(selected.as_os_str())).resolve_program(), + Err(hack_desktop_core::runner::RunError::Spawn(_)) + )); +} +fn service_source( + t: &Tree, + service: serde_json::Value, + isolated: bool, + launch_disabled: bool, +) -> LiveSource { + let program = t.0.join("hack"); + std::fs::write(t.0.join("status"), serde_json::to_string(&service).unwrap()).unwrap(); + std::fs::write( + &program, + r#"#!/bin/sh +printf '%s\n' "$*" >> "$HOME/calls" +case "$*" in +'version --json') printf '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}';; +'daemon status --json') /bin/cat "$HOME/status";; +'doctor --json') printf '{"ok":true,"data":{"checks":[{"id":"linux dns route","status":"ok"}]}}';; +'daemon install --no-interactive') printf installed > "$HOME/action";; +*) exit 98;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + warm_version(&program, &t.0); + LiveSource { + runner: CliRunner::new(program) + .with_env("HOME", t.0.to_str().unwrap()) + .with_env( + "HACK_HOME", + t.0.join(if isolated { "isolated" } else { ".hack" }) + .to_str() + .unwrap(), + ) + .with_env( + "HACK_LAUNCHD_DISABLE", + if launch_disabled { "1" } else { "0" }, + ) + .with_env("HACK_SYSTEMD_DISABLE", "0"), + daemon: None, + } +} +#[test] +fn install_needs_positive_capability_and_home_ownership_independently() { + use hack_desktop_core::setup::Remedy; + for (status, isolated, disabled, allowed) in [ + (serde_json::json!({"running":false}), false, false, false), + ( + serde_json::json!({"running":false,"service":{"available":false,"installed":false}}), + false, + false, + false, + ), + ( + serde_json::json!({"running":false,"service":{"available":true,"installed":false}}), + false, + false, + true, + ), + ( + serde_json::json!({"running":false,"service":{"available":true,"installed":false},"socket_path":"/work/private/daemon/hackd.sock"}), + false, + false, + false, + ), + ( + serde_json::json!({"running":false,"service":{"available":true,"installed":false}}), + true, + false, + false, + ), + ( + serde_json::json!({"running":false,"service":{"available":true,"installed":false}}), + false, + true, + false, + ), + ] { + let t = Tree::new(); + let source = service_source(&t, status, isolated, disabled); + let report = source.setup(); + assert!(report.cli_ready()); + assert_eq!( + report.cards.last().unwrap().remedy == Remedy::Run(SafeAction::InstallDaemon), + allowed + ); + assert!( + !t.0.join("action").exists(), + "reads never install or start anything" + ); + let result = source.setup_action(SafeAction::InstallDaemon, &report); + let calls = std::fs::read_to_string(t.0.join("calls")).unwrap(); + assert_eq!( + calls + .lines() + .filter(|l| *l == "daemon install --no-interactive") + .count(), + usize::from(allowed), + "{result}" + ); + assert_eq!(t.0.join("action").exists(), allowed); + assert!(!calls.contains("daemon start")); + } +} +#[test] +fn capability_disappearing_after_check_does_not_invoke_install_fallback() { + let t = Tree::new(); + let source = service_source( + &t, + serde_json::json!({"running":false,"service":{"available":true,"installed":false}}), + false, + false, + ); + let report = source.setup(); + std::fs::write( + t.0.join("status"), + r#"{"service":{"available":false,"installed":false}}"#, + ) + .unwrap(); + assert!( + source + .setup_action(SafeAction::InstallDaemon, &report) + .contains("no install was attempted") + ); + let calls = std::fs::read_to_string(t.0.join("calls")).unwrap(); + assert!(!calls.contains("daemon install")); + assert!(!calls.contains("daemon start")); + assert!(!t.0.join("action").exists()); +} +#[test] +fn terminal_binds_checked_path_and_context_despite_different_login_environment() { + use hack_desktop_core::terminal::Script; + use std::io::Write; + let t = Tree::new(); + let _source = service_source( + &t, + serde_json::json!({"running":false,"service":{"available":true,"installed":false}}), + true, + true, + ); + let mut source = LiveSource { + runner: CliRunner::on_path("hack") + .with_env("PATH", t.0.to_str().unwrap()) + .with_env("HOME", t.0.to_str().unwrap()) + .with_env("HACK_HOME", t.0.join("isolated").to_str().unwrap()) + .with_env("HACK_LAUNCHD_DISABLE", "1") + .with_env("HACK_RUNTIME_BACKEND", "compose"), + daemon: None, + }; + let report = source.setup(); + source.runner = source.runner.clone().with_env("PATH", "/wrong/login/bin"); + assert!( + source.cli_program().is_err(), + "discovery changed after the check" + ); + let checked = t.0.join("hack"); + // Replace only our fake's body to record execution; the selected path stays fixed. + std::fs::write(&checked,r#"#!/bin/sh +printf '%s\n' "$HOME" "$HACK_HOME" "$HACK_RUNTIME_BACKEND" "$HACK_LAUNCHD_DISABLE" "${UNLISTED_VALUE-unset}" "$*" > "$HOME/context-receipt" +exit 7 +"#).unwrap(); + let bound = report + .command(vec!["global".into(), "trust".into()]) + .unwrap(); + assert!(bound.args.contains(&checked.to_str().unwrap().to_owned())); + let script = Script::create(&t.0, &bound).unwrap(); + let path = script.path().to_owned(); + let mut child = std::process::Command::new("/bin/sh") + .arg(&path) + .env("HOME", "/wrong/login/home") + .env("HACK_HOME", "/wrong/login/state") + .env("HACK_RUNTIME_BACKEND", "native") + .env("HACK_LAUNCHD_DISABLE", "0") + .env("PATH", "/wrong/bin") + .env("UNLISTED_VALUE", "fixture") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(b"\n\n").unwrap(); + assert_eq!(child.wait().unwrap().code(), Some(7)); + let receipt = std::fs::read_to_string(t.0.join("context-receipt")).unwrap(); + assert_eq!( + receipt.lines().collect::>(), + vec![ + t.0.to_str().unwrap(), + t.0.join("isolated").to_str().unwrap(), + "compose", + "1", + "unset", + "global trust" + ] + ); + assert!(!path.exists()); +} +#[test] +fn version_timeout_is_unknown_without_forcing_a_missing_cli_gate() { + let t = Tree::new(); + let program = t.0.join("hack"); + std::fs::write( + &program, + "#!/bin/sh\nif [ \"$1\" = __warmup ]; then printf '{}'; exit 0; fi\nprintf '%s' \"$*\" > \"$HOME/calls\"\n/bin/sleep 5\n", + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + let source = LiveSource { + runner: CliRunner::new(program) + .with_env("HOME", t.0.to_str().unwrap()) + .with_env("HACK_HOME", t.0.join(".hack").to_str().unwrap()) + .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1"), + daemon: None, + }; + source + .runner + .run_json::(&["__warmup"], None, Duration::from_secs(60)) + .unwrap(); + let report = source.read_setup_within(Duration::from_secs(3)); + assert!(!report.cli_ready()); + assert!(!report.force_cli_gate); + assert!(report.cards[0].detail.contains("did not answer")); + assert_eq!( + std::fs::read_to_string(t.0.join("calls")).unwrap(), + "version --json" + ); +} diff --git a/apps/desktop/core/tests/terminal.rs b/apps/desktop/core/tests/terminal.rs new file mode 100644 index 000000000..bd7ae7f4a --- /dev/null +++ b/apps/desktop/core/tests/terminal.rs @@ -0,0 +1,294 @@ +//! Scripts run only fake executables. Launch adapters record argv, never open GUI. +use hack_desktop_core::terminal::{CommandSpec, Launcher, MAC_TERMINAL_APP, Platform, Script}; +use std::os::unix::fs::PermissionsExt; +use std::{ + ffi::OsString, + io::{BufRead, Write}, + path::{Path, PathBuf}, +}; +struct Tree(PathBuf); +impl Tree { + fn new() -> Self { + let p = std::env::temp_dir().join(format!( + "terminal-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&p).unwrap(); + Self(p) + } +} +impl Drop for Tree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +struct Fake { + calls: std::cell::RefCell)>>, + fail: bool, +} +impl Launcher for Fake { + fn launch(&self, p: &Path, a: &[OsString]) -> Result<(), String> { + self.calls.borrow_mut().push((p.into(), a.into())); + if self.fail { + Err("fixture refused".into()) + } else { + Ok(()) + } + } +} +fn spec() -> CommandSpec { + CommandSpec { + program: "hack".into(), + args: vec!["global".into(), "trust".into()], + } +} +#[test] +fn private_script_fixed_adapters_and_failure_cleanup() { + let t = Tree::new(); + let fake = Fake { + calls: Default::default(), + fail: true, + }; + let script = Script::create(&t.0, &spec()).unwrap(); + let path = script.path().to_owned(); + assert_eq!( + std::fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + // A bundle that is not a root-owned system directory is refused before + // anything launches, and the unlaunched script is removed. + let fake_bundle = t.0.join("Terminal.app"); + std::fs::create_dir(&fake_bundle).unwrap(); + let refused = Script::create(&t.0, &spec()).unwrap(); + let refused_path = refused.path().to_owned(); + assert!( + refused + .open_with(Platform::Mac, None, &fake_bundle, &fake) + .is_err() + ); + assert!(!refused_path.exists()); + assert!(fake.calls.borrow().is_empty()); + if !Path::new(MAC_TERMINAL_APP).exists() { + let missing = Script::create(&t.0, &spec()).unwrap(); + let missing_path = missing.path().to_owned(); + assert!(missing.open(Platform::Mac, None, &fake).is_err()); + assert!(!missing_path.exists()); + assert!(fake.calls.borrow().is_empty()); + } + // A root-owned, non-writable directory stands in for the system bundle: + // `open -a` receives that absolute path, never an application name. + assert!( + script + .open_with(Platform::Mac, None, Path::new("/usr"), &fake) + .is_err() + ); + assert!(!path.exists()); + let calls = fake.calls.borrow(); + assert_eq!(calls[0].0, Path::new("/usr/bin/open")); + assert_eq!( + calls[0].1[..3], + [ + OsString::from("-g"), + OsString::from("-a"), + OsString::from("/usr") + ] + ); + drop(calls); + let script = Script::create(&t.0, &spec()).unwrap(); + let path = script.path().to_owned(); + assert!(script.open(Platform::Linux, None, &fake).is_err()); + assert!(!path.exists()); + assert_eq!(fake.calls.borrow().len(), 1); + let script = Script::create(&t.0, &spec()).unwrap(); + let fake = Fake { + calls: Default::default(), + fail: false, + }; + let path = script.path().to_owned(); + script + .open( + Platform::Linux, + Some(Path::new("/usr/bin/xdg-terminal-exec")), + &fake, + ) + .unwrap(); + assert_eq!(fake.calls.borrow()[0].1, vec![OsString::from(path)]); +} +#[test] +fn enter_required_quoted_argv_and_cleanup_after_command() { + let t = Tree::new(); + let fake = t.0.join("fake hack"); + let marker = t.0.join("ran"); + std::fs::write( + &fake, + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$MARKER\"\nexit 7\n", + ) + .unwrap(); + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o700)).unwrap(); + let values = vec![ + "a'b".into(), + "$(touch forbidden); `id` $HOME".into(), + "space argument".into(), + ]; + let script = Script::create( + &t.0, + &CommandSpec { + program: fake.to_str().unwrap().into(), + args: values.clone(), + }, + ) + .unwrap(); + let path = script.path().to_owned(); + let mut child = std::process::Command::new("/bin/sh") + .arg(&path) + .env("MARKER", &marker) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); + let mut line = String::new(); + stdout.read_line(&mut line).unwrap(); + assert!(line.contains("fake hack")); + line.clear(); + stdout.read_line(&mut line).unwrap(); + assert!(line.contains("Press Enter")); + assert!(!marker.exists()); + let mut input = child.stdin.take().unwrap(); + input.write_all(b"\n").unwrap(); + line.clear(); + stdout.read_line(&mut line).unwrap(); + assert!(line.contains("Press Enter to close")); + assert!(line.contains("exit 7")); + assert!( + child.try_wait().unwrap().is_none(), + "output remains visible until close confirmation" + ); + assert!(path.exists()); + input.write_all(b"\n").unwrap(); + assert_eq!(child.wait().unwrap().code(), Some(7)); + assert_eq!( + std::fs::read_to_string(&marker).unwrap(), + format!("{}\n", values.join("\n")) + ); + assert!(!path.exists()); + assert!(!path.parent().unwrap().exists()); +} +#[test] +fn eof_cancels_and_controls_rejected() { + let t = Tree::new(); + let script = Script::create(&t.0, &spec()).unwrap(); + let path = script.path().to_owned(); + let status = std::process::Command::new("/bin/sh") + .arg(&path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(!status.success()); + assert!(!path.exists()); + for bad in ["hack\nwhoami", "hack\0oops", ""] { + assert!( + Script::create( + &t.0, + &CommandSpec { + program: bad.into(), + args: vec![] + } + ) + .is_err() + ); + } +} + +#[test] +fn system_launcher_drains_launcher_output_and_passes_only_listed_environment() { + // A stand-in launcher (no terminal opens): 256 KB on each stream would + // fill an undrained pipe, so it can only finish if output is discarded. + // HACK_HOME is set for the test process but is not on the launch list. + assert!( + std::env::var_os("HACK_HOME").is_some(), + "precondition: run through scripts/test.sh" + ); + let tree = Tree::new(); + let marker = tree.0.join("done"); + let script = format!( + "head -c 262144 /dev/zero; head -c 262144 /dev/zero >&2; printf '%s' \"${{HACK_HOME-unset}}\" > '{}'", + marker.display() + ); + hack_desktop_core::terminal::SystemLauncher + .launch( + Path::new("/bin/sh"), + &[OsString::from("-c"), OsString::from(script)], + ) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while std::fs::read_to_string(&marker).map_or(true, |s| s.is_empty()) { + assert!( + std::time::Instant::now() < deadline, + "launcher blocked on its output" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(std::fs::read_to_string(&marker).unwrap(), "unset"); +} + +#[test] +fn script_temp_root_writable_by_others_is_refused() { + let tree = Tree::new(); + let spec = CommandSpec { + program: "/usr/bin/true".into(), + args: vec![], + }; + let shared = tree.0.join("shared"); + std::fs::create_dir(&shared).unwrap(); + // Writable by others without the sticky bit: the private script directory + // could be renamed away and replaced before the terminal reads it. + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(Script::create(&shared, &spec).is_err()); + assert_eq!( + std::fs::read_dir(&shared).unwrap().count(), + 0, + "nothing created" + ); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(Script::create(&shared, &spec).is_ok()); +} + +#[test] +fn script_uses_the_validated_root_not_a_replaceable_alias() { + let tree = Tree::new(); + let spec = CommandSpec { + program: "/usr/bin/true".into(), + args: vec![], + }; + let private = tree.0.join("private"); + let shared = tree.0.join("shared"); + for dir in [&private, &shared] { + std::fs::create_dir(dir).unwrap(); + } + std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + // An alias in a directory others can change, pointing at a safe root. + let alias = shared.join("alias"); + std::os::unix::fs::symlink(&private, &alias).unwrap(); + let script = Script::create(&alias, &spec).unwrap(); + let canonical = std::fs::canonicalize(&private).unwrap(); + assert!( + script.path().starts_with(&canonical), + "{} must not go through the alias", + script.path().display() + ); +} diff --git a/apps/desktop/diagrams/setup-evidence.mmd b/apps/desktop/diagrams/setup-evidence.mmd new file mode 100644 index 000000000..5745805b9 --- /dev/null +++ b/apps/desktop/diagrams/setup-evidence.mmd @@ -0,0 +1,14 @@ +flowchart TD + Launch[Launch or check again] --> Read[Bounded background reads] + Read --> CLI{Supported CLI?} + CLI -->|No or unknown| Required[Required CLI card] + Required --> Launch + CLI -->|Yes| Cards[Ordered setup cards] + Cards -->|Missing or skipped| Unknown[Unresolved evidence] + Cards -->|Explicit Run| Safe[Fixed daemon action] + Safe --> Read + Cards -->|Admin command| Terminal[Copy or terminal handoff] + Terminal --> Enter[Show command; wait Enter] + Enter --> Fresh[Check again or regain focus] + Fresh --> Read + Cards -->|Continue| Projects[Project view] diff --git a/apps/desktop/diagrams/setup-evidence.svg b/apps/desktop/diagrams/setup-evidence.svg new file mode 100644 index 000000000..4c42ce786 --- /dev/null +++ b/apps/desktop/diagrams/setup-evidence.svg @@ -0,0 +1,66 @@ +No or unknownYesMissing or skippedExplicit RunAdmin commandContinueLaunch or check againBounded backgroundreadsSupported CLI?Required CLI cardOrdered setup cardsUnresolved evidenceFixed daemon actionCopy or terminal handoffShow command; waitEnterCheck again or regainfocusProject view \ No newline at end of file diff --git a/apps/desktop/screenshots/setup-dark.png b/apps/desktop/screenshots/setup-dark.png new file mode 100644 index 000000000..74469d886 Binary files /dev/null and b/apps/desktop/screenshots/setup-dark.png differ diff --git a/apps/desktop/screenshots/setup-light.png b/apps/desktop/screenshots/setup-light.png new file mode 100644 index 000000000..838da09c4 Binary files /dev/null and b/apps/desktop/screenshots/setup-light.png differ diff --git a/apps/desktop/screenshots/setup-service-unavailable-dark.png b/apps/desktop/screenshots/setup-service-unavailable-dark.png new file mode 100644 index 000000000..384660c01 Binary files /dev/null and b/apps/desktop/screenshots/setup-service-unavailable-dark.png differ diff --git a/apps/desktop/screenshots/setup-service-unavailable-light.png b/apps/desktop/screenshots/setup-service-unavailable-light.png new file mode 100644 index 000000000..2a3131a9f Binary files /dev/null and b/apps/desktop/screenshots/setup-service-unavailable-light.png differ