diff --git a/.changeset/quiet-daemons-prove.md b/.changeset/quiet-daemons-prove.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/quiet-daemons-prove.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/test/cli/install-vm/README.md b/test/cli/install-vm/README.md index 91e8ea319..cb935824e 100644 --- a/test/cli/install-vm/README.md +++ b/test/cli/install-vm/README.md @@ -1,6 +1,6 @@ # Optional Firecracker install compatibility suite -This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets. +This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, authenticated daemon upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets. ## Run @@ -13,6 +13,7 @@ Requirements: ```sh bun run test:install-vm -- --list bun run test:install-vm -- --scenario pnpm-global-upgrade +bun run test:install-vm -- --scenario authenticated-daemon-upgrade bun run test:install-vm ``` @@ -28,7 +29,7 @@ The runner deliberately does not reclaim a stale `tmp/install-vm/.lock`, because owned by a racing process is unsafe. After an interrupted host dies, confirm no suite is running and remove that lock directory manually before retrying. -Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts `. +Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Fixture preparation also compiles two full Hunk binaries from isolated copies of the exact checkout and reflink-capable dependency snapshots: the current authenticated daemon revision and the immediately preceding incompatible revision. The checkout `sourceIdentity` remains source-only; a separate `daemonUpgradeBuildInputIdentity` frames and hashes the ignored `node_modules` snapshot bytes and contained symlink targets plus the Bun executable bytes/version used by the builder. External dependency symlinks are rejected, and an isolated PATH entry forces nested build commands to use that attested Bun executable. The fixture manifest binds both compiled binary SHA-256 digests, which the guest compares with the live A/B daemon executables. Temporary source, dependency, and package staging trees are removed before fixtures become visible. The daemon-upgrade scenario therefore adds two compilation passes and about one production idle timeout to a targeted run. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Scenario-specific required-evidence contracts bind required command IDs to exact expectation semantics and make missing daemon lifecycle proof fail aggregation and release validation. The daemon scenario stops one exact test-owned upgraded client across incumbent retirement, waits for the other original client to register on the successor, then resumes and verifies the delayed PID/start-token registers without restart; it never signals a daemon. Release validation rejects symlinked or escaping evidence and reads the referenced health, metadata, executable, fixture-manifest, warning, command-log, and session-list artifacts from the run directory instead of trusting result labels alone. It also requires the locally reusable fixture set to pass checkout, build-input, and package checksum verification, then extracts and hashes the actual A/B package binaries as an independent digest trust input; missing or stale local fixtures fail validation. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts `. A targeted development run can be checked with `--scenario `; that explicit subset check is not complete release evidence. ## Security boundary diff --git a/test/cli/install-vm/contract.test.ts b/test/cli/install-vm/contract.test.ts index 93d21690b..73efc7f52 100644 --- a/test/cli/install-vm/contract.test.ts +++ b/test/cli/install-vm/contract.test.ts @@ -117,6 +117,46 @@ describe("install VM contract", () => { scenarios: [{ ...manifest.scenarios[0], script: "../escape.sh" }], }), ).toThrow("unsafe script path"); + expect( + validateScenarioManifest({ + schemaVersion: 1, + scenarios: [ + { + ...manifest.scenarios[0], + requiredEvidence: { + commands: ["run-upgrade"], + commandExpectations: { "run-upgrade": "exit 0" }, + assertions: ["daemon-preserved"], + observations: ["oldDaemonPid", "transcriptPath"], + }, + }, + ], + }).scenarios[0]?.requiredEvidence, + ).toEqual({ + commands: ["run-upgrade"], + commandExpectations: { "run-upgrade": "exit 0" }, + assertions: ["daemon-preserved"], + observations: ["oldDaemonPid", "transcriptPath"], + }); + for (const requiredEvidence of [ + { commands: ["duplicate", "duplicate"] }, + { commands: ["run-upgrade"], commandExpectations: {} }, + { commands: ["run-upgrade"], commandExpectations: { other: "exit 0" } }, + { + commands: ["run-upgrade"], + commandExpectations: { "run-upgrade": "anything passed" }, + }, + { assertions: ["Uppercase"] }, + { observations: ["not-kebab-case"] }, + { unknown: ["value"] }, + ]) { + expect(() => + validateScenarioManifest({ + schemaVersion: 1, + scenarios: [{ ...manifest.scenarios[0], requiredEvidence }], + }), + ).toThrow(); + } }); test("treats expected nonzero commands as passes only when diagnostics match", () => { diff --git a/test/cli/install-vm/contract.ts b/test/cli/install-vm/contract.ts index 282d74184..e3b0c0e7e 100644 --- a/test/cli/install-vm/contract.ts +++ b/test/cli/install-vm/contract.ts @@ -4,12 +4,20 @@ import path from "node:path"; export type InstallVmProfile = "minimal" | "node"; export type InstallVmNetwork = "local" | "live"; +export interface InstallVmScenarioRequiredEvidence { + commands?: string[]; + commandExpectations?: Record; + assertions?: string[]; + observations?: string[]; +} + export interface InstallVmScenario { id: string; description: string; profile: InstallVmProfile; script: string; network: InstallVmNetwork; + requiredEvidence?: InstallVmScenarioRequiredEvidence; } export interface InstallVmScenarioManifest { @@ -97,6 +105,36 @@ export interface InstallVmPins { const SCENARIO_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+$/; +const ZERO_EXIT_COMMAND_EXPECTATIONS = new Set([ + "background PTY remains live", + "SIGSTOP exact owned B client", + "SIGCONT exact owned B client", +]); + +/** Validate one expectation against the closed install-VM command grammar. */ +export function validateInstallVmCommandExpectation(expectation: string, exitCode?: number) { + const exitMatch = /^exit (-?(?:0|[1-9][0-9]*))$/.exec(expectation); + if (exitMatch) { + if (exitCode !== undefined && exitCode !== Number(exitMatch[1])) { + throw new Error(`Install VM command has impossible exit expectation: ${expectation}.`); + } + return; + } + if (expectation === "nonzero exit") { + if (exitCode === 0) { + throw new Error("Install VM command has impossible nonzero exit expectation."); + } + return; + } + if (expectation === "observed exit") return; + if (ZERO_EXIT_COMMAND_EXPECTATIONS.has(expectation)) { + if (exitCode !== undefined && exitCode !== 0) { + throw new Error(`Install VM command has impossible zero-exit expectation: ${expectation}.`); + } + return; + } + throw new Error(`Install VM command has unsupported expectation: ${expectation}.`); +} /** Validate checksum-attested VM inputs and exact versions used by compatibility scenarios. */ export function validateInstallVmPins(value: unknown) { @@ -218,6 +256,62 @@ export function validateScenarioManifest(value: unknown): InstallVmScenarioManif if (path.basename(scenario.script) !== scenario.script || !scenario.script.endsWith(".sh")) { throw new Error(`Scenario ${scenario.id} has an unsafe script path.`); } + if (scenario.requiredEvidence !== undefined) { + if (!scenario.requiredEvidence || typeof scenario.requiredEvidence !== "object") { + throw new Error(`Scenario ${scenario.id} has malformed required evidence.`); + } + const requiredEvidence = scenario.requiredEvidence as Record; + const expectedKeys = ["commands", "commandExpectations", "assertions", "observations"]; + if (Object.keys(requiredEvidence).some((key) => !expectedKeys.includes(key))) { + throw new Error(`Scenario ${scenario.id} has unknown required evidence.`); + } + for (const key of ["commands", "assertions", "observations"] as const) { + const entries = requiredEvidence[key]; + if (entries === undefined) continue; + if ( + !Array.isArray(entries) || + entries.length === 0 || + entries.some( + (entry) => + typeof entry !== "string" || + (key === "observations" + ? !/^[A-Za-z][A-Za-z0-9]*$/.test(entry) + : !SCENARIO_ID_PATTERN.test(entry)), + ) || + new Set(entries).size !== entries.length + ) { + throw new Error(`Scenario ${scenario.id} has malformed required ${key}.`); + } + } + const commandExpectations = requiredEvidence.commandExpectations; + if (commandExpectations !== undefined) { + if ( + !commandExpectations || + typeof commandExpectations !== "object" || + Array.isArray(commandExpectations) || + (Object.getPrototypeOf(commandExpectations) !== Object.prototype && + Object.getPrototypeOf(commandExpectations) !== null) + ) { + throw new Error(`Scenario ${scenario.id} has malformed command expectations.`); + } + const commands = requiredEvidence.commands; + const expectationRecord = commandExpectations as Record; + if ( + !Array.isArray(commands) || + Object.keys(expectationRecord).sort().join("\0") !== [...commands].sort().join("\0") + ) { + throw new Error( + `Scenario ${scenario.id} command expectations must exactly match required commands.`, + ); + } + for (const [commandId, expectation] of Object.entries(expectationRecord)) { + if (!SCENARIO_ID_PATTERN.test(commandId) || typeof expectation !== "string") { + throw new Error(`Scenario ${scenario.id} has malformed command expectations.`); + } + validateInstallVmCommandExpectation(expectation); + } + } + } } return manifest as InstallVmScenarioManifest; diff --git a/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts b/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts new file mode 100644 index 000000000..78f89a7f3 --- /dev/null +++ b/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts @@ -0,0 +1,371 @@ +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { delimiter } from "node:path"; +import { createHash } from "node:crypto"; + +export const DAEMON_UPGRADE_VERSION_A = "899.0.0"; +export const DAEMON_UPGRADE_VERSION_B = "899.0.1"; + +const DAEMON_REVISION_PATTERN = /export const HUNK_SESSION_DAEMON_VERSION = ([1-9][0-9]*);/g; + +/** Read the one numeric Hunk daemon revision declaration used by authenticated app negotiation. */ +export function readDaemonRevision(source: string) { + const matches = [...source.matchAll(DAEMON_REVISION_PATTERN)]; + if (matches.length !== 1) { + throw new Error("Daemon upgrade fixtures require exactly one HUNK_SESSION_DAEMON_VERSION."); + } + const revision = Number(matches[0]![1]); + if (!Number.isSafeInteger(revision) || revision < 2) { + throw new Error("Daemon upgrade fixtures require a daemon revision of at least 2."); + } + return revision; +} + +/** Replace exactly one daemon revision declaration in an isolated fixture checkout. */ +export function replaceDaemonRevision(source: string, revision: number) { + readDaemonRevision(source); + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error("Daemon fixture revision must be a positive safe integer."); + } + return source.replace( + DAEMON_REVISION_PATTERN, + `export const HUNK_SESSION_DAEMON_VERSION = ${revision};`, + ); +} + +/** Enumerate tracked and non-ignored checkout files without consulting committed-only bytes. */ +function checkoutFiles(repoRoot: string) { + const listed = Bun.spawnSync( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + { cwd: repoRoot, stdout: "pipe", stderr: "pipe" }, + ); + if (listed.exitCode !== 0) { + throw new Error( + `Unable to enumerate daemon fixture checkout: ${new TextDecoder().decode(listed.stderr).trim()}`, + ); + } + return new TextDecoder().decode(listed.stdout).split("\0").filter(Boolean); +} + +/** Frame one build-input value so paths and contents cannot concatenate ambiguously. */ +function updateFramed(hash: ReturnType, value: string | Uint8Array) { + const bytes = typeof value === "string" ? Buffer.from(value) : Buffer.from(value); + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(bytes.byteLength)); + hash.update(length); + hash.update(bytes); +} + +/** Attest ignored dependency bytes/symlink targets plus the exact Bun runtime used to build. */ +export function computeDaemonUpgradeBuildInputIdentity( + repoRoot: string, + options: { + dependenciesRoot?: string; + bunExecutable?: string; + bunVersion?: string; + } = {}, +) { + const realRepoRoot = realpathSync(repoRoot); + const dependenciesRoot = options.dependenciesRoot ?? path.join(repoRoot, "node_modules"); + const bunExecutable = options.bunExecutable ?? process.execPath; + const bunVersion = options.bunVersion ?? Bun.version; + if (!existsSync(dependenciesRoot) || !existsSync(bunExecutable)) { + throw new Error("Daemon upgrade build-input attestation requires dependencies and Bun."); + } + const hash = createHash("sha256"); + updateFramed(hash, "hunk-daemon-upgrade-build-input-v1"); + updateFramed(hash, bunVersion); + updateFramed(hash, readFileSync(bunExecutable)); + const walk = (directory: string, relativeDirectory: string) => { + for (const name of readdirSync(directory).sort()) { + const absolute = path.join(directory, name); + const relative = path.posix.join(relativeDirectory, name); + const stat = lstatSync(absolute); + if (stat.isDirectory()) { + updateFramed(hash, `d:${relative}`); + walk(absolute, relative); + } else if (stat.isSymbolicLink()) { + const resolvedTarget = realpathSync(absolute); + const targetRelative = path.relative(realRepoRoot, resolvedTarget); + if ( + targetRelative === ".." || + targetRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(targetRelative) + ) { + throw new Error(`Daemon build input symlink escapes the checkout: ${relative}`); + } + updateFramed(hash, `l:${relative}`); + updateFramed(hash, readlinkSync(absolute)); + } else if (stat.isFile()) { + updateFramed(hash, `f:${relative}`); + updateFramed(hash, readFileSync(absolute)); + } else { + throw new Error(`Unsupported daemon build input: ${relative}`); + } + } + }; + walk(dependenciesRoot, "node_modules"); + return hash.digest("hex"); +} + +/** Build an environment whose `bun` command resolves to the exact attested executable. */ +export function createDaemonUpgradeCompilerEnvironment( + destination: string, + options: { env?: NodeJS.ProcessEnv; bunExecutable?: string } = {}, +) { + const compilerBin = path.join(destination, ".hunk-fixture-compiler-bin"); + const bunExecutable = realpathSync(options.bunExecutable ?? process.execPath); + rmSync(compilerBin, { recursive: true, force: true }); + mkdirSync(compilerBin, { recursive: true }); + const bunLink = path.join(compilerBin, "bun"); + symlinkSync(bunExecutable, bunLink); + const env = { + ...(options.env ?? process.env), + PATH: `${compilerBin}${delimiter}${options.env?.PATH ?? process.env.PATH ?? ""}`, + }; + const resolved = Bun.spawnSync(["sh", "-c", "command -v bun"], { + env, + stdout: "pipe", + stderr: "pipe", + }); + const resolvedPath = new TextDecoder().decode(resolved.stdout).trim(); + if ( + resolved.exitCode !== 0 || + resolvedPath !== bunLink || + realpathSync(resolvedPath) !== bunExecutable + ) { + rmSync(compilerBin, { recursive: true, force: true }); + throw new Error("Daemon fixture compiler did not resolve to the attested Bun executable."); + } + return { + env, + resolvedBun: resolvedPath, + cleanup: () => rmSync(compilerBin, { recursive: true, force: true }), + }; +} + +/** Snapshot installed dependencies into one Linux fixture build without sharing mutable paths. */ +export function snapshotDaemonUpgradeDependencies(repoRoot: string, destination: string) { + if (process.platform !== "linux") { + throw new Error("Daemon upgrade fixture dependency snapshots require Linux cp(1)."); + } + const dependencies = path.join(repoRoot, "node_modules"); + if (!existsSync(dependencies)) { + throw new Error("Daemon upgrade fixture builds require the checkout node_modules directory."); + } + mkdirSync(destination, { recursive: true }); + const copied = Bun.spawnSync( + ["cp", "-a", "--reflink=auto", `${dependencies}${path.sep}.`, destination], + { stdout: "pipe", stderr: "pipe" }, + ); + if (copied.exitCode !== 0) { + throw new Error( + `Unable to snapshot daemon fixture dependencies with cp --reflink=auto: ${new TextDecoder().decode(copied.stderr).trim()}`, + ); + } +} + +/** Return whether a resolved path stays at or below its expected root directory. */ +function pathIsContained(root: string, candidate: string) { + const relative = path.relative(root, candidate); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + +/** Resolve one Git checkout path without accepting lexical traversal. */ +function containedCheckoutPath(root: string, relativePath: string) { + if ( + path.isAbsolute(relativePath) || + relativePath.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`Daemon fixture checkout path escapes the checkout: ${relativePath}`); + } + const absolute = path.resolve(root, ...relativePath.split("/")); + if (!pathIsContained(path.resolve(root), absolute)) { + throw new Error(`Daemon fixture checkout path escapes the checkout: ${relativePath}`); + } + return absolute; +} + +/** Reject existing destination parents that could redirect a checkout copy. */ +function assertNoDestinationParentSymlinks(root: string, relativePath: string) { + let current = path.resolve(root); + for (const segment of relativePath.split("/").slice(0, -1)) { + current = path.join(current, segment); + if (existsSync(current) && lstatSync(current).isSymbolicLink()) { + throw new Error(`Daemon fixture checkout destination contains a symlink: ${relativePath}`); + } + } +} + +/** Copy tracked checkout bytes without allowing source or destination symlinks to escape. */ +export function copyDaemonUpgradeCheckoutFiles(repoRoot: string, destination: string) { + const realRepoRoot = realpathSync(repoRoot); + mkdirSync(destination, { recursive: true }); + const realDestination = realpathSync(destination); + for (const relativePath of checkoutFiles(repoRoot)) { + const source = containedCheckoutPath(realRepoRoot, relativePath); + const resolvedSource = realpathSync(source); + if (!pathIsContained(realRepoRoot, resolvedSource)) { + throw new Error(`Daemon fixture checkout entry escapes the checkout: ${relativePath}`); + } + const target = containedCheckoutPath(realDestination, relativePath); + assertNoDestinationParentSymlinks(realDestination, relativePath); + const stat = lstatSync(source); + mkdirSync(path.dirname(target), { recursive: true }); + if (stat.isSymbolicLink()) { + const linkTarget = readlinkSync(source); + if (path.isAbsolute(linkTarget)) { + throw new Error(`Daemon fixture checkout symlink must be relative: ${relativePath}`); + } + const relocatedTarget = path.resolve(path.dirname(target), linkTarget); + if (!pathIsContained(realDestination, relocatedTarget)) { + throw new Error( + `Daemon fixture checkout symlink escapes the isolated checkout: ${relativePath}`, + ); + } + symlinkSync(linkTarget, target); + continue; + } + if (!stat.isFile()) { + throw new Error(`Unsupported daemon fixture checkout entry: ${relativePath}`); + } + copyFileSync(source, target); + chmodSync(target, stat.mode & 0o777); + } +} + +/** Resolve one mutable fixture source file without following any symlink component. */ +function daemonUpgradeRewriteFile(destination: string, relativePath: string) { + const root = realpathSync(destination); + const target = containedCheckoutPath(root, relativePath); + let current = root; + for (const segment of relativePath.split("/")) { + current = path.join(current, segment); + const stat = lstatSync(current); + if (stat.isSymbolicLink()) { + throw new Error(`Daemon fixture rewrite path may not contain a symlink: ${relativePath}`); + } + } + if (!lstatSync(target).isFile()) { + throw new Error(`Daemon fixture rewrite path must be a regular file: ${relativePath}`); + } + return target; +} + +/** Rewrite only the package version and daemon revision in one isolated checkout. */ +export function rewriteDaemonUpgradeVariantSources( + destination: string, + packageVersion: string, + daemonRevision: number, +) { + const packagePath = daemonUpgradeRewriteFile(destination, "package.json"); + const protocolPath = daemonUpgradeRewriteFile(destination, "src/session/protocol.ts"); + const packageManifest = JSON.parse(readFileSync(packagePath, "utf8")) as Record; + packageManifest.version = packageVersion; + writeFileSync(packagePath, `${JSON.stringify(packageManifest, null, 2)}\n`); + + writeFileSync( + protocolPath, + replaceDaemonRevision(readFileSync(protocolPath, "utf8"), daemonRevision), + ); +} + +/** Copy the exact checkout snapshot into an isolated build tree while preserving file modes. */ +function copyCheckout(repoRoot: string, destination: string) { + copyDaemonUpgradeCheckoutFiles(repoRoot, destination); + snapshotDaemonUpgradeDependencies(repoRoot, path.join(destination, "node_modules")); +} + +/** Build one fully functional fixture binary with only version/revision bytes changed. */ +async function buildVariant( + repoRoot: string, + destination: string, + packageVersion: string, + daemonRevision: number, + buildInputIdentity: string, +) { + copyCheckout(repoRoot, destination); + const snapshotIdentity = computeDaemonUpgradeBuildInputIdentity(destination); + if (snapshotIdentity !== buildInputIdentity) { + throw new Error("Daemon upgrade dependency snapshot changed while it was copied."); + } + rewriteDaemonUpgradeVariantSources(destination, packageVersion, daemonRevision); + const compiler = createDaemonUpgradeCompilerEnvironment(destination); + try { + const proc = Bun.spawn([process.execPath, "run", "./scripts/build-bin.ts"], { + cwd: destination, + env: compiler.env, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + if ((await proc.exited) !== 0) { + throw new Error(`Failed to build daemon upgrade fixture ${packageVersion}.`); + } + } finally { + compiler.cleanup(); + } + const binary = path.join(destination, "dist", "hunk"); + if (!existsSync(binary)) throw new Error(`Missing daemon upgrade binary for ${packageVersion}.`); + return binary; +} + +export interface DaemonUpgradeFixtureBuild { + daemonUpgradeBuildInputIdentity: string; + versionA: string; + versionB: string; + revisionA: number; + revisionB: number; + binaryA: string; + binaryB: string; + binarySha256A: string; + binarySha256B: string; +} + +/** Build incompatible authenticated Hunk binaries from isolated copies of the exact checkout. */ +export async function prepareDaemonUpgradeBinaries(repoRoot: string, buildRoot: string) { + rmSync(buildRoot, { recursive: true, force: true }); + mkdirSync(buildRoot, { recursive: true }); + const daemonUpgradeBuildInputIdentity = computeDaemonUpgradeBuildInputIdentity(repoRoot); + const protocolSource = readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"); + const revisionB = readDaemonRevision(protocolSource); + const revisionA = revisionB - 1; + const binaryA = await buildVariant( + repoRoot, + path.join(buildRoot, "revision-a"), + DAEMON_UPGRADE_VERSION_A, + revisionA, + daemonUpgradeBuildInputIdentity, + ); + const binaryB = await buildVariant( + repoRoot, + path.join(buildRoot, "revision-b"), + DAEMON_UPGRADE_VERSION_B, + revisionB, + daemonUpgradeBuildInputIdentity, + ); + return { + daemonUpgradeBuildInputIdentity, + versionA: DAEMON_UPGRADE_VERSION_A, + versionB: DAEMON_UPGRADE_VERSION_B, + revisionA, + revisionB, + binaryA, + binaryB, + binarySha256A: createHash("sha256").update(readFileSync(binaryA)).digest("hex"), + binarySha256B: createHash("sha256").update(readFileSync(binaryB)).digest("hex"), + } satisfies DaemonUpgradeFixtureBuild; +} diff --git a/test/cli/install-vm/prepare-fixtures.test.ts b/test/cli/install-vm/prepare-fixtures.test.ts index 7086c59db..d0d77f84a 100644 --- a/test/cli/install-vm/prepare-fixtures.test.ts +++ b/test/cli/install-vm/prepare-fixtures.test.ts @@ -5,8 +5,12 @@ import { mkdirSync, mkdtempSync, readFileSync, + readlinkSync, + realpathSync, + renameSync, rmSync, symlinkSync, + unlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,18 +21,42 @@ import { CURL_BAD_CHECKSUM_VERSION, CURL_TRUNCATED_VERSION, CURL_UNAVAILABLE_VERSION, + deriveVerifiedDaemonUpgradeBinaryDigests, FIXTURE_VERSION_A, FIXTURE_VERSION_B, verifyInstallVmFixtures, type InstallVmFixtureManifest, } from "./prepare-fixtures"; +import { + DAEMON_UPGRADE_VERSION_A, + DAEMON_UPGRADE_VERSION_B, + computeDaemonUpgradeBuildInputIdentity, + copyDaemonUpgradeCheckoutFiles, + createDaemonUpgradeCompilerEnvironment, + readDaemonRevision, + replaceDaemonRevision, + rewriteDaemonUpgradeVariantSources, + snapshotDaemonUpgradeDependencies, +} from "./prepare-daemon-upgrade-fixtures"; /** Initialize the minimal Git checkout required by source-identity discovery. */ function initializeTestGitRepo(repo: string) { - const result = Bun.spawnSync(["git", "init", "--quiet"], { cwd: repo, stderr: "pipe" }); + const result = Bun.spawnSync(["git", "init", "--quiet"], { + cwd: repo, + stderr: "pipe", + }); if (result.exitCode !== 0) throw new Error("Unable to initialize test Git repository."); } +/** Add paths to the index of one temporary Git fixture. */ +function addTestGitFiles(repo: string, ...paths: string[]) { + const result = Bun.spawnSync(["git", "add", "--", ...paths], { + cwd: repo, + stderr: "pipe", + }); + if (result.exitCode !== 0) throw new Error("Unable to index test Git files."); +} + /** Hash one small test fixture. */ function sha256(filePath: string) { return createHash("sha256").update(readFileSync(filePath)).digest("hex"); @@ -40,7 +68,20 @@ function writeTestFixtures(repo: string, fixtures: string) { const httpRoot = path.join(fixtures, "http"); mkdirSync(packageRoot, { recursive: true }); mkdirSync(httpRoot, { recursive: true }); - const versions = ["1.0.0", FIXTURE_VERSION_A, FIXTURE_VERSION_B]; + mkdirSync(path.join(repo, "src", "session"), { recursive: true }); + mkdirSync(path.join(repo, "node_modules"), { recursive: true }); + writeFileSync(path.join(repo, "node_modules", "fixture-dependency"), "dependency\n"); + writeFileSync( + path.join(repo, "src", "session", "protocol.ts"), + "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", + ); + const versions = [ + "1.0.0", + DAEMON_UPGRADE_VERSION_A, + DAEMON_UPGRADE_VERSION_B, + FIXTURE_VERSION_A, + FIXTURE_VERSION_B, + ]; const packages = versions.flatMap((version) => ["hunkdiff-linux-x64", "hunkdiff"].map((name) => { const tarball = `${name}-${version}.tgz`; @@ -50,11 +91,20 @@ function writeTestFixtures(repo: string, fixtures: string) { }), ); const manifest: InstallVmFixtureManifest = { - schemaVersion: 1, + schemaVersion: 2, sourceIdentity: computeInstallVmFixtureSourceIdentity(repo), + daemonUpgradeBuildInputIdentity: computeDaemonUpgradeBuildInputIdentity(repo), currentVersion: "1.0.0", versionA: FIXTURE_VERSION_A, versionB: FIXTURE_VERSION_B, + daemonUpgrade: { + versionA: DAEMON_UPGRADE_VERSION_A, + versionB: DAEMON_UPGRADE_VERSION_B, + revisionA: 10, + revisionB: 11, + binarySha256A: "a".repeat(64), + binarySha256B: "b".repeat(64), + }, packages, }; const manifestBytes = `${JSON.stringify(manifest, null, 2)}\n`; @@ -95,6 +145,289 @@ function writeTestFixtures(repo: string, fixtures: string) { } describe("install VM package fixtures", () => { + test("derives trusted daemon binary digests from the actual platform tarballs", async () => { + if (process.platform !== "linux") return; + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-tarball-digests-")); + try { + const packages = path.join(root, "packages"); + mkdirSync(packages, { recursive: true }); + const fixtures = [ + [DAEMON_UPGRADE_VERSION_A, "daemon-a\n"], + [DAEMON_UPGRADE_VERSION_B, "daemon-b\n"], + ] as const; + const entries = []; + const digests: string[] = []; + for (const [version, contents] of fixtures) { + const stage = path.join(root, `stage-${version}`, "package", "bin"); + mkdirSync(stage, { recursive: true }); + const binary = path.join(stage, "hunk"); + writeFileSync(binary, contents); + const tarball = `hunkdiff-linux-x64-${version}.tgz`; + const packed = Bun.spawnSync( + [ + "tar", + "-czf", + path.join(packages, tarball), + "-C", + path.dirname(path.dirname(stage)), + "package/bin/hunk", + ], + { stderr: "pipe" }, + ); + expect(packed.exitCode).toBe(0); + entries.push({ name: "hunkdiff-linux-x64", version, tarball, sha256: "0".repeat(64) }); + digests.push(sha256(binary)); + } + const manifest = { + schemaVersion: 2, + sourceIdentity: "0".repeat(64), + daemonUpgradeBuildInputIdentity: "1".repeat(64), + currentVersion: "1.0.0", + versionA: FIXTURE_VERSION_A, + versionB: FIXTURE_VERSION_B, + daemonUpgrade: { + versionA: DAEMON_UPGRADE_VERSION_A, + versionB: DAEMON_UPGRADE_VERSION_B, + revisionA: 10, + revisionB: 11, + binarySha256A: digests[0]!, + binarySha256B: digests[1]!, + }, + packages: entries, + } satisfies InstallVmFixtureManifest; + + await expect(deriveVerifiedDaemonUpgradeBinaryDigests(root, manifest)).resolves.toEqual({ + binarySha256A: digests[0]!, + binarySha256B: digests[1]!, + }); + manifest.daemonUpgrade.binarySha256A = "f".repeat(64); + await expect(deriveVerifiedDaemonUpgradeBinaryDigests(root, manifest)).rejects.toThrow( + "do not match their manifest", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("attests dependency bytes, symlink targets, and Bun build inputs", () => { + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-inputs-")); + try { + const dependencies = path.join(root, "node_modules"); + const bun = path.join(root, "bun"); + mkdirSync(dependencies, { recursive: true }); + writeFileSync(path.join(dependencies, "dependency"), "one"); + writeFileSync(path.join(dependencies, "other"), "other"); + symlinkSync("dependency", path.join(dependencies, "link")); + writeFileSync(bun, "bun-one"); + const options = { + dependenciesRoot: dependencies, + bunExecutable: bun, + bunVersion: "1.2.3", + }; + const first = computeDaemonUpgradeBuildInputIdentity(root, options); + expect(first).toMatch(/^[0-9a-f]{64}$/); + writeFileSync(path.join(dependencies, "dependency"), "two"); + expect(computeDaemonUpgradeBuildInputIdentity(root, options)).not.toBe(first); + writeFileSync(path.join(dependencies, "dependency"), "one"); + rmSync(path.join(dependencies, "link")); + symlinkSync("other", path.join(dependencies, "link")); + expect(computeDaemonUpgradeBuildInputIdentity(root, options)).not.toBe(first); + + const external = path.join(root, "..", `${path.basename(root)}-external`); + writeFileSync(external, "outside-one"); + rmSync(path.join(dependencies, "link")); + symlinkSync(external, path.join(dependencies, "link")); + expect(() => computeDaemonUpgradeBuildInputIdentity(root, options)).toThrow( + "escapes the checkout", + ); + writeFileSync(external, "outside-two"); + expect(() => computeDaemonUpgradeBuildInputIdentity(root, options)).toThrow( + "escapes the checkout", + ); + rmSync(external, { force: true }); + } finally { + rmSync(path.join(root, "..", `${path.basename(root)}-external`), { + force: true, + }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects escaping checkout symlinks while preserving contained links", () => { + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-checkout-links-")); + const repo = path.join(root, "repo"); + const externalPackage = path.join(root, "external-package.json"); + const externalSource = path.join(root, "external-src"); + try { + mkdirSync(path.join(repo, "src", "session"), { recursive: true }); + initializeTestGitRepo(repo); + writeFileSync(path.join(repo, "package.json"), '{"version":"1.0.0"}\n'); + writeFileSync( + path.join(repo, "src", "session", "protocol.ts"), + "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", + ); + writeFileSync(path.join(repo, "AGENTS.md"), "instructions\n"); + symlinkSync("AGENTS.md", path.join(repo, "CLAUDE.md"), "file"); + addTestGitFiles(repo, "package.json", "src/session/protocol.ts", "AGENTS.md", "CLAUDE.md"); + + writeFileSync(externalPackage, '{"version":"outside"}\n'); + rmSync(path.join(repo, "package.json")); + symlinkSync(path.relative(repo, externalPackage), path.join(repo, "package.json"), "file"); + expect(() => copyDaemonUpgradeCheckoutFiles(repo, path.join(root, "package-copy"))).toThrow( + "entry escapes the checkout", + ); + expect(readFileSync(externalPackage, "utf8")).toBe('{"version":"outside"}\n'); + + rmSync(path.join(repo, "package.json")); + writeFileSync(path.join(repo, "package.json"), '{"version":"1.0.0"}\n'); + mkdirSync(path.join(externalSource, "session"), { recursive: true }); + writeFileSync( + path.join(externalSource, "session", "protocol.ts"), + "export const HUNK_SESSION_DAEMON_VERSION = 99;\n", + ); + rmSync(path.join(repo, "src"), { recursive: true }); + symlinkSync(path.relative(repo, externalSource), path.join(repo, "src"), "dir"); + expect(() => copyDaemonUpgradeCheckoutFiles(repo, path.join(root, "parent-copy"))).toThrow( + "entry escapes the checkout", + ); + + unlinkSync(path.join(repo, "src")); + mkdirSync(path.join(repo, "src", "session"), { recursive: true }); + writeFileSync( + path.join(repo, "src", "session", "protocol.ts"), + "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", + ); + const containedCopy = path.join(root, "contained-copy"); + copyDaemonUpgradeCheckoutFiles(repo, containedCopy); + expect(readlinkSync(path.join(containedCopy, "CLAUDE.md"))).toBe("AGENTS.md"); + expect(readFileSync(path.join(containedCopy, "package.json"), "utf8")).toContain("1.0.0"); + + // A source link can leave and re-enter through another symlink, yet escape after relocation. + symlinkSync(path.join(repo, "AGENTS.md"), path.join(root, "reentry"), "file"); + symlinkSync("../reentry", path.join(repo, "REENTRY.md"), "file"); + addTestGitFiles(repo, "REENTRY.md"); + expect(() => copyDaemonUpgradeCheckoutFiles(repo, path.join(root, "reentry-copy"))).toThrow( + "symlink escapes the isolated checkout", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("refuses to rewrite fixture source through symlink components", () => { + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-rewrite-links-")); + const checkout = path.join(root, "checkout"); + const packageTarget = path.join(checkout, "package-target.json"); + const externalSource = path.join(root, "external-src"); + try { + mkdirSync(path.join(checkout, "src", "session"), { recursive: true }); + writeFileSync(packageTarget, '{"version":"unchanged"}\n'); + symlinkSync("package-target.json", path.join(checkout, "package.json"), "file"); + writeFileSync( + path.join(checkout, "src", "session", "protocol.ts"), + "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", + ); + expect(() => rewriteDaemonUpgradeVariantSources(checkout, "899.0.0", 10)).toThrow( + "rewrite path may not contain a symlink", + ); + expect(readFileSync(packageTarget, "utf8")).toBe('{"version":"unchanged"}\n'); + + rmSync(path.join(checkout, "package.json")); + writeFileSync(path.join(checkout, "package.json"), '{"version":"unchanged"}\n'); + mkdirSync(path.join(externalSource, "session"), { recursive: true }); + const externalProtocol = path.join(externalSource, "session", "protocol.ts"); + writeFileSync(externalProtocol, "export const HUNK_SESSION_DAEMON_VERSION = 99;\n"); + rmSync(path.join(checkout, "src"), { recursive: true }); + symlinkSync(path.relative(checkout, externalSource), path.join(checkout, "src"), "dir"); + expect(() => rewriteDaemonUpgradeVariantSources(checkout, "899.0.0", 10)).toThrow( + "rewrite path may not contain a symlink", + ); + expect(readFileSync(path.join(checkout, "package.json"), "utf8")).toContain("unchanged"); + expect(readFileSync(externalProtocol, "utf8")).toContain("VERSION = 99"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("forces fixture compiler resolution ahead of a hostile PATH", () => { + if (process.platform !== "linux") return; + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-compiler-")); + try { + const hostileBin = path.join(root, "hostile"); + const attestedBun = path.join(root, "attested-bun"); + mkdirSync(hostileBin); + writeFileSync(path.join(hostileBin, "bun"), "#!/bin/sh\nexit 99\n", { + mode: 0o755, + }); + writeFileSync(attestedBun, "attested compiler bytes\n", { mode: 0o755 }); + const compiler = createDaemonUpgradeCompilerEnvironment(path.join(root, "build"), { + env: { + ...process.env, + PATH: `${hostileBin}${path.delimiter}${process.env.PATH ?? ""}`, + }, + bunExecutable: attestedBun, + }); + try { + expect(realpathSync(compiler.resolvedBun)).toBe(realpathSync(attestedBun)); + expect(compiler.resolvedBun.startsWith(path.join(root, "build"))).toBe(true); + } finally { + compiler.cleanup(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("snapshots dependencies while preserving workspace links inside the isolated tree", () => { + if (process.platform !== "linux") return; + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-deps-")); + const isolated = path.join(root, "isolated"); + try { + mkdirSync(path.join(root, "repo", "node_modules", "@hunk"), { + recursive: true, + }); + mkdirSync(path.join(root, "repo", "packages", "session-broker"), { + recursive: true, + }); + writeFileSync(path.join(root, "repo", "node_modules", "dependency.txt"), "snapshot\n"); + symlinkSync( + "../../packages/session-broker", + path.join(root, "repo", "node_modules", "@hunk", "session-broker"), + ); + mkdirSync(path.join(isolated, "packages", "session-broker"), { + recursive: true, + }); + writeFileSync(path.join(isolated, "packages", "session-broker", "marker"), "isolated\n"); + + snapshotDaemonUpgradeDependencies( + path.join(root, "repo"), + path.join(isolated, "node_modules"), + ); + + expect(readFileSync(path.join(isolated, "node_modules", "dependency.txt"), "utf8")).toBe( + "snapshot\n", + ); + expect(realpathSync(path.join(isolated, "node_modules", "@hunk", "session-broker"))).toBe( + realpathSync(path.join(isolated, "packages", "session-broker")), + ); + writeFileSync(path.join(root, "repo", "node_modules", "dependency.txt"), "mutated\n"); + expect(readFileSync(path.join(isolated, "node_modules", "dependency.txt"), "utf8")).toBe( + "snapshot\n", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rewrites exactly one positive daemon revision for isolated full-binary fixtures", () => { + const source = "export const HUNK_SESSION_DAEMON_VERSION = 11;\n"; + expect(readDaemonRevision(source)).toBe(11); + expect(replaceDaemonRevision(source, 10)).toContain("VERSION = 10;"); + expect(() => readDaemonRevision(`${source}${source}`)).toThrow("exactly one"); + expect(() => readDaemonRevision("export const unrelated = 1;\n")).toThrow("exactly one"); + expect(() => replaceDaemonRevision(source, 0)).toThrow("positive safe integer"); + }); + test("builds two distinct Linux x64 package topologies from staged engine metadata", () => { const stagedEngines = { node: ">=99" }; const fixtureA = buildSyntheticPackageManifests(FIXTURE_VERSION_A, stagedEngines); @@ -174,7 +507,9 @@ describe("install VM package fixtures", () => { const fixtures = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-fixtures-")); try { initializeTestGitRepo(repo); - mkdirSync(path.join(repo, "test", "cli", "install-vm"), { recursive: true }); + mkdirSync(path.join(repo, "test", "cli", "install-vm"), { + recursive: true, + }); writeFileSync(path.join(repo, "package.json"), '{"name":"fixture","version":"1.0.0"}\n'); writeFileSync(path.join(repo, "test", "cli", "install-vm", "source.txt"), "source\n"); const manifest = writeTestFixtures(repo, fixtures); @@ -204,13 +539,60 @@ describe("install VM package fixtures", () => { const drifted = { ...manifest, packages: manifest.packages.slice(0, -1) }; writeFileSync(path.join(fixtures, "fixture-manifest.json"), `${JSON.stringify(drifted)}\n`); writeFileSync(httpManifest, `${JSON.stringify(drifted)}\n`); - expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("exactly six"); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("exactly ten"); + + const staleContract = { + ...manifest, + daemonUpgrade: { ...manifest.daemonUpgrade, revisionA: 9 }, + }; + writeFileSync( + path.join(fixtures, "fixture-manifest.json"), + `${JSON.stringify(staleContract)}\n`, + ); + writeFileSync(httpManifest, `${JSON.stringify(staleContract)}\n`); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("malformed or stale"); + + const unboundBinary = { + ...manifest, + daemonUpgrade: { + ...manifest.daemonUpgrade, + binarySha256A: "b".repeat(64), + }, + }; + writeFileSync( + path.join(fixtures, "fixture-manifest.json"), + `${JSON.stringify(unboundBinary)}\n`, + ); + writeFileSync(httpManifest, `${JSON.stringify(unboundBinary)}\n`); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("malformed or stale"); + const unknownContractKey = { + ...manifest, + daemonUpgrade: { ...manifest.daemonUpgrade, extra: true }, + }; + writeFileSync( + path.join(fixtures, "fixture-manifest.json"), + `${JSON.stringify(unknownContractKey)}\n`, + ); + writeFileSync(httpManifest, `${JSON.stringify(unknownContractKey)}\n`); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("malformed or stale"); writeFileSync(path.join(fixtures, "fixture-manifest.json"), manifestBytes); writeFileSync(httpManifest, manifestBytes); const tarball = path.join(fixtures, "packages", manifest.packages[0]!.tarball); writeFileSync(tarball, "tampered\n"); expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); + rmSync(tarball); + symlinkSync(path.join(repo, "package.json"), tarball); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); + rmSync(tarball); + symlinkSync(path.join("..", manifest.packages[1]!.tarball), tarball); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); + rmSync(tarball); + const packages = path.join(fixtures, "packages"); + const realPackages = path.join(fixtures, "packages-real"); + renameSync(packages, realPackages); + symlinkSync(realPackages, packages); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); } finally { rmSync(repo, { recursive: true, force: true }); rmSync(fixtures, { recursive: true, force: true }); diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts index ff65d3d6b..5f08d99f9 100644 --- a/test/cli/install-vm/prepare-fixtures.ts +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -5,6 +5,7 @@ import { existsSync, lstatSync, mkdirSync, + mkdtempSync, readFileSync, readlinkSync, renameSync, @@ -13,6 +14,7 @@ import { writeFileSync, } from "node:fs"; import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; import path from "node:path"; import { assertNoMandatoryBunDependency, @@ -23,6 +25,13 @@ import { } from "../../../scripts/prebuilt-package-helpers"; import { stagePrebuiltArtifact } from "../../../scripts/build-prebuilt-artifact"; import { npmCommand } from "../../../scripts/script-helpers"; +import { + DAEMON_UPGRADE_VERSION_A, + DAEMON_UPGRADE_VERSION_B, + computeDaemonUpgradeBuildInputIdentity, + prepareDaemonUpgradeBinaries, + readDaemonRevision, +} from "./prepare-daemon-upgrade-fixtures"; export const FIXTURE_VERSION_A = "900.0.0"; export const FIXTURE_VERSION_B = "900.0.1"; @@ -38,11 +47,20 @@ export interface FixturePackage { } export interface InstallVmFixtureManifest { - schemaVersion: 1; + schemaVersion: 2; sourceIdentity: string; + daemonUpgradeBuildInputIdentity: string; currentVersion: string; versionA: string; versionB: string; + daemonUpgrade: { + versionA: string; + versionB: string; + revisionA: number; + revisionB: number; + binarySha256A: string; + binarySha256B: string; + }; packages: FixturePackage[]; } @@ -156,12 +174,29 @@ export function computeInstallVmFixtureSourceIdentity(repoRoot: string) { return hash.digest("hex"); } +/** Resolve one fixture file without allowing symlink components or output-root escape. */ +function containedFixtureFile(outputRoot: string, relativePath: string) { + const root = path.resolve(outputRoot); + let current = root; + for (const segment of relativePath.split("/")) { + current = path.join(current, segment); + const stat = lstatSync(current); + if (stat.isSymbolicLink()) + throw new Error(`Fixture path may not be a symlink: ${relativePath}`); + } + const resolved = path.resolve(current); + if (!resolved.startsWith(`${root}${path.sep}`) || !lstatSync(resolved).isFile()) { + throw new Error(`Fixture path is not a contained regular file: ${relativePath}`); + } + return resolved; +} + /** Verify reusable fixtures still match this checkout and every declared tarball digest. */ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { const manifestPath = path.join(outputRoot, "fixture-manifest.json"); const manifestBytes = readFileSync(manifestPath, "utf8"); const manifest = JSON.parse(manifestBytes) as InstallVmFixtureManifest; - if (manifest.schemaVersion !== 1) throw new Error("Fixture manifest must use schemaVersion 1."); + if (manifest.schemaVersion !== 2) throw new Error("Fixture manifest must use schemaVersion 2."); const expectedIdentity = computeInstallVmFixtureSourceIdentity(repoRoot); if (manifest.sourceIdentity !== expectedIdentity) { throw new Error("Install VM fixtures do not match the current checkout identity."); @@ -178,14 +213,45 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { if (manifest.versionA !== FIXTURE_VERSION_A || manifest.versionB !== FIXTURE_VERSION_B) { throw new Error("Fixture upgrade versions do not match the harness contract."); } + if ( + manifest.daemonUpgradeBuildInputIdentity !== computeDaemonUpgradeBuildInputIdentity(repoRoot) + ) { + throw new Error( + "Fixture daemon upgrade build inputs do not match current dependencies and Bun.", + ); + } + const expectedRevisionB = readDaemonRevision( + readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"), + ); + const daemonUpgrade = manifest.daemonUpgrade; + if ( + !daemonUpgrade || + typeof daemonUpgrade !== "object" || + Object.keys(daemonUpgrade).sort().join("\0") !== + ["binarySha256A", "binarySha256B", "revisionA", "revisionB", "versionA", "versionB"].join( + "\0", + ) || + daemonUpgrade.versionA !== DAEMON_UPGRADE_VERSION_A || + daemonUpgrade.versionB !== DAEMON_UPGRADE_VERSION_B || + daemonUpgrade.revisionB !== expectedRevisionB || + daemonUpgrade.revisionA !== expectedRevisionB - 1 || + !/^[0-9a-f]{64}$/.test(daemonUpgrade.binarySha256A) || + !/^[0-9a-f]{64}$/.test(daemonUpgrade.binarySha256B) || + daemonUpgrade.binarySha256A === daemonUpgrade.binarySha256B + ) { + throw new Error("Fixture daemon upgrade contract is malformed or stale."); + } const expectedIdentities = new Set( - [manifest.currentVersion, manifest.versionA, manifest.versionB].flatMap((version) => [ - `hunkdiff-linux-x64@${version}`, - `hunkdiff@${version}`, - ]), + [ + manifest.currentVersion, + daemonUpgrade.versionA, + daemonUpgrade.versionB, + manifest.versionA, + manifest.versionB, + ].flatMap((version) => [`hunkdiff-linux-x64@${version}`, `hunkdiff@${version}`]), ); if (!Array.isArray(manifest.packages) || manifest.packages.length !== expectedIdentities.size) { - throw new Error("Fixture manifest must contain exactly six coupled packages."); + throw new Error("Fixture manifest must contain exactly ten coupled packages."); } const identities = new Set(); @@ -202,8 +268,16 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { if (!expectedIdentities.has(identity)) throw new Error(`Unexpected fixture package: ${identity}`); identities.add(identity); - const tarballPath = path.join(outputRoot, "packages", fixturePackage.tarball); - if (!existsSync(tarballPath) || sha256(tarballPath) !== fixturePackage.sha256) { + let tarballPath: string; + try { + tarballPath = containedFixtureFile( + outputRoot, + path.posix.join("packages", fixturePackage.tarball), + ); + } catch { + throw new Error(`Fixture tarball checksum mismatch: ${fixturePackage.tarball}`); + } + if (sha256(tarballPath) !== fixturePackage.sha256) { throw new Error(`Fixture tarball checksum mismatch: ${fixturePackage.tarball}`); } } @@ -259,6 +333,71 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { return manifest; } +const MAX_DAEMON_FIXTURE_BINARY_BYTES = 512 * 1024 * 1024; + +/** Hash the actual daemon binaries stored in a checksum-verified local fixture set. */ +export async function deriveVerifiedDaemonUpgradeBinaryDigests( + outputRoot: string, + manifest: InstallVmFixtureManifest, +) { + const digestForVersion = async (version: string) => { + const fixturePackage = manifest.packages.find( + (entry) => entry.name === "hunkdiff-linux-x64" && entry.version === version, + ); + if (!fixturePackage) { + throw new Error(`Trusted fixture set is missing the Linux x64 package for ${version}.`); + } + const tarball = containedFixtureFile( + outputRoot, + path.posix.join("packages", fixturePackage.tarball), + ); + const extractionRoot = mkdtempSync(path.join(tmpdir(), "hunk-daemon-fixture-binary-")); + try { + const extraction = Bun.spawn( + [ + "tar", + "-xzf", + tarball, + "--no-same-owner", + "--no-same-permissions", + "-C", + extractionRoot, + "package/bin/hunk", + ], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ); + const timeout = setTimeout(() => extraction.kill(), 30_000); + timeout.unref?.(); + const exitCode = await extraction.exited; + clearTimeout(timeout); + const stderr = await new Response(extraction.stderr).text(); + if (exitCode !== 0) { + throw new Error( + `Unable to extract trusted daemon fixture ${version}: ${stderr.trim() || `tar exited ${exitCode}`}`, + ); + } + const binary = containedFixtureFile(extractionRoot, "package/bin/hunk"); + const stat = lstatSync(binary); + if (stat.size <= 0 || stat.size > MAX_DAEMON_FIXTURE_BINARY_BYTES) { + throw new Error(`Trusted daemon fixture ${version} has an invalid binary size.`); + } + return sha256(binary); + } finally { + rmSync(extractionRoot, { recursive: true, force: true }); + } + }; + + const binarySha256A = await digestForVersion(manifest.daemonUpgrade.versionA); + const binarySha256B = await digestForVersion(manifest.daemonUpgrade.versionB); + if ( + binarySha256A !== manifest.daemonUpgrade.binarySha256A || + binarySha256B !== manifest.daemonUpgrade.binarySha256B + ) { + throw new Error("Trusted daemon fixture package binaries do not match their manifest digests."); + } + return { binarySha256A, binarySha256B }; +} + /** Write stable indented JSON with a trailing newline. */ function writeJson(filePath: string, value: unknown) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); @@ -354,6 +493,52 @@ async function stageSyntheticPackage( ]; } +/** Stage a full compiled Hunk binary behind the same coupled npm package topology. */ +async function stageDaemonUpgradePackage( + repoRoot: string, + stageRoot: string, + version: string, + binary: string, + engines: Readonly>, + packageOutput: string, +) { + const { meta, platform } = buildSyntheticPackageManifests(version, engines); + const platformDir = path.join(stageRoot, `${platform.name}-daemon-${version}`); + mkdirSync(path.join(platformDir, "bin"), { recursive: true }); + writeJson(path.join(platformDir, "package.json"), platform); + copyFileSync(binary, path.join(platformDir, "bin", "hunk")); + chmodSync(path.join(platformDir, "bin", "hunk"), 0o755); + + const metaDir = path.join(stageRoot, `hunkdiff-daemon-${version}`); + mkdirSync(path.join(metaDir, "bin"), { recursive: true }); + mkdirSync(path.join(metaDir, "dist", "npm"), { recursive: true }); + copyFileSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); + chmodSync(path.join(metaDir, "bin", "hunk.cjs"), 0o755); + copyFixtureSkills(repoRoot, metaDir); + writeFileSync( + path.join(metaDir, "dist", "npm", "main.js"), + `console.error('Full daemon upgrade fixture ${version} requires its Linux x64 platform package.');\nprocess.exitCode = 1;\n`, + ); + writeJson(path.join(metaDir, "package.json"), meta); + + const platformTarball = await packPackage(platformDir, packageOutput); + const metaTarball = await packPackage(metaDir, packageOutput); + return [ + { + name: platform.name, + version, + tarball: platformTarball, + sha256: sha256(path.join(packageOutput, platformTarball)), + }, + { + name: meta.name, + version, + tarball: metaTarball, + sha256: sha256(path.join(packageOutput, metaTarball)), + }, + ]; +} + /** Stage one synthetic standalone archive and matching checksum manifest. */ async function stageSyntheticCurlArchive( repoRoot: string, @@ -407,9 +592,11 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str try { const packageOutput = path.join(temporaryRoot, "packages"); const stageRoot = path.join(temporaryRoot, "stage"); + const daemonBuildRoot = path.join(temporaryRoot, "daemon-builds"); mkdirSync(packageOutput, { recursive: true }); mkdirSync(stageRoot, { recursive: true }); + const daemonUpgrade = await prepareDaemonUpgradeBinaries(repoRoot, daemonBuildRoot); const currentPlatform = path.join(releaseRoot, "hunkdiff-linux-x64"); if (!existsSync(currentPlatform)) { throw new Error("Install VM fixtures require a Linux x64 prebuilt package."); @@ -427,6 +614,28 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str sha256: sha256(path.join(packageOutput, tarball)), }); } + packages.push( + ...(await stageDaemonUpgradePackage( + repoRoot, + stageRoot, + daemonUpgrade.versionA, + daemonUpgrade.binaryA, + currentManifest.engines, + packageOutput, + )), + ); + packages.push( + ...(await stageDaemonUpgradePackage( + repoRoot, + stageRoot, + daemonUpgrade.versionB, + daemonUpgrade.binaryB, + currentManifest.engines, + packageOutput, + )), + ); + // Remove isolated source/build trees before fixtures become atomically visible. + rmSync(daemonBuildRoot, { recursive: true, force: true }); packages.push( ...(await stageSyntheticPackage( repoRoot, @@ -454,7 +663,10 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str `${JSON.stringify({ tag_name: `v${currentVersion}` })}\n`, ); const artifactRoot = path.join(stageRoot, "artifacts"); - const artifactDir = stagePrebuiltArtifact({ repoRoot, outputRoot: artifactRoot }); + const artifactDir = stagePrebuiltArtifact({ + repoRoot, + outputRoot: artifactRoot, + }); const archiveName = "hunkdiff-linux-x64.tar.gz"; const goodDownloadDir = path.join(downloads, `v${currentVersion}`); mkdirSync(goodDownloadDir, { recursive: true }); @@ -494,11 +706,20 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str writeFileSync(path.join(httpRoot, "install.sh"), installer); const fixtureManifest: InstallVmFixtureManifest = { - schemaVersion: 1, + schemaVersion: 2, sourceIdentity, + daemonUpgradeBuildInputIdentity: daemonUpgrade.daemonUpgradeBuildInputIdentity, currentVersion, versionA: FIXTURE_VERSION_A, versionB: FIXTURE_VERSION_B, + daemonUpgrade: { + versionA: daemonUpgrade.versionA, + versionB: daemonUpgrade.versionB, + revisionA: daemonUpgrade.revisionA, + revisionB: daemonUpgrade.revisionB, + binarySha256A: daemonUpgrade.binarySha256A, + binarySha256B: daemonUpgrade.binarySha256B, + }, packages, }; writeJson(path.join(temporaryRoot, "fixture-manifest.json"), fixtureManifest); @@ -510,6 +731,9 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str }; writeJson(path.join(temporaryRoot, "curl-versions.json"), curlVersions); writeJson(path.join(httpRoot, "curl-versions.json"), curlVersions); + // Staging inputs are not release fixtures; discard their duplicate archives and source trees + // before the atomically published directory becomes visible to reusable VM runs. + rmSync(stageRoot, { recursive: true, force: true }); verifyInstallVmFixtures(repoRoot, temporaryRoot); if (existsSync(outputRoot)) renameSync(outputRoot, backupRoot); diff --git a/test/cli/install-vm/results.test.ts b/test/cli/install-vm/results.test.ts index c4471cbe9..4f948a6f3 100644 --- a/test/cli/install-vm/results.test.ts +++ b/test/cli/install-vm/results.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -20,6 +28,140 @@ const scenario = { network: "local" as const, }; +const daemonScenario = { + id: "authenticated-daemon-upgrade", + description: "Daemon upgrade evidence", + profile: "node" as const, + script: "authenticated-daemon-upgrade.sh", + network: "local" as const, + requiredEvidence: { + commands: ["upgrade-daemon-b"], + commandExpectations: { "upgrade-daemon-b": "exit 0" }, + }, +}; + +/** Write a compact but semantically complete authenticated-upgrade release result. */ +function writeDaemonReleaseEvidence(output: string) { + const directory = path.join(output, "scenarios", daemonScenario.id); + mkdirSync(directory, { recursive: true }); + const observations = { + daemonPackageVersionA: "899.0.0", + daemonPackageVersionB: "899.0.1", + daemonRevisionA: "10", + daemonRevisionB: "11", + daemonUpgradeBuildInputIdentity: "c".repeat(64), + oldDaemonPid: "100", + oldDaemonStartToken: "1000", + newDaemonPid: "200", + newDaemonStartToken: "2000", + oldClientPid: "101", + oldClientStartToken: "1001", + newFirstClientPid: "201", + newFirstClientStartToken: "2001", + newSecondClientPid: "202", + newSecondClientStartToken: "2002", + newFirstWrapperStartToken: "3001", + newSecondWrapperStartToken: "3002", + oldExecutableDigest: "a".repeat(64), + newExecutableDigest: "b".repeat(64), + oldExecutableLocation: "/fixture/a", + newExecutableLocation: "/fixture/b", + oldExecutablePath: "old-executable.txt", + newExecutablePath: "new-executable.txt", + fixtureManifestPath: "daemon-fixture-manifest.json", + reconnectDurationMs: "70000", + overlapHealthPath: "overlap-health.json", + recoveredHealthPath: "recovered-health.json", + oldMetadataPath: "old-metadata.json", + recoveredMetadataPath: "recovered-metadata.json", + oldSessionListPath: "old-session-list.json", + firstRecoveredSessionListPath: "first-recovered-session-list.json", + recoveredSessionListPath: "recovered-session-list.json", + incompatibleWarningPath: "incompatible-warning.log", + }; + const files: Record = { + "overlap-health.json": '{"ok":true}', + "recovered-health.json": '{"ok":true}', + "old-metadata.json": '{"pid":100}', + "recovered-metadata.json": '{"pid":200}', + "old-session-list.json": '{"sessions":[{"pid":101}]}', + "first-recovered-session-list.json": '{"sessions":[{"pid":201}]}', + "recovered-session-list.json": '{"sessions":[{"pid":201},{"pid":202}]}', + "incompatible-warning.log": + "Close older Hunk windows; this window will reconnect automatically.\n", + "old-executable.txt": `pid=100\nstartToken=1000\nlocation=/fixture/a\ndigest=${"a".repeat(64)}\n`, + "new-executable.txt": `pid=200\nstartToken=2000\nlocation=/fixture/b\ndigest=${"b".repeat(64)}\n`, + "daemon-fixture-manifest.json": JSON.stringify({ + schemaVersion: 2, + sourceIdentity, + daemonUpgradeBuildInputIdentity: "c".repeat(64), + daemonUpgrade: { + versionA: "899.0.0", + versionB: "899.0.1", + revisionA: 10, + revisionB: 11, + binarySha256A: "a".repeat(64), + binarySha256B: "b".repeat(64), + }, + }), + }; + for (const [relativePath, contents] of Object.entries(files)) { + writeFileSync(path.join(directory, relativePath), contents); + } + const artifacts = Object.keys(files).map((relativePath) => + path.posix.join("scenarios", daemonScenario.id, relativePath), + ); + const result = { + schemaVersion: 1 as const, + run: { + id: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:02:00Z", + platform: "linux-x64" as const, + sourceIdentity, + status: "passed" as const, + }, + tools: { + firecracker: "Firecracker v1.16.1", + kernel: "6.18.44", + node: "v24.14.1", + npm: "11.11.0", + pnpm: "11.23.0", + verdaccio: "v6.10.1", + }, + scenarios: [ + { + id: daemonScenario.id, + description: daemonScenario.description, + status: "passed" as const, + durationMs: 100, + exitCode: 0, + commands: [ + { + id: "upgrade-daemon-b", + status: "passed" as const, + expectation: "exit 0", + exitCode: 0, + logPath: "incompatible-warning.log", + }, + ], + observations, + assertions: [ + { + id: "migration", + status: "passed" as const, + expected: "recovered", + actual: "recovered", + message: "evidence matched", + }, + ], + artifacts, + }, + ], + }; + return { result, directory }; +} + describe("install VM results", () => { test("parses assertion protocol and rejects malformed fields", () => { expect(parseAssertionTsv("missing\tpassed\texit 1\texit 1\texpected failure\n")).toEqual([ @@ -156,6 +298,25 @@ describe("install VM results", () => { releaseExpected, ), ).toThrow("malformed command evidence"); + expect(() => + validateInstallVmReleaseResult( + { + ...result, + scenarios: [ + { + ...result.scenarios[0], + commands: [ + { + ...result.scenarios[0]!.commands[0], + expectation: "looks successful", + }, + ], + }, + ], + }, + releaseExpected, + ), + ).toThrow("unsupported expectation"); for (const tools of [ { ...result.tools, pnpm: "11.22.0" }, @@ -185,6 +346,260 @@ describe("install VM results", () => { } }); + test("rejects tampered authenticated daemon upgrade artifacts and observations", () => { + const output = mkdtempSync(path.join(tmpdir(), "hunk-daemon-release-evidence-")); + try { + const { result, directory } = writeDaemonReleaseEvidence(output); + const expected = { + sourceIdentity, + pnpmVersion: "11.23.0", + scenarios: [daemonScenario], + resultDirectory: output, + daemonUpgradeBuildInputIdentity: "c".repeat(64), + daemonRevision: 11, + daemonUpgradeBinaryDigests: { + binarySha256A: "a".repeat(64), + binarySha256B: "b".repeat(64), + }, + }; + expect(validateInstallVmReleaseResult(result, expected)).toBe(result); + const mutate = (update: (copy: typeof result) => void) => { + const copy = structuredClone(result); + update(copy); + return () => validateInstallVmReleaseResult(copy, expected); + }; + + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.daemonRevisionB = "10"; + })(), + ).toThrow("revisions must be adjacent"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.daemonRevisionA = "1"; + copy.scenarios[0]!.observations.daemonRevisionB = "2"; + })(), + ).toThrow("does not match this checkout"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.newDaemonPid = "100"; + copy.scenarios[0]!.observations.newDaemonStartToken = "1000"; + })(), + ).toThrow("reused the incumbent process identity"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.newExecutableDigest = "a".repeat(64); + })(), + ).toThrow("digests are invalid or equal"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.reconnectDurationMs = "120001"; + })(), + ).toThrow("duration exceeds its bound"); + + writeFileSync(path.join(directory, "overlap-health.json"), '{"ok":true,"pid":100}'); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "not exact minimal health", + ); + writeFileSync(path.join(directory, "overlap-health.json"), '{"ok":true}'); + writeFileSync(path.join(directory, "old-metadata.json"), '{"pid":999}'); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "PID does not match observations", + ); + writeFileSync(path.join(directory, "old-metadata.json"), '{"pid":100}'); + writeFileSync( + path.join(directory, "recovered-session-list.json"), + '{"sessions":[{"pid":201}]}', + ); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "not the original clients", + ); + writeFileSync( + path.join(directory, "recovered-session-list.json"), + '{"sessions":[{"pid":201},{"pid":202}]}', + ); + writeFileSync( + path.join(directory, "old-executable.txt"), + `pid=100\nstartToken=9999\nlocation=/fixture/a\ndigest=${"a".repeat(64)}\n`, + ); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "does not match observations", + ); + writeFileSync( + path.join(directory, "old-executable.txt"), + `pid=100\nstartToken=1000\nlocation=/fixture/a\ndigest=${"a".repeat(64)}\n`, + ); + const fixtureManifestPath = path.join(directory, "daemon-fixture-manifest.json"); + const fixtureManifest = JSON.parse(readFileSync(fixtureManifestPath, "utf8")); + fixtureManifest.daemonUpgrade.binarySha256A = "d".repeat(64); + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "fixture manifest does not match", + ); + fixtureManifest.daemonUpgrade.binarySha256A = "a".repeat(64); + fixtureManifest.daemonUpgrade.extra = true; + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "fixture manifest does not match", + ); + delete fixtureManifest.daemonUpgrade.extra; + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.commands[0]!.expectation = "exit 0"; + copy.scenarios[0]!.commands[0]!.exitCode = 1; + })(), + ).toThrow("impossible exit expectation"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.commands[0]!.expectation = "observed exit"; + copy.scenarios[0]!.commands[0]!.exitCode = 97; + })(), + ).toThrow("command upgrade-daemon-b expected exit 0"); + + const coherentlyTampered = structuredClone(result); + coherentlyTampered.scenarios[0]!.observations.oldExecutableDigest = "d".repeat(64); + coherentlyTampered.scenarios[0]!.observations.newExecutableDigest = "e".repeat(64); + writeFileSync( + path.join(directory, "old-executable.txt"), + `pid=100\nstartToken=1000\nlocation=/fixture/a\ndigest=${"d".repeat(64)}\n`, + ); + writeFileSync( + path.join(directory, "new-executable.txt"), + `pid=200\nstartToken=2000\nlocation=/fixture/b\ndigest=${"e".repeat(64)}\n`, + ); + fixtureManifest.daemonUpgrade.binarySha256A = "d".repeat(64); + fixtureManifest.daemonUpgrade.binarySha256B = "e".repeat(64); + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + expect(() => validateInstallVmReleaseResult(coherentlyTampered, expected)).toThrow( + "fixture manifest does not match", + ); + writeFileSync( + path.join(directory, "old-executable.txt"), + `pid=100\nstartToken=1000\nlocation=/fixture/a\ndigest=${"a".repeat(64)}\n`, + ); + writeFileSync( + path.join(directory, "new-executable.txt"), + `pid=200\nstartToken=2000\nlocation=/fixture/b\ndigest=${"b".repeat(64)}\n`, + ); + fixtureManifest.daemonUpgrade.binarySha256A = "a".repeat(64); + fixtureManifest.daemonUpgrade.binarySha256B = "b".repeat(64); + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + + const outside = path.join(output, "outside-health.json"); + writeFileSync(outside, '{"ok":true}'); + unlinkSync(path.join(directory, "overlap-health.json")); + symlinkSync(outside, path.join(directory, "overlap-health.json")); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "may not be a symlink", + ); + unlinkSync(path.join(directory, "overlap-health.json")); + symlinkSync("recovered-health.json", path.join(directory, "overlap-health.json")); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "may not be a symlink", + ); + unlinkSync(path.join(directory, "overlap-health.json")); + writeFileSync(path.join(directory, "overlap-health.json"), '{"ok":true}'); + + unlinkSync(path.join(directory, "recovered-health.json")); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "references missing artifact", + ); + } finally { + rmSync(output, { recursive: true, force: true }); + } + }); + + test("enforces scenario-specific required evidence during aggregation and release validation", () => { + const output = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-required-")); + const requiredScenario = { + ...scenario, + requiredEvidence: { + commands: ["upgrade"], + commandExpectations: { upgrade: "exit 0" }, + assertions: ["daemon-preserved"], + observations: ["daemonPid", "transcriptPath"], + }, + }; + try { + const scenarioDir = path.join(output, "scenarios", scenario.id); + mkdirSync(path.join(scenarioDir, "commands"), { recursive: true }); + writeFileSync( + path.join(scenarioDir, "result.json"), + `${JSON.stringify({ id: scenario.id, exitCode: 0, durationMs: 10 })}\n`, + ); + writeFileSync( + path.join(scenarioDir, "commands.tsv"), + "upgrade\tpassed\texit 0\t0\tcommands/upgrade.log\n", + ); + writeFileSync(path.join(scenarioDir, "commands", "upgrade.log"), "ok\n"); + writeFileSync( + path.join(scenarioDir, "assertions.tsv"), + "daemon-preserved\tpassed\talive\talive\told daemon survived\n", + ); + writeFileSync(path.join(scenarioDir, "transcript.log"), "transcript\n"); + writeFileSync( + path.join(scenarioDir, "observations.tsv"), + "daemonPid\t123\ntranscriptPath\ttranscript.log\n", + ); + const aggregated = aggregateInstallVmResults({ + outputDir: output, + runId: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + sourceIdentity, + scenarios: [requiredScenario], + tools: {}, + }); + expect(aggregated.run.status).toBe("passed"); + + writeFileSync(path.join(scenarioDir, "observations.tsv"), "daemonPid\t123\n"); + expect( + aggregateInstallVmResults({ + outputDir: output, + runId: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + sourceIdentity, + scenarios: [requiredScenario], + tools: {}, + }).run.status, + ).toBe("failed"); + + const releaseExpected = { + sourceIdentity, + pnpmVersion: "11.23.0", + scenarios: [requiredScenario], + }; + const release = { + ...aggregated, + tools: { + firecracker: "Firecracker v1.16.1", + kernel: "6.18.44", + node: "v24.14.1", + npm: "11.11.0", + pnpm: releaseExpected.pnpmVersion, + verdaccio: "v6.10.1", + }, + }; + expect(validateInstallVmReleaseResult(release, releaseExpected)).toBe(release); + const missing = { + ...release, + scenarios: [ + { + ...release.scenarios[0]!, + observations: { daemonPid: "123" }, + }, + ], + }; + expect(() => validateInstallVmReleaseResult(missing, releaseExpected)).toThrow( + "required observation transcriptPath", + ); + } finally { + rmSync(output, { recursive: true, force: true }); + } + }); + test("writes deterministic JSON and JUnit projections", () => { const output = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-result-")); try { diff --git a/test/cli/install-vm/results.ts b/test/cli/install-vm/results.ts index 722471e30..8f06d6ebb 100644 --- a/test/cli/install-vm/results.ts +++ b/test/cli/install-vm/results.ts @@ -1,7 +1,15 @@ -import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { + existsSync, + lstatSync, + readFileSync, + readdirSync, + realpathSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import { buildInstallVmJunit, + validateInstallVmCommandExpectation, type InstallVmAssertion, type InstallVmCommandResult, type InstallVmRunResult, @@ -9,6 +17,10 @@ import { type InstallVmScenario, type InstallVmScenarioResult, } from "./contract"; +import { + DAEMON_UPGRADE_VERSION_A, + DAEMON_UPGRADE_VERSION_B, +} from "./prepare-daemon-upgrade-fixtures"; interface RawScenarioResult { id: string; @@ -34,6 +46,34 @@ function safeArtifactPath(value: string) { return normalized; } +/** Resolve an artifact without allowing any symlink component or root escape. */ +function containedArtifact( + root: string, + relativePath: string, + expectedKind: "file" | "directory" | "either" = "either", +) { + const safe = safeArtifactPath(relativePath); + const realRoot = realpathSync(root); + let current = realRoot; + for (const segment of safe.split("/")) { + current = path.join(current, segment); + const stat = lstatSync(current); + if (stat.isSymbolicLink()) throw new Error(`Install VM artifact may not be a symlink: ${safe}`); + } + const resolved = realpathSync(current); + if (resolved !== realRoot && !resolved.startsWith(`${realRoot}${path.sep}`)) { + throw new Error(`Install VM artifact escapes its run directory: ${safe}`); + } + const stat = lstatSync(resolved); + if (expectedKind === "file" && !stat.isFile()) { + throw new Error(`Install VM artifact is not a regular file: ${safe}`); + } + if (expectedKind === "directory" && !stat.isDirectory()) { + throw new Error(`Install VM artifact is not a directory: ${safe}`); + } + return resolved; +} + /** Parse guest assertion TSV without allowing embedded control fields. */ export function parseAssertionTsv(contents: string): InstallVmAssertion[] { if (!contents.trim()) return []; @@ -80,7 +120,13 @@ export function parseCommandTsv(contents: string): InstallVmCommandResult[] { throw new Error(`Invalid command status for ${id}: ${status}`); } if (!Number.isSafeInteger(exitCode)) throw new Error(`Invalid command exit code for ${id}.`); - return { id, status, expectation, exitCode, logPath: safeArtifactPath(logPath) }; + return { + id, + status, + expectation, + exitCode, + logPath: safeArtifactPath(logPath), + }; }); } @@ -112,6 +158,273 @@ function hasUniqueIds(records: readonly Record[]) { return ids.every((id) => typeof id === "string") && new Set(ids).size === ids.length; } +/** Require each scenario-declared proof item to exist exactly once and be successful/nonempty. */ +function validateRequiredEvidence( + scenario: InstallVmScenario, + evidence: { + commands: readonly { id: string; status: string; expectation: string }[]; + assertions: readonly { id: string; status: string }[]; + observations: Readonly>; + }, +) { + for (const id of scenario.requiredEvidence?.commands ?? []) { + const matches = evidence.commands.filter((command) => command.id === id); + if (matches.length !== 1 || matches[0]?.status !== "passed") { + throw new Error(`Install VM scenario ${scenario.id} is missing required command ${id}.`); + } + const expectedExpectation = scenario.requiredEvidence?.commandExpectations?.[id]; + if (expectedExpectation !== undefined && matches[0]?.expectation !== expectedExpectation) { + throw new Error( + `Install VM scenario ${scenario.id} command ${id} expected ${expectedExpectation}, got ${matches[0]?.expectation}.`, + ); + } + } + for (const id of scenario.requiredEvidence?.assertions ?? []) { + const matches = evidence.assertions.filter((assertion) => assertion.id === id); + if (matches.length !== 1 || matches[0]?.status !== "passed") { + throw new Error(`Install VM scenario ${scenario.id} is missing required assertion ${id}.`); + } + } + for (const key of scenario.requiredEvidence?.observations ?? []) { + const value = evidence.observations[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Install VM scenario ${scenario.id} is missing required observation ${key}.`); + } + } +} + +const DAEMON_UPGRADE_SCENARIO_ID = "authenticated-daemon-upgrade"; +const DAEMON_UPGRADE_WARNING = "Close older Hunk windows"; +const MAX_DAEMON_RECONNECT_DURATION_MS = 120_000; + +/** Parse one required positive integer observation. */ +function positiveObservation(observations: Record, key: string) { + const value = Number(observations[key]); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Authenticated daemon upgrade has invalid ${key}.`); + } + return value; +} + +/** Read one scenario artifact beneath the validated run directory. */ +function readScenarioArtifact(resultDirectory: string, scenarioId: string, relativePath: string) { + const safeRelativePath = safeArtifactPath(relativePath); + try { + return readFileSync( + containedArtifact( + resultDirectory, + path.posix.join("scenarios", scenarioId, safeRelativePath), + "file", + ), + "utf8", + ); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Install VM artifact")) throw error; + throw new Error(`Install VM scenario ${scenarioId} is missing artifact ${safeRelativePath}.`); + } +} + +/** Validate the daemon migration scenario's process and protocol evidence from guest artifacts. */ +function validateAuthenticatedDaemonUpgradeEvidence( + scenario: Record, + resultDirectory: string, + runSourceIdentity: string, + expectedBuildInputIdentity?: string, + expectedDaemonRevision?: number, + expectedBinaryDigests?: { readonly binarySha256A: string; readonly binarySha256B: string }, +) { + const observations = scenario.observations as Record; + if ( + observations.daemonPackageVersionA !== DAEMON_UPGRADE_VERSION_A || + observations.daemonPackageVersionB !== DAEMON_UPGRADE_VERSION_B + ) { + throw new Error("Authenticated daemon upgrade fixture versions are not fixed."); + } + const revisionA = positiveObservation(observations, "daemonRevisionA"); + const revisionB = positiveObservation(observations, "daemonRevisionB"); + if (revisionB !== revisionA + 1) { + throw new Error("Authenticated daemon upgrade revisions must be adjacent."); + } + if (expectedDaemonRevision !== undefined && revisionB !== expectedDaemonRevision) { + throw new Error("Authenticated daemon upgrade revision does not match this checkout."); + } + + const oldDaemonPid = positiveObservation(observations, "oldDaemonPid"); + const oldDaemonStartToken = positiveObservation(observations, "oldDaemonStartToken"); + const newDaemonPid = positiveObservation(observations, "newDaemonPid"); + const newDaemonStartToken = positiveObservation(observations, "newDaemonStartToken"); + if (oldDaemonPid === newDaemonPid && oldDaemonStartToken === newDaemonStartToken) { + throw new Error("Authenticated daemon upgrade reused the incumbent process identity."); + } + const oldClientPid = positiveObservation(observations, "oldClientPid"); + const newFirstClientPid = positiveObservation(observations, "newFirstClientPid"); + const newSecondClientPid = positiveObservation(observations, "newSecondClientPid"); + positiveObservation(observations, "oldClientStartToken"); + positiveObservation(observations, "newFirstClientStartToken"); + positiveObservation(observations, "newSecondClientStartToken"); + positiveObservation(observations, "newFirstWrapperStartToken"); + positiveObservation(observations, "newSecondWrapperStartToken"); + + const oldDigest = observations.oldExecutableDigest ?? ""; + const newDigest = observations.newExecutableDigest ?? ""; + if ( + !SOURCE_IDENTITY_PATTERN.test(oldDigest) || + !SOURCE_IDENTITY_PATTERN.test(newDigest) || + oldDigest === newDigest + ) { + throw new Error("Authenticated daemon upgrade executable digests are invalid or equal."); + } + const parseExecutableEvidence = (key: "oldExecutablePath" | "newExecutablePath") => { + const fields = Object.fromEntries( + readScenarioArtifact(resultDirectory, DAEMON_UPGRADE_SCENARIO_ID, observations[key]!) + .trimEnd() + .split("\n") + .map((line) => { + const index = line.indexOf("="); + if (index < 1) throw new Error(`Authenticated daemon upgrade ${key} is malformed.`); + return [line.slice(0, index), line.slice(index + 1)]; + }), + ); + if ( + Object.keys(fields).sort().join("\0") !== + ["digest", "location", "pid", "startToken"].join("\0") + ) { + throw new Error(`Authenticated daemon upgrade ${key} is malformed.`); + } + return fields; + }; + const oldExecutable = parseExecutableEvidence("oldExecutablePath"); + const newExecutable = parseExecutableEvidence("newExecutablePath"); + for (const [evidence, pid, token, location, digest, key] of [ + [ + oldExecutable, + oldDaemonPid, + oldDaemonStartToken, + observations.oldExecutableLocation, + oldDigest, + "oldExecutablePath", + ], + [ + newExecutable, + newDaemonPid, + newDaemonStartToken, + observations.newExecutableLocation, + newDigest, + "newExecutablePath", + ], + ] as const) { + if ( + evidence.pid !== String(pid) || + evidence.startToken !== String(token) || + evidence.location !== location || + evidence.digest !== digest + ) { + throw new Error(`Authenticated daemon upgrade ${key} does not match observations.`); + } + } + const fixtureManifest = JSON.parse( + readScenarioArtifact( + resultDirectory, + DAEMON_UPGRADE_SCENARIO_ID, + observations.fixtureManifestPath!, + ), + ) as Record; + const fixtureUpgrade = fixtureManifest.daemonUpgrade as Record | undefined; + const buildInputIdentity = fixtureManifest.daemonUpgradeBuildInputIdentity; + if ( + fixtureManifest.sourceIdentity !== runSourceIdentity || + fixtureManifest.schemaVersion !== 2 || + !isRecord(fixtureUpgrade) || + Object.keys(fixtureUpgrade).sort().join("\0") !== + ["binarySha256A", "binarySha256B", "revisionA", "revisionB", "versionA", "versionB"].join( + "\0", + ) || + fixtureUpgrade.versionA !== DAEMON_UPGRADE_VERSION_A || + fixtureUpgrade.versionB !== DAEMON_UPGRADE_VERSION_B || + fixtureUpgrade.revisionA !== revisionA || + fixtureUpgrade.revisionB !== revisionB || + fixtureUpgrade.binarySha256A !== oldDigest || + fixtureUpgrade.binarySha256B !== newDigest || + expectedBinaryDigests?.binarySha256A !== oldDigest || + expectedBinaryDigests?.binarySha256B !== newDigest || + typeof buildInputIdentity !== "string" || + !SOURCE_IDENTITY_PATTERN.test(buildInputIdentity) || + observations.daemonUpgradeBuildInputIdentity !== buildInputIdentity || + (expectedBuildInputIdentity !== undefined && buildInputIdentity !== expectedBuildInputIdentity) + ) { + throw new Error( + "Authenticated daemon upgrade fixture manifest does not match release evidence.", + ); + } + + const reconnectDuration = positiveObservation(observations, "reconnectDurationMs"); + if (reconnectDuration > MAX_DAEMON_RECONNECT_DURATION_MS) { + throw new Error("Authenticated daemon upgrade reconnect duration exceeds its bound."); + } + + for (const key of ["overlapHealthPath", "recoveredHealthPath"] as const) { + if ( + readScenarioArtifact(resultDirectory, DAEMON_UPGRADE_SCENARIO_ID, observations[key]!) !== + '{"ok":true}' + ) { + throw new Error(`Authenticated daemon upgrade ${key} is not exact minimal health.`); + } + } + for (const [key, expectedPid] of [ + ["oldMetadataPath", oldDaemonPid], + ["recoveredMetadataPath", newDaemonPid], + ] as const) { + const metadata = JSON.parse( + readScenarioArtifact(resultDirectory, DAEMON_UPGRADE_SCENARIO_ID, observations[key]!), + ) as { pid?: unknown }; + if (metadata.pid !== expectedPid) { + throw new Error(`Authenticated daemon upgrade ${key} PID does not match observations.`); + } + } + + const readSessionPids = ( + key: "oldSessionListPath" | "firstRecoveredSessionListPath" | "recoveredSessionListPath", + ) => { + const value = JSON.parse( + readScenarioArtifact(resultDirectory, DAEMON_UPGRADE_SCENARIO_ID, observations[key]!), + ) as { sessions?: Array<{ pid?: unknown }> }; + if (!Array.isArray(value.sessions) || value.sessions.some((entry) => !isRecord(entry))) { + throw new Error(`Authenticated daemon upgrade ${key} has malformed sessions.`); + } + return value.sessions + .map((entry) => entry.pid) + .sort((left, right) => Number(left) - Number(right)); + }; + if (JSON.stringify(readSessionPids("oldSessionListPath")) !== JSON.stringify([oldClientPid])) { + throw new Error("Authenticated daemon upgrade old session PID does not match its client."); + } + if ( + JSON.stringify(readSessionPids("firstRecoveredSessionListPath")) !== + JSON.stringify([newFirstClientPid]) + ) { + throw new Error( + "Authenticated daemon upgrade first successor session is not its original client.", + ); + } + const recoveredPids = [newFirstClientPid, newSecondClientPid].sort((left, right) => left - right); + if ( + JSON.stringify(readSessionPids("recoveredSessionListPath")) !== JSON.stringify(recoveredPids) + ) { + throw new Error( + "Authenticated daemon upgrade recovered session PIDs are not the original clients.", + ); + } + if ( + !readScenarioArtifact( + resultDirectory, + DAEMON_UPGRADE_SCENARIO_ID, + observations.incompatibleWarningPath!, + ).includes(DAEMON_UPGRADE_WARNING) + ) { + throw new Error("Authenticated daemon upgrade warning evidence is missing required guidance."); + } +} + /** Validate that release evidence is complete, consistent, and matches this checkout. */ export function validateInstallVmReleaseResult( value: unknown, @@ -119,6 +432,13 @@ export function validateInstallVmReleaseResult( sourceIdentity: string; pnpmVersion: string; scenarios: readonly InstallVmScenario[]; + resultDirectory?: string; + daemonUpgradeBuildInputIdentity?: string; + daemonRevision?: number; + daemonUpgradeBinaryDigests?: { + readonly binarySha256A: string; + readonly binarySha256B: string; + }; }, ) { if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.run)) { @@ -214,6 +534,7 @@ export function validateInstallVmReleaseResult( throw new Error(`Install VM release result has malformed command evidence for ${id}.`); } safeArtifactPath(command.logPath); + validateInstallVmCommandExpectation(command.expectation, command.exitCode as number); } const assertionRecords = scenario.assertions.filter(isRecord); @@ -236,7 +557,57 @@ export function validateInstallVmReleaseResult( if (typeof artifact !== "string") { throw new Error(`Install VM release result has malformed artifacts for ${id}.`); } - safeArtifactPath(artifact); + const relativePath = safeArtifactPath(artifact); + if (expected.resultDirectory) { + try { + containedArtifact(expected.resultDirectory, relativePath); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Install VM artifact")) + throw error; + throw new Error(`Install VM release result references missing artifact ${relativePath}.`); + } + } + } + if (expected.resultDirectory) { + for (const command of commandRecords as Array<{ + logPath: string; + expectation: string; + exitCode: number; + status: string; + }>) { + const declaredPath = path.posix.join("scenarios", id, command.logPath); + const declared = (scenario.artifacts as string[]).some( + (artifact) => artifact === declaredPath || declaredPath.startsWith(`${artifact}/`), + ); + if (!declared) throw new Error(`Install VM command log is not declared: ${declaredPath}.`); + containedArtifact(expected.resultDirectory, declaredPath, "file"); + } + } + validateRequiredEvidence(definition, { + commands: commandRecords as Array<{ id: string; status: string; expectation: string }>, + assertions: assertionRecords as Array<{ id: string; status: string }>, + observations: scenario.observations as Record, + }); + for (const key of definition.requiredEvidence?.observations ?? []) { + if (!key.endsWith("Path")) continue; + const relativePath = (scenario.observations as Record)[key]!; + const expectedArtifact = path.posix.join("scenarios", id, relativePath); + if (!(scenario.artifacts as string[]).includes(expectedArtifact)) { + throw new Error(`Install VM scenario ${id} is missing required path artifact ${key}.`); + } + } + if (id === DAEMON_UPGRADE_SCENARIO_ID) { + if (!expected.resultDirectory) { + throw new Error("Authenticated daemon upgrade validation requires its run directory."); + } + validateAuthenticatedDaemonUpgradeEvidence( + scenario, + expected.resultDirectory, + value.run.sourceIdentity as string, + expected.daemonUpgradeBuildInputIdentity, + expected.daemonRevision, + expected.daemonUpgradeBinaryDigests, + ); } } @@ -301,6 +672,21 @@ export function aggregateInstallVmResults(options: { message: "guest returned no command evidence", }); } + try { + validateRequiredEvidence(scenario, { + commands, + assertions, + observations, + }); + } catch (error) { + assertions.push({ + id: "required-evidence", + status: "failed", + expected: "complete declared evidence", + actual: "missing", + message: error instanceof Error ? error.message : "required evidence missing", + }); + } const artifacts = readdirSync(directory) .filter( (entry) => diff --git a/test/cli/install-vm/scenarios.json b/test/cli/install-vm/scenarios.json index 5e9bbd08c..68a2fd708 100644 --- a/test/cli/install-vm/scenarios.json +++ b/test/cli/install-vm/scenarios.json @@ -15,6 +15,90 @@ "script": "npm-global-upgrade.sh", "network": "local" }, + { + "id": "authenticated-daemon-upgrade", + "description": "Keep an incompatible authenticated daemon alive until quiescence, then reconnect upgraded windows to one successor.", + "profile": "node", + "script": "authenticated-daemon-upgrade.sh", + "network": "local", + "requiredEvidence": { + "commands": [ + "install-daemon-a", + "old-session-list", + "upgrade-daemon-b", + "incompatible-daemon-b", + "suspend-new-second", + "resume-new-second", + "recovered-session-list" + ], + "commandExpectations": { + "install-daemon-a": "exit 0", + "old-session-list": "exit 0", + "upgrade-daemon-b": "exit 0", + "incompatible-daemon-b": "nonzero exit", + "suspend-new-second": "SIGSTOP exact owned B client", + "resume-new-second": "SIGCONT exact owned B client", + "recovered-session-list": "exit 0" + }, + "assertions": [ + "old-producer-registered", + "new-clients-incompatible", + "old-daemon-survived-overlap", + "old-session-still-usable", + "one-incumbent-metadata", + "delayed-client-suspended", + "old-daemon-retired-after-quiescence", + "first-client-established-successor", + "delayed-client-recovered", + "new-clients-not-relaunched", + "new-daemon-binary", + "new-producers-registered", + "minimal-health-before", + "minimal-health-after", + "test-process-cleanup" + ], + "observations": [ + "daemonPackageVersionA", + "daemonPackageVersionB", + "daemonRevisionA", + "daemonRevisionB", + "daemonUpgradeBuildInputIdentity", + "oldDaemonPid", + "newDaemonPid", + "oldDaemonStartToken", + "newDaemonStartToken", + "oldExecutableDigest", + "newExecutableDigest", + "oldExecutableLocation", + "newExecutableLocation", + "oldExecutablePath", + "newExecutablePath", + "fixtureManifestPath", + "oldClientPid", + "oldClientStartToken", + "newFirstWrapperPid", + "newFirstWrapperStartToken", + "newSecondWrapperPid", + "newSecondWrapperStartToken", + "newFirstClientPid", + "newFirstClientStartToken", + "newSecondClientPid", + "newSecondClientStartToken", + "reconnectDurationMs", + "oldTranscriptPath", + "newFirstTranscriptPath", + "newSecondTranscriptPath", + "incompatibleWarningPath", + "oldMetadataPath", + "recoveredMetadataPath", + "overlapHealthPath", + "recoveredHealthPath", + "oldSessionListPath", + "firstRecoveredSessionListPath", + "recoveredSessionListPath" + ] + } + }, { "id": "pnpm-prebuilt-no-bun", "description": "Install the current prebuilt package with pnpm and no Bun runtime.", diff --git a/test/cli/install-vm/scenarios/authenticated-daemon-upgrade.sh b/test/cli/install-vm/scenarios/authenticated-daemon-upgrade.sh new file mode 100755 index 000000000..f3d5e4d53 --- /dev/null +++ b/test/cli/install-vm/scenarios/authenticated-daemon-upgrade.sh @@ -0,0 +1,507 @@ +#!/usr/bin/env bash +# This scenario intentionally relies on Linux /proc and GNU timeout, date, readlink, and sha256sum. +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile + +old_wrapper= +old_wrapper_token= +old_client= +old_client_token= +new_first_wrapper= +new_first_wrapper_token= +new_first_client= +new_first_client_token= +new_second_wrapper= +new_second_wrapper_token= +new_second_client= +new_second_client_token= +new_second_stopped=0 +fd3_open=0 +fd4_open=0 +fd5_open=0 + +wait_for() { + local timeout_seconds=$1 + shift + local deadline=$((SECONDS + timeout_seconds)) + while ((SECONDS < deadline)); do + "$@" && return 0 + sleep 0.5 + done + return 1 +} + +process_identity_snapshot() { + local pid=$1 + [[ -r /proc/$pid/stat ]] || return 1 + # One read returns Linux stat fields 3 (state) and 22 (starttime). + awk '{line=$0; sub(/^[^)]*\) /,"",line); split(line,fields," "); print fields[1], fields[20]}' "/proc/$pid/stat" +} + +process_start_token() { + local state token + read -r state token < <(process_identity_snapshot "$1") || return 1 + [[ $state != Z ]] || return 1 + printf '%s' "$token" +} + +process_identity_is() { + local pid=$1 expected_token=$2 state token + [[ -n $pid && -n $expected_token ]] || return 1 + read -r state token < <(process_identity_snapshot "$pid") || return 1 + [[ $state != Z && $token == "$expected_token" ]] +} + +process_identity_state_is() { + local pid=$1 expected_token=$2 expected_state=$3 state token + read -r state token < <(process_identity_snapshot "$pid") || return 1 + [[ $token == "$expected_token" && $state == "$expected_state" ]] +} + +process_identity_not_stopped() { + local pid=$1 expected_token=$2 state token + read -r state token < <(process_identity_snapshot "$pid") || return 1 + [[ $token == "$expected_token" && $state != T && $state != t && $state != Z ]] +} + +process_identity_gone() { + ! process_identity_is "$1" "$2" +} + +pidfd_signal_owned_identity() { + local pid=$1 token=$2 signal_name=$3 + python3 - "$pid" "$token" "$signal_name" <<'PY' +import os, signal, sys +pid, expected, signal_name = int(sys.argv[1]), sys.argv[2], sys.argv[3] +def identity(): + with open(f"/proc/{pid}/stat", "r", encoding="utf-8") as stream: + fields = stream.read().rsplit(") ", 1)[1].split() + return fields[0], fields[19] +state, token = identity() +if state == "Z" or token != expected: + raise SystemExit(1) +fd = os.pidfd_open(pid, 0) +try: + state, token = identity() + if state == "Z" or token != expected: + raise SystemExit(1) + signal.pidfd_send_signal(fd, getattr(signal, f"SIG{signal_name}")) +finally: + os.close(fd) +PY +} + +terminate_owned_identity() { + local pid=$1 token=$2 + process_identity_is "$pid" "$token" || return 0 + pidfd_signal_owned_identity "$pid" "$token" TERM 2>/dev/null || return 1 + if ! wait_for 3 process_identity_gone "$pid" "$token"; then + pidfd_signal_owned_identity "$pid" "$token" KILL 2>/dev/null || return 1 + wait_for 3 process_identity_gone "$pid" "$token" || return 1 + fi +} + +cleanup_upgrade() { + local status=$? + trap - EXIT + set +e + if [[ $new_second_stopped == 1 ]] && process_identity_is "$new_second_client" "$new_second_client_token"; then + pidfd_signal_owned_identity "$new_second_client" "$new_second_client_token" CONT 2>/dev/null || true + new_second_stopped=0 + fi + [[ $fd3_open == 1 ]] && printf 'q' >&3 + [[ $fd4_open == 1 ]] && printf 'q' >&4 + [[ $fd5_open == 1 ]] && printf 'q' >&5 + sleep 0.5 + local cleanup_failed=0 + terminate_owned_identity "$old_client" "$old_client_token" || cleanup_failed=1 + terminate_owned_identity "$new_first_client" "$new_first_client_token" || cleanup_failed=1 + terminate_owned_identity "$new_second_client" "$new_second_client_token" || cleanup_failed=1 + terminate_owned_identity "$old_wrapper" "$old_wrapper_token" || cleanup_failed=1 + terminate_owned_identity "$new_first_wrapper" "$new_first_wrapper_token" || cleanup_failed=1 + terminate_owned_identity "$new_second_wrapper" "$new_second_wrapper_token" || cleanup_failed=1 + if [[ -n $old_wrapper ]] && process_identity_gone "$old_wrapper" "$old_wrapper_token"; then wait "$old_wrapper" 2>/dev/null || true; fi + if [[ -n $new_first_wrapper ]] && process_identity_gone "$new_first_wrapper" "$new_first_wrapper_token"; then wait "$new_first_wrapper" 2>/dev/null || true; fi + if [[ -n $new_second_wrapper ]] && process_identity_gone "$new_second_wrapper" "$new_second_wrapper_token"; then wait "$new_second_wrapper" 2>/dev/null || true; fi + if [[ $cleanup_failed == 0 ]]; then + record_assertion test-process-cleanup passed "all test-owned identities gone" gone "bounded pidfd cleanup completed" + else + record_assertion test-process-cleanup failed "all test-owned identities gone" alive "bounded cleanup left a test-owned identity" + status=1 + fi + [[ $fd3_open == 1 ]] && exec 3>&- + [[ $fd4_open == 1 ]] && exec 4>&- + [[ $fd5_open == 1 ]] && exec 5>&- + exit "$status" +} +trap cleanup_upgrade EXIT + +metadata_pid() { + timeout 5 node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(!Number.isInteger(value.pid)||value.pid<1) process.exit(1); process.stdout.write(String(value.pid));' "$1" +} + +health_is_minimal() { + timeout 5 node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const keys=Object.keys(value); process.exit(keys.length===1&&keys[0]==="ok"&&value.ok===true?0:1);' "$1" +} + +session_pids_are() { + local binary=$1 expected_csv=$2 output=$3 + timeout 8 "$binary" session list --json >"$output" 2>/dev/null || return 1 + timeout 5 node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const actual=Array.isArray(value.sessions)?value.sessions.map((session)=>session.pid).sort((a,b)=>a-b):[]; const expected=process.argv[2].split(",").filter(Boolean).map(Number).sort((a,b)=>a-b); process.exit(JSON.stringify(actual)===JSON.stringify(expected)?0:1);' "$output" "$expected_csv" +} + +wrapper_alive() { + process_identity_is "$1" "$2" +} + +find_descendant_executable() { + local root=$1 expected_binary=$2 current child executable + local expected_executable + expected_executable=$(readlink -f "$expected_binary") || return 1 + local -a queue=("$root") + while ((${#queue[@]} > 0)); do + current=${queue[0]} + queue=("${queue[@]:1}") + [[ -r /proc/$current/task/$current/children ]] || continue + for child in $(<"/proc/$current/task/$current/children"); do + queue+=("$child") + executable=$(readlink -f "/proc/$child/exe" 2>/dev/null || true) + if [[ $executable == "$expected_executable" ]]; then + printf '%s' "$child" + return 0 + fi + done + done + return 1 +} + +capture_client_identity() { + local wrapper=$1 binary=$2 pid_variable=$3 token_variable=$4 pid token + pid=$(find_descendant_executable "$wrapper" "$binary") || return 1 + token=$(process_start_token "$pid") || return 1 + printf -v "$pid_variable" '%s' "$pid" + printf -v "$token_variable" '%s' "$token" +} + +start_tui() { + local binary=$1 input_fd=$2 transcript=$3 warning_log=$4 result_variable=$5 + local command wrapper_pid + printf -v command 'env HUNK_DISABLE_UPDATE_NOTICE=1 %q --no-extensions patch %q 2>>%q' \ + "$binary" "$patch_file" "$warning_log" + script --quiet --return --flush --command "$command" "$transcript" <&"$input_fd" >/dev/null 2>&1 & + wrapper_pid=$! + printf -v "$result_variable" '%s' "$wrapper_pid" +} + +successor_metadata_ready() { + local metadata_file=$1 old_pid=$2 old_token=$3 candidate_pid candidate_token + [[ -f $metadata_file ]] || return 1 + candidate_pid=$(metadata_pid "$metadata_file") || return 1 + candidate_token=$(process_start_token "$candidate_pid" 2>/dev/null) || return 1 + [[ $candidate_pid != "$old_pid" || $candidate_token != "$old_token" ]] || return 1 + curl --max-time 3 -fsS "http://127.0.0.1:$HUNK_MCP_PORT/health" >/dev/null +} + +for tool in script sha256sum timeout node readlink python3; do + command -v "$tool" >/dev/null 2>&1 || { + record_assertion linux-tooling failed present "$tool missing" "authenticated daemon upgrade requires Linux/GNU tooling" + scenario_finish + } +done +[[ -r /proc/self/stat ]] || { + record_assertion linux-proc failed present missing "authenticated daemon upgrade requires Linux /proc" + scenario_finish +} + +manifest_file="$artifact_dir/daemon-fixture-manifest.json" +timeout 10 curl --max-time 8 -fsS "$HTTP_URL/fixture-manifest.json" >"$manifest_file" +daemon_version_a=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.versionA' "$manifest_file") +daemon_version_b=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.versionB' "$manifest_file") +daemon_revision_a=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.revisionA' "$manifest_file") +daemon_revision_b=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.revisionB' "$manifest_file") +daemon_binary_sha256_a=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.binarySha256A' "$manifest_file") +daemon_binary_sha256_b=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.binarySha256B' "$manifest_file") +daemon_build_input_identity=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgradeBuildInputIdentity' "$manifest_file") +record_observation daemonPackageVersionA "$daemon_version_a" +record_observation daemonPackageVersionB "$daemon_version_b" +record_observation daemonRevisionA "$daemon_revision_a" +record_observation daemonRevisionB "$daemon_revision_b" +record_observation daemonUpgradeBuildInputIdentity "$daemon_build_input_identity" +record_observation fixtureManifestPath daemon-fixture-manifest.json + +export XDG_RUNTIME_DIR="$HOME/runtime" +export HUNK_MCP_HOST=127.0.0.1 +export HUNK_MCP_PORT=48761 +export HUNK_DISABLE_UPDATE_NOTICE=1 +unset HUNK_MCP_DISABLE +mkdir -p "$XDG_RUNTIME_DIR" +chmod 0700 "$XDG_RUNTIME_DIR" + +cat >"$HOME/change.patch" <<'PATCH' +diff --git a/example.ts b/example.ts +index 3f52c4e..33d4f84 100644 +--- a/example.ts ++++ b/example.ts +@@ -1 +1 @@ +-export const answer = 41; ++export const answer = 42; +PATCH +patch_file="$HOME/change.patch" + +run_expect install-daemon-a 0 timeout 240 npm install -g "hunkdiff@$daemon_version_a" --registry "$REGISTRY_URL" +installed_binary="$npm_config_prefix/lib/node_modules/hunkdiff/node_modules/hunkdiff-linux-x64/bin/hunk" +old_binary="$HOME/hunk-daemon-a" +cp "$installed_binary" "$old_binary" +chmod 0755 "$old_binary" +run_expect version-daemon-a 0 timeout 10 "$old_binary" --version +assert_contains version-a-matches "$command_dir/version-daemon-a.log" "$daemon_version_a" + +mkfifo "$HOME/old-input" "$HOME/new-first-input" "$HOME/new-second-input" +exec 3<>"$HOME/old-input" +fd3_open=1 +exec 4<>"$HOME/new-first-input" +fd4_open=1 +exec 5<>"$HOME/new-second-input" +fd5_open=1 +old_transcript="$artifact_dir/old-transcript.log" +new_first_transcript="$artifact_dir/new-first-transcript.log" +new_second_transcript="$artifact_dir/new-second-transcript.log" +old_warning="$artifact_dir/old-warning.log" +new_first_warning="$artifact_dir/new-first-warning.log" +new_second_warning="$artifact_dir/new-second-warning.log" +start_tui "$old_binary" 3 "$old_transcript" "$old_warning" old_wrapper +old_wrapper_token=$(process_start_token "$old_wrapper") +record_command start-old-tui passed "background PTY remains live" 0 "old-transcript.log" +if wait_for 20 capture_client_identity "$old_wrapper" "$old_binary" old_client old_client_token; then + record_observation oldWrapperPid "$old_wrapper" + record_observation oldWrapperStartToken "$old_wrapper_token" + record_observation oldClientPid "$old_client" + record_observation oldClientStartToken "$old_client_token" +else + record_assertion old-client-identity failed "owned Hunk descendant" missing "could not identify old TUI process" +fi + +old_list="$artifact_dir/old-session-list.json" +if wait_for 20 session_pids_are "$old_binary" "$old_client" "$old_list"; then + record_assertion old-producer-registered passed "one authenticated A client PID" present "old TUI registered" +else + record_assertion old-producer-registered failed "one authenticated A client PID" missing "see old transcript" +fi +run_expect old-session-list 0 timeout 10 "$old_binary" session list --json +cp "$command_dir/old-session-list.log" "$old_list" + +metadata="$XDG_RUNTIME_DIR/hunk-mcp/daemon-127-0-0-1-$HUNK_MCP_PORT.json" +if ! wait_for 10 test -f "$metadata"; then + record_assertion old-metadata failed present missing "scenario-owned launch metadata was not published" + scenario_finish +fi +cp "$metadata" "$artifact_dir/old-metadata.json" +old_daemon_pid=$(metadata_pid "$metadata") +old_start_token=$(process_start_token "$old_daemon_pid") +old_executable_location=$(readlink "/proc/$old_daemon_pid/exe") +old_executable_digest=$(timeout 10 sha256sum "/proc/$old_daemon_pid/exe" | awk '{print $1}') +printf 'pid=%s\nstartToken=%s\nlocation=%s\ndigest=%s\n' \ + "$old_daemon_pid" "$old_start_token" "$old_executable_location" "$old_executable_digest" \ + >"$artifact_dir/old-executable.txt" +record_observation oldDaemonPid "$old_daemon_pid" +record_observation oldDaemonStartToken "$old_start_token" +record_observation oldExecutableDigest "$old_executable_digest" +record_observation oldExecutableLocation "$old_executable_location" +record_observation oldExecutablePath old-executable.txt +record_observation oldMetadataPath old-metadata.json +record_observation oldTranscriptPath old-transcript.log +record_observation oldSessionListPath old-session-list.json + +timeout 10 curl --max-time 8 -fsS "http://127.0.0.1:$HUNK_MCP_PORT/health" >"$artifact_dir/overlap-health.json" +if health_is_minimal "$artifact_dir/overlap-health.json"; then + record_assertion minimal-health-before passed '{"ok":true}' exact "public health is liveness-only" +else + record_assertion minimal-health-before failed '{"ok":true}' different "public health leaked extra fields" +fi +record_observation overlapHealthPath overlap-health.json + +run_expect upgrade-daemon-b 0 timeout 240 npm install -g "hunkdiff@$daemon_version_b" --registry "$REGISTRY_URL" +run_expect version-daemon-b 0 timeout 10 hunk --version +assert_contains version-b-matches "$command_dir/version-daemon-b.log" "$daemon_version_b" +new_binary=$(command -v hunk) + +start_tui "$new_binary" 4 "$new_first_transcript" "$new_first_warning" new_first_wrapper +new_first_wrapper_token=$(process_start_token "$new_first_wrapper") +start_tui "$new_binary" 5 "$new_second_transcript" "$new_second_warning" new_second_wrapper +new_second_wrapper_token=$(process_start_token "$new_second_wrapper") +record_command start-new-first passed "background PTY remains live" 0 "new-first-transcript.log" +record_command start-new-second passed "background PTY remains live" 0 "new-second-transcript.log" +if ! wait_for 20 capture_client_identity "$new_first_wrapper" "$installed_binary" new_first_client new_first_client_token; then + record_assertion new-first-client-identity failed "owned Hunk descendant" missing "could not identify first B TUI" +fi +if ! wait_for 20 capture_client_identity "$new_second_wrapper" "$installed_binary" new_second_client new_second_client_token; then + record_assertion new-second-client-identity failed "owned Hunk descendant" missing "could not identify second B TUI" +fi +record_observation newFirstWrapperPid "$new_first_wrapper" +record_observation newFirstWrapperStartToken "$new_first_wrapper_token" +record_observation newSecondWrapperPid "$new_second_wrapper" +record_observation newSecondWrapperStartToken "$new_second_wrapper_token" +record_observation newFirstClientPid "$new_first_client" +record_observation newFirstClientStartToken "$new_first_client_token" +record_observation newSecondClientPid "$new_second_client" +record_observation newSecondClientStartToken "$new_second_client_token" +record_observation newFirstTranscriptPath new-first-transcript.log +record_observation newSecondTranscriptPath new-second-transcript.log + +# The TUI renderer owns its terminal; retain stable one-shot warning evidence while both original +# interactive B processes remain in pre-authentication quiescent wait. +run_expect_nonzero incompatible-daemon-b timeout 12 "$new_binary" session list --json +cp "$command_dir/incompatible-daemon-b.log" "$artifact_dir/incompatible-warning.log" +record_observation incompatibleWarningPath incompatible-warning.log +if grep -Fq 'Close older Hunk windows' "$artifact_dir/incompatible-warning.log" && \ + wrapper_alive "$new_first_wrapper" "$new_first_wrapper_token" && \ + wrapper_alive "$new_second_wrapper" "$new_second_wrapper_token" && \ + process_identity_is "$new_first_client" "$new_first_client_token" && \ + process_identity_is "$new_second_client" "$new_second_client_token"; then + record_assertion new-clients-incompatible passed "stable warning with both original B clients alive" present "new clients wait without replacement" +else + record_assertion new-clients-incompatible failed "stable warning with both original B clients alive" missing "see warning and transcripts" +fi + +# Observe multiple reconnect intervals while A still owns live work. +sleep 8 +overlap_pid=$(metadata_pid "$metadata") +overlap_token=$(process_start_token "$overlap_pid" 2>/dev/null || true) +if [[ $overlap_pid == "$old_daemon_pid" && $overlap_token == "$old_start_token" ]] && \ + wrapper_alive "$old_wrapper" "$old_wrapper_token"; then + record_assertion old-daemon-survived-overlap passed "same live daemon PID/start token" preserved "A remained available throughout B overlap" +else + record_assertion old-daemon-survived-overlap failed "same live daemon PID/start token" changed "A daemon or producer disappeared" +fi +if session_pids_are "$old_binary" "$old_client" "$artifact_dir/old-overlap-session-list.json"; then + record_assertion old-session-still-usable passed "original authenticated A client" usable "old session survived B retries" +else + record_assertion old-session-still-usable failed "original authenticated A client" unavailable "old session stopped serving" +fi +if [[ $(find "$XDG_RUNTIME_DIR/hunk-mcp" -maxdepth 1 -name 'daemon-*.json' | wc -l) == 1 && $overlap_pid == "$old_daemon_pid" ]]; then + record_assertion one-incumbent-metadata passed "one incumbent metadata record" one "fixed endpoint published one incumbent record" +else + record_assertion one-incumbent-metadata failed "one incumbent metadata record" multiple "duplicate metadata evidence found" +fi + +# Suspend only the exact test-owned second B client. It must miss endpoint absence and recover after +# the first B client has established a healthy successor. +if process_identity_is "$new_second_client" "$new_second_client_token" && \ + pidfd_signal_owned_identity "$new_second_client" "$new_second_client_token" STOP && \ + (wait_for 5 process_identity_state_is "$new_second_client" "$new_second_client_token" T || \ + wait_for 1 process_identity_state_is "$new_second_client" "$new_second_client_token" t); then + new_second_stopped=1 + record_command suspend-new-second passed "SIGSTOP exact owned B client" 0 "new-second-transcript.log" + record_assertion delayed-client-suspended passed "exact original second B identity stopped" stopped "test delayed one client across migration" +else + record_command suspend-new-second failed "SIGSTOP exact owned B client" 1 "new-second-transcript.log" + record_assertion delayed-client-suspended failed "exact original second B identity stopped" running "could not suspend owned client" +fi + +reconnect_started_ms=$(date +%s%3N) +printf 'q' >&3 +exec 3>&- +fd3_open=0 +if wait_for 10 process_identity_gone "$old_wrapper" "$old_wrapper_token"; then + wait "$old_wrapper" 2>/dev/null || true + old_wrapper= + old_wrapper_token= + if process_identity_is "$old_client" "$old_client_token"; then + record_assertion old-client-close failed "old client exits with wrapper" alive "surviving descendant requires cleanup" + else + old_client= + old_client_token= + fi +else + record_assertion old-window-close failed "old test-owned wrapper exits after q" alive "old window did not close promptly" +fi + +# The daemon owns its production 60-second idle shutdown. Runtime evidence shows that the same +# incumbent survives overlap and later retires after its last producer closes; source tests prove +# Hunk has no metadata/PID signalling authority. +if wait_for 90 process_identity_gone "$old_daemon_pid" "$old_start_token"; then + record_assertion old-daemon-retired-after-quiescence passed "incumbent identity retires after quiescence" retired "production idle shutdown completed" +else + record_assertion old-daemon-retired-after-quiescence failed "incumbent identity retires after quiescence" alive "incumbent exceeded quiescent deadline" +fi + +if ! wait_for 30 successor_metadata_ready "$metadata" "$old_daemon_pid" "$old_start_token"; then + record_assertion successor-ready failed "new daemon identity and health" missing "first B client did not establish successor" +fi +first_recovered_list="$artifact_dir/first-recovered-session-list.json" +if wait_for 20 session_pids_are "$new_binary" "$new_first_client" "$first_recovered_list"; then + record_assertion first-client-established-successor passed "exact original first B client" present "first waiter registered before delayed client resumed" +else + record_assertion first-client-established-successor failed "exact original first B client" missing "successor ownership was not established by first client" +fi +record_observation firstRecoveredSessionListPath first-recovered-session-list.json + +if [[ $new_second_stopped == 1 ]] && process_identity_is "$new_second_client" "$new_second_client_token" && \ + pidfd_signal_owned_identity "$new_second_client" "$new_second_client_token" CONT && \ + wait_for 5 process_identity_not_stopped "$new_second_client" "$new_second_client_token"; then + new_second_stopped=0 + record_command resume-new-second passed "SIGCONT exact owned B client" 0 "new-second-transcript.log" +else + record_command resume-new-second failed "SIGCONT exact owned B client" 1 "new-second-transcript.log" +fi + +recovered_list="$artifact_dir/recovered-session-list.json" +if wait_for 30 session_pids_are "$new_binary" "$new_first_client,$new_second_client" "$recovered_list"; then + record_assertion new-producers-registered passed "two exact original B client PIDs" present "both waiting clients registered" +else + record_assertion new-producers-registered failed "two exact original B client PIDs" missing "B clients did not recover" +fi +if process_identity_is "$new_first_client" "$new_first_client_token" && \ + process_identity_is "$new_second_client" "$new_second_client_token"; then + record_assertion delayed-client-recovered passed "original delayed PID/start token registered" unchanged "stopped B client authenticated without restart" +else + record_assertion delayed-client-recovered failed "original delayed PID/start token registered" changed "delayed B process identity was lost" +fi +run_expect recovered-session-list 0 timeout 10 "$new_binary" session list --json +cp "$command_dir/recovered-session-list.log" "$recovered_list" + +cp "$metadata" "$artifact_dir/recovered-metadata.json" +new_daemon_pid=$(metadata_pid "$metadata") +new_start_token=$(process_start_token "$new_daemon_pid") +new_executable_location=$(readlink "/proc/$new_daemon_pid/exe") +new_executable_digest=$(timeout 10 sha256sum "/proc/$new_daemon_pid/exe" | awk '{print $1}') +printf 'pid=%s\nstartToken=%s\nlocation=%s\ndigest=%s\n' \ + "$new_daemon_pid" "$new_start_token" "$new_executable_location" "$new_executable_digest" \ + >"$artifact_dir/new-executable.txt" +record_observation newDaemonPid "$new_daemon_pid" +record_observation newDaemonStartToken "$new_start_token" +record_observation newExecutableDigest "$new_executable_digest" +record_observation newExecutableLocation "$new_executable_location" +record_observation newExecutablePath new-executable.txt +record_observation recoveredMetadataPath recovered-metadata.json +record_observation recoveredSessionListPath recovered-session-list.json +record_observation reconnectDurationMs "$(( $(date +%s%3N) - reconnect_started_ms ))" + +installed_digest=$(timeout 10 sha256sum "$installed_binary" | awk '{print $1}') +if [[ ($new_daemon_pid != "$old_daemon_pid" || $new_start_token != "$old_start_token") && \ + $old_executable_digest == "$daemon_binary_sha256_a" && \ + $new_executable_digest == "$daemon_binary_sha256_b" && \ + $new_executable_digest == "$installed_digest" && \ + $new_executable_digest != "$old_executable_digest" ]]; then + record_assertion new-daemon-binary passed "manifest-bound A/B executables and successor identity" matched "runtime binaries match compiled fixture provenance" +else + record_assertion new-daemon-binary failed "manifest-bound A/B executables and successor identity" mismatched "runtime executable evidence differs from fixture manifest" +fi +if wrapper_alive "$new_first_wrapper" "$new_first_wrapper_token" && \ + wrapper_alive "$new_second_wrapper" "$new_second_wrapper_token"; then + record_assertion new-clients-not-relaunched passed "original B wrappers remain alive" unchanged "B recovered without app restart" +else + record_assertion new-clients-not-relaunched failed "original B wrappers remain alive" exited "a waiting B wrapper disappeared" +fi + +timeout 10 curl --max-time 8 -fsS "http://127.0.0.1:$HUNK_MCP_PORT/health" >"$artifact_dir/recovered-health.json" +if health_is_minimal "$artifact_dir/recovered-health.json"; then + record_assertion minimal-health-after passed '{"ok":true}' exact "successor health is liveness-only" +else + record_assertion minimal-health-after failed '{"ok":true}' different "successor health leaked extra fields" +fi +record_observation recoveredHealthPath recovered-health.json + +scenario_finish diff --git a/test/cli/install-vm/validate-release-result.ts b/test/cli/install-vm/validate-release-result.ts index 3a0d29f73..90808e00f 100644 --- a/test/cli/install-vm/validate-release-result.ts +++ b/test/cli/install-vm/validate-release-result.ts @@ -4,25 +4,77 @@ import { readFileSync } from "node:fs"; import path from "node:path"; -import { loadScenarioManifest, validateInstallVmPins } from "./contract"; -import { computeInstallVmFixtureSourceIdentity } from "./prepare-fixtures"; +import { loadScenarioManifest, selectScenarios, validateInstallVmPins } from "./contract"; +import { + computeDaemonUpgradeBuildInputIdentity, + readDaemonRevision, +} from "./prepare-daemon-upgrade-fixtures"; +import { + computeInstallVmFixtureSourceIdentity, + deriveVerifiedDaemonUpgradeBinaryDigests, + verifyInstallVmFixtures, +} from "./prepare-fixtures"; import { validateInstallVmReleaseResult } from "./results"; const repoRoot = path.resolve(import.meta.dir, "../../.."); const resultPath = process.argv[2]; -if (!resultPath || process.argv.length !== 3) { - throw new Error("Usage: validate-release-result.ts "); +const targetedScenario = process.argv[3] === "--scenario" ? process.argv[4] : undefined; +if ( + !resultPath || + (process.argv.length !== 3 && + !(process.argv.length === 5 && process.argv[3] === "--scenario" && targetedScenario)) +) { + throw new Error("Usage: validate-release-result.ts [--scenario ]"); } const manifest = loadScenarioManifest(path.join(import.meta.dir, "scenarios.json")); const pins = validateInstallVmPins( JSON.parse(readFileSync(path.join(import.meta.dir, "pins.json"), "utf8")), ); -const result = validateInstallVmReleaseResult(JSON.parse(readFileSync(resultPath, "utf8")), { - sourceIdentity: computeInstallVmFixtureSourceIdentity(repoRoot), - pnpmVersion: pins.pnpmVersion, - scenarios: manifest.scenarios, -}); +const resolvedResultPath = path.resolve(resultPath); +const scenarios = targetedScenario + ? selectScenarios(manifest, [targetedScenario]) + : manifest.scenarios; +let daemonUpgradeBinaryDigests; +let daemonUpgradeBuildInputIdentity; +let daemonRevision; +if (scenarios.some((scenario) => scenario.id === "authenticated-daemon-upgrade")) { + const fixtureDirectory = path.join(repoRoot, "tmp", "install-vm", "fixtures"); + let fixtureManifest; + try { + fixtureManifest = verifyInstallVmFixtures(repoRoot, fixtureDirectory); + } catch (error) { + throw new Error( + `Trusted install VM fixture set is missing or stale: ${error instanceof Error ? error.message : String(error)}`, + ); + } + try { + daemonUpgradeBinaryDigests = await deriveVerifiedDaemonUpgradeBinaryDigests( + fixtureDirectory, + fixtureManifest, + ); + } catch (error) { + throw new Error( + `Trusted install VM fixture binaries are invalid: ${error instanceof Error ? error.message : String(error)}`, + ); + } + daemonUpgradeBuildInputIdentity = computeDaemonUpgradeBuildInputIdentity(repoRoot); + daemonRevision = readDaemonRevision( + readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"), + ); +} +const result = validateInstallVmReleaseResult( + JSON.parse(readFileSync(resolvedResultPath, "utf8")), + { + sourceIdentity: computeInstallVmFixtureSourceIdentity(repoRoot), + pnpmVersion: pins.pnpmVersion, + scenarios, + resultDirectory: path.dirname(resolvedResultPath), + daemonUpgradeBuildInputIdentity, + daemonRevision, + daemonUpgradeBinaryDigests, + }, +); console.log( - `Validated ${result.scenarios.length} install VM scenarios for source ${result.run.sourceIdentity}.`, + `Validated ${targetedScenario ? "targeted" : "complete"} install VM evidence for ${result.scenarios.length} scenario(s) and source ${result.run.sourceIdentity}.`, );