diff --git a/cli/src/services/hooks/commit_patch_id.rs b/cli/src/services/hooks/commit_patch_id.rs index c5fe61507..0556630f9 100644 --- a/cli/src/services/hooks/commit_patch_id.rs +++ b/cli/src/services/hooks/commit_patch_id.rs @@ -369,10 +369,11 @@ mod tests { use super::{ compute_commit_patch_id, compute_commit_patch_id_with, parse_patch_id_output, - CommitPatchIdRunner, + CommitPatchIdRunner, COMMIT_PATCH_ID_DEADLINE, }; - const FAKE_GIT_DEADLINE: Duration = Duration::from_millis(500); + #[cfg(target_os = "linux")] + const FAKE_GIT_TIMEOUT_DEADLINE: Duration = Duration::from_millis(500); #[cfg(target_os = "linux")] const CHILD_EXIT_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(target_os = "linux")] @@ -730,12 +731,12 @@ mod tests { } } - async fn run_fake_git(fake: &FakeGit) -> anyhow::Result> { + async fn run_fake_git(fake: &FakeGit, deadline: Duration) -> anyhow::Result> { let leading_args = [fake.script.as_os_str()]; let runner = CommitPatchIdRunner { program: OsStr::new("/bin/sh"), leading_args: &leading_args, - deadline: FAKE_GIT_DEADLINE, + deadline, ..CommitPatchIdRunner::default() }; compute_commit_patch_id_with(&runner, Path::new("."), FAKE_COMMIT_OID).await @@ -769,7 +770,9 @@ mod tests { #[tokio::test] async fn commit_patch_id_reports_non_zero_git_exit() { let fake = fake_git("exit 3", "cat >/dev/null"); - let error = run_fake_git(&fake).await.expect_err("non-zero show"); + let error = run_fake_git(&fake, COMMIT_PATCH_ID_DEADLINE) + .await + .expect_err("non-zero show"); assert!( error.to_string().contains("git show exited with status 3"), "{error:#}" @@ -779,7 +782,9 @@ mod tests { #[tokio::test] async fn commit_patch_id_reports_malformed_patch_id_output() { let fake = fake_git("echo patch", "cat >/dev/null; echo not-a-patch-id"); - let error = run_fake_git(&fake).await.expect_err("malformed output"); + let error = run_fake_git(&fake, COMMIT_PATCH_ID_DEADLINE) + .await + .expect_err("malformed output"); assert!(error.to_string().contains("malformed"), "{error:#}"); } @@ -788,7 +793,9 @@ mod tests { async fn commit_patch_id_times_out_and_kills_both_git_processes() { let fake = fake_git("exec sleep 30", "exec cat"); let started = std::time::Instant::now(); - let error = run_fake_git(&fake).await.expect_err("timeout"); + let error = run_fake_git(&fake, FAKE_GIT_TIMEOUT_DEADLINE) + .await + .expect_err("timeout"); assert!(error.to_string().contains("deadline"), "{error:#}"); assert!(started.elapsed() < CHILD_EXIT_TIMEOUT); diff --git a/context/architecture.md b/context/architecture.md index 97ac14da6..f9cf7fd14 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -89,7 +89,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.95.0.default` with `rustfmt` + `clippy`, and reads package/check version from repo-root `.version`. Its `cliGeneratedInput` derivation invokes the shared generated-input producer from a declarative source containing the producer and canonical inputs, then publishes the producer-validated handoff as its store output. Native, release, test, and Clippy Cargo derivations receive that same store path through `SCE_CLI_GENERATED_INPUT_DIR`; their Cargo environments exclude Pkl and assert that it is unavailable. Repository-mode `cli/build.rs` validates the handoff before copying it into `OUT_DIR`; published crates use the validated packaging-only fallback. The build script stages SQL under `OUT_DIR/static/migrations` and writes `OUT_DIR/generated_migrations.rs` with deterministic migration constants sorted by numeric filename prefix. - Crane dependency-only derivations and `cli-fmt` intentionally do not receive `SCE_CLI_GENERATED_INPUT_DIR`, so canonical generation changes invalidate the producer and compiling derivations while preserving host/musl dependency artifacts and formatting. The root flake runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed paths and exposes `cli-generated-input` as the focused payload/inventory integrity check. It also exposes directory-scoped JS validation derivations for `npm/` and `config/lib/`, while `pkl-generated` uses a narrow canonical-input source set plus maybe-missing forbidden paths so reintroduced generated repository artifacts fail the check. - Git-commit embedding is release-only. `SCE_GIT_COMMIT` is injected via a `releaseCommitArgs` fragment (`{ SCE_GIT_COMMIT = shortGitCommit; }`) merged only into the release derivations — `scePackageMusl` on Linux and a dedicated native-toolchain `sceReleasePackageNative` on Darwin — and is deliberately absent from `commonCargoArgs`. As a result native `packages.sce`/`packages.default` and the `cli-tests`/`cli-clippy`/`cli-fmt` check derivations carry no commit in their inputs and stay cache-reusable across commits (native `sce version` reports `unknown`), while `.#sce-release` reports the real commit. `cli/build.rs` `emit_git_commit` emits the `rustc-env` value only when `SCE_GIT_COMMIT` is explicitly set (with `rerun-if-env-changed=SCE_GIT_COMMIT` as its sole rerun trigger) — no `git rev-parse` fallback and no `.git/HEAD`/`.git/packed-refs` watches — and `cli/src/services/version/mod.rs` resolves the commit through `option_env!("SCE_GIT_COMMIT")` with an `"unknown"` fallback. On Darwin the release derivation therefore diverges from `.#sce` (shared deps `cargoArtifacts`, but distinct final crate carrying the commit). -- `flake.nix` exposes `packages..ci-checks` (`ciChecks`) as the explicit long-running validation tier so the expensive work lives behind `nix build .#ci-checks` and never folds into `nix flake check`. It is a `runCommand` aggregate that symlinks its primary member `sceReleasePackage` into `$out/sce-release` (forcing the static-musl release build on Linux, native on Darwin) and, on Linux only, symlinks `releasePortabilityAuditCheck` into `$out/release-portability-audit`. That Linux-only `releasePortabilityAuditCheck` derivation runs `nix/release/native-portability-audit.sh --platform linux --binary ${sceReleasePackage}/bin/sce` (with `binutils`+`coreutils` on PATH) so `nix build .#ci-checks` fails when the real release binary carries forbidden `/nix/store/` references. This is distinct from `checks..native-portability-audit` (`nativePortabilityAuditCheck`), which remains a fixture-based unit test of the audit script on both platforms. CI wiring to `.#ci-checks` is owned by the separate `release-validation` matrix job in `.github/workflows/pr-ci.yml`, which builds `nix build .#ci-checks` on every PR/`main`/tag commit. +- `flake.nix` exposes `packages..ci-checks` (`ciChecks`) as the explicit long-running validation tier so the expensive work lives behind `nix build .#ci-checks` and never folds into `nix flake check`. It is a `runCommand` aggregate that symlinks its primary member `sceReleasePackage` into `$out/sce-release` (forcing the static-musl release build on Linux, native on Darwin) and, on Linux only, symlinks `releasePortabilityAuditCheck` into `$out/release-portability-audit`. That Linux-only `releasePortabilityAuditCheck` derivation runs `nix/release/native-portability-audit.sh --platform linux --binary ${sceReleasePackage}/bin/sce` (with `binutils`+`coreutils` on PATH) so `nix build .#ci-checks` fails when the real release binary carries forbidden `/nix/store/` references. The NixOS VM npm install test (`npmNixosVmTest`, `packages.x86_64-linux.npm-nixos-vm`) is not a member; it is run locally only. This is distinct from `checks..native-portability-audit` (`nativePortabilityAuditCheck`), which remains a fixture-based unit test of the audit script on both platforms. CI wiring to `.#ci-checks` is owned by the separate `release-validation` matrix job in `.github/workflows/pr-ci.yml`, which builds `nix build .#ci-checks` on every PR/`main`/tag commit. - Flatpak flake tooling is Linux-only and reduced to a minimal app surface: `apps.sce-flatpak` is the umbrella entrypoint (`nix run .#sce-flatpak -- ` for `validate`, `prepare-local-manifest --repo-root `, etc.) that delegates to `packaging/flatpak/sce-flatpak.sh`; `apps.release-flatpak-package` emits deterministic Flatpak GitHub Release source-manifest assets; `apps.release-flatpak-bundle` builds a source-built `.flatpak` bundle; helper apps `apps.regenerate-flatpak-manifest` and `apps.regenerate-cargo-sources` rewrite the checked-in generated artifacts from their Nix sources. The standalone `flatpak-validate`, `flatpak-local-manifest`, and `flatpak-build` wrapper apps are removed in favor of `sce-flatpak `. `checks..flatpak-static-validation` runs the Nix-built static validator script during default `nix flake check`, and `checks..flatpak-manifest-parity` plus `checks..cargo-sources-parity` enforce the generated-artifact parity contracts; default `nix flake check` does not run `flatpak-builder`. The Linux dev shell includes `appstreamcli`, `flatpak`, and `flatpak-builder`. - The shared config-lib source set is rooted at `config/lib/` and includes the shared `package.json`, `bun.lock`, and `tsconfig.json` plus `agent-trace-plugin/` and `bash-policy-plugin/`; `config-lib-bun-tests` runs the bash-policy plugin wrapper tests from that shared root, while `config-lib-biome-check` and `config-lib-biome-format` run Biome over the copied shared package source. `.github/workflows/publish-crates.yml` copies the repository to a temporary clean workspace, enters that copy's Nix dev shell to generate `cli/package-fallback/`, and runs Cargo publication against the prepared copy so crates.io publish does not mutate repository artifacts. - The config-lib check source preserves repo-relative access to shared CLI patch fixtures: Nix copies a filtered repo-shaped source containing `config/lib/**` plus `cli/src/services/structured_patch/fixtures`, then runs Bun/Biome from `config/lib/`. diff --git a/context/context-map.md b/context/context-map.md index 64b24fa62..758b74e35 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -113,7 +113,7 @@ Feature/domain context: - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) -- `context/sce/cli-npm-distribution-contract.md` (implemented `sce` npm launcher package, release-manifest/checksum-verified native binary install flow with manifest identity checks, bounded validating archive extraction, and atomic `runtime/sce` install, the supported darwin/arm64 plus linux x64+arm64 npm platform matrix, the executable launcher-mode requirement and offline packed-package smoke test (`npm/test/pack-smoke.sh`, flake check `npm-pack-smoke`, and the `release-npm-package` post-pack gate), the prerequisite that npm-consumed native archives are portable and free of forbidden `/nix/store/` runtime references before upload, and dedicated `.github/workflows/publish-npm.yml` downstream npm publish-stage contract including prerelease publication via npm `next` dist-tag instead of `latest`) +- `context/sce/cli-npm-distribution-contract.md` (implemented `sce` npm launcher package, release-manifest/checksum-verified native binary install flow with manifest identity checks, bounded validating archive extraction, and atomic `runtime/sce` install, the supported darwin/arm64 plus linux x64+arm64 npm platform matrix, the executable launcher-mode requirement and offline packed-package smoke test (`npm/test/pack-smoke.sh`, flake check `npm-pack-smoke`, and the `release-npm-package` post-pack gate, plus the opt-in x86_64-linux NixOS VM real-install test `.#npm-nixos-vm`), the prerequisite that npm-consumed native archives are portable and free of forbidden `/nix/store/` runtime references before upload, and dedicated `.github/workflows/publish-npm.yml` downstream npm publish-stage contract including prerelease publication via npm `next` dist-tag instead of `latest`) - `context/sce/cli-cargo-distribution-contract.md` (implemented `sce` Cargo publication posture plus supported crates.io and wrapper-driven local-checkout install guidance, explicit unsupported `cargo install --git` boundary, dedicated crates.io publish workflow with semver prerelease guard, canonical generated-input declaration/producer ownership consumed by the Cargo wrapper, generated-output check, package-fallback preparation, and Nix pre-Cargo derivation, plus the deterministic combined checksummed fallback used for Pkl-free downstream crate builds) - `context/sce/flake-build-performance.md` (current native-vs-release package split, release-only commit embedding, shared-producer-backed pre-Cargo generated-input derivation consumed by native/release/test/Clippy but excluded from dependency/format derivations, slim default/database devShell contract, normal-vs-expensive CI tiers, durable before/after benchmark summary, validation evidence, and remaining cold-build trade-offs) - `context/sce/config-schema-publication.md` (current embedded-only publication contract for the generated SCE config JSON Schema and the canonical SCE web application URL used in config declarations) diff --git a/context/overview.md b/context/overview.md index 77dd982bc..0eaa0537f 100644 --- a/context/overview.md +++ b/context/overview.md @@ -151,7 +151,7 @@ Lightweight post-task verification baseline (required after each completed task) - For architecture decisions: `context/decisions/` - No generated snapshot-parity workflow is checked in; deterministic ephemeral generation is enforced through `nix run .#pkl-check-generated` and the root `nix flake check` `pkl-generated` derivation. GitHub Actions workflow lint is enforced through the root `nix flake check` `workflow-actionlint` derivation (all `.github/workflows/*.yml`). -- PR validation runs through `.github/workflows/pr-ci.yml` on `pull_request`, `push` to `main`, and `workflow_dispatch` and now defines two matrix jobs. A workflow-level concurrency group (`${{ github.workflow }}-${{ github.ref }}`, `cancel-in-progress: true`) cancels superseded runs on the same ref, and every matrix job installs Nix via `DeterminateSystems/nix-installer-action@v22`, enables `DeterminateSystems/magic-nix-cache-action@v14` immediately after, runs on an `ubuntu-latest` + `macos-latest` matrix (`fail-fast: false`), and has a `timeout-minutes: 90` guard. The `nix-ci` job (name `Nix CI (ubuntu-latest)` / `Nix CI (macos-latest)`, for required branch protection checks) runs timed `nix flake check --print-build-logs`, then builds and smoke-tests the **native** package via timed `nix build .#sce --out-link result --print-build-logs` plus `./result/bin/sce --help` / `./result/bin/sce version` — it no longer builds the musl release (`.#default`/`.#sce` is native since the flake-speedup split). A separate `release-validation` job (name `Release validation (ubuntu-latest)` / `Release validation (macos-latest)`) builds `nix build .#ci-checks --out-link result-ci-checks --print-build-logs` on every commit, which forces the `.#sce-release` static-musl build and, on Linux, the real-binary portability audit guarded inside `ciChecks`; macOS builds the native-on-Darwin release package. This always-on release coverage on every PR/push (no path filters) is the intentional trade-off recorded in the plan Decisions section. The Nix store cache is the only cache layer; no separate Cargo/Bun/npm/`node_modules` caches. Release-publish workflows (`release-sce*.yml`) build the release binary via `nix run .#release-artifacts` (which targets `.#sce-release`), not `.#default`. +- PR validation runs through `.github/workflows/pr-ci.yml` on `pull_request`, `push` to `main`, and `workflow_dispatch` and now defines two matrix jobs. A workflow-level concurrency group (`${{ github.workflow }}-${{ github.ref }}`, `cancel-in-progress: true`) cancels superseded runs on the same ref, and every matrix job installs Nix via `DeterminateSystems/nix-installer-action@v22`, enables `DeterminateSystems/magic-nix-cache-action@v14` immediately after, runs on an `ubuntu-latest` + `macos-latest` matrix (`fail-fast: false`), and has a `timeout-minutes: 90` guard. The `nix-ci` job (name `Nix CI (ubuntu-latest)` / `Nix CI (macos-latest)`, for required branch protection checks) runs timed `nix flake check --print-build-logs`, then builds and smoke-tests the **native** package via timed `nix build .#sce --out-link result --print-build-logs` plus `./result/bin/sce --help` / `./result/bin/sce version` — it no longer builds the musl release (`.#default`/`.#sce` is native since the flake-speedup split). A separate `release-validation` job (name `Release validation (ubuntu-latest)` / `Release validation (macos-latest)`) builds `nix build .#ci-checks --out-link result-ci-checks --print-build-logs` on every commit, which forces the `.#sce-release` static-musl build and, on Linux, the real-binary portability audit guarded inside `ciChecks`. The local-only `.#npm-nixos-vm` NixOS VM npm install test is not part of CI because hosted runners lack `/dev/kvm`. macOS builds the native-on-Darwin release package. This always-on release coverage on every PR/push (no path filters) is the intentional trade-off recorded in the plan Decisions section. The Nix store cache is the only cache layer; no separate Cargo/Bun/npm/`node_modules` caches. Release-publish workflows (`release-sce*.yml`) build the release binary via `nix run .#release-artifacts` (which targets `.#sce-release`), not `.#default`. - The generated SCE config JSON Schema is produced ephemerally through the canonical `nix run .#pkl-generate` path and embedded into the CLI or packaging fallback. Release snapshots are checked in under `schema/v/config.json`; see `context/sce/config-schema-publication.md`. ## Cross-target parity diff --git a/context/patterns.md b/context/patterns.md index 454d9c362..466d6a056 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -15,7 +15,7 @@ - Keep direct Cargo verification commands as secondary targeted-debugging tools rather than the default repo-validation path. - Keep `cargo fmt` available for explicit autofix formatting flows; do not present it as the preferred verification command. - Gate workflow YAML edits through `checks..workflow-actionlint` inside root `nix flake check`; run `nix run nixpkgs#actionlint -- .github/workflows/*.yml` for targeted workflow lint during CI workflow edits. -- Keep PR validation in `.github/workflows/pr-ci.yml` (`Nix CI`): triggers on `pull_request`, `push` to `main`, and `workflow_dispatch`; two matrix jobs (`nix-ci` and `release-validation`), each on an `ubuntu-latest` + `macos-latest` matrix with `fail-fast: false`; pinned `DeterminateSystems/nix-installer-action@v22` with `DeterminateSystems/magic-nix-cache-action@v14` immediately after install; workflow-level concurrency (`${{ github.workflow }}-${{ github.ref }}`, `cancel-in-progress: true`) and job `timeout-minutes: 90`. The `nix-ci` quality gates are timed `nix flake check --print-build-logs`, timed `nix build .#sce --out-link result --print-build-logs` (the **native** package), and smoke tests against `./result/bin/sce` from that already-built package (no diagnostic `nix flake metadata` step and no separate `nix run` smoke evaluations); it does not build the musl release. The `release-validation` job builds `nix build .#ci-checks --out-link result-ci-checks --print-build-logs` on every commit, forcing the `.#sce-release` build plus the Linux-guarded real-binary portability audit. Pin third-party Actions to explicit version tags, not `@main`. +- Keep PR validation in `.github/workflows/pr-ci.yml` (`Nix CI`): triggers on `pull_request`, `push` to `main`, and `workflow_dispatch`; two matrix jobs (`nix-ci` and `release-validation`), each on an `ubuntu-latest` + `macos-latest` matrix with `fail-fast: false`; pinned `DeterminateSystems/nix-installer-action@v22` with `DeterminateSystems/magic-nix-cache-action@v14` immediately after install; workflow-level concurrency (`${{ github.workflow }}-${{ github.ref }}`, `cancel-in-progress: true`) and job `timeout-minutes: 90`. The `nix-ci` quality gates are timed `nix flake check --print-build-logs`, timed `nix build .#sce --out-link result --print-build-logs` (the **native** package), and smoke tests against `./result/bin/sce` from that already-built package (no diagnostic `nix flake metadata` step and no separate `nix run` smoke evaluations); it does not build the musl release. The `release-validation` job builds `nix build .#ci-checks --out-link result-ci-checks --print-build-logs` on every commit, forcing the `.#sce-release` build plus the Linux-guarded real-binary portability audit; the NixOS VM npm test (`.#npm-nixos-vm`) stays local-only because hosted runners lack `/dev/kvm`. Pin third-party Actions to explicit version tags, not `@main`. ## Root Biome scoping diff --git a/context/plans/npm-nixos-vm-integration-test.md b/context/plans/npm-nixos-vm-integration-test.md new file mode 100644 index 000000000..046c16a88 --- /dev/null +++ b/context/plans/npm-nixos-vm-integration-test.md @@ -0,0 +1,205 @@ +# Plan: npm-nixos-vm-integration-test + +## Change summary + +Extends the hardened npm installer work (PR #344, plan `harden-npm-installer`) with a production-representative NixOS VM integration test. `npm/test/pack-smoke.sh` gains a real-binary mode (`--binary `) that wraps the Nix-built `sce-release` executable in the canonical `sce-v-/{bin/sce,LICENSE,README.md}` archive layout, signs a manifest with a throwaway key swapped only into the staged package copy, serves it through the existing `fixture-release-server.mjs`, and runs the real `npm pack → npm install → postinstall → signed manifest verification → archive download → checksum verification → extraction → atomic install` path before executing the npm-generated `sce` launcher directly. The existing fixture (stub binary) mode stays the default and remains in `nix flake check` as `npm-pack-smoke`. + +A new `pkgs.testers.runNixOSTest` definition under `nix/tests/` boots a minimal NixOS VM, runs real-binary mode as a non-root user with an isolated HOME, npm cache, and prefix, and asserts launcher/binary modes, direct shebang execution, `sce --help`, and `sce version --format json` matching `.version`. Because NixOS has no FHS `/lib64` loader or `/usr/lib` glibc, successful execution proves the static-musl binary needs no unavailable dynamic libraries. The VM test is x86_64-linux only and local-only: it stays out of the fast `nix flake check` tier and, after hosted `ubuntu-latest` runners proved to lack `/dev/kvm` (PR CI run 41700381363), out of `ciChecks` and CI as well (T05). The installer architecture, production public key, and verification code are unchanged. + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [x] AC1: Fixture mode is unchanged in behavior and still runs in the fast tier: the `npm-pack-smoke` flake check passes with the stub binary and is still a member of `checks.`. + - Validate: `nix build .#checks.x86_64-linux.npm-pack-smoke --print-build-logs` passes; `nix eval .#checks.x86_64-linux --apply builtins.attrNames` lists `npm-pack-smoke` and does not list `npm-nixos-vm`. +- [x] AC2: Real-binary mode installs the actual release binary through the full postinstall path and executes it: given `--binary /bin/sce`, the smoke packs the package, installs it with lifecycle scripts enabled and without `SCE_NPM_SKIP_DOWNLOAD`, and asserts `runtime/sce` mode `0755`, an executable packed `package/bin/sce.js`, a `.bin/sce` link resolving into the installed `@crocoder-dev/sce` package, exit 0 from `sce --help`, and `sce version --format json` `.version` equal to repo `.version`. + - Validate: `nix build .#sce-release && nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary ./result/bin/sce` exits 0 and prints the reported version; `grep -n 'release-manifest-public-key.pem' npm/test/pack-smoke.sh` shows key substitution only in the staged copy and `git diff main -- npm/lib` is empty. +- [x] AC3: The NixOS VM test boots a clean VM and, as a non-root user with isolated HOME/npm cache/prefix and no external network, installs the real packed package and runs the npm-generated `sce` launcher directly (shebang execution, not `node sce.js`), with `sce --help` succeeding and parsed `sce version --format json` `.version` equal to `.version`. + - Validate: `nix build .#npm-nixos-vm --print-build-logs` passes and the log shows the VM test steps, the installed modes, and the parsed version; inspect `nix/tests/npm-nixos-vm.nix` for a non-root user, no `SCE_NPM_SKIP_DOWNLOAD`, no manual copy/chmod into `runtime/`, and no network beyond loopback. +- [ ] AC4: `nix build .#ci-checks` on x86_64-linux is unchanged by this plan: it builds `sce-release` and the release portability audit and does not include `npm-nixos-vm`; other systems' `ci-checks` evaluate. + - Validate: `nix build .#ci-checks --print-build-logs` passes on x86_64-linux and `ls -l result` shows only `sce-release` and `release-portability-audit`; `nix eval .#packages.aarch64-darwin.ci-checks.drvPath` still evaluates. +- [ ] AC5: CI does not run or require KVM: `.github/workflows/pr-ci.yml` has no KVM enablement step and no `kvm`/`nixos-test` system features, and the NixOS VM test is documented as a local-only run. + - Validate: `grep -n -i 'kvm\|nixos-test' .github/workflows/pr-ci.yml` finds nothing; `nix build .#checks.x86_64-linux.workflow-actionlint` passes; AC8 inspection confirms the local-only wording. +- [x] AC6: The tests fail for the right reason under injected faults: a packed launcher without its executable bit fails at the launcher-mode assertion; a non-executable/invalid `--binary` fails at the `sce --help` execution step; an installer that does not produce `runtime/sce` fails at the `runtime/sce` assertion. Faults are injected only in temporary worktrees or fixtures and the original state is restored. + - Validate: plan task evidence records each injected fault, the command run, and the exact failing `pack-smoke:` message; `git status` is clean afterward. +- [x] AC7: Existing installer negative tests still reject invalid signatures, mismatched versions, bad checksums, malicious archive entries, and leave no partial `runtime/sce` on failure. + - Validate: `nix build .#checks.x86_64-linux.npm-bun-tests --print-build-logs` passes. +- [ ] AC8: Durable docs describe fixture vs VM testing, why the VM uses the real musl binary, what the VM guarantees, what remains macOS/ARM64-release-only, how to run it locally, and that it is local-only because hosted CI runners lack KVM. + - Validate: inspect `context/sce/cli-npm-distribution-contract.md` and `context/sce/flake-build-performance.md` for those points and matching command names. + +### Full validation + +Repository-wide checks `/validate` runs after the last task, regardless of +which criterion they map to. + +- `nix flake check` +- `nix build .#ci-checks --print-build-logs` + +### Context sync + +- `context/sce/cli-npm-distribution-contract.md` (packed-package smoke modes, NixOS VM test, verification baseline) +- `context/sce/flake-build-performance.md` (`ci-checks` membership and CI tier/KVM requirement) +- `context/context-map.md` (annotation for the npm contract entry, if its summary changes) + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `npm/test/pack-smoke.sh`, new `nix/tests/npm-nixos-vm.nix`, root `flake.nix` (VM test wiring, `ciChecks`, file sets), `.github/workflows/pr-ci.yml` (Linux `release-validation` KVM enablement), `context/sce/cli-npm-distribution-contract.md`, `context/sce/flake-build-performance.md`, `context/context-map.md`. +- **Out of scope:** changes to `npm/lib/*.js`, `npm/bin/sce.js`, the production public key, `fixture-release-server.mjs` behavior beyond what real-binary mode strictly needs, release packaging apps, macOS CI steps, aarch64 VM tests, publishing. +- **Constraints:** no Docker; no external network (all artifacts from Nix inputs or the loopback fixture server); reuse the existing signing, archive, and HTTPS server logic in `pack-smoke.sh` rather than copying it; no `SCE_NPM_SKIP_DOWNLOAD=1`; no manual copy or chmod into `runtime/`; do not weaken signature, identity, or checksum verification; `sceReleasePackage` referenced, never rebuilt with different arguments. +- **Non-goal:** a generic VM test framework, or repeating unit-level negative installer tests inside the VM. + +## Assumptions + +- The VM test is exposed as `packages.x86_64-linux.npm-nixos-vm` (built via `nix build .#npm-nixos-vm`) rather than `checks.x86_64-linux.npm-nixos-vm`, because every `checks.` member is built by `nix flake check` and the request requires the VM to stay out of the fast tier. This mirrors the existing opt-in `mutation-trace-quint-connect` package precedent. +- `nix/tests/` does not exist yet; the plan creates it with one file, `nix/tests/npm-nixos-vm.nix`, called from `flake.nix` with `sceReleasePackage` and the npm source as arguments. +- Real-binary mode reads the expected version from repo `.version` when available (passed by the caller), falling back to the staged `package.json` version; release-npm-package already enforces `.version` = `npm/package.json` = `cli/Cargo.toml`. +- Real-binary mode copies the given binary into the archive (preserving `0755`) with fixture LICENSE/README files, since the archive reader only writes `bin/sce` and the layout, not the file contents, is the contract under test. +- The npm smoke assertions added for `runtime/sce` mode `0755` and the `.bin/sce` link target apply in both modes, so the fast tier also guards them. +- GitHub-hosted `ubuntu-latest` runners expose `/dev/kvm` after the documented udev rule, and Nix needs `system-features = nixos-test benchmark big-parallel kvm` via the installer's `extra-conf`. + +## Task stack + +- [x] T01: `Add real-binary mode and install-layout assertions to pack-smoke.sh` (status:done) + - Task ID: T01 + - Scope: In — `npm/test/pack-smoke.sh`: `--binary ` (and an optional `--expected-version` / `--prefix` if needed by the VM), real-binary archive staging in the canonical layout, `sce --help` exit-status and `sce version --format json` `.version` assertions in real mode, `runtime/sce` mode `0755` and `.bin/sce` → installed package resolution assertions in both modes; usage text. Out — any change to `npm/lib`, `npm/bin`, the production key, or `fixture-release-server.mjs` signing/serving logic; flake wiring. + - Dependencies: none + - Done when: default invocation behaves as before plus the new layout assertions; `--binary` drives the same signing/server/install path with the real binary; no signing, archive, or server logic is duplicated; shellcheck clean. + - Verify: `nix build .#checks.x86_64-linux.npm-pack-smoke --print-build-logs`; `nix build .#sce-release && nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary ./result/bin/sce`; `nix shell nixpkgs#shellcheck -c shellcheck npm/test/pack-smoke.sh`; in a temporary worktree inject (a) `chmod -x npm/bin/sce.js` with `--package-dir`, (b) `--binary` pointing at a non-executable/garbage file, (c) an installer that skips the rename, and record each failing message. + - Completed: 2026-11-03 + - Files changed: `npm/test/pack-smoke.sh` + - Result: Added `--binary ` (copies the given executable as `bin/sce` mode 0755 into the canonical `sce-v-/{bin/sce,LICENSE,README.md}` archive; all signing, manifest, pack, loopback-server, and install steps are shared with fixture mode), `--expected-version ` (default: staged `package.json` version), and `--prefix ` (persistent install prefix for the T02 VM assertions). Both modes now assert `runtime/sce` mode `755` (via `node fs.statSync`, portable to macOS) and that `node_modules/.bin/sce` realpath equals the installed `@crocoder-dev/sce/bin/sce.js`. Real mode asserts `sce --help` exit 0 and `sce version --format json` `.version` == expected version; fixture mode keeps its exact stub-output assertions. Usage text updated. No change to `npm/lib`, `npm/bin`, the production key, or `fixture-release-server.mjs`; key substitution stays confined to `$stage_dir/lib/release-manifest-public-key.pem`. + - Verify: + - `nix build .#checks.x86_64-linux.npm-pack-smoke --print-build-logs`: passed (log shows `installed runtime/sce mode 755`, `.bin/sce resolves to …/@crocoder-dev/sce/bin/sce.js`, `OK (sce --help, sce version)`). + - `nix build .#sce-release` + `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary /bin/sce`: passed, exit 0, `real binary reported version 0.4.0-pre-alpha-v12`; also passed with `--expected-version "$(cat .version)" --prefix ` (installed `runtime/sce` retained as `-rwxr-xr-x`). `--expected-version 9.9.9` fails with `pack-smoke: sce version reported '0.4.0-pre-alpha-v12'; expected '9.9.9'.` + - `nix shell nixpkgs#shellcheck -c shellcheck npm/test/pack-smoke.sh`: passed (no findings). + - Fault (a), scratch copy of `npm/` with `chmod -x bin/sce.js`, `--package-dir`: `pack-smoke: Packed package/bin/sce.js has mode -rw-r--r--; expected an executable mode (e.g. -rwxr-xr-x). Ensure npm/bin/sce.js is committed as Git mode 100755.` + - Fault (b), `--binary` pointing at a non-executable truncated-ELF garbage file: `pack-smoke: sce --help exited non-zero.` + - Fault (c), scratch copy of `npm/` with `installBinaryFile(...)` removed from `lib/install.js`, `--package-dir`: `pack-smoke: Installer did not produce …/node_modules/@crocoder-dev/sce/runtime/sce.` + - `git status` afterward shows only `npm/test/pack-smoke.sh` and this plan changed. + - Deviations: The first fault (b) attempt used random bytes. libuv's `execvp` falls back to `/bin/sh` on ENOEXEC, so the shell interpreted the bytes and created two empty stray files in the caller's cwd. Both were removed, and the fault was re-run from a scratch cwd with a deterministic truncated-ELF file. T02 should run garbage-binary faults from a throwaway cwd. + - Context impact: localized — npm packed-package smoke test surface (`context/sce/cli-npm-distribution-contract.md` smoke-mode description); full documentation is owned by T04. + - Context synchronization: synced + +- [x] T02: `Add NixOS VM integration test for the real npm install` (status:done) + - Task ID: T02 + - Scope: In — new `nix/tests/npm-nixos-vm.nix` using `pkgs.testers.runNixOSTest` (minimal node with `nodejs`, `openssl`, `gnutar`, `gzip`, `coreutils`, `jq`; non-root user; no networking beyond loopback; testScript running `pack-smoke.sh --binary ${sceReleasePackage}/bin/sce` as that user with isolated HOME/cache/prefix, then asserting modes, direct launcher execution, and parsed version); `flake.nix` exposure as `packages.x86_64-linux.npm-nixos-vm` only. Out — `ciChecks` membership and CI workflow changes (T03); aarch64/darwin. + - Dependencies: T01 + - Done when: `nix build .#npm-nixos-vm` boots the VM, completes the real install and direct CLI execution, and passes; the attribute does not evaluate on non-x86_64-linux systems; `nix flake check` does not build it. + - Verify: `nix build .#npm-nixos-vm --print-build-logs` (requires KVM locally); `nix eval .#checks.x86_64-linux --apply builtins.attrNames`; `nix flake check --no-build`; temporary-worktree fault: VM run with a broken `--binary` fails at the execution step. + - Completed: 2026-11-03 + - Files changed: `nix/tests/npm-nixos-vm.nix` (new), `flake.nix` + - Result: Added `nix/tests/npm-nixos-vm.nix`, a `pkgs.testers.runNixOSTest` with one node. The node has `bash`, `coreutils`, `gnutar`, `gzip`, `jq`, `nodejs`, and `openssl`, a non-root `sce-tester` user, no DHCP, no interfaces, and `virtualisation.vlans = [ ]` (loopback only). The testScript checks that the user's uid is not 0, then copies `npmSrc/npm` into the user's home. As that user, with HOME, TMPDIR, `npm_config_cache`, and `npm_config_userconfig` isolated, it runs `pack-smoke.sh --binary ${sceReleasePackage}/bin/sce --expected-version <.version> --prefix ~/prefix`. It then asserts: `runtime/sce` mode `755`, an executable `.bin/sce` that resolves to the installed `bin/sce.js`, a direct shebang run of `.bin/sce --help`, and `sce version --format json | jq -er .version` equal to `.version`. There is no `SCE_NPM_SKIP_DOWNLOAD` and no manual copy or chmod into `runtime/`. `flake.nix` imports the test with `pkgs`, `npmSrc`, `sceReleasePackage`, and `expectedVersion = version`, and exposes it only as `packages.x86_64-linux.npm-nixos-vm` via `optionalAttrs (system == "x86_64-linux")`. On user request, the explanatory comments added in the new file and in `flake.nix` were removed. + - Verify: + - `nix build .#npm-nixos-vm --print-build-logs`: passed. This host has no `/dev/kvm`, so QEMU logged `failed to initialize kvm` and ran under TCG; the test script finished in 115s. The log shows `pack-smoke: installed runtime/sce mode 755`, `.bin/sce resolves to /home/sce-tester/prefix/node_modules/@crocoder-dev/sce/bin/sce.js`, `real binary reported version 0.4.0-pre-alpha-v12`, `runtime/sce mode: 755`, `launcher mode: 755`, and `parsed version: 0.4.0-pre-alpha-v12`. pack-smoke did not use its `node` fallback: `/usr/bin/env` exists, so the shebang ran directly. Removing the comments left the drv path unchanged (`d09iqhh4…-vm-test-run-npm-nixos-vm.drv`). + - `nix eval .#checks.x86_64-linux --apply builtins.attrNames`: lists `npm-pack-smoke` and does not list `npm-nixos-vm`. `nix eval .#packages.x86_64-darwin --apply 'p: builtins.hasAttr "npm-nixos-vm" p'` returns `false`, and the `aarch64-darwin` package names do not include it. + - `nix flake check --no-build`: passed (`all checks passed!`). + - Fault: a scratch copy of `npm/` and `nix/tests/` was imported with `sceReleasePackage` replaced by a derivation whose `bin/sce` is a truncated 7-byte ELF header. The VM failed at the install subtest with `pack-smoke: sce --help exited non-zero.` (test `install the packed package through postinstall with the real binary` failed). The repo worktree was not touched. + - Deviations: The new file is registered with `git add -N` (intent-to-add) so the flake can see it. The first fault-injection attempt used `builtins.getFlake` on the working directory and stalled copying the tree; it was stopped and re-run from a minimal scratch copy. + - Context impact: localized. Adds the opt-in `.#npm-nixos-vm` package (x86_64-linux), relevant to `context/sce/cli-npm-distribution-contract.md` and `context/sce/flake-build-performance.md`. Full documentation is owned by T04, and `ciChecks` membership by T03. + - Context synchronization: synced + +- [x] T03: `Run the NixOS VM test in ci-checks with KVM on Linux CI` (status:done) + - Task ID: T03 + - Scope: In — `flake.nix` `ciChecks` links `npm-nixos-vm` on x86_64-linux next to `release-portability-audit`; `.github/workflows/pr-ci.yml` `release-validation` Linux leg enables `/dev/kvm` and passes `extra-conf` system features to the Nix installer, failing if KVM is missing. Out — macOS steps, `nix-ci` job, new runners unless the hosted runner is proven unable to run the test. + - Dependencies: T02 + - Done when: `nix build .#ci-checks` on x86_64-linux builds `sce-release` once and runs the VM test; Darwin `ci-checks` evaluation unchanged; workflow passes actionlint. + - Verify: `nix build .#ci-checks --print-build-logs`; `nix eval .#packages.aarch64-darwin.ci-checks.drvPath`; `nix build .#checks.x86_64-linux.workflow-actionlint`. + - Completed: 2026-11-03 + - Files changed: `flake.nix`, `.github/workflows/pr-ci.yml` + - Result: `ciChecks` now links `npm-nixos-vm` (`npmNixosVmTest`) on x86_64-linux only, using `optionalString (system == "x86_64-linux")`. The new guard is concatenated onto the existing Linux audit guard, so the build script and drv are byte-identical on other systems. In `.github/workflows/pr-ci.yml`, the `release-validation` job gets an `Enable KVM` step, gated on `matrix.os == 'ubuntu-latest'`. It installs the `99-kvm4all` udev rule, reloads and triggers it, and exits 1 with `::error::/dev/kvm is not available; the NixOS VM test requires KVM.` when `/dev/kvm` is not readable and writable. The Install Nix step passes `extra-conf: system-features = nixos-test benchmark big-parallel kvm` on ubuntu only (empty on macOS). The `nix-ci` job and the macOS steps are unchanged. On user request, the explanatory comment above `ciChecks` and the new workflow comment were removed. + - Verify: + - `nix build .#ci-checks --print-build-logs`: passed (exit 0). `result` contains `sce-release`, `release-portability-audit`, and `npm-nixos-vm`, and its out path equals `nix eval .#packages.x86_64-linux.ci-checks.outPath` (`9zpgkyb9…-sce-ci-checks`). The log shows the portability audit passing on `dm1zsfzx…-sce-0.4.0-pre-alpha-v12/bin/sce`, plus the VM test lines `pack-smoke: installed runtime/sce mode 755`, `real binary reported version 0.4.0-pre-alpha-v12`, `runtime/sce mode: 755`, `launcher mode: 755`, `parsed version: 0.4.0-pre-alpha-v12`, and `test script finished in 109.70s`. This host has no `/dev/kvm` (QEMU logged `failed to initialize kvm`), so the VM ran under TCG. `nix-store -qR` on the `ci-checks` drv finds a single `sce-0.4.0-pre-alpha-v12.drv` (`yc4j00d7…`), referenced by both `vm-test-run-npm-nixos-vm.drv` and `sce-release-portability-audit.drv`. + - `nix eval .#packages.aarch64-darwin.ci-checks.drvPath`: evaluates to `zxz1djgg…-sce-ci-checks.drv`, identical to the HEAD worktree. `x86_64-darwin` (`cc5cbl22…`) and `aarch64-linux` (`9w6kpm8m…`) are also identical to HEAD. + - `nix build .#checks.x86_64-linux.workflow-actionlint`: passed (also re-run after the comment removal). + - Deviations: The first guard was placed on its own line. That added a blank line to the Darwin build script and changed the aarch64-darwin drv. It was folded onto the existing `optionalString` line to keep non-x86_64-linux drvs unchanged. KVM availability on hosted `ubuntu-latest` is still proven only by a PR CI run (see Open questions). + - Context impact: localized. `ci-checks` membership on x86_64-linux and the KVM requirement of the Linux `release-validation` job affect `context/sce/flake-build-performance.md` and `context/sce/cli-npm-distribution-contract.md`. Full documentation is owned by T04. + - Context synchronization: synced + +- [x] T04: `Document fixture smoke versus NixOS VM npm integration testing` (status:done) + - Task ID: T04 + - Scope: In — `context/sce/cli-npm-distribution-contract.md` (both smoke modes, VM guarantees, real musl rationale, macOS/ARM64 release-only coverage, local run command, KVM requirement, verification baseline); `context/sce/flake-build-performance.md` (`ci-checks` membership, KVM runner requirement); `context/context-map.md` annotation if needed. Out — README marketing changes, unrelated context edits. + - Dependencies: T03 + - Done when: docs state every point in AC8 concisely and their command names match the flake. + - Verify: inspect the two context files; `grep -n 'npm-nixos-vm' context/sce/*.md flake.nix`. + - Completed: 2026-11-03 + - Files changed: `context/sce/cli-npm-distribution-contract.md`, `context/sce/flake-build-performance.md` + - Result: The earlier T01–T03 syncs already documented both smoke modes, the VM's guarantees and musl/no-FHS rationale, `ci-checks` membership, the CI KVM enablement, and the verification baseline. This task filled the remaining AC8 gaps. The npm contract now has three new bullets in its packed-package smoke section: (1) fixture mode versus the VM, including why the VM needs the real static-musl binary (the stub cannot reveal native linkage problems); (2) the VM covers only `linux/x64`, while `linux/arm64` and `darwin/arm64` remain covered only by the release pipeline's portability audits and extracted-binary smoke; (3) the local run command with KVM/TCG behavior, and the CI path through `.#ci-checks` in `release-validation (ubuntu-latest)`: KVM enabled by a udev rule, a hard failure when KVM is missing, the `nixos-test benchmark big-parallel kvm` system features, and no VM on the macOS leg. `flake-build-performance.md` gained the standalone local build command and its non-KVM behavior. `context/context-map.md` already names `.#npm-nixos-vm` accurately and was left unchanged. + - Verify: + - Inspection of both context files: every AC8 point is present (fixture vs VM, real musl rationale, VM guarantees, macOS/ARM64 release-only coverage, local run command, KVM/runner requirements). + - `grep -n 'npm-nixos-vm' context/sce/*.md flake.nix`: the docs reference `.#npm-nixos-vm`, `packages.x86_64-linux.npm-nixos-vm`, and `nix/tests/npm-nixos-vm.nix`, matching `flake.nix` (`npm-nixos-vm = npmNixosVmTest;`, the `$out/npm-nixos-vm` link in `ci-checks`, and the `./nix/tests/npm-nixos-vm.nix` import). The workflow wording matches `.github/workflows/pr-ci.yml` lines 82–93. + - Deviations: none. + - Context impact: localized. Documentation-only within `context/sce/`; no code, interface, or terminology change. + - Context synchronization: synced + +- [x] T05: `Drop the NixOS VM test and KVM enablement from CI; keep it local-only` (status:done) + - Task ID: T05 + - Scope: In — `flake.nix` (remove the `npm-nixos-vm` link from `ciChecks`; keep `packages.x86_64-linux.npm-nixos-vm`), `.github/workflows/pr-ci.yml` (remove the `Enable KVM` step and the `extra-conf` system features), and the context docs that described CI coverage of the VM (`context/sce/cli-npm-distribution-contract.md`, `context/sce/flake-build-performance.md`, `context/overview.md`, `context/patterns.md`, `context/glossary.md`, `context/architecture.md`). Out — `nix/tests/npm-nixos-vm.nix`, `npm/test/pack-smoke.sh`. + - Dependencies: T04 + - Reason: PR CI run `41700381363`, job `126141783713` (`Release validation (ubuntu-latest)`, commit `41bdb5e9`) failed at `Enable KVM` with `/dev/kvm is not available; the NixOS VM test requires KVM.` The user decided to drop KVM from CI and run the VM test locally only. + - Done when: `ciChecks` no longer includes the VM test; the workflow matches its pre-T03 state; docs describe the VM test as local-only; AC4/AC5/AC8 amended accordingly. + - Verify: `nix build .#checks.x86_64-linux.workflow-actionlint`; `nix build .#ci-checks` and `ls result/`; `nix flake check`; `nix build .#npm-nixos-vm` still builds. + - Completed: 2026-11-03 + - Files changed: `flake.nix`, `.github/workflows/pr-ci.yml`, `context/sce/cli-npm-distribution-contract.md`, `context/sce/flake-build-performance.md`, `context/overview.md`, `context/patterns.md`, `context/glossary.md`, `context/architecture.md`, this plan + - Context synchronization: synced + +## Open questions + +- Resolved by T05: hosted `ubuntu-latest` runners do not provide `/dev/kvm` (PR CI run 41700381363), so the VM test is local-only. Originally: hosted runners were expected to provide KVM after a udev rule, but this is only proven by an actual PR CI run. If the run shows KVM is unavailable or the VM is too slow for the 90-minute `release-validation` budget, choosing a dedicated or larger runner is a cost decision for the maintainers rather than something T03 can settle alone. + +## Validation Report + +**Status:** failed +**Date:** 2026-11-03 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed; x86_64-linux only, other systems omitted as incompatible) +- `nix build .#ci-checks --print-build-logs` -> exit 0 (`result/` links `sce-release`, `release-portability-audit`, `npm-nixos-vm`; portability audit passed; VM test finished in 104.78s under TCG because this host has no `/dev/kvm`) +- `nix build .#checks.x86_64-linux.npm-pack-smoke .#checks.x86_64-linux.npm-bun-tests .#checks.x86_64-linux.workflow-actionlint` -> exit 0 (all three passed) +- `nix eval .#checks.x86_64-linux --apply builtins.attrNames` -> exit 0 (lists `npm-pack-smoke`; no `npm-nixos-vm`) +- `nix build .#sce-release` + `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary /bin/sce` -> exit 0 (`installed runtime/sce mode 755`, `.bin/sce resolves to …/@crocoder-dev/sce/bin/sce.js`, `real binary reported version 0.4.0-pre-alpha-v12`, `OK (real binary: sce --help, sce version --format json)`; run from a scratch cwd, no stray files) +- `grep -n 'release-manifest-public-key.pem' npm/test/pack-smoke.sh` -> exit 0 (single hit, line 153, writes only `$stage_dir/lib/…`) +- `git diff 77a84b60 HEAD -- npm/lib npm/bin` -> exit 0 (empty; this plan's commits do not touch `npm/lib` or `npm/bin`) +- `nix-store -qR` on the `ci-checks` drv -> exit 0 (one `sce-0.4.0-pre-alpha-v12.drv`, `5f9rgjkl…`, in the closure of both `vm-test-run-npm-nixos-vm.drv` and `sce-release-portability-audit.drv`) +- `nix eval .#packages.aarch64-darwin.ci-checks.drvPath` -> exit 0 (evaluates to `506gxj81…-sce-ci-checks.drv`) +- `gh pr list --head npm-nixos-integration-test` / `gh run list --branch npm-nixos-integration-test` -> exit 0 (no PR and no CI runs; branch not pushed) + +### Success-criteria verification + +- [x] AC1: Fixture mode unchanged and in the fast tier -> `npm-pack-smoke` built and passed; it is listed in `checks.x86_64-linux` and `npm-nixos-vm` is not. +- [x] AC2: Real-binary mode installs and executes the actual release binary -> host `--binary` smoke passed with the asserted mode, link, `--help`, and version; key substitution only in the staged copy; no `npm/lib` changes from this plan (`git diff main -- npm/lib` is non-empty only because of the parent `harden-npm-installer` commits not yet on `main`). +- [x] AC3: NixOS VM test runs the real install as a non-root user and executes the launcher directly -> `ci-checks` log shows the VM test steps, `runtime/sce mode: 755`, `launcher mode: 755`, `parsed version: 0.4.0-pre-alpha-v12`; `nix/tests/npm-nixos-vm.nix` inspected: `sce-tester` with uid != 0 asserted, no `SCE_NPM_SKIP_DOWNLOAD`, no copy/chmod into `runtime/`, `useDHCP = false`, `interfaces = { }`, `vlans = [ ]`. +- [x] AC4: `ci-checks` builds `sce-release` once with VM test and audit -> passed; single shared `sce-release` drv; aarch64-darwin `ci-checks` still evaluates. +- [ ] AC5: `release-validation (ubuntu-latest)` runs the VM test with KVM and fails loudly without it -> workflow inspection passes (ubuntu-only `Enable KVM` step exits 1 with `::error::` when `/dev/kvm` is missing; ubuntu-only `extra-conf` system features) and `workflow-actionlint` passed, but the required PR CI run does not exist yet. +- [x] AC6: Injected faults fail for the right reason -> T01/T02 evidence records faults (a) launcher mode, (b) non-executable `--binary` at `sce --help` (host and VM), (c) missing `runtime/sce`, each with its exact `pack-smoke:` message; `git status` shows no fault residue (only the staged T04 context/plan edits). +- [x] AC7: Existing installer negative tests still pass -> `npm-bun-tests` built and passed (also in `nix flake check`). +- [x] AC8: Durable docs cover fixture vs VM, musl rationale, guarantees, macOS/ARM64 release-only coverage, local command, KVM/runner requirements -> all present in `context/sce/cli-npm-distribution-contract.md` (Packed-package smoke test section) and `context/sce/flake-build-performance.md`; command names match `flake.nix`. + +### Failed checks and follow-ups + +- AC5 PR CI run: no PR and no GitHub Actions run exist for `npm-nixos-integration-test` (branch not pushed); evidence: `gh pr list --head npm-nixos-integration-test` returned `[]`, `gh run list` returned nothing; required: commit the staged T04 changes, push the branch, open the PR, and confirm `release-validation (ubuntu-latest)` passes `Enable KVM` and builds `npm-nixos-vm` within the 90-minute budget. + +### Residual risks + +- KVM availability and VM runtime on hosted `ubuntu-latest` are unproven until the PR CI run (plan Open questions). +- Local VM runs used TCG emulation, not KVM. +- `linux/arm64` and `darwin/arm64` npm installs remain covered only by release-pipeline audits and extracted-binary smoke. + +### Retry + +After repairs, rerun: + +`/validate context/plans/npm-nixos-vm-integration-test.md` diff --git a/context/sce/cli-npm-distribution-contract.md b/context/sce/cli-npm-distribution-contract.md index be2732b7c..642a724fe 100644 --- a/context/sce/cli-npm-distribution-contract.md +++ b/context/sce/cli-npm-distribution-contract.md @@ -58,16 +58,24 @@ This file captures the implemented npm distribution slice of the `sce` CLI insta ## Packed-package smoke test -- `npm/test/pack-smoke.sh` (with helper `npm/test/fixture-release-server.mjs`) verifies the real packed artifact end to end without network access: it stages a copy of the package (or `--package-dir `, or the `package/` contents of an already packed `--tarball `, whose launcher mode is asserted first), swaps a throwaway RSA manifest-signing public key into the staged copy only, builds a stub `sce` archive in the canonical `sce-v-/{bin/sce,LICENSE,README.md}` layout with a matching signed manifest, serves it over loopback HTTPS (self-signed certificate trusted via `NODE_EXTRA_CA_CERTS`, selected via `SCE_NPM_RELEASE_BASE_URL`), runs `npm pack`, fails unless the packed `package/bin/sce.js` entry is executable, installs into an isolated `--prefix` with an isolated npm cache/config, and asserts exact output from `sce --help` and `sce version`. +- `npm/test/pack-smoke.sh` (with helper `npm/test/fixture-release-server.mjs`) verifies the real packed artifact end to end without network access: it stages a copy of the package (or `--package-dir `, or the `package/` contents of an already packed `--tarball `, whose launcher mode is asserted first), swaps a throwaway RSA manifest-signing public key into the staged copy only, builds a stub `sce` archive in the canonical `sce-v-/{bin/sce,LICENSE,README.md}` layout with a matching signed manifest, serves it over loopback HTTPS (self-signed certificate trusted via `NODE_EXTRA_CA_CERTS`, selected via `SCE_NPM_RELEASE_BASE_URL`), runs `npm pack`, fails unless the packed `package/bin/sce.js` entry is executable, installs into an isolated prefix with an isolated npm cache/config, asserts the installed `runtime/sce` has mode `755` and that `node_modules/.bin/sce` resolves to the installed `@crocoder-dev/sce/bin/sce.js`, then runs the installed CLI. +- Two archive modes share every signing, manifest, pack, server, and install step. Fixture mode, the default, serves a stub `bin/sce` and asserts exact output from `sce --help` and `sce version`. Real-binary mode (`--binary `, e.g. the Nix-built `sce-release` binary) serves the given executable as `bin/sce` and asserts that `sce --help` exits 0 and that `.version` from `sce version --format json` equals the expected version. +- `--expected-version ` overrides the expected version (default: the staged `package.json` version). `--prefix ` installs into a caller-owned directory that is kept after the run; otherwise the prefix is temporary. - The fixture archive is produced by real GNU `tar`, so the smoke also exercises the in-repo archive reader against the release pipeline's archive format under Node. -- Only the host platform is exercised, with a stub binary; the production public key and manifest trust code are not modified. +- Only the host platform is exercised. The production public key and manifest trust code are not modified. - Root `flake.nix` runs the smoke in the build sandbox as `checks..npm-pack-smoke` (part of `nix flake check`; loopback HTTPS works inside the sandbox). The sandbox has no `/usr/bin/env`, so there the installed bin is run as `node ` after its `-x` check. Host runs, including the `release-npm-package` gate, exec the bin directly. +- `nix/tests/npm-nixos-vm.nix` (exposed as `packages.x86_64-linux.npm-nixos-vm`; local-only, neither a flake check nor a `.#ci-checks` member) runs real-binary mode inside a minimal NixOS VM (`pkgs.testers.runNixOSTest`, loopback-only networking) as a non-root user with an isolated HOME, npm cache, and prefix, against `${sceReleasePackage}/bin/sce` and the repo `.version`. It then asserts `runtime/sce` mode `755`, an executable `.bin/sce` resolving to the installed `bin/sce.js`, direct shebang execution of `sce --help`, and the `jq`-parsed `sce version --format json` `.version`. NixOS has no FHS loader or `/usr/lib` glibc, so a passing run shows the static-musl binary needs no unavailable dynamic libraries. +- Fixture mode versus the VM: fixture mode proves the packed package, signing/manifest/checksum/archive flow, and launcher wiring on the host, cheaply on every `nix flake check`, but its stub cannot reveal native linkage problems. The VM installs the actual static-musl `x86_64-unknown-linux-musl` release binary that the archive pipeline ships, so it also proves that binary runs through the npm launcher on a system with no FHS libraries. +- The VM covers only `linux/x64`. `linux/arm64` and `darwin/arm64` npm installs are not executed by any npm-level test; their native archives are covered only by the release pipeline's portability audits and extracted-binary `sce version --format json` smoke before upload. +- Running the VM test locally: `nix build .#npm-nixos-vm --print-build-logs` on x86_64-linux. With `/dev/kvm` it runs accelerated. Without it, QEMU falls back to TCG emulation, which is slower but still passes. CI does not run it: hosted `ubuntu-latest` runners do not expose `/dev/kvm`, so the test is run manually before release-affecting npm or musl-build changes. ## Verification baseline - `nix develop -c sh -c 'cd npm && bun test ./test/*.test.js'` - `nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh` +- `nix build .#sce-release && nix shell nixpkgs#nodejs nixpkgs#openssl -c bash npm/test/pack-smoke.sh --binary ./result/bin/sce` - `nix build .#checks..npm-pack-smoke --print-build-logs` +- `nix build .#npm-nixos-vm --print-build-logs` (x86_64-linux; uses KVM when `/dev/kvm` exists, otherwise slower QEMU emulation) - `nix develop -c sh -c 'cd npm && SCE_NPM_SKIP_DOWNLOAD=1 bun ./lib/install.js'` - `nix run .#release-manifest -- --version --artifacts-dir --out-dir --signing-key-file ` - `nix run .#release-npm-package -- --version --out-dir ` diff --git a/context/sce/flake-build-performance.md b/context/sce/flake-build-performance.md index 7af52bf02..7d9119fea 100644 --- a/context/sce/flake-build-performance.md +++ b/context/sce/flake-build-performance.md @@ -10,12 +10,19 @@ The root flake separates ordinary development from release-only work: - `packages.ci-checks` is the explicit expensive tier. It forces the release package and, on Linux, audits the real release binary for forbidden `/nix/store/` runtime references. +- `packages.x86_64-linux.npm-nixos-vm` is a NixOS VM test of the real packed + npm install against the release binary. It is local-only: neither a flake + check nor a `ci-checks` member, so no automated tier boots a VM. Build it with + `nix build .#npm-nixos-vm --print-build-logs`; it runs without `/dev/kvm` + under slower QEMU emulation. - `nix flake check` runs the normal test/lint/format/parity/static checks without building `.#sce-release`. - `devShells.default` provides development tools without `scePackage` or the Turso CLI. `devShells.database` adds Turso. - `.github/workflows/pr-ci.yml` runs native package smoke tests in `nix-ci` and - runs `.#ci-checks` separately in `release-validation` on every commit. + runs `.#ci-checks` separately in `release-validation` on every commit. Hosted + `ubuntu-latest` runners do not expose `/dev/kvm`, so CI does not run the + NixOS VM test. Git-commit embedding is release-only. Native packages and normal CLI check derivations do not receive `SCE_GIT_COMMIT`; release packages do. diff --git a/flake.nix b/flake.nix index 431df1b53..aaaf2a99c 100644 --- a/flake.nix +++ b/flake.nix @@ -1683,11 +1683,6 @@ mkdir -p "$out" ''; - # Explicit long-running validation tier. Its primary member is the - # static-musl release build; on Linux it also forces the release - # portability audit over the real binary. Building this aggregate - # (nix build .#ci-checks) is the expensive command, keeping - # nix flake check fast (it never builds .#sce-release). ciChecks = pkgs.runCommand "sce-ci-checks" { } @@ -1701,6 +1696,11 @@ ''} ''; + npmNixosVmTest = import ./nix/tests/npm-nixos-vm.nix { + inherit pkgs npmSrc sceReleasePackage; + expectedVersion = version; + }; + # The dedicated MBT runner is opt-in as a package, not a flake check: # cli-tests already executes the same mutation_trace::mbt test module. # The Quint workflow tests the spec separately, without rebuilding Rust MBT. @@ -1745,6 +1745,9 @@ mutation-trace-quint-connect = mutationTraceQuintConnectCheck; quint-language-server = quintLanguageServerPackage; default = scePackage; + } + // pkgs.lib.optionalAttrs (system == "x86_64-linux") { + npm-nixos-vm = npmNixosVmTest; }; checks = diff --git a/nix/tests/npm-nixos-vm.nix b/nix/tests/npm-nixos-vm.nix new file mode 100644 index 000000000..193fd7096 --- /dev/null +++ b/nix/tests/npm-nixos-vm.nix @@ -0,0 +1,98 @@ +{ + pkgs, + npmSrc, + sceReleasePackage, + expectedVersion, +}: + +let + testUser = "sce-tester"; + homeDir = "/home/${testUser}"; + prefixDir = "${homeDir}/prefix"; + installedDir = "${prefixDir}/node_modules/@crocoder-dev/sce"; + launcher = "${prefixDir}/node_modules/.bin/sce"; + + asUser = + command: + builtins.toJSON '' + su - ${testUser} -s ${pkgs.bash}/bin/bash -c ${pkgs.lib.escapeShellArg '' + set -euo pipefail + export HOME=${homeDir} + export TMPDIR=${homeDir}/tmp + export npm_config_cache=${homeDir}/npm-cache + export npm_config_userconfig=${homeDir}/npmrc + ${command} + ''} + ''; +in +pkgs.testers.runNixOSTest { + name = "npm-nixos-vm"; + + nodes.machine = { + networking.useDHCP = false; + networking.interfaces = { }; + virtualisation.vlans = [ ]; + + users.users.${testUser} = { + isNormalUser = true; + home = homeDir; + }; + + environment.systemPackages = [ + pkgs.bash + pkgs.coreutils + pkgs.gnutar + pkgs.gzip + pkgs.jq + pkgs.nodejs + pkgs.openssl + ]; + + virtualisation.memorySize = 2048; + }; + + testScript = '' + machine.wait_for_unit("multi-user.target") + + with subtest("test user is non-root"): + uid = machine.succeed("id -u ${testUser}").strip() + assert uid != "0", f"test user must not be root, got uid {uid}" + + with subtest("stage npm package source"): + machine.succeed(${asUser '' + mkdir -p "$TMPDIR" "$npm_config_cache" + : >"$npm_config_userconfig" + cp -R ${npmSrc}/npm "$HOME/npm" + chmod -R u+w "$HOME/npm" + ''}) + + with subtest("install the packed package through postinstall with the real binary"): + print(machine.succeed(${asUser '' + bash "$HOME/npm/test/pack-smoke.sh" \ + --binary ${sceReleasePackage}/bin/sce \ + --expected-version ${pkgs.lib.escapeShellArg expectedVersion} \ + --prefix ${prefixDir} 2>&1 + ''})) + + with subtest("installed modes"): + runtime_mode = machine.succeed("stat -c %a ${installedDir}/runtime/sce").strip() + print(f"runtime/sce mode: {runtime_mode}") + assert runtime_mode == "755", f"runtime/sce mode {runtime_mode}, expected 755" + launcher_mode = machine.succeed("stat -L -c %a ${launcher}").strip() + print(f"launcher mode: {launcher_mode}") + assert int(launcher_mode, 8) & 0o111, f"launcher mode {launcher_mode} is not executable" + launcher_target = machine.succeed("realpath ${launcher}").strip() + expected_target = machine.succeed("realpath ${installedDir}/bin/sce.js").strip() + assert launcher_target == expected_target, f"launcher resolves to {launcher_target}, expected {expected_target}" + + with subtest("direct launcher execution"): + machine.succeed(${asUser "${launcher} --help >/dev/null"}) + version_json = machine.succeed(${asUser "${launcher} version --format json"}) + print(f"sce version --format json: {version_json.strip()}") + reported_version = machine.succeed( + ${asUser "${launcher} version --format json | jq -er .version"} + ).strip() + print(f"parsed version: {reported_version}") + assert reported_version == ${builtins.toJSON expectedVersion}, f"sce version reported {reported_version!r}" + ''; +} diff --git a/npm/test/pack-smoke.sh b/npm/test/pack-smoke.sh index 573155b1b..9d98c5a3c 100755 --- a/npm/test/pack-smoke.sh +++ b/npm/test/pack-smoke.sh @@ -5,18 +5,36 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" LAUNCHER_ENTRY="package/bin/sce.js" SERVER_START_TIMEOUT_SECONDS=10 +INSTALLED_PACKAGE_DIR="node_modules/@crocoder-dev/sce" +EXPECTED_RUNTIME_MODE="755" EXPECTED_HELP_OUTPUT="sce fixture help" SOURCE_TARBALL="" +SOURCE_BINARY="" +EXPECTED_VERSION="" +PREFIX_DIR="" usage() { cat <<'EOF' Usage: pack-smoke.sh [--package-dir | --tarball ] + [--binary ] [--expected-version ] + [--prefix ] Packs the npm launcher package, installs it into an isolated prefix against a loopback fixture release, and verifies `sce --help` and `sce version`. With --tarball, the launcher mode is asserted on the given packed tarball and its package contents are used instead of a package directory. + +With --binary, the given executable (e.g. the Nix-built sce-release binary) is +served as bin/sce in the release archive instead of the fixture stub. The +installed CLI must exit 0 for `sce --help` and report the expected version via +`sce version --format json`. + +--expected-version overrides the version asserted from `sce version` +(default: the staged package.json version). + +--prefix installs into the given directory and keeps it after the run instead +of using a temporary prefix. EOF } @@ -38,6 +56,23 @@ while [[ $# -gt 0 ]]; do SOURCE_TARBALL="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" shift 2 ;; + --binary) + [[ -n "${2:-}" ]] || fail "Missing value for --binary." + [[ -f "$2" ]] || fail "Binary $2 does not exist." + SOURCE_BINARY="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" + shift 2 + ;; + --expected-version) + [[ -n "${2:-}" ]] || fail "Missing value for --expected-version." + EXPECTED_VERSION="$2" + shift 2 + ;; + --prefix) + [[ -n "${2:-}" ]] || fail "Missing value for --prefix." + mkdir -p "$2" + PREFIX_DIR="$(cd "$2" && pwd)" + shift 2 + ;; --help | -h) usage exit 0 @@ -72,7 +107,7 @@ trap cleanup EXIT stage_dir="$work_dir/stage" release_dir="$work_dir/release" pack_dir="$work_dir/pack" -prefix_dir="$work_dir/prefix" +prefix_dir="${PREFIX_DIR:-$work_dir/prefix}" keys_dir="$work_dir/keys" mkdir -p "$stage_dir" "$release_dir" "$pack_dir" "$prefix_dir" "$keys_dir" @@ -110,6 +145,8 @@ target_triple="$(node -p "JSON.parse(process.argv[1]).targetTriple" "$platform_j target_os="$(node -p "JSON.parse(process.argv[1]).os" "$platform_json")" target_arch="$(node -p "JSON.parse(process.argv[1]).arch" "$platform_json")" +expected_version="${EXPECTED_VERSION:-$version}" + printf 'pack-smoke: version %s, target %s\n' "$version" "$target_triple" openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$keys_dir/signing.pem" 2>/dev/null @@ -125,7 +162,11 @@ archive_name="${archive_root}.tar.gz" manifest_name="sce-v${version}-release-manifest.json" archive_src="$work_dir/archive-src" mkdir -p "$archive_src/$archive_root/bin" -cat >"$archive_src/$archive_root/bin/sce" <"$archive_src/$archive_root/bin/sce" <&2; exit 64 ;; esac EOF +fi chmod 0755 "$archive_src/$archive_root/bin/sce" printf 'fixture license\n' >"$archive_src/$archive_root/LICENSE" printf 'fixture readme\n' >"$archive_src/$archive_root/README.md" @@ -196,8 +238,21 @@ SCE_NPM_RELEASE_BASE_URL="https://127.0.0.1:${port}" \ "$tarball" || fail "npm install of the packed tarball failed." +installed_dir="$prefix_dir/$INSTALLED_PACKAGE_DIR" +runtime_bin="$installed_dir/runtime/sce" +[[ -f "$runtime_bin" ]] || fail "Installer did not produce $runtime_bin." +runtime_mode="$(node -p "(require('fs').statSync(process.argv[1]).mode & 0o777).toString(8)" "$runtime_bin")" +[[ "$runtime_mode" == "$EXPECTED_RUNTIME_MODE" ]] || + fail "Installed $runtime_bin has mode $runtime_mode; expected $EXPECTED_RUNTIME_MODE." +printf 'pack-smoke: installed runtime/sce mode %s\n' "$runtime_mode" + sce_bin="$prefix_dir/node_modules/.bin/sce" [[ -x "$sce_bin" ]] || fail "Installed sce bin $sce_bin is not executable." +sce_bin_target="$(realpath "$sce_bin")" +expected_bin_target="$(realpath "$installed_dir/bin/sce.js")" +[[ "$sce_bin_target" == "$expected_bin_target" ]] || + fail "Installed sce bin resolves to $sce_bin_target; expected $expected_bin_target." +printf 'pack-smoke: .bin/sce resolves to %s\n' "$sce_bin_target" sce_cmd=("$sce_bin") if [[ ! -x /usr/bin/env ]]; then @@ -206,11 +261,24 @@ if [[ ! -x /usr/bin/env ]]; then fi help_output="$("${sce_cmd[@]}" --help)" || fail "sce --help exited non-zero." + +if [[ -n "$SOURCE_BINARY" ]]; then + version_json="$("${sce_cmd[@]}" version --format json)" || + fail "sce version --format json exited non-zero." + reported_version="$(node -p "JSON.parse(process.argv[1]).version" "$version_json")" || + fail "sce version --format json printed invalid JSON: $version_json" + [[ "$reported_version" == "$expected_version" ]] || + fail "sce version reported '$reported_version'; expected '$expected_version'." + printf 'pack-smoke: real binary reported version %s\n' "$reported_version" + printf 'pack-smoke: OK (real binary: sce --help, sce version --format json)\n' + exit 0 +fi + [[ "$help_output" == "$EXPECTED_HELP_OUTPUT" ]] || fail "sce --help printed '$help_output'; expected '$EXPECTED_HELP_OUTPUT'." version_output="$("${sce_cmd[@]}" version)" || fail "sce version exited non-zero." -[[ "$version_output" == "sce $version" ]] || - fail "sce version printed '$version_output'; expected 'sce $version'." +[[ "$version_output" == "sce $expected_version" ]] || + fail "sce version printed '$version_output'; expected 'sce $expected_version'." printf 'pack-smoke: OK (sce --help, sce version)\n'