diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index d799a9eba..19ed2cac7 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -104,9 +104,68 @@ Leave `HACK_RUNTIME_BACKEND` unset, or set it to `native`, to return to the nati default. A shell that exported `HACK_RUNTIME_BACKEND=compose` for other work also selects Compose for `hack-next`; unset it before native testing. -The bundled `hack-v5` entrypoint follows the same rule for manual bundles: -unset selects native and requires `HACK_NATIVE_HOME`; `compose` needs no native -home and uses your own Hack home. +The bundled `hack-v5` entrypoint follows the same backend rule for manual bundles. +Native defaults to `~/.hack/native-runtime`, or `$HACK_HOME/native-runtime` when +`HACK_HOME` is selected. An absolute `HACK_NATIVE_HOME` overrides that default. +Selecting a home creates nothing; setup creates missing directories privately +after consent. Existing homes must be owned by you, mode 0700, and have no symlink +components. `compose` needs no native home and uses your own Hack home. + +### Prepare the native runtime once + +Use the candidate frontend for both setup and project commands: + +```sh +hack-next native setup +hack-next doctor --native-preflight +hack-next up -d +``` + +The command is `native setup` because it prepares native provider files without +starting a VM or changing the Compose backend. It lists the pinned downloads, +sizes, SHA-256 hashes, cache and destination, then asks once. Scripts use +`hack-next native setup --accept-downloads --json`; JSON never prompts and passes +the runtime's setup result through unchanged. Completed files are verified and +reused; interrupted downloads resume. The complete offline archive input form is +also available; see [provider setup](native-candidate.md). + +Preflight reports runtime installation separately from project review, in text +and in the JSON `runtime_setup` item. A fresh or interrupted home blocks startup +with `hack native setup` as its fix. Noninteractive `up` refuses before starting +an owner; interactive `up` offers the same pinned setup and continues only after +successful setup and a fresh readiness check. Record readiness is a quick check; +startup still verifies prepared content before executing it. + +![Native first-use flow](native-first-use.svg) + +Diagram [source](native-first-use.mmd); rendered with Pele 1.9.0. + +Managed `hack-next` keeps its version's receipt-owned `native-home` so upgrades +can find and stop its runtime. It refuses a differing `HACK_NATIVE_HOME` rather +than silently ignoring it. Use that version's bundle `hack-v5` launcher for a +custom home. To move a manual bundle's home, stop its projects and runtime using +the old home and matching executor, preserve the old home for retained data, then +select a fresh absolute `HACK_NATIVE_HOME` and run `native setup` again. Setup +does not migrate live receipts, disks or project data. The machine home is +distinct from each project's `.hack/native/` adaptation directory. +An explicit `native setup` selects the native home even when the surrounding +shell sets `HACK_RUNTIME_BACKEND=compose`; ordinary Compose commands still remove +native selectors. + +The frontend passes only system environment variables to setup; project values, +credentials and proxy variables are excluded. Downloads need direct HTTPS access; +use the offline inputs where a proxy is required. Invalid prepared trees and pin +mismatches are preserved and need their own repair or a fresh home, rather than +an endless setup retry. Setup-only homes permit managed upgrades only after an +existing-lock quiescence check; unexpected or busy state still refuses. As with +the previous empty-home check, a detached process that has not created any state +is outside this observation's proof. + +Older candidate executors cannot prove the new setup-only quiescence predicate. +If an older selected version was set up but never started, finish its setup, +start a project with that old version's `hack-next up`, then run `hack-next down` +before upgrading. Its runtime must report `stopped`. An interrupted or unknown +old home still refuses an upgrade; do not remove its receipts to bypass the check. ### Stop stable hackd before a Compose run diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 648dbc57c..638825000 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -99,9 +99,11 @@ mkdir -m 700 /absolute/private/candidate-home ./hack-native --candidate-root /absolute/private/candidate-home --version ``` -The home must already exist, be owned by the current user, and have mode `0700`. +The home must already exist, be owned by the current user, and have mode `0700`, +except that `runtime setup` and its read-only `inspect` accept an absent home. +Setup creates missing directories privately after consent. Use its canonical absolute path without symlink components (on macOS, use -`/private/tmp` rather than `/tmp`). Every command requires this explicit home, +`/private/tmp` rather than `/tmp`). Every lower-level `hack-native` command requires this explicit home, including help and version. Its `checkout` field is the historical receipt name for this stable home; `channel` reports `installed-candidate`. State remains under `/.hack-local`. Keep this home fixed across binary upgrades: receipts and @@ -110,11 +112,12 @@ an active home or adopting another installation's state is not. For normal project commands, follow the short [native start page](https://hack.foo/docs/v5/native/start). Manual bundle users -use the adjacent `hack-v5` entrypoint with an explicit `HACK_NATIVE_HOME`; -keep the bundle together. The [candidate installer guide](candidate-install.md#test-both-backends) +use the adjacent `hack-v5` entrypoint and keep the bundle together. It defaults to +`$HACK_HOME/native-runtime` or `~/.hack/native-runtime`; an absolute +`HACK_NATIVE_HOME` overrides that default. The [candidate installer guide](candidate-install.md#test-both-backends) defines backend selectors, private versus shared homes, stable-daemon quiescence -and returning to Compose. Neither entrypoint prepares the provider, migrates a -project or falls back to Docker for an unsupported native command. An unversioned +and returning to Compose. Prepare the provider explicitly with `native setup`. +Neither entrypoint migrates a project or falls back to Docker for an unsupported native command. An unversioned local build reports its repository version; only a versioned bundle includes candidate version provenance. The entrypoint alone does not identify a release. `hack-v5` passes arguments, exit codes and signals through `exec`; it never replaces @@ -202,7 +205,9 @@ Environment-file findings may name their source path. The runtime review half needs a native runtime, so run the preflight from the `hack-next` launcher for the full list. Without native selection only the CLI's checks run and the review is reported as `skipped`. The review needs no running -VM. +VM. A selected runtime also receives a read-only setup inspection. Incomplete or +unavailable setup is a `native_runtime_setup` blocker; run `hack native setup` +before retrying. Without native selection, setup is reported as unavailable. Use a matching updated CLI and runtime. A runtime without start-admission review is reported as `unavailable` with an update hint and cannot produce `ready: true`. @@ -231,6 +236,12 @@ With `--json` the report is the `data` of the standard result envelope: "backend": "native", "compose_file": ".hack/docker-compose.yml", "ready": false, + "runtime_setup": { + "state": "ready", + "installation_state": "ready", + "code": "native_setup_ready", + "fix": "Native runtime is set up." + }, "runtime_review": { "status": "completed" }, "blockers": [ { @@ -247,8 +258,8 @@ With `--json` the report is the `data` of the standard result envelope: } ``` -- `ready` is true only when there is no blocker and the runtime review - completed. +- `ready` is true only when there is no blocker, setup is ready and the runtime + review completed. - The exit status follows the blockers: `0` with none, `1` otherwise. A skipped or unavailable review is not a blocker, so that run exits `0` with `ready: false`. With `--json` the envelope is `ok: true` in both cases; read @@ -277,7 +288,7 @@ With `--json` the report is the `data` of the standard result envelope: `unsupported_build_form`, `emulated_build`, `environment_file_input`, `missing_image`, `extra_hosts_without_dependencies`, `unsupported_logging`, `invalid_restart`, `unsupported_label`, `invalid_http_probe`, `managed_env_unreadable`, - `environment_delivery`, `shared_source_grant_required`, + `environment_delivery`, `native_runtime_setup`, `shared_source_grant_required`, `route_probe_required` and `too_many_bridges`. Runtime findings use the review's own diagnostic codes, for example `privileged_service` or `invalid_mount`, and `review_incompatible` when the review does not admit the @@ -915,12 +926,23 @@ For ordinary setup and consent, use the short The remainder describes the setup and recovery contract. `--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. Setup lists the pinned SmolVM, Docker engine and four Alpine guest packages, including exact compressed sizes, hosts, hashes, private cache and destinations. +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 @@ -2530,8 +2552,9 @@ PID, path or depth in the file. Replace the authority fields with exactly one of - `"host_lifecycle": "NAME"`: a persistent `up.before` hook (its `name`, or `hook-N` when unnamed) or a `lifecycle.processes` entry of this instance. Hack reads the pane PID from its lifecycle state and requires the live tmux session - to carry the recorded ownership token and that pane to be alive. zellij - sessions record no pane PID and refuse. + to carry the recorded ownership token and that pane to be alive. Interactive + workspaces require tmux; legacy zellij lifecycle records have no pane PID and + refuse. See [sessions and migration](../sessions.md). - `"host_shared_helper": "NAME"`: an enabled `lifecycle.sharedHelpers` entry whose claim is `owned` and `ready`, lists this instance as a holder, and whose PID and birth time are live. An adopted helper refuses. diff --git a/docs/guides/native-first-use.mmd b/docs/guides/native-first-use.mmd new file mode 100644 index 000000000..0ccad6b17 --- /dev/null +++ b/docs/guides/native-first-use.mmd @@ -0,0 +1,18 @@ +flowchart TD + Up["hack up"] --> Inspect["Inspect installation"] + Inspect --> Ready{"Ready?"} + Ready -->|Yes| Start["Review project and start"] + Ready -->|No| Offer{"Interactive and resumable?"} + Offer -->|No| Refuse["Refuse with guidance"] + Offer -->|Yes| Consent{"Accept pinned downloads?"} + Consent -->|No| Refuse + Consent -->|Yes| Setup["Verify and prepare files"] + Setup -->|Success| Reinspect["Re-inspect installation"] + Reinspect --> Installed{"Ready after setup?"} + Installed -->|Yes| Start + Installed -->|No| Refuse + Setup -->|Failed or interrupted| Refuse + class Start ok + class Refuse fail + class Setup live + class Consent wait diff --git a/docs/guides/native-first-use.svg b/docs/guides/native-first-use.svg new file mode 100644 index 000000000..8c6466034 --- /dev/null +++ b/docs/guides/native-first-use.svg @@ -0,0 +1,66 @@ +YesNoNoYesNoYesSuccessYesNoFailed or interruptedhack upInspect installationReady?Review project and startInteractive andresumable?Refuse with guidanceAccept pinned downloads?Verify and prepare filesRe-inspect installationReady after setup? \ No newline at end of file diff --git a/docs/native/try-it.md b/docs/native/try-it.md index cf9e14f07..0b3e7de0d 100644 --- a/docs/native/try-it.md +++ b/docs/native/try-it.md @@ -30,10 +30,18 @@ virtual machine, or change DNS or certificate trust. Newer prereleases are on th ## 2. Prepare the runtime The native backend needs its virtual machine software set up once per installed -version. Run the explicit setup command; `up` never downloads provider files. +version. In next.17, use the same frontend for setup and project commands: -A `hack-next` install keeps each version in its own folder. The two paths you need -are the native program and its private home: +```sh +"$HOME/.hack-next/bin/hack-next" native setup +``` + +Noninteractive `up` refuses until setup is complete. Interactive `up` can offer +setup, ask for download consent, and continue after success. See the +[first-use guide](../versions/v5/native/setup.md) for default homes and scripts. + +Installed next.16 needs the older command below. A `hack-next` install keeps each +version in its own folder; these paths also support the manual alternatives: ```sh V=5.0.0-next.16 # the version you installed; `hack-next --manager status` shows it @@ -52,7 +60,8 @@ profile probe. It needs no administrator password or keychain access. Rerun afte an interrupted download to resume or restart safely. A complete matching install is verified before consent and reused without prompting, downloading or preparing again. -For scripts, use `runtime setup --no-interactive --accept-downloads`. Without +For next.17 scripts, use `hack-next native setup --accept-downloads`; with the +older executor, use `runtime setup --no-interactive --accept-downloads`. Without `--accept-downloads`, a noninteractive run needing preparation fails with `E_INTERACTIVE_REQUIRED`. Complete matching installs need no flag. Add `--json` for per-file `downloaded`, `resumed`, `reused` or `offline` outcomes and exact diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af5b832cf..dcb5b1c4c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1563,6 +1563,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 exec-sessions` diff --git a/docs/versions/v5/native/preflight.md b/docs/versions/v5/native/preflight.md index d614465c3..2ecd286ac 100644 --- a/docs/versions/v5/native/preflight.md +++ b/docs/versions/v5/native/preflight.md @@ -35,6 +35,10 @@ The CLI checks the selected native adaptation and project-wide host aliases, then asks the selected native runtime for a read-only project-plan review. For that full review, run the command through the `hack-next` launcher described on [Install](../install.md). The review needs no running VM. +The selected runtime also receives a read-only setup inspection. If setup is +incomplete or unavailable, preflight reports a `native_runtime_setup` blocker; +run `hack native setup` before retrying. Without native selection, setup is +reported as unavailable and the runtime review is skipped. | `runtime_review.status` | Meaning | | --- | --- | @@ -44,7 +48,9 @@ on [Install](../install.md). The review needs no running VM. | `unavailable` | The selected runtime did not return a review. | With `--json`, the report is inside `data` of the result envelope. Read `ready`, -`blockers`, `warnings` and `runtime_review`; `ok: true` means the command produced +`blockers`, `warnings`, `runtime_setup` and `runtime_review`; `ready: true` +requires setup to be ready and the runtime review to complete without blockers. +`ok: true` means the command produced a report. For a produced report, exit status is 1 when blockers were found and 0 otherwise. Usage refusals exit non-zero without producing a report. An exit of 0 can still have `ready: false` when the runtime review was skipped or unavailable. diff --git a/docs/versions/v5/native/setup.md b/docs/versions/v5/native/setup.md index f335868d0..e70de50a4 100644 --- a/docs/versions/v5/native/setup.md +++ b/docs/versions/v5/native/setup.md @@ -1,28 +1,28 @@ -# Prepare the native runtime (under verification) +# Prepare the native runtime -next.16 includes `runtime setup`. It is **under verification**: publishing the -command does not establish real-VM acceptance. Use an Apple Silicon Mac, a +Use the candidate frontend's `native setup` command. Setup success does not +establish real-VM or application acceptance. Use an Apple Silicon Mac, a disposable project and synthetic data. Read [the limits](limits.md) first. -After [installing next.16](../install.md), select that version's native program -and private home. `hack-next --manager status` shows the selected version. +After [installing the candidate](../install.md), use the same frontend for setup +and project commands. Its launcher selects the version's private native home: ```sh -V=5.0.0-next.16 -NATIVE="$HOME/.hack-next/versions/$V/bundle/hack-native" -NATIVE_HOME="$HOME/.hack-next/versions/$V/native-home" -"$NATIVE" --candidate-root "$NATIVE_HOME" runtime setup +CANDIDATE="$HOME/.hack-next/bin/hack-next" +unset HACK_RUNTIME_BACKEND HACK_NATIVE_HOME +"$CANDIDATE" native setup ``` Setup lists the pinned files, exact compressed sizes, source hosts, hashes and destinations before asking once for consent. It downloads and verifies the provider, engine and guest network tools, prepares them in staging, and ends -with the development-profile probe. Ordinary `up` does not download these files. +with the development-profile probe. Noninteractive `up` refuses until setup is +complete. Interactive `up` can offer the same consent and continue after success. Setup needs no administrator password or keychain access and ignores proxy -settings; use the [offline form](try-it.md#offline-or-older-builds) on networks -that require a proxy. +settings; use the offline form below on networks that require a proxy. -For scripts, add `--no-interactive --accept-downloads`. A noninteractive run +For scripts, run `"$CANDIDATE" native setup --accept-downloads --json`; JSON +never prompts and passes the setup result through unchanged. A noninteractive run needing preparation refuses with `E_INTERACTIVE_REQUIRED` without that consent. A complete matching installation is verified and reused without prompting or downloading. An incomplete, changed or differently pinned provider directory is @@ -33,9 +33,46 @@ Interrupted downloads can be resumed or restarted by rerunning setup. A exact offline inputs or a reviewed newer release. Setup does not upgrade a home's provider pin in place. -After preparation, continue with [Try it](try-it.md#3-check-the-project). -Setup success is not proof that the project starts, retains data or switches -back to Compose correctly. Those results remain under verification. +## Offline inputs + +Supply all three inputs from the matching pinned release: + +```sh +"$CANDIDATE" native setup \ + --archive /absolute/smolvm.tar.gz \ + --engine-archive /absolute/docker.tgz \ + --network-tools-directory /absolute/network-tools +``` + +Do not combine offline inputs with `--accept-downloads`. The network-tools +directory must contain the four pinned Alpine packages. Setup verifies the same +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 -Sources: [release setup procedure](https://github.com/hack-dance/hack/blob/b21c3b189841c1a3cbb8af2b76414fa347dc7057/docs/native/try-it.md), -[setup and recovery contract](https://github.com/hack-dance/hack/blob/b21c3b189841c1a3cbb8af2b76414fa347dc7057/docs/guides/native-candidate.md#manual-candidate-upgrade-and-rollback). +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 or uninstalling. Older executors cannot prove +setup-only quiescence; do not delete receipts to bypass a refusal. See the +[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). + +After preparation, continue with [Check before starting](start.md#check-before-starting). +Setup success is not proof that the project starts, retains data or switches +back to Compose correctly. diff --git a/docs/versions/v5/native/start.md b/docs/versions/v5/native/start.md index f6683426a..a786fe00a 100644 --- a/docs/versions/v5/native/start.md +++ b/docs/versions/v5/native/start.md @@ -27,34 +27,46 @@ stateDiagram-v2 ## Prepare the runtime -Keep the `hack-next` install from [Install](../install.md). Use the exact version -shown by `"$HOME/.hack-next/bin/hack-next" --manager status`: +Keep the `hack-next` install from [Install](../install.md). Its launcher selects +the installed version and that version's private native home: ```sh -V=5.0.0-next.16 -NATIVE="$HOME/.hack-next/versions/$V/bundle/hack-native" -NATIVE_HOME="$HOME/.hack-next/versions/$V/native-home" CANDIDATE="$HOME/.hack-next/bin/hack-next" -unset HACK_RUNTIME_BACKEND +unset HACK_RUNTIME_BACKEND HACK_NATIVE_HOME ``` Do not use the Compose shell function from page 1 for native commands. The full launcher path below selects native and that version's private homes. -Today's setup command is: +Prepare the runtime with the same frontend, without another binary or a home argument. + +These frontend setup steps are intended for next.17; installed next.16 retains +its [older setup procedure](setup.md#older-setup-only-homes). ```sh -"$NATIVE" --candidate-root "$NATIVE_HOME" runtime setup +"$CANDIDATE" native setup +# Noninteractive downloads need explicit consent: +"$CANDIDATE" native setup --accept-downloads --json ``` -A `hack` command for this step is coming (HACK-2193); only this setup line needs -to change when it lands. Setup shows file sizes, download hosts, hashes and -locations, then asks for consent. Read the list. `up` never downloads the runtime -for you. A matching install is checked and reused. For offline inputs or an -interrupted download, use [setup and recovery details](setup.md). +Setup shows file sizes, download hosts, hashes and locations, then asks once for +consent. Read the list. A matching install is checked and reused. Noninteractive +`up` refuses until setup is complete; interactive `up` can offer setup with the +same consent and continue after success. For offline inputs or an interrupted +download, use [setup and recovery details](setup.md). + +Manual bundle users run the adjacent `hack-v5 native setup`. Their default home is +`$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](setup.md#manual-homes). ## Check before starting +`hack upgrade check` checks native configuration only. It does not inspect runtime +setup or runtime admission, so a finding with no configuration blockers is not +permission to start. Run setup above and the full preflight below. + ```sh "$CANDIDATE" doctor --native-preflight --path /absolute/project ``` @@ -62,7 +74,8 @@ interrupted download, use [setup and recovery details](setup.md). Add the project's `--profile` selection if needed. Preflight reads the Compose project and lists blockers with fixes. It starts no VM or service. Continue only when there are no blockers **and** the report says `Runtime review: completed`. -In JSON, this means `ready: true` and `runtime_review.status: "completed"`. +The runtime setup item must also say ready. In JSON, this means `ready: true` +and `runtime_review.status: "completed"`. An exit of 0 alone is not enough: runtime review can be skipped or unavailable. If blocked, fix the named field and rerun. Do not remove a check or delete state diff --git a/docs/versions/v5/upgrade.md b/docs/versions/v5/upgrade.md index c9b533f9c..6b7e1de1a 100644 --- a/docs/versions/v5/upgrade.md +++ b/docs/versions/v5/upgrade.md @@ -8,6 +8,10 @@ This is today's manual path. **To be replaced by the upgrade assistant** and try v5 on one Compose project at a time. Native is a separate trial. The published v5 candidate bundle requires an Apple Silicon Mac. +In builds with `hack upgrade check`, its native finding covers configuration +only; runtime setup and admission are not checked. For a native trial, follow +[runtime setup and full preflight](native/start.md#prepare-the-runtime). + ```mermaid --- title: A reversible upgrade trial diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 8968c521a..df5299338 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -231,8 +231,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!( @@ -974,6 +993,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/check-native-acquisition-plan-cli.ts b/scripts/check-native-acquisition-plan-cli.ts index 9db1698a4..b8d6315ba 100644 --- a/scripts/check-native-acquisition-plan-cli.ts +++ b/scripts/check-native-acquisition-plan-cli.ts @@ -265,7 +265,11 @@ try { ], checkout ); - await assertNativePlanHome({ home, failedUps: 0 }); + await assertNativePlanHome({ + home, + failedUps: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke(["up", "web", "--detach"], checkout, false, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -284,7 +288,11 @@ try { "planning creates no runtime state" ); } - await assertNativePlanHome({ home, failedUps: 1 }); + await assertNativePlanHome({ + home, + failedUps: 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 b80505bfe..816e18ffc 100644 --- a/scripts/check-native-endpoint-plan-cli.ts +++ b/scripts/check-native-endpoint-plan-cli.ts @@ -368,7 +368,11 @@ try { "actual resolution rejects unbound logical reference" ); await authored(checkout, project); - await assertNativePlanHome({ home, failedUps: 0 }); + await assertNativePlanHome({ + home, + failedUps: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -395,8 +399,11 @@ try { "no generated runtime state" ); } - - await assertNativePlanHome({ home, failedUps: 1 }); + await assertNativePlanHome({ + home, + failedUps: 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 3cbcd0d24..f391232f8 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -250,7 +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, failedUps: 0 }); + await assertNativePlanHome({ + home, + failedUps: 0, + privateInputs: [sentinel, directory], + }); const unsupported = await invoke( ["up", "web", "--detach", "--json"], checkout, @@ -264,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", @@ -273,15 +277,21 @@ 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"); - await assertNativePlanHome({ home, failedUps: 2 }); + 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"); + await assertNativePlanHome({ + home, + failedUps: 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 a14993c5f..81cbad7bd 100644 --- a/scripts/check-native-host-plan-cli.ts +++ b/scripts/check-native-host-plan-cli.ts @@ -278,7 +278,11 @@ try { "local settings cannot inject host commands" ); await rm(join(checkout, ".hack/hack.local.json")); - await assertNativePlanHome({ home, failedUps: 0 }); + await assertNativePlanHome({ + home, + failedUps: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -288,7 +292,6 @@ try { runtime.exit !== 0 && (runtime.stdout + runtime.stderr).includes("E_USAGE"), "native detached service selection refuses before input or runtime work" ); - await assertNativePlanHome({ home, failedUps: 1 }); assert( !( (await Bun.file(marker).exists()) || @@ -298,6 +301,11 @@ try { ), "no hooks, process, registry or runtime effects" ); + await assertNativePlanHome({ + home, + failedUps: 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 0c7edf3c4..b841cc8ea 100644 --- a/scripts/check-native-process-plan-cli.ts +++ b/scripts/check-native-process-plan-cli.ts @@ -263,7 +263,11 @@ try { ], checkout ); - await assertNativePlanHome({ home, failedUps: 0 }); + await assertNativePlanHome({ + home, + failedUps: 0, + privateInputs: [canary, directory], + }); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -290,7 +294,11 @@ try { "planning creates no runtime state" ); } - await assertNativePlanHome({ home, failedUps: 1 }); + await assertNativePlanHome({ + home, + failedUps: 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 50c0a949a..4639c3e02 100644 --- a/scripts/check-native-routing-plan-cli.ts +++ b/scripts/check-native-routing-plan-cli.ts @@ -381,8 +381,9 @@ try { await rm(join(checkout, ".hack/hack.local.json")); await assertNativePlanHome({ home, - failedUps: 0, preparedHackFiles: ["hack.config.json"], + failedUps: 0, + privateInputs: [canary, directory], }); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", @@ -404,8 +405,9 @@ try { ); await assertNativePlanHome({ home, - failedUps: 1, preparedHackFiles: ["hack.config.json"], + failedUps: 1, + privateInputs: [canary, directory], }); process.stdout.write( `Relocated native routing planning: ${checks} checks passed; no DNS/trust/runtime effects\n` 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 53c42a26d..e4a84b0bd 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); @@ -527,11 +535,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`), }; } @@ -559,7 +568,7 @@ export class Channel { "--json", ], { - env: this.environment(version), + env: childSetupInspectionEnvironment(), stdin: "ignore", stdout: "pipe", stderr: "ignore", @@ -578,14 +587,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) { @@ -965,16 +1017,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/scripts/lib/native-plan-home.ts b/scripts/lib/native-plan-home.ts index 9e60150e6..fed8de9f9 100644 --- a/scripts/lib/native-plan-home.ts +++ b/scripts/lib/native-plan-home.ts @@ -10,6 +10,7 @@ export async function assertNativePlanHome(opts: { readonly home: string; readonly failedUps: number; readonly preparedHackFiles?: readonly string[]; + readonly privateInputs?: readonly string[]; }): Promise { const expectedHackFiles = [...(opts.preparedHackFiles ?? [])]; if (opts.failedUps > 0) { @@ -26,6 +27,9 @@ export async function assertNativePlanHome(opts: { return; } const text = await Bun.file(join(opts.home, ".hack/activity.jsonl")).text(); + if (opts.privateInputs?.some((value) => text.includes(value))) { + throw new Error("Unexpected private input in lifecycle activity"); + } const lines = text.trim().split("\n"); if (!text.endsWith("\n") || lines.length !== opts.failedUps) { throw new Error("Expected one complete activity record per failed up"); 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 8098cac69..88a3ce1b5 100644 --- a/src/backends/native-preflight.ts +++ b/src/backends/native-preflight.ts @@ -46,6 +46,11 @@ import { NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; +import { + inspectNativeSetup, + type NativeSetupState, + nativeSetupGuidance, +} from "./native-runtime-setup.ts"; import { NATIVE_START_REFUSAL_ENTRIES } from "./native-start-refusal-entries.ts"; import { readNativeStartRefusals } from "./native-start-refusals.ts"; @@ -62,16 +67,16 @@ import { readNativeStartRefusals } from "./native-start-refusals.ts"; * Invariants: * - Read-only for the project and the runtime. It starts no VM, pulls no * image, runs no lifecycle hook and writes nothing to the project. The only - * runtime requests are `project plan` reviews, which read the project and - * need no running runtime; the adapted Compose document they review lives + * runtime requests are read-only `runtime setup inspect` and `project plan` + * reviews, which need no running runtime; the adapted Compose document lives * in a private temporary directory that is removed afterwards. * - It judges what `hack up` judges: the Compose document after the selected * native adaptation and project-wide host aliases are applied, not the raw * file. * - Value-free. A finding carries a closed code, a service name, a field path - * and fixed sentences. Authored values (environment values, label keys and - * values, paths, image names) are never printed, and neither is the - * runtime's own message text. + * and fixed sentences. Environment-file findings may name source paths. + * Environment values, label keys and values, image names and the runtime's + * own message text are never printed. * - Not an admission decision. `hack up` still decides with its own checks; a * clean report does not start anything and does not skip any of them. */ @@ -111,6 +116,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; /** @@ -1466,6 +1477,44 @@ export type NativePreflightOptions = { readonly invoke?: typeof invokeNativeRuntime; }; +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; +} + function deliveryFindings( document: NonNullable, plan: unknown @@ -1478,12 +1527,22 @@ function deliveryFindings( } } -/** Runs both gates over one project and merges their findings. */ +/** 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" @@ -1506,7 +1565,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, @@ -1559,7 +1621,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"), @@ -1606,6 +1672,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 65ae23d1a..7958051a4 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 865230a50..588f89ec3 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -17,6 +17,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, @@ -101,6 +102,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 c2569eb2e..b1a0c3256 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -91,6 +91,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"; @@ -6402,6 +6403,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/src/lib/upgrade-check-probes.ts b/src/lib/upgrade-check-probes.ts index 8a7b382d2..5aea5581a 100644 --- a/src/lib/upgrade-check-probes.ts +++ b/src/lib/upgrade-check-probes.ts @@ -82,6 +82,7 @@ export type GlobalInfraProbe = { }; export type NativePreflightProbe = { + /** Configuration blockers only; runtime setup and admission are not inspected. */ readonly ready: boolean; readonly blockers: number; readonly warnings: number; diff --git a/src/lib/upgrade-check-project.ts b/src/lib/upgrade-check-project.ts index 6927be81f..80f8a6873 100644 --- a/src/lib/upgrade-check-project.ts +++ b/src/lib/upgrade-check-project.ts @@ -502,7 +502,7 @@ function nativeFinding( ? { id: "native-preflight", status: "ok", - summary: `Ready for the native backend (${probe.warnings} warning(s)).`, + summary: `No native configuration blockers (${probe.warnings} warning(s)); runtime setup and runtime review were not checked. Run hack native setup, then hack doctor --native-preflight.`, args: null, action: null, } @@ -510,7 +510,7 @@ function nativeFinding( // Native is optional: a Compose project upgrades without it. id: "native-preflight", status: "info", - summary: `Not ready for the native backend (${probe.blockers} blocker(s)); Compose is unaffected.`, + summary: `Native configuration has ${probe.blockers} blocker(s); runtime setup and runtime review were not checked. Compose is unaffected.`, args: null, action: null, }; 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 011bb8ac7..2659563e0 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 88be0d5aa..c29073fc7 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-plan-home.test.ts b/tests/native-plan-home.test.ts index 62883f3b6..4d4bfd8b7 100644 --- a/tests/native-plan-home.test.ts +++ b/tests/native-plan-home.test.ts @@ -83,3 +83,11 @@ test("rejects a torn activity record", async () => { "Expected one complete activity record per failed up" ); }); + +test("rejects private fixture inputs in otherwise valid activity", async () => { + const privateInput = "synthetic-private-plan-home-value"; + await writeActivity([{ ...failedUp, unexpected: privateInput }]); + await expect( + assertNativePlanHome({ home, failedUps: 1, privateInputs: [privateInput] }) + ).rejects.toThrow("Unexpected private input in lifecycle activity"); +}); diff --git a/tests/native-preflight-cli.test.ts b/tests/native-preflight-cli.test.ts index c10751415..e5116d7b3 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"; @@ -67,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. @@ -87,6 +94,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, @@ -141,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, @@ -275,7 +291,11 @@ test("preflight and start share env-file admission and never publish file values expect(up.code).toBe(1); expect(up.output).not.toContain(PRIVATE); expect(report.output).not.toContain(PRIVATE); - expect((await f.calls()).slice(before.length)).toEqual([]); + // Detached up inspects setup in the parent and the foreground child. + expect((await f.calls()).slice(before.length)).toEqual([ + "runtime setup", + "runtime setup", + ]); } }, 30_000); @@ -299,11 +319,9 @@ test("preflight and static start refuse oversized file delivery before pool effe expect(up.code).toBe(1); expect(up.output).toContain("32 KiB"); expect(up.output).not.toContain("private-quota-canary"); - expect( - (await f.calls()) - .slice(before.length) - .every((call) => call === "project plan") - ).toBe(true); + const [setup, ...reviews] = (await f.calls()).slice(before.length); + expect(setup).toBe("runtime setup"); + expect(reviews.every((call) => call === "project plan")).toBe(true); }); test( @@ -332,14 +350,18 @@ test( const up = await cli(f, ["up"], env); expect(up.code).toBe(1); expect(up.output).toContain("overlap an environment source"); - expect((await f.calls()).slice(before.length)).toEqual([]); + expect((await f.calls()).slice(before.length)).toEqual(["runtime setup"]); expect(report.output).not.toContain(PRIVATE); expect(up.output).not.toContain(PRIVATE); expect((await f.argv()).join("\n")).not.toContain(PRIVATE); + const beforeDetached = await f.calls(); const detached = await cli(f, ["up", "--json"], env); expect(detached.code).toBe(1); expect(detached.output).not.toContain(PRIVATE); - expect((await f.calls()).slice(before.length)).toEqual([]); + expect((await f.calls()).slice(beforeDetached.length)).toEqual([ + "runtime setup", + "runtime setup", + ]); }, SPAWN_TIMEOUT_MS ); @@ -363,8 +385,8 @@ test("preflight and start refuse ignored root dotenv in a native build before ef )?.message ).toContain('source "../.env"'); expect(report.output).not.toContain(PRIVATE); - const before = await f.calls(); for (const args of [["up"], ["up", "--json"]]) { + const before = await f.calls(); const started = await cli(f, args, env); expect(started.code).toBe(1); if (args.includes("--json")) { @@ -376,7 +398,11 @@ test("preflight and start refuse ignored root dotenv in a native build before ef expect(started.output).toContain("overlap an environment source"); } expect(started.output).not.toContain(PRIVATE); - expect((await f.calls()).slice(before.length)).toEqual([]); + expect((await f.calls()).slice(before.length)).toEqual( + args.includes("--json") + ? ["runtime setup", "runtime setup"] + : ["runtime setup"] + ); } }); @@ -400,7 +426,7 @@ test("preflight requires the same decryption key as start and closes key errors" expect(up.code).toBe(1); expect(up.output).toContain("E_CONFIG_INVALID"); expect(up.output).not.toContain(PRIVATE); - expect((await f.calls()).slice(before.length)).toEqual([]); + expect((await f.calls()).slice(before.length)).toEqual(["runtime setup"]); }); test("old runtimes cannot silently ignore explicit environment unsets", async () => { @@ -525,8 +551,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 ); @@ -570,6 +600,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.", @@ -790,7 +821,7 @@ test( expect(up.output).toContain( "Run `hack doctor --native-preflight` to list every blocker with its fix." ); - expect(await f.calls()).toEqual([]); + expect(await f.calls()).toEqual(["runtime setup"]); }, SPAWN_TIMEOUT_MS ); @@ -875,7 +906,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"); @@ -920,7 +952,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"); @@ -1444,7 +1476,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-observe.test.ts b/tests/native-project-observe.test.ts index d82720d4e..af981954f 100644 --- a/tests/native-project-observe.test.ts +++ b/tests/native-project-observe.test.ts @@ -582,7 +582,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); @@ -638,7 +638,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-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 09550284d..3d346bafa 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 92e6d09ff..4417957be 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 4538a4415..38f62c4ad 100644 --- a/tests/native-shell-environment-cli.test.ts +++ b/tests/native-shell-environment-cli.test.ts @@ -16,6 +16,7 @@ import { prepareNativeProjectInput, } from "../src/backends/native-project-input.ts"; import { nativeEnvironmentDelivery } 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 } from "./helpers/private-cli-env.ts"; @@ -89,6 +90,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 d0884cda8..34e201bae 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/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; } diff --git a/tests/python-free-download.test.ts b/tests/python-free-download.test.ts index 73749212c..ec50e1264 100644 --- a/tests/python-free-download.test.ts +++ b/tests/python-free-download.test.ts @@ -608,6 +608,9 @@ for (const scenario of ["success", "total", "stall", "cleanup"] as const) { expect(calls).toBe(scenario === "stall" ? 2 : 5); if (scenario === "cleanup") { expect(archiveReadPending).toBe(true); + expect(readFileSync(join(root, fixture.name))).toEqual( + fixture.archive + ); } clock.advance(31); await drain(); diff --git a/tests/python-free-installed-manager.test.ts b/tests/python-free-installed-manager.test.ts index 12b36cef2..e77964f0a 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; } @@ -184,6 +190,7 @@ test.skipIf(!supported)( HACK_NATIVE_HOME: "/foreign", HACK_GLOBAL_CONFIG_PATH: "/foreign", }; + const nativeEnv = { ...env, HACK_NATIVE_HOME: undefined }; const run = (args: string[], extra: NodeJS.ProcessEnv = {}) => Bun.spawnSync([manager, "--root", channel, ...args], { cwd: root, @@ -218,7 +225,23 @@ test.skipIf(!supported)( join(root, "bunfig.toml"), "this is deliberately not valid TOML [[[" ); - expect(run(["run", "hello"]).exitCode).toBe(0); + const refusedOverride = run(["run", "hello"]); + expect(refusedOverride.stderr.toString()).toContain( + "receipt-owned native home" + ); + expect(refusedOverride.stdout.toString()).toBe(""); + expect(refusedOverride.exitCode).toBe(1); + const ordinaryRun = run(["run", "hello"], { + HACK_NATIVE_HOME: undefined, + }); + expect(ordinaryRun.stderr.toString()).toBe(""); + expect(ordinaryRun.exitCode).toBe(0); + const selectedHome = `${channel}/versions/5.0.0-next.1/native-home`; + const matchingOverride = run(["run", "hello"], { + HACK_NATIVE_HOME: selectedHome, + }); + expect(matchingOverride.stderr.toString()).toBe(""); + expect(matchingOverride.exitCode).toBe(0); expect(Bun.which("python3", { PATH: path })).toBeNull(); expect(Bun.which("bun", { PATH: path })).toBeNull(); const launcher = join(channel, "bin/hack-next"); @@ -235,9 +258,20 @@ test.skipIf(!supported)( timeout: 30_000, }).stdout.toString() ).toContain("hack-next --manager"); - const launch = Bun.spawnSync([launcher, "hello"], { + const refusedLaunch = Bun.spawnSync([launcher, "hello"], { env, timeout: 30_000, + stdout: "pipe", + stderr: "pipe", + }); + expect(refusedLaunch.stderr.toString()).toContain( + "receipt-owned native home" + ); + expect(refusedLaunch.stdout.toString()).toBe(""); + expect(refusedLaunch.exitCode).toBe(1); + const launch = Bun.spawnSync([launcher, "hello"], { + env: nativeEnv, + timeout: 30_000, }); expect(launch.stderr.toString()).toBe(""); expect(launch.exitCode).toBe(0); @@ -248,11 +282,13 @@ test.skipIf(!supported)( `selectors:${channel}/versions/5.0.0-next.1/bundle/hack-native:${channel}/versions/5.0.0-next.1/native-home:` ); expect( - Bun.spawnSync([launcher, "exit-seven"], { env, timeout: 30_000 }) - .exitCode + Bun.spawnSync([launcher, "exit-seven"], { + env: nativeEnv, + timeout: 30_000, + }).exitCode ).toBe(7); const waiting = Bun.spawn([launcher, "wait-for-signal"], { - env, + env: nativeEnv, stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -304,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"], + ] as const) { + writeFileSync(fixtureStatus, status, { mode: 0o600 }); + try { + const rejected = run([ "upgrade", "--version", "5.0.0-next.2", @@ -314,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", diff --git a/tests/python-free-installer.test.ts b/tests/python-free-installer.test.ts index 650a12c6a..38beb77ee 100644 --- a/tests/python-free-installer.test.ts +++ b/tests/python-free-installer.test.ts @@ -57,14 +57,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({ @@ -120,6 +125,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", diff --git a/tests/upgrade-check.test.ts b/tests/upgrade-check.test.ts index 91d95d7bd..52353dbce 100644 --- a/tests/upgrade-check.test.ts +++ b/tests/upgrade-check.test.ts @@ -235,6 +235,34 @@ test("a project with only the legacy TOML config or only a Compose file is live, expect(report.registry.missing).toEqual([]); }); +for (const ready of [true, false]) { + test(`upgrade native configuration finding does not claim runtime readiness: ${ready}`, async () => { + const project = await liveProject("native-config-only"); + const findings = await inspectUpgradeProject({ + project: { ...project, project: project.name }, + homeDir: home, + deadline: Date.now() + 60_000, + nativePreflight: async () => ({ + ready, + blockers: ready ? 0 : 3, + warnings: 0, + }), + }); + const finding = findings.find((value) => value.id === "native-preflight"); + expect(finding?.status).toBe(ready ? "ok" : "info"); + expect(finding?.summary).toContain( + "runtime setup and runtime review were not checked" + ); + expect(finding?.summary).not.toContain("Ready for the native backend"); + if (ready) { + expect(finding?.summary).toContain("hack native setup"); + expect(finding?.summary).toContain("hack doctor --native-preflight"); + } else { + expect(finding?.summary).toContain("3 blocker(s)"); + } + }); +} + test("once the time budget is spent, project checks report not checked without running", async () => { const project = await liveProject("app-late"); let ran = false;