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.
+
+
+
+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 @@
+
\ 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");