From a7dd39502fda45b8f8ec1eb0e40d2e594637de70 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:26:14 +0000 Subject: [PATCH 1/7] feat(native): guide first-use runtime setup Expose verified resumable setup through the frontend, choose a private home by default, and share read-only installation admission between preflight and native up. Preserve explicit consent and managed upgrade quiescence. Fixes HACK-2193. --- docs/guides/candidate-install.md | 65 ++- docs/guides/native-candidate.md | 24 +- docs/guides/native-first-use.mmd | 18 + docs/guides/native-first-use.svg | 66 +++ docs/reference/cli.md | 50 ++ packages/runtime-core/src/main.rs | 26 +- packages/runtime-core/src/provider/setup.rs | 7 +- .../src/provider/setup/inspection.rs | 487 ++++++++++++++++++ .../runtime-core/src/provider/setup/tests.rs | 43 ++ .../runtime-core/src/runtime_setup_cli.rs | 89 +++- .../runtime-core/tests/installed_setup.rs | 96 ++++ .../hack-diagnose/references/error-codes.md | 3 +- scripts/hack-v5.sh | 38 +- scripts/installer/channel.ts | 80 ++- src/agents/error-code-reference.ts | 5 + src/backends/native-preflight.ts | 73 ++- src/backends/native-runtime-client.ts | 56 +- src/backends/native-runtime-setup.ts | 314 +++++++++++ src/cli/spec.ts | 2 + src/commands/native.ts | 144 ++++++ src/commands/project.ts | 18 + src/lib/cli-result.ts | 1 + tests/helpers/native-setup-inspection.ts | 42 ++ .../native-authored-command-dispatch.test.ts | 4 + tests/native-beta-label.test.ts | 2 +- tests/native-candidate-launcher.test.ts | 38 +- ...ative-compose-backend-features-cli.test.ts | 2 + tests/native-preflight-cli.test.ts | 24 +- tests/native-project-branch-cli.test.ts | 2 + tests/native-project-settings-cli.test.ts | 2 + tests/native-refusal-cli.test.ts | 2 + tests/native-runtime-client.test.ts | 2 +- tests/native-runtime-setup.test.ts | 298 +++++++++++ tests/native-shell-environment-cli.test.ts | 2 + tests/native-up-already-running-cli.test.ts | 2 + tests/python-free-installer.test.ts | 123 ++++- 36 files changed, 2186 insertions(+), 64 deletions(-) create mode 100644 docs/guides/native-first-use.mmd create mode 100644 docs/guides/native-first-use.svg create mode 100644 packages/runtime-core/src/provider/setup/inspection.rs create mode 100644 packages/runtime-core/tests/installed_setup.rs create mode 100644 src/backends/native-runtime-setup.ts create mode 100644 src/commands/native.ts create mode 100644 tests/helpers/native-setup-inspection.ts create mode 100644 tests/native-runtime-setup.test.ts diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index d799a9eba..19ed2cac7 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -104,9 +104,68 @@ Leave `HACK_RUNTIME_BACKEND` unset, or set it to `native`, to return to the nati default. A shell that exported `HACK_RUNTIME_BACKEND=compose` for other work also selects Compose for `hack-next`; unset it before native testing. -The bundled `hack-v5` entrypoint follows the same rule for manual bundles: -unset selects native and requires `HACK_NATIVE_HOME`; `compose` needs no native -home and uses your own Hack home. +The bundled `hack-v5` entrypoint follows the same backend rule for manual bundles. +Native defaults to `~/.hack/native-runtime`, or `$HACK_HOME/native-runtime` when +`HACK_HOME` is selected. An absolute `HACK_NATIVE_HOME` overrides that default. +Selecting a home creates nothing; setup creates missing directories privately +after consent. Existing homes must be owned by you, mode 0700, and have no symlink +components. `compose` needs no native home and uses your own Hack home. + +### Prepare the native runtime once + +Use the candidate frontend for both setup and project commands: + +```sh +hack-next native setup +hack-next doctor --native-preflight +hack-next up -d +``` + +The command is `native setup` because it prepares native provider files without +starting a VM or changing the Compose backend. It lists the pinned downloads, +sizes, SHA-256 hashes, cache and destination, then asks once. Scripts use +`hack-next native setup --accept-downloads --json`; JSON never prompts and passes +the runtime's setup result through unchanged. Completed files are verified and +reused; interrupted downloads resume. The complete offline archive input form is +also available; see [provider setup](native-candidate.md). + +Preflight reports runtime installation separately from project review, in text +and in the JSON `runtime_setup` item. A fresh or interrupted home blocks startup +with `hack native setup` as its fix. Noninteractive `up` refuses before starting +an owner; interactive `up` offers the same pinned setup and continues only after +successful setup and a fresh readiness check. Record readiness is a quick check; +startup still verifies prepared content before executing it. + +![Native first-use flow](native-first-use.svg) + +Diagram [source](native-first-use.mmd); rendered with Pele 1.9.0. + +Managed `hack-next` keeps its version's receipt-owned `native-home` so upgrades +can find and stop its runtime. It refuses a differing `HACK_NATIVE_HOME` rather +than silently ignoring it. Use that version's bundle `hack-v5` launcher for a +custom home. To move a manual bundle's home, stop its projects and runtime using +the old home and matching executor, preserve the old home for retained data, then +select a fresh absolute `HACK_NATIVE_HOME` and run `native setup` again. Setup +does not migrate live receipts, disks or project data. The machine home is +distinct from each project's `.hack/native/` adaptation directory. +An explicit `native setup` selects the native home even when the surrounding +shell sets `HACK_RUNTIME_BACKEND=compose`; ordinary Compose commands still remove +native selectors. + +The frontend passes only system environment variables to setup; project values, +credentials and proxy variables are excluded. Downloads need direct HTTPS access; +use the offline inputs where a proxy is required. Invalid prepared trees and pin +mismatches are preserved and need their own repair or a fresh home, rather than +an endless setup retry. Setup-only homes permit managed upgrades only after an +existing-lock quiescence check; unexpected or busy state still refuses. As with +the previous empty-home check, a detached process that has not created any state +is outside this observation's proof. + +Older candidate executors cannot prove the new setup-only quiescence predicate. +If an older selected version was set up but never started, finish its setup, +start a project with that old version's `hack-next up`, then run `hack-next down` +before upgrading. Its runtime must report `stopped`. An interrupted or unknown +old home still refuses an upgrade; do not remove its receipts to bypass the check. ### Stop stable hackd before a Compose run diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 028d26d10..b8b4b8340 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -934,21 +934,35 @@ compatibility. V4 and candidate homes remain separate; this procedure does not migrate V4 data into the candidate or replace the installed `hack`. DNS, trust and client configuration are outside this bundle-selection procedure. -Provider setup is separate and explicit. Prepare the native runtime with: +Provider setup is explicit and available from the candidate frontend, without a +second binary or home argument: ```sh -./hack-native --candidate-root /absolute/private/candidate-home runtime setup +hack-next native setup +# For noninteractive use: +hack-next native setup --accept-downloads --json ``` -`runtime setup` lists the pinned SmolVM, Docker engine and four Alpine guest +`native setup` lists the pinned SmolVM, Docker engine and four Alpine guest packages with their exact compressed byte lengths, source hosts, SHA-256 hashes, private cache and install destination, then asks once when preparation is needed. `--no-interactive` (or truthy `HACK_NO_INTERACTIVE` values `1`, `true`, `yes` or `on`, ignoring case and surrounding whitespace) refuses with `E_INTERACTIVE_REQUIRED` unless -`--accept-downloads` explicitly consents. No provider download happens from `up`, -the installer, the probe or any other implicit path. The optional BuildKit client +`--accept-downloads` explicitly consents. Interactive `up` can offer setup with +the same explicit consent; noninteractive `up` refuses until setup is complete. +The installer and probe never download provider files. The optional BuildKit client is still prepared separately below. +Manual bundle frontends use `$HACK_HOME/native-runtime` or +`~/.hack/native-runtime` by default, with an absolute `HACK_NATIVE_HOME` override. +Managed `hack-next` uses the version's receipt-owned home. See +[native first use and moving homes](candidate-install.md#prepare-the-native-runtime-once). +The lower-level `hack-native --candidate-root /absolute/private/home runtime setup` +remains available for explicit runtime administration. Its read-only +`runtime setup inspect --json` reports installation records without creating a +home, acquiring a lease or hashing provider trees. Only the manager's +`--quiescence` inspection takes existing locks; startup verifies full content. + The executable embeds `provider-pins.json` and checks its hashes against the manual preparation pins. HTTPS redirects may use only the original host and the pin's explicit `redirect_hosts`. The GitHub release pin allows diff --git a/docs/guides/native-first-use.mmd b/docs/guides/native-first-use.mmd new file mode 100644 index 000000000..0ccad6b17 --- /dev/null +++ b/docs/guides/native-first-use.mmd @@ -0,0 +1,18 @@ +flowchart TD + Up["hack up"] --> Inspect["Inspect installation"] + Inspect --> Ready{"Ready?"} + Ready -->|Yes| Start["Review project and start"] + Ready -->|No| Offer{"Interactive and resumable?"} + Offer -->|No| Refuse["Refuse with guidance"] + Offer -->|Yes| Consent{"Accept pinned downloads?"} + Consent -->|No| Refuse + Consent -->|Yes| Setup["Verify and prepare files"] + Setup -->|Success| Reinspect["Re-inspect installation"] + Reinspect --> Installed{"Ready after setup?"} + Installed -->|Yes| Start + Installed -->|No| Refuse + Setup -->|Failed or interrupted| Refuse + class Start ok + class Refuse fail + class Setup live + class Consent wait diff --git a/docs/guides/native-first-use.svg b/docs/guides/native-first-use.svg new file mode 100644 index 000000000..8c6466034 --- /dev/null +++ b/docs/guides/native-first-use.svg @@ -0,0 +1,66 @@ +YesNoNoYesNoYesSuccessYesNoFailed or interruptedhack upInspect installationReady?Review project and startInteractive andresumable?Refuse with guidanceAccept pinned downloads?Verify and prepare filesRe-inspect installationReady after setup? \ No newline at end of file diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 46880255c..ec6384533 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1534,6 +1534,56 @@ hack projects prune [options] | `--version, -v` | Show version | +## `hack native` + +Set up the opt-in native runtime + +### Usage + +```bash +hack native [options] +``` + +### Subcommands + +| Command | Summary | +| --- | --- | +| `hack native setup` | Download and prepare the pinned native runtime for this home | + +### Options + +| Option | Description | +| --- | --- | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + +## `hack native setup` + +Download and prepare the pinned native runtime for this home + +### Usage + +```bash +hack native setup [options] +``` + +Verifies and resumes pinned downloads without starting a VM. Requires Apple Silicon macOS. Non-interactive downloads need --accept-downloads. --json passes the runtime result through unchanged. + +### Options + +| Option | Description | +| --- | --- | +| `--json` | Output JSON (machine-readable) | +| `--accept-downloads` | Consent to the displayed pinned downloads | +| `--archive ` | Offline pinned provider archive (requires the complete offline input set) | +| `--engine-archive ` | Offline pinned engine archive | +| `--network-tools-directory ` | Private directory containing all pinned networking packages | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + + ## Diagnostics ## `hack log-pipe` diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 252f5520e..f78f90492 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -230,8 +230,27 @@ fn run() -> Result<(), CandidateError> { "Supply --candidate-root explicitly, or use the matching candidate launcher.", )); } - let requested = resolve_candidate_root(Path::new(&arguments[1]))?; let command: Vec<&str> = arguments[2..].iter().map(String::as_str).collect(); + // First-use observation/setup must work before installed-home discovery: + // an absent home is an ordinary installation state, not provider_state. + if installed_entrypoint() { + let home = Path::new(&arguments[1]); + match command.as_slice() { + ["runtime", "setup", "inspect", "--json", "--quiescence"] => { + return print_json( + &hack_runtime_core::provider::setup::inspect_home_quiescence(home)?, + ); + } + ["runtime", "setup", "inspect", "--json"] => { + return print_json(&hack_runtime_core::provider::setup::inspect_home(home)?); + } + ["runtime", "setup", arguments @ ..] => { + return runtime_setup_cli::run_home(home, arguments); + } + _ => {} + } + } + let requested = resolve_candidate_root(Path::new(&arguments[1]))?; match command.as_slice() { [] | ["--help"] | ["help"] => { println!( @@ -966,6 +985,11 @@ fn run() -> Result<(), CandidateError> { } } } + ["runtime", "setup", "inspect", "--json"] => { + print_json(&hack_runtime_core::provider::setup::inspect_candidate( + &discover_candidate(&requested)?, + )?)?; + } ["runtime", "setup", arguments @ ..] => { runtime_setup_cli::run(&discover_candidate(&requested)?, arguments)?; } diff --git a/packages/runtime-core/src/provider/setup.rs b/packages/runtime-core/src/provider/setup.rs index a733e35a9..a42095863 100644 --- a/packages/runtime-core/src/provider/setup.rs +++ b/packages/runtime-core/src/provider/setup.rs @@ -2,6 +2,11 @@ //! Preparation uses the manual entrypoints in an isolated staging home. A single rename //! publishes the complete provider set; interrupted staging can be discarded and rebuilt. mod download; +mod inspection; +pub use inspection::{ + Inspection, InstallationState, create_home, inspect_candidate, inspect_home, + inspect_home_quiescence, validate_home_path, +}; #[cfg(test)] mod tests; @@ -14,7 +19,7 @@ use std::{ path::{Path, PathBuf}, }; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct FilePin { pub url: String, pub archive_sha256: String, diff --git a/packages/runtime-core/src/provider/setup/inspection.rs b/packages/runtime-core/src/provider/setup/inspection.rs new file mode 100644 index 000000000..4bea46f19 --- /dev/null +++ b/packages/runtime-core/src/provider/setup/inspection.rs @@ -0,0 +1,487 @@ +//! Installation observation takes no leases and does not hash provider trees. Startup +//! still verifies content before execution; this is first-use guidance, not attestation. +use super::{FilePin, pins}; +use crate::{ + Candidate, CandidateError, + provider::{artifact, network_tools, state}, +}; +use serde::Serialize; +use std::{ + fs, + io::ErrorKind, + os::unix::fs::{DirBuilderExt, MetadataExt}, + path::{Component, Path}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallationState { + NotSetUp, + PartialResumable, + Ready, + InvalidPreserved, + PinMismatch, + UnsafeHome, + Unavailable, + UnsupportedHost, +} + +impl InstallationState { + fn code(self) -> &'static str { + match self { + Self::NotSetUp | Self::PartialResumable => "E_NATIVE_SETUP_REQUIRED", + Self::Ready => "native_setup_ready", + Self::InvalidPreserved => "native_setup_invalid", + Self::PinMismatch => "native_setup_pin_mismatch", + Self::UnsafeHome => "native_setup_unsafe_home", + Self::Unavailable => "native_setup_unavailable", + Self::UnsupportedHost => "unsupported_host", + } + } +} + +#[derive(Debug, Serialize)] +pub struct Inspection { + pub protocol_version: u32, + pub state: InstallationState, + pub installation_state: InstallationState, + pub supported_host: bool, + pub code: &'static str, + pub pins: Vec, + /// Narrow manager upgrade predicate, independent of installation readiness. + pub setup_only: bool, +} + +/// Validate a possibly absent selected home without creating or resolving it. +pub fn validate_home_path(home: &Path) -> Result<(), CandidateError> { + if !home.is_absolute() || home.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(CandidateError::new( + "invalid_candidate_home", + "Native home must be absolute without parent traversal.", + )); + } + crate::reject_aliased_state(home) +} + +/// Called only after consent and host admission. Existing directories are never +/// chmodded or adopted as private homes. Each newly created component is 0700. +pub fn create_home(home: &Path) -> Result { + validate_home_path(home)?; + let mut current = std::path::PathBuf::new(); + for component in home.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(CandidateError::new( + "unsafe_candidate_home", + "Native home must not follow aliases.", + )); + } + Err(error) if error.kind() == ErrorKind::NotFound => { + fs::DirBuilder::new() + .mode(0o700) + .create(¤t) + .map_err(state::io)?; + crate::reject_aliased_state(¤t)?; + } + Err(error) => return Err(state::io(error)), + } + } + Candidate::discover_installed(home) +} + +/// Read-only and lock-free, including absent homes. Publication is atomic; a +/// concurrent setup may be reported resumable and can safely be retried later. +pub fn inspect_home(home: &Path) -> Result { + let supported = cfg!(all(target_os = "macos", target_arch = "aarch64")); + inspect(home, supported) +} + +/// Manager-only quiescence proof. Unlike ordinary inspection, this may contend +/// with setup/startup. Existing-only leases create nothing and fail closed. +pub fn inspect_home_quiescence(home: &Path) -> Result { + let mut inspection = inspect_home(home)?; + inspection.setup_only = Candidate::discover_installed(home) + .and_then(|candidate| quiescent_setup_only(&candidate)) + .unwrap_or(false); + Ok(inspection) +} + +/// Checkout-local binaries retain their existing checkout admission. Their root +/// is a source directory rather than an installed private home. +pub fn inspect_candidate(candidate: &Candidate) -> Result { + let installation_state = match classify_candidate(candidate) { + Ok(value) => value, + Err(error) => failure_state(&error), + }; + result( + installation_state, + false, + cfg!(all(target_os = "macos", target_arch = "aarch64")), + ) +} + +fn inspect(home: &Path, supported_host: bool) -> Result { + let (installation_state, setup_only) = classify(home); + result(installation_state, setup_only, supported_host) +} + +fn result( + installation_state: InstallationState, + setup_only: bool, + supported_host: bool, +) -> Result { + let state = if !supported_host + && !matches!( + installation_state, + InstallationState::UnsafeHome | InstallationState::Unavailable + ) { + InstallationState::UnsupportedHost + } else { + installation_state + }; + let code = state.code(); + Ok(Inspection { + protocol_version: 1, + state, + installation_state, + supported_host, + code, + pins: pins()?, + setup_only, + }) +} + +fn classify(home: &Path) -> (InstallationState, bool) { + use InstallationState::*; + if let Err(error) = validate_home_path(home) { + return ( + if error.code == "state_unavailable" { + Unavailable + } else { + UnsafeHome + }, + false, + ); + } + match fs::symlink_metadata(home) { + Err(error) if error.kind() == ErrorKind::NotFound => return (NotSetUp, false), + Err(_) => return (Unavailable, false), + Ok(_) => {} + } + let candidate = match Candidate::discover_installed(home) { + Ok(candidate) => candidate, + Err(_) => return (UnsafeHome, false), + }; + // Only the explicit quiescence form may grant the manager predicate. + let only_setup = false; + match classify_candidate(&candidate) { + Ok(value) => (value, only_setup), + Err(error) => (failure_state(&error), only_setup), + } +} + +fn failure_state(error: &CandidateError) -> InstallationState { + match error.code { + "provider_pin_changed" => InstallationState::PinMismatch, + "provider_state" | "state_unavailable" => InstallationState::Unavailable, + _ => InstallationState::InvalidPreserved, + } +} + +fn installed_metadata(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| { + if error.kind() == ErrorKind::NotFound { + CandidateError::new("artifact_invalid", "Prepared runtime file is absent.") + } else { + state::io(error) + } + })?; + if metadata.file_type().is_symlink() { + return Err(CandidateError::new( + "artifact_invalid", + "Prepared runtime files must not be aliases.", + )); + } + Ok(metadata) +} + +fn classify_candidate(candidate: &Candidate) -> Result { + let root = &candidate.state_root; + if !root.try_exists().map_err(state::io)? { + return Ok(InstallationState::NotSetUp); + } + state::check_private_directory(root)?; + let providers = root.join("providers"); + crate::reject_aliased_state(&providers)?; + if !providers.try_exists().map_err(state::io)? { + return Ok(InstallationState::PartialResumable); + } + state::check_private_directory(&providers)?; + for pin in [&artifact::SMOLVM, &artifact::ENGINE] { + installed_metadata(&providers.join(pin.record_name()))?; + let record = artifact::verify_record(candidate, pin)?; + if record.tree_sha256.len() != 64 + || !record.tree_sha256.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(CandidateError::new( + "artifact_invalid", + "Invalid prepared tree record.", + )); + } + let tree = artifact::tree_root(candidate, pin); + if !installed_metadata(&tree)?.is_dir() { + return Err(CandidateError::new( + "artifact_invalid", + "Prepared tree is absent.", + )); + } + } + let packages = providers.join("network-tools"); + installed_metadata(&packages)?; + state::check_private_directory(&packages)?; + for (name, _) in network_tools::PACKAGES { + let info = installed_metadata(&packages.join(name))?; + // SAFETY: geteuid has no preconditions. + if !info.is_file() + || info.nlink() != 1 + || info.uid() != unsafe { libc::geteuid() } + || info.mode() & 0o077 != 0 + || info.len() == 0 + || info.len() > 2 * 1024 * 1024 + { + return Err(CandidateError::new( + "network_tools", + "Unsafe networking archive.", + )); + } + } + Ok(InstallationState::Ready) +} + +/// No runtime owner/VM state can exist in a setup-only home. Unexpected entries +/// fail closed; this never removes anything or treats a missing owner as idle. +fn setup_only(candidate: &Candidate) -> Result { + fn subset(path: &Path, allowed: &[&str]) -> Result { + crate::reject_aliased_state(path)?; + if !path.try_exists().map_err(state::io)? { + return Ok(true); + } + state::check_private_directory(path)?; + for entry in fs::read_dir(path).map_err(state::io)? { + let entry = entry.map_err(state::io)?; + if !allowed.iter().any(|name| entry.file_name() == *name) { + return Ok(false); + } + } + Ok(true) + } + let root = &candidate.state_root; + if !subset(&candidate.checkout, &[".hack-local"])? + || !subset(root, &["providers", "setup", "run"])? + || !subset(&root.join("run"), &["smolvm"])? + || !subset(&root.join("run/smolvm"), &["operation.lock"])? + { + return Ok(false); + } + if !subset( + &root.join("setup"), + &["operation.lock", "downloads", "staging"], + )? { + return Ok(false); + } + for name in ["providers", "setup", "setup/downloads", "setup/staging"] { + let path = root.join(name); + crate::reject_aliased_state(&path)?; + if path.try_exists().map_err(state::io)? { + state::check_private_directory(&path)?; + } + } + let lock = root.join("run/smolvm/operation.lock"); + match fs::symlink_metadata(lock) { + Ok(info) => { + // SAFETY: geteuid has no preconditions. + Ok(info.is_file() + && info.nlink() == 1 + && info.uid() == unsafe { libc::geteuid() } + && info.mode() & 0o7777 == 0o600) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(true), + Err(error) => Err(state::io(error)), + } +} + +fn quiescent_setup_only(candidate: &Candidate) -> Result { + let directories = [ + candidate.state_root.join("setup"), + candidate.state_root.join("run/smolvm"), + ]; + let mut leases = Vec::new(); + for directory in &directories { + crate::reject_aliased_state(directory)?; + let lease = if directory.try_exists().map_err(state::io)? { + Some(state::Lock::acquire_existing(directory)?) + } else { + None + }; + leases.push(lease); + } + if !setup_only(candidate)? { + return Ok(false); + } + for (directory, lease) in directories.iter().zip(&leases) { + if let Some(lease) = lease { + lease.verify_path(directory)?; + } else if directory.try_exists().map_err(state::io)? { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use std::os::unix::fs::{PermissionsExt, symlink}; + + struct Fixture(std::path::PathBuf); + impl Fixture { + fn new() -> Self { + Self(crate::private_test_root( + &fs::canonicalize(std::env::temp_dir()).unwrap(), + "setup-inspect", + )) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + + // Record-only readiness deliberately does not execute or attest these trees. + pub(in crate::provider::setup) fn prepared_records(candidate: &Candidate) { + let providers = candidate.state_root.join("providers"); + state::private_directory(&providers).unwrap(); + let pins = pins().unwrap(); + for (tree, source) in [(&artifact::SMOLVM, &pins[0]), (&artifact::ENGINE, &pins[1])] { + state::private_directory(&artifact::tree_root(candidate, tree)).unwrap(); + let version = if tree.record_name() == "smolvm.json" { + artifact::VERSION + } else { + artifact::ENGINE_VERSION + }; + state::write(&providers.join(tree.record_name()), &serde_json::json!({"version": version, "archive_sha256": source.archive_sha256, "tree_sha256": "a".repeat(64)})).unwrap(); + } + let directory = providers.join("network-tools"); + state::private_directory(&directory).unwrap(); + for (name, _) in network_tools::PACKAGES { + let path = directory.join(name); + fs::write(&path, b"fixture").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + } + + #[test] + fn absent_partial_ready_and_held_lease_are_read_only() { + let fixture = Fixture::new(); + let absent = fixture.0.join("absent"); + assert_eq!( + inspect(&absent, true).unwrap().state, + InstallationState::NotSetUp + ); + assert!(!absent.exists()); + let candidate = create_home(&absent).unwrap(); + assert!(inspect_home_quiescence(&absent).unwrap().setup_only); + state::private_directory(&candidate.state_root.join("setup/downloads")).unwrap(); + fs::write( + candidate.state_root.join("setup/downloads/file.part"), + b"prefix", + ) + .unwrap(); + assert_eq!( + inspect(&absent, true).unwrap().state, + InstallationState::PartialResumable + ); + prepared_records(&candidate); + let _lease = state::Lock::acquire(&candidate.state_root.join("run/smolvm")).unwrap(); + assert_eq!( + inspect(&absent, true).unwrap().state, + InstallationState::Ready + ); + assert!(!inspect_home_quiescence(&absent).unwrap().setup_only); + drop(_lease); + let setup_lease = state::Lock::acquire(&candidate.state_root.join("setup")).unwrap(); + assert!(!inspect_home_quiescence(&absent).unwrap().setup_only); + drop(setup_lease); + assert!(inspect_home_quiescence(&absent).unwrap().setup_only); + assert!(!inspect(&absent, true).unwrap().setup_only); + fs::write( + candidate.state_root.join("run/smolvm/owner.json"), + b"unexpected", + ) + .unwrap(); + assert!(!inspect_home_quiescence(&absent).unwrap().setup_only); + assert_eq!( + inspect(&absent, false).unwrap().state, + InstallationState::UnsupportedHost + ); + } + + #[test] + fn mismatched_invalid_unsafe_and_foreign_state_have_different_fixes() { + let fixture = Fixture::new(); + let candidate = Candidate::discover_installed(&fixture.0).unwrap(); + prepared_records(&candidate); + let record = candidate.state_root.join("providers/smolvm.json"); + fs::remove_file(&record).unwrap(); + state::write(&record, &serde_json::json!({"version":"old", "archive_sha256":"b".repeat(64), "tree_sha256":"a".repeat(64)})).unwrap(); + assert_eq!( + inspect(&fixture.0, true).unwrap().state, + InstallationState::PinMismatch + ); + fs::remove_file(&record).unwrap(); + assert_eq!( + inspect(&fixture.0, true).unwrap().state, + InstallationState::InvalidPreserved + ); + symlink("missing", candidate.state_root.join("setup")).unwrap(); + symlink("missing", &record).unwrap(); + assert_eq!( + inspect(&fixture.0, true).unwrap().state, + InstallationState::InvalidPreserved + ); + assert!(!inspect(&fixture.0, true).unwrap().setup_only); + fs::set_permissions(&fixture.0, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + inspect(&fixture.0, true).unwrap().state, + InstallationState::UnsafeHome + ); + assert_eq!(fs::metadata(&fixture.0).unwrap().mode() & 0o777, 0o755); + } + + #[test] + fn quiescence_refuses_unknown_setup_and_missing_locks_and_home_entries() { + let fixture = Fixture::new(); + let candidate = Candidate::discover_installed(&fixture.0).unwrap(); + let setup = candidate.state_root.join("setup"); + let lease = state::Lock::acquire(&setup).unwrap(); + drop(lease); + assert!(inspect_home_quiescence(&fixture.0).unwrap().setup_only); + fs::write(setup.join("unexpected"), b"unknown").unwrap(); + assert!(!inspect_home_quiescence(&fixture.0).unwrap().setup_only); + fs::remove_file(setup.join("unexpected")).unwrap(); + state::private_directory(&candidate.state_root.join("run/smolvm")).unwrap(); + assert!(!inspect_home_quiescence(&fixture.0).unwrap().setup_only); + let lease = state::Lock::acquire(&candidate.state_root.join("run/smolvm")).unwrap(); + drop(lease); + assert!(inspect_home_quiescence(&fixture.0).unwrap().setup_only); + fs::create_dir(fixture.0.join("prepared-bases")).unwrap(); + assert!(!inspect_home_quiescence(&fixture.0).unwrap().setup_only); + assert_eq!( + failure_state(&CandidateError::new("provider_state", "access unavailable")), + InstallationState::Unavailable + ); + } +} diff --git a/packages/runtime-core/src/provider/setup/tests.rs b/packages/runtime-core/src/provider/setup/tests.rs index 482bab21a..1836503f7 100644 --- a/packages/runtime-core/src/provider/setup/tests.rs +++ b/packages/runtime-core/src/provider/setup/tests.rs @@ -406,6 +406,49 @@ fn full_setup_orchestration_downloads_once_and_reuses_without_prepare() { ); } +#[test] +fn local_server_setup_transitions_the_shared_installation_gate() { + let root = Fixture::new(); + let candidate = Candidate::discover_installed(&root.0).unwrap(); + assert_eq!( + inspect_home(&root.0).unwrap().installation_state, + InstallationState::NotSetUp + ); + let (pin, server) = server(vec![ + ( + "get /provider.tgz", + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + ), + ( + "get /engine.tgz", + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + ), + ( + "get /guest.apk", + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + ), + ]); + install( + &candidate, + None, + &fixture_pins(&pin), + |staged, inputs| { + fixture_prepare(staged, inputs)?; + super::inspection::tests::prepared_records(staged); + Ok(()) + }, + fixture_verify, + ) + .unwrap(); + server.join().unwrap(); + let inspection = inspect_home(&root.0).unwrap(); + assert_eq!(inspection.installation_state, InstallationState::Ready); + assert!(inspect_home_quiescence(&root.0).unwrap().setup_only); + // The fixture's prepared bytes deliberately are not production artifacts. + // Readiness guides first use; startup still performs the full content check. + assert!(artifact::verify(&candidate).is_err()); +} + #[test] fn offline_setup_and_partial_manual_install_preservation() { let root = Fixture::new(); diff --git a/packages/runtime-core/src/runtime_setup_cli.rs b/packages/runtime-core/src/runtime_setup_cli.rs index 512b142e2..83d7970d3 100644 --- a/packages/runtime-core/src/runtime_setup_cli.rs +++ b/packages/runtime-core/src/runtime_setup_cli.rs @@ -15,6 +15,42 @@ pub fn run(candidate: &Candidate, arguments: &[&str]) -> Result<(), CandidateErr ) } +/// Installed entrypoint dispatches here before eager discovery: a first-use home +/// does not exist yet. Neither a refused consent nor an unsupported host creates it. +pub fn run_home(home: &Path, arguments: &[&str]) -> Result<(), CandidateError> { + let options = parse(arguments)?; + setup::validate_home_path(home)?; + if home.try_exists().map_err(|_| { + CandidateError::new("native_setup_unavailable", "Cannot inspect native home.") + })? { + return run(&Candidate::discover_installed(home)?, arguments); + } + if options.offline.is_none() { + confirm_downloads(home, &options)?; + } + if !cfg!(all(target_os = "macos", target_arch = "aarch64")) { + return Err(CandidateError::new( + "unsupported_host", + "Native setup requires Apple Silicon macOS.", + )); + } + let candidate = setup::create_home(home)?; + let inputs = options + .offline + .map(|(archive, engine, packages)| setup::OfflineInputs { + archive: Path::new(archive), + engine_archive: Path::new(engine), + network_tools: Path::new(packages), + }); + let result = setup::run(&candidate, inputs, inputs.is_none())?; + writeln!( + io::stdout().lock(), + "{}", + serde_json::to_string(&result).expect("setup result is serializable") + ) + .map_err(|_| CandidateError::new("setup_output", "Cannot write setup result.")) +} + fn execute( candidate: &Candidate, arguments: &[&str], @@ -36,17 +72,6 @@ fn execute( .map_err(|_| CandidateError::new("setup_output", "Cannot write setup result."))?; return Ok(()); } - let pins = setup::pins()?; - let interactive = !options.no_interactive - && io::stdin().is_terminal() - && io::stderr().is_terminal() - && !truthy_env(std::env::var("HACK_NO_INTERACTIVE").ok().as_deref()); - if interactive && !cfg!(all(target_os = "macos", target_arch = "aarch64")) { - return Err(CandidateError::new( - "unsupported_host", - "Native setup requires Apple Silicon macOS.", - )); - } if let Some((archive, engine, packages)) = options.offline { let result = install( candidate, @@ -65,6 +90,34 @@ fn execute( .map_err(|_| CandidateError::new("setup_output", "Cannot write setup result."))?; return Ok(()); } + confirm_downloads(&candidate.checkout, &options)?; + let result = install(candidate, None, true)?; + writeln!( + output, + "{}", + serde_json::to_string(&result).expect("setup result is serializable") + ) + .map_err(|_| CandidateError::new("setup_output", "Cannot write setup result."))?; + Ok(()) +} + +fn confirm_downloads(home: &Path, options: &Options<'_>) -> Result<(), CandidateError> { + // The frontend already displayed the compiled pins before explicit consent. + // Raw callers passing this flag have also supplied their consent in advance. + if options.accept { + return Ok(()); + } + let pins = setup::pins()?; + let interactive = !options.no_interactive + && io::stdin().is_terminal() + && io::stderr().is_terminal() + && !truthy_env(std::env::var("HACK_NO_INTERACTIVE").ok().as_deref()); + if interactive && !cfg!(all(target_os = "macos", target_arch = "aarch64")) { + return Err(CandidateError::new( + "unsupported_host", + "Native setup requires Apple Silicon macOS.", + )); + } eprintln!("Pinned native runtime files (exact compressed byte lengths):"); for pin in &pins { let url = reqwest::Url::parse(&pin.url) @@ -85,11 +138,11 @@ fn execute( } eprintln!( "Private download cache: {}", - setup::download_directory(candidate).display() + home.join(".hack-local/setup/downloads").display() ); eprintln!( "Install destination: {}", - candidate.state_root.join("providers").display() + home.join(".hack-local/providers").display() ); setup::consent(options.accept, interactive, || { eprint!("Download and prepare these pinned files? [y/N] "); @@ -106,15 +159,7 @@ fn execute( answer.trim().to_ascii_lowercase().as_str(), "y" | "yes" )) - })?; - let result = install(candidate, None, true)?; - writeln!( - output, - "{}", - serde_json::to_string(&result).expect("setup result is serializable") - ) - .map_err(|_| CandidateError::new("setup_output", "Cannot write setup result."))?; - Ok(()) + }) } fn truthy_env(value: Option<&str>) -> bool { diff --git a/packages/runtime-core/tests/installed_setup.rs b/packages/runtime-core/tests/installed_setup.rs new file mode 100644 index 000000000..6f4c1ddaa --- /dev/null +++ b/packages/runtime-core/tests/installed_setup.rs @@ -0,0 +1,96 @@ +#![cfg(feature = "installed-candidate")] +//! Exercise dispatch before installed-home discovery at the real binary boundary. +use std::{ + fs, + os::unix::fs::{DirBuilderExt, PermissionsExt, symlink}, + path::PathBuf, + process::{Command, Output}, +}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let root = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!( + "installed-setup-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + Self(root) + } + fn run(&self, home: &str, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_hack-native")) + .arg("--candidate-root") + .arg(self.0.join(home)) + .args(args) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HACK_NO_INTERACTIVE", "1") + .stdin(std::process::Stdio::null()) + .output() + .unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +#[test] +fn absent_home_can_be_inspected_and_refused_consent_creates_nothing() { + let f = Fixture::new(); + let output = f.run("missing/child", &["runtime", "setup", "inspect", "--json"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let inspection: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(inspection["protocol_version"], 1); + assert_eq!(inspection["installation_state"], "not_set_up"); + assert!(!f.0.join("missing").exists()); + let declined = f.run( + "missing/child", + &["runtime", "setup", "--no-interactive", "--json"], + ); + assert!(!declined.status.success()); + assert!(String::from_utf8_lossy(&declined.stderr).contains("E_INTERACTIVE_REQUIRED")); + assert!(!f.0.join("missing").exists()); + #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] + { + assert_eq!(inspection["state"], "unsupported_host"); + let unsupported = f.run( + "missing/child", + &["runtime", "setup", "--accept-downloads", "--no-interactive"], + ); + assert!(!unsupported.status.success()); + assert!(String::from_utf8_lossy(&unsupported.stderr).contains("unsupported_host")); + assert!(!f.0.join("missing").exists()); + } +} + +#[test] +fn alias_and_existing_public_home_are_preserved() { + let f = Fixture::new(); + let directory = f.0.join("public"); + fs::create_dir(&directory).unwrap(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap(); + symlink(&directory, f.0.join("alias")).unwrap(); + for name in ["public", "alias"] { + let result = f.run(name, &["runtime", "setup", "inspect", "--json"]); + assert!(result.status.success()); + let inspection: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(inspection["state"], "unsafe_home"); + assert_eq!(inspection["setup_only"], false); + } + assert_eq!( + fs::metadata(directory).unwrap().permissions().mode() & 0o777, + 0o755 + ); +} diff --git a/plugins/hack/skills/hack-diagnose/references/error-codes.md b/plugins/hack/skills/hack-diagnose/references/error-codes.md index 36e09998b..55b83353b 100644 --- a/plugins/hack/skills/hack-diagnose/references/error-codes.md +++ b/plugins/hack/skills/hack-diagnose/references/error-codes.md @@ -19,6 +19,7 @@ stay within the request. | `E_COMPOSE_FAILED` | A Compose operation failed or left a stop incomplete. | `hack logs --json --no-follow --tail 200` | | `E_COMPOSE_OUTPUT_INVALID` | Compose exited successfully but printed output Hack cannot parse as JSON, usually an unsupported Compose version. | `docker compose version` | | `E_STARTUP_INCOMPLETE` | After start or restart, a service is neither running nor a successful one-shot. | `hack ps --json` | +| `E_NATIVE_SETUP_REQUIRED` | The selected native home needs runtime setup before project startup. | `hack doctor --native-preflight --json` | | `E_DEPENDENCY_BOOTSTRAP_FAILED` | A dependency installer service did not complete successfully. | `hack logs --json --no-follow --tail 200` | | `E_STARTUP_TIMEOUT` | Bounded startup ran out of time before services settled. | `hack ps --json` | | `E_CANCELLED` | The caller cancelled the operation; pending ownership is retained. | `hack ps --json` | @@ -43,4 +44,4 @@ stay within the request. --- -Content revision: `e104e92c08a5`. Generated by Hack; edit the canonical source and regenerate the plugin. +Content revision: `12ec120ab5ff`. Generated by Hack; edit the canonical source and regenerate the plugin. diff --git a/scripts/hack-v5.sh b/scripts/hack-v5.sh index 844096822..a8ee48eed 100644 --- a/scripts/hack-v5.sh +++ b/scripts/hack-v5.sh @@ -3,7 +3,8 @@ # # Backend selection: an unset or empty HACK_RUNTIME_BACKEND selects the native # runtime with this bundle's adjacent executor and the explicit private -# HACK_NATIVE_HOME. An explicit HACK_RUNTIME_BACKEND=compose runs this bundle's +# HACK_NATIVE_HOME (default: $HACK_HOME/native-runtime or ~/.hack/native-runtime). +# An explicit HACK_RUNTIME_BACKEND=compose runs this bundle's # CLI on the Compose backend with the caller's own Hack home (HACK_HOME, or # ~/.hack when unset); native selectors are removed so they cannot leak into # Compose children. Any other value refuses rather than guessing. @@ -14,11 +15,42 @@ case "$backend" in native|compose) ;; *) echo "HACK_RUNTIME_BACKEND must be native or compose (unset selects native)." >&2; exit 64 ;; esac +# An explicit native operation must use the same home as native up, even when +# the surrounding shell selects Compose for ordinary project commands. +for task_argument in "$@"; do + case "$task_argument" in + --no-interactive) continue ;; + native) backend=native ;; + esac + break +done if [ "$backend" = native ]; then - case "${HACK_NATIVE_HOME:-}" in + trim_path() { + path_value=$1 + path_value=${path_value#"${path_value%%[![:space:]]*}"} + path_value=${path_value%"${path_value##*[![:space:]]}"} + printf '%s' "$path_value" + } + native_home=$(trim_path "${HACK_NATIVE_HOME:-}") + if [ -z "$native_home" ]; then + hack_home=$(trim_path "${HACK_HOME:-}") + if [ -z "$hack_home" ]; then + user_home=$(trim_path "${HOME:-}") + case "$user_home" in + /*) hack_home="$user_home/.hack" ;; + *) echo "Native runtime needs an absolute HOME, HACK_HOME or HACK_NATIVE_HOME." >&2; exit 64 ;; + esac + fi + case "$hack_home" in + /*) native_home="$hack_home/native-runtime" ;; + *) echo "HACK_HOME must be an absolute path." >&2; exit 64 ;; + esac + fi + case "$native_home" in /*) ;; - *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; + *) echo "HACK_NATIVE_HOME must be an absolute path." >&2; exit 64 ;; esac + export HACK_NATIVE_HOME="$native_home" fi if [ ! -x "$bundle/hack-cli" ] || [ ! -x "$bundle/hack-native" ]; then echo "Incomplete candidate bundle: hack-cli and hack-native are required." >&2 diff --git a/scripts/installer/channel.ts b/scripts/installer/channel.ts index 528db1d9a..73e1c4334 100644 --- a/scripts/installer/channel.ts +++ b/scripts/installer/channel.ts @@ -53,6 +53,14 @@ export const launcherBytes = () => '#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -P "$(dirname "$0")/.." && pwd -P)\nif [ "${1-}" = --manager ]; then shift; exec "$root/manager" --root "$root" "$@"; fi\nexec "$root/manager" --root "$root" run -- "$@"\n' ); const nonce = () => randomUUID().replaceAll("-", ""); +/** Setup inspection consumes no project credentials or ambient native selectors. */ +function childSetupInspectionEnvironment(): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = {}; + for (const name of ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"]) { + environment[name] = process.env[name]; + } + return environment; +} function lexists(path: string) { try { lstatSync(path); @@ -526,11 +534,12 @@ export class Channel { return environment; } Reflect.deleteProperty(environment, "HACK_GLOBAL_CONFIG_PATH"); + const selectedHome = this.path(`versions/${version}/native-home`); return { ...environment, HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: this.path(`versions/${version}/bundle/hack-native`), - HACK_NATIVE_HOME: this.path(`versions/${version}/native-home`), + HACK_NATIVE_HOME: selectedHome, HACK_HOME: this.path(`versions/${version}/cli-home`), }; } @@ -558,7 +567,7 @@ export class Channel { "--json", ], { - env: this.environment(version), + env: childSetupInspectionEnvironment(), stdin: "ignore", stdout: "pipe", stderr: "ignore", @@ -577,14 +586,57 @@ export class Channel { "Candidate runtime is active or ambiguous; selection preserved." ); if (status.phase === "uninitialized") { - requireThat( - readdirSync(join(directory, "native-home")).length === 0, - "Uninitialized home contains state; inspect it through its owning CLI." - ); + this.requireSetupOnlyHome(directory); } this.validate(managerUpgrade); } } + /** Setup artifacts alone own no VM. Unknown uninitialized state remains a + * refusal, including old executors that cannot prove the setup-only predicate. + */ + private requireSetupOnlyHome(directory: string) { + const home = join(directory, "native-home"); + if (readdirSync(home).length === 0) { + return; + } + const result = Bun.spawnSync( + [ + join(directory, "bundle/hack-native"), + "--candidate-root", + home, + "runtime", + "setup", + "inspect", + "--json", + "--quiescence", + ], + { + env: childSetupInspectionEnvironment(), + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + timeout: 30_000, + maxBuffer: 1024 * 1024, + } + ); + requireThat( + result.exitCode === 0 && result.stdout.length <= 1024 * 1024, + "Uninitialized home could not be inspected; selection preserved." + ); + let inspection: unknown; + try { + inspection = JSON.parse(result.stdout.toString()); + } catch { + throw new Error( + "Malformed native setup inspection; selection preserved." + ); + } + const report = object(inspection); + requireThat( + report.protocol_version === 1 && report.setup_only === true, + "Uninitialized home contains runtime or unknown state; inspect it through its owning CLI." + ); + } select(version: string | null) { this.requireQuiescent(this.state.selected); if (version !== null) { @@ -912,16 +964,30 @@ export class Channel { this.validate(); } async run(arguments_: string[]) { - const backend = process.env.HACK_RUNTIME_BACKEND || "native"; + let backend = process.env.HACK_RUNTIME_BACKEND || "native"; requireThat( backend === "native" || backend === "compose", "HACK_RUNTIME_BACKEND must be native or compose (unset selects native)." ); + // Explicit native operations select native setup even from a Compose shell. + if ( + arguments_.filter((argument) => argument !== "--no-interactive")[0] === + "native" + ) { + backend = "native"; + } const version = this.state.selected; requireThat( version !== null, "No candidate selected. Use your ordinary stable hack command." ); + if (backend === "native") { + const override = process.env.HACK_NATIVE_HOME?.trim(); + requireThat( + !override || override === this.path(`versions/${version}/native-home`), + "hack-next uses its receipt-owned native home for safe upgrades; use the bundle's hack-v5 launcher for a custom HACK_NATIVE_HOME." + ); + } const checksum = this.state.installed[version]; requireThat(checksum, "Missing selected receipt."); const directory = this.verifyVersion(version, checksum); diff --git a/src/agents/error-code-reference.ts b/src/agents/error-code-reference.ts index 72fa4f670..64a851b9f 100644 --- a/src/agents/error-code-reference.ts +++ b/src/agents/error-code-reference.ts @@ -16,6 +16,11 @@ type ErrorCodeGuide = { }; const ERROR_CODE_GUIDES: Readonly> = { + E_NATIVE_SETUP_REQUIRED: { + meaning: + "The selected native home needs runtime setup before project startup.", + readFirst: "`hack doctor --native-preflight --json`", + }, E_DOCKER_UNAVAILABLE: { meaning: "The container runtime CLI or engine cannot be reached.", readFirst: "`hack doctor --json`", diff --git a/src/backends/native-preflight.ts b/src/backends/native-preflight.ts index 04c7b5ed2..148aff4e4 100644 --- a/src/backends/native-preflight.ts +++ b/src/backends/native-preflight.ts @@ -43,6 +43,11 @@ import { NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; +import { + inspectNativeSetup, + type NativeSetupState, + nativeSetupGuidance, +} from "./native-runtime-setup.ts"; import { shellReference } from "./native-shell-environment.ts"; import { NATIVE_START_REFUSAL_ENTRIES } from "./native-start-refusal-entries.ts"; import { readNativeStartRefusals } from "./native-start-refusals.ts"; @@ -109,6 +114,12 @@ export type NativePreflightReport = { readonly compose_file: string; /** No blocker was found and the runtime review completed. */ readonly ready: boolean; + readonly runtime_setup: { + readonly state: NativeSetupState; + readonly installation_state?: NativeSetupState; + readonly code: string; + readonly fix: string; + }; readonly runtime_review: { readonly status: NativePreflightReviewStatus; /** @@ -1462,12 +1473,60 @@ export type NativePreflightOptions = { readonly invoke?: typeof invokeNativeRuntime; }; -/** Runs both gates over one project and merges their findings. */ +function hasSelectedRuntime( + runtime: NativePreflightOptions["runtime"] +): runtime is NativeRuntimeSelection { + return runtime !== null && runtime !== "incomplete"; +} + +async function collectSetupFinding( + opts: NativePreflightOptions +): Promise { + let runtimeSetup: NativePreflightReport["runtime_setup"] = { + state: "unavailable", + code: "native_selection_unavailable", + fix: "Select a native candidate to inspect its installation; run this command through hack-next or the candidate bundle.", + }; + if (hasSelectedRuntime(opts.runtime)) { + try { + const inspected = await inspectNativeSetup({ + runtime: opts.runtime, + cwd: opts.projectRoot, + invoke: opts.invoke, + }); + runtimeSetup = { + state: inspected.state, + installation_state: inspected.installation_state, + code: inspected.code, + fix: nativeSetupGuidance(inspected), + }; + } catch { + runtimeSetup = { + state: "unavailable", + code: "native_setup_unavailable", + fix: "Native runtime installation could not be inspected; update the selected candidate and check access to its home.", + }; + } + } + return runtimeSetup; +} + +/** Runs installation, CLI and runtime gates over the same selected project. */ export async function collectNativePreflight( opts: NativePreflightOptions ): Promise { + const runtimeSetup = await collectSetupFinding(opts); const adapted = await adaptForPreflight(opts); const cli = [...opts.settings, ...adapted.findings]; + if (hasSelectedRuntime(opts.runtime) && runtimeSetup.state !== "ready") { + cli.push({ + severity: "blocker", + gate: "cli", + code: "native_runtime_setup", + message: `Native runtime setup: ${runtimeSetup.state.replaceAll("_", " ")}.`, + fix: runtimeSetup.fix, + }); + } let runtime: RuntimeReview = { review: opts.runtime === "incomplete" @@ -1490,7 +1549,10 @@ export async function collectNativePreflight( review: { status: "skipped", code: "native_adaptation_invalid" }, findings: [], }; - } else if (opts.runtime !== null && opts.runtime !== "incomplete") { + } else if ( + hasSelectedRuntime(opts.runtime) && + runtimeSetup.state === "ready" + ) { runtime = await collectNativeRuntimeFindings({ runtime: opts.runtime, projectRoot: opts.projectRoot, @@ -1542,7 +1604,11 @@ export async function collectNativePreflight( report_version: 1, backend: "native", compose_file: relative(opts.projectRoot, opts.composeFile), - ready: blockers.length === 0 && runtime.review.status === "completed", + ready: + blockers.length === 0 && + runtime.review.status === "completed" && + runtimeSetup.state === "ready", + runtime_setup: runtimeSetup, runtime_review: runtime.review, blockers, warnings: all.filter((found) => found.severity === "warning"), @@ -1589,6 +1655,7 @@ export function renderNativePreflightReport( ): string { const lines = [ `Native preflight for ${report.compose_file}`, + `Runtime setup: ${report.runtime_setup.state.replaceAll("_", " ")}. ${report.runtime_setup.fix}`, reviewLine(report.runtime_review), "", ]; diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index fd46de9aa..93a2cf27d 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -1,7 +1,10 @@ -import { isAbsolute } from "node:path"; +import { accessSync, constants } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; import { childEnvironment } from "../lib/child-environment.ts"; import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { normalizeHomeDir, readAbsolutePathOverride } from "../lib/home-dir.ts"; import { type NativeProcessRefusal, readNativeProcessRefusals, @@ -315,16 +318,59 @@ export function resolveNativeRuntimeSelection( if (env.HACK_RUNTIME_BACKEND !== "native") { return null; } - const binary = env.HACK_NATIVE_BINARY; - const home = env.HACK_NATIVE_HOME; - if (!(binary && home && isAbsolute(binary) && isAbsolute(home))) { + return resolveNativeSetupSelection(env); +} + +/** Native setup is explicit even when the project uses the Compose backend. */ +export function resolveNativeSetupSelection( + env: Readonly> = process.env +): NativeRuntimeSelection { + let binary = env.HACK_NATIVE_BINARY?.trim(); + if (!binary) { + const adjacent = join(dirname(process.execPath), "hack-native"); + try { + accessSync(adjacent, constants.X_OK); + binary = adjacent; + } catch { + binary = undefined; + } + } + if (!(binary && isAbsolute(binary))) { throw new ClosedCliError( - `Native runtime requires absolute HACK_NATIVE_BINARY and HACK_NATIVE_HOME paths. ${nativeBetaCandidatePointer()}` + `Native runtime requires an adjacent bundled hack-native or an absolute HACK_NATIVE_BINARY path. ${nativeBetaCandidatePointer()}` ); } + const home = resolveNativeHome(env); return { binary, home }; } +/** Selecting a home never creates it. Explicit isolation overrides fail closed. */ +export function resolveNativeHome( + env: Readonly> = process.env +): string { + const explicit = readAbsolutePathOverride({ + name: "HACK_NATIVE_HOME", + value: env.HACK_NATIVE_HOME, + }); + if (explicit) { + return explicit; + } + const hackHome = readAbsolutePathOverride({ + name: "HACK_HOME", + value: env.HACK_HOME, + }); + if (hackHome) { + return join(hackHome, "native-runtime"); + } + const userHome = normalizeHomeDir(env.HOME) ?? normalizeHomeDir(homedir()); + if (!userHome) { + throw new ClosedCliError( + "Native runtime needs an absolute HOME, HACK_HOME or HACK_NATIVE_HOME." + ); + } + return join(userHome, ".hack", "native-runtime"); +} + /** * Bounded one-shot control requests. Private input travels only over stdin; * subprocess diagnostics are omitted because they can contain application data. diff --git a/src/backends/native-runtime-setup.ts b/src/backends/native-runtime-setup.ts new file mode 100644 index 000000000..273c8a095 --- /dev/null +++ b/src/backends/native-runtime-setup.ts @@ -0,0 +1,314 @@ +import { constants as osConstants } from "node:os"; +import { basename, join } from "node:path"; +import { childEnvironment } from "../lib/child-environment.ts"; +import { HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { canPrompt, confirmSafe } from "../lib/interactivity.ts"; +import { + invokeNativeRuntime, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +const STATES = [ + "not_set_up", + "partial_resumable", + "ready", + "invalid_preserved", + "pin_mismatch", + "unsafe_home", + "unavailable", + "unsupported_host", +] as const; +export type NativeSetupState = (typeof STATES)[number]; +export type NativeSetupPin = { + readonly url: string; + readonly size_bytes: number; + readonly archive_sha256: string; +}; +export type NativeSetupInspection = { + readonly protocol_version: 1; + readonly state: NativeSetupState; + readonly code: string; + readonly installation_state: NativeSetupState; + readonly supported_host: boolean; + readonly setup_only: boolean; + readonly pins: readonly NativeSetupPin[]; +}; + +const HASH = /^[a-f0-9]{64}$/; +const CODES: Readonly> = { + not_set_up: "E_NATIVE_SETUP_REQUIRED", + partial_resumable: "E_NATIVE_SETUP_REQUIRED", + ready: "native_setup_ready", + invalid_preserved: "native_setup_invalid", + pin_mismatch: "native_setup_pin_mismatch", + unsafe_home: "native_setup_unsafe_home", + unavailable: "native_setup_unavailable", + unsupported_host: "unsupported_host", +}; +const GUIDANCE: Readonly> = { + not_set_up: "Native runtime is not set up; run hack native setup.", + partial_resumable: + "Native runtime setup is incomplete; run hack native setup to resume.", + ready: "Native runtime is set up.", + invalid_preserved: + "Native runtime files are incomplete or invalid; preserve this home and select a fresh private HACK_NATIVE_HOME with the bundle's hack-v5 launcher before running hack native setup.", + pin_mismatch: + "Native runtime pins differ from this release; preserve this home for its matching release and select a fresh private HACK_NATIVE_HOME with the bundle's hack-v5 launcher.", + unsafe_home: + "Native runtime home is unsafe; select an owned mode-0700 home without symlink components using HACK_NATIVE_HOME.", + unavailable: + "Native runtime installation could not be inspected; check the selected home's access and retry.", + unsupported_host: "Native runtime requires Apple Silicon macOS.", +}; + +function state(value: unknown): NativeSetupState | undefined { + return STATES.find((candidate) => candidate === value); +} + +/** Reject incompatible runtimes without exposing unvalidated subprocess text. */ +export function parseNativeSetupInspection( + value: unknown +): NativeSetupInspection { + if ( + !isRecord(value) || + value.protocol_version !== 1 || + !state(value.state) || + !state(value.installation_state) || + typeof value.supported_host !== "boolean" || + typeof value.setup_only !== "boolean" || + !Array.isArray(value.pins) || + value.pins.length === 0 || + value.pins.length > 16 + ) { + throw new Error("Unsupported native setup inspection protocol."); + } + const pins: NativeSetupPin[] = []; + for (const pin of value.pins) { + if ( + !isRecord(pin) || + typeof pin.url !== "string" || + !Number.isSafeInteger(pin.size_bytes) || + typeof pin.size_bytes !== "number" || + pin.size_bytes <= 0 || + typeof pin.archive_sha256 !== "string" || + !HASH.test(pin.archive_sha256) + ) { + throw new Error("Invalid native setup pin."); + } + const url = new URL(pin.url); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error("Invalid native setup source."); + } + pins.push({ + url: pin.url, + size_bytes: pin.size_bytes, + archive_sha256: pin.archive_sha256, + }); + } + const effectiveState = state(value.state); + const installationState = state(value.installation_state); + if ( + !(effectiveState && installationState) || + installationState === "unsupported_host" || + value.code !== CODES[effectiveState] || + !( + value.supported_host || + ["unsupported_host", "unsafe_home", "unavailable"].includes( + effectiveState + ) + ) || + (effectiveState === "ready" && + (!value.supported_host || installationState !== "ready")) + ) { + throw new Error("Inconsistent native setup inspection."); + } + return { + protocol_version: 1, + state: effectiveState, + code: CODES[effectiveState], + installation_state: installationState, + supported_host: value.supported_host, + setup_only: value.setup_only, + pins, + }; +} + +/** Shared read-only source of truth for up, doctor and upgrade diagnostics. */ +export async function inspectNativeSetup(opts: { + readonly runtime: NativeRuntimeSelection; + readonly cwd: string; + readonly invoke?: typeof invokeNativeRuntime; +}): Promise { + return parseNativeSetupInspection( + await (opts.invoke ?? invokeNativeRuntime)({ + runtime: opts.runtime, + cwd: opts.cwd, + args: ["runtime", "setup", "inspect", "--json"], + timeoutMs: 30_000, + }) + ); +} + +export function nativeSetupGuidance( + inspection: Pick +): string { + return GUIDANCE[inspection.state]; +} + +export function nativeSetupCanResume( + inspection: Pick +): boolean { + return ( + inspection.state === "not_set_up" || + inspection.state === "partial_resumable" + ); +} + +function refuse(inspection: Pick): never { + throw new HackCliError({ + code: nativeSetupCanResume(inspection) + ? "E_NATIVE_SETUP_REQUIRED" + : "E_STARTUP_INCOMPLETE", + message: nativeSetupGuidance(inspection), + detail: { + runtime_setup: inspection.state, + native_code: CODES[inspection.state], + }, + }); +} + +/** Pin previews contain compiled public metadata only; no project env is read. */ +export function previewNativeSetup(opts: { + readonly inspection: NativeSetupInspection; + readonly runtime: NativeRuntimeSelection; +}): void { + process.stderr.write( + `Native runtime home: ${opts.runtime.home}\nPinned downloads (verified before preparation):\n` + ); + for (const pin of opts.inspection.pins) { + const url = new URL(pin.url); + process.stderr.write( + ` ${basename(url.pathname)}: ${pin.size_bytes} bytes; source ${url.hostname}; SHA-256 ${pin.archive_sha256}\n` + ); + } + process.stderr.write( + `Private download cache: ${join(opts.runtime.home, ".hack-local/setup/downloads")}\nInstall destination: ${join(opts.runtime.home, ".hack-local/providers")}\n` + ); +} + +/** No request timeout: acquisition is resumable and may take longer than startup. + * The owned child shares the foreground group and receives forwarded signals; + * JSON stdout and stderr are inherited; text mode ignores the JSON body and + * renders a fixed summary only after successful completion. + */ +export async function runNativeSetupChild(opts: { + readonly runtime: NativeRuntimeSelection; + readonly cwd: string; + readonly json: boolean; + readonly acceptDownloads: boolean; + readonly offline?: readonly string[]; +}): Promise { + const env: Record = {}; + for (const key of ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"]) { + env[key] = process.env[key]; + } + env.HACK_NO_INTERACTIVE = "1"; + env.HACK_NATIVE_HOME = opts.runtime.home; + const child = Bun.spawn( + [ + opts.runtime.binary, + "--candidate-root", + opts.runtime.home, + "runtime", + "setup", + "--no-interactive", + ...(opts.json ? ["--json"] : []), + ...(opts.offline ?? (opts.acceptDownloads ? ["--accept-downloads"] : [])), + ], + { + cwd: opts.cwd, + env: childEnvironment(env), + stdin: "ignore", + stdout: opts.json ? "inherit" : "ignore", + stderr: "inherit", + } + ); + let canceledExit: number | undefined; + const interrupt = () => { + canceledExit = 130; + child.kill("SIGINT"); + }; + const terminate = () => { + canceledExit = 143; + child.kill("SIGTERM"); + }; + process.on("SIGINT", interrupt); + process.on("SIGTERM", terminate); + try { + const exit = await child.exited; + const result = + canceledExit ?? + (child.signalCode + ? 128 + (osConstants.signals[child.signalCode] ?? 1) + : exit); + if (result === 0 && !opts.json) { + process.stdout.write("Native runtime is set up.\n"); + } + return result; + } finally { + process.off("SIGINT", interrupt); + process.off("SIGTERM", terminate); + } +} + +/** Before any startup effect, offer one consent and re-inspect after setup. */ +export async function requireNativeSetup(opts: { + readonly runtime: NativeRuntimeSelection; + readonly cwd: string; + readonly json: boolean; +}): Promise { + let inspection: NativeSetupInspection; + try { + inspection = await inspectNativeSetup(opts); + } catch { + refuse({ state: "unavailable" }); + } + if (inspection.state === "ready") { + return 0; + } + if (!nativeSetupCanResume(inspection) || opts.json || !canPrompt()) { + refuse(inspection); + } + previewNativeSetup({ ...opts, inspection }); + const accepted = await confirmSafe({ + message: "Download and prepare these pinned files, then continue hack up?", + initialValue: false, + nonInteractive: "decline", + }); + if (!accepted) { + refuse(inspection); + } + const exit = await runNativeSetupChild({ + ...opts, + json: false, + acceptDownloads: true, + }); + if (exit !== 0) { + process.stderr.write( + "Native runtime setup did not complete; run hack native setup to resume.\n" + ); + return exit; + } + const installed = await inspectNativeSetup(opts); + if (installed.state !== "ready") { + refuse(installed); + } + return 0; +} diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 063875158..2f5e3ff95 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -15,6 +15,7 @@ import { helpCommand } from "../commands/help.ts"; import { internalCommand } from "../commands/internal.ts"; import { logPipeCommand } from "../commands/log-pipe.ts"; import { mcpCommand } from "../commands/mcp.ts"; +import { nativeCommand } from "../commands/native.ts"; import { nodeCommand } from "../commands/node.ts"; import { downCommand, @@ -96,6 +97,7 @@ export const CLI_SPEC = defineCli({ configCommand, mcpCommand, setupCommand, + nativeCommand, sessionCommand, sshCommand, agentCommand, diff --git a/src/commands/native.ts b/src/commands/native.ts new file mode 100644 index 000000000..c74f568d5 --- /dev/null +++ b/src/commands/native.ts @@ -0,0 +1,144 @@ +import { resolveNativeSetupSelection } from "../backends/native-runtime-client.ts"; +import { + inspectNativeSetup, + nativeSetupCanResume, + nativeSetupGuidance, + previewNativeSetup, + runNativeSetupChild, +} from "../backends/native-runtime-setup.ts"; +import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; +import { optJson } from "../cli/options.ts"; +import { HackCliError } from "../lib/cli-result.ts"; +import { confirmSafe } from "../lib/interactivity.ts"; + +const setupSpec = defineCommand({ + name: "setup", + summary: "Download and prepare the pinned native runtime for this home", + group: "Global", + description: + "Verifies and resumes pinned downloads without starting a VM. Requires Apple Silicon macOS. Non-interactive downloads need --accept-downloads. --json passes the runtime result through unchanged.", + options: [ + optJson, + defineOption({ + name: "acceptDownloads", + type: "boolean", + long: "--accept-downloads", + description: "Consent to the displayed pinned downloads", + } as const), + defineOption({ + name: "archive", + type: "string", + long: "--archive", + valueHint: "", + description: + "Offline pinned provider archive (requires the complete offline input set)", + } as const), + defineOption({ + name: "engineArchive", + type: "string", + long: "--engine-archive", + valueHint: "", + description: "Offline pinned engine archive", + } as const), + defineOption({ + name: "networkToolsDirectory", + type: "string", + long: "--network-tools-directory", + valueHint: "", + description: + "Private directory containing all pinned networking packages", + } as const), + ], + positionals: [], + subcommands: [], +} as const); + +const setup = withHandler(setupSpec, async ({ ctx, args }) => { + const { archive, engineArchive, networkToolsDirectory } = args.options; + const hasOffline = [archive, engineArchive, networkToolsDirectory].some( + (value) => value !== undefined + ); + if ( + hasOffline && + !( + archive && + engineArchive && + networkToolsDirectory && + !args.options.acceptDownloads + ) + ) { + throw new HackCliError({ + code: "E_USAGE", + message: + "Offline setup needs --archive, --engine-archive and --network-tools-directory together, without --accept-downloads.", + }); + } + const runtime = resolveNativeSetupSelection(); + const inspection = await inspectNativeSetup({ runtime, cwd: ctx.cwd }); + if (!(inspection.state === "ready" || nativeSetupCanResume(inspection))) { + throw new HackCliError({ + code: "E_STARTUP_INCOMPLETE", + message: nativeSetupGuidance(inspection), + detail: { runtime_setup: inspection.state, native_code: inspection.code }, + }); + } + if (inspection.state !== "ready") { + previewNativeSetup({ runtime, inspection }); + } + let accepted = args.options.acceptDownloads === true; + if ( + !hasOffline && + inspection.state !== "ready" && + !args.options.acceptDownloads + ) { + // JSON never prompts: its stdout must remain the runtime's JSON alone. + if (args.options.json) { + throw new HackCliError({ + code: "E_INTERACTIVE_REQUIRED", + message: + "Native setup needs download consent; pass --accept-downloads.", + }); + } + accepted = await confirmSafe({ + message: "Download and prepare these pinned files?", + initialValue: false, + nonInteractive: "fail", + hint: "Pass --accept-downloads.", + }); + if (!accepted) { + process.stderr.write( + "Pinned downloads declined; no files downloaded or installed.\n" + ); + return 1; + } + } + const offline = + archive && engineArchive && networkToolsDirectory + ? [ + "--archive", + archive, + "--engine-archive", + engineArchive, + "--network-tools-directory", + networkToolsDirectory, + ] + : undefined; + return await runNativeSetupChild({ + runtime, + cwd: ctx.cwd, + json: args.options.json === true, + acceptDownloads: accepted, + offline, + }); +}); + +/** Explicit native operations never change the project's Compose backend. */ +export const nativeCommand = defineCommand({ + name: "native", + summary: "Set up the opt-in native runtime", + group: "Global", + options: [], + positionals: [], + subcommands: [setup], + expandInRootHelp: true, +} as const); diff --git a/src/commands/project.ts b/src/commands/project.ts index bb288ed57..31cfb51db 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -90,6 +90,7 @@ import { invokeNativeRuntime, resolveNativeRuntimeSelection, } from "../backends/native-runtime-client.ts"; +import { requireNativeSetup } from "../backends/native-runtime-setup.ts"; import type { NativeServiceExit } from "../backends/native-service-exit-monitor.ts"; import { composeRuntimeBackend } from "../backends/runtime-backend.ts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; @@ -6353,6 +6354,23 @@ async function handleUp({ noteNativeBackendBeta({ json: args.options.json === true }); } const native = resolveNativeRuntimeSelection(); + if (native) { + refuseUnsupportedNativeUpOptions({ + restart: false, + target: args.options.target, + services: args.positionals.services, + }); + const setupExit = await requireNativeSetup({ + runtime: native, + cwd: ctx.cwd, + json: args.options.json === true, + }); + if (setupExit !== 0) { + return setupExit; + } + // Detached children and setup continuations use exactly the selected home. + process.env.HACK_NATIVE_HOME = native.home; + } if ((args.options.detach || args.options.json) && native) { refuseUnsupportedNativeUpOptions({ restart: false, diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 099e20fb7..3d962cc39 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -28,6 +28,7 @@ export const HACK_ERROR_CODES = [ "E_COMPOSE_FAILED", "E_COMPOSE_OUTPUT_INVALID", "E_STARTUP_INCOMPLETE", + "E_NATIVE_SETUP_REQUIRED", "E_DEPENDENCY_BOOTSTRAP_FAILED", "E_STARTUP_TIMEOUT", "E_CANCELLED", diff --git a/tests/helpers/native-setup-inspection.ts b/tests/helpers/native-setup-inspection.ts new file mode 100644 index 000000000..74dcc2561 --- /dev/null +++ b/tests/helpers/native-setup-inspection.ts @@ -0,0 +1,42 @@ +/** Protocol fixture only; this does not qualify native artifacts or a guest. */ +export function nativeSetupInspection(state = "ready") { + return { + protocol_version: 1, + state, + code: + state === "not_set_up" || state === "partial_resumable" + ? "E_NATIVE_SETUP_REQUIRED" + : state === "unsupported_host" + ? "unsupported_host" + : state === "invalid_preserved" + ? "native_setup_invalid" + : `native_setup_${state}`, + installation_state: state, + supported_host: true, + setup_only: true, + pins: [ + { + url: "https://example.invalid/provider.tgz", + size_bytes: 5, + archive_sha256: "a".repeat(64), + }, + ], + }; +} + +/** Keep observation outside older fixtures' effect tripwires. First-use tests + * separately assert the complete inspection request and its ordering. + */ +export function nativeSetupShellReply(): string { + return `if [ "$#" = 6 ] && [ "$3" = runtime ] && [ "$4" = setup ] && [ "$5" = inspect ] && [ "$6" = --json ]; then + printf '%s\\n' '${JSON.stringify(nativeSetupInspection())}' + exit 0 +fi`; +} + +export function nativeSetupScriptReply(): string { + return `if (process.argv.length === 8 && process.argv.slice(4).join(" ") === "runtime setup inspect --json") { + console.log(${JSON.stringify(JSON.stringify(nativeSetupInspection()))}); + process.exit(0); +}`; +} diff --git a/tests/native-authored-command-dispatch.test.ts b/tests/native-authored-command-dispatch.test.ts index 753455da9..ba2c4aa60 100644 --- a/tests/native-authored-command-dispatch.test.ts +++ b/tests/native-authored-command-dispatch.test.ts @@ -8,6 +8,7 @@ import { NATIVE_RUN_EMPTY_SELECTION_REFUSAL, } from "../src/backends/native-project-one-off.ts"; import { isRecord } from "../src/lib/guards.ts"; +import { nativeSetupScriptReply } from "./helpers/native-setup-inspection.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; import { requireTestConfigCompiler } from "./helpers/test-config-compiler.ts"; @@ -34,6 +35,7 @@ async function fixture() { await Bun.write( native, `#!${process.execPath} +${nativeSetupScriptReply()} await Bun.write(${JSON.stringify(join(root, "runtime-called"))}, "unexpected-runtime-call"); process.exit(2); ` @@ -496,6 +498,7 @@ macTest.each([ await Bun.write( selected.native, `#!${process.execPath} +${nativeSetupScriptReply()} const args=process.argv.slice(2); if(args.length!==8||args[0]!=='--candidate-root'||args[2]!=='graph'||args[3]!=='native'||args[4]!=='plan'||args[5]!=='--source-file'||args[7]!=='--json') { await Bun.write(${JSON.stringify(join(selected.root, "runtime-called"))}, 'unexpected-effect'); process.exit(2); @@ -585,6 +588,7 @@ macTest( await Bun.write( selected.native, `#!${process.execPath} +${nativeSetupScriptReply()} const args=process.argv.slice(2); if(args[2]!=='graph'||args[3]!=='native'||args[4]!=='plan'){ await Bun.write(${JSON.stringify(join(selected.root, "runtime-called"))}, 'unexpected-effect'); process.exit(2); diff --git a/tests/native-beta-label.test.ts b/tests/native-beta-label.test.ts index 42f836903..784b51020 100644 --- a/tests/native-beta-label.test.ts +++ b/tests/native-beta-label.test.ts @@ -92,7 +92,7 @@ test("native selection without a candidate says where the native backend comes f const message = String((refusal as Error).message); // The contract the candidate launcher relies on stays the first sentence. expect(message).toStartWith( - "Native runtime requires absolute HACK_NATIVE_BINARY and HACK_NATIVE_HOME paths." + "Native runtime requires an adjacent bundled hack-native or an absolute HACK_NATIVE_BINARY path." ); expect(message).toContain("beta"); expect(message).toContain("Apple Silicon macOS"); diff --git a/tests/native-candidate-launcher.test.ts b/tests/native-candidate-launcher.test.ts index 80dba79b8..34e64bfd8 100644 --- a/tests/native-candidate-launcher.test.ts +++ b/tests/native-candidate-launcher.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { chmod, copyFile, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { resolveNativeHome } from "../src/backends/native-runtime-client.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -138,14 +139,47 @@ test.each([ ); }); -test("native selection refuses without an absolute private home", async () => { - for (const home of [undefined, "", "relative"]) { +test("native selection refuses a relative private home", async () => { + for (const home of ["relative"]) { const refused = await launch({ HACK_NATIVE_HOME: home }, ["up"]); expect(refused.exitCode).toBe(64); expect(refused.stdout).toBe(""); } }); +test("shell and TypeScript select the same default private home without creating it", async () => { + for (const selected of [ + { HACK_NATIVE_HOME: undefined, HACK_HOME: `${root}/isolated` }, + { HACK_NATIVE_HOME: " ", HACK_HOME: " " }, + { HACK_NATIVE_HOME: ` ${root}/explicit `, HACK_HOME: "relative" }, + ]) { + const reply = await launch(selected, ["native", "setup"]); + expect(reply.exitCode).toBe(7); + expect(reply.stdout.split("\n")[2]).toBe( + resolveNativeHome(launcherEnv(selected)) + ); + } +}); + +test.each([ + { prefix: [] }, + { prefix: ["--no-interactive"] }, +])("explicit native setup keeps the native home in a Compose shell ($prefix)", async ({ + prefix, +}) => { + const result = await launch({ HACK_RUNTIME_BACKEND: "compose" }, [ + ...prefix, + "native", + "setup", + ]); + expect(result.exitCode).toBe(7); + expect(result.stdout.split("\n").slice(0, 3)).toEqual([ + "native", + `${root}/hack-native`, + `${root}/private home`, + ]); +}); + test.each([ undefined, "compose", diff --git a/tests/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index c83ee430c..0a1de7bb7 100644 --- a/tests/native-compose-backend-features-cli.test.ts +++ b/tests/native-compose-backend-features-cli.test.ts @@ -16,6 +16,7 @@ import { NATIVE_CONFIGURATION_REFUSAL } from "../src/backends/native-project-sta import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; import { foreignComposeNetworkNames } from "../src/lib/native-hack-compose-conventions.ts"; import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; +import { nativeSetupShellReply } from "./helpers/native-setup-inspection.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -78,6 +79,7 @@ async function fixture(compose: Record) { await writeFile( binary, `#!/bin/sh +${nativeSetupShellReply()} printf '%s\\n' "$*" >> '${stub}/calls' if [ -f '${stub}/'"$3-$4"'.out' ]; then cat '${stub}/'"$3-$4"'.out'; exit 0; fi printf '%s' '{"code":"runtime_not_running"}' >&2 diff --git a/tests/native-preflight-cli.test.ts b/tests/native-preflight-cli.test.ts index 44dd53fff..829b804f3 100644 --- a/tests/native-preflight-cli.test.ts +++ b/tests/native-preflight-cli.test.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import type { NativePreflightReport } from "../src/backends/native-preflight.ts"; +import { nativeSetupInspection } from "./helpers/native-setup-inspection.ts"; import { nativeSourceSizeReceipt } from "./helpers/native-source-size.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -87,6 +88,7 @@ exit 1 ); const write = async (name: string, value: unknown) => await writeFile(join(stub, name), JSON.stringify(value)); + await write("runtime-setup.out", nativeSetupInspection()); return { root, project, @@ -319,8 +321,12 @@ test( // Authored values and the runtime's own text never reach the output. expect(output).not.toContain(PRIVATE); expect(output).not.toContain("runtime text"); - // The only runtime requests are the two reviews: nothing is started. - expect(await f.calls()).toEqual(["project plan", "project plan"]); + // Installation inspection and the two project reviews start nothing. + expect(await f.calls()).toEqual([ + "runtime setup", + "project plan", + "project plan", + ]); }, SPAWN_TIMEOUT_MS ); @@ -364,6 +370,7 @@ test( expect(human.stdout).toBe( [ "Native preflight for .hack/docker-compose.yml", + "Runtime setup: ready. Native runtime is set up.", "Runtime review: completed.", "", "No blockers. Native up should admit this project.", @@ -556,7 +563,10 @@ test( expect(up.output).toContain( "Run `hack doctor --native-preflight` to list every blocker with its fix." ); - expect(await f.calls()).toEqual(["runtime check-project-share"]); + expect(await f.calls()).toEqual([ + "runtime setup", + "runtime check-project-share", + ]); }, SPAWN_TIMEOUT_MS ); @@ -641,7 +651,8 @@ test( expect((await f.normalized()).networks?.shared).toMatchObject({ "x-hack-isolated": true, }); - const [original, normalized] = await f.argv(); + const [setup, original, normalized] = await f.argv(); + expect(setup).toContain("runtime setup inspect --json"); expect(original).toContain("project plan --project "); expect(original).toContain("--profile dev --profile ops --json"); expect(original).not.toContain("--normalized-file"); @@ -686,7 +697,7 @@ test( code: "native_adaptation_invalid", }); expect(codes(invalidAdaptation.report, "runtime")).toEqual([]); - expect(await adaptation.calls()).toEqual([]); + expect(await adaptation.calls()).toEqual(["runtime setup"]); const config = await fixture({ services: { web: { image: IMAGE } } }); await writeFile(join(config.projectDir, "hack.config.json"), "{ not json"); @@ -1210,7 +1221,8 @@ test("preflight reports the Compose budget before sending context for more than result.report.blockers.some((entry) => entry.code === "service_budget") ).toBe(true); const args = await f.argv(); - expect(args).toHaveLength(1); + expect(args).toHaveLength(2); + expect(args[0]).toContain("runtime setup inspect --json"); expect(args.join("\n")).not.toContain("--start-managed-service"); expect(args.join("\n")).not.toContain(PRIVATE); }); diff --git a/tests/native-project-branch-cli.test.ts b/tests/native-project-branch-cli.test.ts index 3e61a26e2..ed994125d 100644 --- a/tests/native-project-branch-cli.test.ts +++ b/tests/native-project-branch-cli.test.ts @@ -15,6 +15,7 @@ import { saveNativeProjectRun, } from "../src/backends/native-project-run.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; +import { nativeSetupShellReply } from "./helpers/native-setup-inspection.ts"; import { nativeSourceSizeReceipt } from "./helpers/native-source-size.ts"; // Each case spawns the source CLI (bounded at 10 s below) one or more times; a cold @@ -74,6 +75,7 @@ async function fixture(approveSource = false) { await writeFile( binary, `#!/bin/sh +${nativeSetupShellReply()} if [ "$4" = "check-project-share" ]; then touch "$HOME/preflight-ran" ${approveSource ? `printf '%s\\n' '{"source_admitted":true,"pool_initialized":false}'\n exit 0` : "exit 71"} diff --git a/tests/native-project-settings-cli.test.ts b/tests/native-project-settings-cli.test.ts index 29c4f83fc..e61f884a8 100644 --- a/tests/native-project-settings-cli.test.ts +++ b/tests/native-project-settings-cli.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { NATIVE_SHARED_SOURCE_REFUSAL } from "../src/backends/native-project-start.ts"; +import { nativeSetupShellReply } from "./helpers/native-setup-inspection.ts"; import { nativeSourceSizeReceipt } from "./helpers/native-source-size.ts"; import { privateCliEnv, privateCliHome } from "./helpers/private-cli-env.ts"; @@ -74,6 +75,7 @@ async function fixture(compose: Record) { await writeFile( binary, `#!/bin/sh +${nativeSetupShellReply()} printf '%s\\n' "$*" >> '${stub}/calls' if [ -f '${stub}/'"$3-$4"'.out' ]; then cat '${stub}/'"$3-$4"'.out'; exit 0; fi printf '%s' '{"code":"runtime_not_running"}' >&2 diff --git a/tests/native-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts index 1cf160beb..56a2f9b69 100644 --- a/tests/native-refusal-cli.test.ts +++ b/tests/native-refusal-cli.test.ts @@ -20,6 +20,7 @@ import { } from "../src/backends/native-project-run.ts"; import { NATIVE_PROVIDER_PIN_MESSAGE } from "../src/backends/native-runtime-client.ts"; import { installNext13Frontend } from "./helpers/native-next13-state.ts"; +import { nativeSetupShellReply } from "./helpers/native-setup-inspection.ts"; import { nativeSourceSizeReceipt } from "./helpers/native-source-size.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -90,6 +91,7 @@ async function fixture(compose = COMPOSE) { await writeFile( binary, `#!/bin/sh +${nativeSetupShellReply()} printf '%s\\n' "$*" >> '${stub}/calls' if [ -f '${stub}/'"$3-$4"'.out' ]; then if [ -f '${stub}/'"$3-$4"'.after-inspect' ]; then cp '${stub}/'"$3-$4"'.after-inspect' '${stub}/graph-inspect.out'; fi diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 61ed069ce..c999d7d13 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -212,7 +212,7 @@ test("native selection is explicit and requires an isolated absolute home", () = } expect(refusal).toBeInstanceOf(ClosedCliError); expect(String(refusal)).toContain( - "Native runtime requires absolute HACK_NATIVE_BINARY and HACK_NATIVE_HOME paths." + "Native runtime requires an adjacent bundled hack-native or an absolute HACK_NATIVE_BINARY path." ); expect( resolveNativeRuntimeSelection({ diff --git a/tests/native-runtime-setup.test.ts b/tests/native-runtime-setup.test.ts new file mode 100644 index 000000000..327c3b7f1 --- /dev/null +++ b/tests/native-runtime-setup.test.ts @@ -0,0 +1,298 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { resolveNativeHome } from "../src/backends/native-runtime-client.ts"; +import { parseNativeSetupInspection } from "../src/backends/native-runtime-setup.ts"; +import { nativeSetupInspection } from "./helpers/native-setup-inspection.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const roots: string[] = []; +const entrypoint = resolve("index.ts"); +const SENTINEL = "synthetic-setup-project-value-must-not-be-in-child"; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-first-use-")) + ); + roots.push(root); + const home = join(root, "native-home"); + const binary = join(root, "native-executor"); + const calls = join(root, "calls"); + const result = '{"status":"installed","files":[]}\n'; + await writeFile( + binary, + `#!${process.execPath} +import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +const args = Bun.argv.slice(2); +const home = args[1]; +appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args.slice(2)) + "\\n"); +if (args.includes("inspect")) { + const reply = ${JSON.stringify(nativeSetupInspection())}; + reply.state = reply.installation_state = existsSync(join(home, "installed")) ? "ready" : existsSync(home) ? "partial_resumable" : "not_set_up"; + reply.code = reply.state === "ready" ? "native_setup_ready" : "E_NATIVE_SETUP_REQUIRED"; + console.log(JSON.stringify(reply)); +} else if (args[2] === "runtime" && args[3] === "setup") { + if (process.env.SETUP_PROJECT_SENTINEL || process.env.HACK_ENV_SECRET_KEY) process.exit(91); + mkdirSync(home, { mode: 0o700, recursive: true }); + if (existsSync(${JSON.stringify(join(root, "interrupt-setup"))})) { + mkdirSync(join(home, ".hack-local/setup/downloads"), { mode: 0o700, recursive: true }); + writeFileSync(join(home, ".hack-local/setup/downloads/provider.part"), "prefix"); + process.stderr.write("fixture-download-started\\n"); + await Bun.sleep(60_000); + process.exit(93); + } + if (existsSync(${JSON.stringify(join(root, "child-signal"))})) { process.kill(process.pid, "SIGTERM"); await Bun.sleep(60_000); } + writeFileSync(join(home, "installed"), "fixture"); + process.stdout.write(${JSON.stringify(result)}); +} else { process.exit(92); } +` + ); + await chmod(binary, 0o700); + const env = privateCliEnv({ + root, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: binary, + HACK_NATIVE_HOME: home, + SETUP_PROJECT_SENTINEL: SENTINEL, + }, + }); + async function cli(args: string[]) { + const child = Bun.spawn([process.execPath, entrypoint, ...args], { + cwd: root, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(stdout + stderr).not.toContain(SENTINEL); + return { stdout, stderr, exitCode }; + } + return { root, home, calls, cli, result, env }; +} + +test("explicit and isolated default native homes are deterministic without creating directories", () => { + expect(resolveNativeHome({ HOME: "/user" })).toBe( + "/user/.hack/native-runtime" + ); + expect(resolveNativeHome({ HOME: "/user", HACK_HOME: " /isolated " })).toBe( + "/isolated/native-runtime" + ); + expect( + resolveNativeHome({ + HACK_NATIVE_HOME: " /explicit ", + HACK_HOME: "relative", + }) + ).toBe("/explicit"); + expect(() => resolveNativeHome({ HACK_NATIVE_HOME: "relative" })).toThrow( + "absolute" + ); + expect(() => resolveNativeHome({ HACK_HOME: "relative" })).toThrow( + "absolute" + ); +}); + +test("setup inspection rejects old, unknown or contradictory readiness replies", () => { + expect(parseNativeSetupInspection(nativeSetupInspection()).state).toBe( + "ready" + ); + for (const value of [ + {}, + { ...nativeSetupInspection(), protocol_version: 2 }, + { ...nativeSetupInspection(), state: "future" }, + { ...nativeSetupInspection(), supported_host: false }, + { + ...nativeSetupInspection(), + pins: [ + { + url: "https://user:secret@example.invalid/file", + size_bytes: 5, + archive_sha256: "a".repeat(64), + }, + ], + }, + ]) { + expect(() => parseNativeSetupInspection(value)).toThrow(); + } +}); + +test("fresh foreground/detached/JSON up refuses before startup with the setup sentence", async () => { + const f = await fixture(); + for (const options of [[], ["-d"], ["--json"]]) { + const reply = await f.cli(["up", ...options]); + expect(reply.exitCode).toBe(1); + expect(reply.stdout + reply.stderr).toContain( + "Native runtime is not set up; run hack native setup." + ); + expect(reply.stdout + reply.stderr).not.toContain("provider_state"); + if (options.includes("--json")) { + expect(JSON.parse(reply.stdout).error.code).toBe( + "E_NATIVE_SETUP_REQUIRED" + ); + } + expect(existsSync(f.home)).toBe(false); + } + const calls = (await readFile(f.calls, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls).toEqual( + new Array(3).fill(["runtime", "setup", "inspect", "--json"]) + ); +}, 90_000); + +test("noninteractive setup requires consent; accepted JSON passes through and up passes the gate", async () => { + const f = await fixture(); + const refused = await f.cli(["native", "setup", "--json"]); + expect(refused.exitCode).toBe(1); + expect(JSON.parse(refused.stdout).error.code).toBe("E_INTERACTIVE_REQUIRED"); + expect(existsSync(f.home)).toBe(false); + const installed = await f.cli([ + "native", + "setup", + "--accept-downloads", + "--json", + ]); + expect(installed.exitCode).toBe(0); + expect(installed.stdout).toBe(f.result); + expect(installed.stderr).toContain("5 bytes"); + expect(installed.stderr).toContain("a".repeat(64)); + const up = await f.cli(["up"]); + expect(up.exitCode).toBe(1); + expect(up.stdout + up.stderr).not.toContain("E_NATIVE_SETUP_REQUIRED"); + // No project exists: reaching project resolution proves the install gate passed. + expect(up.stdout + up.stderr).toContain("E_PROJECT_NOT_FOUND"); +}, 90_000); + +test("preflight JSON reports missing runtime as its own blocker", async () => { + const f = await fixture(); + const projectDir = join(f.root, ".hack"); + await mkdir(projectDir); + await writeFile( + join(projectDir, "docker-compose.yml"), + "services:\n web:\n image: alpine:3\n" + ); + await writeFile(join(projectDir, "hack.config.json"), '{"name":"first-use"}'); + const report = await f.cli(["doctor", "--native-preflight", "--json"]); + expect(report.exitCode).toBe(1); + const data = JSON.parse(report.stdout).data; + expect(data.ready).toBe(false); + expect(data.runtime_setup.state).toBe("not_set_up"); + expect(data.runtime_setup.code).toBe("E_NATIVE_SETUP_REQUIRED"); + expect(data.runtime_setup.fix).toContain("hack native setup"); + expect( + data.blockers.some( + (found: { code: string }) => found.code === "native_runtime_setup" + ) + ).toBe(true); +}, 90_000); + +test("interactive up asks once, forwards interruption and retains resumable inputs without starting", async () => { + const f = await fixture(); + await writeFile(join(f.root, "interrupt-setup"), "interrupt this fixture"); + let output = ""; + let answered = false; + let interrupted = false; + let cancel: (() => void) | undefined; + const terminal = new Bun.Terminal({ + cols: 120, + rows: 40, + data(tty, bytes) { + output += new TextDecoder().decode(bytes); + if (!answered && output.includes("then continue hack up?")) { + answered = true; + tty.write("y\r"); + } + if (!interrupted && output.includes("fixture-download-started")) { + interrupted = true; + cancel?.(); + } + }, + }); + try { + const child = Bun.spawn([process.execPath, entrypoint, "up"], { + cwd: f.root, + env: { ...f.env, HACK_NO_INTERACTIVE: "0" }, + terminal, + timeout: 30_000, + }); + cancel = () => child.kill("SIGINT"); + expect(await child.exited).toBe(130); + expect(answered && interrupted).toBe(true); + const calls = (await readFile(f.calls, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls).toEqual([ + ["runtime", "setup", "inspect", "--json"], + ["runtime", "setup", "--no-interactive", "--accept-downloads"], + ]); + expect( + await readFile( + join(f.home, ".hack-local/setup/downloads/provider.part"), + "utf8" + ) + ).toBe("prefix"); + expect(existsSync(join(f.home, "installed"))).toBe(false); + const preflight = await f.cli(["up", "--json"]); + expect(preflight.stdout).toContain("setup is incomplete"); + expect(preflight.stdout).not.toContain("provider_state"); + } finally { + terminal.close(); + } +}, 40_000); + +test("human setup prints a summary and a ready inspection does not grant download consent", async () => { + const f = await fixture(); + const installed = await f.cli(["native", "setup", "--accept-downloads"]); + expect(installed.exitCode).toBe(0); + expect(installed.stdout).toBe("Native runtime is set up.\n"); + const reused = await f.cli(["native", "setup", "--json"]); + expect(reused.exitCode).toBe(0); + expect(reused.stderr).toBe(""); + expect(reused.stdout).toBe(f.result); + const calls = (await readFile(f.calls, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls.at(-1)).toEqual([ + "runtime", + "setup", + "--no-interactive", + "--json", + ]); +}, 30_000); + +test("a signaled setup child propagates its signal status", async () => { + const f = await fixture(); + await writeFile(join(f.root, "child-signal"), "terminate this fixture child"); + const result = await f.cli([ + "native", + "setup", + "--accept-downloads", + "--json", + ]); + expect(result.exitCode).toBe(143); + expect(existsSync(join(f.home, "installed"))).toBe(false); +}, 30_000); diff --git a/tests/native-shell-environment-cli.test.ts b/tests/native-shell-environment-cli.test.ts index a838e9bdd..7568098cc 100644 --- a/tests/native-shell-environment-cli.test.ts +++ b/tests/native-shell-environment-cli.test.ts @@ -17,6 +17,7 @@ import { } from "../src/backends/native-project-input.ts"; import { nativeEnvironmentDelivery } from "../src/backends/native-project-start.ts"; import { resolveShellEnvironment } from "../src/backends/native-shell-environment.ts"; +import { nativeSetupShellReply } from "./helpers/native-setup-inspection.ts"; import { nativeSourceSizeReceipt } from "./helpers/native-source-size.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -90,6 +91,7 @@ async function fixture() { await writeFile( binary, `#!/bin/sh +${nativeSetupShellReply()} printf '%s\\n' "$*" >> '${stub}/calls' if [ -f '${stub}/'"$3-$4"'.out' ]; then cat '${stub}/'"$3-$4"'.out'; exit 0; fi printf '%s' '{"code":"runtime_not_running"}' >&2 diff --git a/tests/native-up-already-running-cli.test.ts b/tests/native-up-already-running-cli.test.ts index 55703b077..ade30c09c 100644 --- a/tests/native-up-already-running-cli.test.ts +++ b/tests/native-up-already-running-cli.test.ts @@ -17,6 +17,7 @@ import { classifyNativeAlreadyRunning, nativeScopedCommand, } from "../src/commands/native-up-already-running.ts"; +import { nativeSetupShellReply } from "./helpers/native-setup-inspection.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -94,6 +95,7 @@ async function fixture(opts: { await writeFile( binary, `#!/bin/sh +${nativeSetupShellReply()} printf '%s\\n' "$*" >> '${stub}/calls' if [ -f '${stub}/'"$3-$4"'.out' ]; then cat '${stub}/'"$3-$4"'.out'; exit 0; fi printf '%s' '{"code":"runtime_not_running"}' >&2 diff --git a/tests/python-free-installer.test.ts b/tests/python-free-installer.test.ts index 64e47e4da..97c9ea384 100644 --- a/tests/python-free-installer.test.ts +++ b/tests/python-free-installer.test.ts @@ -53,14 +53,19 @@ function scratch() { return root; } const revision = "a".repeat(40); -function fixtureArchive(root: string, version = "5.0.0-next.1") { +function fixtureArchive( + root: string, + version = "5.0.0-next.1", + nativeScript = "fixture-native", + launcherScript = "fixture-launcher" +) { const bundle = join(root, `bundle-${version}`); mkdirSync(bundle, { mode: 0o700 }); const files: Record = { - "hack-native": Buffer.from("fixture-native"), + "hack-native": Buffer.from(nativeScript), "hack-relay-guest": Buffer.from("fixture-guest"), "hack-cli": Buffer.from("fixture-cli"), - "hack-v5": Buffer.from("fixture-launcher"), + "hack-v5": Buffer.from(launcherScript), "provider-pins.json": Buffer.from("{}\n"), "README.md": Buffer.from("fixture\n"), "prerelease.json": jsonBytes({ @@ -116,6 +121,118 @@ async function installed() { return f; } +fixtureTest( + "managed launches refuse a custom home instead of hiding it from quiescence", + async () => { + const f = await installed(); + const previous = process.env.HACK_NATIVE_HOME; + try { + process.env.HACK_NATIVE_HOME = join(f.root, "custom"); + await expect(f.channel.run(["up"])).rejects.toThrow( + "receipt-owned native home" + ); + expect(() => f.channel.requireQuiescent("5.0.0-next.1")).not.toThrow(); + expect(f.channel.environment("5.0.0-next.1").HACK_NATIVE_HOME).toBe( + join(f.channel.root, "versions/5.0.0-next.1/native-home") + ); + // Compose retains its existing removal of native selectors. + expect( + f.channel.environment("5.0.0-next.1", "compose").HACK_NATIVE_HOME + ).toBeUndefined(); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(process.env, "HACK_NATIVE_HOME"); + } else { + process.env.HACK_NATIVE_HOME = previous; + } + } + } +); + +fixtureTest( + "uninitialized setup-only homes require the manager's quiescence inspection", + async () => { + const f = fixture({ quiescence: undefined }); + const calls = join(f.root, "native-calls"); + const native = `#!/bin/sh +if [ -n "$HACK_NATIVE_HOME" ]; then exit 90; fi +printf '%s\\n' "$*" >> '${calls}' +case "$4" in + status|down) printf '%s\\n' '{"phase":"uninitialized","process_alive":false}' ;; + setup) + if [ "$7" != --quiescence ]; then exit 91; fi + if [ -f "$2/.hack-local/run/smolvm/owner.json" ]; then ready=false; else ready=true; fi + printf '{"protocol_version":1,"setup_only":%s}\\n' "$ready" + ;; + *) exit 92 ;; +esac +`; + const input = fixtureArchive(f.root, "5.0.0-next.1", native); + await f.channel.install("5.0.0-next.1", input.archive, input.checksum); + const state = join( + f.channel.root, + "versions/5.0.0-next.1/native-home/.hack-local" + ); + mkdirSync(join(state, "setup/downloads"), { recursive: true, mode: 0o700 }); + const previous = process.env.HACK_NATIVE_HOME; + try { + process.env.HACK_NATIVE_HOME = join(f.root, "ambient-custom-home"); + f.channel.requireQuiescent("5.0.0-next.1"); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(process.env, "HACK_NATIVE_HOME"); + } else { + process.env.HACK_NATIVE_HOME = previous; + } + } + expect(readFileSync(calls, "utf8")).toContain( + "runtime setup inspect --json --quiescence" + ); + mkdirSync(join(state, "run/smolvm"), { recursive: true, mode: 0o700 }); + writeFileSync(join(state, "run/smolvm/owner.json"), "unknown", { + mode: 0o600, + }); + expect(() => f.channel.requireQuiescent("5.0.0-next.1")).toThrow( + "runtime or unknown state" + ); + } +); + +fixtureTest( + "explicit native setup selects the receipt home even when Compose is the ambient backend", + async () => { + const f = fixture(); + const selected = join(f.root, "selected-native-home"); + const launcher = `#!/bin/sh +printf '%s' "$HACK_NATIVE_HOME" > '${selected}' +exit 7 +`; + const input = fixtureArchive( + f.root, + "5.0.0-next.1", + "fixture-native", + launcher + ); + await f.channel.install("5.0.0-next.1", input.archive, input.checksum); + const previous = process.env.HACK_RUNTIME_BACKEND; + try { + process.env.HACK_RUNTIME_BACKEND = "compose"; + expect(await f.channel.run(["--no-interactive", "native", "setup"])).toBe( + 7 + ); + expect(readFileSync(selected, "utf8")).toBe( + join(f.channel.root, "versions/5.0.0-next.1/native-home") + ); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(process.env, "HACK_RUNTIME_BACKEND"); + } else { + process.env.HACK_RUNTIME_BACKEND = previous; + } + } + } +); + describe("standalone manager ownership and atomic state", () => { fixtureTest( "installs, upgrades, rolls back and deselects without touching stable bytes", From 031e154924c8fa571c2dda7b8d6656bcb3e1c7c2 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:44:05 +0000 Subject: [PATCH 2/7] test(native): prepare readiness in up wiring fixtures Answer the read-only setup inspection before mocked project startup, and expect the earlier closed refusal when the executor is unavailable. Preserve metadata, fingerprint, and no-effects assertions. --- tests/native-project-observe.test.ts | 4 ++-- tests/native-up-env-fingerprint-wiring.test.ts | 6 ++++++ tests/native-up-runtime-metadata-selection.test.ts | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/native-project-observe.test.ts b/tests/native-project-observe.test.ts index 859c0e3b0..6445715b3 100644 --- a/tests/native-project-observe.test.ts +++ b/tests/native-project-observe.test.ts @@ -561,7 +561,7 @@ exit 2 } }); -test("unsupported native forms and missing restart consent refuse before Docker or hooks", async () => { +test("native admission errors and missing restart consent refuse before Docker or hooks", async () => { const { writeFile, readFile, chmod } = await import("node:fs/promises"); const { resolve } = await import("node:path"); const opts = await fixture(false); @@ -617,7 +617,7 @@ test("unsupported native forms and missing restart consent refuse before Docker expect(code).not.toBe(0); expect(stdout + stderr).toContain( operation === "up" - ? "E_STARTUP_INCOMPLETE: Native detached startup was not confirmed." + ? "E_STARTUP_INCOMPLETE: Native runtime installation could not be inspected; check the selected home's access and retry." : operation === "restart" ? "E_PERMISSION: Native restart requires HACK_NATIVE_SHARED_SOURCE=1" : operation === "exec" diff --git a/tests/native-up-env-fingerprint-wiring.test.ts b/tests/native-up-env-fingerprint-wiring.test.ts index 2a767e972..2f7db53c9 100644 --- a/tests/native-up-env-fingerprint-wiring.test.ts +++ b/tests/native-up-env-fingerprint-wiring.test.ts @@ -23,6 +23,7 @@ import { saveNativeEnvFingerprint, } from "../src/lib/native-env-fingerprint-state.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { nativeSetupScriptReply } from "./helpers/native-setup-inspection.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; // HACK-1140 command wiring: `hack up` / `hack restart` on the native image @@ -176,6 +177,11 @@ async function nativeProject(): Promise<{ process.env.HACK_HOME = join(root, ".global-hack"); process.env.HACK_RUNTIME_BACKEND = "native"; process.env.HACK_NATIVE_BINARY = join(root, "hack-local"); + await writeFile( + process.env.HACK_NATIVE_BINARY, + `#!${process.execPath}\n${nativeSetupScriptReply()}\nprocess.exit(91);\n`, + { mode: 0o700 } + ); process.env.HACK_NATIVE_HOME = join(root, "native-home"); process.env.HACK_NATIVE_SHARED_SOURCE = "1"; return { diff --git a/tests/native-up-runtime-metadata-selection.test.ts b/tests/native-up-runtime-metadata-selection.test.ts index a053c22a3..6258da0b7 100644 --- a/tests/native-up-runtime-metadata-selection.test.ts +++ b/tests/native-up-runtime-metadata-selection.test.ts @@ -8,6 +8,7 @@ import { PROJECT_CONFIG_FILENAME, } from "../src/constants.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { nativeSetupScriptReply } from "./helpers/native-setup-inspection.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; // `hack up` on the native backend must hand startNativeProject the public runtime @@ -92,6 +93,11 @@ async function nativeProject(config: Record) { process.env.HACK_HOME = join(root, ".global-hack"); process.env.HACK_RUNTIME_BACKEND = "native"; process.env.HACK_NATIVE_BINARY = join(root, "hack-local"); + await writeFile( + process.env.HACK_NATIVE_BINARY, + `#!${process.execPath}\n${nativeSetupScriptReply()}\nprocess.exit(91);\n`, + { mode: 0o700 } + ); process.env.HACK_NATIVE_HOME = join(root, "native-home"); return root; } From 2ee4a95e5ed32551c4a74457c846814249e0c4eb Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:56:27 +0000 Subject: [PATCH 3/7] docs: fix native setup links in rendered v5 pages --- docs/versions/v5/native/setup.md | 14 +++++++++++++- docs/versions/v5/native/start.md | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/versions/v5/native/setup.md b/docs/versions/v5/native/setup.md index 8013c1005..6db2ddb2a 100644 --- a/docs/versions/v5/native/setup.md +++ b/docs/versions/v5/native/setup.md @@ -50,13 +50,25 @@ sizes and hashes and never falls back to downloading missing offline inputs. See the [complete setup contract](full-reference.md#manual-candidate-upgrade-and-rollback) for filenames and recovery details. +## Manual homes + +Manual bundle users run the adjacent `hack-v5 native setup`. Its default home is +`$HACK_HOME/native-runtime` or `~/.hack/native-runtime`; an absolute +`HACK_NATIVE_HOME` overrides that default. Managed `hack-next` instead keeps its +version's receipt-owned home and refuses a differing override. + +To move a manual home, stop its projects and runtime with the old home and +matching executor, preserve that home for retained data, then select a fresh +absolute `HACK_NATIVE_HOME` and run `hack-v5 native setup` again. Setup does not +move receipts, disks or project data. Keep each home with its matching executor. + ## Older setup-only homes If you ran next.16's `runtime setup` and never started a project, finish setup, run `up` then `down` with that same next.16 launcher, and confirm its runtime is `stopped` before upgrading. Older executors cannot prove setup-only quiescence; do not delete receipts to bypass an upgrade refusal. See the -[home and upgrade workaround](../../guides/candidate-install.md#prepare-the-native-runtime-once). +[upgrade and recovery reference](full-reference.md#manual-candidate-upgrade-and-rollback). The frontend command above is intended for next.17; installed next.16 retains the [older setup procedure](https://github.com/hack-dance/hack/blob/b21c3b189841c1a3cbb8af2b76414fa347dc7057/docs/native/try-it.md). diff --git a/docs/versions/v5/native/start.md b/docs/versions/v5/native/start.md index 4fa549e84..c55333ce6 100644 --- a/docs/versions/v5/native/start.md +++ b/docs/versions/v5/native/start.md @@ -59,7 +59,7 @@ Manual bundle users run the adjacent `hack-v5 native setup`. Their default home `$HACK_HOME/native-runtime` or `~/.hack/native-runtime`; an absolute `HACK_NATIVE_HOME` selects another home. Managed `hack-next` keeps its version's receipt-owned home and refuses a differing override. See -[home selection and moving homes](../../guides/candidate-install.md#prepare-the-native-runtime-once). +[home selection and moving homes](setup.md#manual-homes). ## Check before starting From c3a5e9e20541c5ba2e87abea4e350fd080c98016 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:01:29 +0000 Subject: [PATCH 4/7] test(installer): preserve rejection controls and settle cleanup fixture --- tests/python-free-download.test.ts | 40 ++++++++++++++------- tests/python-free-installed-manager.test.ts | 35 +++++++++++++----- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/tests/python-free-download.test.ts b/tests/python-free-download.test.ts index 76348709d..8a142725b 100644 --- a/tests/python-free-download.test.ts +++ b/tests/python-free-download.test.ts @@ -540,6 +540,7 @@ for (const scenario of ["success", "total", "stall", "cleanup"] as const) { let calls = 0; let cancellations = 0; const signals: unknown[] = []; + const archiveReadBlocked = Promise.withResolvers(); const mock = spyOn(globalThis, "fetch").mockImplementation( fetchMock((url, options) => { calls++; @@ -560,19 +561,27 @@ for (const scenario of ["success", "total", "stall", "cleanup"] as const) { ) { return Promise.resolve( new Response( - new ReadableStream({ - start(controller) { - if (scenario === "cleanup") { - controller.enqueue(fixture.archive); - } + new ReadableStream( + { + start(controller) { + if (scenario === "cleanup") { + controller.enqueue(fixture.archive); + } + }, + pull() { + if (scenario === "cleanup") { + archiveReadBlocked.resolve(); + } + }, + cancel() { + cancellations++; + if (scenario === "cleanup") { + return new Promise(() => {}); + } + }, }, - cancel() { - cancellations++; - if (scenario === "cleanup") { - return new Promise(() => {}); - } - }, - }) + { highWaterMark: 0 } + ) ) ); } @@ -595,6 +604,13 @@ for (const scenario of ["success", "total", "stall", "cleanup"] as const) { await drain(); } expect(calls).toBe(scenario === "stall" ? 2 : 5); + if (scenario === "cleanup") { + // With no prefetch, the next read follows the awaited archive write. + await archiveReadBlocked.promise; + expect(readFileSync(join(root, fixture.name))).toEqual( + fixture.archive + ); + } clock.advance(31); await drain(); if (scenario === "cleanup") { diff --git a/tests/python-free-installed-manager.test.ts b/tests/python-free-installed-manager.test.ts index 3c5503c8b..394b94fb6 100644 --- a/tests/python-free-installed-manager.test.ts +++ b/tests/python-free-installed-manager.test.ts @@ -26,7 +26,13 @@ const EXECUTOR = `#include #include int main(int argc, char **argv) { if (argc > 1 && strcmp(argv[1], "--candidate-root") == 0) { - const char *kind = getenv("FIXTURE_STATUS"); + char kind_buffer[32] = ""; + char status_path[4096]; + const char *home = getenv("HOME"); + if (!home || snprintf(status_path, sizeof(status_path), "%s/fixture-status", home) >= sizeof(status_path)) return 8; + FILE *status = fopen(status_path, "r"); + if (status) { if (!fgets(kind_buffer, sizeof(kind_buffer), status)) { fclose(status); return 8; } fclose(status); } + const char *kind = kind_buffer; if (kind && strcmp(kind, "error") == 0) return 7; if (kind && strcmp(kind, "malformed") == 0) { puts("not json"); return 0; } if (kind && strcmp(kind, "active") == 0) { puts("{\\"phase\\":\\"running\\",\\"process_alive\\":true}"); return 0; } @@ -334,9 +340,16 @@ test.skipIf(!supported)( ); expect(unsignedResult.exitCode).toBe(1); expect(readFileSync(join(channel, ".selection.json"))).toEqual(before); - for (const status of ["error", "active", "malformed"]) { - const rejected = run( - [ + // Quiescence children receive only system variables, including this private HOME. + const fixtureStatus = join(home, "fixture-status"); + for (const [status, message] of [ + ["error", "Candidate quiescence check failed"], + ["active", "Candidate runtime is active or ambiguous"], + ["malformed", "JSON Parse error"], + ]) { + writeFileSync(fixtureStatus, status, { mode: 0o600 }); + try { + const rejected = run([ "upgrade", "--version", "5.0.0-next.2", @@ -344,11 +357,15 @@ test.skipIf(!supported)( second.target, "--sha256", second.checksum, - ], - { FIXTURE_STATUS: status } - ); - expect(rejected.exitCode).toBe(1); - expect(readFileSync(join(channel, ".selection.json"))).toEqual(before); + ]); + expect(rejected.stderr.toString()).toContain(message); + expect(rejected.exitCode).toBe(1); + expect(readFileSync(join(channel, ".selection.json"))).toEqual( + before + ); + } finally { + rmSync(fixtureStatus); + } } const upgraded = manage([ "upgrade", From 1d88e7f982b112ab32483403ccd14e0ffd3af442 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:24:33 +0000 Subject: [PATCH 5/7] test(native): require exact setup inspection requests --- tests/native-preflight-cli.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/native-preflight-cli.test.ts b/tests/native-preflight-cli.test.ts index 039821aee..e5116d7b3 100644 --- a/tests/native-preflight-cli.test.ts +++ b/tests/native-preflight-cli.test.ts @@ -68,6 +68,12 @@ async function fixture(compose: Record) { binary, `#!/bin/sh printf '%s\\n' "$*" >> '${stub}/calls' +if [ "$3" = runtime ] && [ "$4" = setup ]; then + if [ "$#" != 6 ] || [ "$5" != inspect ] || [ "$6" != --json ]; then + printf '%s' 'fixture refuses setup mutation' >&2 + exit 1 + fi +fi reply="$3-$4" # The second review names the adapted document: keep a copy and, when the # test scripted one, answer it separately from the review of the original. @@ -143,7 +149,15 @@ exit 1 (await readFile(join(stub, "calls"), "utf8").catch(() => "")) .split("\n") .filter(Boolean) - .map((line) => line.split(" ").slice(2, 4).join(" ")), + .map((line) => { + const args = line.split(" ").slice(2); + const command = args.slice(0, 2).join(" "); + if (command === "runtime setup") { + // Assert full argv before shortening the observation for callers. + expect(args).toEqual(["runtime", "setup", "inspect", "--json"]); + } + return command; + }), native: { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: binary, From 49cd363ea19b38a9aa090c1afee180bc71ca753d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:36:08 +0000 Subject: [PATCH 6/7] test(native): account for refused lifecycle activity in plan checks --- scripts/check-native-acquisition-plan-cli.ts | 15 ++++-- scripts/check-native-endpoint-plan-cli.ts | 13 ++++- scripts/check-native-env-plan-cli.ts | 18 +++++-- scripts/check-native-host-plan-cli.ts | 20 +++++-- scripts/check-native-process-plan-cli.ts | 15 ++++-- scripts/check-native-routing-plan-cli.ts | 15 +++++- scripts/lib/native-plan-home-assertions.ts | 57 ++++++++++++++++++++ 7 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 scripts/lib/native-plan-home-assertions.ts diff --git a/scripts/check-native-acquisition-plan-cli.ts b/scripts/check-native-acquisition-plan-cli.ts index d38b95270..41b1edc5d 100644 --- a/scripts/check-native-acquisition-plan-cli.ts +++ b/scripts/check-native-acquisition-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { assertNativePlanHome } from "./lib/native-plan-home-assertions.ts"; /** Qualify pure acquisition intent in a relocated bundle and a real linked worktree. */ const root = resolve(import.meta.dir, ".."); @@ -264,6 +265,11 @@ try { ], checkout ); + await assertNativePlanHome({ + home, + failedUpCount: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke(["up", "web", "--detach"], checkout, false, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -282,10 +288,11 @@ try { "planning creates no runtime state" ); } - assert( - (await readdir(join(home, ".hack")).catch(() => [])).length === 0, - "planning creates no registry or runtime home state" - ); + await assertNativePlanHome({ + home, + failedUpCount: 1, + privateInputs: [canary, directory], + }); process.stdout.write( `Relocated native acquisition planning: ${checks} checks passed; no hooks/pulls/builds/decryption/runtime effects\n` ); diff --git a/scripts/check-native-endpoint-plan-cli.ts b/scripts/check-native-endpoint-plan-cli.ts index 8b98031cd..9643b75d4 100644 --- a/scripts/check-native-endpoint-plan-cli.ts +++ b/scripts/check-native-endpoint-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { assertNativePlanHome } from "./lib/native-plan-home-assertions.ts"; /** Exercise pure endpoint planning through the relocated CLI and real Git linkage. */ const root = resolve(import.meta.dir, ".."); @@ -367,6 +368,11 @@ try { "actual resolution rejects unbound logical reference" ); await authored(checkout, project); + await assertNativePlanHome({ + home, + failedUpCount: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -393,8 +399,11 @@ try { "no generated runtime state" ); } - const homeEntries = await readdir(join(home, ".hack")).catch(() => []); - assert(homeEntries.length === 0, "no registry or runtime home writes"); + await assertNativePlanHome({ + home, + failedUpCount: 1, + privateInputs: [canary, directory], + }); process.stdout.write( `Relocated native endpoint planning: ${checks} checks passed; no hooks/DNS/trust/runtime effects\n` ); diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 04e189bc0..81f73d941 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { assertNativePlanHome } from "./lib/native-plan-home-assertions.ts"; /** Real compiled CLI/sidecar, isolated HOME and Git worktrees, no source or toolchain on PATH. */ const root = resolve(import.meta.dir, ".."); @@ -249,6 +250,11 @@ try { await Bun.write(join(checkout, ".hack/hack.config.json"), "{}"); await refused([], "E_NATIVE_PROJECT_CONFLICT", checkout); await rm(join(checkout, ".hack/hack.config.json")); + await assertNativePlanHome({ + home, + failedUpCount: 0, + privateInputs: [sentinel, directory], + }); const unsupported = await invoke( ["up", "web", "--detach", "--json"], checkout, @@ -276,10 +282,14 @@ try { detachedResult.ok === false && record(detachedResult.error).code === "E_STARTUP_INCOMPLETE", "admitted detached startup returns one JSON failure envelope without readiness"); - require((await readdir(home)).length === 0 && - !(await readdir(join(checkout, ".hack"))).some( - (name) => name === ".internal" || name === ".branch" - ), "no registry or generated runtime state"); + require(!(await readdir(join(checkout, ".hack"))).some( + (name) => name === ".internal" || name === ".branch" + ), "no registry or generated runtime state"); + await assertNativePlanHome({ + home, + failedUpCount: 2, + privateInputs: [sentinel, directory], + }); process.stdout.write( "Relocated native env plan acceptance: offline validation, metadata-only output, missing overlays/refs, immutable directives, separate hashes, real linked-worktree layers, CI/slim/opt-out, unsafe-input and runtime refusal passed\n" ); diff --git a/scripts/check-native-host-plan-cli.ts b/scripts/check-native-host-plan-cli.ts index de6dd3033..72da78e8b 100644 --- a/scripts/check-native-host-plan-cli.ts +++ b/scripts/check-native-host-plan-cli.ts @@ -3,6 +3,7 @@ import { copyFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { assertNativePlanHome } from "./lib/native-plan-home-assertions.ts"; /** Actual relocated CLI and sidecar; no Bun/Rust on PATH and no host effects. */ const root = resolve(import.meta.dir, ".."); @@ -277,6 +278,11 @@ try { "local settings cannot inject host commands" ); await rm(join(checkout, ".hack/hack.local.json")); + await assertNativePlanHome({ + home, + failedUpCount: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -287,13 +293,19 @@ try { "native detached service selection refuses before input or runtime work" ); assert( - !(await Bun.file(marker).exists()) && - (await readdir(home)).length === 0 && - !(await readdir(join(checkout, ".hack"))).some( + !( + (await Bun.file(marker).exists()) || + (await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" - ), + ) + ), "no hooks, process, registry or runtime effects" ); + await assertNativePlanHome({ + home, + failedUpCount: 1, + privateInputs: [canary, directory], + }); process.stdout.write( `Relocated native host planning: ${checks} checks passed; no host execution or secret delivery\n` ); diff --git a/scripts/check-native-process-plan-cli.ts b/scripts/check-native-process-plan-cli.ts index a9e2d0c8b..29cfc534e 100644 --- a/scripts/check-native-process-plan-cli.ts +++ b/scripts/check-native-process-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { assertNativePlanHome } from "./lib/native-plan-home-assertions.ts"; /** Qualify pure process planning through a relocated bundle and real Git worktree. */ const root = resolve(import.meta.dir, ".."); @@ -262,6 +263,11 @@ try { ], checkout ); + await assertNativePlanHome({ + home, + failedUpCount: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -288,10 +294,11 @@ try { "planning creates no runtime state" ); } - assert( - (await readdir(join(home, ".hack")).catch(() => [])).length === 0, - "planning creates no registry or runtime home state" - ); + await assertNativePlanHome({ + home, + failedUpCount: 1, + privateInputs: [canary, directory], + }); process.stdout.write( `Relocated native process planning: ${checks} checks passed; no hooks/DNS/trust/runtime effects\n` ); diff --git a/scripts/check-native-routing-plan-cli.ts b/scripts/check-native-routing-plan-cli.ts index f85076287..5e30cb5b5 100644 --- a/scripts/check-native-routing-plan-cli.ts +++ b/scripts/check-native-routing-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { assertNativePlanHome } from "./lib/native-plan-home-assertions.ts"; /** Exercise the relocated executable/sidecar with isolated policy and real linked Git worktrees. */ const root = resolve(import.meta.dir, ".."); @@ -378,6 +379,12 @@ try { "local route injection refused" ); await rm(join(checkout, ".hack/hack.local.json")); + await assertNativePlanHome({ + home, + hasGlobalConfig: true, + failedUpCount: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -393,9 +400,15 @@ try { (await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ) - ) && (await readdir(join(home, ".hack"))).join() === "hack.config.json", + ), "no hook, registry, route publication or generated-state effects" ); + await assertNativePlanHome({ + home, + hasGlobalConfig: true, + failedUpCount: 1, + privateInputs: [canary, directory], + }); process.stdout.write( `Relocated native routing planning: ${checks} checks passed; no DNS/trust/runtime effects\n` ); diff --git a/scripts/lib/native-plan-home-assertions.ts b/scripts/lib/native-plan-home-assertions.ts new file mode 100644 index 000000000..93346ac63 --- /dev/null +++ b/scripts/lib/native-plan-home-assertions.ts @@ -0,0 +1,57 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; + +/** Planning writes no state; refused up attempts may write only their activity journal. */ +export async function assertNativePlanHome(opts: { + readonly home: string; + readonly failedUpCount: number; + readonly privateInputs: readonly string[]; + readonly hasGlobalConfig?: boolean; +}): Promise { + const expected = [ + ...(opts.hasGlobalConfig ? ["hack.config.json"] : []), + ...(opts.failedUpCount > 0 ? ["activity.jsonl"] : []), + ].sort(); + const homeEntries = (await readdir(opts.home)).sort(); + check( + JSON.stringify(homeEntries) === + JSON.stringify(expected.length ? [".hack"] : []), + "unexpected global home state" + ); + if (expected.length) { + check( + JSON.stringify((await readdir(join(opts.home, ".hack"))).sort()) === + JSON.stringify(expected), + "unexpected registry or runtime home state" + ); + } + if (opts.failedUpCount === 0) { + return; + } + const journal = await Bun.file( + join(opts.home, ".hack/activity.jsonl") + ).text(); + const records: unknown[] = journal + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + check( + records.length === opts.failedUpCount && + records.every( + (record) => + isRecord(record) && + record.action === "up" && + record.outcome === "failed" && + record.project === null + ) && + opts.privateInputs.every((value) => !journal.includes(value)), + "expected only redacted failed lifecycle activity" + ); +} + +function check(condition: boolean, label: string): asserts condition { + if (!condition) { + throw new Error(`Native plan home acceptance failed: ${label}`); + } +} From 857d2a0d7ae9287686c06ed1bc7f930baad1561a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:38:43 +0000 Subject: [PATCH 7/7] test(native): identify setup refusal before detached admission --- scripts/check-native-env-plan-cli.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 81f73d941..86cead2e6 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -268,8 +268,8 @@ try { (unsupported.stdout + unsupported.stderr).includes( "E_USAGE" ), "native detached service selection refuses before input or runtime work"); - // Whole-project detach is admitted. Invalid input keeps this relocated-bundle - // check offline on supported hosts; unsupported hosts also fail before ready. + // A missing executor refuses at setup inspection, before detached admission + // or reading the deliberately invalid authored input. await Bun.write(join(checkout, ".hack/hack.project.json"), "{"); const detached = await invoke(["up", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", @@ -277,11 +277,13 @@ try { HACK_NATIVE_HOME: join(home, "native"), }); const detachedResult = record(JSON.parse(detached.stdout)); + const setupError = record(detachedResult.error); require(detached.exit === 1 && detached.stderr === "" && detachedResult.ok === false && - record(detachedResult.error).code === - "E_STARTUP_INCOMPLETE", "admitted detached startup returns one JSON failure envelope without readiness"); + setupError.code === "E_STARTUP_INCOMPLETE" && + record(setupError.detail).runtime_setup === + "unavailable", "missing executor returns one setup-unavailable JSON refusal before detached admission"); require(!(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ), "no registry or generated runtime state");