diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ee79f2c2b..388ba5235 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -215,9 +215,9 @@ observations, not locks against concurrent same-user changes. ## What v0 does not do yet -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). +The Activity tab, CPU and memory, always-on and broader daemon controls come in +later units. The design and the CLI/daemon requests they need are in `DESIGN.md` +(PR #800). ## Setup @@ -386,6 +386,104 @@ 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. +## Upgrade review (preview) + +![Upgrade evidence flow](diagrams/upgrade.svg) ([source](diagrams/upgrade.mmd)) + +**Upgrade Hack** opens without probing for updates. **Review readiness** runs the +selected CLI's read-only `upgrade check --json` on a background worker, keeping +valid reports from exit 1. Only `report_version` 2 is read: every plan step must +carry its `count` and `targets`, and a step covers the machine (no targets), one +checkout (its name) or several (`projects`). A target must name a live checkout, +by name and root, with exactly one finding for that step; any other report stays +Unknown. Grouped cards list each checkout with its own instruction and, when it +has one, its own quoted command. The hackd version comes from the socket named in the +CLI's own read-only `daemon status --json` answer. + +Every upgrade read and write (readiness, CLI version, daemon status, snapshot and +rollback) runs the CLI by its checked absolute path with the same non-secret +execution context as Setup (home, `HACK_HOME`, backend, service guards, the CLI's +`HACK_HOST_PROBES_DISABLE=1` hard deny when it is set, and Docker selectors; +nothing else from the app's environment) and Doctor's private tool +PATH: per-operation wrappers only for tools no other user can change, with +interpreter startup variables such as `BASH_ENV` removed. Like Doctor, every run +disables repository `core.fsmonitor` and hooks through command-line Git config, +because `upgrade check` reads Git in registered checkouts, and pins the native +runtime Doctor checked. Copied commands keep the host-probe hard deny too. They +run later, without the private wrapper PATH, so their PATH keeps only directories +no other user can change (owner- or root-owned, not group- or other-writable, with +the same ancestry and ACL rule as Doctor); with none left, it is `/usr/bin:/bin`. If that PATH cannot be +built, or a tool such as `docker` or `git` on PATH is in a location other users can +change, or PATH is too long to check fully, the operation is refused before the CLI +runs and the message names the tools. No `update --check` probe or daily check runs. The +CLI now has a read-only update check (R14), but this desktop adapter does not +consume it yet, so availability stays Unknown for every live install kind, including candidates and +Homebrew. Readiness is separate from update availability. Actual Upgrade stays +disabled until verified target and installation evidence exists; the current +live adapter never supplies that evidence. Doctor's +helper and Docker preflight also runs first, so a remote Docker endpoint (for +example `DOCKER_HOST=ssh://...`) refuses the operation as well. + +**Save file snapshot** is an explicit CLI write, available after a complete ready +review. The app accepts a recovery point only for `status: created`, `complete: +true`, an empty `gaps` list and an absolute `manifest.json` path under the returned +snapshot id. Refused, malformed and incomplete receipts enable neither Upgrade +nor rollback. For a refused receipt the pane shows the CLI's own reason (control +characters removed, at most 600 characters) so the user knows what to fix +before retrying. The app never opens the saved files or reads file values or keys. +The CLI remains responsible for private-manifest validation and file ownership. + +With proposed R14 evidence, the fixture flow offers **Upgrade in Terminal** only +after a complete snapshot. The authored sequence is `brew upgrade hack` followed +by `brew upgrade --cask hack-desktop`, stopping on failure. The shared terminal +adapter quotes each argument, displays the sequence and waits for Enter. It never +runs finding `command` text. Plan argv may be displayed/copied only after quoting, +bound like Setup's commands to the reviewed CLI and context (`/usr/bin/env -i +HOME=… HACK_HOME=… …`), so a terminal run addresses the reviewed home, +backend and guards, plus `HACK_MCP_COMMAND` naming the reviewed CLI so a copied +daemon command restarts that CLI. A review without a checked context offers no copy; +R17 safe metadata and an authored allowlist are required before any plan fix can +be run in the app. Today those cards offer Copy and documentation only. No admin +action, password prompt, self update or relaunch happens in the app. + +Terminal launch and window focus never confirm success. **Check again** requires +fresh CLI and Homebrew-installed app versions at the exact target, hackd matching +the CLI, and a live, fully readable ready report with no remaining plan. Every +previous step needs the same scope, checkout root and explicit `ok` finding; +a grouped step needs that for every checkout it covered, under the id of the +finding that put the checkout in the step (an `apply_fix` group may differ). +A disappeared step, missing scope, unreadable checkout or unchecked registry +entry stays Unknown. The snapshot plan entry is proven by its separate complete +receipt. A read adapter for `brew info --json=v2 --cask hack-desktop` uses the +installed version, never the catalog version; it stays unwired until the desktop adopts +R14's installation-ownership evidence. **Restart hackd** is explicit, shares the machine-action +gate with setup/recovery, and re-reads versions after the command. No poll starts +or restarts a service. Upgrade reads run only on demand, including while hidden; +there is no eager or background daily upgrade check. + +**Restore files from snapshot** calls only `upgrade rollback --rollback-manifest + --json`, without overwrite. Results list each path, status, +kept-as path and reason. Incomplete restores stay failed. A complete result says +**Files restored. Versions, containers and services were not changed.** Fresh +readiness is required again. All state and receipts stay in memory for this app +session. The desktop has no signing/cask distribution or production updater in +this unit (DESIGN sections 4 and 8). + +Offline proposed-capability examples (no CLI, Homebrew or terminal effects): + +```sh +cargo run -p hack-desktop -- --upgrade-fixture ready --theme dark +cargo run -p hack-desktop -- --upgrade-fixture availability-unknown +``` + +Fixture names: `availability-unknown`, `available`, `checking`, `blocked`, `ready`, +`snapshotting`, `incomplete`, `applying`, `unknown`, `restarting`, `done`, `failed`, +`rolling-back`, `restored`. `--screenshots` renders all fourteen in both themes +without a window. Evidence: [light](screenshots/upgrade-light.png), +[dark](screenshots/upgrade-dark.png). Core tests use only fake executables, isolated homes, disabled +autostart, injected launchers and pure evidence inputs. They do not qualify live +Homebrew, terminal, platform focus, a published app or a real upgrade. + ## Logs tab `hack logs --json` for the selected project, following by default: diff --git a/apps/desktop/app/src/doctor_view.rs b/apps/desktop/app/src/doctor_view.rs index eb2ed20eb..1d71ed752 100644 --- a/apps/desktop/app/src/doctor_view.rs +++ b/apps/desktop/app/src/doctor_view.rs @@ -28,6 +28,7 @@ pub enum Pane { Project, Doctor, Setup, + Upgrade, } pub fn render(pane: &DoctorPane, cx: &mut Context) -> AnyElement { diff --git a/apps/desktop/app/src/main.rs b/apps/desktop/app/src/main.rs index 47c6762ea..e4b5f4abf 100644 --- a/apps/desktop/app/src/main.rs +++ b/apps/desktop/app/src/main.rs @@ -14,6 +14,7 @@ mod logs_view; #[cfg(feature = "screenshots")] mod screenshots; mod setup_view; +mod upgrade_view; mod view; use std::sync::Arc; @@ -26,6 +27,7 @@ use hack_desktop_core::source::{FixtureSource, LiveSource, Source}; pub(crate) struct Args { pub fixture: Option, pub setup_fixture: Option, + pub upgrade_fixture: Option, pub logs_fixture: Option, pub theme: Option, pub screenshots: Option, @@ -33,7 +35,7 @@ pub(crate) struct Args { fn usage() -> ! { eprintln!( - "usage: hack-desktop [--setup-fixture missing-cli|partial|ready|native-unknown|service-unavailable] [--fixtures | --logs-fixture ] [--theme light|dark] [--list-fixtures] [--screenshots ]" + "usage: hack-desktop [--upgrade-fixture ] [--setup-fixture missing-cli|partial|ready|native-unknown|service-unavailable] [--fixtures | --logs-fixture ] [--theme light|dark] [--list-fixtures] [--screenshots ]" ); std::process::exit(2) } @@ -42,6 +44,7 @@ fn parse_args() -> Args { let mut a = Args { fixture: None, setup_fixture: None, + upgrade_fixture: None, logs_fixture: None, theme: None, screenshots: None, @@ -49,6 +52,13 @@ fn parse_args() -> Args { let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { match arg.as_str() { + "--upgrade-fixture" => { + let name = it.next().unwrap_or_else(|| usage()); + if hack_desktop_core::upgrade_fixtures::load(&name).is_none() { + usage(); + } + a.upgrade_fixture = Some(name); + } "--setup-fixture" => { let name = it.next().unwrap_or_else(|| usage()); if hack_desktop_core::setup_fixtures::load(&name).is_none() { @@ -131,6 +141,7 @@ fn main() { gpui_kit::application().run(move |cx| { gpui_kit::init(cx); let setup_fixture = args.setup_fixture; + let upgrade_fixture = args.upgrade_fixture; let fixture = args .fixture .or(args.logs_fixture.map(|l| { @@ -140,7 +151,8 @@ fn main() { Fixture::Running } })) - .or(setup_fixture.as_ref().map(|_| Fixture::Running)); + .or(setup_fixture.as_ref().map(|_| Fixture::Running)) + .or(upgrade_fixture.as_ref().map(|_| Fixture::Running)); let logs_fixture = args.logs_fixture; let theme = args.theme; let source = source_for(fixture); @@ -173,6 +185,12 @@ fn main() { { app.show_setup_now(report); } + if let Some(flow) = upgrade_fixture + .as_deref() + .and_then(hack_desktop_core::upgrade_fixtures::load) + { + app.show_upgrade_now(flow); + } 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 1016afa88..5555cfc50 100644 --- a/apps/desktop/app/src/screenshots.rs +++ b/apps/desktop/app/src/screenshots.rs @@ -35,6 +35,45 @@ 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::upgrade_fixtures::NAMES { + cx.update(|cx| Theme::change(mode, None, cx)); + let opts = WindowOptions { + focus: false, + show: false, + ..crate::window_options() + }; + 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_upgrade_now( + hack_desktop_core::upgrade_fixtures::load(name) + .expect("known upgrade fixture"), + ); + app + }) + }) + }) + .expect("offscreen upgrade window"); + cx.update_window(handle, |_, w, cx| w.render_frame(cx)) + .expect("render upgrade"); + let image = cx.capture_screenshot(handle).expect("upgrade renderer"); + let suffix = if mode == ThemeMode::Dark { + "dark" + } else { + "light" + }; + image + .save(dir.join(format!("upgrade-{name}-{suffix}.png"))) + .expect("save upgrade"); + cx.update_window(handle, |_, w, _| w.remove_window()).ok(); + } for name in hack_desktop_core::setup_fixtures::NAMES { cx.update(|cx| Theme::change(mode, None, cx)); let mut opts = crate::window_options(); diff --git a/apps/desktop/app/src/setup_controller.rs b/apps/desktop/app/src/setup_controller.rs index f8d047bd5..ea40e3734 100644 --- a/apps/desktop/app/src/setup_controller.rs +++ b/apps/desktop/app/src/setup_controller.rs @@ -16,7 +16,7 @@ impl HackApp { self.setup.refresh_again = true; return; } - if self.setup.action_busy || self.recovery_busy { + if self.setup.action_busy || self.recovery_busy || self.upgrade.busy { return; } let generation = self.setup.begin(); @@ -65,6 +65,7 @@ impl HackApp { && !self.setup.loading && !self.setup.action_busy && !self.recovery_busy + && !self.upgrade.busy { self.pane = Pane::Project; cx.notify(); diff --git a/apps/desktop/app/src/upgrade_controller.rs b/apps/desktop/app/src/upgrade_controller.rs new file mode 100644 index 000000000..9d4d4f7ec --- /dev/null +++ b/apps/desktop/app/src/upgrade_controller.rs @@ -0,0 +1,199 @@ +//! Explicit upgrade effects share setup/recovery singleflight gates. +use super::*; +use hack_desktop_core::upgrade::State; +impl HackApp { + pub fn show_upgrade_now(&mut self, flow: hack_desktop_core::upgrade::Flow) { + self.upgrade = flow; + self.pane = Pane::Upgrade; + } + pub fn open_upgrade(&mut self, cx: &mut Context) { + self.pane = Pane::Upgrade; + cx.notify(); + } + fn upgrade_gated(&self) -> bool { + self.action_busy() + || self.setup.loading + || !self.action_started.is_empty() + || (self.live && !self.setup.report.cli_ready()) + } + pub fn review_upgrade(&mut self, cx: &mut Context) { + if self.upgrade_gated() { + return; + } + if !self.live { + self.upgrade.message = Some("Fixture: no CLI read executed".into()); + cx.notify(); + return; + } + self.upgrade.begin(State::Checking); + let source = self.source.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { source.upgrade_check() }) + .await; + let _ = this.update(cx, |this, cx| { + match result { + Ok(observation) => this.upgrade.checked(observation), + Err(e) => { + this.upgrade.busy = false; + this.upgrade.state = State::Unknown; + this.upgrade.observation.report = None; + this.upgrade.message = Some(e); + } + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + pub fn save_upgrade_files(&mut self, cx: &mut Context) { + if self.upgrade_gated() || !self.upgrade.can_snapshot() { + return; + } + if !self.live { + self.upgrade.message = Some("Fixture: no files written".into()); + cx.notify(); + return; + } + // Invalidate any previous receipt before a new write is attempted. + self.upgrade.snapshot = None; + self.upgrade.begin(State::Snapshotting); + let source = self.source.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { source.upgrade_snapshot() }) + .await; + let _ = this.update(cx, |this, cx| { + match result { + Ok(s) => this.upgrade.saved(s), + Err(e) => this.upgrade.failed(e), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + pub fn run_upgrade_terminal(&mut self, cx: &mut Context) { + if self.upgrade_gated() || !self.upgrade.can_upgrade() { + return; + } + if !self.live { + self.upgrade.message = Some("Fixture: no terminal opened".into()); + cx.notify(); + return; + } + let source = self.source.clone(); + let expected_cli = self.upgrade.observation.cli_program.clone(); + self.upgrade.begin(State::Applying); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { + use hack_desktop_core::terminal::{ + Platform, Script, SystemLauncher, linux_adapter, + }; + if source.cli_program()? != expected_cli { + return Err("The selected CLI changed. Review readiness again.".into()); + } + let adapter = linux_adapter(); + let platform = if cfg!(target_os = "macos") { + Platform::Mac + } else { + Platform::Linux + }; + Script::create_sequence( + &std::env::temp_dir(), + &hack_desktop_core::upgrade::homebrew_commands(), + )? + .open(platform, adapter.as_deref(), &SystemLauncher) + }) + .await; + let _ = this.update(cx, |this, cx| { + match result { + Ok(()) => this.upgrade.handed_off(), + Err(e) => this.upgrade.failed(e), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + pub fn rollback_upgrade_files(&mut self, cx: &mut Context) { + if self.upgrade_gated() || !self.upgrade.can_rollback() { + return; + } + if !self.live { + self.upgrade.message = Some("Fixture: no files restored".into()); + cx.notify(); + return; + } + let Some(snapshot) = self.upgrade.snapshot.clone() else { + return; + }; + self.upgrade.begin(State::RollingBack); + let source = self.source.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { source.upgrade_rollback(&snapshot) }) + .await; + let _ = this.update(cx, |this, cx| { + match result { + Ok(result) => this.upgrade.rolled_back(result), + Err(e) => this.upgrade.failed(e), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + pub fn restart_upgrade_daemon(&mut self, cx: &mut Context) { + if self.upgrade_gated() || self.upgrade.previous.is_none() { + return; + } + if !self.live { + self.upgrade.message = Some("Fixture: no daemon restarted".into()); + cx.notify(); + return; + } + // Shared recovery gate; no automatic restart and no auto-starting poll. + self.recovery_busy = true; + self.upgrade.begin(State::Restarting); + let source = self.source.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { + source.recover_daemon(DaemonRecovery::Restart)?; + source.upgrade_check() + }) + .await; + let _ = this.update(cx, |this, cx| { + this.recovery_busy = false; + match result { + Ok(observation) => { + let matches = + observation.cli.is_some() && observation.daemon == observation.cli; + this.upgrade.checked(observation); + if !matches { + this.upgrade.failed( + "hackd still differs from the CLI or could not be read".into(), + ); + } + } + Err(e) => this.upgrade.failed(e), + } + this.refresh_now_async(cx); + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } +} diff --git a/apps/desktop/app/src/upgrade_view.rs b/apps/desktop/app/src/upgrade_view.rs new file mode 100644 index 000000000..27a0a641c --- /dev/null +++ b/apps/desktop/app/src/upgrade_view.rs @@ -0,0 +1,298 @@ +//! Ordinary user copy; proposed capability boundaries belong in the docs. +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::upgrade::{Flow, State}; +pub fn render(flow: &Flow, machine_busy: bool, cx: &mut Context) -> AnyElement { + let t = cx.theme().clone(); + let busy = machine_busy || flow.busy; + let mut body = v_flex().id("upgrade-body").flex_1().min_h_0().overflow_y_scroll().p_5().gap_3() + .child(div().text_lg().font_weight(FontWeight::SEMIBOLD).child(flow.state.label())) + .child(div().text_sm().text_color(t.muted_foreground).child(if flow.target.is_none() { + "Update availability could not be verified. You can review readiness and save files, but Upgrade is unavailable." + } else { "Review readiness, save a complete file snapshot, then run the upgrade in your terminal. Check again to confirm each result." })); + if let Some(target) = &flow.target { + body = body.child(div().child(format!("Target: Hack {}", target.version))); + } + body = body.child( + h_flex() + .gap_3() + .flex_wrap() + .child(div().child(format!( + "CLI: {}", + flow.observation.cli.as_deref().unwrap_or("Unknown") + ))) + .child(div().child(format!( + "Installed app: {}", + flow.observation.app.as_deref().unwrap_or("Unknown") + ))) + .child(div().child(format!( + "hackd: {}", + flow.observation.daemon.as_deref().unwrap_or("Unknown") + ))), + ); + if let Some(message) = &flow.message { + body = body.child( + div() + .text_sm() + .p_3() + .border_1() + .border_color(t.border) + .child(message.clone()), + ); + } + if let Some(snapshot) = &flow.snapshot { + if let Some(reason) = snapshot.refusal() { + body = body.child( + div() + .text_sm() + .p_3() + .border_1() + .border_color(t.warning) + .child(format!("The CLI refused to create a snapshot: {reason}")), + ); + } + body = body.child(div().text_sm().child(if snapshot.manifest().is_some() { + "Files were saved. Rollback restores files only." + } else { + "This snapshot is not a complete recovery point. Upgrade and rollback are unavailable." + })); + if let Some(path) = snapshot.manifest() { + body = body.child(div().text_xs().child(path.to_owned())); + } + for gap in snapshot.gaps.iter().flatten() { + body = body.child( + div() + .text_sm() + .child(format!("{}: {}", gap.name, gap.reason)), + ); + } + } + if let Some(report) = &flow.observation.report { + for finding in report.blockers.iter().chain(&report.incomplete) { + body = body.child( + div() + .p_3() + .border_1() + .border_color(t.warning) + .child(format!( + "{} · {}: {}", + finding.scope, finding.id, finding.summary + )), + ); + } + if report.plan.is_empty() { + body = body.child(div().child(if report.ready() { + "No readiness steps remain." + } else { + "Readiness could not be fully checked." + })); + } + for (i, step) in report.plan.iter().enumerate() { + let mut card = v_flex() + .p_3() + .gap_2() + .border_1() + .border_color(t.border) + .rounded_lg() + .child(div().font_weight(FontWeight::SEMIBOLD).child(format!( + "{}. {} · {}", + i + 1, + step.scope, + step.id + ))) + .child(div().text_sm().child(step.why.clone())); + if step.targets.len() > 1 { + card = card.child(div().text_xs().child(format!("{} checkouts:", step.count))); + } + if let Some(action) = &step.action { + card = card.child(div().text_sm().child(action.clone())); + } + // Copy only: the reviewed CLI bound to the reviewed context. With + // no checked context there is no command to offer. + if let Some(command) = step + .args + .as_ref() + .and_then(|a| flow.observation.copy_command(a)) + { + card = card.child(copy_row(format!("upgrade-copy-{i}"), command)); + } + for (j, row) in step.target_rows(&flow.observation).into_iter().enumerate() { + let mut target = v_flex() + .pl_3() + .gap_1() + .border_l_1() + .border_color(t.border) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(row.scope), + ); + if let Some(action) = row.action { + target = target.child(div().text_sm().child(action)); + } + if let Some(command) = row.command { + target = target.child(copy_row(format!("upgrade-copy-{i}-{j}"), command)); + } + card = card.child(target); + } + body = body.child(card); + } + } + if let Some(before) = &flow.previous { + for step in before.plan.iter().filter(|s| s.id != "snapshot") { + let confirmed = flow + .observation + .report + .as_ref() + .is_some_and(|r| r.confirms(step, before)); + body = body.child(div().text_sm().child(format!( + "{} · {}: {}", + step.scope, + step.id, + if confirmed { "Confirmed" } else { "Unknown" } + ))); + } + } + if let Some(result) = &flow.rollback { + if let Some(message) = &result.message { + body = body.child(div().text_sm().child(message.clone())); + } + for file in &result.results { + body = body.child(div().text_sm().child(format!( + "{}: {}{}{}", + file.path, + file.status, + file.kept_as + .as_ref() + .map(|p| format!(" · kept as {p}")) + .unwrap_or_default(), + file.message + .as_ref() + .map(|m| format!(" · {m}")) + .unwrap_or_default() + ))); + } + } + if flow.state == State::Restored { + body = body.child( + div().child("Files restored. Versions, containers and services were not changed."), + ); + } + let commands = hack_desktop_core::upgrade::homebrew_commands() + .iter() + .filter_map(|c| c.display().ok()) + .collect::>() + .join(" && "); + body = body.child( + div() + .text_xs() + .text_color(t.muted_foreground) + .child(commands), + ); + let actions = h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("upgrade-review") + .label(if flow.observation.report.is_some() { + "Check again" + } else { + "Review readiness" + }) + .small() + .outline() + .disabled(busy) + .on_click(cx.listener(|this, _, _, cx| this.review_upgrade(cx))), + ) + .child( + Button::new("upgrade-save") + .label("Save file snapshot") + .small() + .outline() + .disabled(busy || !flow.can_snapshot()) + .on_click(cx.listener(|this, _, _, cx| this.save_upgrade_files(cx))), + ) + .child( + Button::new("upgrade-run") + .label("Upgrade in Terminal") + .small() + .outline() + .disabled(busy || !flow.can_upgrade()) + .on_click(cx.listener(|this, _, _, cx| this.run_upgrade_terminal(cx))), + ) + .child( + Button::new("upgrade-restart") + .label("Restart hackd") + .small() + .outline() + .disabled(busy || flow.previous.is_none()) + .on_click(cx.listener(|this, _, _, cx| this.restart_upgrade_daemon(cx))), + ) + .child( + Button::new("upgrade-rollback") + .label("Restore files from snapshot") + .small() + .outline() + .disabled(busy || !flow.can_rollback()) + .on_click(cx.listener(|this, _, _, cx| this.rollback_upgrade_files(cx))), + ) + .child( + Button::new("upgrade-docs") + .label("Upgrade documentation") + .small() + .ghost() + .on_click(|_, _, cx| { + cx.open_url( + "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/upgrade.md", + ) + }), + ); + v_flex() + .flex_1() + .h_full() + .min_w_0() + .child( + div() + .px_5() + .py_3() + .border_b_1() + .border_color(t.border) + .text_xl() + .font_weight(FontWeight::BOLD) + .child("Upgrade Hack"), + ) + .child(body) + .child( + div() + .p_3() + .border_t_1() + .border_color(t.border) + .child(actions), + ) + .into_any_element() +} + +/// A quoted command shown in full with a copy button. Never executed here. +fn copy_row(id: String, command: String) -> impl IntoElement { + v_flex() + .gap_1() + .child(div().text_xs().child(command.clone())) + .child( + Button::new(id) + .label("Copy command") + .small() + .ghost() + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(command.clone())) + }), + ) +} diff --git a/apps/desktop/app/src/view.rs b/apps/desktop/app/src/view.rs index d21425519..d3d0c9fd6 100644 --- a/apps/desktop/app/src/view.rs +++ b/apps/desktop/app/src/view.rs @@ -38,6 +38,8 @@ use hack_desktop_core::viewmodel::{ #[path = "setup_controller.rs"] mod setup_controller; +#[path = "upgrade_controller.rs"] +mod upgrade_controller; const TAIL_LINES: u32 = 20; @@ -54,6 +56,7 @@ pub struct HackApp { doctor: DoctorPane, pub(crate) setup: hack_desktop_core::setup::Pane, setup_initial: bool, + pub(crate) upgrade: hack_desktop_core::upgrade::Flow, _activation: Option, /// Is the window visible (not minimised or hidden)? Automatic doctor /// runs wait for it. @@ -84,6 +87,7 @@ impl HackApp { doctor: DoctorPane::default(), setup: Default::default(), setup_initial: true, + upgrade: Default::default(), _activation: None, window_visible: true, _visibility: None, @@ -527,7 +531,7 @@ impl HackApp { self.machine_action_busy() || self.model.has_in_flight() } pub(crate) fn machine_action_busy(&self) -> bool { - self.recovery_busy || self.setup.action_busy + self.recovery_busy || self.setup.action_busy || self.upgrade.busy } fn recover_daemon(&mut self, action: DaemonRecovery, cx: &mut Context) { @@ -668,7 +672,14 @@ impl HackApp { .py_2() // v0 does not read routing status yet: shown as unknown. .child(machine_row("Routing and DNS", None, t.muted_foreground)) - .child(self.doctor_row(cx)), + .child(self.doctor_row(cx)) + .child( + Button::new("upgrade-pane") + .label("Upgrade Hack") + .small() + .ghost() + .on_click(cx.listener(|this, _, _, cx| this.open_upgrade(cx))), + ), ) } @@ -1348,6 +1359,14 @@ impl Render for HackApp { _ if self.pane == Pane::Setup => { crate::setup_view::render(&self.setup, self.action_busy(), cx) } + _ if self.pane == Pane::Upgrade => crate::upgrade_view::render( + &self.upgrade, + self.setup.action_busy + || self.setup.loading + || self.recovery_busy + || !self.action_started.is_empty(), + 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. diff --git a/apps/desktop/core/src/execution.rs b/apps/desktop/core/src/execution.rs index af2251589..66eea6588 100644 --- a/apps/desktop/core/src/execution.rs +++ b/apps/desktop/core/src/execution.rs @@ -11,6 +11,7 @@ const KEYS: &[&str] = &[ "HACK_LAUNCHD_DISABLE", "HACK_SYSTEMD_DISABLE", "HACK_DAEMON_AUTOSTART_DISABLE", + "HACK_HOST_PROBES_DISABLE", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "DOCKER_CONTEXT", @@ -91,6 +92,11 @@ impl ExecutionContext { } // Observers and fixes never opt into incidental daemon autostart. entries.push(("HACK_DAEMON_AUTOSTART_DISABLE".into(), "1".into())); + // The CLI's hard deny on host probes is kept whenever it is set; a + // checked read or copied command must never re-enable them. + if get("HACK_HOST_PROBES_DISABLE").map(str::trim) == Some("1") { + entries.push(("HACK_HOST_PROBES_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()); diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 52d4911a9..90376338e 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -26,6 +26,9 @@ pub mod setup_io; pub mod source; pub mod state; pub mod terminal; +pub mod upgrade; +pub mod upgrade_fixtures; +pub mod upgrade_io; pub mod viewmodel; pub mod env; diff --git a/apps/desktop/core/src/source.rs b/apps/desktop/core/src/source.rs index 35dd3adc5..e3af11d0b 100644 --- a/apps/desktop/core/src/source.rs +++ b/apps/desktop/core/src/source.rs @@ -180,6 +180,20 @@ pub trait Source: Send + Sync { fn log_tail(&self, repo_root: &Path, native: bool, lines: u32) -> Result, String>; + /// Explicit upgrade Review; never a periodic poll. + fn upgrade_check(&self) -> Result { + Err("Fixture: upgrade reads disabled".into()) + } + fn upgrade_snapshot(&self) -> Result { + Err("Fixture: snapshot writes disabled".into()) + } + fn upgrade_rollback( + &self, + _snapshot: &crate::upgrade::Snapshot, + ) -> Result { + Err("Fixture: rollback writes disabled".into()) + } + /// Fresh setup checks. Fixture implementations stay fully offline. fn setup(&self) -> crate::setup::Report { crate::setup::fixture(self.snapshot(), &self.doctor_runs(&[])) @@ -271,6 +285,20 @@ pub const DOCTOR_BUDGET: Duration = Duration::from_secs(90); /// The reason shown for projects the budget did not reach. pub const NOT_CHECKED_BUDGET: &str = "not checked (time budget)"; +/// Doctor and the upgrade check invoke Git read commands in registered +/// checkouts. Repository config must never turn those observations into +/// fsmonitor or hook execution, so these command-line config entries (which +/// take precedence over repository config) are applied to every such run. +pub(crate) fn git_reads_only(runner: CliRunner) -> CliRunner { + runner + .with_env("GIT_CONFIG_PARAMETERS", "") + .with_env("GIT_CONFIG_COUNT", "2") + .with_env("GIT_CONFIG_KEY_0", "core.fsmonitor") + .with_env("GIT_CONFIG_VALUE_0", "false") + .with_env("GIT_CONFIG_KEY_1", "core.hooksPath") + .with_env("GIT_CONFIG_VALUE_1", "/dev/null") +} + /// Read one doctor report. Exit 1 still carries a report; a payload /// without a `checks` array is not a report. fn read_doctor( @@ -278,16 +306,7 @@ fn read_doctor( root: Option<&Path>, timeout: Duration, ) -> Result { - // Doctor invokes Git read commands. Repository config must never turn - // those observations into fsmonitor or hook execution. - let v: serde_json::Value = runner - .clone() - .with_env("GIT_CONFIG_PARAMETERS", "") - .with_env("GIT_CONFIG_COUNT", "2") - .with_env("GIT_CONFIG_KEY_0", "core.fsmonitor") - .with_env("GIT_CONFIG_VALUE_0", "false") - .with_env("GIT_CONFIG_KEY_1", "core.hooksPath") - .with_env("GIT_CONFIG_VALUE_1", "/dev/null") + let v: serde_json::Value = git_reads_only(runner.clone()) .run_json(&["doctor", "--json"], root, timeout) .map_err(|e| e.to_string())?; if !v @@ -386,14 +405,14 @@ pub(crate) fn trusted_interpreter(real: &Path, uid: u32, depth: u8, wrapper_path /// Wrappers exec the original absolute path so shims keep their normal $0. /// No credential/context values are stored. Drop removes only this owned scratch. #[derive(Debug)] -struct ProbeTools { +pub(crate) struct ProbeTools { dir: PathBuf, - coverage: Option<&'static str>, - unchecked: std::collections::BTreeSet<&'static str>, + pub(crate) coverage: Option<&'static str>, + pub(crate) unchecked: std::collections::BTreeSet<&'static str>, } impl ProbeTools { - fn path(&self) -> &std::ffi::OsStr { + pub(crate) fn path(&self) -> &std::ffi::OsStr { self.dir.as_os_str() } } @@ -448,7 +467,7 @@ pub(crate) fn read_daemon_status( })? } -fn trusted_probe_path(path: &std::ffi::OsStr) -> Result { +pub(crate) fn trusted_probe_path(path: &std::ffi::OsStr) -> Result { trusted_probe_path_with_limits(path, 64, 8192) } @@ -891,6 +910,19 @@ impl LiveSource { } impl Source for LiveSource { + fn upgrade_check(&self) -> Result { + crate::upgrade_io::check(self) + } + fn upgrade_snapshot(&self) -> Result { + crate::upgrade_io::snapshot(self) + } + fn upgrade_rollback( + &self, + snapshot: &crate::upgrade::Snapshot, + ) -> Result { + crate::upgrade_io::rollback(self, snapshot) + } + fn cli_program(&self) -> Result { self.runner .resolve_program() diff --git a/apps/desktop/core/src/terminal.rs b/apps/desktop/core/src/terminal.rs index 9faa36852..acad90ead 100644 --- a/apps/desktop/core/src/terminal.rs +++ b/apps/desktop/core/src/terminal.rs @@ -116,7 +116,18 @@ pub struct Script { } impl Script { pub fn create(temp_root: &Path, spec: &CommandSpec) -> Result { - let command = spec.display()?; + Self::create_sequence(temp_root, std::slice::from_ref(spec)) + } + /// Fixed authored argv only. Stop the sequence on the first failing command. + pub fn create_sequence(temp_root: &Path, specs: &[CommandSpec]) -> Result { + if specs.is_empty() { + return Err("No terminal commands".into()); + } + let command = specs + .iter() + .map(CommandSpec::display) + .collect::, _>>()? + .join(" && "); if !temp_root.is_absolute() || temp_root.to_str().is_none() { return Err("Terminal temp root must be an absolute UTF-8 path".into()); } diff --git a/apps/desktop/core/src/upgrade.rs b/apps/desktop/core/src/upgrade.rs new file mode 100644 index 000000000..8bbf5abc8 --- /dev/null +++ b/apps/desktop/core/src/upgrade.rs @@ -0,0 +1,554 @@ +//! Upgrade decisions are pure. Only explicit UI actions call the CLI adapters. +//! R14 target discovery and R17 safe-fix metadata are not available in live mode. +use serde::Deserialize; +use std::path::Path; + +/// The only `upgrade check --json` contract read. Version 2 groups checkout +/// steps (`count`, `targets`); version 1 never shipped in a release tag. +pub const REPORT_VERSION: u32 = 2; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Finding { + pub id: String, + pub status: String, + pub summary: String, + pub args: Option>, + pub action: Option, + /// The grouped plan step this finding belongs to when it is not `id`. + pub apply_fix: Option, +} +impl Finding { + /// The plan step id that covers this finding. + pub fn step_id(&self) -> &str { + self.apply_fix.as_deref().unwrap_or(&self.id) + } +} +/// One checkout a grouped plan step covers. Its argv is display-only. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PlanTarget { + pub scope: String, + pub project: String, + pub root: String, + pub status: String, + pub args: Option>, + pub action: Option, +} +/// A plan step: `machine` (no targets), one checkout (its name, one target) +/// or `projects` (two or more targets). +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Step { + pub id: String, + pub scope: String, + pub why: String, + pub args: Option>, + pub action: Option, + pub count: usize, + pub targets: Vec, +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ScopedFinding { + pub scope: String, + pub id: String, + pub summary: String, +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Project { + pub name: String, + pub root: String, + pub state: String, + pub findings: Vec, +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct RegistryEntry { + pub name: String, + pub root: String, +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Registry { + pub state: String, + pub total: usize, + pub live: usize, + pub missing: Vec, + pub unreadable: Vec, + pub unchecked: Vec, + pub rejected: usize, +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Report { + pub report_version: u32, + pub host_probes: String, + pub ready: bool, + pub machine: Vec, + pub projects: Vec, + pub registry: Registry, + pub blockers: Vec, + pub incomplete: Vec, + pub plan: Vec, +} +impl Report { + pub fn readable(&self) -> bool { + self.report_version == REPORT_VERSION + && self.host_probes == "enabled" + && matches!(self.registry.state.as_str(), "valid" | "absent") + && self.registry.missing.is_empty() + && self.registry.unreadable.is_empty() + && self.registry.unchecked.is_empty() + && self.registry.rejected == 0 + && self.registry.live == self.registry.total + && self.projects.len() == self.registry.live + && self.projects.iter().all(|p| { + p.state == "live" && p.name != "machine" && Path::new(&p.root).is_absolute() + }) + && self.projects.iter().enumerate().all(|(i, p)| { + !self.projects[..i] + .iter() + .any(|q| q.name == p.name || q.root == p.root) + }) + && self.plan.iter().all(|step| self.consistent(step)) + } + /// A step's scope, count and targets agree with each other and name a + /// live checkout whose single finding the step covers. + fn consistent(&self, step: &Step) -> bool { + let scope = match step.targets.as_slice() { + [] => "machine", + [only] => only.scope.as_str(), + _ => "projects", + }; + step.count == step.targets.len() + && step.scope == scope + && step.targets.iter().enumerate().all(|(i, t)| { + !step.targets[..i].iter().any(|u| u.scope == t.scope) + && self.target_finding(step, t).is_some() + }) + } + /// The one finding in the target's checkout (same name and root) that + /// this step covers, if there is exactly one. + fn target_finding(&self, step: &Step, target: &PlanTarget) -> Option<&Finding> { + let project = self + .projects + .iter() + .find(|p| p.name == target.scope && p.root == target.root)?; + let mut matching = project.findings.iter().filter(|f| f.step_id() == step.id); + let found = matching.next()?; + matching.next().is_none().then_some(found) + } + pub fn ready(&self) -> bool { + self.readable() + && self.ready + && self.blockers.is_empty() + && self.incomplete.is_empty() + && self + .machine + .iter() + .chain(self.projects.iter().flat_map(|p| &p.findings)) + .all(|f| matches!(f.status.as_str(), "ok" | "info" | "warn")) + } + /// Absence is never proof: match the same scope, checkout and explicit ok id. + pub fn confirms(&self, step: &Step, before: &Report) -> bool { + if !self.readable() + || self.incomplete.iter().any(|f| f.scope == step.scope) + || self + .blockers + .iter() + .any(|f| f.scope == step.scope && f.id == step.id) + { + return false; + } + if step.id == "snapshot" && step.scope == "machine" { + return true; // Proven separately by the admitted snapshot, not a finding. + } + let explicit_ok = |fs: &[Finding], id: &str| { + let matching: Vec<_> = fs.iter().filter(|f| f.id == id).collect(); + matching.len() == 1 && matching[0].status == "ok" + }; + if step.scope == "machine" { + return step.targets.is_empty() && explicit_ok(&self.machine, &step.id); + } + // Every covered checkout needs its own explicit ok, under the id of + // the finding that put it in the step (a grouped fix may differ). + !step.targets.is_empty() + && step.targets.iter().all(|target| { + let Some(old) = before.target_finding(step, target) else { + return false; + }; + !self.incomplete.iter().any(|f| f.scope == target.scope) + && !self + .blockers + .iter() + .any(|f| f.scope == target.scope && f.id == old.id) + && self + .projects + .iter() + .find(|p| { + p.name == target.scope && p.root == target.root && p.state == "live" + }) + .is_some_and(|p| explicit_ok(&p.findings, &old.id)) + }) + } +} + +/// Proposed R14 evidence. Live adapters currently never produce this value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + pub version: String, + pub cli_program: String, + pub install_kind: String, + pub available: bool, +} +impl Target { + pub fn usable(&self, cli: &str) -> bool { + self.available + && self.install_kind == "homebrew" + && self.cli_program == cli + && Path::new(cli).is_absolute() + && version(&self.version) + } +} +fn version(s: &str) -> bool { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || ".-+".contains(c)) + && s.chars().next().is_some_and(|c| c.is_ascii_digit()) +} +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Observation { + pub cli_program: String, + pub cli: Option, + pub app: Option, + pub daemon: Option, + pub report: Option, + /// The non-secret context the review ran with. Copied commands are bound + /// to it so a terminal run sees the same home, backend and guards. + pub context: Option, +} +impl Observation { + /// The reviewed CLI and context bound to `args`, quoted for copying. + /// Without a checked context there is nothing safe to copy. + pub fn copy_command(&self, args: &[String]) -> Option { + let mut spec = self + .context + .as_ref()? + .bind(&crate::terminal::CommandSpec { + program: self.cli_program.clone(), + args: args.to_vec(), + }) + .ok()?; + // The CLI starts the daemon by invoking itself and would otherwise + // prefer whatever `hack` is first on PATH: pin the reviewed one. + let at = spec.args.iter().position(|a| *a == self.cli_program)?; + spec.args + .insert(at, format!("HACK_MCP_COMMAND={}", self.cli_program)); + spec.display().ok() + } +} +/// One checkout of a grouped step as shown: its name, its own instruction +/// and its copyable command. Display only; nothing here is executed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetRow { + pub scope: String, + pub action: Option, + pub command: Option, +} +impl Step { + /// Per-checkout rows for a step covering two or more checkouts. A single + /// checkout's instruction and command are the step's own. + pub fn target_rows(&self, observation: &Observation) -> Vec { + if self.targets.len() < 2 { + return Vec::new(); + } + self.targets + .iter() + .map(|t| TargetRow { + scope: t.scope.clone(), + action: t.action.clone().filter(|a| !a.trim().is_empty()), + command: t.args.as_ref().and_then(|a| observation.copy_command(a)), + }) + .collect() + } +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Gap { + pub name: String, + pub reason: String, +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Snapshot { + pub status: String, + pub id: Option, + pub manifest_path: Option, + pub complete: Option, + pub gaps: Option>, + /// The CLI's explanation when it refused to create a snapshot. + #[serde(default)] + pub message: Option, +} +impl Snapshot { + /// Longest refusal explanation shown, in characters. + pub const REFUSAL_MAX: usize = 600; + /// The CLI's reason for a refused snapshot, for display: control + /// characters become spaces and the text is bounded. `None` unless the + /// status is `refused` and a non-blank message was given. + pub fn refusal(&self) -> Option { + if self.status != "refused" { + return None; + } + let text: String = self + .message + .as_deref()? + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let text = text.trim(); + if text.is_empty() { + return None; + } + let mut shown: String = text.chars().take(Self::REFUSAL_MAX).collect(); + if text.chars().count() > Self::REFUSAL_MAX { + shown.push('…'); + } + Some(shown) + } + pub fn manifest(&self) -> Option<&str> { + if self.status != "created" + || self.complete != Some(true) + || !self.gaps.as_ref().is_some_and(Vec::is_empty) + { + return None; + } + let id = self.id.as_deref()?; + let manifest = self.manifest_path.as_deref()?; + let path = Path::new(manifest); + (path.is_absolute() + && path.file_name()?.to_str()? == "manifest.json" + && path.parent()?.file_name()?.to_str()? == id + && !id.is_empty() + && id != "." + && id != ".." + && !path + .components() + .any(|p| matches!(p, std::path::Component::ParentDir)) + && !manifest.chars().any(char::is_control)) + .then_some(manifest) + } +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct RestoredFile { + pub path: String, + pub status: String, + pub kept_as: Option, + pub message: Option, +} +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct Rollback { + pub status: String, + #[serde(default)] + pub results: Vec, + pub message: Option, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum State { + #[default] + AvailabilityUnknown, + Available, + Checking, + Blocked, + Ready, + Snapshotting, + Incomplete, + Applying, + Unknown, + Restarting, + Done, + Failed, + RollingBack, + Restored, +} +impl State { + pub fn label(self) -> &'static str { + match self { + Self::AvailabilityUnknown => "Update availability unknown", + Self::Available => "Update available", + Self::Checking => "Checking readiness…", + Self::Blocked => "Readiness needs attention", + Self::Ready => "Readiness checked", + Self::Snapshotting => "Saving files…", + Self::Incomplete => "Snapshot incomplete", + Self::Applying => "Waiting for external commands", + Self::Unknown => "Could not confirm the upgrade", + Self::Restarting => "Restarting hackd…", + Self::Done => "Hack is up to date", + Self::Failed => "Upgrade needs attention", + Self::RollingBack => "Restoring files…", + Self::Restored => "Files restored", + } + } +} +#[derive(Debug, Clone, Default)] +pub struct Flow { + pub state: State, + pub busy: bool, + pub target: Option, + pub observation: Observation, + pub snapshot: Option, + pub previous: Option, + pub rollback: Option, + pub message: Option, +} +impl Flow { + pub fn can_snapshot(&self) -> bool { + !self.busy + && Path::new(&self.observation.cli_program).is_absolute() + && self.observation.cli.as_deref().is_some_and(version) + && self.observation.report.as_ref().is_some_and(Report::ready) + && self.previous.is_none() + } + pub fn can_upgrade(&self) -> bool { + self.can_snapshot() + && self + .snapshot + .as_ref() + .and_then(Snapshot::manifest) + .is_some() + && self + .target + .as_ref() + .is_some_and(|t| t.usable(&self.observation.cli_program)) + } + pub fn can_rollback(&self) -> bool { + !self.busy + && self + .snapshot + .as_ref() + .and_then(Snapshot::manifest) + .is_some() + } + pub fn begin(&mut self, state: State) -> bool { + let admitted = match state { + State::Checking => !self.busy, + State::Snapshotting => self.can_snapshot(), + State::Applying => self.can_upgrade(), + State::Restarting => !self.busy && self.previous.is_some(), + State::RollingBack => self.can_rollback(), + _ => false, + }; + if !admitted { + return false; + } + self.busy = true; + self.state = state; + self.message = None; + true + } + pub fn checked(&mut self, observation: Observation) { + self.busy = false; + self.observation = observation; + let Some(report) = &self.observation.report else { + self.state = State::Unknown; + return; + }; + if self.previous.is_some() { + self.state = if self.done() { + State::Done + } else { + State::Unknown + }; + } else if !report.ready() { + self.state = State::Blocked; + } else if self + .target + .as_ref() + .is_some_and(|t| t.usable(&self.observation.cli_program)) + { + self.state = State::Ready; + } else { + self.state = State::AvailabilityUnknown; + } + } + pub fn saved(&mut self, snapshot: Snapshot) { + self.busy = false; + self.state = if snapshot.manifest().is_some() { + if self + .target + .as_ref() + .is_some_and(|t| t.usable(&self.observation.cli_program)) + { + State::Ready + } else { + State::AvailabilityUnknown + } + } else { + State::Incomplete + }; + self.snapshot = Some(snapshot); + } + pub fn handed_off(&mut self) { + self.busy = false; + self.previous = self.observation.report.clone(); + self.state = State::Applying; + self.message = Some("Terminal opened. Run the commands there, then Check again. Opening a terminal does not confirm success.".into()); + } + pub fn failed(&mut self, message: String) { + self.busy = false; + self.state = State::Failed; + self.message = Some(message); + } + pub fn rolled_back(&mut self, result: Rollback) { + self.busy = false; + self.state = if result.status == "restored" + && result.results.iter().all(|r| { + matches!( + r.status.as_str(), + "restored" | "already-restored" | "moved-aside" + ) + }) { + State::Restored + } else { + State::Failed + }; + self.rollback = Some(result); + self.previous = None; + // A restored file set is no longer an upgrade readiness observation. + self.observation.report = None; + } + pub fn done(&self) -> bool { + let (Some(target), Some(before), Some(after)) = + (&self.target, &self.previous, &self.observation.report) + else { + return false; + }; + before.ready() + && target.usable(&self.observation.cli_program) + && self + .snapshot + .as_ref() + .and_then(Snapshot::manifest) + .is_some() + && self.observation.cli.as_deref() == Some(&target.version) + && self.observation.app.as_deref() == Some(&target.version) + && self.observation.daemon == self.observation.cli + && after.ready() + && after.plan.is_empty() + && before.plan.iter().all(|step| after.confirms(step, before)) + } +} +/// The only upgrade terminal commands currently authored. No finding text enters here. +pub fn homebrew_commands() -> Vec { + vec![ + crate::terminal::CommandSpec { + program: "brew".into(), + args: vec!["upgrade".into(), "hack".into()], + }, + crate::terminal::CommandSpec { + program: "brew".into(), + args: vec!["upgrade".into(), "--cask".into(), "hack-desktop".into()], + }, + ] +} +/// Untrusted argv may be copied after quoting, but never passed to terminal Run. +pub fn display_step(cli: &str, args: &[String]) -> Result { + crate::terminal::CommandSpec { + program: cli.into(), + args: args.to_vec(), + } + .display() +} diff --git a/apps/desktop/core/src/upgrade_fixtures.rs b/apps/desktop/core/src/upgrade_fixtures.rs new file mode 100644 index 000000000..b7ab5d094 --- /dev/null +++ b/apps/desktop/core/src/upgrade_fixtures.rs @@ -0,0 +1,222 @@ +//! Offline proposed R14/R17 examples. Never invoke a process or use a real home. +use crate::upgrade::*; +pub const NAMES: &[&str] = &[ + "availability-unknown", + "available", + "checking", + "blocked", + "ready", + "snapshotting", + "incomplete", + "applying", + "unknown", + "restarting", + "done", + "failed", + "rolling-back", + "restored", +]; +pub fn report() -> Report { + Report { + report_version: REPORT_VERSION, + host_probes: "enabled".into(), + ready: true, + machine: vec![Finding { + id: "daemon".into(), + status: "ok".into(), + summary: "Daemon matches the CLI".into(), + args: None, + action: None, + apply_fix: None, + }], + projects: vec![Project { + name: "sample".into(), + root: "/example/project".into(), + state: "live".into(), + findings: vec![Finding { + id: "env-format".into(), + status: "warn".into(), + summary: "Review environment file names before migration".into(), + args: Some(vec![ + "env".into(), + "migrate".into(), + "--path".into(), + "/example/project".into(), + ]), + action: None, + apply_fix: None, + }], + }], + registry: Registry { + state: "valid".into(), + total: 1, + live: 1, + missing: vec![], + unreadable: vec![], + unchecked: vec![], + rejected: 0, + }, + blockers: vec![], + incomplete: vec![], + plan: vec![ + Step { + id: "snapshot".into(), + scope: "machine".into(), + why: "Save files before making changes".into(), + args: Some(vec!["upgrade".into(), "snapshot".into()]), + action: None, + count: 0, + targets: vec![], + }, + Step { + id: "env-format".into(), + scope: "sample".into(), + why: "Migrate environment files in sample".into(), + args: Some(vec![ + "env".into(), + "migrate".into(), + "--path".into(), + "/example/project".into(), + ]), + action: None, + count: 1, + targets: vec![PlanTarget { + scope: "sample".into(), + project: "sample".into(), + root: "/example/project".into(), + status: "warn".into(), + args: Some(vec![ + "env".into(), + "migrate".into(), + "--path".into(), + "/example/project".into(), + ]), + action: None, + }], + }, + ], + } +} +pub fn snapshot() -> Snapshot { + Snapshot { + status: "created".into(), + id: Some("example-snapshot".into()), + manifest_path: Some("/example/snapshots/example-snapshot/manifest.json".into()), + complete: Some(true), + gaps: Some(vec![]), + message: None, + } +} +pub fn load(name: &str) -> Option { + let state = match name { + "availability-unknown" => State::AvailabilityUnknown, + "available" => State::Available, + "checking" => State::Checking, + "blocked" => State::Blocked, + "ready" => State::Ready, + "snapshotting" => State::Snapshotting, + "incomplete" => State::Incomplete, + "applying" => State::Applying, + "unknown" => State::Unknown, + "restarting" => State::Restarting, + "done" => State::Done, + "failed" => State::Failed, + "rolling-back" => State::RollingBack, + "restored" => State::Restored, + _ => return None, + }; + let mut flow = Flow { + state, + target: Some(Target { + version: "5.1.0".into(), + cli_program: "/example/bin/hack".into(), + install_kind: "homebrew".into(), + available: true, + }), + observation: Observation { + cli_program: "/example/bin/hack".into(), + cli: Some("5.0.0".into()), + app: Some("5.0.0".into()), + daemon: Some("5.0.0".into()), + report: Some(report()), + context: crate::execution::ExecutionContext::from_values(&[ + ("HOME".into(), "/example/home".into()), + ("PATH".into(), "/usr/bin:/bin".into()), + ]) + .ok(), + }, + ..Flow::default() + }; + flow.busy = matches!( + state, + State::Checking | State::Snapshotting | State::Restarting | State::RollingBack + ); + if state == State::AvailabilityUnknown { + flow.target = None; + } + if matches!(state, State::Available | State::Checking) { + flow.observation.report = None; + } + if state == State::Blocked { + let r = flow.observation.report.as_mut()?; + r.ready = false; + r.blockers.push(ScopedFinding { + scope: "sample".into(), + id: "env-key".into(), + summary: "The environment key cannot be checked".into(), + }); + } + if !matches!( + state, + State::Available + | State::Checking + | State::Blocked + | State::Snapshotting + | State::AvailabilityUnknown + ) { + flow.snapshot = Some(snapshot()); + } + if state == State::Incomplete { + let s = flow.snapshot.as_mut()?; + s.complete = Some(false); + s.gaps = Some(vec![Gap { + name: "sample".into(), + reason: "Checkout could not be read".into(), + }]); + } + if matches!( + state, + State::Applying + | State::Unknown + | State::Restarting + | State::Done + | State::Failed + | State::RollingBack + ) { + flow.previous = Some(report()); + } + if state == State::Done { + let r = flow.observation.report.as_mut()?; + r.plan.clear(); + r.projects[0].findings[0].status = "ok".into(); + flow.observation.cli = Some("5.1.0".into()); + flow.observation.app = Some("5.1.0".into()); + flow.observation.daemon = Some("5.1.0".into()); + } + if state == State::Failed { + flow.message = Some("The installed app version still differs. Files were saved; check versions again or restore files.".into()); + } + if state == State::Restored { + flow.rolled_back(Rollback { + status: "restored".into(), + message: None, + results: vec![RestoredFile { + path: "/example/project/.hack/hack.env.json".into(), + status: "restored".into(), + kept_as: Some("/example/project/.hack/hack.env.json.kept".into()), + message: None, + }], + }); + } + Some(flow) +} diff --git a/apps/desktop/core/src/upgrade_io.rs b/apps/desktop/core/src/upgrade_io.rs new file mode 100644 index 000000000..8f4fc85c5 --- /dev/null +++ b/apps/desktop/core/src/upgrade_io.rs @@ -0,0 +1,199 @@ +//! Bounded CLI-only adapters. No file bytes, keys, automatic updates or admin fixes. +use crate::{ + execution::ExecutionContext, + runner::CliRunner, + source::{LiveSource, ProbeTools, trusted_probe_path}, + upgrade::*, +}; +use serde::de::DeserializeOwned; +use std::time::Duration; + +/// Accept valid success envelopes even when readiness exits nonzero. Bare JSON, +/// signals, unsupported report versions and error envelopes are not observations. +pub fn read(runner: &CliRunner, args: &[&str]) -> Result { + let (stdout, _, code) = runner + .clone() + .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .with_cwd("/") + .run_raw(args, None, Duration::from_secs(60)) + .map_err(|_| "The CLI read failed or timed out".to_owned())?; + if code.is_none() { + return Err("The CLI was interrupted before confirming a result".into()); + } + #[derive(serde::Deserialize)] + struct Envelope { + ok: bool, + data: Option, + } + let envelope: Envelope = serde_json::from_str(&stdout) + .map_err(|_| "The CLI returned an invalid upgrade report".to_owned())?; + if !envelope.ok { + return Err("The CLI refused this upgrade operation".into()); + } + envelope + .data + .ok_or_else(|| "The CLI returned no upgrade report".into()) +} +/// One checked CLI operation: the resolved executable, the non-secret +/// execution context it runs with, and a runner bound to both plus Doctor's +/// private tool PATH. The wrapper directory lives as long as this value, so +/// keep it alive until every child of the operation has exited. +struct Checked { + runner: CliRunner, + program: String, + context: ExecutionContext, + _tools: ProbeTools, +} + +/// Bind the selected CLI to the checked context. Its children (docker, git +/// and other tools) resolve only through wrappers for executables no other +/// user can change, with interpreter startup variables removed. Anything +/// that cannot be established refuses the operation before the CLI runs. +fn checked(source: &LiveSource) -> Result { + let program = source + .runner + .resolve_program() + .map_err(|_| "The Hack CLI is unavailable or in a location other users can change")? + .into_os_string() + .into_string() + .map_err(|_| "The Hack CLI path is not UTF-8")?; + let context = ExecutionContext::capture(&source.runner)?; + let tools = trusted_probe_path(std::ffi::OsStr::new( + context.value("PATH").unwrap_or_default(), + )) + .map_err(|_| "Could not prepare a private tool PATH, so the CLI was not run")?; + if tools.coverage.is_some() { + return Err( + "PATH has too many entries to check every tool, so the CLI was not run. Shorten PATH, then check again." + .into(), + ); + } + if !tools.unchecked.is_empty() { + let names = tools + .unchecked + .iter() + .copied() + .collect::>() + .join(", "); + return Err(format!( + "These tools on PATH are in locations other users can change: {names}. The CLI was not run. Fix their permissions or remove them from PATH, then check again." + )); + } + let tool_path = tools + .path() + .to_str() + .ok_or("The private tool PATH is not UTF-8, so the CLI was not run")? + .to_owned(); + // `upgrade check` runs `git ls-files` in registered checkouts: apply + // Doctor's Git overrides so repository config cannot run fsmonitor or hooks. + let runner = crate::source::git_reads_only( + context + .runner(&program)? + .with_env("PATH", &tool_path) + .without_interpreter_startup() + .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .with_cwd("/"), + ); + // The checks call docker and the native runtime too: apply Doctor's + // helper and Docker preflight (local socket only, checked plugins and + // helpers) before anything runs, and pin the checked native runtime. + let native = crate::doctor_plugins::validate_helpers(&runner) + .and_then(|pin| { + crate::doctor_plugins::validate(&crate::doctor_plugins::Discovery::from_runner(&runner)) + .map(|()| pin) + }) + .map_err(|why| format!("{why}. The CLI was not run."))?; + let runner = native.apply(runner, std::path::Path::new(tools.path())); + Ok(Checked { + runner, + program, + context, + _tools: tools, + }) +} + +pub fn check(source: &LiveSource) -> Result { + let checked = checked(source)?; + let report: Report = read(&checked.runner, &["upgrade", "check", "--json"])?; + if report.report_version != REPORT_VERSION { + return Err("This upgrade report version is not supported".into()); + } + let cli = checked.runner.version(Duration::from_secs(5)).ok(); + // The injected client is a test seam; a normal launch finds the socket + // in the selected CLI's own read-only `daemon status` answer. + let status = checked + .runner + .run_raw( + &["daemon", "status", "--json"], + None, + Duration::from_secs(5), + ) + .ok() + .and_then(|(out, _, _)| serde_json::from_str::(&out).ok()); + let daemon = source + .effective_daemon(status.as_ref()) + .and_then(|d| d.status().ok()) + .map(|d| d.version); + Ok(Observation { + cli_program: checked.program, + cli, + daemon, + // No verified R14 install/target evidence: never probe update --check or + // infer Homebrew ownership from PATH/current version. No daily poll. + app: None, + report: Some(report), + // Copied commands carry the same home, backend and guards as this + // read. They run later without the private wrapper PATH, so only PATH + // directories no other user can change are kept. + context: Some(checked.context.with_controlled_path()?), + }) +} +pub fn snapshot(source: &LiveSource) -> Result { + let checked = checked(source)?; + read(&checked.runner, &["upgrade", "snapshot", "--json"]) +} +pub fn rollback(source: &LiveSource, snapshot: &Snapshot) -> Result { + let path = snapshot + .manifest() + .ok_or("A complete snapshot is required")?; + let checked = checked(source)?; + // No --overwrite. The CLI owns validation and conflict preservation. + read( + &checked.runner, + &["upgrade", "rollback", "--rollback-manifest", path, "--json"], + ) +} + +/// Observe an installed cask only after installation ownership is verified by +/// R14. The caller supplies the discovered Homebrew runner; live discovery is +/// intentionally not wired yet. Tests inject a fake; live Homebrew is not called. +pub fn installed_app(runner: &CliRunner) -> Result { + let (stdout, _, exit) = runner + .run_raw( + &["info", "--json=v2", "--cask", "hack-desktop"], + None, + Duration::from_secs(15), + ) + .map_err(|_| "Could not read the installed app version")?; + if exit != Some(0) { + return Err("Could not read the installed app version".into()); + } + #[derive(serde::Deserialize)] + struct Info { + casks: Vec, + } + #[derive(serde::Deserialize)] + struct Cask { + token: String, + installed: Option, + } + let info: Info = serde_json::from_str(&stdout).map_err(|_| "Invalid installed app report")?; + match info.casks.as_slice() { + [cask] if cask.token == "hack-desktop" => cask + .installed + .clone() + .filter(|v| !v.is_empty()) + .ok_or_else(|| "App is not installed".into()), + _ => Err("Installed app identity is unknown".into()), + } +} diff --git a/apps/desktop/core/tests/fixtures/upgrade/check-v2.json b/apps/desktop/core/tests/fixtures/upgrade/check-v2.json new file mode 100644 index 000000000..e616cad54 --- /dev/null +++ b/apps/desktop/core/tests/fixtures/upgrade/check-v2.json @@ -0,0 +1,400 @@ +{ + "ok": true, + "data": { + "report_version": 2, + "generated_at": "2026-01-01T00:00:00.000Z", + "duration_ms": 0, + "cli": { + "version": "5.0.0", + "channel": "stable", + "candidate_run": false, + "build": "release", + "command": "hack" + }, + "host_probes": "enabled", + "machine": [ + { + "id": "installed", + "status": "info", + "summary": "Running 5.0.0; the hack on PATH is 5.0.0 (standalone) at /usr/local/bin/hack.", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "daemon", + "status": "info", + "summary": "hackd is running, run by systemd; login service registered (systemd: installed, enabled, active).", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "global-infra", + "status": "ok", + "summary": "Hack's global proxy and DNS are installed and running.", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "proxy-ports", + "status": "ok", + "summary": "Ports 80 and 443 reach Hack's proxy.", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "secret-store", + "status": "ok", + "summary": "Secrets use the OS keychain; no file store to check.", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "registry", + "status": "ok", + "summary": "3 registered project(s), 3 checkout(s) with worktrees: 3 live, 0 dead, 0 unreadable.", + "args": null, + "action": null, + "kind": "manual", + "command": null + } + ], + "registry": { + "state": "valid", + "projects": 3, + "total": 3, + "live": 3, + "missing": [], + "unreadable": [], + "unchecked": [], + "rejected": 0 + }, + "projects": [ + { + "name": "alpha", + "project": "alpha", + "root": "/example/alpha", + "project_dir": "/example/alpha/.hack", + "state": "live", + "reason": null, + "findings": [ + { + "id": "env-format", + "status": "warn", + "summary": "Env uses the legacy format (legacy-json); migrate it to YAML layers.", + "args": [ + "doctor", + "--migrate-env-config", + "--path", + "/example/alpha" + ], + "action": null, + "kind": "manual", + "command": "hack doctor --migrate-env-config --path /example/alpha" + }, + { + "id": "env-key", + "status": "ok", + "summary": "No env key file (whether one is needed is the env-secrets check).", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "tracked-files", + "status": "ok", + "summary": "No machine-local or sensitive files are tracked.", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "ignore-rules", + "status": "warn", + "summary": "The committed .hack/.gitignore is missing.", + "args": ["doctor", "--fix", "--path", "/example/alpha"], + "action": null, + "kind": "manual", + "command": "hack doctor --fix --path /example/alpha" + }, + { + "id": "agent-integrations", + "status": "ok", + "summary": "Agent integrations are current (or not installed).", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "env-secrets", + "status": "ok", + "summary": "No secrets in env layers.", + "args": null, + "action": null, + "kind": "manual", + "command": null + } + ] + }, + { + "name": "beta", + "project": "beta", + "root": "/example/beta", + "project_dir": "/example/beta/.hack", + "state": "live", + "reason": null, + "findings": [ + { + "id": "env-format", + "status": "warn", + "summary": "Env uses the legacy format (legacy-json); migrate it to YAML layers.", + "args": [ + "doctor", + "--migrate-env-config", + "--path", + "/example/beta" + ], + "action": null, + "kind": "manual", + "command": "hack doctor --migrate-env-config --path /example/beta" + }, + { + "id": "env-key", + "status": "ok", + "summary": "No env key file (whether one is needed is the env-secrets check).", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "tracked-files", + "status": "ok", + "summary": "No machine-local or sensitive files are tracked.", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "ignore-rules", + "status": "warn", + "summary": "The committed .hack/.gitignore is missing.", + "args": ["doctor", "--fix", "--path", "/example/beta"], + "action": null, + "kind": "manual", + "command": "hack doctor --fix --path /example/beta" + }, + { + "id": "agent-integrations", + "status": "ok", + "summary": "Agent integrations are current (or not installed).", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "env-secrets", + "status": "ok", + "summary": "No secrets in env layers.", + "args": null, + "action": null, + "kind": "manual", + "command": null + } + ] + }, + { + "name": "gamma", + "project": "gamma", + "root": "/example/gamma", + "project_dir": "/example/gamma/.hack", + "state": "live", + "reason": null, + "findings": [ + { + "id": "env-format", + "status": "ok", + "summary": "No env configuration.", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "env-key", + "status": "ok", + "summary": "No env key file (whether one is needed is the env-secrets check).", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "tracked-files", + "status": "ok", + "summary": "No machine-local or sensitive files are tracked.", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "ignore-rules", + "status": "warn", + "summary": "The committed .hack/.gitignore is missing.", + "args": ["doctor", "--fix", "--path", "/example/gamma"], + "action": null, + "kind": "manual", + "command": "hack doctor --fix --path /example/gamma" + }, + { + "id": "agent-integrations", + "status": "ok", + "summary": "Agent integrations are current (or not installed).", + "args": null, + "action": null, + "kind": "manual", + "command": null + }, + { + "id": "env-secrets", + "status": "ok", + "summary": "No secrets in env layers.", + "args": null, + "action": null, + "kind": "manual", + "command": null + } + ] + } + ], + "blockers": [], + "incomplete": [], + "plan": [ + { + "order": 1, + "id": "snapshot", + "kind": "safe", + "scope": "machine", + "status": "step", + "count": 0, + "targets": [], + "command": "hack upgrade snapshot", + "args": ["upgrade", "snapshot"], + "action": null, + "why": "Keep a private copy of the files the following steps change, so the upgrade rollback command can put them back.", + "blocker": false, + "applies": false + }, + { + "order": 2, + "id": "env-format", + "kind": "manual", + "status": "warn", + "count": 2, + "targets": [ + { + "scope": "alpha", + "project": "alpha", + "root": "/example/alpha", + "status": "warn", + "apply_eligible": true, + "command": "hack doctor --migrate-env-config --path /example/alpha", + "args": [ + "doctor", + "--migrate-env-config", + "--path", + "/example/alpha" + ], + "action": null + }, + { + "scope": "beta", + "project": "beta", + "root": "/example/beta", + "status": "warn", + "apply_eligible": true, + "command": "hack doctor --migrate-env-config --path /example/beta", + "args": [ + "doctor", + "--migrate-env-config", + "--path", + "/example/beta" + ], + "action": null + } + ], + "blocker": false, + "scope": "projects", + "command": "hack upgrade review --only env-format", + "args": ["upgrade", "review", "--only", "env-format"], + "action": "Migration must use its resumable env owner; review tracked-file consent and recovery before applying.", + "why": "Env uses the legacy format; migrate it to YAML layers.", + "applies": false + }, + { + "order": 3, + "id": "ignore-rules", + "kind": "safe", + "status": "warn", + "count": 3, + "targets": [ + { + "scope": "alpha", + "project": "alpha", + "root": "/example/alpha", + "status": "warn", + "apply_eligible": true, + "command": "hack doctor --fix --path /example/alpha", + "args": ["doctor", "--fix", "--path", "/example/alpha"], + "action": null + }, + { + "scope": "beta", + "project": "beta", + "root": "/example/beta", + "status": "warn", + "apply_eligible": true, + "command": "hack doctor --fix --path /example/beta", + "args": ["doctor", "--fix", "--path", "/example/beta"], + "action": null + }, + { + "scope": "gamma", + "project": "gamma", + "root": "/example/gamma", + "status": "warn", + "apply_eligible": true, + "command": "hack doctor --fix --path /example/gamma", + "args": ["doctor", "--fix", "--path", "/example/gamma"], + "action": null + } + ], + "blocker": false, + "scope": "projects", + "command": "hack upgrade apply --only ignore-rules", + "args": ["upgrade", "apply", "--only", "ignore-rules"], + "action": null, + "why": "The committed .hack/.gitignore is missing or out of date.", + "applies": true + } + ], + "next_command": "hack upgrade snapshot", + "next_action": null, + "ready": true + } +} diff --git a/apps/desktop/core/tests/terminal.rs b/apps/desktop/core/tests/terminal.rs index bd7ae7f4a..7ab56daa5 100644 --- a/apps/desktop/core/tests/terminal.rs +++ b/apps/desktop/core/tests/terminal.rs @@ -213,6 +213,47 @@ fn eof_cancels_and_controls_rejected() { } } +#[test] +fn authored_sequence_waits_for_enter_runs_both_and_stops_on_failure() { + for exit in [0, 3] { + let t = Tree::new(); + let fake_program = t.0.join("fake-brew"); + let receipt = t.0.join("receipt"); + std::fs::write( + &fake_program, + format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$RECEIPT\"\nexit {exit}\n"), + ) + .unwrap(); + std::fs::set_permissions(&fake_program, std::fs::Permissions::from_mode(0o700)).unwrap(); + let specs: Vec<_> = hack_desktop_core::upgrade::homebrew_commands() + .into_iter() + .map(|mut c| { + c.program = fake_program.to_str().unwrap().into(); + c + }) + .collect(); + let script = Script::create_sequence(&t.0, &specs).unwrap(); + let mut child = std::process::Command::new(script.path()) + .env("RECEIPT", &receipt) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + assert!(!receipt.exists()); + child.stdin.take().unwrap().write_all(b"\n").unwrap(); + assert_eq!(child.wait().unwrap().code(), Some(exit)); + assert_eq!( + std::fs::read_to_string(receipt).unwrap(), + if exit == 0 { + "upgrade hack\nupgrade --cask hack-desktop\n" + } else { + "upgrade hack\n" + } + ); + assert!(!script.path().exists()); + } +} + #[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 diff --git a/apps/desktop/core/tests/upgrade.rs b/apps/desktop/core/tests/upgrade.rs new file mode 100644 index 000000000..3a727a968 --- /dev/null +++ b/apps/desktop/core/tests/upgrade.rs @@ -0,0 +1,356 @@ +use hack_desktop_core::{upgrade::*, upgrade_fixtures as fixtures}; + +#[test] +fn done_requires_all_fresh_evidence_and_explicit_previous_steps() { + let good = fixtures::load("done").unwrap(); + assert!(good.done()); + for kind in 0..13 { + let mut flow = good.clone(); + let report = flow.observation.report.as_mut().unwrap(); + match kind { + 0 => flow.observation.cli = None, + 1 => flow.observation.app = Some("5.0.0".into()), + 2 => flow.observation.daemon = Some("5.0.0".into()), + 3 => report.ready = false, + 4 => report.plan = fixtures::report().plan, + 5 => report.projects.clear(), + 6 => report.projects[0].findings.clear(), + 7 => report.projects[0].state = "unreadable".into(), + 8 => report.incomplete.push(ScopedFinding { + scope: "sample".into(), + id: "env-format".into(), + summary: "not checked".into(), + }), + 9 => report.registry.unchecked.push(RegistryEntry { + name: "sample".into(), + root: "/example/project".into(), + }), + 10 => report.projects[0].root = "/example/other".into(), + 11 => report.projects[0].findings[0].status = "info".into(), + 12 => flow.observation.cli_program = "/other/hack".into(), + _ => unreachable!(), + } + assert!(!flow.done(), "invalid evidence case {kind}"); + } +} +#[test] +fn snapshots_refused_incomplete_or_invalid_cannot_authorize_any_effect() { + for kind in 0..8 { + let mut flow = fixtures::load("ready").unwrap(); + let s = flow.snapshot.as_mut().unwrap(); + match kind { + 0 => s.status = "refused".into(), + 1 => s.complete = Some(false), + 2 => s.complete = None, + 3 => s.gaps = None, + 4 => { + s.gaps = Some(vec![Gap { + name: "sample".into(), + reason: "unchecked".into(), + }]) + } + 5 => s.manifest_path = Some("relative/manifest.json".into()), + 6 => s.manifest_path = Some("/example/other/manifest.json".into()), + 7 => s.manifest_path = Some("/example/../example-snapshot/manifest.json".into()), + _ => unreachable!(), + } + assert!(!flow.can_upgrade()); + assert!(!flow.can_rollback()); + } +} +#[test] +fn unknown_discovery_allows_readiness_but_never_upgrade() { + let mut flow = fixtures::load("availability-unknown").unwrap(); + assert!(flow.can_snapshot()); + assert!(!flow.can_upgrade()); + flow.saved(fixtures::snapshot()); + assert!(!flow.can_upgrade()); + flow.target = fixtures::load("ready").unwrap().target; + assert!(flow.can_upgrade()); + flow.handed_off(); + assert_eq!(flow.state, State::Applying); + assert!(!flow.done()); + let observation = flow.observation.clone(); + flow.checked(observation); + assert_eq!(flow.state, State::Unknown); +} +#[test] +fn rollback_restores_files_only_and_clears_readiness() { + let mut flow = fixtures::load("ready").unwrap(); + flow.rolled_back(Rollback { + status: "incomplete".into(), + results: vec![RestoredFile { + path: "/example/file".into(), + status: "conflict".into(), + kept_as: Some("/example/file.kept".into()), + message: Some("File changed".into()), + }], + message: None, + }); + assert_eq!(flow.state, State::Failed); + assert!(!flow.can_upgrade()); + assert_eq!( + flow.rollback.unwrap().results[0].kept_as.as_deref(), + Some("/example/file.kept") + ); +} +#[test] +fn authored_homebrew_sequence_and_quoted_untrusted_display() { + let commands = homebrew_commands(); + assert_eq!(commands[0].display().unwrap(), "'brew' 'upgrade' 'hack'"); + assert_eq!( + commands[1].display().unwrap(), + "'brew' 'upgrade' '--cask' 'hack-desktop'" + ); + assert_eq!( + display_step("/example/hack", &["$(touch nope);'".into()]).unwrap(), + "'/example/hack' '$(touch nope);'\\'''" + ); +} +#[test] +fn each_fixture_is_offline_and_busy_is_singleflight() { + for name in fixtures::NAMES { + let mut flow = fixtures::load(name).unwrap(); + flow.busy = false; + assert!(flow.begin(State::Checking)); + assert!(!flow.begin(State::Snapshotting)); + assert!(!flow.can_upgrade()); + assert!(!flow.can_rollback()); + } +} + +#[test] +fn pure_effect_admission_rejects_unknown_target_and_incomplete_snapshot() { + let mut unknown = fixtures::load("availability-unknown").unwrap(); + assert!(!unknown.begin(State::Applying)); + assert!(!unknown.begin(State::RollingBack)); + assert!(!unknown.begin(State::Restarting)); + assert!(unknown.begin(State::Snapshotting)); + let mut incomplete = fixtures::load("incomplete").unwrap(); + assert!(!incomplete.begin(State::Applying)); + assert!(!incomplete.begin(State::RollingBack)); +} +#[test] +fn duplicate_scope_and_contradictory_findings_stay_unknown() { + let good = fixtures::load("done").unwrap(); + let mut f = good.clone(); + let r = f.observation.report.as_mut().unwrap(); + r.projects.push(r.projects[0].clone()); + r.registry.total += 1; + r.registry.live += 1; + assert!(!f.done()); + let mut f = good; + f.observation.report.as_mut().unwrap().machine[0].status = "blocker".into(); + assert!(!f.done()); +} + +#[test] +fn unreadable_or_relative_cli_never_admits_snapshot_or_upgrade() { + let mut flow = fixtures::load("ready").unwrap(); + flow.observation.cli = None; + assert!(!flow.can_snapshot()); + assert!(!flow.can_upgrade()); + flow.observation.cli = Some("5.0.0".into()); + flow.observation.cli_program = "hack".into(); + assert!(!flow.can_snapshot()); + assert!(!flow.can_upgrade()); +} + +/// The CLI's own v2 output: grouped steps cover several checkouts. +fn v2() -> Report { + #[derive(serde::Deserialize)] + struct Envelope { + data: Report, + } + serde_json::from_str::(include_str!("fixtures/upgrade/check-v2.json")) + .unwrap() + .data +} +fn resolved(before: &Report) -> Report { + let mut after = before.clone(); + after.plan.clear(); + for p in &mut after.projects { + for f in &mut p.findings { + f.status = "ok".into(); + } + } + after +} +#[test] +fn grouped_steps_confirm_only_when_every_covered_checkout_is_explicitly_ok() { + let before = v2(); + assert!(before.ready()); + let after = resolved(&before); + for step in &before.plan { + assert!(after.confirms(step, &before), "{}", step.id); + } + let step = &before.plan[2]; + for kind in 0..6 { + let mut after = resolved(&before); + match kind { + 0 => after.projects[1] + .findings + .retain(|f| f.id != "ignore-rules"), + 1 => after.projects[2].state = "unreadable".into(), + 2 => after.projects[0].root = "/example/moved".into(), + 3 => after.incomplete.push(ScopedFinding { + scope: "beta".into(), + id: "env-key".into(), + summary: "not checked".into(), + }), + 4 => { + let f = after.projects[2].findings[3].clone(); + after.projects[2].findings.push(f); + } + 5 => after.projects[1].findings[3].status = "warn".into(), + _ => unreachable!(), + } + assert!(!after.confirms(step, &before), "case {kind}"); + } + // A missing target in the earlier report is never evidence either. + let mut short = before.clone(); + short.projects.retain(|p| p.name != "gamma"); + assert!(!after.confirms(step, &short)); +} +#[test] +fn grouped_fix_confirms_the_finding_it_was_built_from() { + let mut before = v2(); + let key = before.projects[0] + .findings + .iter_mut() + .find(|f| f.id == "env-key") + .unwrap(); + key.status = "warn".into(); + key.apply_fix = Some("env-key-permissions".into()); + let mut step = before.plan[2].clone(); + step.id = "env-key-permissions".into(); + step.scope = "alpha".into(); + step.count = 1; + step.targets.truncate(1); + before.plan.push(step.clone()); + assert!(before.readable()); + let after = resolved(&before); + assert!(after.confirms(&step, &before)); + let mut still_weak = after.clone(); + still_weak.projects[0] + .findings + .iter_mut() + .find(|f| f.id == "env-key") + .unwrap() + .status = "warn".into(); + assert!(!still_weak.confirms(&step, &before)); +} +#[test] +fn contradictory_grouped_plan_steps_make_the_report_unreadable() { + let good = v2(); + for kind in 0..6 { + let mut r = good.clone(); + let step = &mut r.plan[1]; + match kind { + 0 => step.count = 3, + 1 => step.scope = "alpha".into(), + 2 => step.targets[1] = step.targets[0].clone(), + 3 => step.targets[0].root = "/example/elsewhere".into(), + 4 => step.targets[0].scope = "delta".into(), + 5 => { + step.targets.clear(); + step.count = 0; + } + _ => unreachable!(), + } + assert!(!r.readable(), "case {kind}"); + assert!(!r.ready(), "case {kind}"); + } +} + +/// Copied commands carry the reviewed home, backend and guards, not just the +/// executable, and are offered only when the review kept its context. +#[test] +fn copied_commands_are_bound_to_the_reviewed_context_or_not_offered() { + let mut observation = fixtures::load("ready").unwrap().observation; + observation.context = Some( + hack_desktop_core::execution::ExecutionContext::from_values(&[ + ("HOME".into(), "/example/home".into()), + ("HACK_HOME".into(), "/example/isolated".into()), + ("HACK_RUNTIME_BACKEND".into(), "native".into()), + ("PATH".into(), "/usr/bin:/bin".into()), + ]) + .unwrap(), + ); + let args = vec!["upgrade".to_owned(), "snapshot".to_owned()]; + let command = observation.copy_command(&args).unwrap(); + assert!( + command.starts_with("'/usr/bin/env' '-i' 'HOME=/example/home'"), + "{command}" + ); + for part in [ + "'HACK_HOME=/example/isolated'", + "'HACK_RUNTIME_BACKEND=native'", + "'HACK_DAEMON_AUTOSTART_DISABLE=1'", + "'HACK_MCP_COMMAND=/example/bin/hack' '/example/bin/hack' 'upgrade' 'snapshot'", + ] { + assert!(command.contains(part), "{part} missing from {command}"); + } + observation.context = None; + assert_eq!(observation.copy_command(&args), None); +} + +/// A grouped step with no shared argv still shows each checkout's own +/// instruction and quoted, context-bound command. +#[test] +fn grouped_steps_show_each_checkout_action_and_command() { + let observation = fixtures::load("ready").unwrap().observation; + let target = |scope: &str, args: Option>, action: Option<&str>| PlanTarget { + scope: scope.into(), + project: scope.into(), + root: format!("/example/{scope}"), + status: "warn".into(), + args, + action: action.map(Into::into), + }; + let step = Step { + id: "project-store".into(), + scope: "projects".into(), + why: "Project stores need review".into(), + args: None, + action: Some("Run each checkout's own command.".into()), + count: 2, + targets: vec![ + target( + "alpha", + Some(vec![ + "doctor".into(), + "--path".into(), + "/example/alpha".into(), + ]), + Some("Move the store in alpha"), + ), + target("beta", None, Some("Remove the stale store in beta by hand")), + ], + }; + let rows = step.target_rows(&observation); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].scope, "alpha"); + assert_eq!(rows[0].action.as_deref(), Some("Move the store in alpha")); + let command = rows[0].command.as_deref().unwrap(); + assert!(command.starts_with("'/usr/bin/env' '-i' "), "{command}"); + assert!( + command.ends_with("'/example/bin/hack' 'doctor' '--path' '/example/alpha'"), + "{command}" + ); + assert_eq!(rows[1].scope, "beta"); + assert_eq!( + rows[1].action.as_deref(), + Some("Remove the stale store in beta by hand") + ); + assert_eq!(rows[1].command, None); + // Without a reviewed context, instructions stay but nothing is copyable. + let mut bare = observation.clone(); + bare.context = None; + assert!(step.target_rows(&bare).iter().all(|r| r.command.is_none())); + // A single checkout's instruction is the step's own. + let mut single = step.clone(); + single.targets.truncate(1); + single.count = 1; + assert!(single.target_rows(&observation).is_empty()); +} diff --git a/apps/desktop/core/tests/upgrade_io.rs b/apps/desktop/core/tests/upgrade_io.rs new file mode 100644 index 000000000..56a49551e --- /dev/null +++ b/apps/desktop/core/tests/upgrade_io.rs @@ -0,0 +1,523 @@ +//! Fake executables only; all homes and command receipts live in owned scratch. +use hack_desktop_core::{ + runner::CliRunner, + source::{LiveSource, Source}, + upgrade::*, + upgrade_io, +}; +use std::{os::unix::fs::PermissionsExt, path::PathBuf}; +struct Tree(PathBuf); +impl Tree { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "upgrade-io-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&path).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + // A private PATH directory with one trusted tool, so reads have a + // hermetic PATH instead of the test process's own. + let bin = path.join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o700)).unwrap(); + let git = bin.join("git"); + std::fs::write(&git, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o700)).unwrap(); + Self(path) + } + /// Fake CLI. Only the checked context reaches it, so its inputs live + /// under HOME (the tree) rather than in ad hoc environment variables. + fn runner(&self, data: &str, exit: i32) -> CliRunner { + std::fs::write(self.0.join("data"), data).unwrap(); + let program = self.0.join("fake-cli"); + std::fs::write( + &program, + format!( + r#"#!/bin/sh +[ "$HACK_DAEMON_AUTOSTART_DISABLE" = 1 ] || exit 97 +[ "$PWD" = / ] || exit 98 +printf '%s\n' "$*" >> "$HOME/calls" +printf '%s|%s|%s|%s\n' "$*" "$PATH" "${{BASH_ENV-unset}}" "$(/bin/ls "$PATH" 2>/dev/null | /usr/bin/tr '\n' ' ')" >> "$HOME/env" +printf '%s|%s=%s|%s=%s|%s|%s\n' "${{GIT_CONFIG_COUNT-unset}}" "${{GIT_CONFIG_KEY_0-}}" "${{GIT_CONFIG_VALUE_0-}}" "${{GIT_CONFIG_KEY_1-}}" "${{GIT_CONFIG_VALUE_1-}}" "${{HACK_HOST_PROBES_DISABLE-unset}}" "${{HACK_NATIVE_BINARY-unset}}" >> "$HOME/guards" +if [ "$*" = 'version --json' ]; then printf '{{"ok":true,"data":{{"schema":"hack.version.v1","version":"5.0.0"}}}}\n'; exit 0; fi +/bin/cat "$HOME/data" +exit {exit} +"# + ), + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + CliRunner::new(program) + .with_env("HOME", self.0.to_str().unwrap()) + .with_env("HACK_HOME", self.0.join("hack").to_str().unwrap()) + .with_env("PATH", self.0.join("bin").to_str().unwrap()) + } + fn source(&self, data: &str, exit: i32) -> LiveSource { + LiveSource { + runner: self.runner(data, exit), + daemon: None, + } + } +} +impl Drop for Tree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +const REPORT: &str = r#"{"ok":true,"data":{"report_version":2,"host_probes":"enabled","ready":false,"machine":[],"projects":[],"registry":{"state":"absent","total":0,"live":0,"missing":[],"unreadable":[],"unchecked":[],"rejected":0},"blockers":[{"scope":"machine","id":"docker","summary":"Docker could not be checked"}],"incomplete":[],"plan":[]}}"#; +#[test] +fn nonzero_valid_check_envelope_is_retained_without_update_or_other_effects() { + let t = Tree::new(); + let source = t.source(REPORT, 1); + let observation = source.upgrade_check().unwrap(); + assert!(!observation.report.unwrap().ready()); + assert_eq!(observation.cli.as_deref(), Some("5.0.0")); + assert!(observation.app.is_none()); + let calls = std::fs::read_to_string(t.0.join("calls")).unwrap(); + assert_eq!( + calls, + "upgrade check --json\nversion --json\ndaemon status --json\n" + ); +} +#[test] +fn malformed_envelopes_missing_required_fields_and_unsupported_version_are_refused() { + let t = Tree::new(); + for (data, exit) in [ + ("{}", 0), + ("not json", 0), + ( + r#"{"ok":false,"error":{"message":"private output must not be echoed"}}"#, + 1, + ), + (r#"{"ok":true,"data":{"ready":true}}"#, 0), + ] { + let error = + upgrade_io::read::(&t.runner(data, exit), &["upgrade", "check", "--json"]) + .unwrap_err(); + assert!(!error.contains("private output")); + } + // Version 1 never shipped in a release; 3 is not yet defined. + for version in ["1", "3"] { + let source = t.source( + &REPORT.replace( + "\"report_version\":2", + &format!("\"report_version\":{version}"), + ), + 0, + ); + assert!(source.upgrade_check().is_err()); + } + // A version 2 plan step without its grouped fields is not an observation. + let stepless = REPORT.replace( + "\"plan\":[]", + r#""plan":[{"id":"snapshot","scope":"machine","why":"Save files","args":["upgrade","snapshot"],"action":null}]"#, + ); + assert!( + upgrade_io::read::(&t.runner(&stepless, 0), &["upgrade", "check", "--json"]) + .is_err() + ); +} +/// Produced by the CLI's own `runUpgradeCheck` (three checkouts, two legacy +/// env configs), with scratch paths replaced by `/example`. +const CHECK_V2: &str = include_str!("fixtures/upgrade/check-v2.json"); +#[test] +fn current_cli_v2_report_is_read_with_grouped_plan_targets() { + let t = Tree::new(); + let source = t.source(CHECK_V2, 0); + let observation = source.upgrade_check().unwrap(); + let report = observation.report.unwrap(); + assert_eq!(report.report_version, REPORT_VERSION); + assert!(report.readable()); + assert!(report.ready()); + let steps: Vec<_> = report + .plan + .iter() + .map(|s| (s.id.as_str(), s.scope.as_str(), s.count)) + .collect(); + assert_eq!( + steps, + [ + ("snapshot", "machine", 0), + ("env-format", "projects", 2), + ("ignore-rules", "projects", 3) + ] + ); + let ignore = &report.plan[2]; + assert_eq!( + ignore + .targets + .iter() + .map(|t| t.scope.as_str()) + .collect::>(), + ["alpha", "beta", "gamma"] + ); + // Plan argv is only ever displayed quoted. + assert_eq!( + display_step("/example/bin/hack", ignore.args.as_ref().unwrap()).unwrap(), + "'/example/bin/hack' 'upgrade' 'apply' '--only' 'ignore-rules'" + ); + let calls = std::fs::read_to_string(t.0.join("calls")).unwrap(); + assert_eq!( + calls, + "upgrade check --json\nversion --json\ndaemon status --json\n" + ); +} +#[test] +fn snapshots_and_rollback_are_explicit_and_use_exact_manifest_without_overwrite() { + let t = Tree::new(); + let source = t.source(r#"{"ok":true,"data":{"status":"created","id":"example","manifest_path":"/example/snapshots/example/manifest.json","complete":true,"gaps":[],"files":[{"path":"/example/file","label":"names only"}]}}"#,0); + let snapshot = upgrade_io::snapshot(&source).unwrap(); + assert!(snapshot.manifest().is_some()); + let source = t.source(r#"{"ok":true,"data":{"status":"incomplete","results":[{"path":"/example/file","status":"conflict","kept_as":"/example/file.kept","message":"File changed"}]}}"#,1); + let result = upgrade_io::rollback(&source, &snapshot).unwrap(); + assert_eq!( + result.results[0].kept_as.as_deref(), + Some("/example/file.kept") + ); + let calls = std::fs::read_to_string(t.0.join("calls")).unwrap(); + assert_eq!( + calls, + "upgrade snapshot --json\nupgrade rollback --rollback-manifest /example/snapshots/example/manifest.json --json\n" + ); + let mut incomplete = snapshot; + incomplete.complete = Some(false); + assert!(upgrade_io::rollback(&source, &incomplete).is_err()); + assert_eq!(std::fs::read_to_string(t.0.join("calls")).unwrap(), calls); + let refused: Snapshot = upgrade_io::snapshot(&t.source( + r#"{"ok":true,"data":{"status":"refused","message":"refused"}}"#, + 1, + )) + .unwrap(); + assert!(refused.manifest().is_none()); +} + +#[test] +fn app_evidence_is_installed_version_not_catalog_version() { + let t = Tree::new(); + // The fake read runner requires neutral cwd and disabled autostart too. + let make = |data: &str| { + t.runner(data, 0) + .with_cwd("/") + .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + }; + assert_eq!( + upgrade_io::installed_app(&make( + r#"{"casks":[{"token":"hack-desktop","version":"5.1.0","installed":"5.0.0"}]}"# + )) + .unwrap(), + "5.0.0" + ); + for data in [ + r#"{"casks":[{"token":"hack-desktop","version":"5.1.0","installed":null}]}"#, + r#"{"casks":[{"token":"other","installed":"5.1.0"}]}"#, + r#"{"casks":[]}"#, + ] { + assert!(upgrade_io::installed_app(&make(data)).is_err()); + } +} + +#[test] +fn any_normal_nonzero_exit_retains_a_valid_envelope() { + let t = Tree::new(); + let report: Report = + upgrade_io::read(&t.runner(REPORT, 7), &["upgrade", "check", "--json"]).unwrap(); + assert!(!report.ready()); +} +#[test] +fn readiness_reads_the_daemon_version_from_the_cli_socket_path() { + use std::io::{Read, Write}; + let t = Tree::new(); + let socket_dir = PathBuf::from(format!( + "/tmp/uio-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + )); + std::fs::create_dir(&socket_dir).unwrap(); + std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + let sock = socket_dir.join("hackd.sock"); + let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(60))) + .unwrap(); + let mut buf = [0; 1024]; + let n = stream.read(&mut buf).unwrap(); + assert!(String::from_utf8_lossy(&buf[..n]).starts_with("GET /v1/status ")); + let body = r#"{"status":"ok","version":"5.0.0"}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .unwrap(); + }); + let program = t.0.join("fake-cli"); + std::fs::write( + &program, + format!( + r#"#!/bin/sh +case "$*" in +'version --json') printf '{{"ok":true,"data":{{"schema":"hack.version.v1","version":"5.0.0"}}}}\n';; +'daemon status --json') printf '{{"status":"running","running":true,"socket_path":"{}"}}';; +*) /bin/cat "$HOME/data";; +esac +"#, + sock.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::write(t.0.join("data"), REPORT).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("PATH", t.0.join("bin").to_str().unwrap()), + daemon: None, + }; + let observation = source.upgrade_check().unwrap(); + assert_eq!(observation.daemon.as_deref(), Some("5.0.0")); + server.join().unwrap(); + let _ = std::fs::remove_dir_all(socket_dir); +} + +/// Every upgrade read and write runs the CLI with only Doctor's private +/// wrapper PATH (alive for the whole operation) and no interpreter startup +/// variables, and the observation keeps the context copied commands use. +#[test] +fn upgrade_operations_see_only_the_private_tool_path_and_no_startup_variables() { + let t = Tree::new(); + let source = LiveSource { + runner: t.runner(REPORT, 1).with_env("BASH_ENV", "/example/startup"), + daemon: None, + }; + let observation = source.upgrade_check().unwrap(); + let context = observation.context.as_ref().expect("checked context"); + assert_eq!(context.value("HACK_HOME"), t.0.join("hack").to_str()); + assert_eq!(context.value("PATH"), t.0.join("bin").to_str()); + let manifest = r#"{"ok":true,"data":{"status":"created","id":"example","manifest_path":"/example/snapshots/example/manifest.json","complete":true,"gaps":[],"files":[]}}"#; + std::fs::write(t.0.join("data"), manifest).unwrap(); + let snapshot = source.upgrade_snapshot().unwrap(); + std::fs::write( + t.0.join("data"), + r#"{"ok":true,"data":{"status":"restored","results":[]}}"#, + ) + .unwrap(); + source.upgrade_rollback(&snapshot).unwrap(); + let env = std::fs::read_to_string(t.0.join("env")).unwrap(); + let lines: Vec<_> = env.lines().collect(); + assert_eq!(lines.len(), 5, "{env}"); + for line in lines { + let [args, path, bash_env, listing] = line.splitn(4, '|').collect::>()[..] else { + panic!("unexpected receipt: {line}"); + }; + assert!( + !path.contains(':'), + "{args}: one private directory, got {path}" + ); + let dir = std::path::Path::new(path); + assert!( + dir.file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with("hack-desktop-tools-"), + "{args}: {path}" + ); + assert_ne!(dir, t.0.join("bin")); + assert_eq!(bash_env, "unset", "{args}"); + assert_eq!( + listing.trim(), + "dirname git", + "{args}: wrappers (git, plus dirname for candidate launchers) exist while the CLI runs" + ); + assert!(!dir.exists(), "{args}: wrappers are removed afterwards"); + } +} + +/// `upgrade check` runs Git in registered checkouts: every upgrade CLI run +/// carries Doctor's command-line Git overrides (fsmonitor off, hooks to +/// /dev/null; proved against real Git by Doctor's +/// `doctor_git_reads_disable_repository_fsmonitor`). The CLI's host-probe hard +/// deny survives into the checked run and copied commands, and the native +/// runtime is pinned to the checked selection. +#[test] +fn upgrade_runs_keep_git_overrides_and_the_host_probe_guard() { + let t = Tree::new(); + let source = LiveSource { + runner: t + .runner(REPORT, 1) + .with_env("HACK_HOST_PROBES_DISABLE", "1"), + daemon: None, + }; + let observation = source.upgrade_check().unwrap(); + let context = observation.context.as_ref().expect("checked context"); + assert_eq!(context.value("HACK_HOST_PROBES_DISABLE"), Some("1")); + let copied = observation + .copy_command(&["upgrade".into(), "check".into()]) + .expect("copied command"); + assert!(copied.contains("HACK_HOST_PROBES_DISABLE=1"), "{copied}"); + let guards = std::fs::read_to_string(t.0.join("guards")).unwrap(); + assert!(!guards.is_empty()); + for line in guards.lines() { + let [count, fsmonitor, hooks, probes, native] = line.split('|').collect::>()[..] + else { + panic!("unexpected receipt: {line}"); + }; + assert_eq!(count, "2"); + assert_eq!(fsmonitor, "core.fsmonitor=false"); + assert_eq!(hooks, "core.hooksPath=/dev/null"); + assert_eq!(probes, "1", "the host-probe hard deny reaches the CLI"); + // The fake CLI is a #! script, so its fallback is pinned absent. + assert!( + native.ends_with("/native-unavailable/hack-native"), + "{native}" + ); + } + // Without the guard the run does not invent one. + let t = Tree::new(); + let source = LiveSource { + runner: t.runner(REPORT, 1), + daemon: None, + }; + let observation = source.upgrade_check().unwrap(); + assert_eq!( + observation + .context + .as_ref() + .unwrap() + .value("HACK_HOST_PROBES_DISABLE"), + None + ); + let guards = std::fs::read_to_string(t.0.join("guards")).unwrap(); + assert!(guards.lines().all(|l| l.split('|').nth(3) == Some("unset"))); +} +/// A copied fix runs later, after the private wrapper PATH is gone: a PATH +/// directory another user can write (empty at review time, so the review +/// itself was safe) is dropped from the copied command's PATH. +#[test] +fn copied_commands_keep_only_controlled_path_directories() { + let t = Tree::new(); + let shared = t.0.join("shared"); + std::fs::create_dir(&shared).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + let bin = t.0.join("bin"); + let path = format!("{}:{}", shared.display(), bin.display()); + let source = LiveSource { + runner: t.runner(REPORT, 1).with_env("PATH", &path), + daemon: None, + }; + let observation = source.upgrade_check().unwrap(); + let context = observation.context.as_ref().expect("checked context"); + assert_eq!(context.value("PATH"), bin.to_str()); + let copied = observation + .copy_command(&["upgrade".into(), "snapshot".into()]) + .expect("copied command"); + assert!(!copied.contains(shared.to_str().unwrap()), "{copied}"); + // With no controlled directory left, the context's system default applies. + let source = LiveSource { + runner: t + .runner(REPORT, 1) + .with_env("PATH", shared.to_str().unwrap()), + daemon: None, + }; + let observation = source.upgrade_check().unwrap(); + assert_eq!( + observation.context.as_ref().unwrap().value("PATH"), + Some("/usr/bin:/bin") + ); +} + +/// A refused snapshot is an `ok` envelope with status `refused`: the CLI's +/// reason is kept for the pane, and the receipt never admits rollback. +#[test] +fn a_refused_snapshot_keeps_the_cli_reason() { + let t = Tree::new(); + let source = t.source( + r#"{"ok":true,"data":{"status":"refused","message":"The snapshot destination is shared with other users.\u001b[31m Fix its permissions, then retry.","detail":{}}}"#, + 1, + ); + let snapshot = upgrade_io::snapshot(&source).unwrap(); + assert!(snapshot.manifest().is_none()); + let reason = snapshot.refusal().expect("refusal reason"); + assert!( + reason.starts_with("The snapshot destination is shared"), + "{reason}" + ); + assert!(reason.contains("Fix its permissions"), "{reason}"); + assert!(!reason.chars().any(char::is_control), "{reason:?}"); + // Long reasons are bounded; a created snapshot has no refusal. + let mut long = snapshot.clone(); + long.message = Some("x".repeat(Snapshot::REFUSAL_MAX * 2)); + assert_eq!( + long.refusal().unwrap().chars().count(), + Snapshot::REFUSAL_MAX + 1 + ); + long.status = "created".into(); + assert_eq!(long.refusal(), None); +} + +#[test] +fn an_unsafe_tool_on_path_fails_every_upgrade_operation_closed() { + let t = Tree::new(); + let shared = t.0.join("shared"); + std::fs::create_dir(&shared).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700)).unwrap(); + let docker = shared.join("docker"); + std::fs::write(&docker, "#!/bin/sh\nexit 0\n").unwrap(); + // Writable by other users: it cannot be trusted to stand for docker. + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o777)).unwrap(); + let path = format!("{}:{}", shared.display(), t.0.join("bin").display()); + let source = LiveSource { + runner: t.runner(REPORT, 0).with_env("PATH", &path), + daemon: None, + }; + let error = source.upgrade_check().unwrap_err(); + assert!(error.contains("docker"), "{error}"); + assert!(error.contains("other users can change"), "{error}"); + assert!( + !error.contains("R14") && !error.contains("Doctor"), + "{error}" + ); + let error = source.upgrade_snapshot().unwrap_err(); + assert!(error.contains("docker"), "{error}"); + let complete = Snapshot { + status: "created".into(), + id: Some("example".into()), + manifest_path: Some("/example/snapshots/example/manifest.json".into()), + complete: Some(true), + gaps: Some(vec![]), + message: None, + }; + assert!(source.upgrade_rollback(&complete).is_err()); + assert!( + !t.0.join("calls").exists(), + "the CLI never ran with an unchecked tool on PATH" + ); +} +#[test] +fn a_remote_docker_endpoint_fails_every_upgrade_operation_closed() { + let t = Tree::new(); + let source = LiveSource { + runner: t + .runner(REPORT, 0) + .with_env("PATH", t.0.join("bin").to_str().unwrap()) + .with_env("DOCKER_HOST", "ssh://user@example"), + daemon: None, + }; + let error = source.upgrade_check().unwrap_err(); + assert!(error.contains("remote endpoint"), "{error}"); + assert!(source.upgrade_snapshot().is_err()); + assert!( + !t.0.join("calls").exists(), + "the CLI never ran with a remote Docker endpoint" + ); +} diff --git a/apps/desktop/diagrams/upgrade.mmd b/apps/desktop/diagrams/upgrade.mmd index 6cef90c08..ce115ef46 100644 --- a/apps/desktop/diagrams/upgrade.mmd +++ b/apps/desktop/diagrams/upgrade.mmd @@ -1,33 +1,50 @@ --- -title: Upgrade · app view of the flow +title: Upgrade · evidence before effects --- stateDiagram-v2 direction TB - [*] --> Checking - Checking --> Done : nothing remains - Checking --> Blocked : blockers or incomplete - Checking --> Ready : work remains - Blocked --> Checking : Re-check - Ready --> Snapshotting : Upgrade - Snapshotting --> Blocked : gaps or refused - Snapshotting --> Applying : complete - Applying --> Unknown : no evidence - Applying --> Failed : step failed - Applying --> RestartingDaemon : steps verified - RestartingDaemon --> Checking : versions match - RestartingDaemon --> Failed : mismatch - Failed --> RollingBack : Roll back - RollingBack --> Restored : files restored - RollingBack --> Failed : partial restore - Restored --> Checking : re-check - Unknown --> Checking : re-check + [*] --> AvailabilityUnknown + AvailabilityUnknown --> Checking : Review click + Checking --> AvailabilityUnknown : no target evidence + Checking --> Blocked : incomplete readiness + Checking --> Ready : verified target, ready + Blocked --> Checking : Check again + Ready --> Snapshotting : Save snapshot click + AvailabilityUnknown --> Snapshotting : Save click, readiness checked + Snapshotting --> Incomplete : refused or gaps + Snapshotting --> Ready : complete, verified target + Snapshotting --> AvailabilityUnknown : complete, no target + Incomplete --> Checking : Check again + Ready --> Applying : Upgrade click, complete snapshot + Applying --> Checking : Check again + Checking --> Done : all fresh evidence + Checking --> Unknown : evidence missing + Unknown --> Checking : Check again + Unknown --> Restarting : Restart click + Restarting --> Checking : fresh versions match + Restarting --> Failed : mismatch or error + Failed --> RollingBack : Restore click, complete snapshot + RollingBack --> Restored : every file restored + RollingBack --> Failed : partial or refused + Restored --> Checking : Review click + note right of Ready + INV target evidence AND complete snapshot + required before terminal upgrade. + Live target discovery needs R14. + end note + note right of Done + INV CLI and installed app equal target, + daemon equals CLI, ready empty plan, + every previous scope and id explicitly ok. + Missing evidence stays Unknown. + end note note right of Restored - INV rollback restores files only, - never versions or containers + INV files only, never versions, + containers or services. end note classDef ok stroke-width:1.6px classDef wait stroke-dasharray:2 2 classDef fail stroke-width:2px - class Done ok - class Snapshotting,Applying,RestartingDaemon,RollingBack wait - class Blocked,Failed,Unknown fail + class Done,Restored ok + class Checking,Snapshotting,Applying,Restarting,RollingBack wait + class Blocked,Incomplete,Failed,Unknown fail diff --git a/apps/desktop/diagrams/upgrade.svg b/apps/desktop/diagrams/upgrade.svg index 1d9b45798..bb37b701e 100644 --- a/apps/desktop/diagrams/upgrade.svg +++ b/apps/desktop/diagrams/upgrade.svg @@ -1,4 +1,4 @@ -Upgrade · app view of the flownothing remainsblockers or incompletework remainsRe-checkUpgradegaps or refusedcompleteno evidencestep failedsteps verifiedversions matchmismatchRoll backfiles restoredpartial restorere-checkre-checkCheckingDoneBlockedReadySnapshottingApplyingUnknownFailedRestartingDaemonRollingBackRestoredINV rollback restores files only,never versions or containers \ No newline at end of file +]]>Upgrade · evidence before effectsReview clickno target evidenceincomplete readinessverified target, readyCheck againSave snapshot clickSave click, readiness checkedrefused or gapscomplete, verified targetcomplete, no targetCheck againUpgrade click, completesnapshotCheck againall fresh evidenceevidence missingCheck againRestart clickfresh versions matchmismatch or errorRestore click, completesnapshotevery file restoredpartial or refusedReview clickAvailabilityUnknownCheckingBlockedSnapshottingIncompleteApplyingUnknownRestartingFailedRollingBackReadyDoneRestoredINV target evidence AND complete snapshotrequired before terminal upgrade.Live target discovery needs R14.INV CLI and installed app equal target,daemon equals CLI, ready empty plan,every previous scope and id explicitly ok.Missing evidence stays Unknown.INV files only, never versions,containers or services. \ No newline at end of file diff --git a/apps/desktop/screenshots/upgrade-dark.png b/apps/desktop/screenshots/upgrade-dark.png new file mode 100644 index 000000000..a23d9fd4e Binary files /dev/null and b/apps/desktop/screenshots/upgrade-dark.png differ diff --git a/apps/desktop/screenshots/upgrade-light.png b/apps/desktop/screenshots/upgrade-light.png new file mode 100644 index 000000000..8b807841b Binary files /dev/null and b/apps/desktop/screenshots/upgrade-light.png differ diff --git a/docs/versions/v5/upgrade.md b/docs/versions/v5/upgrade.md index 133db2413..ce47a2f6f 100644 --- a/docs/versions/v5/upgrade.md +++ b/docs/versions/v5/upgrade.md @@ -122,3 +122,22 @@ login service still points at the old executable, use the chosen install's `hack daemon install`. Keep this outside the shared-home candidate trial; [daemon and service details](guides/upgrade-from-v4.md#daemon-old-lock-and-missing-checkouts) explain the difference. + +## Desktop app preview + +The desktop preview has an **Upgrade Hack** pane that wraps the same commands. +It does not upgrade anything yet: + +- **Review readiness** runs `hack upgrade check --json` and shows its findings. + It never checks whether an update exists, so availability stays Unknown. +- **Save file snapshot** runs the CLI's snapshot after a ready review. Only a + complete snapshot counts as a recovery point. +- **Restore files from snapshot** runs `hack upgrade rollback` for that exact + snapshot. It restores files only; versions, containers and services do not + change. +- **Upgrade** stays disabled until Hack can verify the target version and who + owns the installation. Plan fixes are shown as commands to copy, never run. + +The preview does not replace the backup, daemon and rollback steps above. +Details are in the desktop app README (`apps/desktop/README.md`, "Upgrade +review").