diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4e6ded93..cd8c3206a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -308,12 +308,13 @@ jobs: "$RUNNER_TEMP/config-schema/bin/python" scripts/check-config-schema.py - name: Test transport, projections and compiled bundle without host toolchains run: | - bunx ultracite check scripts/build-config-compiler.ts scripts/build-release.ts scripts/check-release-config-compiler.ts scripts/check-config-compiler-cli.ts tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts tests/native-project-inputs.test.ts tests/native-project-inputs-acquisition-race.test.ts tests/release-download-installer.test.ts + bunx ultracite check scripts/build-lifecycle-supervisor.ts scripts/check-release-lifecycle-supervisor.ts scripts/build-config-compiler.ts scripts/build-release.ts scripts/check-release-config-compiler.ts scripts/check-config-compiler-cli.ts tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts tests/native-project-inputs.test.ts tests/native-project-inputs-acquisition-race.test.ts tests/release-download-installer.test.ts bun test tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts tests/native-project-inputs.test.ts tests/native-project-inputs-acquisition-race.test.ts bun run --cwd packages/cli typecheck bun test tests/release-download-installer.test.ts bun run build:release --skip-tests --out=dist/config-compiler-release bun scripts/check-release-config-compiler.ts dist/config-compiler-release + bun scripts/check-release-lifecycle-supervisor.ts dist/config-compiler-release bun scripts/check-config-compiler-cli.ts bunx ultracite check scripts/check-native-env-plan-cli.ts tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts bun test tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts tests/project-env-config.test.ts tests/project-env-metadata.test.ts @@ -650,7 +651,7 @@ jobs: --tag hack-runtime-ci:slim - name: Smoke slim runtime image defaults run: | - docker run --rm --entrypoint sh hack-runtime-ci:slim -lc 'command -v bun >/dev/null && command -v hack >/dev/null && test "${HACK_EXECUTION_MODE}" = "codex" && test "${HACK_DAEMON_DISABLE_DOCKER_EVENTS}" = "1" && hack --help >/tmp/hack-help.txt && grep -q "Usage:" /tmp/hack-help.txt' + docker run --rm --entrypoint sh hack-runtime-ci:slim -lc 'command -v bun >/dev/null && command -v hack >/dev/null && test -x /usr/local/bin/hack-lifecycle-supervisor && test "${HACK_EXECUTION_MODE}" = "codex" && test "${HACK_DAEMON_DISABLE_DOCKER_EVENTS}" = "1" && hack --help >/tmp/hack-help.txt && grep -q "Usage:" /tmp/hack-help.txt' - name: Smoke slim runtime mounted-project env flow run: bash scripts/portable-container-smoke.sh hack-runtime-ci:slim linux/amd64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3af8a3708..a6f83675e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,6 +121,8 @@ jobs: run: bun run build:release "--version=$RELEASE_VERSION" --skip-tests - name: Qualify installed release compiler without host runtimes or network run: bun scripts/check-release-config-compiler.ts dist/release + - name: Qualify installed lifecycle supervisor + run: bun scripts/check-release-lifecycle-supervisor.ts dist/release - name: Upload tarball env: GH_TOKEN: ${{ github.token }} diff --git a/.hack/README.md b/.hack/README.md index a3ef5e841..0f64416ff 100644 --- a/.hack/README.md +++ b/.hack/README.md @@ -16,6 +16,7 @@ hack run --profile toolchain toolchain -- test hack run --profile toolchain toolchain -- check hack run --profile toolchain toolchain -- rust hack run --profile toolchain toolchain -- rust-check +hack run --profile toolchain toolchain -- lifecycle-supervisor hack run --profile toolchain toolchain -- config-compiler hack run --profile toolchain toolchain -- build hack run --profile toolchain toolchain -- exec bun index.ts --help diff --git a/.hack/tasks.json b/.hack/tasks.json index 0cf5d7bb5..782f7440a 100644 --- a/.hack/tasks.json +++ b/.hack/tasks.json @@ -34,7 +34,7 @@ "inputs": [ { "path": ".hack/toolchain/run.sh", - "sha256": "0690919e0937c49638ea1c8318d731df3c45df26c3865c6a37ae263e8854e6a5" + "sha256": "d71945600ee66cf53a2ffb23f7aa7603fd217fde8a6875e97668b72ccce4fad5" }, { "path": ".hack/toolchain/Dockerfile", @@ -97,7 +97,7 @@ "inputs": [ { "path": ".hack/toolchain/run.sh", - "sha256": "0690919e0937c49638ea1c8318d731df3c45df26c3865c6a37ae263e8854e6a5" + "sha256": "d71945600ee66cf53a2ffb23f7aa7603fd217fde8a6875e97668b72ccce4fad5" }, { "path": ".hack/toolchain/Dockerfile", @@ -154,7 +154,7 @@ "inputs": [ { "path": ".hack/toolchain/run.sh", - "sha256": "0690919e0937c49638ea1c8318d731df3c45df26c3865c6a37ae263e8854e6a5" + "sha256": "d71945600ee66cf53a2ffb23f7aa7603fd217fde8a6875e97668b72ccce4fad5" }, { "path": ".hack/toolchain/Dockerfile", diff --git a/.hack/toolchain/run.sh b/.hack/toolchain/run.sh index d887d7f29..5f1b92b84 100644 --- a/.hack/toolchain/run.sh +++ b/.hack/toolchain/run.sh @@ -20,6 +20,7 @@ case "$task" in check) install_deps; bun run typecheck; exec bun run check ;; rust-check) cargo fmt --manifest-path packages/runtime-core/Cargo.toml --check; exec cargo clippy --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir /build/rust --all-targets --all-features --jobs 2 -- -D warnings ;; rust) exec cargo test --all-features --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir /build/rust --jobs 2 "$@" ;; + lifecycle-supervisor) exec bun scripts/build-lifecycle-supervisor.ts ;; config-compiler) install_deps cargo fmt --manifest-path packages/config-compiler/Cargo.toml --check @@ -30,6 +31,7 @@ case "$task" in ;; build) install_deps + bun scripts/build-lifecycle-supervisor.ts # Bun 1.3.9 stages in cwd; its cross-device fallback can emit zero-filled # executables when cwd is the host bind mount and dist is a named volume. cd /app/dist @@ -38,5 +40,5 @@ case "$task" in exec /app/dist/hack --version ;; exec) exec "$@" ;; - *) echo "Tasks: models, install, test [paths], check, rust [args], rust-check, config-compiler, build, exec " >&2; exit 2 ;; + *) echo "Tasks: models, install, test [paths], check, rust [args], rust-check, lifecycle-supervisor, config-compiler, build, exec " >&2; exit 2 ;; esac diff --git a/docker/slim-runtime/Dockerfile b/docker/slim-runtime/Dockerfile index 7a0a9b174..468423658 100644 --- a/docker/slim-runtime/Dockerfile +++ b/docker/slim-runtime/Dockerfile @@ -1,5 +1,10 @@ # syntax=docker/dockerfile:1.7 +FROM rust:1.97.1-slim-bookworm AS supervisor-build +WORKDIR /src +COPY packages/lifecycle-supervisor ./packages/lifecycle-supervisor +RUN cargo +1.97.1 build --locked --release --jobs 2 --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir /build + FROM oven/bun:1.4.4 AS hack-build WORKDIR /src @@ -33,8 +38,9 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY --from=hack-build /out/hack /usr/local/bin/hack +COPY --from=supervisor-build /build/release/hack-lifecycle-supervisor /usr/local/bin/hack-lifecycle-supervisor -RUN chmod +x /usr/local/bin/hack \ +RUN chmod 755 /usr/local/bin/hack /usr/local/bin/hack-lifecycle-supervisor \ && mkdir -p /var/lib/hack /workspace ENV HOME=/var/lib/hack diff --git a/docs/guides/prereleases.md b/docs/guides/prereleases.md index 8d2eba59f..39ff6351c 100644 --- a/docs/guides/prereleases.md +++ b/docs/guides/prereleases.md @@ -265,3 +265,24 @@ exec, run, restart and retaining shutdown, then exercise its browser sign-in, data-backed pages and assets. Record the bundle version and source commit with the result. Unit tests, hosted CI and a responding health endpoint do not replace that application check. + +### Lifecycle supervisor packaging + +New release archives include `hack-lifecycle-supervisor` beside the CLI. The +standard and slim installers install it with executable mode 0755; Homebrew keeps +it beside `libexec/hack`. Source installs prepare it with +`bun run build:lifecycle-supervisor` (Rust 1.97.1, locked dependencies). +The slim runtime image includes the same sibling executable. + +Stable archives build it for macOS arm64/x86_64 and Linux arm64/x86_64. Existing +prerelease bundles remain macOS arm64 only: their supervisor is ad-hoc signed and +covered by the bundle checksums. Retained managers accept older bundles without +this helper. Packaging does not change the lifecycle runner default. + +Self-upgrades stage the helper before publishing the CLI, retain its previous +bytes and SHA-256 in the backup, and restore it if publication fails. +Before publishing anything, a self-upgrade asks the staged CLI which helper +handshake it requires and refuses the release, leaving the installation +unchanged, if the staged helper is missing or answers differently. Releases +whose CLI predates the helper are still accepted. Keep the +helper with its matching CLI when moving an installation. diff --git a/docs/versions/v5/install.md b/docs/versions/v5/install.md index 7013df83f..20091d578 100644 --- a/docs/versions/v5/install.md +++ b/docs/versions/v5/install.md @@ -110,3 +110,24 @@ anything, run `hack update --check --json --observe` through the same launcher; it prints the upgrade command for this install. For a candidate rollback or return to stable, use [the full procedure](../guides/candidate-install.md#upgrade-roll-back-and-return-to-stable). + +### Lifecycle supervisor packaging + +New release archives include `hack-lifecycle-supervisor` beside the CLI. The +standard and slim installers install it with executable mode 0755; Homebrew keeps +it beside `libexec/hack`. Source installs prepare it with +`bun run build:lifecycle-supervisor` (Rust 1.97.1, locked dependencies). +The slim runtime image includes the same sibling executable. + +Stable archives build it for macOS arm64/x86_64 and Linux arm64/x86_64. Existing +prerelease bundles remain macOS arm64 only: their supervisor is ad-hoc signed and +covered by the bundle checksums. Retained managers accept older bundles without +this helper. Packaging does not change the lifecycle runner default. + +Self-upgrades stage the helper before publishing the CLI, retain its previous +bytes and SHA-256 in the backup, and restore it if publication fails. +Before publishing anything, a self-upgrade asks the staged CLI which helper +handshake it requires and refuses the release, leaving the installation +unchanged, if the staged helper is missing or answers differently. Releases +whose CLI predates the helper are still accepted. Keep the +helper with its matching CLI when moving an installation. diff --git a/index.ts b/index.ts index 55319426e..eed28784d 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,9 @@ #!/usr/bin/env bun +import { + LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT, + LIFECYCLE_SUPERVISOR_VERSION, +} from "./src/lib/lifecycle-supervisor-handshake.ts"; import { finishNativeCpuDiagnostics, initializeNativeCpuDiagnostics, @@ -25,6 +29,11 @@ if (Bun.argv[2] === NATIVE_DETACHED_OWNER_ARGUMENT) { ); } +if (Bun.argv[2] === LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT) { + process.stdout.write(`${LIFECYCLE_SUPERVISOR_VERSION}\n`); + process.exit(0); +} + if (Bun.argv[2] === TTY_SUPERVISOR_ARGUMENT && process.send) { process.exit(await runTtySupervisor()); } diff --git a/package.json b/package.json index a3db09693..114d682e9 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev": "bun run --cwd packages/cli dev", "build": "bun run --cwd packages/cli build", "build:local": "sh scripts/build-hack-local.sh", + "build:lifecycle-supervisor": "bun scripts/build-lifecycle-supervisor.ts", "build:config-compiler": "bun scripts/build-config-compiler.ts", "check:config-compiler": "cargo fmt --manifest-path packages/config-compiler/Cargo.toml --check && cargo clippy --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir .hack-local/config-compiler-target --all-targets --jobs 2 -- -D warnings", "test:config-compiler": "bash scripts/test-rust.sh --crate=config-compiler", diff --git a/packages/cli/package.json b/packages/cli/package.json index 860aa69a1..9ec177392 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,12 +6,12 @@ "module": "index.ts", "scripts": { "dev": "bun run ../../index.ts", - "build": "cd ../.. && bun build index.ts --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig --no-compile-autoload-tsconfig --no-compile-autoload-package-json --outfile dist/hack", + "build": "cd ../.. && bun scripts/build-lifecycle-supervisor.ts && bun build index.ts --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig --no-compile-autoload-tsconfig --no-compile-autoload-package-json --outfile dist/hack", "build:release": "cd ../.. && bun run scripts/build-release.ts", "build:runtime-image": "cd ../.. && bun run scripts/build-node-runtime-image.ts", "build:node-runtime-image": "cd ../.. && bun run scripts/build-node-runtime-image.ts --variant node-runtime", "build:slim-runtime-image": "cd ../.. && bun run scripts/build-node-runtime-image.ts --variant slim", - "install:dev": "cd ../.. && bun run scripts/install-cli.ts --mode=dev --force", + "install:dev": "cd ../.. && bun scripts/build-lifecycle-supervisor.ts && bun run scripts/install-cli.ts --mode=dev --force", "install:bin": "cd ../.. && bun run scripts/install-cli.ts --mode=bin --force", "install:status": "cd ../.. && bun run scripts/install-status.ts", "uninstall:local": "cd ../.. && bun run scripts/uninstall-cli.ts", diff --git a/scripts/build-lifecycle-supervisor.ts b/scripts/build-lifecycle-supervisor.ts new file mode 100644 index 000000000..caef59064 --- /dev/null +++ b/scripts/build-lifecycle-supervisor.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env bun +import { chmod, copyFile, mkdir, mkdtemp, rename, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { verifyConfigCompilerBuildPath } from "./build-config-compiler.ts"; + +/** Build the host sibling with locked dependencies and bounded Cargo parallelism. */ +export async function buildLifecycleSupervisor(): Promise { + const root = resolve(import.meta.dir, ".."); + const target = join(root, ".hack-local/lifecycle-supervisor-target"); + const output = join(root, "dist/hack-lifecycle-supervisor"); + for (const path of [ + join(root, ".hack-local"), + target, + join(root, "dist"), + output, + ]) { + await verifyConfigCompilerBuildPath({ path }); + } + const child = Bun.spawn( + [ + "cargo", + "+1.97.1", + "build", + "--locked", + "--release", + "--jobs", + "2", + "--manifest-path", + "packages/lifecycle-supervisor/Cargo.toml", + "--target-dir", + target, + ], + { + cwd: root, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + timeout: 300_000, + } + ); + if ((await child.exited) !== 0) { + throw new Error("Lifecycle supervisor build failed."); + } + const binary = join(target, "release/hack-lifecycle-supervisor"); + await verifyConfigCompilerBuildPath({ + path: binary, + allowCargoHardLink: true, + }); + await mkdir(join(root, "dist"), { recursive: true }); + const stage = await mkdtemp(join(root, "dist/.lifecycle-supervisor-build-")); + try { + const staged = join(stage, "hack-lifecycle-supervisor"); + await copyFile(binary, staged); + await chmod(staged, 0o755); + if (process.platform === "darwin") { + for (const args of [ + ["--force", "--sign", "-"], + ["--verify", "--strict"], + ]) { + const signature = Bun.spawn(["/usr/bin/codesign", ...args, staged], { + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + timeout: 30_000, + }); + if ((await signature.exited) !== 0) { + throw new Error( + "Lifecycle supervisor signature verification failed." + ); + } + } + } + // A locally installed helper may still be supervising work. Replace its + // directory entry instead of writing to that running executable's inode. + await verifyConfigCompilerBuildPath({ path: output }); + await rename(staged, output); + } finally { + await rm(stage, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + await buildLifecycleSupervisor(); +} diff --git a/scripts/build-native-candidate.sh b/scripts/build-native-candidate.sh index 7a1cd776c..50d5da6e9 100755 --- a/scripts/build-native-candidate.sh +++ b/scripts/build-native-candidate.sh @@ -118,6 +118,11 @@ if [ -n "$version" ]; then else bun build index.ts --compile --outfile "$out/hack-cli" fi +bun scripts/build-lifecycle-supervisor.ts +cp dist/hack-lifecycle-supervisor "$out/hack-lifecycle-supervisor" +chmod 755 "$out/hack-lifecycle-supervisor" +/usr/bin/codesign --force --sign - "$out/hack-lifecycle-supervisor" +/usr/bin/codesign --verify --strict "$out/hack-lifecycle-supervisor" bun scripts/build-config-compiler.ts cp dist/hack-config-compiler "$out/hack-config-compiler" cp packages/config-compiler/generated/hack.project.schema.json "$out/hack.project.schema.json" @@ -158,7 +163,7 @@ fi else shasum -a 256 hack-native hack-relay-guest hack-storage-root-witness native-storage-tool.json hack-cli hack-v5 provider-pins.json README.md > SHA256SUMS fi - shasum -a 256 hack-config-compiler hack.project.schema.json hack.local.schema.json >> SHA256SUMS + shasum -a 256 hack-lifecycle-supervisor hack-config-compiler hack.project.schema.json hack.local.schema.json >> SHA256SUMS shasum -a 256 mcp/*/manifest.json mcp/*/hack-mcp-adapter mcp/*/hack-mcp-owner mcp/*/hack-mcp-backend >> SHA256SUMS ) echo "Candidate bundle: $out" diff --git a/scripts/build-release.ts b/scripts/build-release.ts index e5720b206..ff039b159 100644 --- a/scripts/build-release.ts +++ b/scripts/build-release.ts @@ -10,6 +10,7 @@ import { renderProjectManagedEnvSchemaJson, } from "../src/templates.ts"; import { buildConfigCompiler } from "./build-config-compiler.ts"; +import { buildLifecycleSupervisor } from "./build-lifecycle-supervisor.ts"; interface BuildArgs { readonly outDirRaw: string | null; @@ -80,6 +81,7 @@ async function main({ args }: { readonly args: BuildArgs }): Promise { } await buildConfigCompiler(); + await buildLifecycleSupervisor(); const binaryPath = resolve(distRoot, "hack"); const buildExit = await run({ cmd: [ @@ -112,6 +114,14 @@ async function main({ args }: { readonly args: BuildArgs }): Promise { }); await chmodExecutable({ path: compilerPath }); + await copyFile({ + from: resolve(distRoot, "hack-lifecycle-supervisor"), + to: resolve(releaseDir, "hack-lifecycle-supervisor"), + }); + await chmodExecutable({ + path: resolve(releaseDir, "hack-lifecycle-supervisor"), + }); + const assetsDir = resolve(releaseDir, "assets"); const gifsDir = resolve(assetsDir, "gifs"); const schemasDir = resolve(assetsDir, "schemas"); @@ -390,6 +400,7 @@ export function renderInstallScript(): string { ...installExecutableFunction(), 'mkdir -p "$INSTALL_BIN" "$INSTALL_ASSETS"', 'install_executable "$ROOT/hack-config-compiler" "$INSTALL_BIN/hack-config-compiler"', + 'install_executable "$ROOT/hack-lifecycle-supervisor" "$INSTALL_BIN/hack-lifecycle-supervisor"', 'install_executable "$ROOT/hack" "$INSTALL_BIN/hack"', "", 'if [ -d "$ASSETS_DIR" ]; then', @@ -516,6 +527,7 @@ export function renderCodexSlimInstallScript(): string { ...installExecutableFunction(), 'mkdir -p "$INSTALL_BIN" "$INSTALL_ASSETS"', 'install_executable "$ROOT/hack-config-compiler" "$INSTALL_BIN/hack-config-compiler"', + 'install_executable "$ROOT/hack-lifecycle-supervisor" "$INSTALL_BIN/hack-lifecycle-supervisor"', 'install_executable "$ROOT/hack" "$REAL_BIN"', "", 'if [ -d "$ASSETS_DIR" ]; then', diff --git a/scripts/check-release-lifecycle-supervisor.ts b/scripts/check-release-lifecycle-supervisor.ts new file mode 100644 index 000000000..573dc8476 --- /dev/null +++ b/scripts/check-release-lifecycle-supervisor.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + readdir, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; +import { + LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT, + LIFECYCLE_SUPERVISOR_VERSION, +} from "../src/lib/lifecycle-supervisor-handshake.ts"; + +const name = "hack-lifecycle-supervisor"; +const release = resolve(Bun.argv[2] ?? "dist/release"); +const suffix = `-${process.platform}-${process.arch === "x64" ? "x86_64" : process.arch}.tar.gz`; +const archives = (await readdir(release)).filter( + (file) => file.startsWith("hack-") && file.endsWith(suffix) +); +assert(archives.length === 1, "exactly one host archive"); +const archive = archives[0]; +assert(archive, "host archive"); +const version = archive.slice(5, -suffix.length); +const root = await mkdtemp(join(tmpdir(), "hack-release-supervisor-")); +try { + await run([ + "tar", + "--no-same-owner", + "-xzf", + join(release, archive), + "-C", + root, + ]); + const bundle = join(root, `hack-${version}-release`); + const bytes = await Bun.file(join(bundle, name)).bytes(); + const hash = createHash("sha256").update(bytes).digest("hex"); + assert( + (await Bun.file(join(bundle, "SHA256SUMS")).text()) + .split("\n") + .includes(`${hash} ${name}`), + "archive checksum" + ); + for (const kind of ["standard", "slim", "local-bin"]) { + const fixture = join(root, kind); + const bin = join(fixture, "bin"); + const home = join(fixture, "home"); + await mkdir(home, { recursive: true }); + const env = { + HOME: home, + BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0", + HACK_DAEMON_AUTOSTART_DISABLE: "1", + HACK_HOME: join(home, ".hack"), + HACK_INSTALL_BIN: bin, + HACK_INSTALL_ASSETS: join(fixture, "assets"), + PATH: `${bin}:/usr/bin:/bin`, + }; + if (kind === "local-bin") { + await run( + [ + process.execPath, + "--no-env-file", + join(import.meta.dir, "install-cli.ts"), + "--mode=bin", + `--dir=${bin}`, + ], + env + ); + } else { + await run( + [ + "bash", + join( + bundle, + kind === "slim" ? "install-codex-slim.sh" : "install.sh" + ), + ], + env, + "n\n" + ); + } + const installed = join(bin, name); + const info = await lstat(installed); + assert( + kind === "local-bin" + ? info.isSymbolicLink() + : info.isFile() && + !info.isSymbolicLink() && + info.nlink === 1 && + (info.mode & 0o777) === 0o755, + "independent executable install" + ); + assert( + Buffer.from(await Bun.file(installed).bytes()).equals(Buffer.from(bytes)), + "installed bytes" + ); + // The installed CLI must declare the same handshake its sibling answers; + // its supervisor preflight refuses any other reply. + assert( + (await stdoutOf( + [join(bin, "hack"), LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT], + env + )) === LIFECYCLE_SUPERVISOR_VERSION, + "installed CLI handshake" + ); + const relocated = join(fixture, name); + await copyFile(installed, relocated); + await chmod(relocated, 0o755); + for (const binary of [installed, relocated]) { + if (process.platform === "darwin") { + await run(["/usr/bin/codesign", "--verify", "--strict", binary], env); + } + // Same empty environment as the CLI preflight; exact reply required. + assert( + (await stdoutOf([binary, "--version"], {})) === + LIFECYCLE_SUPERVISOR_VERSION, + "installed supervisor handshake" + ); + // Empty framed input must refuse without launching work or consulting any home. + const child = Bun.spawn([binary, "status"], { + env, + cwd: home, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 15_000, + }); + const [code, output, diagnostic] = await Promise.all([ + child.exited, + new Response(child.stdout).arrayBuffer(), + new Response(child.stderr).text(), + ]); + const frame = Buffer.from(output); + assert( + code === 1 && + frame.length >= 4 && + frame.readUInt32BE(0) === frame.length - 4, + "installed refusal frame" + ); + const receipt: unknown = JSON.parse(frame.subarray(4).toString()); + assert( + isRecord(receipt) && + receipt.version === 1 && + receipt.outcome === "refused", + "installed protocol receipt" + ); + assert( + diagnostic === + "Lifecycle supervisor refused or failed; values omitted.\n", + "value-free refusal" + ); + } + assert((await readdir(home)).length === 0, "no home mutation"); + } + process.stdout.write( + `Release supervisor acceptance (${process.platform}/${process.arch}): archive checksum, normal/slim/local-bin installs, exact bytes, modes, CLI/supervisor handshake, relocation and framed refusal passed\n` + ); +} finally { + await rm(root, { recursive: true, force: true }); +} + +function assert(value: unknown, message: string): asserts value { + if (!value) { + throw new Error(`Release supervisor acceptance failed: ${message}`); + } +} +async function run( + command: string[], + env?: Record, + input?: string +) { + const child = Bun.spawn(command, { + env: env ?? { PATH: "/usr/bin:/bin" }, + stdin: input ? new TextEncoder().encode(input) : "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + }); + const [code, , stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + assert(code === 0, `installer/extraction: ${stderr}`); +} +async function stdoutOf( + command: string[], + env: Record +): Promise { + const child = Bun.spawn(command, { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 15_000, + }); + const [code, stdout] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + assert(code === 0, `${command.at(-1)} exit`); + return stdout.trim(); +} diff --git a/scripts/install-cli.ts b/scripts/install-cli.ts index 35308a9e6..c694ff808 100644 --- a/scripts/install-cli.ts +++ b/scripts/install-cli.ts @@ -62,30 +62,44 @@ async function main(parsed: ParseOk): Promise { return 1; } - const current = await readCurrentInstall(targetPath); - if (current.status === "present" && isSameInstall({ current, desired })) { - printSuccess({ - mode: parsed.mode, - installDir, - targetPath, - repoRoot, - note: "Already installed", + const desiredFiles: DesiredInstall[] = [desired]; + if (desired.kind === "symlink") { + const helper = "hack-lifecycle-supervisor"; + const linkTarget = resolve(repoRoot, "dist", helper); + // Keep both symlinks bound to the same checkout's build output. + desiredFiles.unshift({ + ...desired, + targetPath: resolve(installDir, helper), + linkTarget, }); - return 0; } - - if (current.status === "present" && !parsed.force) { - process.stderr.write(`Refusing to overwrite existing '${CLI_NAME}' at:\n`); - process.stderr.write(` ${targetPath}\n`); - process.stderr.write("Re-run with --force to replace it.\n"); - return 1; + const installs = await Promise.all( + desiredFiles.map(async (file) => ({ + desired: file, + current: await readCurrentInstall(file.targetPath), + })) + ); + for (const install of installs) { + if ( + install.current.status === "present" && + !isSameInstall(install) && + !parsed.force + ) { + process.stderr.write( + `Refusing to overwrite existing install at ${install.desired.targetPath}. Re-run with --force to replace it.\n` + ); + return 1; + } } - - if (current.status === "present") { - await rm(targetPath, { force: true }); + for (const install of installs) { + if (isSameInstall(install)) { + continue; + } + if (install.current.status === "present") { + await rm(install.desired.targetPath, { force: true }); + } + await applyInstall({ desired: install.desired }); } - - await applyInstall({ desired }); printSuccess({ mode: parsed.mode, installDir, targetPath, repoRoot }); return 0; } @@ -194,6 +208,15 @@ async function desiredInstall(opts: { }; } + if ( + !(await fileExists( + resolve(opts.repoRoot, "dist/hack-lifecycle-supervisor") + )) + ) { + throw new Error( + "Missing lifecycle supervisor. Run: bun run build:lifecycle-supervisor" + ); + } const linkTarget = resolve(opts.repoRoot, "dist/hack"); const exists = await fileExists(linkTarget); if (!exists) { diff --git a/scripts/install-codex-slim.sh b/scripts/install-codex-slim.sh index bebbe3018..f4683512f 100755 --- a/scripts/install-codex-slim.sh +++ b/scripts/install-codex-slim.sh @@ -22,6 +22,7 @@ fi repo_root="$(cd "${repo_root}" && pwd)" bun_bin="$(command -v bun)" +(cd "$repo_root" && bun scripts/build-lifecycle-supervisor.ts) wrapper_path="${bin_dir}/hack" mkdir -p "${bin_dir}" "${assets_dir}" "${HOME}/.hack" diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py index 010fd8a1a..4928e415d 100644 --- a/scripts/install-prerelease.py +++ b/scripts/install-prerelease.py @@ -33,11 +33,12 @@ # manifest. The CLI resolves both as siblings of hack-native; older bundles omit both. STORAGE_TOOL = "hack-storage-root-witness" STORAGE_TOOL_MANIFEST = "native-storage-tool.json" +SUPERVISOR_PAYLOAD = frozenset(("hack-lifecycle-supervisor",)) STORAGE_TOOL_PAYLOAD = frozenset((STORAGE_TOOL, STORAGE_TOOL_MANIFEST)) STORAGE_TOOL_KEYS = {"version", "kind", "platform", "architecture", "file", "sha256", "bytes", "sourceRevision"} EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", - "hack-config-compiler")) + "hack-config-compiler", "hack-lifecycle-supervisor")) BUNDLE_FILES = PAYLOAD | {"SHA256SUMS"} MCP_FILES = {"adapter": "hack-mcp-adapter", "owner": "hack-mcp-owner", "backend": "hack-mcp-backend"} @@ -210,7 +211,7 @@ def payload_inventory(names): "Incomplete config compiler payload.") storage = names & STORAGE_TOOL_PAYLOAD require(not storage or storage == STORAGE_TOOL_PAYLOAD, "Incomplete storage tool payload.") - extra = names - BUNDLE_FILES - COMPILER_PAYLOAD - STORAGE_TOOL_PAYLOAD + extra = names - BUNDLE_FILES - COMPILER_PAYLOAD - STORAGE_TOOL_PAYLOAD - SUPERVISOR_PAYLOAD if extra: matches = [MCP_MEMBER.fullmatch(name) for name in extra] require(all(matches), "Foreign candidate MCP payload.") @@ -335,6 +336,8 @@ def verify_signatures(bundle): payload = bundle_inventory(bundle) if "hack-config-compiler" in payload: names.append("hack-config-compiler") + if "hack-lifecycle-supervisor" in payload: + names.append("hack-lifecycle-supervisor") names.extend(sorted(name for name in payload if MCP_MEMBER.fullmatch(name) and not name.endswith("/manifest.json"))) # codesign verifies every supplied path and fails if any is invalid. Keep all @@ -363,7 +366,7 @@ def extract_archive(archive, checksum, destination, version, release_metadata=No total = 0 for entry in source: require(len(entries) < MAX_ARCHIVE_MEMBERS, "Too many archive entries.") - require((entry.name in BUNDLE_FILES | COMPILER_PAYLOAD | STORAGE_TOOL_PAYLOAD + require((entry.name in BUNDLE_FILES | COMPILER_PAYLOAD | STORAGE_TOOL_PAYLOAD | SUPERVISOR_PAYLOAD or MCP_MEMBER.fullmatch(entry.name)) and entry.name not in seen, "Foreign, duplicate or traversing archive entry.") require(entry.isfile() and not entry.issparse() and entry.size > 0 diff --git a/scripts/installer/bundle.ts b/scripts/installer/bundle.ts index 1b68fc428..9c189960e 100644 --- a/scripts/installer/bundle.ts +++ b/scripts/installer/bundle.ts @@ -162,6 +162,7 @@ export function verifySignatures(bundle: string) { ...payload.filter( (name) => name === "hack-config-compiler" || + name === "hack-lifecycle-supervisor" || (MCP.test(name) && !name.endsWith("/manifest.json")) ), ]; diff --git a/scripts/installer/integrity.ts b/scripts/installer/integrity.ts index d0bc27e29..b10b5f22e 100644 --- a/scripts/installer/integrity.ts +++ b/scripts/installer/integrity.ts @@ -39,10 +39,12 @@ const COMPILER = [ "hack.project.schema.json", "hack.local.schema.json", ]; +const SUPERVISOR = ["hack-lifecycle-supervisor"]; const STORAGE = ["hack-storage-root-witness", "native-storage-tool.json"]; export const MCP = /^mcp\/([0-9a-f]{64})\/(manifest\.json|hack-mcp-adapter|hack-mcp-owner|hack-mcp-backend)$(?![\s\S])/; const EXECUTABLES = new Set([ + ...SUPERVISOR, "hack-native", "hack-relay-guest", "hack-cli", @@ -384,7 +386,8 @@ export function inventory(names: string[]): string[] { "Incomplete storage tool payload." ); const extras = names.filter( - (name) => ![...required, ...COMPILER, ...STORAGE].includes(name) + (name) => + ![...required, ...COMPILER, ...STORAGE, ...SUPERVISOR].includes(name) ); if (extras.length) { const identity = MCP.exec(extras[0] ?? "")?.[1]; @@ -482,7 +485,13 @@ export function archiveEntries(compressed: Uint8Array) { const name = `${prefix ? `${prefix}/` : ""}${field(header, 0, 100)}`; requireThat( !entries.has(name) && - ([...CORE, ...COMPILER, ...STORAGE, "SHA256SUMS"].includes(name) || + ([ + ...CORE, + ...COMPILER, + ...STORAGE, + ...SUPERVISOR, + "SHA256SUMS", + ].includes(name) || MCP.test(name)), "Foreign, duplicate or traversing archive entry." ); diff --git a/scripts/prerelease-plan.ts b/scripts/prerelease-plan.ts index 55e272a3e..a33d7e3b9 100644 --- a/scripts/prerelease-plan.ts +++ b/scripts/prerelease-plan.ts @@ -48,6 +48,7 @@ export const PAYLOAD_MODES: Readonly> = { [NATIVE_STORAGE_TOOL_FILE]: 0o500, [NATIVE_STORAGE_TOOL_MANIFEST]: 0o600, "hack-config-compiler": 0o755, + "hack-lifecycle-supervisor": 0o755, "hack.project.schema.json": 0o600, "hack.local.schema.json": 0o600, }; @@ -775,12 +776,17 @@ export async function packagePrerelease({ throw new Error("Native bundle root must be a directory, not an alias"); } const entries = await readdir(bundle); - const compilerPayload = configCompilerPayload(entries); + const helperPayload = [ + ...(entries.includes("hack-lifecycle-supervisor") + ? ["hack-lifecycle-supervisor"] + : []), + ...configCompilerPayload(entries), + ]; const mcpPayload = await nativeCandidateMcpPayload(bundle); - const payload = [...PRERELEASE_PAYLOAD, ...compilerPayload, ...mcpPayload]; + const payload = [...PRERELEASE_PAYLOAD, ...helperPayload, ...mcpPayload]; const expected = [ ...PRERELEASE_PAYLOAD, - ...compilerPayload, + ...helperPayload, "SHA256SUMS", ...(mcpPayload.length ? ["mcp"] : []), ].sort(); diff --git a/scripts/update-homebrew-tap.ts b/scripts/update-homebrew-tap.ts index cfd208b05..b133f6a9c 100644 --- a/scripts/update-homebrew-tap.ts +++ b/scripts/update-homebrew-tap.ts @@ -302,6 +302,7 @@ export function renderFormula({ " def install", ' libexec.install "hack"', ` libexec.install "${FORMULA_CONFIG_COMPILER}"`, + ' libexec.install "hack-lifecycle-supervisor"', ' (libexec/"assets").install Dir["assets/*"] if (buildpath/"assets").directory?', ' (libexec/"assets/binaries").install Dir["binaries/*"] if (buildpath/"binaries").directory?', ' (bin/"hack").write_env_script libexec/"hack", HACK_ASSETS_DIR: libexec/"assets"', @@ -309,6 +310,7 @@ export function renderFormula({ "", " test do", ' assert_match "hack", shell_output("#{bin}/hack --help")', + ' assert_predicate libexec/"hack-lifecycle-supervisor", :executable?', ` assert_match "transport_version", shell_output("#{libexec}/${FORMULA_CONFIG_COMPILER} --protocol")`, ' (testpath/".hack").mkpath', ' (testpath/".hack/hack.project.json").write <<~JSON', diff --git a/src/lib/lifecycle-supervisor-handshake.ts b/src/lib/lifecycle-supervisor-handshake.ts new file mode 100644 index 000000000..d021ecc11 --- /dev/null +++ b/src/lib/lifecycle-supervisor-handshake.ts @@ -0,0 +1,11 @@ +/** Exact `--version` reply this CLI requires from its sibling supervisor. */ +export const LIFECYCLE_SUPERVISOR_VERSION = + "hack-lifecycle-supervisor 0.1.0 protocol=1 incremental=1"; + +/** + * Hidden CLI argument printing LIFECYCLE_SUPERVISOR_VERSION, so an installer + * can check a staged companion against its staged CLI. CLIs released before + * the companion do not answer it. + */ +export const LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT = + "--internal-lifecycle-supervisor-handshake"; diff --git a/src/lib/lifecycle-supervisor.ts b/src/lib/lifecycle-supervisor.ts index 0a9e751da..a3d254d1e 100644 --- a/src/lib/lifecycle-supervisor.ts +++ b/src/lib/lifecycle-supervisor.ts @@ -8,6 +8,7 @@ import { readLifecycleState, resolveLifecycleLogPath, } from "./lifecycle-runtime.ts"; +import { LIFECYCLE_SUPERVISOR_VERSION } from "./lifecycle-supervisor-handshake.ts"; import { ensurePrivateDirectory } from "./private-file.ts"; import { withProjectsRegistryLock } from "./projects-registry-lock.ts"; @@ -106,9 +107,6 @@ export function resolveLifecycleSupervisorBinary(): string { : resolve(import.meta.dir, "../../dist/hack-lifecycle-supervisor"); } -export const LIFECYCLE_SUPERVISOR_VERSION = - "hack-lifecycle-supervisor 0.1.0 protocol=1 incremental=1"; - /** Validate the public, value-free handshake before publishing any ownership. */ export async function preflightLifecycleSupervisor(): Promise<{ path: string; diff --git a/src/lib/release-install.ts b/src/lib/release-install.ts index 0433a03cc..6ae70d550 100644 --- a/src/lib/release-install.ts +++ b/src/lib/release-install.ts @@ -17,7 +17,10 @@ import { import { dirname, join } from "node:path"; import { withProjectsRegistryLock } from "./projects-registry-lock.ts"; import { markReleaseStage, reclaimReleaseStages } from "./release-staging.ts"; -import { verifyStagedReleaseVersion } from "./release-version.ts"; +import { + verifyStagedLifecycleSupervisor, + verifyStagedReleaseVersion, +} from "./release-version.ts"; async function present(path: string) { return await lstat(path).catch((error: NodeJS.ErrnoException) => { @@ -93,9 +96,20 @@ export async function publishRelease(opts: { "hack-config-compiler" ); const hasCompiler = Boolean(await present(compilerSource)); + const supervisorPath = join( + dirname(opts.binaryPath), + "hack-lifecycle-supervisor" + ); + const oldSupervisor = await present(supervisorPath); + const supervisorSource = join( + opts.extractedReleaseDir, + "hack-lifecycle-supervisor" + ); + const hasSupervisor = Boolean(await present(supervisorSource)); if ( (oldBinary && !oldBinary.isFile()) || (oldCompiler && !oldCompiler.isFile()) || + (oldSupervisor && !oldSupervisor.isFile()) || (oldAssets && !oldAssets.isDirectory()) ) { throw new Error( @@ -121,10 +135,12 @@ export async function publishRelease(opts: { let assetsPublished = false; let binaryPublished = false; let compilerPublished = false; + let supervisorPublished = false; let complete = false; let assetsIdentity: Stats | null = null; let binaryIdentity: Stats | null = null; let compilerIdentity: Stats | null = null; + let supervisorIdentity: Stats | null = null; try { await markReleaseStage(binaryStage, opts.binaryPath); await mkdir(assetsStage, { mode: 0o700 }); @@ -154,6 +170,19 @@ export async function publishRelease(opts: { ); await chmod(join(binaryStage, "hack-config-compiler"), 0o755); } + if (oldSupervisor) { + await copyFile( + supervisorPath, + join(backup, "hack-lifecycle-supervisor") + ); + } + if (hasSupervisor) { + await copyFile( + supervisorSource, + join(binaryStage, "hack-lifecycle-supervisor") + ); + await chmod(join(binaryStage, "hack-lifecycle-supervisor"), 0o755); + } const next = join(binaryStage, "hack"); await copyFile(join(opts.extractedReleaseDir, "hack"), next); await chmod(next, 0o755); @@ -182,6 +211,18 @@ export async function publishRelease(opts: { previousCompilerSha256: oldCompiler ? hash(await readFile(join(backup, "hack-config-compiler"))) : null, + supervisor: hasSupervisor ? supervisorPath : null, + installedSupervisorSha256: hasSupervisor + ? hash( + await readFile(join(binaryStage, "hack-lifecycle-supervisor")) + ) + : null, + previousSupervisor: oldSupervisor + ? join(backup, "hack-lifecycle-supervisor") + : null, + previousSupervisorSha256: oldSupervisor + ? hash(await readFile(join(backup, "hack-lifecycle-supervisor"))) + : null, }; await writeFile( join(backup, "manifest.json"), @@ -191,6 +232,12 @@ export async function publishRelease(opts: { await syncTree(backup); await syncTree(binaryStage); await verifyStagedReleaseVersion(next, opts.version); + await verifyStagedLifecycleSupervisor({ + cli: next, + supervisor: hasSupervisor + ? join(binaryStage, "hack-lifecycle-supervisor") + : null, + }); opts.checkpoint?.("staged"); assetsIdentity = await lstat(assetsStage); binaryIdentity = await lstat(next); @@ -199,6 +246,11 @@ export async function publishRelease(opts: { join(binaryStage, "hack-config-compiler") ); } + if (hasSupervisor) { + supervisorIdentity = await lstat( + join(binaryStage, "hack-lifecycle-supervisor") + ); + } await assertIdentity(opts.assetsDir, oldAssets); if (oldAssets) { await rename(opts.assetsDir, oldAssetsPath); @@ -212,7 +264,16 @@ export async function publishRelease(opts: { await rename(join(binaryStage, "hack-config-compiler"), compilerPath); compilerPublished = true; } + if (hasSupervisor) { + await assertIdentity(supervisorPath, oldSupervisor); + await rename( + join(binaryStage, "hack-lifecycle-supervisor"), + supervisorPath + ); + supervisorPublished = true; + } opts.checkpoint?.("compiler-published"); + opts.checkpoint?.("supervisor-published"); await assertIdentity(opts.binaryPath, oldBinary); await rename(next, opts.binaryPath); binaryPublished = true; @@ -245,6 +306,20 @@ export async function publishRelease(opts: { await rm(compilerPath); } } + if (supervisorPublished) { + await assertIdentity(supervisorPath, supervisorIdentity); + if (oldSupervisor) { + const rollback = join(binaryStage, "rollback-supervisor"); + await copyFile( + join(backup, "hack-lifecycle-supervisor"), + rollback + ); + await chmod(rollback, oldSupervisor.mode & 0o777); + await rename(rollback, supervisorPath); + } else { + await rm(supervisorPath); + } + } if (assetsPublished) { await assertIdentity(opts.assetsDir, assetsIdentity); await rm(opts.assetsDir, { recursive: true }); diff --git a/src/lib/release-staging.ts b/src/lib/release-staging.ts index 67fe87689..baa58da3c 100644 --- a/src/lib/release-staging.ts +++ b/src/lib/release-staging.ts @@ -14,6 +14,8 @@ const ENTRIES = new Set([ "stage.json", "hack", "hack-config-compiler", + "hack-lifecycle-supervisor", + "rollback-supervisor", "rollback", "rollback-compiler", ]); diff --git a/src/lib/release-version.ts b/src/lib/release-version.ts index 763265d4c..4e78a5ad8 100644 --- a/src/lib/release-version.ts +++ b/src/lib/release-version.ts @@ -1,3 +1,4 @@ +import { LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT } from "./lifecycle-supervisor-handshake.ts"; import { execOrThrow } from "./shell.ts"; /** Execute only a checksum-verified staged CLI and require its documented output. */ @@ -15,3 +16,48 @@ export async function verifyStagedReleaseVersion( ); } } + +const HANDSHAKE_PATTERN = + /^hack-lifecycle-supervisor \S+ protocol=\d+ incremental=\d+$/; + +async function outputOf(command: readonly string[]): Promise { + const child = Bun.spawn([...command], { + env: {}, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + timeout: 10_000, + }); + const [code, stdout] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + ]); + return code === 0 ? stdout.trim() : null; +} + +/** + * Before publication, require the staged companion to answer exactly the + * handshake its staged CLI expects, so an upgrade cannot replace a working + * pair with a CLI whose supervisor preflight (and therefore native stop) + * fails. Staged CLIs that do not declare a handshake are legacy releases. + */ +export async function verifyStagedLifecycleSupervisor(opts: { + readonly cli: string; + readonly supervisor: string | null; +}): Promise { + const expected = await outputOf([ + opts.cli, + LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT, + ]); + if (expected === null || !HANDSHAKE_PATTERN.test(expected)) { + return; + } + const actual = opts.supervisor + ? await outputOf([opts.supervisor, "--version"]) + : null; + if (actual !== expected) { + throw new Error( + "Release lifecycle supervisor companion is missing or does not match its CLI; installation unchanged." + ); + } +} diff --git a/tests/codex-slim-maintenance.test.ts b/tests/codex-slim-maintenance.test.ts index e43d7b070..827f15177 100644 --- a/tests/codex-slim-maintenance.test.ts +++ b/tests/codex-slim-maintenance.test.ts @@ -29,6 +29,13 @@ printf '%s\\n' "$*" >> "$MAINTENANCE_TRACE" if [ "$*" = "install --frozen-lockfile" ]; then exit "$MAINTENANCE_INSTALL_EXIT" fi +if [ "$*" = "scripts/build-lifecycle-supervisor.ts" ]; then + [ "$MAINTENANCE_BUILD_EXIT" = "0" ] || exit "$MAINTENANCE_BUILD_EXIT" + mkdir -p dist + printf '#!/bin/sh\\nexit 0\\n' > dist/hack-lifecycle-supervisor + chmod 700 dist/hack-lifecycle-supervisor + exit 0 +fi printf 'unlocked install rewrote lockfile\\n' > bun.lock ` ); @@ -39,9 +46,11 @@ printf 'unlocked install rewrote lockfile\\n' > bun.lock async function maintain({ paths, installExit, + buildExit = 0, }: { readonly paths: Awaited>; readonly installExit: number; + readonly buildExit?: number; }) { const child = Bun.spawn(["/bin/bash", script, paths.project], { env: { @@ -51,6 +60,7 @@ async function maintain({ HACK_ASSETS_DIR: join(paths.root, "assets"), MAINTENANCE_TRACE: join(paths.root, "trace"), MAINTENANCE_INSTALL_EXIT: String(installExit), + MAINTENANCE_BUILD_EXIT: String(buildExit), }, stdout: "pipe", stderr: "pipe", @@ -92,7 +102,25 @@ test("successful repeat maintenance preserves the lockfile and wrapper", async ( "preserve this lockfile\n" ); expect(await readFile(join(paths.root, "trace"), "utf8")).toBe( - "install --frozen-lockfile\ninstall --frozen-lockfile\n" + "install --frozen-lockfile\nscripts/build-lifecycle-supervisor.ts\ninstall --frozen-lockfile\nscripts/build-lifecycle-supervisor.ts\n" + ); + } finally { + await rm(paths.root, { recursive: true, force: true }); + } +}); + +test("supervisor build failure preserves the frozen lock and does not install a wrapper", async () => { + const paths = await fixture(); + try { + expect( + (await maintain({ paths, installExit: 0, buildExit: 43 })).exitCode + ).toBe(43); + expect(await readFile(join(paths.project, "bun.lock"), "utf8")).toBe( + "preserve this lockfile\n" + ); + expect(await Bun.file(join(paths.installed, "hack")).exists()).toBe(false); + expect(await readFile(join(paths.root, "trace"), "utf8")).toBe( + "install --frozen-lockfile\nscripts/build-lifecycle-supervisor.ts\n" ); } finally { await rm(paths.root, { recursive: true, force: true }); diff --git a/tests/prerelease-plan.test.ts b/tests/prerelease-plan.test.ts index 2245e89a0..adb8f4787 100644 --- a/tests/prerelease-plan.test.ts +++ b/tests/prerelease-plan.test.ts @@ -1040,6 +1040,51 @@ test("packages the compiler and both schemas beside the CLI", async () => { } }); +test("packages and extracts the supervisor extension with executable mode", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-supervisor-package-")); + try { + const bundle = await fixture(root); + await compilerFixture(bundle); + await writeFile( + join(bundle, "hack-lifecycle-supervisor"), + "synthetic supervisor\n", + { mode: 0o755 } + ); + await Bun.write( + join(bundle, "SHA256SUMS"), + await renderChecksums({ + root: bundle, + names: [ + ...PRERELEASE_PAYLOAD, + "hack-lifecycle-supervisor", + ...CONFIG_COMPILER_PAYLOAD, + ], + }) + ); + const output = join(root, "assets"); + await packagePrerelease({ + plan: createPrereleasePlan(input), + bundle, + output, + }); + expect( + await Bun.file( + join(`${output}-installer-fixture`, "hack-lifecycle-supervisor") + ).text() + ).toBe("synthetic supervisor\n"); + await chmod(join(bundle, "hack-lifecycle-supervisor"), 0o644); + await expect( + packagePrerelease({ + plan: createPrereleasePlan(input), + bundle, + output: join(root, "bad"), + }) + ).rejects.toThrow("unsafe permissions"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("preserves the older complete compiler/project-schema pair", async () => { const root = await mkdtemp(join(tmpdir(), "hack-compiler-legacy-pair-")); try { @@ -1763,6 +1808,7 @@ test("packager payload is exactly the versioned bundle the builder checksums, in // Packaging compares SHA256SUMS byte for byte, so order is part of the contract. expect(versioned.filter((name) => !name.startsWith("mcp/"))).toEqual([ ...PRERELEASE_PAYLOAD, + "hack-lifecycle-supervisor", ...CONFIG_COMPILER_PAYLOAD, ]); // nativeCandidateMcpPayload owns this group and returns it in this order. @@ -1821,7 +1867,7 @@ test("installer accepts exactly the versioned bundle contract with the packaged " return False", "print(json.dumps({", " 'accepted': sorted(m.payload_inventory(names | {'SHA256SUMS'})),", - " 'top_level': sorted(m.PAYLOAD | m.COMPILER_PAYLOAD | m.STORAGE_TOOL_PAYLOAD),", + " 'top_level': sorted(m.PAYLOAD | m.COMPILER_PAYLOAD | m.STORAGE_TOOL_PAYLOAD | m.SUPERVISOR_PAYLOAD),", " 'member_ceiling': m.MAX_ARCHIVE_MEMBERS,", " 'modes': {name: m.payload_mode(name) for name in sorted(names)},", " 'foreign_refused': refused(names | {'SHA256SUMS', 'native-storage-tool.sig'}),", diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py index 1282e7de6..810d2978d 100644 --- a/tests/python/test_prerelease_install.py +++ b/tests/python/test_prerelease_install.py @@ -479,6 +479,25 @@ def fail_compiler(arguments, **options): self.channel.install("5.0.0-next.2", archive, checksum, True) self.assertEqual(self.selection(), before) + def test_supervisor_extension_installs_with_checksums_modes_and_signature_inventory(self): + def payload(entries): + files = {name: value for name, value, _ in with_current_payload(entries) + if name != "SHA256SUMS"} + files["hack-lifecycle-supervisor"] = b"synthetic supervisor" + files["SHA256SUMS"] = "".join(sha(value) + " " + name + "\n" + for name, value in sorted(files.items())).encode() + return [(name, value, tarfile.REGTYPE) for name, value in files.items()] + archive, checksum = self.archive(mutate=payload) + with self.channel.lock(): + self.channel.install("5.0.0-next.1", archive, checksum) + bundle = self.channel.root / "versions/5.0.0-next.1/bundle" + supervisor = bundle / "hack-lifecycle-supervisor" + self.assertEqual(supervisor.read_bytes(), b"synthetic supervisor") + self.assertEqual(supervisor.stat().st_mode & 0o777, 0o755) + self.calls.clear() + installer.verify_signatures(bundle) + self.assertIn(str(supervisor), self.calls[0][0]) + def test_signature_batch_covers_all_executable_assets_in_one_strict_call(self): archive, checksum = self.archive(mutate=with_current_payload) with self.channel.lock(): diff --git a/tests/release-download-installer.test.ts b/tests/release-download-installer.test.ts index 88033a1e7..6f2a06857 100644 --- a/tests/release-download-installer.test.ts +++ b/tests/release-download-installer.test.ts @@ -156,11 +156,12 @@ for (const [kind, render] of [ // Linux, after the compiler was already replaced, leaving a mixed install. for (const [kind, render, running] of [ ["standard", renderInstallScript, "hack"], + ["standard", renderInstallScript, "hack-lifecycle-supervisor"], ["slim", renderCodexSlimInstallScript, "hack-real"], // A slim install over a standard one replaces the running `hack` with the wrapper. ["slim over standard", renderCodexSlimInstallScript, "hack"], ] as const) { - test(`bundled ${kind} installer replaces an executable that is still running`, async () => { + test(`bundled ${kind} installer replaces running ${running}`, async () => { const root = await mkdtemp(join(tmpdir(), "hack-bundled-installer-")); // bash keeps working under another file name. A system `sleep` may be a // multi-call binary that picks its applet from argv[0] and exits at once. @@ -201,6 +202,10 @@ for (const [kind, render, running] of [ join(bundle, "hack-config-compiler"), "#!/bin/sh\necho new compiler\n" ); + await Bun.write( + join(bundle, "hack-lifecycle-supervisor"), + "synthetic supervisor\n" + ); const installer = join(bundle, "install.sh"); await Bun.write(installer, render()); const child = Bun.spawn(["bash", installer], { @@ -236,6 +241,12 @@ for (const [kind, render, running] of [ expect(await Bun.file(join(bin, "hack-config-compiler")).text()).toBe( "#!/bin/sh\necho new compiler\n" ); + expect( + await Bun.file(join(bin, "hack-lifecycle-supervisor")).text() + ).toBe("synthetic supervisor\n"); + expect( + (await stat(join(bin, "hack-lifecycle-supervisor"))).mode & 0o777 + ).toBe(0o755); // Replaced by rename, not rewritten in place. expect((await stat(previous)).ino).not.toBe(before.ino); for (const name of await readdir(bin)) { @@ -244,8 +255,13 @@ for (const [kind, render, running] of [ // Nothing staged is left behind. expect((await readdir(bin)).sort()).toEqual( kind === "standard" - ? ["hack", "hack-config-compiler"] - : ["hack", "hack-config-compiler", "hack-real"] + ? ["hack", "hack-config-compiler", "hack-lifecycle-supervisor"] + : [ + "hack", + "hack-config-compiler", + "hack-lifecycle-supervisor", + "hack-real", + ] ); if (kind !== "standard") { expect(await Bun.file(join(bin, "hack")).text()).toContain( diff --git a/tests/release-host-smoke.test.ts b/tests/release-host-smoke.test.ts index 9d22e56f5..1e7eb787c 100644 --- a/tests/release-host-smoke.test.ts +++ b/tests/release-host-smoke.test.ts @@ -87,6 +87,9 @@ test("CI and the release build use the same pins, build and installed-archive ac expect(runs(release.steps)).toContain( 'bun run build:release "--version=$RELEASE_VERSION" --skip-tests' ); + expect(runs(ci.steps)).toContain( + "bun scripts/check-release-lifecycle-supervisor.ts dist/config-compiler-release\n" + ); // The release accepts the installed archive before it uploads anything. const names = release.steps.map((step) => step.run ?? step.name ?? ""); const accepted = names.indexOf( @@ -97,6 +100,11 @@ test("CI and the release build use the same pins, build and installed-archive ac ); expect(accepted).toBeGreaterThan(-1); expect(uploaded).toBeGreaterThan(accepted); + const supervisorAccepted = names.indexOf( + "bun scripts/check-release-lifecycle-supervisor.ts dist/release" + ); + expect(supervisorAccepted).toBeGreaterThan(-1); + expect(uploaded).toBeGreaterThan(supervisorAccepted); // The CI smoke cannot publish: its token is read-only. expect(ci.permissions).toEqual({ contents: "read" }); }); diff --git a/tests/update-homebrew-tap.test.ts b/tests/update-homebrew-tap.test.ts index f317a8687..7d5991e1a 100644 --- a/tests/update-homebrew-tap.test.ts +++ b/tests/update-homebrew-tap.test.ts @@ -50,6 +50,7 @@ describe("Homebrew formula", () => { test("installs the config compiler beside the executable the wrapper runs", () => { const install = block("def install"); expect(install).toContain('libexec.install "hack"'); + expect(install).toContain('libexec.install "hack-lifecycle-supervisor"'); expect(install).toContain(`libexec.install "${FORMULA_CONFIG_COMPILER}"`); // The env script execs libexec/hack, so the CLI's execPath is libexec/hack; // the compiler must be exactly where the CLI resolves it, never on PATH. diff --git a/tests/upgrade-installer.test.ts b/tests/upgrade-installer.test.ts index 9ca079075..f965e8157 100644 --- a/tests/upgrade-installer.test.ts +++ b/tests/upgrade-installer.test.ts @@ -7,12 +7,17 @@ import { readFile, realpath, rm, + stat, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeReleaseArchive } from "../scripts/build-release.ts"; import { download } from "../scripts/installer/download.ts"; +import { + LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT, + LIFECYCLE_SUPERVISOR_VERSION, +} from "../src/lib/lifecycle-supervisor-handshake.ts"; import { publishRelease } from "../src/lib/release-install.ts"; import { verifyReleaseDirectory } from "../src/lib/release-integrity.ts"; import { @@ -67,6 +72,7 @@ async function releaseFixture( const files: Record = { hack: `#!/bin/sh\ncase "$0" in */.hack-update-stage-*/hack) ;; *) exit 93 ;; esac\nprintf '%s\\n' 'hack v${fault === "version" ? "4.3.0" : version}'\n`, "hack-config-compiler": "#!/bin/sh\nexit 0\n", + "hack-lifecycle-supervisor": "#!/bin/sh\nexit 0\n", "assets/schema.json": "new schema\n", "binaries/tool": "bundled executable\n", }; @@ -328,6 +334,7 @@ for (const phase of [ "staged", "assets-published", "compiler-published", + "supervisor-published", "binary-published", ]) { test(`stable publication rolls back at ${phase}; a rerun installs matching binary/compiler/assets`, async () => { @@ -341,6 +348,8 @@ for (const phase of [ const compiler = join(bin, "hack-config-compiler"); await writeFile(binary, "old CLI"); await writeFile(compiler, "old compiler"); + const supervisor = join(bin, "hack-lifecycle-supervisor"); + await writeFile(supervisor, "old supervisor", { mode: 0o700 }); await writeFile(join(assets, "schema.json"), "old schema"); await writeFile(join(assets, "mutagen"), "user managed extension"); const opts = { @@ -361,6 +370,8 @@ for (const phase of [ ).rejects.toThrow("fixture publication fault"); expect(await readFile(binary, "utf8")).toBe("old CLI"); expect(await readFile(compiler, "utf8")).toBe("old compiler"); + expect(await readFile(supervisor, "utf8")).toBe("old supervisor"); + expect((await stat(supervisor)).mode & 0o777).toBe(0o700); expect(await readFile(join(assets, "schema.json"), "utf8")).toBe( "old schema" ); @@ -381,7 +392,15 @@ for (const phase of [ expect(await readFile(join(result.backupManifest, "../hack"), "utf8")).toBe( "old CLI" ); + expect(await readFile(supervisor, "utf8")).toBe("#!/bin/sh\nexit 0\n"); + expect((await stat(supervisor)).mode & 0o777).toBe(0o755); const manifest = await readFile(result.backupManifest, "utf8"); + expect(JSON.parse(manifest).previousSupervisorSha256).toBe( + sha("old supervisor") + ); + expect(JSON.parse(manifest).installedSupervisorSha256).toBe( + sha("#!/bin/sh\nexit 0\n") + ); for (const value of ["old CLI", "old schema", "user managed extension"]) { expect(manifest.includes(value)).toBe(false); } @@ -396,6 +415,91 @@ for (const phase of [ ).toBe(false); }); } +/** A release CLI that declares a companion handshake, as current builds do. */ +async function requireCompanion(release: string, reply: string | null) { + const handshake = LIFECYCLE_SUPERVISOR_VERSION; + await writeFile( + join(release, "hack"), + `#!/bin/sh\ncase "$0" in */.hack-update-stage-*/hack) ;; *) exit 93 ;; esac\nif [ "$1" = '${LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT}' ]; then printf '%s\\n' '${handshake}'; exit 0; fi\nprintf '%s\\n' 'hack v4.3.1'\n`, + { mode: 0o755 } + ); + const companion = join(release, "hack-lifecycle-supervisor"); + if (reply === null) { + await rm(companion); + } else { + await writeFile( + companion, + `#!/bin/sh\n[ "$1" = --version ] && printf '%s\\n' '${reply}'\n`, + { mode: 0o755 } + ); + } +} +for (const [label, reply] of [ + ["missing", null], + ["incompatible", "hack-lifecycle-supervisor 0.0.9 protocol=0 incremental=0"], +] as const) { + test(`${label} companion for a handshake-declaring CLI leaves the installation unchanged`, async () => { + const root = await scratch(); + const f = await releaseFixture(root); + await requireCompanion(f.release, reply); + const bin = join(root, "bin"); + await mkdir(bin); + const binary = join(bin, "hack"); + const supervisor = join(bin, "hack-lifecycle-supervisor"); + await writeFile(binary, "old CLI"); + await writeFile(supervisor, "old supervisor", { mode: 0o700 }); + await expect( + publishRelease({ + version: "4.3.1", + extractedReleaseDir: f.release, + binaryPath: binary, + assetsDir: join(root, "assets"), + }) + ).rejects.toThrow("companion is missing or does not match its CLI"); + expect(await readFile(binary, "utf8")).toBe("old CLI"); + expect(await readFile(supervisor, "utf8")).toBe("old supervisor"); + expect((await readdir(bin)).sort()).toEqual([ + "hack", + "hack-lifecycle-supervisor", + ]); + }); +} +test("matching companion handshake publishes the pair", async () => { + const root = await scratch(); + const f = await releaseFixture(root); + await requireCompanion(f.release, LIFECYCLE_SUPERVISOR_VERSION); + const binary = join(root, "bin/hack"); + await publishRelease({ + version: "4.3.1", + extractedReleaseDir: f.release, + binaryPath: binary, + assetsDir: join(root, "assets"), + }); + expect( + await readFile(join(root, "bin/hack-lifecycle-supervisor"), "utf8") + ).toContain(LIFECYCLE_SUPERVISOR_VERSION); +}); + +test("failed first supervisor publication removes the new sibling", async () => { + const root = await scratch(); + const f = await releaseFixture(root); + const binary = join(root, "bin/hack"); + await expect( + publishRelease({ + version: "4.3.1", + extractedReleaseDir: f.release, + binaryPath: binary, + assetsDir: join(root, "assets"), + checkpoint: (phase) => { + if (phase === "supervisor-published") { + throw new Error("fixture fault"); + } + }, + }) + ).rejects.toThrow("fixture fault"); + expect(await readdir(join(root, "bin"))).toEqual([]); +}); + test("stable uninstall can be repeated and preserves all user state", async () => { const root = await scratch(); const home = join(root, "home");