diff --git a/docs/env.md b/docs/env.md index e2c5a60a2..35af7494f 100644 --- a/docs/env.md +++ b/docs/env.md @@ -262,6 +262,7 @@ into a host command, and `--target` on env and host commands chooses the `host` | `hack host shell` | `--env`, `--scope`, `--target` | When the shell starts. | Start a new shell. | Runs on the host, not in a container. | | `hack config validate` | `--env` | Per invocation; resolves the native selection without reading managed env documents. | Not needed. | Authored native projects only; cannot be combined with `--file`. | | `hack config plan` | `--env` | Per invocation; metadata only (names, scopes, secret flags), never decrypted. | Not needed. | Authored native projects only. | +| `hack project target` | `--env`, `--service` | Per invocation, read-only; reports the overlay selection (explicit, project default or base) without reading env files or values. | Not needed. | Selection only: overlay existence is not checked; no runtime required. | | `hack session start` | `--env`, `--service` | When the workspace is created; the name gains `.env-` (`.env-base` for base spellings), and `--up` runs `hack up -d` with the same selection. | Start a new workspace or pass `--env` to `hack session exec`. | Injects into the workspace shell, not into containers. | | `hack session exec` | `--env`, `--service` | Per command; omitted reuses the selection encoded in the workspace name. | Each command resolves again. | Injects into the workspace command, not into containers. | diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 19cacd84b..f417fa166 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -30,8 +30,10 @@ latest-version lookup or automatic updater. By default installation creates a private, current-user-owned mode-`0700` `~/.hack-next` directory. Each immutable version directory contains a complete bundle, its own `native-home`, and its own `cli-home`. An installation receipt binds -the bundle hashes and both homes' filesystem identities. Stable Homebrew `hack`, -its links, and `~/.hack` are untouched. No shell profile or PATH is edited. +the bundle hashes and both homes' filesystem identities. Installation leaves stable +Homebrew `hack`, its links, and `~/.hack` untouched; only an explicit +[Compose test](#test-both-backends) uses your own Hack home. No shell profile or +PATH is edited. Run the candidate explicitly: @@ -41,9 +43,13 @@ Run the candidate explicitly: python3 "$HOME/.hack-next/manager.py" --root "$HOME/.hack-next" status ``` -`hack-next` selects the adjacent verified native executor and the selected version's -private native and CLI homes. These settings override inherited installation -selectors, including `HACK_GLOBAL_CONFIG_PATH`. Explicit native project adaptation, +By default `hack-next` uses the native backend. It selects the adjacent verified +native executor and the selected version's private native and CLI homes. These +settings override inherited installation selectors, including `HACK_NATIVE_BINARY`, +`HACK_NATIVE_HOME`, `HACK_HOME` and `HACK_GLOBAL_CONFIG_PATH`. To run the same +verified candidate on the Compose backend, set `HACK_RUNTIME_BACKEND=compose`; see +[Test both backends](#test-both-backends). Any other non-empty value refuses before +the candidate starts. Explicit native project adaptation, dependency, AWS profile, routing, and source-sharing settings still reach the CLI. Arguments, standard input and output, exit codes, and signals reach the selected candidate. Concurrent observation and stop commands can accompany a foreground @@ -63,7 +69,54 @@ Provider setup and actual application acceptance remain separate steps. Installa does not install the provider, boot a VM, enroll a project, move volumes, or activate system DNS or trust. Follow [native candidate preparation](native-candidate.md) using the selected bundle and its `native-home`. Candidate `hack-next update` -retains the existing refusal; use the channel manager's explicit upgrade command. +retains the existing refusal on both backends; use the channel manager's explicit +upgrade command. + +## Test both backends + +The same selected candidate can run on either backend. You do not need to call the +bundled `hack-cli` directly or create a private `HACK_HOME` to test Compose. + +| Backend | Command | Hack home | Routing, DNS and TLS | +| --- | --- | --- | --- | +| Native (default) | `"$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | The version's private `cli-home` and `native-home` | The candidate's native routing, set up through [native candidate preparation](native-candidate.md) | +| Compose | `HACK_RUNTIME_BACKEND=compose "$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | Your own `HACK_HOME` (default `~/.hack`) and `HACK_GLOBAL_CONFIG_PATH` | Your existing global Caddy, CoreDNS and local CA | + +With `HACK_RUNTIME_BACKEND=compose`, `hack-next` verifies the selected bundle as +usual and then runs its `hack-cli` on the Compose backend. It keeps your Hack home, +so `https://.hack` routes, container DNS and CA trust use the global +services that `hack global install` already set up. It removes `HACK_NATIVE_BINARY` +and `HACK_NATIVE_HOME` so native selections cannot reach Compose children. The +candidate's private homes are not used. Compose `up` still uses the configurable +Compose startup budget; a cold image build can need a larger +`HACK_COMPOSE_STARTUP_TIMEOUT_MS`. + +Compose testing shares state with stable Hack: + +- The candidate reads and writes the same project registry, global configuration + and project `.hack/` files as stable `hack`. Stable v4 uses an older registry + lock protocol, so do not run stable `hack` and the candidate against the same + home at the same time. Stop one before you start the other. +- `hack-next` does not set up the shared global services for you. Install or + change them with stable `hack global` commands. +- To test Compose in isolation instead, set `HACK_HOME` to a separate private + directory. That home has no global services, so routing, container DNS and CA + trust are not tested. + +Leave `HACK_RUNTIME_BACKEND` unset, or set it to `native`, to return to the native +default. A shell that exported `HACK_RUNTIME_BACKEND=compose` for other work also +selects Compose for `hack-next`; unset it before native testing. + +Backend selection needs the current channel manager. The manager released with +`5.0.0-next.3` through `5.0.0-next.5` always selects native, and ignores +`HACK_RUNTIME_BACKEND=compose`. Run the reviewed `upgrade-manager` operation below +once to receive backend selection. This works for every retained bundle, including +`5.0.0-next.5`. Candidate CLIs from `5.0.0-next.5` and earlier refuse `update` only +on the native backend; do not run `hack-next update` on Compose with those bundles. + +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. ## Upgrade and return to a retained candidate diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index b81fb7190..6ab6d4a40 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -110,8 +110,14 @@ export HACK_NATIVE_HOME=/absolute/private/candidate-home ``` Keep the bundle together and invoke this entrypoint by its full path (do not copy -or symlink only the script). It selects the adjacent executor even if inherited -backend variables select another binary. It preserves arguments, exit codes and +or symlink only the script). With `HACK_RUNTIME_BACKEND` unset (or `native`) it +selects the native backend and the adjacent executor, even if an inherited +`HACK_NATIVE_BINARY` selects another binary. An explicit +`HACK_RUNTIME_BACKEND=compose` runs the same bundled CLI on the Compose backend +with your own Hack home and existing global routing services. It needs no +`HACK_NATIVE_HOME` and removes the native selectors. Other values refuse with exit +code 64. The [candidate installer guide](https://github.com/hack-dance/hack/blob/next/docs/guides/candidate-install.md#test-both-backends) +describes Compose testing and its shared-state limits. It preserves arguments, exit codes and signals through `exec`, and never replaces the installed `hack`. Project-specific native adaptation, dependency and routing selections remain explicit; this entrypoint does not prepare a provider or migrate existing projects. An unversioned local diff --git a/docs/guides/portable-bootstrap-qualification.md b/docs/guides/portable-bootstrap-qualification.md index 709490027..218c32162 100644 --- a/docs/guides/portable-bootstrap-qualification.md +++ b/docs/guides/portable-bootstrap-qualification.md @@ -56,6 +56,18 @@ available container capabilities, and any skips. Review the tested Install script and Start skill before publishing the private environment. A local or ordinary Linux-container result does not qualify Codex Cloud by itself. +## Negative controls in Docker CI + +The hosted `docker-e2e` job also runs the `portable-bootstrap-negatives` scenario. +On the slim path, it checks that a missing key, a wrong key, an overlay sealed +under another key, a stale pinned image, a lockfile that no longer matches its +manifest and a corrupted dependency cache each stop the bootstrap without +changing a running app. It also checks that a second identical bootstrap leaves +no diff and does not reinstall. Each stage records its time and byte counts in a +value-free receipt. See +[the e2e harness guide](../../tests/e2e/README.md#portable-bootstrap-negative-controls) +for the stages and what they do not cover. + ## Repeat maintenance `scripts/maintain-codex-slim.sh` installs dependencies with diff --git a/docs/guides/v5-dogfooding.md b/docs/guides/v5-dogfooding.md index bae09b2e1..461b72311 100644 --- a/docs/guides/v5-dogfooding.md +++ b/docs/guides/v5-dogfooding.md @@ -9,8 +9,9 @@ compatibility, lower resource use or whole-product readiness. The [version-pinned candidate installer](candidate-install.md) provides `hack-next` alongside stable `hack`. It does not replace Homebrew Hack, edit PATH, convert -projects, move volumes, enable DNS or trust, or start a VM. Candidate homes and -stable state are separate. Do not alias `hack` to `hack-next` as a rollout step. +projects, move volumes, enable DNS or trust, or start a VM. On the default native +backend, candidate homes and stable state are separate; an explicit Compose test +uses your own Hack home. Do not alias `hack` to `hack-next` as a rollout step. Each candidate upgrade starts fresh native and CLI homes. Retained bundles keep their original homes; selecting another version does not migrate application data. @@ -35,6 +36,11 @@ These commands do not establish that an arbitrary v4 project can run natively. the [candidate guide](native-candidate.md), with admitted storage and an explicitly approved source share and network policy. A refused guard is a result to inspect, not permission to change host security, erase evidence or raise resource limits. + Test the backend the project will use. `hack-next` runs native by default; + `HACK_RUNTIME_BACKEND=compose hack-next` runs the same candidate on Compose with + your own Hack home, so global routing, DNS and CA trust are checked too. See + [Test both backends](candidate-install.md#test-both-backends), including why + stable `hack` must not run against the same home at the same time. 3. Exercise the project's supported command form: foreground start, readiness, status, finite logs, noninteractive exec, retaining stop and restart. Exercise one-off run only where admitted. For an authored project, check generated public diff --git a/docs/reference.md b/docs/reference.md index ae65b4fcf..bb35f50da 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -30,3 +30,4 @@ - [Integrations](./integrations.md) - [MCP tool contract](./reference/mcp-tools.md) — every MCP tool: annotations, inputs, argv, results, and the value-free env tools - [MCP logs contract](./reference/mcp-logs.md) — cutoff versus backend failure for the MCP log tools, and the cursor/handle decision +- [AX target and result contract](./reference/ax-target-result-contract.md) — provisional target schema, closed refusals and the read-only `hack project target` inspector diff --git a/docs/reference/ax-target-result-contract.md b/docs/reference/ax-target-result-contract.md new file mode 100644 index 000000000..4a6fe2213 --- /dev/null +++ b/docs/reference/ax-target-result-contract.md @@ -0,0 +1,241 @@ +# AX target and result contract (provisional) + +Status: **provisional decision record** (HACK-1144, WU31). Every command name, +tool name, field, reason and retry value on this page can still change in the +agent-experience (AX) compatibility review. It is not a stable interface +promise. Fields and codes that the review must confirm are marked +_provisional_ below. + +This page records how Hack names the thing an agent wants to act on (the +**target**), how a read-only inspector reports it, and how adapters (CLI and +MCP) share one result shape. The first implementation is the read-only +inspector `hack project target` and its MCP tool `hack.project.target`. + +## Decisions + +1. **A target is an exact tuple, not a name.** It is the project, the checkout, + the runtime instance, an optional service, the env overlay selection and a + config generation, plus the selector kind and the observation time. A + project name alone is never a target. +2. **Git branch is not runtime identity.** The checkout reports its current Git + branch, but the runtime instance is a separate field. An explicit instance + selector (`--branch`) wins over the branch-derived default, and the + inspector never creates, switches or deletes Git branches or worktrees. +3. **References grant no authority.** A resolved target identifies scope only + (`authority: "none"`). A caller that acts on it must revalidate at its own + effect boundary, for example by passing the generation it inspected. +4. **Equal names are not merged.** Registration ownership is decided by the + exact checkout root (or a recorded linked worktree of a registration), never + by name. Two registrations that share a name are refused as + `ambiguous_project` when selected by name, and are never renamed. +5. **Generation covers Hack config only.** The generation fingerprints + `.hack/hack.config.json`, `.hack/hack.toml` and `.hack/docker-compose.yml`. + Ordinary source edits do not change it. Qualifying an immutable source + revision is a separate, explicit concern and is not part of this contract. +6. **No fake atomic snapshot.** The inspector reads the generation before and + after resolving the other fields. If it changed during the read, the result + is the `generation_changed_during_read` refusal, not a mixed snapshot. +7. **Snapshot reads have no side effects.** No registry write (not even the + last-seen touch other commands perform), no engine or Docker call, no + startup, repair or global sync, and no Git mutation. The only child + processes are read-only Git queries that existing project commands already + run. +8. **Readiness is not claimed.** The snapshot does not probe the runtime. It + reports `runtime: { observed: false, reason: "not_probed" }` instead of + guessing whether containers exist or are healthy. +9. **One semantics across adapters.** The MCP tool spawns the same CLI command + and passes selection through unresolved, so every resolution refusal comes + from the same code. There is no MCP-only selection policy. The MCP server + only checks input grammar before spawning; any value it accepts is then + judged by the CLI. +10. **Errors reuse existing codes.** Refusals reuse the stable CLI error codes + in `HACK_ERROR_CODES`. The closed `detail.reason` is the discriminator. + Adding a dedicated code (for example for staleness) is left to the + compatibility review. + +## Commands + +| Surface | Name (_provisional_) | Class | +| --- | --- | --- | +| CLI | `hack project target [--project=…\|--path=…] [--branch=…] [--service=…] [--env=…] [--expect-generation=…] [--json]` | read-only | +| MCP | `hack.project.target` (inputs `projectName`, `path`, `branch`, `service`, `env`, `expectGeneration`) | read-only | + +The MCP tool runs `hack project target --json` with the same options. Its +`data` is the CLI envelope, re-validated against this contract (`null` when +the CLI output does not match). `stdout` is withheld because `data` carries the +same envelope. + +## Result envelope + +The CLI prints exactly one envelope with `--json` and exits 0 on success and 1 +on a refusal. + +```json +{ + "ok": true, + "data": { + "schema": "hack.target.v0", + "provisional": true, + "observed_at": "2026-10-19T12:00:00.000Z", + "authority": "none", + "selector": { + "kind": "path", + "branch_explicit": false, + "env_explicit": false, + "expected_generation": null + }, + "project": { + "name": "alpha", + "registration": { "id": "alpha", "name": "alpha", "match": "worktree" }, + "project_dir": "/work/alpha-login/.hack" + }, + "checkout": { + "root": "/work/alpha-login", + "kind": "linked", + "git_branch": "feature/login" + }, + "instance": { + "branch": "feature-login", + "source": "worktree", + "compose_project": "alpha--feature-login" + }, + "service": null, + "services": ["api", "web"], + "env": { "overlay": null, "source": "base" }, + "generation": { + "id": "cfg-v0:0123456789abcdef0123456789abcdef", + "scope": "hack_config", + "inputs": [ + { "file": "hack.config.json", "present": true }, + { "file": "hack.toml", "present": false }, + { "file": "docker-compose.yml", "present": true } + ] + }, + "runtime": { "observed": false, "reason": "not_probed" } + } +} +``` + +| Field | Meaning | +| --- | --- | +| `selector.kind` | `project` (by registered name), `path` or `cwd` | +| `project.registration` | The registration that owns this exact checkout root (`match: root`) or lists it as a linked worktree (`match: worktree`); `null` when unregistered | +| `checkout.kind` | `primary`, `linked` (a linked Git worktree) or `not_git` | +| `instance.source` | `explicit` (`--branch`), `worktree` (derived from a linked worktree's branch) or `default` | +| `instance.branch` | The instance slug. An explicit `--branch` is slugged like `hack up --branch` (`Feature/X` becomes `feature-x`) | +| `instance.compose_project` | The Compose project name commands use for this instance (`--` for a branch instance) | +| `services` | Service names declared in the Compose file, sorted; `[]` when the file is absent or has no `services` key. A Compose file that is not YAML, whose top level is not a mapping, or whose `services` is not a mapping is refused as `compose_parse_failed` | +| `env.source` | `explicit`, `project_default` (configured default overlay) or `base`; selection only, overlay existence is not checked | +| `generation.id` | `cfg-v0:` plus 32 hex characters; pass it back as `--expect-generation` | + +All fields are _provisional_. + +## Refusals + +A refusal is `{ ok: false, error: { code, message, detail } }`. `message` is +fixed text that never repeats caller input, paths or registry values. +`detail` is `{ reason, stage, retry, provisional: true }`, plus +`observed_generation` for `stale_generation` only. + +| `reason` (_provisional_) | `code` | `stage` | `retry` | +| --- | --- | --- | --- | +| `conflicting_selectors` | `E_USAGE` | `selector` | `fix_selector` | +| `invalid_selector` | `E_USAGE` | `selector` | `fix_selector` | +| `project_not_found` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | +| `ambiguous_project` | `E_USAGE` | `project` | `fix_selector` | +| `registered_root_missing` | `E_PROJECT_NOT_FOUND` | `project` | `fix_project` | +| `path_not_found` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | +| `no_project_context` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | +| `project_inputs_unsupported` | `E_CONFIG_INVALID` | `project` | `fix_project` | +| `config_parse_failed` | `E_CONFIG_PARSE` | `project` | `fix_project` | +| `compose_parse_failed` | `E_CONFIG_PARSE` | `project` | `fix_project` | +| `detached_checkout` | `E_USAGE` | `instance` | `fix_selector` | +| `service_not_found` | `E_SERVICE_NOT_FOUND` | `service` | `fix_selector` | +| `stale_generation` | `E_USAGE` | `generation` | `reinspect` | +| `generation_changed_during_read` | `E_USAGE` | `generation` | `reinspect` | +| `generation_input_too_large` | `E_CONFIG_INVALID` | `generation` | `fix_project` | +| `input_permission_denied` | `E_PERMISSION` | `project` | `fix_project` | +| `input_unreadable` | `E_CONFIG_INVALID` | `project` | `fix_project` | + +A filesystem fault while reading an input (for example a Compose file with no +read permission, or a directory where a config file belongs) is reported as +`input_permission_denied` (`EACCES`, `EPERM`) or `input_unreadable` (any other +filesystem error). The OS message is never surfaced, because it contains the +absolute path. + +`retry` is advice for the caller. Like suggested actions and application logs, +it carries no execution authority. + +The CLI parser refuses a malformed `--project` or `--env` value before the +inspector runs, as it does for every command. That refusal is +`{ ok: false, error: { code: "E_USAGE", message } }` with no `detail`. The MCP +server refuses grammar-invalid `branch`, `service`, `env`, `projectName` length +or `expectGeneration` before spawning, as `invalid_arguments` with +`spawned: false`. These are input-shape refusals; every resolution refusal +above is identical on both surfaces. + +These parser refusals are the only detail-less errors in the contract. A +non-filesystem fault outside the catalogue (a defect) still makes the CLI print +`E_UNEXPECTED` with free text and no `detail`. That envelope is off contract: +the MCP adapter accepts a detail-less error only when its code is `E_USAGE`, so +it returns `data: null` for any other. + +## Result semantics for future operations + +These rules are recorded for the operations that will consume targets (WU20, +WU21). The inspector itself only produces snapshots. + +- Separate **accepted work**, **terminal result** and **bounded observation**. + Work that failed but was observed successfully is not task success. +- Report partial results and freshness windows honestly; never present a + stitched read as an atomic snapshot. +- Preserve the compatible `ok`/`data`/`error`/`detail` envelope and documented + child exit codes. +- Never include secret values or ciphertext in inspect or env metadata. + +## Compatibility matrix + +| Concern | CLI `hack project target` | MCP `hack.project.target` | Evidence | +| --- | --- | --- | --- | +| Two projects, selection by name | yes | yes | paired fixture | +| Two branches (linked worktree default, explicit instance) | yes | yes | paired fixture | +| Explicit instance slug (`Feature/X` gives `--feature-x`) | yes | yes | paired fixture | +| Env selection: project default, explicit overlay, `base` | yes | yes | paired fixture | +| Conflicting selectors | `conflicting_selectors` | same | paired fixture | +| Stale generation | `stale_generation` | same | paired fixture | +| Source edit keeps generation | yes | yes | paired fixture | +| Ambiguous equal names, moved checkout, unknown project | closed refusals | same | paired fixture | +| Two registrations owning one checkout | `ambiguous_project` | same | paired fixture | +| Malformed Hack config or Compose file, oversized input | `config_parse_failed`, `compose_parse_failed`, `generation_input_too_large` | same | paired fixture | +| Unreadable input (directory in place of a file, no read permission) | `input_unreadable`, `input_permission_denied` | same | paired fixture (permission case skipped as root) | +| Detached linked worktree, no project, missing path | closed refusals | same | paired fixture | +| Unknown service | `service_not_found` | same | paired fixture | +| Config changed during read | `generation_changed_during_read` | same code path | domain test | +| No registry, state or Git writes | yes | yes (audit log only) | paired fixture | +| Malformed selector | `invalid_selector` / parser `E_USAGE` | `invalid_arguments`, not spawned | paired fixture | +| Drifted CLI output, detail-less non-`E_USAGE` error, data claiming authority | n/a | `data: null`; foreign fields dropped | stubbed CLI fixture | + +The fixtures live in `tests/project-target-inspect.test.ts`. + +## Known gaps + +- **Runtime readback.** The snapshot never observes containers, so it does not + prove an instance exists, is running or is healthy. Actual container and env + readback for two projects and two branches needs a host with an engine and + is not covered here. +- **Concurrent sessions and slow or failed probes.** Not exercised: the + inspector has no runtime probe yet. +- **Env overlays.** Selection only; overlay existence and resolved keys are not + checked. Use `hack env list` or `hack.project.env.keys` for key metadata. +- **Compose validity.** The inspector refuses a Compose file it cannot parse + as a YAML mapping, but it does not run `docker compose config`, so a file + that parses but that Compose would reject (for example an invalid service + definition) is still reported as a target. +- **Generation scope.** Worktree-local overrides, env files and native + `hack.project.json` inputs are not part of the generation. Native projects + are refused as `project_inputs_unsupported`. +- **Pagination and byte limits.** The result is small and unpaginated; the + rule that critical blockers survive pagination applies to later list + operations. +- **Capability-limited clients and unknown provenance.** Not modeled. +- WU20, WU21 and WU28 runtime and AX gates remain open. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f27f6501d..533ea57d1 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -23,6 +23,7 @@ hack project [options] | Command | Summary | | --- | --- | | `hack project owner` | Inspect project ownership | +| `hack project target` | Inspect the exact project, checkout, instance and service a command would target (read-only, provisional) | ### Options @@ -56,6 +57,31 @@ hack project owner [options] | `--help, -h` | Show help | | `--version, -v` | Show version | +## `hack project target` + +Inspect the exact project, checkout, instance and service a command would target (read-only, provisional) + +### Usage + +```bash +hack project target [options] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--path, -p ` | Run a project command against a repo path (overrides cwd search) | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--branch ` | Run against a branch-specific instance (compose name + hostnames) | +| `--service ` | Also resolve one Compose service of the target | +| `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | +| `--expect-generation ` | Refuse unless the project's config generation still equals this id | +| `--json` | Output JSON (machine-readable) | +| `--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 init` diff --git a/docs/reference/mcp-logs.md b/docs/reference/mcp-logs.md index a734b9277..f6cab90d3 100644 --- a/docs/reference/mcp-logs.md +++ b/docs/reference/mcp-logs.md @@ -64,6 +64,23 @@ To continue a stream approximately, call again with `since` set to the last event's `ts`. `since` selects the Loki backend and cannot be combined with `compose`. Expect overlap at the boundary and remove duplicates on the client. +## Container restarts + +A restart of the logged container does not change the contract. A tail that +spans the restart still ends in one outcome within its bound. Compose may +reattach to the restarted container from a coarse timestamp, so a tail that +spans the restart can repeat a few lines from before it. A snapshot taken after +the restart holds each line once, in order, and a new tail starts from the +current output. + +## Verification + +`tests/mcp-logs-contract.test.ts` checks each outcome against a scripted stub +backend. The `mcp-logs-compose` scenario in the optional `docker-e2e` CI job +checks the same outcomes through `hack mcp serve` against a real Compose +project (see `tests/e2e/README.md`). It does not cover the Loki backend or the +native/authored logs path. + ## Decision: no handles, cursors or idempotency keys (no-go) Status: no-go for now (HACK-1134). This decision adds no cursor implementation. diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 237bf124c..2e8b10d2c 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -38,6 +38,7 @@ Every tool sets `openWorldHint: false`. Mutating tools set `idempotentHint: fals | `hack.project.env.explain` | read-only | `hack env explain --json -- ` | | `hack.project.env.keys` | read-only | `hack env list --json` | | `hack.project.instances` | read-only | `hack projects --json --project=` | +| `hack.project.target` | read-only | `hack project target --json` (provisional) | ## Project selection @@ -195,6 +196,26 @@ projects and carries container labels, mounts and networks, which `data` omits. `stderr` is returned as captured. `found` is `false` when the project is registered but the CLI listing does not include it. +## `hack.project.target` + +Resolves the exact target a command would act on: `hack project target --json +[--project=…] [--path=…] [--branch=…] [--service=…] [--env=…] +[--expect-generation=…]`. The tool, its inputs and the result schema are +provisional; see the [AX target and result contract](./ax-target-result-contract.md). + +- Inputs: `projectName`, `path`, `branch`, `service`, `env` and + `expectGeneration`, all optional. +- Unlike the other project tools, selection is not resolved by the MCP server. + `projectName` and `path` are passed through, both of them when both are + given, so the CLI owns every resolution refusal (for example + `conflicting_selectors`) and the MCP result matches the CLI exactly. +- `data` is the CLI's JSON envelope, re-validated against the contract, or + `null` when the output does not match it. A detail-less error is accepted + only as a parser `E_USAGE` refusal, so an `E_UNEXPECTED` fault message never + reaches `data`. `stdout` is always `""`, because + `data` carries the same envelope. `stderr` is returned as captured. +- The command never probes the engine, writes the registry or changes Git. + ## Audit log Every spawned command appends one JSON line to `$HACK_HOME/mcp-audit.log` (by diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index e27d7f184..8c42abcd4 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -68,7 +68,7 @@ provider contact. A refusal names no option value, path or project detail. | --- | --- | --- | | `up` | Whole-project foreground; `--branch`, `--profile` and `--env` (including `--env base`) keep their compiler contracts | `--detach`, `--json`, `--recover`, service subsets, single-service, command, workdir, follow, tail and format options, other unsupported options | | `down` | Whole-project owner-mediated stop of a ready generation through its live foreground owner or private no-host endpoint | `--json`, `--detach`, `--profile` and `--env` overrides, service subsets including an empty selection, single-service, command, workdir, follow, tail and format options | -| `down --recover` | Explicit retirement of a complete stored generation whose owners are dead; omit `--profile` | `--json`, `--detach`, explicit empty or named `--profile`, `--env` overrides, service subsets, single-service, command, workdir, follow, tail and format options | +| `down --recover` | Explicit retirement of a complete stored generation whose owners are dead, or of a pending startup intent that never reserved runtime state; omit `--profile` | `--json`, `--detach`, explicit empty or named `--profile`, `--env` overrides, service subsets, single-service, command, workdir, follow, tail and format options | | `ps` | `ps` and `ps --json` for the exact saved project and explicit `--branch` | `--profile`, `--env`, service subsets or a single service, `--recover`, `--detach`, command, workdir, follow, tail and format options | | `logs` | `logs SERVICE --no-follow` with `--tail` from 1 through 1000 (default 200), plain or `--json` | Follow mode (the default), omitted, multiple or malformed service selection, non-integer or out-of-range `--tail`, pretty format, time and query filters, `--profile`, `--env`, `--recover`, `--detach`, command and workdir options | | `exec` | `exec SERVICE -- COMMAND ARG...` with closed stdin, no TTY and an optional absolute `--workdir` | Empty command or empty first argument, NUL bytes, more than 256 arguments, an argument over 16 KiB or 64 KiB in total, relative `--workdir`, interactive or TTY input, `--json`, `--profile`, `--env`, service subsets, follow, tail and format options, `--recover`, `--detach` | @@ -90,6 +90,21 @@ inspection also refuses, the captured native startup code takes precedence over the inspection code. That preserves the failure's owning boundary; it does not prove cleanup. Retained bindings and recovery requirements stay in force. These diagnostics do not grant retry or cleanup authority. + +A native consumer can refuse after the startup intent is reserved but before it +reserves any runtime journal. For example, a `repository@sha256:` image reaches +execution and refuses `native_graph_admission`. Such a run has no receipt, so the +Removed retirement inspection can never succeed. After the consumer process has +settled, startup asks the native runtime to inspect the exact run. Startup retires +the startup intent and its source envelope only when the runtime refuses +`native_graph_unreserved`: the run's journal directory is exactly absent under +private state directories, and no foreground owner is published for the run. The +failure then reports outcome `unreserved` with the original native code, and `ps` +reports `not_started`. Any other inspection result keeps the attempt retained. +Examples include `native_graph_receipt` for damaged or foreign evidence, +`native_graph_foreground` for a published owner, and a stopped runtime. This +applies only to attempts without lifecycle hooks, host processes or a live-stop +owner. A canceled `unreserved` startup exits 130, like confirmed removal. Readiness requires authenticated current observations and unchanged input before publication. The foreground owner remains attached until owned shutdown finishes; Ctrl-C requests that shutdown. Exact durable Removed evidence retires the tagged @@ -107,7 +122,10 @@ Only fresh runtime observations supply state and health. Stored readiness and ph do not prove that a service is still live. A project with no saved attempt reports `not_started`; a retained startup intent -without a Ready mapping reports `pending` with no confirmed service rows. When a +without a Ready mapping reports `pending` with no confirmed service rows. That +state covers a startup in progress and a retained incomplete attempt. When no +startup is running, `down --recover` can retire the attempt if it never reserved +runtime state (see below). When a Ready generation is saved but the candidate runtime is stopped (native code `runtime_not_running`, or the authenticated `native_graph_foreground` control code with that exact cause), `ps` exits 0 and reports `runtime_not_running` with the @@ -184,6 +202,19 @@ nor acquires managed values or starts a provider. Omit `--profile` to recover the stored generation; explicit empty or named profile overrides both refuse. +When the selection has a retained startup intent but no Ready mapping and no +recovery intent, `down --recover` takes a different path. It handles only an +incomplete startup that never reserved runtime state. One example is an attempt +retained by an earlier CLI before this retirement existed. The command first +takes a fresh startup admission. A live or interrupted foreground CLI still holds +that admission, so the command refuses as busy in that case. It also requires that +no Ready mapping, lifecycle-hook owner or live-stop owner exists. It then inspects +the exact run. It retires the start intent and the validated source envelope only +when the native runtime refuses `native_graph_unreserved`. It removes the source +first, so an interrupted retirement keeps the start intent blocking. Any receipt, +any other refusal, or a successful inspection retains every file. This path does +not compile input, acquire values, serve a run or touch guest resources. + The closed version 3 publication and version 1 recovery selector remain supported with their original calendar boot qualifier; clock drift conservatively refuses that legacy recovery. New version 4 owners use version 2 UUID selectors. Missing, diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 4ebe01556..874608f2d 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -264,7 +264,11 @@ ordinary frontend selection yet and does not implement reactive health or hooks. The optional feature exposes this bounded consumer through a distinct public CLI: `graph native plan --source-file FILE --json`, then `graph native run --source-file FILE --expect-review SHA --json`. -`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. These commands +`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. On macOS, +`inspect` refuses `native_graph_unreserved` before guest access only when the run's journal +directory is exactly absent under private state directories and no foreground owner is +published for the run. It is a point-in-time observation, not proof that no consumer can +still start. A partial, public or present journal keeps the ordinary receipt refusal. These commands are explicit bounded prerequisites, not full ordinary project parity. Their project network does not publish ports or grant host-service access. Normal native `hack up` continues to require the full source/storage/routing/host and foreground-owner contract. diff --git a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs index af74ff024..165e4a529 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs @@ -586,6 +586,47 @@ fn abandoned_publication_is_evidence_and_never_implicit_recovery_authority() { assert!(fixture.owner_root().join("owner.json").exists()); } +#[test] +fn inspect_names_only_an_exactly_absent_unpublished_journal_as_unreserved() { + // HACK-1272: a startup refused before reservation must be distinguishable from + // damaged or foreign receipt evidence, without weakening either refusal. + let fixture = Fixture::new(); + let code = |fixture: &Fixture| inspect(&fixture.candidate, RUN).unwrap_err().code; + assert_eq!(code(&fixture), "native_graph_unreserved"); + assert!( + !journal::directory(&fixture.candidate, RUN) + .unwrap() + .exists() + ); + + // A published (live or abandoned) owner is evidence, never "unreserved". + let receipt = fixture.receipt(); + let mut publication = owner::Publication::bind(&fixture.candidate, &receipt.review).unwrap(); + assert_eq!(code(&fixture), "native_graph_foreground"); + publication.finish().unwrap(); + assert_eq!(code(&fixture), "native_graph_unreserved"); + + // A partial journal directory keeps the ordinary receipt refusal. + let root = journal::directory(&fixture.candidate, RUN).unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .recursive(true) + .create(&root) + .unwrap(); + assert_ne!(code(&fixture), "native_graph_unreserved"); + fs::remove_dir(&root).unwrap(); + + // A public ancestor refuses instead of proving absence. + let parent = root.parent().unwrap(); + fs::set_permissions(parent, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(code(&fixture), "native_graph_receipt"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).unwrap(); + + // A reserved journal is validated as before. + journal::reserve(&fixture.candidate, &receipt).unwrap(); + assert_ne!(code(&fixture), "native_graph_unreserved"); +} + #[test] fn direct_operation_guard_refuses_replaced_lock_path() { let fixture = Fixture::new(); diff --git a/packages/runtime-core/src/provider/graph/native/journal.rs b/packages/runtime-core/src/provider/graph/native/journal.rs index 576f85a77..75908f5f4 100644 --- a/packages/runtime-core/src/provider/graph/native/journal.rs +++ b/packages/runtime-core/src/provider/graph/native/journal.rs @@ -555,6 +555,40 @@ fn valid_network_name(value: &str) -> bool { || matches!(*byte, b'-' | b'_' | b'.') }) } +/// Stable refusal for a run that never reserved native runtime state. +#[cfg(target_os = "macos")] +pub(super) fn unreserved() -> CandidateError { + error( + "native_graph_unreserved", + "Native runtime state for this run was never reserved: its journal is absent and no foreground owner is published. This point-in-time observation does not prove that no consumer can still start; no state was created, repaired or adopted.", + ) +} +/// True only when this run's journal directory is exactly absent beneath private +/// existing ancestors. A present, partial or unreadable path returns false so the +/// ordinary receipt validation still owns it. Read-only; nothing is created. +#[cfg(target_os = "macos")] +pub(super) fn absent(candidate: &Candidate, run: &str) -> Result { + let root = directory(candidate, run)?; + match fs::symlink_metadata(&root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return Ok(false), + } + for path in root + .ancestors() + .skip(1) + .take_while(|path| path.starts_with(&candidate.state_root)) + { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => state::check_private_directory(path).map_err(|_| refused())?, + Err(_) => return Err(refused()), + } + } + Ok( + fs::symlink_metadata(&root) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound), + ) +} pub(super) fn directory(candidate: &Candidate, run: &str) -> Result { if !hex(run, 32) { return Err(refused()); diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index aa8acd468..074c93ea7 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -1286,7 +1286,20 @@ fn logs_with( } /// Read-only same-incarnation inspection. A historical ready phase is not current health. +/// +/// A run whose journal is exactly absent and whose foreground owner is not +/// published refuses `native_graph_unreserved` before any guest access. Absence +/// is a point-in-time observation: only a caller that separately proved its +/// consumer settled may treat it as "never reserved". Every other journal state +/// keeps the ordinary receipt validation below. pub fn inspect(candidate: &Candidate, run: &str) -> Result { + #[cfg(target_os = "macos")] + if journal::absent(candidate, run)? { + super::foreground::require_unpublished(candidate, run)?; + if journal::absent(candidate, run)? { + return Err(journal::unreserved()); + } + } let engine = Engine::connect_cleanup(candidate)?; let (receipt, _) = journal::load( candidate, diff --git a/plugins/hack/skills/hack-cli/references/patterns.md b/plugins/hack/skills/hack-cli/references/patterns.md index c65735eff..6840c5e0d 100644 --- a/plugins/hack/skills/hack-cli/references/patterns.md +++ b/plugins/hack/skills/hack-cli/references/patterns.md @@ -183,13 +183,13 @@ missing part with ad hoc scripts. ## HACK-P11: Stable agent guidance and typed operation results - Use when: Agents run project workflows and need versioned guidance plus machine-readable results. -- Disposition: Partial. Missing: A guidance freeze during execution and a typed terminal result for resumed background operations remain open. +- Disposition: Partial. Missing: A freeze that refuses explicit guidance updates while an agent runs, and a typed terminal result for resumed background operations, remain open. - Owner: WU19/WU21; tracked in HACK-1155. - Commands: `hack up --json`, `hack down --json`, `hack restart --json`, `hack doctor --json`, `hack run -- ` - Prerequisites: Generated guidance from Hack's canonical source with a content revision; `--json` on lifecycle and diagnostic commands. - Env ownership: Results report names and states, not values. -- Readiness: Pass child argv after `--` in `hack run -- `. Check the exit status or `--json` envelope directly rather than through a pipe filter. -- Cleanup: Guidance updates are explicit `hack setup` operations, never side effects of ordinary commands. +- Readiness: Pass child argv after `--` in `hack run -- `; its exit status, and the MCP run result's `exitCode`, are the child's own. Check the exit status or `--json` envelope directly rather than through a pipe filter. +- Cleanup: Guidance updates are explicit `hack setup` operations, never side effects of ordinary commands. The `agent-surface-freeze` E2E scenario checks that init, agent, doctor and setup-sync runs leave tracked guidance and client config byte-identical. - Provenance: 2 consumers (HACK-P11 research register). @@ -214,4 +214,4 @@ missing part with ad hoc scripts. --- -Content revision: `ff1faf16f047`. Generated by Hack; edit the canonical source and regenerate the plugin. +Content revision: `03a667cfa6a4`. Generated by Hack; edit the canonical source and regenerate the plugin. diff --git a/scripts/hack-v5.sh b/scripts/hack-v5.sh index beda8d690..56089d824 100644 --- a/scripts/hack-v5.sh +++ b/scripts/hack-v5.sh @@ -1,15 +1,36 @@ #!/bin/sh -# Relocatable frontend for an explicitly selected native candidate home. +# Relocatable frontend for an explicitly selected candidate bundle. +# +# 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 +# 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. set -eu bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) -case "${HACK_NATIVE_HOME:-}" in - /*) ;; - *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; +backend=${HACK_RUNTIME_BACKEND:-native} +case "$backend" in + native|compose) ;; + *) echo "HACK_RUNTIME_BACKEND must be native or compose (unset selects native)." >&2; exit 64 ;; esac +if [ "$backend" = native ]; then + case "${HACK_NATIVE_HOME:-}" in + /*) ;; + *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; + esac +fi if [ ! -x "$bundle/hack-cli" ] || [ ! -x "$bundle/hack-native" ]; then echo "Incomplete candidate bundle: hack-cli and hack-native are required." >&2 exit 69 fi -export HACK_RUNTIME_BACKEND=native -export HACK_NATIVE_BINARY="$bundle/hack-native" +# Candidate CLIs refuse self-update on either backend. +export HACK_CANDIDATE_ENTRYPOINT=hack-v5 +if [ "$backend" = native ]; then + export HACK_RUNTIME_BACKEND=native + export HACK_NATIVE_BINARY="$bundle/hack-native" +else + unset HACK_NATIVE_BINARY HACK_NATIVE_HOME + export HACK_RUNTIME_BACKEND=compose +fi exec "$bundle/hack-cli" "$@" diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py index c85b4a7a8..010fd8a1a 100644 --- a/scripts/install-prerelease.py +++ b/scripts/install-prerelease.py @@ -67,6 +67,8 @@ "d39e77623be1876567e9db66c468e8da96067aeac0d88089df28efc11f850ef8", "e2e2bcbacf58b21d1086047c94727e5e814776384439700adcf5aabe5c49c2fa", }) +# Launch backends. Unset or empty selects native; anything else refuses. +BACKENDS = frozenset(("native", "compose")) MANAGER_UPGRADE = ".manager-upgrade.json" @@ -782,9 +784,24 @@ def verify_version(self, version, checksum): "Candidate home identity changed.") return directory - def environment(self, version): + def environment(self, version, backend="native"): + """Child environment for one launch. + + Native pins this version's executor and both private homes, overriding + inherited installation selectors. Compose is an explicit caller choice: + it keeps the caller's HACK_HOME and HACK_GLOBAL_CONFIG_PATH so the + existing global Caddy/CoreDNS and local CA apply, and removes native + selectors. Either way the CLI knows it is a candidate and refuses + self-update. + """ directory = self.root / "versions" / version environment = dict(os.environ) + environment["HACK_CANDIDATE_ENTRYPOINT"] = "hack-next" + if backend == "compose": + environment.pop("HACK_NATIVE_BINARY", None) + environment.pop("HACK_NATIVE_HOME", None) + environment["HACK_RUNTIME_BACKEND"] = "compose" + return environment environment.pop("HACK_GLOBAL_CONFIG_PATH", None) environment.update({"HACK_RUNTIME_BACKEND": "native", "HACK_NATIVE_BINARY": str(directory / "bundle/hack-native"), @@ -901,6 +918,9 @@ def install(self, version, archive=None, checksum=None, upgrade=False): remove_created_stage(stage, created) def run(self, arguments): + backend = os.environ.get("HACK_RUNTIME_BACKEND") or "native" + require(backend in BACKENDS, + "HACK_RUNTIME_BACKEND must be native or compose (unset selects native).") version = self.state["selected"] require(version is not None, "No candidate selected. Use your ordinary stable hack command.") directory = self.verify_version(version, self.state["installed"][version]) @@ -908,8 +928,11 @@ def run(self, arguments): # Shared launcher locks permit ps/down alongside foreground up, while an # exclusive selection switch refuses any in-flight launcher. Background # runtime state is gated by the owning executor on later switches. - child = subprocess.Popen([str(directory / "bundle/hack-v5"), *arguments], - env=self.environment(version)) + # Compose runs the verified CLI directly: retained bundles' hack-v5 + # entrypoints always select native and would ignore the choice. + entrypoint = "bundle/hack-cli" if backend == "compose" else "bundle/hack-v5" + child = subprocess.Popen([str(directory / entrypoint), *arguments], + env=self.environment(version, backend)) handlers = {} def forward(signum, _frame): child.send_signal(signum) diff --git a/scripts/portable-bootstrap-smoke.ts b/scripts/portable-bootstrap-smoke.ts index 204b102ec..7ba1ac508 100644 --- a/scripts/portable-bootstrap-smoke.ts +++ b/scripts/portable-bootstrap-smoke.ts @@ -204,7 +204,10 @@ function isRecord(value: unknown): value is Record { * v1:base64(iv):base64(tag):base64(ciphertext), AES-256-GCM, SHA-256(key text). * The synthetic key and plaintext never become fixture files or CLI arguments. */ -function encryptSynthetic(opts: { key: string; secret: string }): string { +export function encryptSynthetic(opts: { + key: string; + secret: string; +}): string { const iv = randomBytes(12); const cipher = createCipheriv( "aes-256-gcm", diff --git a/src/agents/pattern-catalogue.ts b/src/agents/pattern-catalogue.ts index 84ec04b4f..b0797f73a 100644 --- a/src/agents/pattern-catalogue.ts +++ b/src/agents/pattern-catalogue.ts @@ -361,14 +361,14 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ "Generated guidance from Hack's canonical source with a content revision; `--json` on lifecycle and diagnostic commands.", envOwnership: "Results report names and states, not values.", readiness: - "Pass child argv after `--` in `hack run -- `. Check the exit status or `--json` envelope directly rather than through a pipe filter.", + "Pass child argv after `--` in `hack run -- `; its exit status, and the MCP run result's `exitCode`, are the child's own. Check the exit status or `--json` envelope directly rather than through a pipe filter.", cleanup: - "Guidance updates are explicit `hack setup` operations, never side effects of ordinary commands.", + "Guidance updates are explicit `hack setup` operations, never side effects of ordinary commands. The `agent-surface-freeze` E2E scenario checks that init, agent, doctor and setup-sync runs leave tracked guidance and client config byte-identical.", negativeControl: "Filtered shell text can hide a failed child, and historical dispatch, PR or gateway requests must not silently expand current product scope.", domainOwner: "WU19/WU21", missingContract: - "A guidance freeze during execution and a typed terminal result for resumed background operations remain open.", + "A freeze that refuses explicit guidance updates while an agent runs, and a typed terminal result for resumed background operations, remain open.", provenance: { consumers: 2, source: "HACK-P11 research register" }, example: null, e2eScenario: null, diff --git a/src/backends/native-authored-project-run.ts b/src/backends/native-authored-project-run.ts index 316d8f7b7..308311c52 100644 --- a/src/backends/native-authored-project-run.ts +++ b/src/backends/native-authored-project-run.ts @@ -45,6 +45,10 @@ import { requestNativeAuthoredLiveStop, } from "./native-authored-live-stop.ts"; import type { NativeAuthoredProcessIncarnation } from "./native-authored-process-incarnation.ts"; +import { + NATIVE_GRAPH_UNRESERVED, + NativeRuntimeRequestError, +} from "./native-runtime-client.ts"; const LIMIT = 64 * 1024; const SOURCE_LIMIT = 1024 * 1024; @@ -140,6 +144,19 @@ export type NativeAuthoredProjectAdmission = { readonly expectedRun?: NativeAuthoredProjectRunSelection; readonly cleaned: NativeAuthoredReceipt; }) => Promise; + /** + * Retire a start intent whose native consumer never reserved runtime state + * (HACK-1272). `refusal` must be the fresh `native_graph_unreserved` inspection + * refusal for this exact run, obtained while this admission is held and after + * the caller proved no consumer for the run can still start. The ready mapping, + * hook owner and live-stop owner must be absent. The run's retained source is + * removed first (absence is accepted) so an interruption keeps the start + * intent blocking. No receipt, journal or guest state is read or changed here. + */ + readonly retireUnreserved: (opts: { + readonly expectedStart: NativeAuthoredProjectStartSelection; + readonly refusal: unknown; + }) => Promise; }; function refused(): never { throw new Error( @@ -540,43 +557,7 @@ export async function withNativeAuthoredProjectRecoveryStorage( ); }, async readSource(run) { - const current = await readPrivate(sourcePath(run), SOURCE_LIMIT); - const value: unknown = JSON.parse(current.text); - if ( - !( - isRecord(value) && - keys( - value, - value.version === 3 || value.version === 5 - ? "branch,env_metadata,hook_permit,kind,overlay,profiles,project,run,version" - : "branch,env_metadata,kind,overlay,profiles,project,run,version" - ) && - (value.version === 2 || - ((value.version === 3 || value.version === 5) && - validHookPin(value.hook_permit))) - ) || - value.kind !== "native-graph-source" || - value.project !== store.identity.projectRoot || - value.branch !== store.identity.branch || - value.run !== run || - !Array.isArray(value.profiles) || - value.profiles.length > 64 || - value.profiles.some( - (profile: unknown) => - typeof profile !== "string" || !validBranch(profile) - ) || - !( - value.overlay === "inherit" || - value.overlay === "base" || - (isRecord(value.overlay) && - keys(value.overlay, "named") && - typeof value.overlay.named === "string" && - validBranch(value.overlay.named)) - ) || - !parseNativeEnvMetadata(value.env_metadata) - ) { - return refused(); - } + const current = await readRetainedSource(store, sourcePath(run), run); await store.check(); return { dev: current.info.dev, @@ -587,6 +568,70 @@ export async function withNativeAuthoredProjectRecoveryStorage( }); }); } +/** Read one retained private source envelope and require its exact kind, scope and run. */ +async function readRetainedSource(store: Store, path: string, run: string) { + const current = await readPrivate(path, SOURCE_LIMIT); + const value: unknown = JSON.parse(current.text); + if ( + !( + isRecord(value) && + keys( + value, + value.version === 3 || value.version === 5 + ? "branch,env_metadata,hook_permit,kind,overlay,profiles,project,run,version" + : "branch,env_metadata,kind,overlay,profiles,project,run,version" + ) && + (value.version === 2 || + ((value.version === 3 || value.version === 5) && + validHookPin(value.hook_permit))) + ) || + value.kind !== "native-graph-source" || + value.project !== store.identity.projectRoot || + value.branch !== store.identity.branch || + value.run !== run || + !Array.isArray(value.profiles) || + value.profiles.length > 64 || + value.profiles.some( + (profile: unknown) => typeof profile !== "string" || !validBranch(profile) + ) || + !( + value.overlay === "inherit" || + value.overlay === "base" || + (isRecord(value.overlay) && + keys(value.overlay, "named") && + typeof value.overlay.named === "string" && + validBranch(value.overlay.named)) + ) || + !parseNativeEnvMetadata(value.env_metadata) + ) { + return refused(); + } + return current; +} +/** Remove this run's exact validated source envelope; an absent source is accepted. */ +async function removeRetainedSource( + store: Store, + path: string, + run: string, + check: () => Promise +): Promise { + let source: Awaited>; + try { + source = await readRetainedSource(store, path, run); + } catch (error) { + if (hasCode(error, "ENOENT")) { + return; + } + return refused(); + } + await check(); + const current = await readPrivate(path, SOURCE_LIMIT); + if (!sameFile(source.info, current.info) || current.text !== source.text) { + return refused(); + } + await unlink(path); + await synchronizeDirectories(store.held); +} async function read(store: Store): Promise { return await readRecord(store, store.file, selected); } @@ -1097,6 +1142,33 @@ export async function withNativeAuthoredProjectAdmission( await synchronizeDirectories(store.held); }); }, + async retireUnreserved(input) { + const expected = selection(input.expectedStart, started); + const run = expected.record.review.provenance.run; + if ( + !( + input.refusal instanceof NativeRuntimeRequestError && + input.refusal.nativeCode === NATIVE_GRAPH_UNRESERVED && + HEX32.test(run) + ) || + (await loadNativeAuthoredHookOwner(hookStore)) !== null + ) { + return refused(); + } + const sourcePath = join(store.root.path, `${run}.source.json`); + await withMutation(async (check) => { + await unchanged(store, store.startFile, expected, started); + await absent(store.file); + await absent(liveStopPath(store)); + await removeRetainedSource(store, sourcePath, run, check); + // If retirement stops here, the durable start continues to block a new run. + await unchanged(store, store.startFile, expected, started); + await absent(store.file); + await check(); + await unlink(store.startFile); + await synchronizeDirectories(store.held); + }); + }, }; try { return await action(Object.freeze(capability)); diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index 6133d9cd9..b2b28a943 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -63,6 +63,7 @@ import { } from "./native-project-process.ts"; import { invokeNativeRuntime, + NATIVE_GRAPH_UNRESERVED, NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; @@ -71,7 +72,12 @@ const RUN = /^[a-f0-9]{32}$/; const CODE = /^[a-z][a-z0-9_]{0,63}$/; const PRIVATE_LIMIT = 256 * 1024; type Inputs = Awaited>; -type Outcome = "not-started" | "removed" | "retained"; +/** + * `unreserved`: the native consumer settled after refusing before it reserved + * any runtime journal or published a receipt, and the exact startup intent and + * source were retired. Nothing is retained. + */ +type Outcome = "not-started" | "removed" | "retained" | "unreserved"; const STAGES = [ "selection", "admission", @@ -137,6 +143,8 @@ export class NativeAuthoredProjectStartError extends Error { const detail = { "not-started": "no native consumer was started", removed: "exact native cleanup was confirmed", + unreserved: + "native runtime state was never reserved; the startup intent was retired and nothing is retained", retained: "startup evidence is retained; inspect owned state before retrying", }[opts.outcome]; @@ -428,6 +436,42 @@ async function retireAttempt(opts: { await attempt.source.remove(); } +/** + * HACK-1272: a consumer that refused before journal reservation (for example + * `native_graph_admission` for a non-content-ID image) leaves no receipt, so + * Removed retirement can never succeed. Retire the exact start intent and + * source only when the settled consumer's failure was never observed as a + * receipt and fresh inspection names the run `native_graph_unreserved` (an + * absent journal and no published foreground owner). Every other inspection + * refusal keeps all evidence. + */ +async function retireUnreservedAttempt(opts: { + readonly admission: NativeAuthoredProjectAdmission; + readonly attempt: Attempt; + readonly failure: unknown; + readonly refusal: unknown; +}): Promise { + if ( + opts.failure === undefined || + opts.attempt.observed !== undefined || + opts.attempt.ready !== undefined || + !( + opts.refusal instanceof NativeRuntimeRequestError && + opts.refusal.nativeCode === NATIVE_GRAPH_UNRESERVED + ) + ) { + return false; + } + // The exact source this attempt wrote is still unchanged; retirement removes + // it before the start intent so an interruption keeps the intent blocking. + await opts.attempt.source.assertFresh(); + await opts.admission.retireUnreserved({ + expectedStart: opts.attempt.start, + refusal: opts.refusal, + }); + return true; +} + const HOOK_STAGES: Record = { "up.before": "hook-up-before", "up.after": "hook-up-after", @@ -534,6 +578,7 @@ async function runPreparedLifecycle(ctx: { }) => void; readonly published: () => void; readonly removed: () => void; + readonly unreserved: () => void; readonly hookRetired: () => void; readonly nativeCode: (code: string | undefined) => void; }): Promise { @@ -677,7 +722,25 @@ async function runPreparedLifecycle(ctx: { await ctx.hookOwner.retire(); ctx.hookRetired(); } - await retireAttempt({ ...retirement, beforeRetire: liveStop?.retire }); + try { + await retireAttempt({ ...retirement, beforeRetire: liveStop?.retire }); + } catch (refusal) { + if ( + ctx.hookOwner !== undefined || + liveStop !== undefined || + !(await retireUnreservedAttempt({ + admission: ctx.admission, + attempt: ctx.admitted, + failure, + refusal, + })) + ) { + throw refusal; + } + ctx.unreserved(); + ctx.completeStop(false); + throw failure; + } ctx.removed(); ctx.completeStop(failure === undefined); if (failure) { @@ -1065,6 +1128,9 @@ export async function serveNativeAuthoredProject( removed: () => { outcome = "removed"; }, + unreserved: () => { + outcome = "unreserved"; + }, hookRetired: () => { hookOwner = undefined; }, @@ -1101,7 +1167,7 @@ export async function serveNativeAuthoredProject( host: hostProcesses, hookStop, storageTool, - retained: outcome !== "removed", + retained: outcome !== "removed" && outcome !== "unreserved", }); } } diff --git a/src/backends/native-authored-unreserved-start.ts b/src/backends/native-authored-unreserved-start.ts new file mode 100644 index 000000000..51e71ac31 --- /dev/null +++ b/src/backends/native-authored-unreserved-start.ts @@ -0,0 +1,112 @@ +import { + loadNativeAuthoredProjectRun, + type NativeAuthoredProjectRunScope, + withNativeAuthoredProjectAdmission, + withNativeAuthoredProjectStatus, +} from "./native-authored-project-run.ts"; +import { + invokeNativeRuntime, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +function refused(): never { + throw new Error( + "Native startup intent is not a provably unreserved attempt; selected state is retained. Values omitted." + ); +} + +/** + * Read-only selection for explicit recovery: true only when a startup intent is + * retained without a Ready mapping or a frontend recovery intent. Any refusal + * (including a saved recovery intent) selects the stored-generation owner, which + * keeps its own refusals. Nothing is created, locked or repaired here. + */ +export async function nativeAuthoredUnreservedStartSelected( + scope: NativeAuthoredProjectRunScope +): Promise { + try { + return await withNativeAuthoredProjectStatus( + { ...scope }, + async (saved) => await Promise.resolve(saved.pending) + ); + } catch { + return false; + } +} + +/** + * Explicit `down --recover` for a startup intent whose native consumer never + * reserved runtime state (HACK-1272). It holds a fresh startup admission, so no + * live or interrupted foreground CLI (and therefore no consumer it spawned) can + * still own this attempt; a stale admission lock refuses as busy. Under that + * admission it requires no Ready mapping, hook owner or live-stop owner, asks the + * native runtime to inspect the exact run, and retires the start intent and its + * source only for the exact `native_graph_unreserved` refusal (journal absent and + * no foreground owner published). Any receipt, any other refusal, or a successful + * inspection retains every file. No input is acquired and nothing is served, + * replayed or cleaned in the guest. + */ +export async function retireNativeAuthoredUnreservedStart(opts: { + readonly scope: NativeAuthoredProjectRunScope; + readonly runtime: NativeRuntimeSelection; + readonly timeoutMs: number; + readonly signal?: AbortSignal; + /** Exact public native-request seam for isolated owner tests. */ + readonly request?: typeof invokeNativeRuntime; +}): Promise<{ readonly run: string }> { + const scope = { ...opts.scope }; + const runtime = { ...opts.runtime }; + const { timeoutMs, signal } = opts; + const request = opts.request ?? invokeNativeRuntime; + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > 300_000 || + signal?.aborted || + runtime.home !== scope.nativeHome + ) { + return refused(); + } + const deadline = performance.now() + timeoutMs; + const remaining = () => { + const value = Math.floor(deadline - performance.now()); + if (value < 1 || signal?.aborted) { + return refused(); + } + return value; + }; + return await withNativeAuthoredProjectAdmission(scope, async (admission) => { + const start = await admission.loadStart(); + if ( + start === null || + (await admission.hooksRetained()) || + (await admission.liveStopRetained()) || + (await loadNativeAuthoredProjectRun(scope)) !== null + ) { + return refused(); + } + const run = start.record.review.provenance.run; + remaining(); + let refusal: unknown; + try { + await request({ + runtime, + cwd: scope.projectRoot, + args: ["graph", "native", "inspect", "--run-id", run, "--json"], + timeoutMs: remaining(), + signal, + boundNativeAuthoredReadDrain: true, + }); + } catch (error) { + refusal = error; + } + if (refusal === undefined) { + // A journal exists; this attempt belongs to receipt-owned recovery. + return refused(); + } + remaining(); + await admission.assertHeld(); + await admission.retireUnreserved({ expectedStart: start, refusal }); + return { run }; + }); +} diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 765b5d77e..7d9e8a9be 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -41,6 +41,14 @@ export class NativeRuntimeRequestError extends ClosedCliError { } } +/** + * Native inspection code for a run whose journal is exactly absent and whose + * foreground owner is not published: no runtime state was ever reserved. It is + * point-in-time only; a caller must separately prove the run's consumer settled + * (or cannot exist) before treating it as retirement authority. + */ +export const NATIVE_GRAPH_UNRESERVED = "native_graph_unreserved"; + /** Native code for a read against a candidate runtime that is not running. */ export const NATIVE_RUNTIME_NOT_RUNNING = "runtime_not_running"; diff --git a/src/commands/project-admin.ts b/src/commands/project-admin.ts index c0d40bf0c..521ea778d 100644 --- a/src/commands/project-admin.ts +++ b/src/commands/project-admin.ts @@ -10,6 +10,7 @@ import { sanitizeProjectSlug, } from "../lib/project.ts"; import { display } from "../ui/display.ts"; +import { targetCommand, targetSpec } from "./project-target.ts"; const ownerShowOptions = [optPath, optProject, optJson] as const; @@ -37,7 +38,7 @@ const projectSpec = defineCommand({ group: "Project", options: [], positionals: [], - subcommands: [ownerSpec], + subcommands: [ownerSpec, targetSpec], } as const); const handleOwnerShow: CommandHandlerFor = async ({ @@ -90,7 +91,7 @@ const ownerCommand = { export const projectCommand = { ...projectSpec, - subcommands: [ownerCommand], + subcommands: [ownerCommand, targetCommand], } as const; async function resolveProjectTarget(input: { diff --git a/src/commands/project-target.ts b/src/commands/project-target.ts new file mode 100644 index 000000000..a98c46b07 --- /dev/null +++ b/src/commands/project-target.ts @@ -0,0 +1,114 @@ +import type { CommandHandlerFor } from "../cli/command.ts"; +import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; +import { + optBranch, + optEnv, + optJson, + optPath, + optProject, +} from "../cli/options.ts"; +import { + emitCliResult, + exitCodeForResult, + HackCliError, +} from "../lib/cli-result.ts"; +import { + inspectTarget, + type TargetInspectData, +} from "../lib/target-inspect.ts"; +import { display } from "../ui/display.ts"; + +const optTargetService = defineOption({ + name: "service", + type: "string", + long: "--service", + valueHint: "", + description: "Also resolve one Compose service of the target", +} as const); + +const optExpectGeneration = defineOption({ + name: "expectGeneration", + type: "string", + long: "--expect-generation", + valueHint: "", + description: + "Refuse unless the project's config generation still equals this id", +} as const); + +const targetOptions = [ + optPath, + optProject, + optBranch, + optTargetService, + optEnv, + optExpectGeneration, + optJson, +] as const; + +/** + * `hack project target`: read-only target inspector (HACK-1144). The name, + * options and result schema are provisional until the AX compatibility + * review; see docs/reference/ax-target-result-contract.md. + */ +export const targetSpec = defineCommand({ + name: "target", + summary: + "Inspect the exact project, checkout, instance and service a command would target (read-only, provisional)", + group: "Project", + options: targetOptions, + positionals: [], + subcommands: [], +} as const); + +const handleTarget: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + const options = args.options; + const result = await inspectTarget({ + cwd: ctx.cwd, + selector: { + ...(options.project === undefined ? {} : { project: options.project }), + ...(options.path === undefined ? {} : { path: options.path }), + ...(options.branch === undefined ? {} : { branch: options.branch }), + ...(options.service === undefined ? {} : { service: options.service }), + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.expectGeneration === undefined + ? {} + : { expectGeneration: options.expectGeneration }), + }, + }); + + if (options.json === true) { + emitCliResult({ result }); + return exitCodeForResult({ result }); + } + if (!result.ok) { + throw new HackCliError(result.error); + } + await display.kv({ title: "Target", entries: targetEntries(result.data) }); + return 0; +}; + +function targetEntries( + data: TargetInspectData +): readonly (readonly [string, string])[] { + return [ + ["Project", data.project.name], + [ + "Registration", + data.project.registration + ? `${data.project.registration.id} (${data.project.registration.match})` + : "unregistered", + ], + ["Checkout", `${data.checkout.root} (${data.checkout.kind})`], + ["Git branch", data.checkout.git_branch ?? "-"], + ["Instance", `${data.instance.compose_project} (${data.instance.source})`], + ["Service", data.service?.name ?? "-"], + ["Env overlay", `${data.env.overlay ?? "base"} (${data.env.source})`], + ["Generation", data.generation.id], + ["Runtime", "not probed"], + ]; +} + +export const targetCommand = withHandler(targetSpec, handleTarget); diff --git a/src/commands/project.ts b/src/commands/project.ts index 682a5140b..7512381f4 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -153,6 +153,7 @@ import { } from "../lib/cli-result.ts"; import { withComposeServiceEnvFingerprints } from "../lib/compose-env-fingerprint.ts"; import { renderManagedComposeEnvOverride } from "../lib/compose-managed-env.ts"; +import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { type ComposeStartupBudgets, type ComposeStartupFailure, @@ -1039,43 +1040,6 @@ function emitRemoteLifecycleJson(opts: { }); } -async function resolveComposeProjectName(opts: { - readonly project: Awaited>; - readonly cfg?: Awaited>; -}): Promise { - const composeName = await readComposeProjectName(opts.project.composeFile); - if (composeName) { - return composeName; - } - - const derived = defaultProjectSlugFromPath(opts.project.projectRoot); - const cfgName = (opts.cfg?.name ?? derived).trim(); - return cfgName.length > 0 ? cfgName : derived; -} - -async function readComposeProjectName( - composeFile: string -): Promise { - const text = await readTextFile(composeFile); - if (!text) { - return null; - } - - let parsed: unknown; - try { - parsed = YAML.parse(text); - } catch { - return null; - } - - if (!isRecord(parsed)) { - return null; - } - const name = getString(parsed, "name"); - const trimmed = name?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - async function buildBranchComposeOverride(opts: { readonly project: Awaited>; readonly branch: string; diff --git a/src/commands/update.ts b/src/commands/update.ts index 1c8fa1dd2..6b5ce8669 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -68,8 +68,13 @@ const handleUpdate: CommandHandlerFor = async ({ ctx, args, }): Promise => { - // The native launcher runs hack-cli; PATH fallback can select the stable install. - if (process.env.HACK_RUNTIME_BACKEND === "native") { + // Candidate launchers run hack-cli; PATH fallback can select the stable install. + // `hack-next` and `hack-v5` mark their children on both backends, because an + // explicit Compose selection no longer carries the native backend marker. + if ( + process.env.HACK_RUNTIME_BACKEND === "native" || + (process.env.HACK_CANDIDATE_ENTRYPOINT ?? "") !== "" + ) { return writeResult({ json: args.options.json === true, result: { diff --git a/src/lib/compose-project-name.ts b/src/lib/compose-project-name.ts new file mode 100644 index 000000000..09d891664 --- /dev/null +++ b/src/lib/compose-project-name.ts @@ -0,0 +1,58 @@ +import { YAML } from "bun"; +import { readTextFile } from "./fs.ts"; +import { getString, isRecord } from "./guards.ts"; +import type { ProjectConfig, ProjectContext } from "./project.ts"; +import { defaultProjectSlugFromPath } from "./project.ts"; + +/** + * Resolve the base Compose project name for a project's default instance. + * + * Precedence: the Compose file's top-level `name:`, then the project config + * `name`, then the slug derived from the project root directory. A branch + * instance appends `--` to this base (see + * {@link branchComposeProjectName}). The read is side-effect free. + */ +export async function resolveComposeProjectName(opts: { + readonly project: Pick; + readonly cfg?: Pick; +}): Promise { + const composeName = await readComposeProjectName(opts.project.composeFile); + if (composeName) { + return composeName; + } + + const derived = defaultProjectSlugFromPath(opts.project.projectRoot); + const cfgName = (opts.cfg?.name ?? derived).trim(); + return cfgName.length > 0 ? cfgName : derived; +} + +/** Compose project name of a branch instance: `--`. */ +export function branchComposeProjectName(opts: { + readonly base: string; + readonly branch: string; +}): string { + return `${opts.base}--${opts.branch}`; +} + +async function readComposeProjectName( + composeFile: string +): Promise { + const text = await readTextFile(composeFile); + if (!text) { + return null; + } + + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch { + return null; + } + + if (!isRecord(parsed)) { + return null; + } + const name = getString(parsed, "name"); + const trimmed = name?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 0187256bb..6690afa85 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -25,6 +25,10 @@ import { NativeAuthoredProjectStartError, serveNativeAuthoredProject, } from "../backends/native-authored-project-start.ts"; +import { + nativeAuthoredUnreservedStartSelected, + retireNativeAuthoredUnreservedStart, +} from "../backends/native-authored-unreserved-start.ts"; import { resolveNativeAuthoredExecStaleEnvWarning } from "../backends/native-exec-stale-env.ts"; import { parseNativeHttpsSelection } from "../backends/native-project-start.ts"; import { @@ -205,7 +209,10 @@ function assertForegroundOptions(options: NativeComposeCommandOptions): void { function startupFailure(error: unknown): number { if (error instanceof NativeAuthoredProjectStartError) { - if (error.canceled && error.outcome === "removed") { + if ( + error.canceled && + (error.outcome === "removed" || error.outcome === "unreserved") + ) { return 130; } throw new HackCliError({ @@ -296,7 +303,7 @@ const STATUS_NOTES: Readonly< > = { not_started: "Native project has not been started.", pending: - "Native project startup is pending; no current service status is confirmed.", + "Native project startup is pending or incomplete; no current service status is confirmed. If no startup is running, `hack down --recover` retires an attempt that never reserved runtime state.", runtime_not_running: "Native runtime is not running; no services are running.", }; @@ -369,6 +376,9 @@ type Seams = { /** Execution seam for command-selection controls; the CLI always uses the real owner. */ readonly serve?: typeof serveNativeAuthoredProject; readonly recover?: typeof recoverNativeAuthoredProject; + /** Pending-start selection and unreserved retirement seams (HACK-1272). */ + readonly unreservedSelected?: typeof nativeAuthoredUnreservedStartSelected; + readonly retireUnreserved?: typeof retireNativeAuthoredUnreservedStart; readonly observe?: typeof nativeAuthoredProjectPs; readonly logs?: typeof nativeAuthoredProjectLogs; readonly stop?: typeof stopNativeAuthoredProject; @@ -408,6 +418,27 @@ async function runStatus(input: LifecycleInput): Promise { } async function runRecovery(input: LifecycleInput): Promise { + if ( + await ( + input.seams.unreservedSelected ?? nativeAuthoredUnreservedStartSelected + )(input.scope) + ) { + // A startup intent without a Ready generation: only a provably unreserved + // attempt is retired; every other retained attempt refuses unchanged. + await (input.seams.retireUnreserved ?? retireNativeAuthoredUnreservedStart)( + { + runtime: input.runtime, + scope: input.scope, + timeoutMs: input.startupTimeoutMs, + signal: input.signal, + } + ); + logger.info({ + message: + "Retired an incomplete native startup that never reserved runtime state; nothing else was changed.", + }); + return 0; + } await (input.seams.recover ?? recoverNativeAuthoredProject)({ runtime: input.runtime, scope: input.scope, diff --git a/src/lib/target-inspect.ts b/src/lib/target-inspect.ts new file mode 100644 index 000000000..c4bdbf22d --- /dev/null +++ b/src/lib/target-inspect.ts @@ -0,0 +1,1174 @@ +import { createHash } from "node:crypto"; +import { readFile, realpath, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { YAML } from "bun"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, +} from "../constants.ts"; +import { resolveEffectiveBranch } from "./branches.ts"; +import { HACK_ERROR_CODES, type HackErrorCode } from "./cli-result.ts"; +import { + branchComposeProjectName, + resolveComposeProjectName, +} from "./compose-project-name.ts"; +import { + isLinkedGitWorktree, + resolveGitCurrentBranch, +} from "./git-worktree.ts"; +import { isRecord } from "./guards.ts"; +import { + defaultProjectSlugFromPath, + findProjectContext, + findProjectContextAtRoot, + type ProjectContext, + parseEnvConfigSelection, + readProjectConfig, + readProjectDefaultEnvConfig, + resolveWorktreeAutoBranch, + sanitizeBranchSlug, + sanitizeProjectSlug, +} from "./project.ts"; +import { ProjectInputSelectionError } from "./project-input-selection.ts"; +import { + AmbiguousProjectNameError, + normalizeProjectName, + PROJECT_SELECTOR_PATTERN, +} from "./project-name.ts"; +import { + AmbiguousProjectIdError, + type RegisteredProject, + readProjectsRegistry, + selectRegisteredProjectByName, +} from "./projects-registry.ts"; + +/** + * Read-only target inspector shared by `hack project target` and the MCP + * `hack.project.target` tool (HACK-1144 / WU31). + * + * The inspector resolves one exact target (project, checkout, runtime + * instance, optional service, env overlay selection and a config generation) + * from local files only. It never starts, repairs, registers or touches + * anything: no registry write, no Docker/engine call, no Git mutation. The + * only child processes are read-only `git rev-parse`/`git branch + * --show-current` lookups that existing project commands already run. + * + * Contract status: every schema name, field, reason and retry value here is + * PROVISIONAL until the AX compatibility review. See + * docs/reference/ax-target-result-contract.md. + * + * A resolved target is a reference: it identifies scope and grants no + * authority. Callers that act on it must revalidate at their own effect + * boundary (for example by passing `expectGeneration`). + */ + +/** Provisional schema id carried by every successful result. */ +export const TARGET_INSPECT_SCHEMA = "hack.target.v0" as const; + +/** Prefix of a provisional config generation id. */ +const GENERATION_PREFIX = "cfg-v0:" as const; + +/** Exact grammar of a generation id accepted as an expectation. */ +export const TARGET_GENERATION_PATTERN = /^cfg-v0:[0-9a-f]{32}$/; + +/** + * Runtime instance selector grammar. Identical to the MCP branch identifier + * rule, so no value can start with `-` or carry `=`. + */ +export const TARGET_BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_./-]*$/; + +/** Compose service grammar, identical to the MCP service identifier rule. */ +export const TARGET_SERVICE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +/** Longest accepted identifier (branch, service). */ +export const TARGET_IDENTIFIER_MAX_LENGTH = 253; + +/** Hack config files a generation covers, in hashing order. */ +const GENERATION_INPUTS = [ + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, + PROJECT_COMPOSE_FILENAME, +] as const; + +/** Bound on one generation input; a larger file is refused, not truncated. */ +const GENERATION_INPUT_MAX_BYTES = 4 * 1024 * 1024; + +/** Which resolution stage refused. Provisional. */ +export type TargetStage = + | "selector" + | "project" + | "instance" + | "service" + | "generation"; + +/** What a caller should do next. Provisional; carries no execution authority. */ +export type TargetRetry = "fix_selector" | "fix_project" | "reinspect"; + +type RefusalSpec = { + readonly code: HackErrorCode; + readonly stage: TargetStage; + readonly retry: TargetRetry; + readonly message: string; +}; + +/** + * Closed catalogue of inspector refusals. Messages are fixed, value-free text: + * they never repeat selectors, paths, names or registry values. Codes reuse + * existing CLI error codes; the `reason` is the stable discriminator. + */ +export const TARGET_REFUSALS = Object.freeze({ + conflicting_selectors: { + code: "E_USAGE", + stage: "selector", + retry: "fix_selector", + message: "Use only one of --project or --path.", + }, + invalid_selector: { + code: "E_USAGE", + stage: "selector", + retry: "fix_selector", + message: + "Invalid target selector. Check the project, branch, service and expected generation values.", + }, + project_not_found: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "Unknown project. Run 'hack projects' to see registered projects.", + }, + ambiguous_project: { + code: "E_USAGE", + stage: "project", + retry: "fix_selector", + message: + "Ambiguous project selection: multiple registrations match. Select by path and inspect 'hack projects' before renaming or pruning an entry.", + }, + registered_root_missing: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_project", + message: + "The registered project root no longer exists. Select the checkout by path or run 'hack projects' to inspect the registration.", + }, + path_not_found: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "Path not found.", + }, + no_project_context: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "No hack project found at the selected location.", + }, + project_inputs_unsupported: { + code: "E_CONFIG_INVALID", + stage: "project", + retry: "fix_project", + message: + "The selected project's inputs are not supported by this inspector (native or conflicting project files).", + }, + config_parse_failed: { + code: "E_CONFIG_PARSE", + stage: "project", + retry: "fix_project", + message: "The project config could not be parsed.", + }, + compose_parse_failed: { + code: "E_CONFIG_PARSE", + stage: "project", + retry: "fix_project", + message: "The project's Compose file could not be parsed.", + }, + detached_checkout: { + code: "E_USAGE", + stage: "instance", + retry: "fix_selector", + message: + "Detached linked worktree cannot select an instance implicitly. Pass a branch, or set worktree.auto_branch=false to target the base instance.", + }, + service_not_found: { + code: "E_SERVICE_NOT_FOUND", + stage: "service", + retry: "fix_selector", + message: + "The selected service is not declared in the project's Compose file.", + }, + stale_generation: { + code: "E_USAGE", + stage: "generation", + retry: "reinspect", + message: + "The project config generation differs from the expected generation. Inspect the target again before acting.", + }, + generation_changed_during_read: { + code: "E_USAGE", + stage: "generation", + retry: "reinspect", + message: + "The project config changed while the target was being read. Inspect the target again.", + }, + generation_input_too_large: { + code: "E_CONFIG_INVALID", + stage: "generation", + retry: "fix_project", + message: "A project config input exceeds the inspector size bound.", + }, + input_permission_denied: { + code: "E_PERMISSION", + stage: "project", + retry: "fix_project", + message: "A project input could not be read: permission denied.", + }, + input_unreadable: { + code: "E_CONFIG_INVALID", + stage: "project", + retry: "fix_project", + message: "A project input could not be read.", + }, +} as const satisfies Record); + +export type TargetRefusalReason = keyof typeof TARGET_REFUSALS; + +export const TARGET_REFUSAL_REASONS = Object.keys(TARGET_REFUSALS) as [ + TargetRefusalReason, + ...TargetRefusalReason[], +]; + +/** Caller selectors. All optional; `project` and `path` are exclusive. */ +export type TargetSelector = { + readonly project?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}; + +export type TargetInspectData = { + readonly schema: typeof TARGET_INSPECT_SCHEMA; + readonly provisional: true; + readonly observed_at: string; + /** References identify scope only; they never authorize an action. */ + readonly authority: "none"; + readonly selector: { + readonly kind: "project" | "path" | "cwd"; + readonly branch_explicit: boolean; + readonly env_explicit: boolean; + readonly expected_generation: string | null; + }; + readonly project: { + readonly name: string; + readonly registration: { + readonly id: string; + readonly name: string; + readonly match: "root" | "worktree"; + } | null; + readonly project_dir: string; + }; + readonly checkout: { + readonly root: string; + readonly kind: "primary" | "linked" | "not_git"; + /** Git branch is reported, never used as runtime identity by itself. */ + readonly git_branch: string | null; + }; + readonly instance: { + readonly branch: string | null; + readonly source: "explicit" | "worktree" | "default"; + readonly compose_project: string; + }; + readonly service: { readonly name: string } | null; + readonly services: readonly string[]; + readonly env: { + readonly overlay: string | null; + readonly source: "explicit" | "project_default" | "base"; + }; + readonly generation: { + readonly id: string; + readonly scope: "hack_config"; + readonly inputs: readonly { + readonly file: string; + readonly present: boolean; + }[]; + }; + /** The snapshot never probes the engine; readiness is not claimed. */ + readonly runtime: { readonly observed: false; readonly reason: "not_probed" }; +}; + +export type TargetRefusalDetail = { + readonly reason: TargetRefusalReason; + readonly stage: TargetStage; + readonly retry: TargetRetry; + readonly provisional: true; + /** Present only for `stale_generation`: the generation actually observed. */ + readonly observed_generation?: string; +}; + +export type TargetInspectEnvelope = + | { readonly ok: true; readonly data: TargetInspectData } + | { + readonly ok: false; + readonly error: { + readonly code: HackErrorCode; + readonly message: string; + readonly detail?: TargetRefusalDetail; + }; + }; + +class TargetRefusal extends Error { + readonly reason: TargetRefusalReason; + readonly observedGeneration: string | undefined; + + constructor(reason: TargetRefusalReason, observedGeneration?: string) { + super(TARGET_REFUSALS[reason].message); + this.reason = reason; + this.observedGeneration = observedGeneration; + } +} + +/** Build the closed refusal envelope for one reason. */ +export function targetRefusalEnvelope(opts: { + readonly reason: TargetRefusalReason; + readonly observedGeneration?: string; +}): TargetInspectEnvelope { + const spec = TARGET_REFUSALS[opts.reason]; + return { + ok: false, + error: { + code: spec.code, + message: spec.message, + detail: { + reason: opts.reason, + stage: spec.stage, + retry: spec.retry, + provisional: true, + ...(opts.observedGeneration === undefined + ? {} + : { observed_generation: opts.observedGeneration }), + }, + }, + }; +} + +/** + * Inspect one target. Returns a refusal envelope for every closed failure. + * A filesystem fault while reading inputs (for example a permission-denied + * Compose file, or a directory where a config file belongs) is mapped to the + * closed `input_permission_denied` / `input_unreadable` refusals, so the OS + * message and its absolute path never reach the caller. Only non-filesystem + * faults (programming errors) still throw. + * + * `afterResolve` is a test seam that runs after the target is resolved and + * before the generation is re-read; production callers never pass it. + */ +export async function inspectTarget(opts: { + readonly cwd: string; + readonly selector: TargetSelector; + readonly registryPath?: string; + readonly now?: () => Date; + readonly afterResolve?: () => Promise; +}): Promise { + try { + const data = await resolveTarget(opts); + return { ok: true, data }; + } catch (error: unknown) { + if (error instanceof TargetRefusal) { + return targetRefusalEnvelope({ + reason: error.reason, + ...(error.observedGeneration === undefined + ? {} + : { observedGeneration: error.observedGeneration }), + }); + } + const fault = filesystemFaultCode(error); + if (fault !== null) { + return targetRefusalEnvelope({ + reason: + fault === "EACCES" || fault === "EPERM" + ? "input_permission_denied" + : "input_unreadable", + }); + } + throw error; + } +} + +/** Shape of an errno code such as `EACCES` or `EISDIR`. */ +const ERRNO_CODE_PATTERN = /^E[A-Z0-9]+$/; + +/** + * The errno code of a Node/Bun filesystem or syscall error, or null for any + * other value. Such errors carry a string `code` and `syscall`; their + * `message` includes the absolute path, which is why it is never surfaced. + */ +function filesystemFaultCode(error: unknown): string | null { + if ( + isRecord(error) && + typeof error.code === "string" && + ERRNO_CODE_PATTERN.test(error.code) && + typeof error.syscall === "string" + ) { + return error.code; + } + return null; +} + +type ValidatedSelector = { + readonly project: string | null; + readonly path: string | null; + readonly branch: string | null; + readonly service: string | null; + /** undefined: use the project default; null: base layers only. */ + readonly env: string | null | undefined; + readonly expectGeneration: string | null; +}; + +function validateSelector(selector: TargetSelector): ValidatedSelector { + if (selector.project !== undefined && selector.path !== undefined) { + throw new TargetRefusal("conflicting_selectors"); + } + let project: string | null = null; + if (selector.project !== undefined) { + const trimmed = selector.project.trim(); + project = PROJECT_SELECTOR_PATTERN.test(trimmed) + ? normalizeProjectName(trimmed) + : null; + if (!project) { + throw new TargetRefusal("invalid_selector"); + } + } + let path: string | null = null; + if (selector.path !== undefined) { + if (selector.path.trim().length === 0) { + throw new TargetRefusal("invalid_selector"); + } + path = selector.path; + } + const branch = validateIdentifier(selector.branch, TARGET_BRANCH_PATTERN); + const service = validateIdentifier(selector.service, TARGET_SERVICE_PATTERN); + let env: string | null | undefined; + if (selector.env !== undefined) { + env = parseEnvConfigSelection(selector.env); + if (env === undefined) { + throw new TargetRefusal("invalid_selector"); + } + } + let expectGeneration: string | null = null; + if (selector.expectGeneration !== undefined) { + if (!TARGET_GENERATION_PATTERN.test(selector.expectGeneration)) { + throw new TargetRefusal("invalid_selector"); + } + expectGeneration = selector.expectGeneration; + } + return { project, path, branch, service, env, expectGeneration }; +} + +function validateIdentifier( + value: string | undefined, + pattern: RegExp +): string | null { + if (value === undefined) { + return null; + } + if (value.length > TARGET_IDENTIFIER_MAX_LENGTH || !pattern.test(value)) { + throw new TargetRefusal("invalid_selector"); + } + return value; +} + +type SelectedProject = { + readonly context: ProjectContext; + readonly kind: TargetInspectData["selector"]["kind"]; +}; + +async function resolveTarget(opts: { + readonly cwd: string; + readonly selector: TargetSelector; + readonly registryPath?: string; + readonly now?: () => Date; + readonly afterResolve?: () => Promise; +}): Promise { + const selector = validateSelector(opts.selector); + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + }); + const selected = await selectProject({ + cwd: opts.cwd, + selector, + projects: registry.projects, + }); + const context = selected.context; + + const before = await readGeneration(context); + const cfg = await readProjectConfig(context); + if (cfg.parseError) { + throw new TargetRefusal("config_parse_failed"); + } + const services = await readComposeServiceNames(context.composeFile); + + const root = await canonicalPath(context.projectRoot); + const registration = await findRegistrationForRoot({ + projects: registry.projects, + root, + }); + + const linked = await isLinkedGitWorktree({ repoRoot: context.projectRoot }); + const gitBranch = + linked === null + ? null + : await resolveGitCurrentBranch({ repoRoot: context.projectRoot }); + + const explicitBranch = selector.branch + ? sanitizeBranchSlug(selector.branch) || "branch" + : null; + const effective = await resolveEffectiveBranch({ + explicitBranch, + projectRoot: context.projectRoot, + autoBranchEnabled: resolveWorktreeAutoBranch(cfg), + }); + if (effective.source === "detached-worktree") { + throw new TargetRefusal("detached_checkout"); + } + const baseCompose = await resolveComposeProjectName({ + project: context, + cfg, + }); + const instanceBranch = effective.branch; + + if (selector.service !== null && !services.includes(selector.service)) { + throw new TargetRefusal("service_not_found"); + } + + const env = await resolveEnvSelection({ context, requested: selector.env }); + + await opts.afterResolve?.(); + const after = await readGeneration(context); + if (after.id !== before.id) { + throw new TargetRefusal("generation_changed_during_read"); + } + if ( + selector.expectGeneration !== null && + selector.expectGeneration !== after.id + ) { + throw new TargetRefusal("stale_generation", after.id); + } + + const now = opts.now ?? (() => new Date()); + return { + schema: TARGET_INSPECT_SCHEMA, + provisional: true, + observed_at: now().toISOString(), + authority: "none", + selector: { + kind: selected.kind, + branch_explicit: selector.branch !== null, + env_explicit: selector.env !== undefined, + expected_generation: selector.expectGeneration, + }, + project: { + name: projectSlug({ cfgName: cfg.name, root: context.projectRoot }), + registration, + project_dir: context.projectDir, + }, + checkout: { + root: context.projectRoot, + kind: checkoutKind(linked), + git_branch: gitBranch, + }, + instance: { + branch: instanceBranch, + source: instanceSource(effective.source), + compose_project: instanceBranch + ? branchComposeProjectName({ + base: baseCompose, + branch: instanceBranch, + }) + : baseCompose, + }, + service: selector.service === null ? null : { name: selector.service }, + services, + env, + generation: { + id: after.id, + scope: "hack_config", + inputs: after.inputs, + }, + runtime: { observed: false, reason: "not_probed" }, + }; +} + +/** Project slug: config `name`, else the slug of the checkout directory. */ +function projectSlug(opts: { + readonly cfgName: string | undefined; + readonly root: string; +}): string { + const configured = opts.cfgName?.trim() ?? ""; + return configured.length > 0 + ? sanitizeProjectSlug(configured) + : defaultProjectSlugFromPath(opts.root); +} + +function checkoutKind( + linked: boolean | null +): TargetInspectData["checkout"]["kind"] { + if (linked === null) { + return "not_git"; + } + return linked ? "linked" : "primary"; +} + +function instanceSource( + source: "explicit" | "worktree" | "none" +): TargetInspectData["instance"]["source"] { + return source === "none" ? "default" : source; +} + +async function selectProject(opts: { + readonly cwd: string; + readonly selector: ValidatedSelector; + readonly projects: readonly RegisteredProject[]; +}): Promise { + if (opts.selector.project !== null) { + let match: RegisteredProject | null; + try { + match = selectRegisteredProjectByName({ + projects: opts.projects, + name: opts.selector.project, + }); + } catch (error: unknown) { + if ( + error instanceof AmbiguousProjectNameError || + error instanceof AmbiguousProjectIdError + ) { + throw new TargetRefusal("ambiguous_project"); + } + throw error; + } + if (!match) { + throw new TargetRefusal("project_not_found"); + } + if (!(await isDirectory(match.repoRoot))) { + throw new TargetRefusal("registered_root_missing"); + } + const context = await guardInputs(() => + findProjectContextAtRoot({ + projectRoot: match.repoRoot, + projectDirName: match.projectDirName, + }) + ); + if (!context) { + throw new TargetRefusal("no_project_context"); + } + return { context, kind: "project" }; + } + + const startDir = + opts.selector.path === null + ? opts.cwd + : resolve(opts.cwd, opts.selector.path); + if (opts.selector.path !== null && !(await isDirectory(startDir))) { + throw new TargetRefusal("path_not_found"); + } + const context = await guardInputs(() => findProjectContext(startDir)); + if (!context) { + throw new TargetRefusal("no_project_context"); + } + return { context, kind: opts.selector.path === null ? "cwd" : "path" }; +} + +async function guardInputs(read: () => Promise): Promise { + try { + return await read(); + } catch (error: unknown) { + if (error instanceof ProjectInputSelectionError) { + throw new TargetRefusal("project_inputs_unsupported"); + } + throw error; + } +} + +/** + * Registration that owns this exact checkout root: its primary root, or one of + * its recorded linked worktrees. Name equality never matches, so independent + * checkouts that share a name stay separate. Two owners refuse as ambiguous. + */ +async function findRegistrationForRoot(opts: { + readonly projects: readonly RegisteredProject[]; + readonly root: string; +}): Promise { + const byRoot: RegisteredProject[] = []; + const byWorktree: RegisteredProject[] = []; + for (const project of opts.projects) { + if ((await canonicalPath(project.repoRoot)) === opts.root) { + byRoot.push(project); + continue; + } + for (const worktree of project.worktrees ?? []) { + if ((await canonicalPath(worktree.path)) === opts.root) { + byWorktree.push(project); + break; + } + } + } + const candidates = byRoot.length > 0 ? byRoot : byWorktree; + if (candidates.length > 1) { + throw new TargetRefusal("ambiguous_project"); + } + const match = candidates[0]; + if (!match) { + return null; + } + return { + id: match.id, + name: match.name, + match: byRoot.length > 0 ? "root" : "worktree", + }; +} + +async function resolveEnvSelection(opts: { + readonly context: ProjectContext; + readonly requested: string | null | undefined; +}): Promise { + if (opts.requested === null) { + return { overlay: null, source: "base" }; + } + if (opts.requested !== undefined) { + return { overlay: opts.requested, source: "explicit" }; + } + const configured = await readProjectDefaultEnvConfig({ + projectDir: opts.context.projectDir, + }); + return configured + ? { overlay: configured, source: "project_default" } + : { overlay: null, source: "base" }; +} + +/** + * Service names declared in the Compose file, sorted. An absent file declares + * none, as does an empty `services:` key. A file that is present but is not YAML, whose top level is not a + * mapping, or whose `services` is not a mapping is refused as + * `compose_parse_failed`: lifecycle commands would fail on it, so it must not + * be reported as a target with no services. The generation read has already + * bounded the file's size. + */ +async function readComposeServiceNames( + composeFile: string +): Promise { + let text: string; + try { + text = await readFile(composeFile, "utf8"); + } catch (error: unknown) { + if (isMissing(error)) { + return []; + } + throw error; + } + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch { + throw new TargetRefusal("compose_parse_failed"); + } + if (!isRecord(parsed)) { + throw new TargetRefusal("compose_parse_failed"); + } + const services = parsed.services; + if (services === undefined || services === null) { + return []; + } + if (!isRecord(services)) { + throw new TargetRefusal("compose_parse_failed"); + } + return Object.keys(services).sort((left, right) => left.localeCompare(right)); +} + +type Generation = { + readonly id: string; + readonly inputs: TargetInspectData["generation"]["inputs"]; +}; + +/** + * Fingerprint of the Hack config inputs only. Ordinary source edits do not + * change it; editing hack.config.json, hack.toml or docker-compose.yml does. + * Absence is hashed distinctly from an empty file. + */ +async function readGeneration(context: ProjectContext): Promise { + const hash = createHash("sha256"); + const inputs: { file: string; present: boolean }[] = []; + for (const file of GENERATION_INPUTS) { + const bytes = await readBoundedInput(resolve(context.projectDir, file)); + hash.update(`${file}\n`); + if (bytes === null) { + hash.update("0\n"); + inputs.push({ file, present: false }); + continue; + } + hash.update(`1\n${bytes.byteLength}\n`); + hash.update(bytes); + inputs.push({ file, present: true }); + } + return { + id: `${GENERATION_PREFIX}${hash.digest("hex").slice(0, 32)}`, + inputs, + }; +} + +async function readBoundedInput(path: string): Promise { + let size: number; + try { + const info = await stat(path); + if (!info.isFile()) { + return null; + } + size = info.size; + } catch (error: unknown) { + if (isMissing(error)) { + return null; + } + throw error; + } + if (size > GENERATION_INPUT_MAX_BYTES) { + throw new TargetRefusal("generation_input_too_large"); + } + try { + const bytes = await readFile(path); + if (bytes.byteLength > GENERATION_INPUT_MAX_BYTES) { + throw new TargetRefusal("generation_input_too_large"); + } + return bytes; + } catch (error: unknown) { + if (isMissing(error)) { + return null; + } + throw error; + } +} + +function isMissing(error: unknown): boolean { + return ( + isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch (error: unknown) { + if (isMissing(error)) { + return false; + } + throw error; + } +} + +async function canonicalPath(path: string): Promise { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +// --------------------------------------------------------------------------- +// Envelope validation shared by adapters (the MCP tool re-validates the CLI's +// stdout through this, so a drifted or foreign payload is never passed on). +// --------------------------------------------------------------------------- + +const KNOWN_ERROR_CODES: ReadonlySet = new Set(HACK_ERROR_CODES); + +/** + * Parse a `hack project target --json` envelope. Returns a rebuilt envelope + * containing only contract fields, or null when the value does not match the + * provisional contract. The only error envelope accepted without a `detail` + * is an `E_USAGE` refusal (the CLI's shared option-grammar refusals, raised + * before the inspector runs). Any other detail-less error, such as an + * `E_UNEXPECTED` fault whose message is free OS text, is off contract and + * becomes null, so it is never passed on. + */ +export function parseTargetInspectEnvelope( + value: unknown +): TargetInspectEnvelope | null { + if (!isRecord(value)) { + return null; + } + if (value.ok === true) { + const data = parseData(value.data); + return data ? { ok: true, data } : null; + } + if (value.ok !== false || !isRecord(value.error)) { + return null; + } + const { code, message, detail } = value.error; + if ( + typeof code !== "string" || + !KNOWN_ERROR_CODES.has(code) || + typeof message !== "string" + ) { + return null; + } + const errorCode = code as HackErrorCode; + if (detail === undefined) { + return errorCode === "E_USAGE" + ? { ok: false, error: { code: errorCode, message } } + : null; + } + const parsedDetail = parseRefusalDetail(detail); + if (!parsedDetail) { + return null; + } + const spec = TARGET_REFUSALS[parsedDetail.reason]; + if (spec.code !== errorCode || spec.message !== message) { + return null; + } + return { + ok: false, + error: { code: errorCode, message, detail: parsedDetail }, + }; +} + +function parseRefusalDetail(value: unknown): TargetRefusalDetail | null { + if (!isRecord(value) || value.provisional !== true) { + return null; + } + const reason = value.reason; + if (typeof reason !== "string" || !Object.hasOwn(TARGET_REFUSALS, reason)) { + return null; + } + const typedReason = reason as TargetRefusalReason; + const spec = TARGET_REFUSALS[typedReason]; + if (value.stage !== spec.stage || value.retry !== spec.retry) { + return null; + } + const observed = value.observed_generation; + if ( + observed !== undefined && + (typedReason !== "stale_generation" || + typeof observed !== "string" || + !TARGET_GENERATION_PATTERN.test(observed)) + ) { + return null; + } + return { + reason: typedReason, + stage: spec.stage, + retry: spec.retry, + provisional: true, + ...(observed === undefined ? {} : { observed_generation: observed }), + }; +} + +function parseData(value: unknown): TargetInspectData | null { + if ( + !isRecord(value) || + value.schema !== TARGET_INSPECT_SCHEMA || + value.provisional !== true || + value.authority !== "none" || + typeof value.observed_at !== "string" + ) { + return null; + } + const selector = parseSelectorEcho(value.selector); + const project = parseProject(value.project); + const checkout = parseCheckout(value.checkout); + const instance = parseInstance(value.instance); + const service = parseService(value.service); + const services = parseStringList(value.services); + const env = parseEnv(value.env); + const generation = parseGeneration(value.generation); + const runtime = value.runtime; + if ( + !( + selector && + project && + checkout && + instance && + service !== undefined && + services && + env && + generation && + isRecord(runtime) && + runtime.observed === false && + runtime.reason === "not_probed" + ) + ) { + return null; + } + return { + schema: TARGET_INSPECT_SCHEMA, + provisional: true, + observed_at: value.observed_at, + authority: "none", + selector, + project, + checkout, + instance, + service, + services, + env, + generation, + runtime: { observed: false, reason: "not_probed" }, + }; +} + +function parseSelectorEcho( + value: unknown +): TargetInspectData["selector"] | null { + if ( + !isRecord(value) || + (value.kind !== "project" && + value.kind !== "path" && + value.kind !== "cwd") || + typeof value.branch_explicit !== "boolean" || + typeof value.env_explicit !== "boolean" + ) { + return null; + } + const expected = value.expected_generation; + if ( + expected !== null && + !(typeof expected === "string" && TARGET_GENERATION_PATTERN.test(expected)) + ) { + return null; + } + return { + kind: value.kind, + branch_explicit: value.branch_explicit, + env_explicit: value.env_explicit, + expected_generation: expected, + }; +} + +function parseProject(value: unknown): TargetInspectData["project"] | null { + if ( + !isRecord(value) || + typeof value.name !== "string" || + typeof value.project_dir !== "string" + ) { + return null; + } + const registration = value.registration; + if (registration === null) { + return { + name: value.name, + registration: null, + project_dir: value.project_dir, + }; + } + if ( + !isRecord(registration) || + typeof registration.id !== "string" || + typeof registration.name !== "string" || + (registration.match !== "root" && registration.match !== "worktree") + ) { + return null; + } + return { + name: value.name, + registration: { + id: registration.id, + name: registration.name, + match: registration.match, + }, + project_dir: value.project_dir, + }; +} + +function parseCheckout(value: unknown): TargetInspectData["checkout"] | null { + if ( + !isRecord(value) || + typeof value.root !== "string" || + (value.kind !== "primary" && + value.kind !== "linked" && + value.kind !== "not_git") || + !(value.git_branch === null || typeof value.git_branch === "string") + ) { + return null; + } + return { root: value.root, kind: value.kind, git_branch: value.git_branch }; +} + +function parseInstance(value: unknown): TargetInspectData["instance"] | null { + if ( + !isRecord(value) || + typeof value.compose_project !== "string" || + (value.source !== "explicit" && + value.source !== "worktree" && + value.source !== "default") || + !(value.branch === null || typeof value.branch === "string") + ) { + return null; + } + return { + branch: value.branch, + source: value.source, + compose_project: value.compose_project, + }; +} + +/** undefined means invalid; null is a valid "no service selected". */ +function parseService( + value: unknown +): TargetInspectData["service"] | undefined { + if (value === null) { + return null; + } + if (!isRecord(value) || typeof value.name !== "string") { + return undefined; + } + return { name: value.name }; +} + +function parseStringList(value: unknown): readonly string[] | null { + if (!Array.isArray(value)) { + return null; + } + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + return null; + } + out.push(entry); + } + return out; +} + +function parseEnv(value: unknown): TargetInspectData["env"] | null { + if ( + !isRecord(value) || + (value.source !== "explicit" && + value.source !== "project_default" && + value.source !== "base") || + !(value.overlay === null || typeof value.overlay === "string") + ) { + return null; + } + return { overlay: value.overlay, source: value.source }; +} + +function parseGeneration( + value: unknown +): TargetInspectData["generation"] | null { + if ( + !isRecord(value) || + typeof value.id !== "string" || + !TARGET_GENERATION_PATTERN.test(value.id) || + value.scope !== "hack_config" || + !Array.isArray(value.inputs) + ) { + return null; + } + const inputs: { file: string; present: boolean }[] = []; + for (const entry of value.inputs) { + if ( + !isRecord(entry) || + typeof entry.file !== "string" || + typeof entry.present !== "boolean" + ) { + return null; + } + inputs.push({ file: entry.file, present: entry.present }); + } + return { id: value.id, scope: "hack_config", inputs }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2e6727362..4526aa25b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -36,6 +36,10 @@ import { resolveRegisteredProjectByName, selectRegisteredProjectByName, } from "../lib/projects-registry.ts"; +import { + parseTargetInspectEnvelope, + TARGET_GENERATION_PATTERN, +} from "../lib/target-inspect.ts"; import { readLinesFromStream } from "../ui/lines.ts"; import type { McpCommandAdmission } from "./command-admission.ts"; import { superviseMcpCommand } from "./command-lifetime.ts"; @@ -202,6 +206,8 @@ const COMPOSE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; /** Same rule for branch names, which may also carry `/` (the CLI slugs it). */ const BRANCH_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_./-]*$/; const IDENTIFIER_MAX_LENGTH = 253; +/** Longest path accepted by the target inspector tool. */ +const TARGET_PATH_MAX_LENGTH = 4096; const READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({ readOnlyHint: true, @@ -1214,6 +1220,53 @@ function registerTools(opts: { }, }); + register({ + name: "hack.project.target", + title: "Inspect project target", + description: + "Resolve the exact project, checkout, runtime instance, optional service, env overlay and config generation that a command would target (hack project target --json). Read-only: no engine probe, registry write or repository change. Selection is resolved by the CLI, so refusals match the CLI exactly; data is the validated CLI envelope. Names and fields are provisional.", + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + projectName: z + .string() + .max(IDENTIFIER_MAX_LENGTH) + .describe("Registered project name (exclusive with path)") + .optional(), + path: z + .string() + .max(TARGET_PATH_MAX_LENGTH) + .describe( + "Checkout path, absolute or relative (exclusive with projectName)" + ) + .optional(), + ...branchInput, + service: composeIdentifierInput("Compose service to resolve").optional(), + env: composeIdentifierInput( + "Env overlay name; 'base' or 'default' selects base layers only" + ).optional(), + expectGeneration: z + .string() + .regex(TARGET_GENERATION_PATTERN) + .describe("Refuse unless the config generation still equals this id") + .optional(), + }, + handler: async (input, extra) => { + const result = await runHackCommand({ + context: opts.context, + signal: extra.signal, + tool: "hack.project.target", + args: buildTargetInspectArgs(input), + }); + + // `data` replaces stdout: it is the same envelope, re-validated + // against the provisional contract (null when it does not match). + return buildToolResult({ + result: { ...result, stdout: "" }, + data: parseTargetInspectEnvelope(parseJson(result.stdout)), + }); + }, + }); + installTypedToolDispatch({ server: opts.server, tools }); } @@ -1547,6 +1600,36 @@ export function buildDoctorArgs(opts: { ]; } +/** + * `hack project target --json` argv. Project selection is passed through + * unresolved (both selectors included when both are given) so the CLI owns + * every resolution refusal, including `conflicting_selectors`. + */ +export function buildTargetInspectArgs(opts: { + readonly projectName?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}): string[] { + return [ + "project", + "target", + "--json", + ...(opts.projectName === undefined + ? [] + : [`--project=${opts.projectName}`]), + ...(opts.path === undefined ? [] : [`--path=${opts.path}`]), + ...(opts.branch === undefined ? [] : [`--branch=${opts.branch}`]), + ...(opts.service === undefined ? [] : [`--service=${opts.service}`]), + ...(opts.env === undefined ? [] : [`--env=${opts.env}`]), + ...(opts.expectGeneration === undefined + ? [] + : [`--expect-generation=${opts.expectGeneration}`]), + ]; +} + /** `hack env explain --json` argv; the validated key follows `--`. */ export function buildEnvExplainArgs(opts: { readonly projectArgs: readonly string[]; diff --git a/tests/e2e-portable-bootstrap-controls.test.ts b/tests/e2e-portable-bootstrap-controls.test.ts new file mode 100644 index 000000000..bda7f0687 --- /dev/null +++ b/tests/e2e-portable-bootstrap-controls.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; +import { createDecipheriv, createHash } from "node:crypto"; +import { encryptSynthetic } from "../scripts/portable-bootstrap-smoke.ts"; +import { + type AppIdentity, + checkStageExpectation, + classifyAppTransition, + containsForbiddenValue, + PORTABLE_BOOTSTRAP_EXPECTATIONS, + PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + PORTABLE_BOOTSTRAP_STAGES, + renderPortableBootstrapReceipt, + type StageReceipt, + unexpectedWorktreeChanges, +} from "./e2e/portable-bootstrap-controls.ts"; +import { ALL_SCENARIOS } from "./e2e/run.ts"; +import { E2E_DURATION_HINTS_SECONDS } from "./e2e/shard.ts"; + +const running = (id: string, startedAt = "t1"): AppIdentity => ({ + id, + startedAt, + running: true, +}); + +function receipt( + stage: (typeof PORTABLE_BOOTSTRAP_STAGES)[number] +): StageReceipt { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[stage]; + return { + stage, + outcome: expected.outcome, + app: expected.app, + durationMs: 1200, + stdoutBytes: 10, + stderrBytes: 20, + trackedBytes: 4096, + cacheBytes: expected.app === "absent" ? null : 128, + cacheVolumes: 1, + }; +} + +const complete = () => PORTABLE_BOOTSTRAP_STAGES.map(receipt); + +describe("portable bootstrap negative controls", () => { + test("is registered for the hosted Docker tier with a shard hint", () => { + const scenario = ALL_SCENARIOS.find( + (entry) => entry.name === "portable-bootstrap-negatives" + ); + expect(scenario?.tier).toBe("docker"); + expect(scenario?.requiresExplicitSelection).toBeUndefined(); + expect( + Object.hasOwn(E2E_DURATION_HINTS_SECONDS, "portable-bootstrap-negatives") + ).toBe(true); + }); + + test("every refusal leaves the consumer absent or unchanged", () => { + for (const stage of PORTABLE_BOOTSTRAP_STAGES) { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[stage]; + expect(stage.startsWith("refuse-")).toBe(expected.outcome === "refused"); + if (expected.outcome === "refused") { + expect(["absent", "unchanged"]).toContain(expected.app); + } + } + expect(PORTABLE_BOOTSTRAP_EXPECTATIONS["bootstrap-repeat"].app).toBe( + "unchanged" + ); + }); + + test("classifies consumer identity by container ID and start time", () => { + expect(classifyAppTransition({ before: null, after: null })).toBe("absent"); + expect(classifyAppTransition({ before: null, after: running("a") })).toBe( + "created" + ); + expect( + classifyAppTransition({ before: running("a"), after: running("a") }) + ).toBe("unchanged"); + expect( + classifyAppTransition({ before: running("a"), after: running("b") }) + ).toBe("replaced"); + // A restart in place is not "unchanged": the refusal touched the consumer. + expect( + classifyAppTransition({ + before: running("a"), + after: running("a", "t2"), + }) + ).toBe("replaced"); + expect(classifyAppTransition({ before: running("a"), after: null })).toBe( + "removed" + ); + }); + + test("checks exit, consumer transition and liveness per stage", () => { + const ok = { + stage: "refuse-stale-image" as const, + exitCode: 1, + timedOut: false, + transition: "unchanged" as const, + running: true, + }; + expect(checkStageExpectation(ok)).toBeNull(); + expect(checkStageExpectation({ ...ok, exitCode: 0 })).toBe( + "expected refused, observed bootstrapped" + ); + expect(checkStageExpectation({ ...ok, transition: "replaced" })).toBe( + "expected consumer unchanged, observed replaced" + ); + expect(checkStageExpectation({ ...ok, running: false })).toBe( + "consumer must be running after the stage" + ); + expect(checkStageExpectation({ ...ok, timedOut: true })).toBe( + "command timed out" + ); + expect( + checkStageExpectation({ + stage: "refuse-missing-key", + exitCode: 1, + timedOut: false, + transition: "absent", + running: false, + }) + ).toBeNull(); + expect( + checkStageExpectation({ + stage: "bootstrap-repeat", + exitCode: 0, + timedOut: false, + transition: "replaced", + running: true, + }) + ).toBe("expected consumer unchanged, observed replaced"); + }); + + test("reports worktree paths outside the allowed set", () => { + const porcelain = [ + "?? .hack/.gitignore", + " M package.json", + "R old.txt -> new.txt", + "", + ].join("\n"); + expect(unexpectedWorktreeChanges({ porcelain: "", allowed: [] })).toEqual( + [] + ); + expect( + unexpectedWorktreeChanges({ + porcelain, + allowed: [".hack/.gitignore", "package.json", "new.txt"], + }) + ).toEqual([]); + expect( + unexpectedWorktreeChanges({ + porcelain, + allowed: [".hack/.gitignore"], + }) + ).toEqual(["new.txt", "package.json"]); + }); + + test("detects forbidden values and ignores empty entries", () => { + expect( + containsForbiddenValue({ texts: ["ok", "has-key-x"], forbidden: ["key"] }) + ).toBe(true); + expect( + containsForbiddenValue({ texts: ["ok"], forbidden: ["", "absent"] }) + ).toBe(false); + }); + + test("renders a complete value-free receipt", () => { + const parsed: unknown = JSON.parse( + renderPortableBootstrapReceipt({ stages: complete(), imageBytes: 7 }) + ); + expect(parsed).toEqual({ + schema: PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + imageBytes: 7, + stages: complete(), + }); + }); + + test("refuses incomplete, reordered or open-ended receipts", () => { + const render = + (stages: readonly unknown[], imageBytes: unknown = 1) => + () => + renderPortableBootstrapReceipt({ stages, imageBytes }); + const message = /Invalid portable bootstrap receipt/; + expect(render(complete().slice(1))).toThrow(message); + expect(render(complete().reverse())).toThrow(message); + expect(render(complete(), -1)).toThrow(message); + expect(render(complete(), 1.5)).toThrow(message); + const mutate = (patch: Record) => + complete().map((entry, index) => + index === 4 ? { ...entry, ...patch } : entry + ); + expect(render(mutate({ outcome: "skipped" }))).toThrow(message); + expect(render(mutate({ app: "restarted" }))).toThrow(message); + expect(render(mutate({ durationMs: -1 }))).toThrow(message); + expect(render(mutate({ stdoutBytes: 0.5 }))).toThrow(message); + expect(render(mutate({ cacheBytes: "128" }))).toThrow(message); + expect(render(mutate({ detail: "free text" }))).toThrow(message); + const missing = complete().map(({ cacheVolumes: _, ...entry }) => entry); + expect(render(missing)).toThrow(message); + expect(render([...complete().slice(0, -1), null])).toThrow(message); + }); + + test("synthetic env values use the project v1 envelope", () => { + const key = "synthetic-key"; + const sealed = encryptSynthetic({ key, secret: "synthetic-value" }); + const [prefix, iv, tag, ciphertext] = sealed.split(":"); + expect(prefix).toBe("v1"); + const decipher = createDecipheriv( + "aes-256-gcm", + createHash("sha256").update(key).digest(), + Buffer.from(iv ?? "", "base64") + ); + decipher.setAuthTag(Buffer.from(tag ?? "", "base64")); + expect( + Buffer.concat([ + decipher.update(Buffer.from(ciphertext ?? "", "base64")), + decipher.final(), + ]).toString("utf8") + ).toBe("synthetic-value"); + expect(sealed).not.toContain("synthetic-value"); + }); +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 4902e0cfd..0b2177f64 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -205,6 +205,64 @@ data survive that cleanup. The canary is then removed separately. PostgreSQL use fixture-only trust on this unexposed network. This qualifies the actual execution host, not Codex Cloud capabilities, browser access or native VM support elsewhere. +## Portable bootstrap negative controls + +`portable-bootstrap-negatives` (HACK-1143) adds negative controls to the portable +bootstrap fixture. It runs the selected CLI in slim mode with cached `alpine:3.20` +pinned by immutable ID, `network_mode: none`, no published ports and no pulls. +A `deps` producer fills a `locked-v1` dependency cache. Its install is frozen: it +fails when the lockfile's manifest digest no longer matches `package.json`, and the +workspace is mounted read-only. The `app` consumer mounts the cache read-only. It +copies the cache artifact only after a synthetic encrypted token, scoped to `app`, +matches the expected digest, so a loaded artifact proves env injection without the +scenario reading the value. + +Every stage runs `hack up --detach app` and must give this result: + +| Stage | Change before the stage | Required result | +| --- | --- | --- | +| `refuse-missing-key` | no key | refused; no owned container, network or volume; only the managed `.hack/.gitignore` may appear | +| `refuse-wrong-key` | wrong key | refused; nothing created; no worktree diff | +| `refuse-wrong-overlay` | `--env drift`, an overlay sealed under another key | refused; nothing created; no worktree diff | +| `bootstrap-cold` | correct key | consumer created; one initialization; one ready cache; no diff | +| `bootstrap-repeat` | none | same consumer ID and start time; same cache; still one initialization; same index; no diff | +| `refuse-wrong-key-running` | wrong key | refused; consumer unchanged | +| `refuse-stale-image` | both services pinned to an absent image ID | refused; consumer unchanged; the stale ID is not pulled and the cached tag keeps its ID | +| `refuse-frozen-lockfile` | `package.json` no longer matches the lockfile | refused; consumer unchanged; lockfile bytes kept; the new cache has an attempt but no ready marker | +| `bootstrap-changed-lockfile` | committed lockfile revision | consumer replaced on a new cache initialized once; the previous ready cache is kept | +| `refuse-corrupted-cache` | the current ready marker is overwritten | refused; consumer unchanged; no reinstall; the corrupted marker is not rewritten | +| `bootstrap-new-generation` | `hack.dependencies.cache-generation` bumped | consumer replaced on a new cache; the corrupted cache is kept for inspection | + +Unchanged means the same full container ID and `State.StartedAt`. CLI output and +Git-tracked files must never contain the keys or token values, and the scenario +reads only fixed container fields because the consumer's environment holds the +token. Cleanup checks exact owner and Compose project labels, and an adjacent +canary volume must survive it. + +The scenario ends with one value-free line, `portable-bootstrap receipt {...}`. For +each stage it records the outcome, the consumer transition, the command duration, +stdout/stderr bytes, Git-tracked fixture bytes, the bytes in the consumer's mounted +cache and the number of owned cache volumes. It also records the pinned image +size. It contains no env values, keys, resource IDs, image IDs or paths; +`tests/e2e/portable-bootstrap-controls.ts` refuses any other shape. + +```sh +HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 bun tests/e2e/run.ts --only=portable-bootstrap-negatives +``` + +Two local tests need no Docker engine. `tests/portable-bootstrap-env-refusal.test.ts` +runs the source CLI on the same fixture against a recording Docker stub: a missing +key, a wrong key and a wrong overlay key must fail before any Compose `create`, +`run`, `start` or `up`, and the correct key must reach the `deps` bootstrap first. +`tests/e2e-portable-bootstrap-controls.test.ts` covers the stage expectations, +consumer classification, worktree diff check and receipt validation. + +This does not cover WU12 storage: mount and free-space admission, generation holds, +reclamation and archives. Old cache volumes are kept, never reclaimed. A stale +image here is a pinned ID that is absent locally, not a baked-tool manifest +mismatch. A pass qualifies the hosted Linux Docker runner, not native VM support or +a cloud provider. + ## Pattern fixture qualification `pattern-p01-dependency-bootstrap` (HACK-1145), `pattern-p02-portable-toolchain` @@ -286,6 +344,49 @@ fixture; host and provider proxy settings remain unchanged. In a real project, add its internal service names to the existing proxy exceptions rather than discarding the project's other entries. +## MCP logs on real Compose (F13) + +`mcp-logs-compose` (HACK-1134, HACK-1155) starts `hack mcp serve` over stdio +and calls the logs tools against a real Compose project. Every tool call runs +the CLI under test through `HACK_MCP_COMMAND`. The fixture uses cached +`alpine:3.20` with `pull_policy: never`, a private internal network, no +published ports, and Hack internal DNS/TLS turned off. Both log backends are set +to `compose`. It has three services: `ticker` prints a boot marker and then a +numbered line every 0.2 s, `quiet` prints one line, and `bulk` prints 1500 +lines of 2048 four-byte emoji (about 12 MiB) and then idles. + +The scenario requires these results: + +- A `quiet` snapshot completes with `cutoff: null`. A `quiet` tail stops at + `maxMs` with a `time` cutoff and only its one line. +- A `ticker` tail stops at `maxMs` with a `time` cutoff and live lines in + order. Another stops at exactly 5 log events with an `events` cutoff. +- A `bulk` snapshot stops at 8 MiB with a `bytes` cutoff and + `outputTruncated`. It keeps an ordered prefix of whole lines. No result + payload may contain U+FFFD, so a split code point fails the run. Where the + cut falls varies from run to run, so a single pass does not prove that this + run cut inside a code point; `tests/mcp-logs-contract.test.ts` covers that + case deterministically. +- With `DOCKER_HOST` pointing at a missing socket, a snapshot and a tail both + return the closed `backend_error`. The tail ends at end of stream, not at + `maxMs`. +- `docker restart` on the exact owned `ticker` container during a tail: the + tail still ends in one typed outcome within its bound. Lines repeated across + the restart are logged, not asserted (see `docs/reference/mcp-logs.md`). The + stored history then holds `BOOT 1` and `BOOT 2` once each, with ordered + lines. A new tail streams only the new boot. A new MCP session does not + replay lines older than the previous session's last line. +- `hack run quiet -- false` exits 1, and an `exit 7` child exits 7 with its + output. The MCP `hack.project.run` result reports `ok: false` and + `exitCode: 7`, and a `true` child reports `ok: true`. + +Cleanup removes only containers, networks and volumes with the fixture's +owner label. + +```sh +HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 bun tests/e2e/run.ts --only=mcp-logs-compose +``` + ## Native file qualification `native-config-files` requires the current compiled CLI, its matching compiler and diff --git a/tests/e2e/portable-bootstrap-controls.ts b/tests/e2e/portable-bootstrap-controls.ts new file mode 100644 index 000000000..da949d027 --- /dev/null +++ b/tests/e2e/portable-bootstrap-controls.ts @@ -0,0 +1,385 @@ +/** + * Pure decisions and value-free receipts for the `portable-bootstrap-negatives` + * Docker scenario. Everything here is free of I/O so the stage expectations, + * app-identity classification, worktree-diff checks and receipt shape can be + * exercised (and mutation-tested) without a Docker engine. + * + * Receipts carry only closed stage/outcome/transition names and non-negative + * integers. They never include env values, keys, secrets, command output, + * resource IDs, image IDs or file paths. + */ + +/** Canonical stage order; a receipt must contain each stage exactly once. */ +export const PORTABLE_BOOTSTRAP_STAGES = [ + "refuse-missing-key", + "refuse-wrong-key", + "refuse-wrong-overlay", + "bootstrap-cold", + "bootstrap-repeat", + "refuse-wrong-key-running", + "refuse-stale-image", + "refuse-frozen-lockfile", + "bootstrap-changed-lockfile", + "refuse-corrupted-cache", + "bootstrap-new-generation", +] as const; + +export type PortableBootstrapStage = (typeof PORTABLE_BOOTSTRAP_STAGES)[number]; + +export type StageOutcome = "refused" | "bootstrapped"; + +/** How the single owned consumer container changed across one stage. */ +export type AppTransition = + | "absent" + | "created" + | "unchanged" + | "replaced" + | "removed"; + +export type AppIdentity = { + readonly id: string; + readonly startedAt: string; + readonly running: boolean; +} | null; + +type StageExpectation = { + readonly outcome: StageOutcome; + readonly app: Exclude; +}; + +/** + * Every refusal must leave the consumer exactly as it was: absent before the + * first successful bootstrap, otherwise the same running container. A repeat + * bootstrap must not recreate it; only a changed cache identity may replace it. + */ +export const PORTABLE_BOOTSTRAP_EXPECTATIONS: Readonly< + Record +> = { + "refuse-missing-key": { outcome: "refused", app: "absent" }, + "refuse-wrong-key": { outcome: "refused", app: "absent" }, + "refuse-wrong-overlay": { outcome: "refused", app: "absent" }, + "bootstrap-cold": { outcome: "bootstrapped", app: "created" }, + "bootstrap-repeat": { outcome: "bootstrapped", app: "unchanged" }, + "refuse-wrong-key-running": { outcome: "refused", app: "unchanged" }, + "refuse-stale-image": { outcome: "refused", app: "unchanged" }, + "refuse-frozen-lockfile": { outcome: "refused", app: "unchanged" }, + "bootstrap-changed-lockfile": { outcome: "bootstrapped", app: "replaced" }, + "refuse-corrupted-cache": { outcome: "refused", app: "unchanged" }, + "bootstrap-new-generation": { outcome: "bootstrapped", app: "replaced" }, +}; + +/** Same container ID and start time means the consumer was not touched. */ +export function classifyAppTransition(opts: { + readonly before: AppIdentity; + readonly after: AppIdentity; +}): AppTransition { + const { before, after } = opts; + if (before === null) { + return after === null ? "absent" : "created"; + } + if (after === null) { + return "removed"; + } + return before.id === after.id && before.startedAt === after.startedAt + ? "unchanged" + : "replaced"; +} + +/** + * Compare one observed stage with its fixed expectation. Returns a fixed, + * value-free failure reason, or null when the stage behaved as required. + */ +export function checkStageExpectation(opts: { + readonly stage: PortableBootstrapStage; + readonly exitCode: number; + readonly timedOut: boolean; + readonly transition: AppTransition; + readonly running: boolean; +}): string | null { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[opts.stage]; + if (opts.timedOut) { + return "command timed out"; + } + const outcome: StageOutcome = + opts.exitCode === 0 ? "bootstrapped" : "refused"; + if (outcome !== expected.outcome) { + return `expected ${expected.outcome}, observed ${outcome}`; + } + if (opts.transition !== expected.app) { + return `expected consumer ${expected.app}, observed ${opts.transition}`; + } + if (expected.app !== "absent" && !opts.running) { + return "consumer must be running after the stage"; + } + return null; +} + +/** + * Paths from `git status --porcelain=v1 --untracked-files=all` that are not in + * `allowed`. Renames report their destination. An empty result means the + * worktree differs from HEAD only by the allowed paths. + */ +export function unexpectedWorktreeChanges(opts: { + readonly porcelain: string; + readonly allowed: readonly string[]; +}): readonly string[] { + const unexpected: string[] = []; + for (const line of opts.porcelain.split("\n")) { + if (line.trim().length === 0) { + continue; + } + const entry = line.slice(3); + const arrow = entry.lastIndexOf(" -> "); + const path = arrow >= 0 ? entry.slice(arrow + 4) : entry; + if (!opts.allowed.includes(path)) { + unexpected.push(path); + } + } + return unexpected.sort(); +} + +/** True when any non-empty forbidden value occurs in any text. */ +export function containsForbiddenValue(opts: { + readonly texts: readonly string[]; + readonly forbidden: readonly string[]; +}): boolean { + return opts.forbidden.some( + (value) => + value.length > 0 && opts.texts.some((text) => text.includes(value)) + ); +} + +export type StageReceipt = { + readonly stage: PortableBootstrapStage; + readonly outcome: StageOutcome; + readonly app: AppTransition; + readonly durationMs: number; + readonly stdoutBytes: number; + readonly stderrBytes: number; + /** Bytes of Git-tracked fixture files after the stage. */ + readonly trackedBytes: number; + /** Content bytes in the consumer's mounted cache; null without a consumer. */ + readonly cacheBytes: number | null; + /** Owned dependency-cache volumes after the stage. */ + readonly cacheVolumes: number; +}; + +export const PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA = + "hack.e2e.portable-bootstrap-negatives.v1"; + +const RECEIPT_KEYS = [ + "stage", + "outcome", + "app", + "durationMs", + "stdoutBytes", + "stderrBytes", + "trackedBytes", + "cacheBytes", + "cacheVolumes", +] as const; + +const OUTCOMES: readonly string[] = ["refused", "bootstrapped"]; +const TRANSITIONS: readonly string[] = [ + "absent", + "created", + "unchanged", + "replaced", + "removed", +]; + +function count(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function invalidReceipt(): Error { + return new Error( + "Invalid portable bootstrap receipt: require each canonical stage once, closed names and non-negative integer measurements" + ); +} + +/** + * Validate a complete receipt and render it as one JSON line. Unknown keys, + * missing or reordered stages, open-ended strings and non-integer + * measurements are refused with one fixed error, so a receipt can carry only + * value-free data. + */ +export function renderPortableBootstrapReceipt(opts: { + readonly stages: readonly unknown[]; + readonly imageBytes: unknown; +}): string { + if ( + !count(opts.imageBytes) || + opts.stages.length !== PORTABLE_BOOTSTRAP_STAGES.length + ) { + throw invalidReceipt(); + } + const stages: StageReceipt[] = []; + for (const [index, value] of opts.stages.entries()) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw invalidReceipt(); + } + const keys = Object.keys(value); + if ( + keys.length !== RECEIPT_KEYS.length || + !RECEIPT_KEYS.every((key) => Object.hasOwn(value, key)) + ) { + throw invalidReceipt(); + } + const record = value as Record<(typeof RECEIPT_KEYS)[number], unknown>; + const stage = PORTABLE_BOOTSTRAP_STAGES[index]; + if ( + stage === undefined || + record.stage !== stage || + typeof record.outcome !== "string" || + !OUTCOMES.includes(record.outcome) || + typeof record.app !== "string" || + !TRANSITIONS.includes(record.app) || + !count(record.durationMs) || + !count(record.stdoutBytes) || + !count(record.stderrBytes) || + !count(record.trackedBytes) || + !(record.cacheBytes === null || count(record.cacheBytes)) || + !count(record.cacheVolumes) + ) { + throw invalidReceipt(); + } + stages.push({ + stage, + outcome: record.outcome as StageOutcome, + app: record.app as AppTransition, + durationMs: record.durationMs, + stdoutBytes: record.stdoutBytes, + stderrBytes: record.stderrBytes, + trackedBytes: record.trackedBytes, + cacheBytes: record.cacheBytes, + cacheVolumes: record.cacheVolumes, + }); + } + return JSON.stringify({ + schema: PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + imageBytes: opts.imageBytes, + stages, + }); +} + +/** Owner label on every fixture container, volume and probe. */ +export const PORTABLE_BOOTSTRAP_OWNER = "hack.e2e.portable-bootstrap-owner"; + +/** + * Frozen install: refuse a lockfile whose manifest digest no longer matches + * `package.json`. The workspace is mounted read-only, so the installer cannot + * repair the lockfile by rewriting it. + */ +export const PORTABLE_BOOTSTRAP_INSTALL_SCRIPT = [ + "set -eu", + "manifest=$(sha256sum /workspace/package.json | cut -d ' ' -f1)", + 'grep -qx "manifest=$manifest" /workspace/bun.lock || { echo "frozen lockfile mismatch" >&2; exit 23; }', + "echo initialized >> /deps/initializations", + "sha256sum /workspace/bun.lock | cut -d ' ' -f1 > /deps/artifact", + "", +].join("\n"); + +export const PORTABLE_BOOTSTRAP_VERIFY_SCRIPT = [ + "set -eu", + 'test "$(cat /deps/artifact)" = "$(sha256sum /workspace/bun.lock | cut -d \' \' -f1)"', + 'test "$(wc -l < /deps/initializations)" -eq 1', + "", +].join("\n"); + +export function sha256Hex(data: string): string { + return new Bun.CryptoHasher("sha256").update(data).digest("hex"); +} + +/** Lockfile bound to one manifest digest; a new revision changes the cache key. */ +export function renderBootstrapLock(opts: { + readonly manifest: string; + readonly revision: number; +}): string { + return `manifest=${sha256Hex(opts.manifest)}\nrevision=${opts.revision}\n`; +} + +/** Project env file whose only value is an app-scoped sealed token. */ +export function renderBootstrapEnvFile(opts: { + readonly environment: string; + readonly sealed: string; +}): string { + return [ + "version: 1", + `environment: ${opts.environment}`, + "secretsprovider: project_key", + "values:", + " app:", + " BOOTSTRAP_TOKEN:", + ` secure: ${opts.sealed}`, + "", + ].join("\n"); +} + +/** + * A locked-v1 producer (`deps`) and a read-only consumer (`app`) on one pinned + * image, with no network, ports or pulls. The consumer exits 41 unless the + * injected token matches `tokenDigest`, and copies the cache artifact only + * after that check, so a loaded artifact proves env injection succeeded + * without the scenario ever reading the value. + */ +export function renderBootstrapCompose(opts: { + readonly name: string; + readonly ref: string; + readonly platform: string; + readonly generation: string; + readonly tokenDigest: string; +}): string { + const base = { + image: opts.ref, + platform: opts.platform, + pull_policy: "never", + network_mode: "none", + }; + const app = [ + "set -eu", + `test "$(printf %s "\${BOOTSTRAP_TOKEN:-}" | sha256sum | cut -d ' ' -f1)" = ${opts.tokenDigest} || exit 41`, + "cp /deps/artifact /tmp/loaded", + "exec sleep 3600", + ].join("\n"); + const labels = { [PORTABLE_BOOTSTRAP_OWNER]: opts.name }; + return `${JSON.stringify( + { + name: opts.name, + services: { + deps: { + ...base, + entrypoint: [], + command: ["sh", "/workspace/deps/install.sh"], + labels: { + ...labels, + "hack.dependencies.cache-volume": "deps", + "hack.dependencies.lockfiles": "bun.lock", + "hack.dependencies.runtime-files": + "package.json,deps/install.sh,deps/verify.sh", + "hack.dependencies.cache-protocol": "locked-v1", + "hack.dependencies.cache-generation": opts.generation, + "hack.dependencies.cache-verify": JSON.stringify([ + "sh", + "/workspace/deps/verify.sh", + ]), + }, + volumes: ["deps:/deps", "..:/workspace:ro"], + }, + app: { + ...base, + labels, + // Compose must pass shell expansions to the container unchanged. + command: ["sh", "-c", app.replaceAll("$", () => "$$")], + depends_on: { + deps: { condition: "service_completed_successfully" }, + }, + volumes: ["deps:/deps:ro"], + }, + }, + volumes: { deps: { labels } }, + }, + null, + 2 + )}\n`; +} diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 8357b3502..13ef0ff6c 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -22,6 +22,7 @@ import { execStaleEnvScenario } from "./scenarios/exec-stale-env.ts"; import { initScenario } from "./scenarios/init.ts"; import { lifecycleHostProcessScenario } from "./scenarios/lifecycle-host-process.ts"; import { lifecycleSessionRecoveryScenario } from "./scenarios/lifecycle-session-recovery.ts"; +import { mcpLogsComposeScenario } from "./scenarios/mcp-logs-compose.ts"; import { nativeComposeAdoptionJobWorktreesScenario } from "./scenarios/native-compose-adoption-job-worktrees.ts"; import { nativeComposeAdoptionRoutingWorktreesScenario } from "./scenarios/native-compose-adoption-routing-worktrees.ts"; import { nativeComposeAdoptionSourceBindWorktreesScenario } from "./scenarios/native-compose-adoption-source-bind-worktrees.ts"; @@ -51,6 +52,7 @@ import { nativeConfigVmFilesScenario } from "./scenarios/native-config-vm-files. import { patternP01DependencyBootstrapScenario } from "./scenarios/pattern-p01-dependency-bootstrap.ts"; import { patternP02PortableToolchainScenario } from "./scenarios/pattern-p02-portable-toolchain.ts"; import { patternP07CapabilityOverlaysScenario } from "./scenarios/pattern-p07-capability-overlays.ts"; +import { portableBootstrapNegativesScenario } from "./scenarios/portable-bootstrap-negatives.ts"; import { portableMultiserviceScenario } from "./scenarios/portable-multiservice.ts"; import { upDownScenario } from "./scenarios/up-down.ts"; import { worktreeBranchDefaultScenario } from "./scenarios/worktree-branch-default.ts"; @@ -103,7 +105,9 @@ export const ALL_SCENARIOS: readonly Scenario[] = [ upDownScenario, execStaleEnvScenario, portableMultiserviceScenario, + portableBootstrapNegativesScenario, composeConfigHashDriftScenario, + mcpLogsComposeScenario, nativeConfigComposeScenario, nativeConfigBuildScenario, nativeConfigDownHooksScenario, diff --git a/tests/e2e/scenarios/mcp-logs-compose.ts b/tests/e2e/scenarios/mcp-logs-compose.ts new file mode 100644 index 000000000..f7364add9 --- /dev/null +++ b/tests/e2e/scenarios/mcp-logs-compose.ts @@ -0,0 +1,1007 @@ +import { chmod } from "node:fs/promises"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { createMonorepoFixture } from "../fixture.ts"; +import { + buildCliEnv, + type CliResult, + expect, + expectExit, + resolveCliSpawnArgs, + runCommand, + type Scenario, + type ScenarioContext, +} from "../harness.ts"; + +const OWNER = "hack.e2e.mcp-logs-compose-owner"; +const PROJECT = "com.docker.compose.project"; +const SERVICE = "com.docker.compose.service"; +const ONEOFF = "com.docker.compose.oneoff"; +const TIMEOUT = 180_000; +const IMAGE = "alpine:3.20"; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const CONTAINER_ID = /^[a-f0-9]{64}$/; +/** The MCP server's shared stdout+stderr capture budget (8 MiB). */ +const OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; +/** The closed post-spawn failure the logs tools report (src/mcp/logs-contract.ts). */ +const BACKEND_ERROR = { + code: "E_UNEXPECTED", + reason: "backend_error", + message: "Log backend failed. Run 'hack logs' for details.", +} as const; +const REPLACEMENT_CHAR = String.fromCodePoint(0xff_fd); +const TICK = /TICK boot=(\d+) seq=(\d+) →/; +const BOOT = /BOOT (\d+) →/; +/** Bulk lines are 2048 four-byte emoji (8 KiB); 1500 of them exceed 8 MiB. */ +const BULK_LINES = 1500; +const BULK_LINE = /BULK (\d+) ((?:😀)+)$/u; +const BULK_EMOJI_PER_LINE = 2048; +const RUNTIME_ENV = { + HACK_EXECUTION_MODE: "slim", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", +} as const; + +// Every service exits promptly on SIGTERM so restart and down stay fast. `$$` +// is Compose's escape for a literal `$` in the shell command. +const TICKER = [ + "trap 'exit 0' TERM", + // The writable layer survives a container restart, so this counts boots. + "b=$$(( $$(cat /tmp/boot 2>/dev/null || echo 0) + 1 ))", + "echo $$b > /tmp/boot", + 'echo "BOOT $$b →"', + "i=0", + 'while :; do i=$$((i + 1)); echo "TICK boot=$$b seq=$$i →"; sleep 0.2; done', +].join("; "); +const QUIET = [ + "trap 'exit 0' TERM", + "echo 'QUIET ready ✓'", + "while :; do sleep 1; done", +].join("; "); +const BULK = [ + "trap 'exit 0' TERM", + "s=😀", + "i=0", + "while [ $$i -lt 11 ]; do s=$$s$$s; i=$$((i + 1)); done", + "n=0", + `while [ $$n -lt ${BULK_LINES} ]; do n=$$((n + 1)); echo "BULK $$n $$s"; done`, + "echo BULK-DONE", + "while :; do sleep 1; done", +].join("; "); + +type ToolPayload = { + readonly isError: boolean; + readonly ok: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly outputTruncated: boolean; + readonly data: Readonly> | null; + readonly error: unknown; + /** The serialized result text, for whole-payload UTF-8 checks. */ + readonly text: string; +}; + +type Tick = { readonly boot: number; readonly seq: number }; + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function object(value: unknown): Record { + const parsed = record(value); + if (!parsed) { + throw new Error("Expected an object from Docker inspect"); + } + return parsed; +} + +/** Narrow one `tools/call` result to the documented logs/run payload. */ +function parsePayload(opts: { + readonly tool: string; + readonly result: unknown; +}): ToolPayload { + const result = record(opts.result); + const structured = record(result?.structuredContent); + if ( + !( + result && + structured && + typeof structured.ok === "boolean" && + typeof structured.exitCode === "number" && + typeof structured.stdout === "string" && + typeof structured.stderr === "string" + ) + ) { + throw new Error(`${opts.tool} returned no structured result payload`); + } + const content = Array.isArray(result.content) ? result.content : []; + const text = content + .map((part) => { + const entry = record(part); + return entry && typeof entry.text === "string" ? entry.text : ""; + }) + .join("\n"); + return { + isError: result.isError === true, + ok: structured.ok, + exitCode: structured.exitCode, + stdout: structured.stdout, + stderr: structured.stderr, + outputTruncated: structured.outputTruncated === true, + data: record(structured.data), + error: structured.error, + text, + }; +} + +function events(payload: ToolPayload): Record[] { + const list = payload.data?.events; + if (!Array.isArray(list)) { + return []; + } + return list.flatMap((event) => { + const parsed = record(event); + return parsed ? [parsed] : []; + }); +} + +/** The raw Compose line of every `type: "log"` event, in stream order. */ +function logLines(payload: ToolPayload): string[] { + return events(payload).flatMap((event) => { + if (event.type !== "log") { + return []; + } + const entry = record(event.entry); + return entry && typeof entry.raw === "string" ? [entry.raw] : []; + }); +} + +function ticksOf(lines: readonly string[]): Tick[] { + return lines.flatMap((line) => { + const match = TICK.exec(line); + return match ? [{ boot: Number(match[1]), seq: Number(match[2]) }] : []; + }); +} + +/** Strictly increasing (boot, seq): no line repeats or arrives out of order. */ +function strictlyOrdered(ticks: readonly Tick[]): boolean { + return ticks.every((tick, index) => { + const previous = ticks[index - 1]; + return ( + !previous || + tick.boot > previous.boot || + (tick.boot === previous.boot && tick.seq > previous.seq) + ); + }); +} + +/** The leading boot-1 ticks up to the first repeat, reorder or new boot. */ +function firstBootRun(ticks: readonly Tick[]): Tick[] { + const run: Tick[] = []; + for (const tick of ticks) { + const previous = run.at(-1); + if (tick.boot !== 1 || (previous && tick.seq <= previous.seq)) { + break; + } + run.push(tick); + } + return run; +} + +function cutoffOf(payload: ToolPayload): unknown { + return payload.data?.cutoff; +} + +function sameCutoff( + actual: unknown, + expected: { readonly bound: string; readonly limit: number } +): boolean { + const cutoff = record(actual); + return ( + cutoff !== null && + cutoff.bound === expected.bound && + cutoff.limit === expected.limit && + Object.keys(cutoff).length === 2 + ); +} + +function isClosedBackendError(error: unknown): boolean { + const parsed = record(error); + return ( + parsed !== null && + Object.keys(parsed).length === 3 && + parsed.code === BACKEND_ERROR.code && + parsed.reason === BACKEND_ERROR.reason && + parsed.message === BACKEND_ERROR.message + ); +} + +function summarize(payload: ToolPayload): string { + return JSON.stringify({ + isError: payload.isError, + ok: payload.ok, + exitCode: payload.exitCode, + outputTruncated: payload.outputTruncated, + cutoff: cutoffOf(payload) ?? null, + stop_reason: payload.data?.stop_reason ?? null, + duration_ms: payload.data?.duration_ms ?? null, + events: events(payload).length, + logs: logLines(payload).length, + error: payload.error ?? null, + stderr: payload.stderr.slice(-400), + }); +} + +/** Quote a path for a POSIX shell script. */ +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function composeDocument(opts: { + readonly name: string; + readonly image: string; +}): string { + const labels = { [OWNER]: opts.name }; + const service = (command: string) => ({ + image: opts.image, + pull_policy: "never", + labels, + stop_grace_period: "2s", + command: ["sh", "-c", command], + }); + return JSON.stringify({ + name: opts.name, + services: { + ticker: service(TICKER), + quiet: service(QUIET), + bulk: service(BULK), + }, + networks: { default: { internal: true, labels } }, + }); +} + +type Cutoff = { readonly bound: string; readonly limit: number }; + +type Session = { + readonly call: ( + tool: string, + input: Readonly> + ) => Promise; + readonly close: () => Promise; +}; + +type ResourceKind = "container" | "network" | "volume"; + +/** Owned effects and observations for one scenario run. */ +type Stack = { + readonly tempRoot: string; + readonly log: (message: string) => void; + readonly docker: (args: readonly string[]) => Promise; + readonly cli: (opts: { + readonly args: readonly string[]; + readonly codes: readonly number[]; + }) => Promise; + readonly list: ( + kind: ResourceKind, + filters: readonly string[] + ) => Promise; + readonly owned: (kind: ResourceKind, id: string) => Promise; + readonly serviceContainer: (service: string) => Promise; + readonly openSession: (opts: { + readonly label: string; + readonly env?: Readonly>; + }) => Promise; +}; + +/** Require a successful result and exactly the expected cutoff (or none). */ +function requireOk(opts: { + readonly step: string; + readonly payload: ToolPayload; + readonly cutoff: Cutoff | null; +}): void { + const { payload } = opts; + expect({ + that: payload.ok && !payload.isError && payload.error === undefined, + message: `${opts.step} must succeed without isError or error: ${summarize(payload)}`, + }); + expect({ + that: + opts.cutoff === null + ? cutoffOf(payload) === null && payload.exitCode === 0 + : sameCutoff(cutoffOf(payload), opts.cutoff), + message: `${opts.step} must report cutoff ${JSON.stringify(opts.cutoff)}: ${summarize(payload)}`, + }); +} + +/** The one typed outcome of a tail bounded by `maxMs`, or `untyped`. */ +function tailOutcome(opts: { + readonly payload: ToolPayload; + readonly maxMs: number; +}): "time-cutoff" | "complete" | "backend_error" | "untyped" { + const { payload } = opts; + if (!payload.ok) { + return isClosedBackendError(payload.error) && cutoffOf(payload) === null + ? "backend_error" + : "untyped"; + } + if (sameCutoff(cutoffOf(payload), { bound: "time", limit: opts.maxMs })) { + return "time-cutoff"; + } + return cutoffOf(payload) === null && + payload.data?.stop_reason === "eof" && + payload.exitCode === 0 + ? "complete" + : "untyped"; +} + +async function waitFor(opts: { + readonly step: string; + readonly probe: () => Promise; +}): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (await opts.probe()) { + return; + } + await Bun.sleep(1000); + } + throw new Error(`Timed out waiting for ${opts.step}`); +} + +/** Wait until the bulk writer finished and the ticker has several lines. */ +async function waitForOutput(session: Session): Promise { + await waitFor({ + step: "bulk output and ticks", + probe: async () => { + const bulk = await session.call("hack.project.logs.snapshot", { + service: "bulk", + tail: 1, + }); + const ticker = await session.call("hack.project.logs.snapshot", { + service: "ticker", + tail: 5, + }); + return ( + logLines(bulk).some((line) => line.endsWith("BULK-DONE")) && + ticksOf(logLines(ticker)).length >= 5 + ); + }, + }); +} + +/** Complete, quiet-wait, live time cutoff and event cutoff outcomes. */ +async function checkBoundedOutcomes(session: Session): Promise { + // Complete: a quiet service reaches end of stream with exit 0. + const quietSnapshot = await session.call("hack.project.logs.snapshot", { + service: "quiet", + }); + requireOk({ step: "quiet snapshot", payload: quietSnapshot, cutoff: null }); + expect({ + that: + logLines(quietSnapshot).filter((line) => line.endsWith("QUIET ready ✓")) + .length === 1 && + events(quietSnapshot).some( + (event) => event.type === "end" && event.reason === "eof" + ), + message: `quiet snapshot must hold its one line and a clean end event: ${summarize(quietSnapshot)}`, + }); + + // Time cutoff on a quiet stream: the wait ends at maxMs, not in a hang. + const quietTail = await session.call("hack.project.logs.tail", { + service: "quiet", + tail: 1, + maxMs: 2000, + }); + requireOk({ + step: "quiet tail", + payload: quietTail, + cutoff: { bound: "time", limit: 2000 }, + }); + expect({ + that: + quietTail.data?.stop_reason === "timeout" && + logLines(quietTail).length === 1, + message: `quiet tail must stop on time with only the replayed line: ${summarize(quietTail)}`, + }); + + // Time cutoff on a live stream: new lines arrive in order until maxMs. + const timeTail = await session.call("hack.project.logs.tail", { + service: "ticker", + tail: 1, + maxEvents: 10_000, + maxMs: 3000, + }); + requireOk({ + step: "ticker time tail", + payload: timeTail, + cutoff: { bound: "time", limit: 3000 }, + }); + const timeTicks = ticksOf(logLines(timeTail)); + const duration = Number(timeTail.data?.duration_ms); + expect({ + that: + timeTail.data?.stop_reason === "timeout" && + timeTicks.length >= 5 && + strictlyOrdered(timeTicks) && + duration >= 3000 && + duration < 30_000, + message: `ticker tail must follow live lines in order until maxMs: ${summarize(timeTail)}`, + }); + + // Event cutoff: exactly maxEvents log events; other events do not count. + // Three replayed ticks (the boot line is older) plus two live ones. + const eventTail = await session.call("hack.project.logs.tail", { + service: "ticker", + tail: 3, + maxEvents: 5, + maxMs: 30_000, + }); + requireOk({ + step: "ticker event tail", + payload: eventTail, + cutoff: { bound: "events", limit: 5 }, + }); + const eventTicks = ticksOf(logLines(eventTail)); + expect({ + that: + eventTail.data?.stop_reason === "max_events" && + logLines(eventTail).length === 5 && + eventTicks.length === 5 && + strictlyOrdered(eventTicks) && + Number(eventTail.data?.duration_ms) < 30_000, + message: `ticker tail must stop at exactly 5 log events, all ticks (${eventTicks.length} ticks): ${summarize(eventTail)}`, + }); +} + +/** + * Byte cutoff: more than 8 MiB of four-byte emoji. The partial result keeps + * its parsed events, in order and whole, and the cut never splits a code + * point (the per-call check rejects any U+FFFD in the payload). + */ +async function checkByteCutoff(opts: { + readonly session: Session; + readonly log: (message: string) => void; +}): Promise { + const bytes = await opts.session.call("hack.project.logs.snapshot", { + service: "bulk", + tail: 10_000, + }); + requireOk({ + step: "bulk snapshot", + payload: bytes, + cutoff: { bound: "bytes", limit: OUTPUT_LIMIT_BYTES }, + }); + const bulkLines = logLines(bytes); + const bulkBodies = bulkLines.flatMap((line) => { + const match = BULK_LINE.exec(line); + return match ? [{ n: Number(match[1]), body: match[2] ?? "" }] : []; + }); + expect({ + that: + bytes.outputTruncated && + bulkBodies.length > 0 && + bulkBodies.length < BULK_LINES && + !bulkLines.some((line) => line.endsWith("BULK-DONE")) && + bulkBodies.every( + (entry, index) => + entry.n === index + 1 && + [...entry.body].length === BULK_EMOJI_PER_LINE + ), + message: `bulk snapshot must keep a complete, ordered prefix of whole lines: ${summarize(bytes)}`, + }); + expect({ + that: !bytes.stdout.includes(REPLACEMENT_CHAR), + message: "bulk snapshot stdout must not split a code point", + }); + opts.log( + `bulk byte cut: ${bulkBodies.length} whole lines, ${Buffer.byteLength(bytes.stdout)} stdout bytes kept (limit ${OUTPUT_LIMIT_BYTES})` + ); +} + +/** + * Backend failure: with the engine unreachable, both tools return the closed + * backend_error, never a truncated success or a time cutoff. + */ +async function checkBackendError(stack: Stack): Promise { + const broken = await stack.openSession({ + label: "unreachable-engine", + env: { DOCKER_HOST: `unix://${join(stack.tempRoot, "absent.sock")}` }, + }); + const snapshot = await broken.call("hack.project.logs.snapshot", { + service: "ticker", + }); + const tail = await broken.call("hack.project.logs.tail", { + service: "ticker", + maxEvents: 5, + maxMs: 30_000, + }); + for (const failed of [snapshot, tail]) { + expect({ + that: + !failed.ok && + failed.isError && + failed.exitCode !== 0 && + isClosedBackendError(failed.error) && + cutoffOf(failed) === null && + logLines(failed).every((line) => !TICK.test(line)) && + !JSON.stringify(failed.error).includes(stack.tempRoot), + message: `logs must return the closed backend_error when the engine is unreachable: ${summarize(failed)}`, + }); + } + expect({ + that: + tail.data?.stop_reason === "eof" && + Number(tail.data?.duration_ms) < 30_000, + message: `a failed tail must end at end of stream, not at maxMs: ${summarize(tail)}`, + }); + await broken.close(); +} + +/** + * Restart the backend container under an in-flight tail, then require both + * boots exactly once in the stored history and a new tail on the new boot. + * Returns the last tick sequence number the session saw. + */ +async function checkRestart(opts: { + readonly stack: Stack; + readonly session: Session; +}): Promise { + const { stack, session } = opts; + const tickerId = await stack.serviceContainer("ticker"); + const startedAt = async () => + await stack.docker([ + "inspect", + tickerId, + "--format", + "{{.State.StartedAt}}", + ]); + const startedBefore = await startedAt(); + const maxMs = 10_000; + const inFlight = session.call("hack.project.logs.tail", { + service: "ticker", + tail: 1, + maxEvents: 10_000, + maxMs, + }); + await Bun.sleep(2500); + await stack.owned("container", tickerId); + await stack.docker(["restart", "--time", "2", tickerId]); + const spanning = await inFlight; + const spanningTicks = ticksOf(logLines(spanning)); + const outcome = tailOutcome({ payload: spanning, maxMs }); + // Compose may re-attach to the restarted container from a coarse + // timestamp, so lines repeated across the restart are recorded, not + // asserted; the pre-restart run itself must be ordered. + stack.log( + `tail across restart: ${outcome}; boots seen ${[...new Set(spanningTicks.map((tick) => tick.boot))].join(",")}; ordered across restart: ${strictlyOrdered(spanningTicks)}` + ); + expect({ + that: + outcome !== "untyped" && + spanning.isError === !spanning.ok && + Number(spanning.data?.duration_ms) < 40_000 && + firstBootRun(spanningTicks).length >= 2, + message: `a tail across a restart must end in one typed outcome after ordered pre-restart lines: ${summarize(spanning)}`, + }); + expect({ + that: + (await stack.serviceContainer("ticker")) === tickerId && + (await startedAt()) !== startedBefore, + message: + "docker restart must restart the same ticker container (positive control)", + }); + + // The stored history holds both boots exactly once, in order. + const histories: ToolPayload[] = []; + await waitFor({ + step: "post-restart ticks", + probe: async () => { + const history = await session.call("hack.project.logs.snapshot", { + service: "ticker", + tail: 10_000, + }); + histories.push(history); + return ( + ticksOf(logLines(history)).filter((tick) => tick.boot === 2).length >= 3 + ); + }, + }); + const history = histories.at(-1); + if (!history) { + throw new Error("No post-restart snapshot"); + } + requireOk({ step: "post-restart snapshot", payload: history, cutoff: null }); + const historyLines = logLines(history); + const boots = historyLines.flatMap((line) => { + const match = BOOT.exec(line); + return match ? [Number(match[1])] : []; + }); + const historyTicks = ticksOf(historyLines); + expect({ + that: + JSON.stringify(boots) === "[1,2]" && + strictlyOrdered(historyTicks) && + historyTicks.some((tick) => tick.boot === 1) && + historyTicks.some((tick) => tick.boot === 2), + message: `snapshot after restart must hold BOOT 1 then BOOT 2 and ordered ticks: ${summarize(history)}`, + }); + + // A new tail starts from the current stream: its one replayed line and + // every later line belong to the new boot. + const fresh = await session.call("hack.project.logs.tail", { + service: "ticker", + tail: 1, + maxEvents: 3, + maxMs: 30_000, + }); + requireOk({ + step: "post-restart tail", + payload: fresh, + cutoff: { bound: "events", limit: 3 }, + }); + const freshTicks = ticksOf(logLines(fresh)); + expect({ + that: + freshTicks.length === 3 && + freshTicks.every((tick) => tick.boot === 2) && + strictlyOrdered(freshTicks), + message: `a tail after restart must stream only the new boot: ${summarize(fresh)}`, + }); + return freshTicks.at(-1)?.seq ?? 0; +} + +/** A new MCP session starts a new stream and never replays older lines. */ +async function checkReconnect(opts: { + readonly session: Session; + readonly lastSeen: number; +}): Promise { + const after = await opts.session.call("hack.project.logs.tail", { + service: "ticker", + tail: 1, + maxEvents: 2, + maxMs: 30_000, + }); + requireOk({ + step: "reconnected tail", + payload: after, + cutoff: { bound: "events", limit: 2 }, + }); + const afterTicks = ticksOf(logLines(after)); + expect({ + that: + afterTicks.length === 2 && + afterTicks.every( + (tick) => tick.boot === 2 && tick.seq >= opts.lastSeen + ) && + strictlyOrdered(afterTicks), + message: `a reconnected tail must not replay earlier lines (last seen seq ${opts.lastSeen}): ${summarize(after)}`, + }); +} + +/** + * HACK-1155 (P11): the child's exit status survives the CLI and the MCP + * envelope. `false` fails with 1; `exit 7` keeps 7 and its output. + */ +async function checkRunExitStatus(opts: { + readonly stack: Stack; + readonly session: Session; +}): Promise { + const { stack, session } = opts; + await stack.cli({ args: ["run", "quiet", "--", "false"], codes: [1] }); + const seven = await stack.cli({ + args: ["run", "quiet", "--", "sh", "-c", "printf p11-partial; exit 7"], + codes: [7], + }); + expect({ + that: seven.stdout.includes("p11-partial"), + message: "hack run must pass the failed child's output through", + result: seven, + }); + const envelope = await session.call("hack.project.run", { + service: "quiet", + cmd: ["sh", "-c", "printf p11-partial; exit 7"], + }); + expect({ + that: + !envelope.ok && + envelope.isError && + envelope.exitCode === 7 && + envelope.stdout.includes("p11-partial"), + message: `hack.project.run must report the child's exit 7 as a failure: ${summarize(envelope)}`, + }); + const passed = await session.call("hack.project.run", { + service: "quiet", + cmd: ["true"], + }); + expect({ + that: passed.ok && !passed.isError && passed.exitCode === 0, + message: `hack.project.run must report a successful child as ok (positive control): ${summarize(passed)}`, + }); + expect({ + that: (await stack.list("container", [`${ONEOFF}=True`])).length === 0, + message: "hack run must not leave a one-off container behind", + }); +} + +/** Require a Linux engine with Compose and the cached image; return its ID. */ +async function requireCachedImage(docker: Stack["docker"]): Promise { + expect({ + that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", + message: "A Linux Docker daemon is required", + }); + await docker(["compose", "version"]); + const image = await docker([ + "image", + "inspect", + IMAGE, + "--format", + "{{.Id}}", + ]); + expect({ + that: IMAGE_ID.test(image), + message: `${IMAGE} must already be cached with an immutable ID`, + }); + return image; +} + +/** Write the fixture and build the owned-effect helpers for one run. */ +async function prepareStack(opts: { + readonly ctx: ScenarioContext; + readonly sessions: Session[]; +}): Promise<{ readonly stack: Stack; readonly clean: () => Promise }> { + const { ctx } = opts; + const docker = async (args: readonly string[]) => { + const result = await runCommand({ + argv: ["docker", ...args], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + expectExit({ + result, + codes: [0], + message: `Docker ${args[0]} must succeed`, + }); + return result.stdout.trim(); + }; + const image = await requireCachedImage(docker); + const fixture = await createMonorepoFixture({ + parentDir: ctx.tempRoot, + withHackConfig: true, + lifecycle: { disableInternal: true }, + }); + const name = fixture.name; + await Bun.write( + join(fixture.hackDir, "hack.config.json"), + JSON.stringify({ + name, + internal: { dns: false, tls: false }, + logs: { + follow_backend: "compose", + snapshot_backend: "compose", + clear_on_down: false, + }, + }) + ); + await Bun.write( + join(fixture.hackDir, "docker-compose.yml"), + composeDocument({ name, image }) + ); + + // The MCP server runs `HACK_MCP_COMMAND` for every tool, so point it at the + // CLI under test (the compiled binary in CI, else the source CLI). + const mcpCommand = join(ctx.tempRoot, "hack-under-test"); + await Bun.write( + mcpCommand, + `#!/bin/sh\nexec ${resolveCliSpawnArgs([]).map(shellQuote).join(" ")} "$@"\n` + ); + await chmod(mcpCommand, 0o755); + + const list = async (kind: ResourceKind, filters: readonly string[]) => { + const args = + kind === "container" ? ["ps", "-aq", "--no-trunc"] : [kind, "ls", "-q"]; + return ( + await docker([ + ...args, + "--filter", + `label=${OWNER}=${name}`, + ...filters.flatMap((filter) => ["--filter", `label=${filter}`]), + ]) + ) + .split(/\s+/) + .filter(Boolean); + }; + const owned = async (kind: ResourceKind, id: string) => { + const expression = + kind === "container" ? "{{json .Config.Labels}}" : "{{json .Labels}}"; + const actual = object( + JSON.parse(await docker([kind, "inspect", id, "--format", expression])) + ); + expect({ + that: actual[OWNER] === name && actual[PROJECT] === name, + message: `Refuse ${kind} mutation outside exact fixture ownership`, + }); + }; + const clean = async () => { + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await list(kind, [])) { + await owned(kind, id); + await docker([ + kind, + "rm", + ...(kind === "container" ? ["--force"] : []), + id, + ]); + } + expect({ + that: (await list(kind, [])).length === 0, + message: `Owned ${kind} cleanup must leave no resources`, + }); + } + }; + const stack: Stack = { + tempRoot: ctx.tempRoot, + log: ctx.log, + docker, + list, + owned, + cli: async ({ args, codes }) => { + const result = await ctx.cli({ + args, + cwd: fixture.root, + timeoutMs: TIMEOUT, + env: RUNTIME_ENV, + }); + expectExit({ result, codes, message: `hack ${args.join(" ")}` }); + return result; + }, + serviceContainer: async (service) => { + const ids = await list("container", [ + `${SERVICE}=${service}`, + `${ONEOFF}=False`, + ]); + const id = ids[0]; + if (ids.length !== 1 || !(id && CONTAINER_ID.test(id))) { + throw new Error(`Expected exactly one owned ${service} container`); + } + await owned("container", id); + return id; + }, + openSession: async (session) => { + const opened = await openMcpSession({ + ctx, + label: session.label, + cwd: fixture.root, + env: { ...RUNTIME_ENV, HACK_MCP_COMMAND: mcpCommand, ...session.env }, + }); + opts.sessions.push(opened); + return opened; + }, + }; + return { stack, clean }; +} + +/** + * Start `hack mcp serve` over stdio with the harness's isolated CLI + * environment. Every call targets the fixture by path and rejects a payload + * that contains U+FFFD, so no tool result may split a code point. + */ +async function openMcpSession(opts: { + readonly ctx: ScenarioContext; + readonly label: string; + readonly cwd: string; + readonly env: Readonly>; +}): Promise { + const [command, ...args] = resolveCliSpawnArgs(["mcp", "serve"]); + if (!command) { + throw new Error("No CLI command to serve MCP"); + } + const transport = new StdioClientTransport({ + command, + args, + cwd: opts.cwd, + env: buildCliEnv({ hackHome: opts.ctx.hackHome, extra: opts.env }), + stderr: "pipe", + }); + let stderrTail = ""; + transport.stderr?.on("data", (chunk: Buffer | string) => { + stderrTail = `${stderrTail}${String(chunk)}`.slice(-2000); + }); + const client = new Client({ name: "hack-e2e-mcp-logs", version: "0.0.0" }); + await client.connect(transport); + return { + close: async () => { + await client.close(); + }, + call: async (tool, input) => { + let raw: unknown; + try { + raw = await client.callTool( + { name: tool, arguments: { ...input } }, + undefined, + { timeout: TIMEOUT } + ); + } catch (error) { + throw new Error( + `${opts.label} ${tool} failed: ${String(error)}\nserver stderr: ${stderrTail}` + ); + } + const payload = parsePayload({ tool, result: raw }); + opts.ctx.log( + `${opts.label} ${tool} ${JSON.stringify(input)}: ${summarize(payload)}` + ); + expect({ + that: !payload.text.includes(REPLACEMENT_CHAR), + message: `${tool} must return valid UTF-8 (no U+FFFD in the payload)`, + }); + return payload; + }, + }; +} + +/** + * F13 / HACK-1134: the MCP `hack.project.logs.snapshot` and + * `hack.project.logs.tail` contract against a real Compose project, driven + * through `hack mcp serve` over stdio with the current CLI as the log backend. + * + * Each collection must end in exactly one typed outcome: + * - complete: a quiet snapshot reaches end of stream (`cutoff: null`); + * - time cutoff: a tail of a ticking service and of a quiet one stops at + * `maxMs` with `data.cutoff = {bound: "time"}`; + * - event cutoff: exactly `maxEvents` `type: "log"` events, `{bound: "events"}`; + * - byte cutoff: a snapshot of more than 8 MiB of four-byte emoji stops at the + * capture budget with `{bound: "bytes"}`, keeps the parsed events and never + * splits a code point (no U+FFFD anywhere in the returned payload); + * - backend_error: with the Docker engine unreachable, both tools return the + * closed `backend_error`, never a truncated success or a time cutoff. + * + * Across a container restart, an in-flight tail must still end in one typed + * outcome inside its bound, the backend keeps both boots' lines exactly once + * and in order, and a new tail starts from the current stream. A new MCP + * session never replays events older than the previous session saw. + * + * HACK-1155 (P11) on the same stack: `hack run -- false` and an `exit 7` + * child return the child's status from the CLI and from the MCP + * `hack.project.run` envelope (`ok: false`, `exitCode: 7`). + */ +export const mcpLogsComposeScenario: Scenario = { + name: "mcp-logs-compose", + tier: "docker", + summary: + "MCP logs cutoffs, backend_error, restart and UTF-8 cut on real Compose (F13)", + run: async (ctx) => { + const sessions: Session[] = []; + const { stack, clean } = await prepareStack({ ctx, sessions }); + try { + await stack.cli({ args: ["up", "--detach"], codes: [0] }); + const session = await stack.openSession({ label: "A" }); + await waitForOutput(session); + await checkBoundedOutcomes(session); + await checkByteCutoff({ session, log: ctx.log }); + await checkBackendError(stack); + const lastSeen = await checkRestart({ stack, session }); + await session.close(); + const reconnected = await stack.openSession({ label: "B" }); + await checkReconnect({ session: reconnected, lastSeen }); + await checkRunExitStatus({ stack, session: reconnected }); + await reconnected.close(); + + await stack.cli({ args: ["down"], codes: [0] }); + expect({ + that: (await stack.list("container", [])).length === 0, + message: "hack down must remove all fixture containers", + }); + } finally { + for (const session of sessions) { + try { + await session.close(); + } catch { + // Already closed. + } + } + await clean(); + } + }, +}; diff --git a/tests/e2e/scenarios/portable-bootstrap-negatives.ts b/tests/e2e/scenarios/portable-bootstrap-negatives.ts new file mode 100644 index 000000000..35646de44 --- /dev/null +++ b/tests/e2e/scenarios/portable-bootstrap-negatives.ts @@ -0,0 +1,742 @@ +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { encryptSynthetic } from "../../../scripts/portable-bootstrap-smoke.ts"; +import { commitAll, createMonorepoFixture } from "../fixture.ts"; +import { + type CliResult, + expect, + expectExit, + runCommand, + type Scenario, +} from "../harness.ts"; +import { + type AppIdentity, + checkStageExpectation, + classifyAppTransition, + containsForbiddenValue, + PORTABLE_BOOTSTRAP_OWNER as OWNER, + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + type PortableBootstrapStage, + renderBootstrapCompose, + renderBootstrapEnvFile, + renderBootstrapLock, + renderPortableBootstrapReceipt, + type StageReceipt, + sha256Hex, + unexpectedWorktreeChanges, +} from "../portable-bootstrap-controls.ts"; + +const PROJECT = "com.docker.compose.project"; +const TIMEOUT = 180_000; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const META = "/deps/.hack-dependency-cache-v1"; +const OVERLAY = "drift"; +const ARCHITECTURES: Readonly> = { + aarch64: "arm64", + arm64: "arm64", + amd64: "amd64", + x86_64: "amd64", +}; +type ResourceKind = "container" | "network" | "volume"; + +function words(text: string): string[] { + return text.split(/\s+/).filter(Boolean); +} + +/** + * Headless bootstrap negative controls on the portable slim path (HACK-1143). + * + * One app consumes a locked-v1 dependency cache initialized by a frozen-install + * producer, with an encrypted synthetic env value scoped to the app. Missing, + * wrong and wrong-overlay keys refuse before any Docker resource exists. Two + * identical bootstraps produce one initialization, one consumer and no + * worktree diff. A wrong key, a stale pinned image, a lockfile that no longer + * matches its manifest and a corrupted ready marker each refuse without + * touching the running consumer, pulling, rewriting the lockfile or + * reinstalling. A changed lockfile and a new cache generation select new + * caches and keep the old ones. Each stage records value-free time and bytes. + * + * No published ports, networks, pulls or real credentials. WU12 storage + * admission, mounts and retention are outside this fixture. + */ +export const portableBootstrapNegativesScenario: Scenario = { + name: "portable-bootstrap-negatives", + tier: "docker", + summary: + "bootstrap refusals: key/overlay, stale image, lockfile, corrupted cache; no-diff repeat", + run: async (ctx) => { + const docker = async (args: readonly string[]) => { + const result = await runCommand({ + argv: ["docker", ...args], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + expectExit({ + result, + codes: [0], + message: `Docker ${args[0]} must succeed`, + }); + return result.stdout.trim(); + }; + expect({ + that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", + message: "A Linux Docker daemon is required", + }); + await docker(["compose", "version"]); + const arch = + ARCHITECTURES[await docker(["info", "--format", "{{.Architecture}}"])]; + expect({ + that: arch !== undefined, + message: "A supported Docker architecture is required", + }); + const image = await docker([ + "image", + "inspect", + "alpine:3.20", + "--format", + "{{.Id}}", + ]); + expect({ + that: IMAGE_ID.test(image), + message: "alpine:3.20 must already be cached with an immutable ID", + }); + const imageBytes = Number( + await docker(["image", "inspect", image, "--format", "{{.Size}}"]) + ); + // A pinned ID that is not present: what a toolchain bump leaves behind. + const staleImage = `sha256:${sha256Hex(`hack-e2e-stale-${randomBytes(16).toString("hex")}`)}`; + const imagePresent = async (ref: string) => + ( + await runCommand({ + argv: ["docker", "image", "inspect", ref, "--format", "{{.Id}}"], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }) + ).exitCode === 0; + expect({ + that: !(await imagePresent(staleImage)), + message: "The stale pinned image must be absent before the control", + }); + + const fixture = await createMonorepoFixture({ + parentDir: ctx.tempRoot, + withHackConfig: true, + lifecycle: { disableInternal: true }, + }); + const name = fixture.name; + const canary = `${name}-canary`; + const cachePrefix = `hack-cache-${name}-deps-`; + // Synthetic values only; never logged, written in plaintext or passed as arguments. + const key = randomBytes(32).toString("base64url"); + const wrongKey = randomBytes(32).toString("base64url"); + const driftKey = randomBytes(32).toString("base64url"); + const secret = randomBytes(24).toString("base64url"); + const driftSecret = randomBytes(24).toString("base64url"); + const forbidden = [key, wrongKey, driftKey, secret, driftSecret]; + const compose = (opts: { + readonly ref: string; + readonly generation: string; + }) => + renderBootstrapCompose({ + name, + ref: opts.ref, + platform: `linux/${arch}`, + generation: opts.generation, + tokenDigest: sha256Hex(secret), + }); + const composePath = join(fixture.hackDir, "docker-compose.yml"); + const lockPath = join(fixture.root, "bun.lock"); + const manifestPath = join(fixture.root, "package.json"); + const manifest = await Bun.file(manifestPath).text(); + const lock = (revision: number) => + renderBootstrapLock({ manifest, revision }); + await Bun.write( + join(fixture.hackDir, "hack.config.json"), + `${JSON.stringify( + { + name, + internal: { dns: false, tls: false }, + logs: { + follow_backend: "compose", + snapshot_backend: "compose", + clear_on_down: false, + }, + }, + null, + 2 + )}\n` + ); + await Bun.write(composePath, compose({ ref: image, generation: "0" })); + await Bun.write( + join(fixture.hackDir, "hack.env.default.yaml"), + renderBootstrapEnvFile({ + environment: "default", + sealed: encryptSynthetic({ key, secret }), + }) + ); + // The overlay is valid YAML but sealed under a key the runner never has. + await Bun.write( + join(fixture.hackDir, `hack.env.${OVERLAY}.yaml`), + renderBootstrapEnvFile({ + environment: OVERLAY, + sealed: encryptSynthetic({ key: driftKey, secret: driftSecret }), + }) + ); + await Bun.write( + join(fixture.root, "deps/install.sh"), + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT + ); + await Bun.write( + join(fixture.root, "deps/verify.sh"), + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT + ); + await Bun.write(lockPath, lock(1)); + await commitAll({ root: fixture.root, message: "bootstrap negatives" }); + + const git = async (args: readonly string[]) => { + const result = await runCommand({ + argv: ["git", "-C", fixture.root, ...args], + cwd: fixture.root, + }); + expectExit({ result, codes: [0], message: `git ${args[0]}` }); + return result.stdout; + }; + const worktree = async (allowed: readonly string[], message: string) => { + const unexpected = unexpectedWorktreeChanges({ + porcelain: await git([ + "status", + "--porcelain=v1", + "--untracked-files=all", + ]), + allowed, + }); + expect({ + that: unexpected.length === 0, + message: `${message}; unexpected paths: ${unexpected.join(", ")}`, + }); + }; + const trackedFiles = async () => + (await git(["ls-files", "-z"])).split("\0").filter(Boolean); + const trackedBytes = async () => { + let total = 0; + for (const path of await trackedFiles()) { + total += Bun.file(join(fixture.root, path)).size; + } + return total; + }; + + const list = async (kind: ResourceKind, owner: string) => { + const args = kind === "container" ? ["ps", "-aq"] : [kind, "ls", "-q"]; + return words( + await docker([...args, "--filter", `label=${OWNER}=${owner}`]) + ); + }; + const owned = async (kind: ResourceKind, id: string, owner: string) => { + const expression = + kind === "container" ? "{{json .Config.Labels}}" : "{{json .Labels}}"; + const labels: unknown = JSON.parse( + await docker([kind, "inspect", id, "--format", expression]) + ); + expect({ + that: + typeof labels === "object" && + labels !== null && + (labels as Record)[OWNER] === owner && + (labels as Record)[PROJECT] === owner, + message: `Refuse ${kind} access outside exact fixture ownership`, + }); + }; + const clean = async (owner: string) => { + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await list(kind, owner)) { + await owned(kind, id, owner); + await docker([ + kind, + "rm", + ...(kind === "container" ? ["--force"] : []), + id, + ]); + } + expect({ + that: (await list(kind, owner)).length === 0, + message: `Owned ${kind} cleanup must leave no resources`, + }); + } + }; + const cacheVolumes = async () => { + const names = (await list("volume", name)).filter((volume) => + volume.startsWith(cachePrefix) + ); + for (const volume of names) { + await owned("volume", volume, name); + } + return names.sort(); + }; + // Inspect only fixed fields: the consumer's Config.Env holds the secret. + const appIdentity = async (): Promise => { + const ids = words( + await docker([ + "ps", + "-aq", + "--filter", + `label=${OWNER}=${name}`, + "--filter", + "label=com.docker.compose.service=app", + ]) + ); + const id = ids[0]; + if (id === undefined) { + return null; + } + expect({ + that: ids.length === 1, + message: "Expected at most one owned consumer container", + }); + await owned("container", id, name); + const [fullId, running, startedAt] = words( + await docker([ + "inspect", + id, + "--format", + "{{.Id}} {{.State.Running}} {{.State.StartedAt}}", + ]) + ); + if (!(fullId && running && startedAt)) { + throw new Error("Consumer identity is incomplete"); + } + return { id: fullId, running: running === "true", startedAt }; + }; + const appExec = async (id: string, script: string) => + await runCommand({ + argv: ["docker", "exec", id, "sh", "-c", script], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + // The consumer copies the artifact only after its env digest matched. + const loaded = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const result = await appExec(identity.id, "cat /tmp/loaded"); + if (result.exitCode === 0) { + return result.stdout.trim(); + } + const state = await docker([ + "inspect", + identity.id, + "--format", + "{{.State.Running}}", + ]); + expect({ + that: state === "true", + message: + "Consumer exited before loading the cache (env digest or artifact missing)", + }); + await Bun.sleep(250); + } + throw new Error("Consumer did not load its dependency cache in time"); + }; + const mountedCache = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const volume = await docker([ + "inspect", + identity.id, + "--format", + '{{range .Mounts}}{{if eq .Destination "/deps"}}{{.Name}} {{.RW}}{{end}}{{end}}', + ]); + const [mounted, rw] = words(volume); + expect({ + that: Boolean(mounted?.startsWith(cachePrefix)) && rw === "false", + message: "Consumer must mount a fingerprinted cache read-only", + }); + return mounted as string; + }; + const cacheFacts = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const result = await appExec( + identity.id, + "wc -l < /deps/initializations; find /deps -type f -exec cat {} + | wc -c" + ); + expectExit({ result, codes: [0], message: "Read mounted cache facts" }); + const [initializations, bytes] = words(result.stdout).map(Number); + if (initializations === undefined || bytes === undefined) { + throw new Error("Cache facts are incomplete"); + } + return { initializations, bytes }; + }; + // Read-only, network-less probe of one exact owned volume. + const probeVolume = async (volume: string, script: string) => { + await owned("volume", volume, name); + return ( + await runCommand({ + argv: [ + "docker", + "run", + "--rm", + "--pull=never", + "--network=none", + "--label", + `${OWNER}=${name}`, + "--label", + `${PROJECT}=${name}`, + "--volume", + `${volume}:/deps:ro`, + image, + "sh", + "-c", + script, + ], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }) + ).exitCode; + }; + const readyMarker = (volume: string) => + `printf 'locked-v1\\n%s\\n' '${volume.slice(cachePrefix.length)}' | cmp -s - ${META}/ready`; + + const cli = async (opts: { + readonly args: readonly string[]; + readonly key: string | null; + }): Promise => { + const result = await ctx.cli({ + args: opts.args, + cwd: fixture.root, + timeoutMs: TIMEOUT, + env: { + HACK_EXECUTION_MODE: "slim", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + ...(opts.key === null ? {} : { HACK_ENV_SECRET_KEY: opts.key }), + }, + }); + // Checked before any assertion can attach this output to a failure. + expect({ + that: !containsForbiddenValue({ + texts: [result.stdout, result.stderr], + forbidden, + }), + message: `hack ${opts.args[0]} output must not contain keys or env values`, + }); + return result; + }; + const receipts: StageReceipt[] = []; + let consumer: AppIdentity = null; + let expectedArtifact = ""; + const stage = async (opts: { + readonly stage: PortableBootstrapStage; + readonly key: string | null; + readonly overlay?: string; + }) => { + const before = await appIdentity(); + const result = await cli({ + args: [ + "up", + "--detach", + ...(opts.overlay ? ["--env", opts.overlay] : []), + "app", + ], + key: opts.key, + }); + const after = await appIdentity(); + const transition = classifyAppTransition({ before, after }); + const failure = checkStageExpectation({ + stage: opts.stage, + exitCode: result.exitCode, + timedOut: result.timedOut, + transition, + running: after?.running === true, + }); + expect({ + that: failure === null, + message: `${opts.stage}: ${failure}`, + result, + }); + if (after !== null) { + // A new consumer must load its cache and stay the same container. + await loaded(after); + expect({ + that: + classifyAppTransition({ + before: after, + after: await appIdentity(), + }) === "unchanged", + message: `${opts.stage}: consumer must stay running without a restart`, + }); + } + const facts = after === null ? null : await cacheFacts(after); + receipts.push({ + stage: opts.stage, + outcome: result.exitCode === 0 ? "bootstrapped" : "refused", + app: transition, + durationMs: result.durationMs, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + trackedBytes: await trackedBytes(), + cacheBytes: facts?.bytes ?? null, + cacheVolumes: (await cacheVolumes()).length, + }); + ctx.log( + `${opts.stage}: ${result.exitCode === 0 ? "bootstrapped" : "refused"}, consumer ${transition}, ${result.durationMs} ms` + ); + consumer = after; + return { result, facts }; + }; + // Refusals and repeats must leave the consumer reading the same artifact. + const consumerKeeps = async (message: string) => { + expect({ + that: (await loaded(consumer)) === expectedArtifact, + message, + }); + }; + const nothingCreated = async (message: string) => { + for (const kind of ["container", "network", "volume"] as const) { + expect({ + that: (await list(kind, name)).length === 0, + message: `${message}: no owned ${kind} may exist`, + }); + } + }; + + try { + await docker([ + "volume", + "create", + "--label", + `${OWNER}=${canary}`, + "--label", + `${PROJECT}=${canary}`, + canary, + ]); + + // 1-3: env refusals happen before any Docker resource exists. + await stage({ stage: "refuse-missing-key", key: null }); + await nothingCreated("Missing key refusal"); + // The first lifecycle command converges Hack's managed ignore block. + await worktree( + [".hack/.gitignore"], + "A refused bootstrap may only add the managed .hack ignore block" + ); + if ((await git(["status", "--porcelain=v1"])).trim().length > 0) { + await commitAll({ + root: fixture.root, + message: "managed ignore block", + }); + } + await stage({ stage: "refuse-wrong-key", key: wrongKey }); + await nothingCreated("Wrong key refusal"); + await worktree([], "Wrong key refusal must leave no diff"); + await stage({ stage: "refuse-wrong-overlay", key, overlay: OVERLAY }); + await nothingCreated("Wrong overlay refusal"); + await worktree([], "Wrong overlay refusal must leave no diff"); + + // 4-5: two identical bootstraps; one initialization and no diff. + const cold = await stage({ stage: "bootstrap-cold", key }); + expectedArtifact = sha256Hex(lock(1)); + await consumerKeeps("Cold bootstrap must load the locked artifact"); + expect({ + that: cold.facts?.initializations === 1, + message: "Cold bootstrap must initialize the cache exactly once", + }); + const firstCache = await mountedCache(consumer); + expect({ + that: + (await probeVolume(firstCache, readyMarker(firstCache))) === 0 && + (await cacheVolumes()).length === 1, + message: "Cold bootstrap must publish one ready cache", + }); + await worktree([], "Cold bootstrap must leave no diff"); + const trackedBefore = await git(["ls-files", "-s"]); + const repeat = await stage({ stage: "bootstrap-repeat", key }); + await consumerKeeps("Repeat bootstrap must keep the loaded artifact"); + expect({ + that: + repeat.facts?.initializations === 1 && + (await mountedCache(consumer)) === firstCache && + (await cacheVolumes()).length === 1, + message: + "Repeat bootstrap must reuse the ready cache without reinstalling", + }); + await worktree([], "Repeat bootstrap must leave no diff"); + expect({ + that: (await git(["ls-files", "-s"])) === trackedBefore, + message: "Repeat bootstrap must leave the tracked index unchanged", + }); + + // 6: a wrong key cannot disturb a running generation. + await stage({ stage: "refuse-wrong-key-running", key: wrongKey }); + await consumerKeeps("Wrong key refusal must keep the consumer"); + await worktree([], "Wrong key refusal must leave no diff"); + + // 7: a stale pinned image is refused, never pulled or retagged. + const composeBytes = await Bun.file(composePath).text(); + await Bun.write( + composePath, + compose({ ref: staleImage, generation: "0" }) + ); + await stage({ stage: "refuse-stale-image", key }); + await consumerKeeps("Stale image refusal must keep the consumer"); + expect({ + that: + !(await imagePresent(staleImage)) && + (await docker([ + "image", + "inspect", + "alpine:3.20", + "--format", + "{{.Id}}", + ])) === image, + message: "Stale image refusal must not pull, build or retag images", + }); + await worktree( + [".hack/docker-compose.yml"], + "Stale image refusal must change only the edited compose file" + ); + await Bun.write(composePath, composeBytes); + await worktree([], "Restored compose must match HEAD"); + + // 8: a lockfile that no longer matches its manifest is never rewritten + // and never publishes a ready cache. + const lockBytes = await Bun.file(lockPath).text(); + const quarantineBefore = await cacheVolumes(); + const unfrozen = manifest.replace( + /\n\}\n?$/, + ',\n "hackE2eFrozenProbe": true\n}\n' + ); + expect({ + that: unfrozen !== manifest, + message: "The frozen lockfile control must change the manifest", + }); + await Bun.write(manifestPath, unfrozen); + await stage({ stage: "refuse-frozen-lockfile", key }); + await consumerKeeps("Frozen lockfile refusal must keep the consumer"); + const quarantined = (await cacheVolumes()).filter( + (volume) => !quarantineBefore.includes(volume) + ); + expect({ + that: + (await Bun.file(lockPath).text()) === lockBytes && + quarantined.length === 1 && + (await probeVolume( + quarantined[0] as string, + `test -f ${META}/attempt && test ! -e ${META}/ready && test ! -e /deps/artifact` + )) === 0, + message: + "Frozen refusal must keep the lockfile and quarantine its cache without a ready marker", + }); + await worktree( + ["package.json"], + "Frozen lockfile refusal must change only the edited manifest" + ); + await Bun.write(manifestPath, manifest); + await worktree([], "Restored manifest must match HEAD"); + + // 9: a changed lockfile selects and initializes a new cache. + await Bun.write(lockPath, lock(2)); + await commitAll({ root: fixture.root, message: "lockfile revision 2" }); + const changed = await stage({ + stage: "bootstrap-changed-lockfile", + key, + }); + expectedArtifact = sha256Hex(lock(2)); + await consumerKeeps("Changed lockfile must load the new artifact"); + const secondCache = await mountedCache(consumer); + expect({ + that: + changed.facts?.initializations === 1 && + secondCache !== firstCache && + (await probeVolume(firstCache, readyMarker(firstCache))) === 0, + message: + "Changed lockfile must initialize a new cache once and retain the previous ready cache", + }); + await worktree([], "Changed lockfile bootstrap must leave no diff"); + + // 10: a corrupted ready marker fails closed without reinstalling. + const corrupt = `printf 'locked-v1\\n%s\\n' '${"0".repeat(16)}'`; + await owned("volume", secondCache, name); + await docker([ + "run", + "--rm", + "--pull=never", + "--network=none", + "--label", + `${OWNER}=${name}`, + "--label", + `${PROJECT}=${name}`, + "--volume", + `${secondCache}:/deps`, + image, + "sh", + "-c", + `${corrupt} > ${META}/ready`, + ]); + const corrupted = await stage({ stage: "refuse-corrupted-cache", key }); + await consumerKeeps("Corrupted cache refusal must keep the consumer"); + expect({ + that: + corrupted.facts?.initializations === 1 && + (await probeVolume( + secondCache, + `${corrupt} | cmp -s - ${META}/ready` + )) === 0, + message: + "Corrupted cache refusal must neither reinstall nor rewrite the marker", + }); + await worktree([], "Corrupted cache refusal must leave no diff"); + + // 11: the documented recovery is a new generation, not in-place repair. + await Bun.write(composePath, compose({ ref: image, generation: "1" })); + await commitAll({ root: fixture.root, message: "cache generation 1" }); + const recovered = await stage({ + stage: "bootstrap-new-generation", + key, + }); + await consumerKeeps("New generation must load the current artifact"); + const thirdCache = await mountedCache(consumer); + expect({ + that: + recovered.facts?.initializations === 1 && + thirdCache !== secondCache && + (await cacheVolumes()).includes(secondCache), + message: + "New generation must initialize once and retain the corrupted cache for inspection", + }); + await worktree([], "New generation bootstrap must leave no diff"); + + const tracked: string[] = []; + for (const path of await trackedFiles()) { + tracked.push(await Bun.file(join(fixture.root, path)).text()); + } + expect({ + that: !containsForbiddenValue({ texts: tracked, forbidden }), + message: "Tracked fixture files must not contain keys or env values", + }); + + expectExit({ + result: await cli({ args: ["down"], key }), + codes: [0], + message: "hack down must succeed", + }); + expect({ + that: (await list("container", name)).length === 0, + message: "hack down must remove the fixture containers", + }); + await clean(name); + await owned("volume", canary, canary); + ctx.log( + `portable-bootstrap receipt ${renderPortableBootstrapReceipt({ stages: receipts, imageBytes })}` + ); + } finally { + try { + await clean(name); + } finally { + await clean(canary); + } + } + }, +}; diff --git a/tests/e2e/shard.ts b/tests/e2e/shard.ts index 966d72f61..5d59d888a 100644 --- a/tests/e2e/shard.ts +++ b/tests/e2e/shard.ts @@ -52,8 +52,13 @@ export const E2E_DURATION_HINTS_SECONDS: Readonly> = { "native-compose-adoption-plural-network-worktrees": 33, "native-compose-adoption-branch-worktrees": 33, "portable-multiservice": 30, + // Estimate until a hosted docker-e2e summary measures it: eleven cached-alpine stages. + "portable-bootstrap-negatives": 45, // Estimate (no measured run yet): two cached-alpine ups plus four run/exec probes. "compose-config-hash-drift": 25, + // Estimate (no measured run yet): one up, ~15 s of bounded tails, a 12 MiB + // log snapshot, one container restart and two one-off runs. + "mcp-logs-compose": 60, "dependency-cache-package": 30, "native-compose-adoption-worktrees": 28, "native-compose-adoption-string-worktrees": 28, diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 29314f5b1..aeeb898b6 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -813,6 +813,11 @@ test("tools/list publishes names, annotations and required inputs for every tool annotations: { title: "List project instances", ...READ_ONLY }, required: [], }, + { + name: "hack.project.target", + annotations: { title: "Inspect project target", ...READ_ONLY }, + required: [], + }, ]); const run = listed.tools.find((tool) => tool.name === "hack.project.run"); expect(run?.inputSchema.properties?.timeoutMs).toMatchObject({ diff --git a/tests/native-authored-command.test.ts b/tests/native-authored-command.test.ts index cbb484264..b491bef7c 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -226,6 +226,85 @@ macTest( } ); +macTest( + "HACK-1272: down --recover retires a pending unreserved start without the generation owner", + async () => { + const selected = await fixture(); + const options = { + ...selected.options, + operation: "down" as const, + recover: true, + instance: "alpha", + }; + const scope = { + projectRoot: selected.root, + projectDir: join(selected.root, ".hack"), + nativeHome: join(selected.root, "candidate"), + branch: "alpha", + }; + const retired: unknown[] = []; + const outcome = (retire: () => Promise<{ readonly run: string }>) => + tryNativeAuthoredCommand({ + ...selected, + options, + serve: () => { + throw new Error("Recovery cannot invoke startup."); + }, + recover: () => { + throw new Error("A pending start has no stored generation."); + }, + unreservedSelected: (value) => { + expect(value).toEqual(scope); + return Promise.resolve(true); + }, + retireUnreserved: (value) => { + retired.push(value); + return retire(); + }, + }).catch((error: unknown) => error); + expect(await outcome(() => Promise.resolve({ run: "a".repeat(32) }))).toBe( + 0 + ); + expect(retired).toHaveLength(1); + expect(retired[0]).toMatchObject({ scope, timeoutMs: 1500 }); + const refused = await outcome(() => + Promise.reject(new Error("Private retained detail.")) + ); + expect(refused).toHaveProperty("code", "E_LIFECYCLE_FAILED"); + expect(String(refused)).not.toContain("Private retained detail"); + expect(await readdir(join(selected.root, ".hack"))).toEqual([]); + } +); + +macTest( + "HACK-1272: a canceled unreserved startup exits 130 like confirmed removal", + async () => { + const selected = await fixture(); + for (const [outcome, expected] of [ + ["unreserved", 130], + ["retained", "E_STARTUP_INCOMPLETE"], + ] as const) { + const result = await tryNativeAuthoredCommand({ + ...selected, + serve: () => + Promise.reject( + new NativeAuthoredProjectStartError({ + outcome, + canceled: true, + stage: "runtime", + nativeCode: "native_graph_canceled", + }) + ), + }).catch((error: unknown) => error); + if (typeof expected === "number") { + expect(result).toBe(expected); + } else { + expect(result).toHaveProperty("code", expected); + } + } + } +); + macTest( "recovery refuses env/profile/effect overrides before runtime pin or stored owner selection", async () => { diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 437adc5ec..3fcfd9d0f 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -6,12 +6,14 @@ import { mkdir, mkdtemp, readdir, + readFile, realpath, rm, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { nativeAuthoredProjectPs } from "../src/backends/native-authored-project-observe.ts"; import { loadNativeAuthoredProjectRun, stopNativeAuthoredProject, @@ -26,6 +28,14 @@ import { NATIVE_STORAGE_TOOL_MANIFEST, nativeStorageToolManifest, } from "../src/backends/native-authored-storage-tool.ts"; +import { + nativeAuthoredUnreservedStartSelected, + retireNativeAuthoredUnreservedStart, +} from "../src/backends/native-authored-unreserved-start.ts"; +import { + type invokeNativeRuntime, + NativeRuntimeRequestError, +} from "../src/backends/native-runtime-client.ts"; import { type ComposeEnvFingerprintRecord, evaluateComposeServiceEnvFreshness, @@ -198,6 +208,10 @@ type FixtureOptions = { readonly semanticAfterHook?: boolean; readonly planFailure?: boolean; readonly failBeforeReceipt?: boolean; + /** Native startup code for failBeforeReceipt; defaults to graph_network_intent. */ + readonly failBeforeReceiptCode?: string; + /** Inspection after failBeforeReceipt proves the journal was never reserved. */ + readonly unreservedInspection?: boolean; readonly foreignPlan?: boolean; readonly foreignNamespace?: boolean; readonly branch?: string | null; @@ -444,7 +458,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ const digests=Object.fromEntries(Object.entries(services).map(([name,values])=>[name,Object.fromEntries(Object.entries(values).map(([key,value])=>[key,createHash('sha256').update(value).digest('hex')]))])); await writeFile(${JSON.stringify(delivery)},JSON.stringify({version:input.version,kind:input.kind,review:input.review,run:input.run,lifetime_seconds:input.lifetime_seconds,keys,digests,expectedDigest:${JSON.stringify(digest)}})); } - if(${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:'graph_network_intent',message:${JSON.stringify(CANARY)}}));process.exit(2)} + if(${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:${JSON.stringify(options.failBeforeReceiptCode ?? "graph_network_intent")},message:${JSON.stringify(CANARY)}}));process.exit(2)} const receipt={version:2,kind:'native-graph-runtime',owner:'f'.repeat(32),boot:'00000000-0000-0000-0000-000000000001',review,phase:'ready-observed',readiness:{web:'started'},resources:{'network:default':{kind:'network',key:'default',name:'hkn-'+input.run+'-network-0',id:'1'.repeat(64),image:null,phase:'created',outbound:true},'container:web':{kind:'container',key:'web',name:'hkn-'+input.run+'-container-0',id:'2'.repeat(64),image:'sha256:'+ '3'.repeat(64),phase:'started',networks:['default']}}}; if(${options.storage === true && options.liveStop === true}){ const namespace=provenance.namespace, owner='9'.repeat(32); @@ -469,7 +483,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ } await finish(); }else{ - if(action==='inspect'&&${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:'native_graph_receipt',message:${JSON.stringify(CANARY)}}));process.exit(2)} + if(action==='inspect'&&${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:${JSON.stringify(options.unreservedInspection === true ? "native_graph_unreserved" : "native_graph_receipt")},message:${JSON.stringify(CANARY)}}));process.exit(2)} if(action==='inspect'&&${JSON.stringify(options.cleanup)}==='missing'){console.error(JSON.stringify({code:'graph_owner_recovery',message:'private-missing-journal-detail'}));process.exit(2)} const receipt=JSON.parse(await readFile(journal,'utf8')); const observations={web:receipt.phase==='removed'?null:{state:'running',health:'none'}}; @@ -505,7 +519,7 @@ async function artifacts(scope: Awaited>["scope"]) { } async function failure( value: Promise, - outcome: "not-started" | "removed" | "retained" + outcome: "not-started" | "removed" | "retained" | "unreserved" ) { const error: unknown = await value.catch((caught: unknown) => caught); expect(error).toBeInstanceOf(NativeAuthoredProjectStartError); @@ -534,7 +548,141 @@ macTest( expect(retained).toHaveLength(2); expect(retained.some((name) => name.endsWith(".start.json"))).toBe(true); expect(retained.some((name) => name.endsWith(".source.json"))).toBe(true); - } + }, + 30_000 +); + +macTest( + "HACK-1272: a consumer refused before journal reservation retires its exact start intent", + async () => { + // M3 next.5 repro: a repository@digest image passes planning, then the + // native consumer refuses native_graph_admission before reserving a journal. + // Retirement inspection can never authenticate a Removed receipt; only the + // exact native_graph_unreserved refusal may retire the attempt. + const selected = await fixture({ + failBeforeReceipt: true, + failBeforeReceiptCode: "native_graph_admission", + unreservedInspection: true, + }); + const error = await failure( + serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 60_000, + }), + "unreserved" + ); + expect(error).toHaveProperty("nativeCode", "native_graph_admission"); + expect(error).toHaveProperty("stage", "runtime"); + expect(String(error)).toContain("never reserved"); + expect(await artifacts(selected.scope)).toEqual([]); + const status = await nativeAuthoredProjectPs({ + runtime: selected.runtime, + scope: selected.scope, + invoke: () => { + throw new Error("No runtime request for an absent attempt."); + }, + }); + expect(status.status).toBe("not_started"); + const calls = (await readFile(selected.calls, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)[4]); + expect(calls).toEqual(["plan", "serve", "inspect"]); + }, + 90_000 +); + +macTest( + "HACK-1272: down --recover retires only a provably unreserved retained start", + async () => { + // Reproduce the next.5 retained state: inspection named the absent journal + // native_graph_receipt, so start and source were retained and ps is pending. + const selected = await fixture({ + failBeforeReceipt: true, + failBeforeReceiptCode: "native_graph_admission", + }); + await failure( + serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 60_000, + }), + "retained" + ); + expect(await artifacts(selected.scope)).toHaveLength(2); + const ps = () => + nativeAuthoredProjectPs({ + runtime: selected.runtime, + scope: selected.scope, + invoke: () => { + throw new Error("No runtime request without a Ready mapping."); + }, + }); + expect((await ps()).status).toBe("pending"); + expect(await nativeAuthoredUnreservedStartSelected(selected.scope)).toBe( + true + ); + const requests: string[][] = []; + const refusing = + (code: string | null): typeof invokeNativeRuntime => + (input) => { + requests.push([...input.args]); + if (code === null) { + return Promise.resolve({ receipt: {}, observations: {} }); + } + return Promise.reject( + new NativeRuntimeRequestError({ + message: "synthetic refusal", + nativeCode: code, + }) + ); + }; + const recover = (code: string | null) => + retireNativeAuthoredUnreservedStart({ + scope: selected.scope, + runtime: selected.runtime, + timeoutMs: 10_000, + request: refusing(code), + }); + // Damaged/foreign receipt evidence, a published owner, a stopped runtime + // and a present journal all keep every file. + for (const code of [ + "native_graph_receipt", + "native_graph_foreground", + "runtime_not_running", + null, + ]) { + expect( + await recover(code).catch((caught: unknown) => caught) + ).toBeInstanceOf(Error); + expect(await artifacts(selected.scope)).toHaveLength(2); + expect((await ps()).status).toBe("pending"); + } + expect(await recover("native_graph_unreserved")).toEqual({ run }); + expect(requests.at(-1)).toEqual([ + "graph", + "native", + "inspect", + "--run-id", + run, + "--json", + ]); + expect(await artifacts(selected.scope)).toEqual([]); + expect((await ps()).status).toBe("not_started"); + expect(await nativeAuthoredUnreservedStartSelected(selected.scope)).toBe( + false + ); + // Nothing remains to retire; a second recovery refuses without a request. + const before = requests.length; + expect( + await recover("native_graph_unreserved").catch( + (caught: unknown) => caught + ) + ).toBeInstanceOf(Error); + expect(requests).toHaveLength(before); + }, + 90_000 ); test("startup diagnostics accept only closed compiler codes", () => { diff --git a/tests/native-candidate-launcher.test.ts b/tests/native-candidate-launcher.test.ts index 8bf40e18e..38a0b8b2b 100644 --- a/tests/native-candidate-launcher.test.ts +++ b/tests/native-candidate-launcher.test.ts @@ -1,55 +1,150 @@ -import { expect, test } from "bun:test"; +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 { privateCliEnv } from "./helpers/private-cli-env.ts"; -test("candidate launcher selects its adjacent executor and preserves arguments and exit", async () => { - const root = await realpath(await mkdtemp(join(tmpdir(), "hack candidate "))); - try { - await copyFile("scripts/hack-v5.sh", join(root, "hack-v5")); - await Bun.write(join(root, "hack-native"), "#!/bin/sh\nexit 0\n"); - await chmod(join(root, "hack-native"), 0o755); - await Bun.write( - join(root, "hack-cli"), - '#!/bin/sh\nprintf "%s\\n" "$HACK_RUNTIME_BACKEND" "$HACK_NATIVE_BINARY" "$HACK_NATIVE_HOME" "$@"\nexit 7\n' - ); - await chmod(join(root, "hack-cli"), 0o755); - const env = { - ...process.env, +/** + * The bundled `hack-v5` entrypoint runs against a stub `hack-cli` that only + * prints the selection it received, so no candidate software, Docker engine or + * real HOME is reached. + */ +const PRINTED = [ + "HACK_RUNTIME_BACKEND", + "HACK_NATIVE_BINARY", + "HACK_NATIVE_HOME", + "HACK_HOME", + "HACK_CANDIDATE_ENTRYPOINT", +] as const; +const STUB_CLI = `#!/bin/sh\n${PRINTED.map( + (name) => `printf "%s\\n" "\${${name}-}"` +).join("\n")}\nprintf "%s\\n" "$@"\nexit 7\n`; + +let root: string; + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "hack candidate "))); + await copyFile("scripts/hack-v5.sh", join(root, "hack-v5")); + await Bun.write(join(root, "hack-native"), "#!/bin/sh\nexit 0\n"); + await chmod(join(root, "hack-native"), 0o755); + await Bun.write(join(root, "hack-cli"), STUB_CLI); + await chmod(join(root, "hack-cli"), 0o755); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +function launcherEnv(env: Readonly>) { + return privateCliEnv({ + root, + env: { HACK_NATIVE_HOME: join(root, "private home"), - HACK_RUNTIME_BACKEND: "compose", HACK_NATIVE_BINARY: "/wrong/executor", - }; - const child = Bun.spawn( - ["sh", join(root, "hack-v5"), "exec", "a b", "--", "$literal"], - { - env, - stdout: "pipe", - stderr: "pipe", - } - ); - expect(await new Response(child.stdout).text()).toBe( - `native\n${root}/hack-native\n${root}/private home\nexec\na b\n--\n$literal\n` + HACK_HOME: join(root, "caller hack home"), + ...env, + }, + }); +} + +async function launch( + env: Readonly>, + args: readonly string[] = ["exec", "a b", "--", "$literal"] +) { + const child = Bun.spawn(["sh", join(root, "hack-v5"), ...args], { + env: launcherEnv(env), + stdout: "pipe", + stderr: "pipe", + }); + return { + stdout: await new Response(child.stdout).text(), + stderr: await new Response(child.stderr).text(), + exitCode: await child.exited, + }; +} + +test.each([ + ["unset", undefined], + ["empty", ""], + ["native", "native"], +])("candidate launcher selects native and its adjacent executor when the backend is %s", async (_label, backend) => { + const result = await launch({ HACK_RUNTIME_BACKEND: backend }); + expect(result.stdout).toBe( + [ + "native", + `${root}/hack-native`, + `${root}/private home`, + `${root}/caller hack home`, + "hack-v5", + "exec", + "a b", + "--", + "$literal", + "", + ].join("\n") + ); + expect(result.exitCode).toBe(7); +}); + +test("explicit Compose selection is respected with the caller's Hack home and no native selectors", async () => { + const result = await launch({ HACK_RUNTIME_BACKEND: "compose" }); + expect(result.stdout).toBe( + [ + "compose", + "", + "", + `${root}/caller hack home`, + "hack-v5", + "exec", + "a b", + "--", + "$literal", + "", + ].join("\n") + ); + expect(result.exitCode).toBe(7); + // Compose needs no native home, so an absent or relative one does not refuse. + for (const home of [undefined, "relative"]) { + const noHome = await launch( + { HACK_RUNTIME_BACKEND: "compose", HACK_NATIVE_HOME: home }, + ["ps"] ); - expect(await child.exited).toBe(7); - for (const home of ["", "relative"]) { - const refused = Bun.spawn(["sh", join(root, "hack-v5"), "up"], { - env: { ...env, HACK_NATIVE_HOME: home }, - stdout: "pipe", - stderr: "pipe", - }); - expect(await refused.exited).toBe(64); - expect(await new Response(refused.stdout).text()).toBe(""); - } - await rm(join(root, "hack-native")); - const incomplete = Bun.spawn(["sh", join(root, "hack-v5"), "up"], { - env, - stdout: "pipe", - stderr: "pipe", - }); - expect(await incomplete.exited).toBe(69); - expect(await new Response(incomplete.stdout).text()).toBe(""); - } finally { - await rm(root, { recursive: true, force: true }); + expect(noHome.exitCode).toBe(7); + expect(noHome.stdout.split("\n").slice(0, 3)).toEqual([ + "compose", + "", + "", + ]); } }); + +test.each([ + "docker", + "Native", + "native ", +])("unknown backend %p refuses before running the CLI", async (backend) => { + const result = await launch({ HACK_RUNTIME_BACKEND: backend }, ["up"]); + expect(result.exitCode).toBe(64); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "HACK_RUNTIME_BACKEND must be native or compose" + ); +}); + +test("native selection refuses without an absolute private home", async () => { + for (const home of [undefined, "", "relative"]) { + const refused = await launch({ HACK_NATIVE_HOME: home }, ["up"]); + expect(refused.exitCode).toBe(64); + expect(refused.stdout).toBe(""); + } +}); + +test.each([ + undefined, + "compose", +])("incomplete bundle refuses on backend %p", async (backend) => { + await rm(join(root, "hack-native")); + const incomplete = await launch({ HACK_RUNTIME_BACKEND: backend }, ["up"]); + expect(incomplete.exitCode).toBe(69); + expect(incomplete.stdout).toBe(""); +}); diff --git a/tests/portable-bootstrap-env-refusal.test.ts b/tests/portable-bootstrap-env-refusal.test.ts new file mode 100644 index 000000000..78ead7e3b --- /dev/null +++ b/tests/portable-bootstrap-env-refusal.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { encryptSynthetic } from "../scripts/portable-bootstrap-smoke.ts"; +import { + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + renderBootstrapCompose, + renderBootstrapEnvFile, + renderBootstrapLock, + sha256Hex, +} from "./e2e/portable-bootstrap-controls.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const cli = resolve(import.meta.dir, "../index.ts"); +// Synthetic, process-local values; never real credentials. +const KEY = "portable-refusal-key-0123456789abcdef"; +const WRONG_KEY = "portable-refusal-wrong-key-0123456789"; +const DRIFT_KEY = "portable-refusal-drift-key-0123456789"; +const SECRET = "portable-refusal-secret-value"; +const DRIFT_SECRET = "portable-refusal-drift-value"; +const FORBIDDEN = [KEY, WRONG_KEY, DRIFT_KEY, SECRET, DRIFT_SECRET]; +const MUTATIONS = new Set(["create", "run", "start", "up"]); + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +/** + * The hosted `portable-bootstrap-negatives` fixture against a recording Docker + * stub: env refusals must happen before Hack asks Compose to create, run or + * start anything. The stub refuses every Compose mutation, so the positive + * control shows the same command reaches the dependency bootstrap with a + * valid key. + */ +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "hack-portable-refusal-")); + roots.push(root); + const project = join(root, "project"); + const bin = join(root, "bin"); + const home = join(root, "home"); + for (const path of [ + join(project, ".hack"), + join(project, "deps"), + bin, + home, + ]) { + await mkdir(path, { recursive: true }); + } + const manifest = '{\n "name": "portable-refusal",\n "private": true\n}\n'; + const files: Record = { + ".gitignore": ".hack/.internal/\n.hack.secret.key\n", + "package.json": manifest, + "bun.lock": renderBootstrapLock({ manifest, revision: 1 }), + "deps/install.sh": PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + "deps/verify.sh": PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + ".hack/hack.config.json": `${JSON.stringify({ + name: "portable-refusal", + internal: { dns: false, tls: false }, + })}\n`, + ".hack/docker-compose.yml": renderBootstrapCompose({ + name: "portable-refusal", + ref: `sha256:${"a".repeat(64)}`, + platform: "linux/arm64", + generation: "0", + tokenDigest: sha256Hex(SECRET), + }), + ".hack/hack.env.default.yaml": renderBootstrapEnvFile({ + environment: "default", + sealed: encryptSynthetic({ key: KEY, secret: SECRET }), + }), + ".hack/hack.env.drift.yaml": renderBootstrapEnvFile({ + environment: "drift", + sealed: encryptSynthetic({ key: DRIFT_KEY, secret: DRIFT_SECRET }), + }), + }; + for (const [path, content] of Object.entries(files)) { + await writeFile(join(project, path), content); + } + const git = (args: string[]) => { + const result = Bun.spawnSync(["git", "-C", project, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).toBe(0); + }; + git(["init", "-q", "-b", "main"]); + git(["add", "."]); + git([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-qm", + "fixture", + ]); + const log = join(root, "docker.jsonl"); + await writeFile(log, ""); + const docker = join(bin, "docker"); + await writeFile( + docker, + `#!${process.execPath} +import { appendFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + "\\n"); +const mutations = ${JSON.stringify([...MUTATIONS])}; +if (args[0] === "compose" && args.some((arg) => mutations.includes(arg))) process.exit(97); +` + ); + await chmod(docker, 0o755); + const up = async (opts: { + readonly key: string | null; + readonly overlay?: string; + }) => { + const child = Bun.spawn( + [ + process.execPath, + cli, + "up", + "--detach", + ...(opts.overlay ? ["--env", opts.overlay] : []), + "app", + ], + { + cwd: project, + timeout: 20_000, + env: privateCliEnv({ + root, + home, + env: { + HACK_HOME: join(home, ".hack"), + PATH: `${bin}:${process.env.PATH ?? ""}`, + HACK_NO_INTERACTIVE: "1", + HACK_EXECUTION_MODE: "slim", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + NO_COLOR: "1", + ...(opts.key === null ? {} : { HACK_ENV_SECRET_KEY: opts.key }), + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const calls = (await readFile(log, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + await writeFile(log, ""); + return { exitCode, stdout, stderr, calls }; + }; + return { up }; +} + +function composeMutations(calls: readonly (readonly string[])[]) { + return calls.filter( + (args) => args[0] === "compose" && args.some((arg) => MUTATIONS.has(arg)) + ); +} + +describe("portable bootstrap env refusals", () => { + test.each([ + { name: "missing key", key: null, reason: ".hack.secret.key" }, + { name: "wrong key", key: WRONG_KEY, reason: "Invalid secure env value." }, + { + name: "overlay sealed under another key", + key: KEY, + overlay: "drift", + reason: "Invalid secure env value.", + }, + ])("$name refuses before any Compose mutation", async (entry) => { + const { up } = await fixture(); + const result = await up(entry); + expect(result.exitCode).not.toBe(0); + expect(composeMutations(result.calls)).toEqual([]); + expect(`${result.stdout}${result.stderr}`).toContain(entry.reason); + for (const value of FORBIDDEN) { + expect(`${result.stdout}${result.stderr}`).not.toContain(value); + } + }, 60_000); + + test("the valid key reaches the dependency bootstrap first", async () => { + const { up } = await fixture(); + const result = await up({ key: KEY }); + expect(result.exitCode).not.toBe(0); + const mutations = composeMutations(result.calls); + expect(mutations.length).toBe(1); + expect(mutations[0]).toContain("run"); + expect(mutations[0]?.at(-1)).toBe("deps"); + for (const value of FORBIDDEN) { + expect(`${result.stdout}${result.stderr}`).not.toContain(value); + } + }, 60_000); +}); diff --git a/tests/project-target-inspect.test.ts b/tests/project-target-inspect.test.ts new file mode 100644 index 000000000..16f41355c --- /dev/null +++ b/tests/project-target-inspect.test.ts @@ -0,0 +1,1005 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { + inspectTarget, + parseTargetInspectEnvelope, + TARGET_REFUSALS, + type TargetInspectEnvelope, + targetRefusalEnvelope, +} from "../src/lib/target-inspect.ts"; +import { buildTargetInspectArgs, createMcpServer } from "../src/mcp/server.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +// --------------------------------------------------------------------------- +// HACK-1144 / WU31: paired CLI and MCP fixtures for the read-only target +// inspector. Every case runs the source CLI directly AND through the MCP tool +// (whose child is the same source CLI), and both envelopes must be identical +// apart from the observation time. The fixture is a private temp root: its own +// HOME, HACK_HOME, registry and Git repositories. No Docker/OrbStack socket, +// keychain, real home or client config is touched; the CLI child gets an +// isolated Docker client config that selects no engine. +// --------------------------------------------------------------------------- + +const CLI_ENTRY = resolve(import.meta.dir, "..", "index.ts"); +const CASE_TIMEOUT_MS = 120_000; + +type Fixture = { + readonly root: string; + readonly state: string; + readonly alpha: string; + readonly beta: string; + readonly client: Client; + /** MCP client whose CLI command is a stub that prints `fake-envelope.json`. */ + readonly stubClient: Client; + readonly cliEnv: Record; + /** Registry bytes and mtime exactly as written before any CLI run. */ + readonly registryText: string; + readonly registryMtimeMs: number; +}; + +let fixture: Fixture; + +function gitEnv(home: string): Record { + return { + PATH: process.env.PATH ?? "", + HOME: home, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }; +} + +function runGit(opts: { + readonly cwd: string; + readonly home: string; + readonly args: readonly string[]; +}): string { + const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: gitEnv(opts.home), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error( + `git ${opts.args.join(" ")} failed: ${result.stderr.toString()}` + ); + } + return result.stdout.toString(); +} + +async function writeProject(opts: { + readonly repo: string; + readonly name: string; + readonly services: readonly string[]; + readonly config?: Readonly>; +}): Promise { + await mkdir(join(opts.repo, ".hack"), { recursive: true }); + const services = opts.services + .map((service) => ` ${service}:\n image: busybox\n`) + .join(""); + await writeFile( + join(opts.repo, ".hack", "docker-compose.yml"), + `services:\n${services}` + ); + await writeFile( + join(opts.repo, ".hack", "hack.config.json"), + `${JSON.stringify({ name: opts.name, ...opts.config })}\n` + ); +} + +/** Larger than the inspector's 4 MiB bound on one generation input. */ +const OVERSIZED_INPUT_BYTES = 4 * 1024 * 1024 + 1024; + +function registryRow(opts: { + readonly id: string; + readonly name: string; + readonly repo: string; + readonly worktrees?: readonly { path: string; branch: string | null }[]; +}) { + return { + id: opts.id, + name: opts.name, + repoRoot: opts.repo, + projectDirName: ".hack", + projectDir: join(opts.repo, ".hack"), + createdAt: "2026-01-01T00:00:00.000Z", + ...(opts.worktrees + ? { + worktrees: opts.worktrees.map((entry) => ({ + ...entry, + lastSeenAt: "2026-01-01T00:00:00.000Z", + })), + } + : {}), + }; +} + +beforeAll(async () => { + // realpath: the macOS tmpdir is a symlink; the registry stores real roots. + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-target-inspect-")) + ); + const home = join(root, "user-home"); + const state = join(root, "state"); + await mkdir(home, { recursive: true }); + await mkdir(state, { recursive: true }); + + // Project alpha: a Git checkout with two linked worktrees. + const alpha = join(root, "alpha"); + await writeProject({ repo: alpha, name: "alpha", services: ["api", "web"] }); + runGit({ cwd: alpha, home, args: ["init", "-q", "-b", "main"] }); + runGit({ cwd: alpha, home, args: ["add", "-A"] }); + runGit({ cwd: alpha, home, args: ["commit", "-q", "-m", "fixture"] }); + runGit({ + cwd: alpha, + home, + args: ["worktree", "add", "-q", "-b", "feature/login", "../alpha-login"], + }); + runGit({ + cwd: alpha, + home, + args: ["worktree", "add", "-q", "--detach", "../alpha-detached"], + }); + + // Project beta: an independent, non-Git project. + const beta = join(root, "beta"); + await writeProject({ repo: beta, name: "beta", services: ["db"] }); + + // Two independent checkouts that share a name (never merged or renamed). + await writeProject({ + repo: join(root, "gamma-one"), + name: "gamma", + services: ["api"], + }); + await writeProject({ + repo: join(root, "gamma-two"), + name: "gamma", + services: ["api"], + }); + + // A registration whose checkout was moved away. + const moved = join(root, "moved"); + await writeProject({ repo: moved, name: "moved", services: ["api"] }); + + // A project with a configured default env overlay. + const delta = join(root, "delta"); + await writeProject({ + repo: delta, + name: "delta", + services: ["api"], + config: { env: { default_overlay: "staging" } }, + }); + + // One checkout owned by two registrations (a corrupt registry). + const shared = join(root, "shared"); + await writeProject({ repo: shared, name: "shared", services: ["api"] }); + + // Unregistered projects with malformed or oversized Hack inputs. + const brokenConfig = join(root, "broken-config"); + await writeProject({ repo: brokenConfig, name: "broken", services: ["api"] }); + await writeFile(join(brokenConfig, ".hack", "hack.config.json"), '{"name":'); + const brokenCompose = join(root, "broken-compose"); + await writeProject({ + repo: brokenCompose, + name: "broken", + services: ["api"], + }); + await writeFile( + join(brokenCompose, ".hack", "docker-compose.yml"), + "services: [unclosed\n" + ); + const listCompose = join(root, "list-compose"); + await writeProject({ repo: listCompose, name: "listed", services: ["api"] }); + await writeFile( + join(listCompose, ".hack", "docker-compose.yml"), + "services:\n - api\n" + ); + const oversized = join(root, "oversized"); + await writeProject({ repo: oversized, name: "oversized", services: ["api"] }); + await writeFile( + join(oversized, ".hack", "docker-compose.yml"), + `services:\n api:\n image: busybox\n#${"x".repeat(OVERSIZED_INPUT_BYTES)}\n` + ); + // Unreadable inputs: a directory where the Compose file belongs, and a + // Compose file with no read permission. Their OS errors carry the path. + const dirCompose = join(root, "dir-compose"); + await writeProject({ repo: dirCompose, name: "dir", services: ["api"] }); + await rm(join(dirCompose, ".hack", "docker-compose.yml")); + await mkdir(join(dirCompose, ".hack", "docker-compose.yml")); + const lockedCompose = join(root, "locked-compose"); + await writeProject({ + repo: lockedCompose, + name: "locked", + services: ["api"], + }); + await chmod(join(lockedCompose, ".hack", "docker-compose.yml"), 0o000); + + await mkdir(join(root, "outside"), { recursive: true }); + + await writeFile( + join(state, "projects.json"), + `${JSON.stringify({ + version: 1, + projects: [ + registryRow({ + id: "alpha", + name: "alpha", + repo: alpha, + worktrees: [ + { path: join(root, "alpha-login"), branch: "feature/login" }, + ], + }), + registryRow({ id: "beta", name: "beta", repo: beta }), + registryRow({ + id: "gamma-1", + name: "gamma", + repo: join(root, "gamma-one"), + }), + registryRow({ + id: "gamma-2", + name: "gamma", + repo: join(root, "gamma-two"), + }), + registryRow({ id: "moved", name: "moved", repo: moved }), + registryRow({ id: "delta", name: "delta", repo: delta }), + registryRow({ id: "shared-a", name: "shared-a", repo: shared }), + registryRow({ id: "shared-b", name: "shared-b", repo: shared }), + ], + })}\n` + ); + await rm(moved, { recursive: true, force: true }); + const registryText = await readFile(join(state, "projects.json"), "utf8"); + const registryMtimeMs = (await stat(join(state, "projects.json"))).mtimeMs; + + const wrapper = join(root, "hack-source-cli"); + await writeFile( + wrapper, + `#!/bin/sh\nexec '${process.execPath}' '${CLI_ENTRY}' "$@"\n` + ); + await chmod(wrapper, 0o755); + + const cliEnv = privateCliEnv({ root, env: { HACK_HOME: state } }); + const mcpEnv = privateCliEnv({ + root, + env: { HACK_HOME: state, HACK_MCP_COMMAND: wrapper }, + }); + const server = createMcpServer({ cwd: alpha, env: mcpEnv }); + const client = new Client({ name: "hack-target-inspect", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // A stub CLI that prints whatever envelope a test stores, so the MCP + // adapter's re-validation can be observed against drifted output. + const stub = join(root, "hack-stub-cli"); + await writeFile( + stub, + `#!/bin/sh\ncat '${join(root, "fake-envelope.json")}'\n` + ); + await chmod(stub, 0o755); + const stubState = join(root, "stub-state"); + await mkdir(stubState, { recursive: true }); + const stubServer = createMcpServer({ + cwd: alpha, + env: privateCliEnv({ + root, + env: { HACK_HOME: stubState, HACK_MCP_COMMAND: stub }, + }), + }); + const stubClient = new Client({ name: "hack-target-stub", version: "1" }); + const [stubClientTransport, stubServerTransport] = + InMemoryTransport.createLinkedPair(); + await stubServer.connect(stubServerTransport); + await stubClient.connect(stubClientTransport); + + fixture = { + root, + state, + alpha, + beta, + client, + stubClient, + cliEnv, + registryText, + registryMtimeMs, + }; +}, CASE_TIMEOUT_MS); + +afterAll(async () => { + await fixture?.client.close(); + await fixture?.stubClient.close(); + if (fixture?.root) { + await rm(fixture.root, { recursive: true, force: true }); + } +}, CASE_TIMEOUT_MS); + +type ToolInput = { + readonly projectName?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}; + +type McpPayload = { + readonly ok: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly command: string; + readonly spawned?: false; + readonly data?: unknown; + readonly error?: { readonly reason: string }; +}; + +async function runCli(input: ToolInput): Promise<{ + readonly exitCode: number; + readonly envelope: unknown; +}> { + const proc = Bun.spawn( + [process.execPath, CLI_ENTRY, ...buildTargetInspectArgs(input)], + { + cwd: fixture.alpha, + env: fixture.cliEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + return { exitCode, envelope: JSON.parse(stdout) as unknown }; +} + +async function runMcp(input: ToolInput): Promise { + const result = await fixture.client.callTool({ + name: "hack.project.target", + arguments: input, + }); + return result.structuredContent as McpPayload; +} + +/** Drop the only intentionally nondeterministic field. */ +function comparable(envelope: unknown): unknown { + const parsed = parseTargetInspectEnvelope(envelope); + if (!parsed) { + return { invalid: envelope }; + } + if (!parsed.ok) { + return parsed; + } + const { observed_at: _observedAt, ...data } = parsed.data; + return { ok: true, data }; +} + +/** Run one case on both surfaces and require the same answer. */ +async function inspectBoth(input: ToolInput): Promise { + const cli = await runCli(input); + const mcp = await runMcp(input); + const parsed = parseTargetInspectEnvelope(cli.envelope); + if (!parsed) { + throw new Error(`CLI envelope off contract: ${JSON.stringify(cli)}`); + } + expect(comparable(mcp.data)).toEqual(comparable(cli.envelope)); + expect(mcp.ok).toBe(parsed.ok); + expect(mcp.exitCode).toBe(cli.exitCode); + expect(cli.exitCode).toBe(parsed.ok ? 0 : 1); + // The MCP tool never pre-resolves selection: every case spawned the CLI. + expect(mcp.spawned).toBeUndefined(); + expect(mcp.stdout).toBe(""); + return parsed; +} + +function expectRefusal( + envelope: TargetInspectEnvelope, + reason: keyof typeof TARGET_REFUSALS +): void { + expect(envelope).toEqual(targetRefusalEnvelope({ reason })); +} + +function okData(envelope: TargetInspectEnvelope) { + if (!envelope.ok) { + throw new Error(`expected a target, got ${JSON.stringify(envelope)}`); + } + return envelope.data; +} + +describe("paired CLI/MCP target fixtures", () => { + test( + "the session checkout resolves to the primary default instance", + async () => { + const data = okData(await inspectBoth({})); + expect(data.selector).toEqual({ + kind: "cwd", + branch_explicit: false, + env_explicit: false, + expected_generation: null, + }); + expect(data.project).toEqual({ + name: "alpha", + registration: { id: "alpha", name: "alpha", match: "root" }, + project_dir: join(fixture.alpha, ".hack"), + }); + expect(data.checkout).toEqual({ + root: fixture.alpha, + kind: "primary", + git_branch: "main", + }); + expect(data.instance).toEqual({ + branch: null, + source: "default", + compose_project: "alpha", + }); + expect(data.services).toEqual(["api", "web"]); + expect(data.service).toBeNull(); + expect(data.env).toEqual({ overlay: null, source: "base" }); + expect(data.authority).toBe("none"); + expect(data.runtime).toEqual({ observed: false, reason: "not_probed" }); + }, + CASE_TIMEOUT_MS + ); + + test( + "a second project is selected by name without leaking the first", + async () => { + const data = okData(await inspectBoth({ projectName: "beta" })); + expect(data.selector.kind).toBe("project"); + expect(data.project.registration).toEqual({ + id: "beta", + name: "beta", + match: "root", + }); + expect(data.checkout).toEqual({ + root: fixture.beta, + kind: "not_git", + git_branch: null, + }); + expect(data.instance.compose_project).toBe("beta"); + expect(data.services).toEqual(["db"]); + }, + CASE_TIMEOUT_MS + ); + + test( + "a linked worktree selects its own branch instance, not the primary", + async () => { + const data = okData( + await inspectBoth({ path: join(fixture.root, "alpha-login") }) + ); + expect(data.project.registration).toEqual({ + id: "alpha", + name: "alpha", + match: "worktree", + }); + expect(data.checkout).toEqual({ + root: join(fixture.root, "alpha-login"), + kind: "linked", + git_branch: "feature/login", + }); + expect(data.instance).toEqual({ + branch: "feature-login", + source: "worktree", + compose_project: "alpha--feature-login", + }); + }, + CASE_TIMEOUT_MS + ); + + test( + "an explicit instance wins and the Git branch is reported unchanged", + async () => { + const data = okData( + await inspectBoth({ + path: join(fixture.root, "alpha-login"), + branch: "review", + service: "api", + }) + ); + expect(data.selector.branch_explicit).toBe(true); + expect(data.checkout.git_branch).toBe("feature/login"); + expect(data.instance).toEqual({ + branch: "review", + source: "explicit", + compose_project: "alpha--review", + }); + expect(data.service).toEqual({ name: "api" }); + const branches = runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["branch", "--list", "--format=%(refname:short)"], + }); + expect(branches.trim().split("\n").sort()).toEqual([ + "feature/login", + "main", + ]); + }, + CASE_TIMEOUT_MS + ); + + test( + "an explicit instance is slugged like 'hack up --branch'", + async () => { + // `hack up --branch Feature/X` targets --feature-x. + const data = okData( + await inspectBoth({ projectName: "alpha", branch: "Feature/X" }) + ); + expect(data.instance).toEqual({ + branch: "feature-x", + source: "explicit", + compose_project: "alpha--feature-x", + }); + expect(data.checkout.git_branch).toBe("main"); + }, + CASE_TIMEOUT_MS + ); + + test( + "env selection: project default, explicit overlay and base agree", + async () => { + const byDefault = okData(await inspectBoth({ projectName: "delta" })); + expect(byDefault.selector.env_explicit).toBe(false); + expect(byDefault.env).toEqual({ + overlay: "staging", + source: "project_default", + }); + + const explicit = okData( + await inspectBoth({ projectName: "delta", env: "preview" }) + ); + expect(explicit.selector.env_explicit).toBe(true); + expect(explicit.env).toEqual({ overlay: "preview", source: "explicit" }); + + const base = okData( + await inspectBoth({ projectName: "delta", env: "base" }) + ); + expect(base.selector.env_explicit).toBe(true); + expect(base.env).toEqual({ overlay: null, source: "base" }); + }, + CASE_TIMEOUT_MS + ); + + test( + "malformed or oversized Hack inputs are refused, not reported empty", + async () => { + expectRefusal( + await inspectBoth({ path: join(fixture.root, "broken-config") }), + "config_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "broken-compose") }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ + path: join(fixture.root, "broken-compose"), + service: "api", + }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "list-compose") }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "oversized") }), + "generation_input_too_large" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "an unreadable input is a closed refusal without the OS message or path", + async () => { + const path = join(fixture.root, "dir-compose"); + expectRefusal(await inspectBoth({ path }), "input_unreadable"); + const cli = await runCli({ path }); + expect(JSON.stringify(cli.envelope)).not.toContain(fixture.root); + expect(JSON.stringify(cli.envelope)).not.toContain("EISDIR"); + }, + CASE_TIMEOUT_MS + ); + + // Root reads a mode-000 file, so the permission fault cannot be produced. + test.skipIf(process.getuid?.() === 0)( + "a permission-denied input is a closed refusal without the OS message", + async () => { + const path = join(fixture.root, "locked-compose"); + expectRefusal(await inspectBoth({ path }), "input_permission_denied"); + const cli = await runCli({ path }); + expect(JSON.stringify(cli.envelope)).not.toContain(fixture.root); + expect(JSON.stringify(cli.envelope)).not.toContain("EACCES"); + }, + CASE_TIMEOUT_MS + ); + + test( + "two registrations that own one checkout are refused as ambiguous", + async () => { + expectRefusal( + await inspectBoth({ path: join(fixture.root, "shared") }), + "ambiguous_project" + ); + expectRefusal( + await inspectBoth({ projectName: "shared-a" }), + "ambiguous_project" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "conflicting selectors are refused identically", + async () => { + expectRefusal( + await inspectBoth({ projectName: "alpha", path: fixture.beta }), + "conflicting_selectors" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "closed project, instance and service refusals agree on both surfaces", + async () => { + expectRefusal( + await inspectBoth({ projectName: "gamma" }), + "ambiguous_project" + ); + expectRefusal( + await inspectBoth({ projectName: "moved" }), + "registered_root_missing" + ); + expectRefusal( + await inspectBoth({ projectName: "zeta" }), + "project_not_found" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "alpha-detached") }), + "detached_checkout" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "outside") }), + "no_project_context" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "absent") }), + "path_not_found" + ); + expectRefusal( + await inspectBoth({ projectName: "alpha", service: "nope" }), + "service_not_found" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "a stale generation is refused; source edits keep the generation", + async () => { + const first = okData(await inspectBoth({ projectName: "beta" })); + const generation = first.generation.id; + + // An ordinary source edit does not invalidate the target. + await writeFile(join(fixture.beta, "app.ts"), "export const x = 1;\n"); + const unchanged = okData( + await inspectBoth({ projectName: "beta", expectGeneration: generation }) + ); + expect(unchanged.generation.id).toBe(generation); + expect(unchanged.selector.expected_generation).toBe(generation); + + // A Hack config edit does. + await writeFile( + join(fixture.beta, ".hack", "hack.config.json"), + `${JSON.stringify({ name: "beta", dev_host: "beta.hack" })}\n` + ); + const stale = await inspectBoth({ + projectName: "beta", + expectGeneration: generation, + }); + const current = okData(await inspectBoth({ projectName: "beta" })); + expect(current.generation.id).not.toBe(generation); + expect(stale).toEqual( + targetRefusalEnvelope({ + reason: "stale_generation", + observedGeneration: current.generation.id, + }) + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "both surfaces refuse a malformed instance selector before resolution", + async () => { + const cli = await runCli({ branch: "-x" }); + expect(cli.exitCode).toBe(1); + expect(cli.envelope).toEqual( + targetRefusalEnvelope({ reason: "invalid_selector" }) + ); + // MCP validates the same grammar at its input boundary: no child runs. + const mcp = await runMcp({ branch: "-x" }); + expect(mcp.ok).toBe(false); + expect(mcp.spawned).toBe(false); + expect(mcp.error?.reason).toBe("invalid_arguments"); + }, + CASE_TIMEOUT_MS + ); + + test( + "inspection writes nothing: no registry, state or Git change", + async () => { + // The baseline is the registry as beforeAll wrote it, not as it is now: + // every earlier paired case already ran the CLI (directly and through + // MCP) against this HACK_HOME, so a last-seen touch made by any of them + // would already be in a baseline read here and go unnoticed. + const registryPath = join(fixture.state, "projects.json"); + const worktreesBefore = runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["worktree", "list", "--porcelain"], + }); + await runCli({ projectName: "alpha", branch: "fresh-instance" }); + await runCli({ path: join(fixture.root, "alpha-login") }); + expect(await readFile(registryPath, "utf8")).toBe(fixture.registryText); + expect((await stat(registryPath)).mtimeMs).toBe(fixture.registryMtimeMs); + // Only the registry and the MCP audit log exist in the state dir. + expect((await readdir(fixture.state)).sort()).toEqual([ + "mcp-audit.log", + "projects.json", + ]); + expect(await readdir(join(fixture.alpha, ".hack"))).toEqual( + expect.not.arrayContaining([".branch", ".internal"]) + ); + expect( + runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["worktree", "list", "--porcelain"], + }) + ).toBe(worktreesBefore); + }, + CASE_TIMEOUT_MS + ); +}); + +describe("MCP adapter re-validation", () => { + async function stubbedData(envelope: unknown): Promise { + await writeFile( + join(fixture.root, "fake-envelope.json"), + JSON.stringify(envelope) + ); + const result = await fixture.stubClient.callTool({ + name: "hack.project.target", + arguments: {}, + }); + const payload = result.structuredContent as McpPayload; + expect(payload.stdout).toBe(""); + return payload.data; + } + + test( + "drifted CLI output is never passed to MCP callers", + async () => { + // Off-contract success data becomes null. + expect( + await stubbedData({ + ok: true, + data: { schema: "hack.target.v0", provisional: true, leak: "x" }, + }) + ).toBeNull(); + // Foreign fields around a valid refusal are dropped. + const refusal = targetRefusalEnvelope({ reason: "service_not_found" }); + if (refusal.ok) { + throw new Error("expected a refusal envelope"); + } + expect( + await stubbedData({ + ...refusal, + leak: "x", + error: { ...refusal.error, leak: "x" }, + }) + ).toEqual(refusal); + // A refusal whose message no longer matches its reason becomes null. + expect( + await stubbedData({ + ...refusal, + error: { ...refusal.error, message: "drifted text" }, + }) + ).toBeNull(); + // A detail-less fault outside the catalogue carries free OS text. + expect( + await stubbedData({ + ok: false, + error: { + code: "E_UNEXPECTED", + message: "EACCES: permission denied, open '/x/.hack/y.yml'", + }, + }) + ).toBeNull(); + // Success data that claims authority or a runtime observation. + const valid = await validSuccessEnvelope(); + expect(await stubbedData(valid)).toEqual(valid); + expect( + await stubbedData({ + ok: true, + data: { ...valid.data, authority: "owner" }, + }) + ).toBeNull(); + }, + CASE_TIMEOUT_MS + ); +}); + +/** A real success envelope from the inspector, for validator mutations. */ +async function validSuccessEnvelope(): Promise<{ + readonly ok: true; + readonly data: Record; +}> { + const result = await inspectTarget({ + cwd: fixture.root, + registryPath: join(fixture.state, "projects.json"), + selector: { path: fixture.beta }, + }); + if (!result.ok) { + throw new Error(`expected a target, got ${JSON.stringify(result)}`); + } + return { ok: true, data: { ...result.data } }; +} + +describe("target inspector domain", () => { + test("a config change during the read is refused, not snapshotted", async () => { + const result = await inspectTarget({ + cwd: fixture.root, + registryPath: join(fixture.state, "projects.json"), + selector: { path: fixture.beta }, + afterResolve: async () => { + await writeFile( + join(fixture.beta, ".hack", "docker-compose.yml"), + "services:\n db:\n image: postgres\n" + ); + }, + }); + expect(result).toEqual( + targetRefusalEnvelope({ reason: "generation_changed_during_read" }) + ); + }); + + test("selectors are validated before any project lookup", async () => { + const registryPath = join(fixture.state, "projects.json"); + for (const selector of [ + { project: "a=b" }, + { branch: "--all" }, + { service: "a b" }, + { env: "X=secret" }, + { expectGeneration: "cfg-v0:zz" }, + { path: "" }, + ]) { + expect( + await inspectTarget({ cwd: fixture.root, registryPath, selector }) + ).toEqual(targetRefusalEnvelope({ reason: "invalid_selector" })); + } + }); + + test("refusal text never repeats caller input", () => { + for (const spec of Object.values(TARGET_REFUSALS)) { + expect(spec.message).not.toMatch( + /gamma|alpha|beta|zeta|secret|tmp|feature/ + ); + } + }); + + test("the adapter validator rejects drifted or foreign envelopes", () => { + const refusal = targetRefusalEnvelope({ reason: "service_not_found" }); + expect(parseTargetInspectEnvelope(refusal)).toEqual(refusal); + const drift = (patch: Record) => ({ + ok: false, + error: { + ...TARGET_REFUSALS.service_not_found, + detail: { + reason: "service_not_found", + stage: "service", + retry: "fix_selector", + provisional: true, + ...patch, + }, + }, + }); + expect(parseTargetInspectEnvelope(drift({ reason: "other" }))).toBeNull(); + expect( + parseTargetInspectEnvelope(drift({ retry: "reinspect" })) + ).toBeNull(); + expect( + parseTargetInspectEnvelope( + drift({ + observed_generation: "cfg-v0:0123456789abcdef0123456789abcdef", + }) + ) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ok: false, + error: { code: "E_NOT_A_CODE", message: "x" }, + }) + ).toBeNull(); + // The message and code must be the reason's own fixed values. + if (refusal.ok) { + throw new Error("expected a refusal envelope"); + } + expect( + parseTargetInspectEnvelope({ + ...refusal, + error: { ...refusal.error, message: "The service is missing." }, + }) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ...refusal, + error: { ...refusal.error, code: "E_USAGE" }, + }) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ok: true, + data: { schema: "hack.target.v1" }, + }) + ).toBeNull(); + }); + + test("the adapter validator only passes parser E_USAGE without detail", () => { + const usage = { + ok: false, + error: { code: "E_USAGE", message: "Unknown option '--x'." }, + }; + expect(parseTargetInspectEnvelope(usage)).toEqual(usage); + for (const code of ["E_UNEXPECTED", "E_PERMISSION", "E_CONFIG_INVALID"]) { + expect( + parseTargetInspectEnvelope({ + ok: false, + error: { code, message: "EISDIR: illegal operation on a directory" }, + }) + ).toBeNull(); + } + }); + + test("the adapter validator refuses data that claims authority or readiness", async () => { + const valid = await validSuccessEnvelope(); + expect(parseTargetInspectEnvelope(valid)).toEqual(valid); + const patched = (patch: Record) => + parseTargetInspectEnvelope({ + ok: true, + data: { ...valid.data, ...patch }, + }); + expect(patched({ authority: "owner" })).toBeNull(); + expect(patched({ authority: undefined })).toBeNull(); + expect( + patched({ runtime: { observed: true, reason: "not_probed" } }) + ).toBeNull(); + expect( + patched({ runtime: { observed: false, reason: "probed" } }) + ).toBeNull(); + expect(patched({ runtime: null })).toBeNull(); + }); +}); diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py index 1bf8db4be..1282e7de6 100644 --- a/tests/python/test_prerelease_install.py +++ b/tests/python/test_prerelease_install.py @@ -163,11 +163,13 @@ def setUp(self): self.run_patch = mock.patch.object(installer.subprocess, "run", side_effect=self.process) self.run_patch.start() self.addCleanup(self.run_patch.stop) + # A manually exported native selection naming a foreign executor and home: + # the launcher must still pin its own. Backend selection has its own tests. self.env_patch = mock.patch.dict(os.environ, { "HOME": str(self.stable_home), "HACK_HOME": str(self.stable_home / ".hack"), "HACK_GLOBAL_CONFIG_PATH": str(self.stable_home / ".hack/config.json"), "HACK_NATIVE_HOME": str(self.stable_home / "foreign-native"), - "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "docker", + "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "native", "HACK_NATIVE_ADAPTATION": "/private/project/adaptation.json", "HACK_NATIVE_DEPENDENCIES": "/private/project/dependencies.json", "HACK_NATIVE_AWS_PROFILE": "fixture-qa", @@ -1508,6 +1510,8 @@ def test_manager_and_selected_receipt_changes_refuse_status(self): def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launchers(self): self.install() + # No backend selection: the channel default is native with private homes. + os.environ.pop("HACK_RUNTIME_BACKEND") child = mock.Mock() child.wait.return_value = 23 with self.channel.lock(shared=True): @@ -1521,6 +1525,7 @@ def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launche self.assertEqual(environment["HACK_NATIVE_HOME"], str(self.channel.root / "versions/5.0.0-next.1/native-home")) self.assertEqual(environment["HACK_NATIVE_BINARY"], str(self.channel.root / "versions/5.0.0-next.1/bundle/hack-native")) self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_CANDIDATE_ENTRYPOINT"], "hack-next") for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_DEPENDENCIES", "HACK_NATIVE_AWS_PROFILE", "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): self.assertEqual(environment[name], os.environ[name]) @@ -1532,6 +1537,96 @@ def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launche with self.channel.lock(shared=True), mock.patch.object(installer.subprocess, "Popen", return_value=child): self.assertEqual(self.channel.run(["ps"]), 143) + def launch(self, channel=None): + """Run the selected candidate through `channel` with a stand-in child process.""" + channel = channel or self.channel + child = mock.Mock() + child.wait.return_value = 0 + # Every manager imports the same standard-library subprocess module. + with channel.lock(shared=True), \ + mock.patch.object(installer.subprocess, "Popen", return_value=child) as spawn: + self.assertEqual(channel.run(["ps", "--path", "a path"]), 0) + self.assertEqual(spawn.call_args.args[0][1:], ["ps", "--path", "a path"]) + return Path(spawn.call_args.args[0][0]), spawn.call_args.kwargs["env"] + + def test_launcher_respects_explicit_backend_and_keeps_native_default(self): + self.install() + version = self.channel.root / "versions/5.0.0-next.1" + for selection in (None, "", "native"): + with self.subTest(backend=selection), mock.patch.dict(os.environ): + if selection is None: + os.environ.pop("HACK_RUNTIME_BACKEND") + else: + os.environ["HACK_RUNTIME_BACKEND"] = selection + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_NATIVE_BINARY"], str(version / "bundle/hack-native")) + self.assertEqual(environment["HACK_NATIVE_HOME"], str(version / "native-home")) + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.assertNotIn("HACK_GLOBAL_CONFIG_PATH", environment) + with mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": "compose"}): + command, environment = self.launch() + # Compose runs the same verified CLI with the caller's own Hack home, so the + # existing global Caddy/CoreDNS and local CA apply; native selectors are removed. + self.assertEqual(command, version / "bundle/hack-cli") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "compose") + self.assertEqual(environment["HACK_CANDIDATE_ENTRYPOINT"], "hack-next") + self.assertEqual(environment["HACK_HOME"], str(self.stable_home / ".hack")) + self.assertEqual(environment["HACK_GLOBAL_CONFIG_PATH"], str(self.stable_home / ".hack/config.json")) + self.assertNotIn("HACK_NATIVE_BINARY", environment) + self.assertNotIn("HACK_NATIVE_HOME", environment) + for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): + self.assertEqual(environment[name], os.environ[name]) + # Unknown selections refuse before verification or spawn; inherited `docker` is + # not silently replaced by either backend. + for selection in ("docker", "Native", "native "): + with self.subTest(backend=selection), \ + mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": selection}), \ + mock.patch.object(installer.subprocess, "Popen") as spawn, \ + mock.patch.object(installer, "verify_signatures") as signatures: + with self.channel.lock(shared=True): + with self.assertRaisesRegex(installer.Refusal, "must be native or compose"): + self.channel.run(["up"]) + spawn.assert_not_called() + signatures.assert_not_called() + + def test_released_next5_manager_needs_upgrade_manager_for_compose_selection(self): + source = SOURCE.parent.parent / "tests/fixtures/prerelease-manager-storage-v5.py" + predecessor = "e2e2bcbacf58b21d1086047c94727e5e814776384439700adcf5aabe5c49c2fa" + self.assertEqual(installer.digest(source), predecessor) + self.assertIn(predecessor, installer.MANAGER_PREDECESSORS) + spec = importlib.util.spec_from_file_location("storage_v5_predecessor", source) + previous = importlib.util.module_from_spec(spec) + spec.loader.exec_module(previous) + self.channel = previous.Channel(self.root / "storage-v5") + self.channel.initialize() + archive, checksum = self.archive("5.0.0-next.1", with_current_payload) + with self.channel.lock(): + self.channel.install("5.0.0-next.1", archive, checksum) + version = self.channel.root / "versions/5.0.0-next.1" + for home in ("native-home", "cli-home"): + (version / home / "marker").write_text(home) + before = self.retained_snapshot() + with mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": "compose"}): + # The released manager always selects native and its private homes. + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.channel = self.upgrade_manager() + self.assertEqual((self.channel.root / "manager.py").read_bytes(), SOURCE.read_bytes()) + self.assertEqual(self.retained_snapshot(), before) + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-cli") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "compose") + self.assertEqual(environment["HACK_HOME"], str(self.stable_home / ".hack")) + os.environ.pop("HACK_RUNTIME_BACKEND") + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.assertEqual(self.retained_snapshot(), before) + def test_host_and_local_archive_cli_contract(self): for system, machine in (("Linux", "aarch64"), ("Darwin", "x86_64")): with mock.patch.object(installer.platform, "system", return_value=system), \ diff --git a/tests/run-child-exit-status.test.ts b/tests/run-child-exit-status.test.ts new file mode 100644 index 000000000..08cfdef27 --- /dev/null +++ b/tests/run-child-exit-status.test.ts @@ -0,0 +1,300 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcpServer } from "../src/mcp/server.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +// --------------------------------------------------------------------------- +// HACK-1155 (P11): `hack run -- ` reports the child's own +// exit status, from the CLI and from the MCP `hack.project.run` envelope, and +// passes the child argv after `--` through unchanged. A pipe filter can hide +// that status only from the pipeline, never from hack's own exit code. +// +// The source CLI runs with a private HOME and HACK_HOME against a recording +// `docker` stub on PATH. The stub plays `docker compose run`: it runs the argv +// after the service name on the host and exits with its status. No Docker +// engine, client config or real home directory is touched. +// --------------------------------------------------------------------------- + +const cli = resolve(import.meta.dir, "../index.ts"); +/** Each test runs the source CLI several times; the default 5 s is too short. */ +const TEST_TIMEOUT_MS = 90_000; +const roots: string[] = []; +const clients: Client[] = []; + +afterEach(async () => { + for (const client of clients.splice(0)) { + await client.close(); + } + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +type RunResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "hack-run-exit-")); + roots.push(root); + const project = join(root, "project"); + const bin = join(root, "bin"); + const home = join(root, "home"); + for (const path of [join(project, ".hack"), bin, home]) { + await mkdir(path, { recursive: true }); + } + await writeFile( + join(project, ".hack", "docker-compose.yml"), + "name: run-exit\nservices:\n app:\n image: alpine:3.20\n" + ); + // Internal DNS/TLS off: no CoreDNS/Caddy probes, whose warnings would + // otherwise depend on the host's global Hack state. + await writeFile( + join(project, ".hack", "hack.config.json"), + '{"name":"run-exit","dev_host":"run-exit.hack","internal":{"dns":false,"tls":false}}\n' + ); + const log = join(root, "docker-log.jsonl"); + await writeFile(log, ""); + const docker = join(bin, "docker"); + await writeFile( + docker, + `#!${process.execPath} +import { appendFileSync } from "node:fs"; +import { YAML } from "bun"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + "\\n"); +const run = args.indexOf("run"); +if (args[0] === "compose" && run >= 0) { + const service = args.indexOf("app", run + 1); + const child = Bun.spawnSync(args.slice(service + 1), { + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + process.exit(child.exitCode ?? 1); +} +if (args.includes("config")) { + const merged = { services: {} }; + for (let i = 0; i < args.length; i++) { + if (args[i] === "-f") { + const doc = YAML.parse(await Bun.file(args[++i]).text()); + Object.assign(merged.services, doc.services ?? {}); + } + } + console.log(JSON.stringify(merged)); +} +` + ); + await chmod(docker, 0o755); + const env = privateCliEnv({ + root, + home, + env: { + HACK_HOME: join(home, ".hack"), + PATH: `${bin}:${process.env.PATH ?? ""}`, + HACK_NO_INTERACTIVE: "1", + }, + }); + const collect = async (child: ReturnType) => { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout as ReadableStream).text(), + new Response(child.stderr as ReadableStream).text(), + child.exited, + ]); + return { exitCode, stdout, stderr } satisfies RunResult; + }; + /** `hack run --path app -- ` from the source CLI. */ + const run = async (cmd: readonly string[]) => + await collect( + Bun.spawn( + [process.execPath, cli, "run", "--path", project, "app", "--", ...cmd], + { + cwd: project, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + } + ) + ); + /** The same command piped through `cat`, with and without pipefail. */ + const piped = async (opts: { + readonly cmd: readonly string[]; + readonly pipefail: boolean; + }) => + await collect( + Bun.spawn( + [ + "/bin/bash", + "-c", + `set ${opts.pipefail ? "-o" : "+o"} pipefail; "$@" | cat; echo "pipeline=$? hack=\${PIPESTATUS[0]}"`, + "hack-pipe", + process.execPath, + cli, + "run", + "--path", + project, + "app", + "--", + ...opts.cmd, + ], + { + cwd: project, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + } + ) + ); + /** The argv each `docker compose run` received after the service name. */ + const runArgv = async () => + (await readFile(log, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]) + .filter((args) => args[0] === "compose" && args.includes("run")) + .map((args) => args.slice(args.indexOf("app", args.indexOf("run")) + 1)); + /** An in-process MCP session whose tools run this source CLI. */ + const mcp = async () => { + const wrapper = join(root, "hack-under-test"); + await writeFile( + wrapper, + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"\n` + ); + await chmod(wrapper, 0o755); + const server = createMcpServer({ + cwd: project, + env: { ...env, HACK_MCP_COMMAND: wrapper }, + }); + const client = new Client({ name: "hack-run-exit", version: "1" }); + clients.push(client); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; + }; + return { run, piped, runArgv, mcp }; +} + +function envelope(result: unknown): { + readonly isError: boolean; + readonly ok: unknown; + readonly exitCode: unknown; + readonly stdout: unknown; +} { + const record = result as { + readonly isError?: boolean; + readonly structuredContent?: Record; + }; + return { + isError: record.isError === true, + ok: record.structuredContent?.ok, + exitCode: record.structuredContent?.exitCode, + stdout: record.structuredContent?.stdout, + }; +} + +test( + "hack run exits with the child's status and keeps its argv after --", + async () => { + const f = await fixture(); + const failed = await f.run(["false"]); + expect({ exitCode: failed.exitCode, stdout: failed.stdout }).toEqual({ + exitCode: 1, + stdout: "", + }); + const seven = await f.run(["sh", "-c", "printf partial; exit 7"]); + expect({ exitCode: seven.exitCode, stdout: seven.stdout }).toEqual({ + exitCode: 7, + stdout: "partial", + }); + // Positive control: a successful child is a successful run. + const passed = await f.run(["true"]); + expect(passed.exitCode).toBe(0); + // Hack-looking words after `--` reach the child, not hack's parser. + const flags = await f.run(["sh", "-c", 'exit "$1"', "sh", "--json"]); + expect(flags.exitCode).not.toBe(0); + expect(await f.runArgv()).toEqual([ + ["false"], + ["sh", "-c", "printf partial; exit 7"], + ["true"], + ["sh", "-c", 'exit "$1"', "sh", "--json"], + ]); + }, + TEST_TIMEOUT_MS +); + +test( + "a pipe filter hides a failed child only from the pipeline, never from hack", + async () => { + const f = await fixture(); + // Negative control: without pipefail the filtered pipeline reports success. + const filtered = await f.piped({ cmd: ["false"], pipefail: false }); + expect(filtered.stdout.trim()).toBe("pipeline=0 hack=1"); + const failed = await f.piped({ cmd: ["false"], pipefail: true }); + expect(failed.stdout.trim()).toBe("pipeline=1 hack=1"); + const seven = await f.piped({ + cmd: ["sh", "-c", "printf partial; exit 7"], + pipefail: false, + }); + expect(seven.stdout.trim()).toBe("partialpipeline=0 hack=7"); + }, + TEST_TIMEOUT_MS +); + +test( + "the MCP hack.project.run envelope reports the child's exit status", + async () => { + const f = await fixture(); + const client = await f.mcp(); + const call = async (cmd: readonly string[]) => + envelope( + await client.callTool({ + name: "hack.project.run", + arguments: { service: "app", cmd: [...cmd] }, + }) + ); + expect(await call(["false"])).toEqual({ + isError: true, + ok: false, + exitCode: 1, + stdout: "", + }); + expect(await call(["sh", "-c", "printf partial; exit 7"])).toEqual({ + isError: true, + ok: false, + exitCode: 7, + stdout: "partial", + }); + // Positive control: a successful child is an ok envelope. + expect(await call(["true"])).toEqual({ + isError: false, + ok: true, + exitCode: 0, + stdout: "", + }); + expect((await f.runArgv()).slice(-3)).toEqual([ + ["false"], + ["sh", "-c", "printf partial; exit 7"], + ["true"], + ]); + }, + TEST_TIMEOUT_MS +); diff --git a/tests/update-command.test.ts b/tests/update-command.test.ts index cf684e170..56f65decd 100644 --- a/tests/update-command.test.ts +++ b/tests/update-command.test.ts @@ -20,6 +20,7 @@ const ENV_KEYS = [ "HACK_NATIVE_HOME", "HACK_HOME", "HACK_ASSETS_DIR", + "HACK_CANDIDATE_ENTRYPOINT", ] as const; const STABLE_BYTES = "unrelated standalone stable Hack sentinel\n"; const refuseNetwork: typeof globalThis.fetch = Object.assign( @@ -50,6 +51,7 @@ beforeEach(async () => { process.env.HACK_RUNTIME_BACKEND = "native"; process.env.HACK_NATIVE_BINARY = join(root, "candidate", "hack-native"); process.env.HACK_NATIVE_HOME = join(root, "candidate-home"); + Reflect.deleteProperty(process.env, "HACK_CANDIDATE_ENTRYPOINT"); }); afterEach(async () => { @@ -146,6 +148,39 @@ test("native update --check --tag also refuses with a human diagnostic", async ( expect(await readFile(stable, "utf8")).toBe(STABLE_BYTES); }); +test.each([ + ["hack-next", "compose"], + ["hack-v5", "compose"], + ["hack-next", undefined], +])("candidate launcher %s on backend %p refuses before lookup or fetch", async (entrypoint, backend) => { + // An explicit Compose selection through a candidate launcher drops the native + // marker; the launcher's own marker must still keep update off the stable install. + process.env.HACK_CANDIDATE_ENTRYPOINT = entrypoint; + Reflect.deleteProperty(process.env, "HACK_NATIVE_BINARY"); + Reflect.deleteProperty(process.env, "HACK_NATIVE_HOME"); + if (backend === undefined) { + Reflect.deleteProperty(process.env, "HACK_RUNTIME_BACKEND"); + } else { + process.env.HACK_RUNTIME_BACKEND = backend; + } + const before = await stableIdentity(); + const fetch = spyOn(globalThis, "fetch").mockImplementation(refuseNetwork); + const lookup = spyOn(Bun, "which").mockImplementation(() => { + throw new Error("unexpected stable binary lookup"); + }); + const result = await capturedRun(["update", "--yes", "--json"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: + "Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it.", + }); + expect(fetch).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); + expect(await readFile(stable, "utf8")).toBe(STABLE_BYTES); + expect(await stableIdentity()).toEqual(before); +}); + test.each([ "compose", undefined,