diff --git a/scripts/lib/npm-invocation.d.mts b/scripts/lib/npm-invocation.d.mts new file mode 100644 index 0000000000..9fa69b44d6 --- /dev/null +++ b/scripts/lib/npm-invocation.d.mts @@ -0,0 +1,5 @@ +/** Preserve the caller's npm entry and arguments without invoking a shell. */ +export function npmInvocation( + arguments_: readonly string[], + environment: Readonly, +): { readonly command: string; readonly arguments: readonly string[] }; diff --git a/scripts/lib/npm-invocation.mjs b/scripts/lib/npm-invocation.mjs new file mode 100644 index 0000000000..2dfe7b18a6 --- /dev/null +++ b/scripts/lib/npm-invocation.mjs @@ -0,0 +1,19 @@ +import { dirname, join } from "node:path"; + +/** Preserve the caller's npm entry and arguments without invoking a shell. */ +export const npmInvocation = (arguments_, environment) => { + const selectedEntry = environment.npm_execpath; + if ( + selectedEntry !== undefined && + (typeof selectedEntry !== "string" || selectedEntry.length === 0) + ) + throw new Error("npm_execpath must name the selected npm CLI entry point"); + const entry = + selectedEntry ?? + (process.platform === "win32" + ? join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js") + : undefined); + return entry === undefined + ? { command: "npm", arguments: arguments_ } + : { command: process.execPath, arguments: [entry, ...arguments_] }; +}; diff --git a/scripts/lib/package-command-owner.mts b/scripts/lib/package-command-owner.mts new file mode 100644 index 0000000000..faca37a940 --- /dev/null +++ b/scripts/lib/package-command-owner.mts @@ -0,0 +1,332 @@ +import { randomUUID } from "node:crypto"; + +import { + ArtifactResourceScope, + type ArtifactResourceOwner, +} from "../../src/artifacts/ArtifactResourceScope.js"; +import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js"; +import type { RetainedResourceScope } from "../../src/application/AnalysisProvider.js"; +import { + prepareOwnedCommand, + runOwnedCommand, +} from "../../src/process/OwnedCommand.js"; +import { + spawnOwnedProviderProcess, + type OwnedProviderProcessSpawnOptions, + type ProviderProcessSnapshot, + type SpawnedOwnedProviderProcess, +} from "../../src/process/ProviderProcess.js"; +import { retainOwnedCommandCleanup } from "../../src/providers/OwnedCommandCleanup.js"; +import { WindowsOwnedProcess } from "../../src/windows/WindowsOwnedProcess.js"; +import { npmInvocation } from "./npm-invocation.mjs"; +import { createWindowsOwnedCommand } from "./windows-owned-command.mjs"; + +export interface PackageVerifierWorkspace { + readonly path: string; + close(): Promise; +} + +interface PackageCommandContext { + readonly signal?: AbortSignal | undefined; + readonly cwd?: string | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; + readonly timeout?: number | undefined; + readonly windowsHide?: boolean | undefined; +} + +export type PackageCommandOptions = PackageCommandContext & + ( + | { + /** execFile-compatible capacity for each output stream. */ + readonly maxBuffer?: number | undefined; + readonly diagnosticBytes?: never; + } + | { + /** Canonical command capacity across both streams. */ + readonly diagnosticBytes: number; + readonly maxBuffer?: never; + } + ); + +export interface PackageCommandOutput { + readonly stdout: string; + readonly stderr: string; +} + +export interface PackageCleanupOwner { + close(): Promise; + readonly cleanupResources?: readonly string[] | undefined; +} + +export interface PackageVerifierCommands { + exec( + command: string, + arguments_: readonly string[], + options?: PackageCommandOptions, + ): Promise; + execNpm( + arguments_: readonly string[], + options?: PackageCommandOptions, + ): Promise; + run( + command: string, + arguments_: readonly string[], + environment?: NodeJS.ProcessEnv, + ): Promise; + runWithStatus( + command: string, + arguments_: readonly string[], + environment?: NodeJS.ProcessEnv, + ): Promise<{ readonly stdout: string; readonly status: number }>; + adopt(owner: PackageCleanupOwner, resource?: string): void; + close(): Promise; +} + +/** An ordinary nonzero exit whose process and output cleanup both completed. */ +export class PackageCommandExitError extends Error { + readonly stdout: string; + readonly stderr: string; + constructor( + readonly command: string, + readonly arguments_: readonly string[], + readonly code: number, + readonly snapshot: ProviderProcessSnapshot, + ) { + super( + `Command failed: ${[command, ...arguments_].join(" ")}\n${snapshot.stderr.text}`, + ); + this.name = "PackageCommandExitError"; + this.stdout = snapshot.stdout.text; + this.stderr = snapshot.stderr.text; + } +} + +/** The same command/workspace capability remains available after failed close. */ +export class PackageVerifierCleanupFailure extends Error { + readonly path: string; + constructor( + cause: unknown, + readonly cleanupOwner: PackageVerifierCommands, + readonly workspace: PackageVerifierWorkspace, + ) { + super( + `Package verification cleanup incomplete; retained ${workspace.path}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + this.name = "PackageVerifierCleanupFailure"; + this.path = workspace.path; + } +} + +const launchPackageCommand = async ( + input: OwnedProviderProcessSpawnOptions, +): Promise => { + if (process.platform !== "win32") return spawnOwnedProviderProcess(input); + input.signal?.throwIfAborted(); + const child = createWindowsOwnedCommand({ + WindowsOwnedProcess, + command: input.command, + arguments: input.arguments, + cwd: input.cwd, + environment: { + ...input.hostEnvironment, + ...input.env, + REA_PROCESS_RUN_ID: input.runId, + }, + }); + // These labels describe the launch; cleanup authority is the exact native job. + return { + process: child, + ownership: { + runId: input.runId, + leaderPid: child.pid, + processGroupId: child.pid, + expectedParentPid: process.pid, + }, + cleanup: () => child.cleanup(), + }; +}; + +const commandLimits = (options: PackageCommandOptions) => { + const timeout = options.timeout ?? 0; + if (!Number.isSafeInteger(timeout) || timeout < 0) + throw new RangeError( + "Package command timeout must be a nonnegative integer", + ); + if (options.maxBuffer !== undefined && options.diagnosticBytes !== undefined) + throw new TypeError("Select a per-stream or aggregate command capacity"); + const capacity = options.diagnosticBytes ?? options.maxBuffer ?? 1024 * 1024; + if (typeof capacity !== "number" || Number.isNaN(capacity) || capacity < 0) + throw new RangeError("Package command output capacity must be nonnegative"); + if (process.platform === "win32" && options.windowsHide === false) + throw new Error( + "Owned package commands require hidden Windows process launch", + ); + return { + timeoutMs: timeout === 0 ? null : timeout, + diagnosticBytes: + options.diagnosticBytes === undefined + ? { stdout: capacity, stderr: capacity } + : capacity, + acceptNonZeroExit: true, + }; +}; + +class PackageCommands implements PackageVerifierCommands { + readonly #resources = new ArtifactResourceScope(); + readonly #controller = new AbortController(); + readonly #adopted = new WeakMap(); + readonly #resourceOwner: RetainedResourceScope; + #closed = false; + #workspaceReleaseStarted = false; + #closing: Promise | undefined; + + constructor( + readonly workspace: PackageVerifierWorkspace, + readonly launcher: ( + input: OwnedProviderProcessSpawnOptions, + ) => Promise, + ) { + this.#resourceOwner = { + retain: (owner, resource) => this.adopt(owner, resource), + run: (work) => this.#resources.run(work), + }; + } + + exec( + command: string, + arguments_: readonly string[], + options: PackageCommandOptions = {}, + ): Promise { + if (this.#closed) + return Promise.reject(new Error("Package command owner is closed")); + return this.#resources.run(async () => { + try { + const limits = commandLimits(options); + const signal = + options.signal === undefined + ? this.#controller.signal + : AbortSignal.any([this.#controller.signal, options.signal]); + // Match execFile's command deadline while keeping native ownership + // preparation independently bounded and inside this resource lifetime. + await prepareOwnedCommand(signal); + const snapshot = await runOwnedCommand( + { + command, + arguments: arguments_, + cwd: options.cwd ?? process.cwd(), + hostEnvironment: options.env ?? process.env, + expectedCommand: null, + runId: `rea-package-command-${randomUUID()}`, + sweepTokenOwnedProcesses: true, + }, + limits, + { + signal, + launcher: this.launcher, + }, + ); + const output = { + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + }; + if (typeof snapshot.exitCode === "number" && snapshot.exitCode !== 0) + throw new PackageCommandExitError( + command, + arguments_, + snapshot.exitCode, + snapshot, + ); + return output; + } catch (cause: unknown) { + retainOwnedCommandCleanup( + cause, + this.#resourceOwner, + "package-verifier", + command, + ); + throw cause; + } + }); + } + + execNpm( + arguments_: readonly string[], + options: PackageCommandOptions = {}, + ): Promise { + const invocation = npmInvocation(arguments_, options.env ?? process.env); + return this.exec(invocation.command, invocation.arguments, options); + } + + async run( + command: string, + arguments_: readonly string[], + environment?: NodeJS.ProcessEnv, + ): Promise { + return (await this.exec(command, arguments_, { env: environment })).stdout; + } + + async runWithStatus( + command: string, + arguments_: readonly string[], + environment?: NodeJS.ProcessEnv, + ): Promise<{ readonly stdout: string; readonly status: number }> { + try { + return { + stdout: await this.run(command, arguments_, environment), + status: 0, + }; + } catch (cause: unknown) { + if (cause instanceof PackageCommandExitError && cause.code === 1) + return { stdout: cause.stdout, status: 1 }; + throw cause; + } + } + + adopt(owner: PackageCleanupOwner, resource = this.workspace.path): void { + if (this.#workspaceReleaseStarted) + throw new Error("Package workspace release has started"); + let retained = this.#adopted.get(owner); + if (retained === undefined) { + retained = { + kind: "lease", + resource, + lease: AnalysisCleanupLease.forCloseOwner(owner, resource), + }; + this.#adopted.set(owner, retained); + } + this.#resources.retain(retained); + } + + close(): Promise { + this.#closed = true; + this.#controller.abort(new Error("Package command owner closed")); + if (this.#closing !== undefined) return this.#closing; + let attempt: Promise; + attempt = this.#release().catch((cause: unknown) => { + if (this.#closing === attempt) this.#closing = undefined; + throw new PackageVerifierCleanupFailure(cause, this, this.workspace); + }); + this.#closing = attempt; + return attempt; + } + + async #release(): Promise { + await this.#resources.close(); + this.#workspaceReleaseStarted = true; + await this.workspace.close(); + } +} + +/** Allocate one command lifetime whose workspace depends on completed cleanup. */ +export async function createPackageVerifierCommands({ + workspace, + launcher = launchPackageCommand, +}: { + readonly workspace: PackageVerifierWorkspace; + readonly launcher?: ( + input: OwnedProviderProcessSpawnOptions, + ) => Promise; +}): Promise { + return new PackageCommands(workspace, launcher); +} diff --git a/scripts/lib/verify-package-commands.d.mts b/scripts/lib/verify-package-commands.d.mts new file mode 100644 index 0000000000..b05e709864 --- /dev/null +++ b/scripts/lib/verify-package-commands.d.mts @@ -0,0 +1,8 @@ +export { createPackageVerifierCommands } from "./package-command-owner.mjs"; +export type { + PackageVerifierCommands, + PackageVerifierWorkspace, + PackageCommandOptions, + PackageCommandOutput, + PackageCleanupOwner, +} from "./package-command-owner.mjs"; diff --git a/scripts/lib/verify-package-commands.mjs b/scripts/lib/verify-package-commands.mjs new file mode 100644 index 0000000000..ae8b753c49 --- /dev/null +++ b/scripts/lib/verify-package-commands.mjs @@ -0,0 +1,13 @@ +let implementation; + +/** Load the development owner as one source graph, only when commands are needed. */ +export async function createPackageVerifierCommands(options) { + implementation ??= import("tsx/esm/api").then(({ tsImport }) => + tsImport( + new URL("./package-command-owner.mts", import.meta.url).href, + import.meta.url, + ), + ); + const module = await implementation; + return module.createPackageVerifierCommands(options); +} diff --git a/scripts/lib/verify-package-core.mjs b/scripts/lib/verify-package-core.mjs index 71fbe71f53..ef5179c690 100644 --- a/scripts/lib/verify-package-core.mjs +++ b/scripts/lib/verify-package-core.mjs @@ -1,6 +1,5 @@ import { execFile } from "node:child_process"; import { lstat } from "node:fs/promises"; -import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { Ajv2020 } from "ajv/dist/2020.js"; @@ -8,55 +7,16 @@ import { Ajv2020 } from "ajv/dist/2020.js"; * `mcp-verifier-results.mjs` reaches into `dist/` at module load, so eager * importing it propagates that build dependency to every consumer in this * file. `verifyCompleteToolCatalog` is the only consumer, so we lazy-load - * the module there and keep the other helpers (`exec`, `run`, `json`, + * the module there and keep the remaining helpers (`exec`, `json`, * `pathExists`) usable from `test:fast` callers that only need `dist/`-free * utilities — see docs/testing.md § test scripts, which promises `test:fast` * runs "without build". */ const loadMcpVerifierResults = () => import("./mcp-verifier-results.mjs"); -/** Promisified child_process execution helper. */ +/** Native-client commands expose the live child handle for caller-owned stdin. */ export const exec = promisify(execFile); -/** Run npm through its JavaScript entry point so Windows command scripts are not required. */ -export const execNpm = (arguments_, options = {}) => { - const environment = options.env ?? process.env; - const selectedEntry = environment.npm_execpath; - if ( - selectedEntry !== undefined && - (typeof selectedEntry !== "string" || selectedEntry.length === 0) - ) - throw new Error("npm_execpath must name the selected npm CLI entry point"); - const entry = - selectedEntry ?? - (process.platform === "win32" - ? join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js") - : undefined); - return entry === undefined - ? exec("npm", arguments_, options) - : exec(process.execPath, [entry, ...arguments_], options); -}; - -/** Run a command and return its stdout. */ -export const run = async (command, args, env) => - (await exec(command, args, { env })).stdout; - -/** Run a command and treat exit code 1 as a non-throwing status result. */ -export const runWithStatus = async (command, args, env) => { - try { - return { stdout: await run(command, args, env), status: 0 }; - } catch (cause) { - if ( - typeof cause === "object" && - cause !== null && - cause.code === 1 && - typeof cause.stdout === "string" - ) - return { stdout: cause.stdout, status: 1 }; - throw cause; - } -}; - /** Parse JSON from a command output string. */ export const json = (text) => JSON.parse(text); diff --git a/scripts/verify-agentenv-environment.mjs b/scripts/verify-agentenv-environment.mjs index d5d4b31954..bd7f0bf009 100644 --- a/scripts/verify-agentenv-environment.mjs +++ b/scripts/verify-agentenv-environment.mjs @@ -1,19 +1,12 @@ #!/usr/bin/env node import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; import { access, readFile, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { setTimeout as pause } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; -import { - OwnedCommandFailure, - runOwnedCommand, -} from "../dist/process/OwnedCommand.js"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; -import { execNpm } from "./lib/verify-package-core.mjs"; +import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs"; const AGENTENV_VERSION = "0.9.1334"; const AGENTENV_PIN = `agentenv-framework ${AGENTENV_VERSION}`; @@ -34,7 +27,6 @@ const TEMPLATE_RULES = [ "opaque-token", "sibling-variance", ]; -const execFileAsync = promisify(execFile); // Both tools are caller-supplied absolute paths, as every real-* lane names its upstream. for (const key of ["REA_AGENT_ENV_COMMAND", "REA_DOCKER_COMMAND"]) @@ -62,8 +54,10 @@ const failures = []; let cases = 0; const receipt = {}; let knownContainers; +let commands; try { - const version = await execFileAsync(agentEnv, ["--version"], { + commands = await createPackageVerifierCommands({ workspace: runtime }); + const version = await commands.exec(agentEnv, ["--version"], { env: environment, timeout: 10_000, maxBuffer: 64 * 1024, @@ -124,7 +118,7 @@ try { await writeFile(capturePath, JSON.stringify(capture)); await writeFile(manifestPath, JSON.stringify(manifest)); // The lane proves this checkout, not the registry package: pack it and install the tarball in the image. - const { stdout: packed } = await execNpm( + const { stdout: packed } = await commands.execNpm( ["pack", "--ignore-scripts", "--json", "--pack-destination", runtime.path], { cwd: checkout, env: environment, maxBuffer: 16 * 1024 * 1024 }, ); @@ -179,20 +173,25 @@ try { attempts += 1; let outcome; try { - outcome = await runOwnedCommand( + const output = await commands.exec( + agentEnv, + ["run", bundle, "--sandbox", "local"], { - command: agentEnv, - arguments: ["run", bundle, "--sandbox", "local"], cwd: runtime.path, - runId: `rea-agentenv-run-${randomUUID()}`, - hostEnvironment: environment, + env: environment, + timeout: timeoutMs, + diagnosticBytes: 4 * 1024 * 1024, }, - { timeoutMs, diagnosticBytes: 4 * 1024 * 1024 }, ); + outcome = { + exitCode: 0, + signal: null, + stdout: { text: output.stdout }, + stderr: { text: output.stderr }, + }; return outcome; } catch (cause) { - outcome = - cause instanceof OwnedCommandFailure ? cause.snapshot : undefined; + outcome = cause?.snapshot; throw cause; } finally { // Recorded before the outcome is judged, so a failed receipt still says how the last @@ -257,7 +256,8 @@ try { failures.push(cause); } try { - await runtime.close(); + if (commands === undefined) await runtime.close(); + else await commands.close(); } catch (cause) { failures.push(cause); } @@ -299,7 +299,7 @@ console.log( ); async function cli(...args) { - const { stdout } = await execFileAsync( + const { stdout } = await commands.exec( process.execPath, [entrypoint, ...args, "--json"], { env: environment, cwd: runtime.path, maxBuffer: 64 * 1024 * 1024 }, @@ -339,7 +339,7 @@ function packedFilename(report) { // Containers are not verifier descendants, so leaks are checked by id against the pre-run set. async function containers() { - const { stdout } = await execFileAsync( + const { stdout } = await commands.exec( docker, ["ps", "-a", "--format", "{{.ID}}\t{{.Names}}"], { env: environment }, @@ -355,7 +355,7 @@ async function containers() { } async function imageIds() { - const { stdout } = await execFileAsync(docker, ["images", "-q"], { + const { stdout } = await commands.exec(docker, ["images", "-q"], { env: environment, }); return [...new Set(stdout.split("\n").filter((line) => line.length > 0))]; diff --git a/scripts/verify-package-artifact.mjs b/scripts/verify-package-artifact.mjs index 2ae148e383..60583ffd76 100644 --- a/scripts/verify-package-artifact.mjs +++ b/scripts/verify-package-artifact.mjs @@ -3,20 +3,23 @@ import { isDeepStrictEqual } from "node:util"; import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; -import { json, run } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; /** Run artifact and JavaScript application analysis against packaged fixtures. */ export async function verifyPackageArtifactAndElectron({ + commands, cli, workspace, environment, }) { const artifactArchive = await verifyPackagedArtifact({ + commands, cli, workspace, environment, }); await verifyPackagedElectronApplication({ + commands, cli, workspace, environment, @@ -24,20 +27,25 @@ export async function verifyPackageArtifactAndElectron({ return { artifactArchive }; } -const verifyPackagedArtifact = async ({ cli, workspace, environment }) => { +const verifyPackagedArtifact = async ({ + commands, + cli, + workspace, + environment, +}) => { const artifactArchive = join(workspace, "artifact.zip"); const artifactWriter = new ZipWriter(new Uint8ArrayWriter()); await artifactWriter.add("app/main.js", new TextReader("main();")); await writeFile(artifactArchive, await artifactWriter.close()); const artifactInspection = json( - await run( + await commands.run( cli, ["inspect-artifact", artifactArchive, "--json"], environment, ), ); const repeatedArtifactInspection = json( - await run( + await commands.run( cli, ["inspect-artifact", artifactArchive, "--json"], environment, @@ -57,6 +65,7 @@ const verifyPackagedArtifact = async ({ cli, workspace, environment }) => { ) throw new Error("packaged artifact inventory CLI failed"); await verifyPackagedArtifactExtraction({ + commands, artifactArchive, artifactInventory, cli, @@ -67,6 +76,7 @@ const verifyPackagedArtifact = async ({ cli, workspace, environment }) => { }; const verifyPackagedElectronApplication = async ({ + commands, cli, workspace, environment, @@ -86,7 +96,7 @@ const verifyPackagedElectronApplication = async ({ 'const { contextBridge, ipcRenderer } = require("electron");\ncontextBridge.exposeInMainWorld("rea", { ping: () => ipcRenderer.invoke("rea:ping") });\n', ); const applicationAnalysis = json( - await run( + await commands.run( cli, ["analyze-javascript-application", applicationRoot, "--json"], environment, @@ -101,7 +111,11 @@ const verifyPackagedElectronApplication = async ({ ) throw new Error("packaged JavaScript application analysis CLI failed"); const routedApplicationAnalysis = json( - await run(cli, ["analyze", applicationRoot, "--json"], environment), + await commands.run( + cli, + ["analyze", applicationRoot, "--json"], + environment, + ), ); assertRoutedApplicationAnalysis(routedApplicationAnalysis); }; @@ -113,6 +127,7 @@ const sameArtifactIdentity = (left, right) => isDeepStrictEqual(left?.edges, right?.edges); const verifyPackagedArtifactExtraction = async ({ + commands, artifactArchive, artifactInventory, cli, @@ -124,7 +139,7 @@ const verifyPackagedArtifactExtraction = async ({ if (occurrence?.logical_path === undefined) throw new Error("packaged artifact inventory omitted the selected member"); const extraction = json( - await run( + await commands.run( cli, ["extract-artifact", artifactArchive, "--json"], environment, diff --git a/scripts/verify-package-capabilities.mjs b/scripts/verify-package-capabilities.mjs index 5a9f79ba95..af2375bb43 100644 --- a/scripts/verify-package-capabilities.mjs +++ b/scripts/verify-package-capabilities.mjs @@ -1,18 +1,22 @@ -import { json, run } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; import { expandCapabilitiesOutput, expandProviderOutput, } from "./lib/mcp-verifier-results.mjs"; /** Verify packaged capabilities, providers, and non-Linux search results. */ -export async function verifyPackageCapabilitiesAndSearch({ cli, environment }) { +export async function verifyPackageCapabilitiesAndSearch({ + commands, + cli, + environment, +}) { const capabilities = expandCapabilitiesOutput( - json(await run(cli, ["capabilities", "--json"], environment)), + json(await commands.run(cli, ["capabilities", "--json"], environment)), ); if (!Array.isArray(capabilities.capabilities)) throw new Error("packaged capabilities CLI failed"); const providers = expandProviderOutput( - json(await run(cli, ["providers", "--json"], environment)), + json(await commands.run(cli, ["providers", "--json"], environment)), ); const candidates = providers.analysis_provider_candidates; if ( @@ -29,7 +33,7 @@ export async function verifyPackageCapabilitiesAndSearch({ cli, environment }) { throw new Error("packaged providers CLI failed"); if (process.platform !== "linux") { const searchResult = json( - await run( + await commands.run( cli, [ "search", diff --git a/scripts/verify-package-discovery.d.mts b/scripts/verify-package-discovery.d.mts index 85f433a50b..3724bd29e4 100644 --- a/scripts/verify-package-discovery.d.mts +++ b/scripts/verify-package-discovery.d.mts @@ -1,3 +1,5 @@ +import type { PackageVerifierCommands } from "./lib/package-command-owner.mjs"; + /** * Host facts the packaged setup support is derived from. * @@ -31,6 +33,7 @@ export function packageSetupSupport( /** Validate --help, --llms, and doctor output and determine host setup support. */ export function verifyPackageDiscovery(input: { + readonly commands: Pick; readonly cli: string; readonly environment: Readonly>; }): Promise<{ diff --git a/scripts/verify-package-discovery.mjs b/scripts/verify-package-discovery.mjs index f0d5016d60..65a119da92 100644 --- a/scripts/verify-package-discovery.mjs +++ b/scripts/verify-package-discovery.mjs @@ -1,4 +1,4 @@ -import { json, run, runWithStatus } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; const REQUIRED_HELP_COMMANDS = [ "setup", @@ -79,10 +79,10 @@ async function readHostLinuxDistribution() { } /** Validate --help, --llms, and doctor output and determine host setup support. */ -export async function verifyPackageDiscovery({ cli, environment }) { - const help = await run(cli, ["--help"], environment); - const llms = await run(cli, ["--llms"], environment); - const doctorExecution = await runWithStatus( +export async function verifyPackageDiscovery({ commands, cli, environment }) { + const help = await commands.run(cli, ["--help"], environment); + const llms = await commands.run(cli, ["--llms"], environment); + const doctorExecution = await commands.runWithStatus( cli, ["doctor", "--json"], environment, diff --git a/scripts/verify-package-evidence.mjs b/scripts/verify-package-evidence.mjs index 47f1600abb..5f0630bae4 100644 --- a/scripts/verify-package-evidence.mjs +++ b/scripts/verify-package-evidence.mjs @@ -2,17 +2,18 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createEvidence } from "../dist/domain/evidence.js"; -import { json, run } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; /** Round-trip historical reference import and evidence bundles. */ export async function verifyPackageEvidence({ + commands, cli, evidenceRoot, referenceRoot, environment, }) { const referenceImport = json( - await run( + await commands.run( cli, ["import-reference-source", referenceRoot, "--json"], environment, @@ -39,21 +40,21 @@ export async function verifyPackageEvidence({ const canonicalBundlePath = join(evidenceRoot, "canonical.json"); await writeFile(sourceBundlePath, serializeEvidenceBundle(sourceBundle)); const importedBundle = json( - await run( + await commands.run( cli, ["evidence-import", sourceBundlePath, "--json"], environment, ), ); const exportedBundle = json( - await run( + await commands.run( cli, ["evidence-export", sourceBundlePath, canonicalBundlePath, "--json"], environment, ), ); const comparedBundle = json( - await run( + await commands.run( cli, ["compare", sourceBundlePath, canonicalBundlePath, "--json"], environment, diff --git a/scripts/verify-package-install.mjs b/scripts/verify-package-install.mjs index 48778c77a7..74e520dc0e 100644 --- a/scripts/verify-package-install.mjs +++ b/scripts/verify-package-install.mjs @@ -2,7 +2,7 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { exec, execNpm, json, run } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; /** Install the packed CLI globally with and without optional dependencies and verify PTY degradation. */ export async function verifyPackageInstall({ @@ -10,8 +10,9 @@ export async function verifyPackageInstall({ prefix, workspace, environment, + commands, }) { - await execNpm( + await commands.execNpm( [ "install", "--global", @@ -26,7 +27,11 @@ export async function verifyPackageInstall({ ); const cli = join(prefix, "bin", "rea"); const packageRunnerCli = join(prefix, "bin", "rea-agents"); - if (!(await run(packageRunnerCli, ["--help"], environment)).includes("setup")) + if ( + !(await commands.run(packageRunnerCli, ["--help"], environment)).includes( + "setup", + ) + ) throw new Error("packaged rea-agents alias did not expose setup"); const processCaptureCapabilityUrl = pathToFileURL( join( @@ -36,7 +41,7 @@ export async function verifyPackageInstall({ ).href; const processCaptureCapability = json( ( - await exec( + await commands.exec( process.execPath, [ "--input-type=module", @@ -52,7 +57,7 @@ export async function verifyPackageInstall({ `packaged PTY backend failed without lifecycle scripts: ${JSON.stringify(processCaptureCapability)}`, ); const noOptionalPrefix = join(workspace, "prefix-no-optional"); - await execNpm( + await commands.execNpm( [ "install", "--global", @@ -81,7 +86,7 @@ export async function verifyPackageInstall({ ).href; const noOptionalCapability = json( ( - await exec( + await commands.exec( process.execPath, [ "--input-type=module", @@ -93,20 +98,26 @@ export async function verifyPackageInstall({ ).stdout, ); if ( - !(await run(noOptionalCli, ["--help"], environment)).includes("setup") || + !(await commands.run(noOptionalCli, ["--help"], environment)).includes( + "setup", + ) || noOptionalCapability.available !== false ) throw new Error( `packaged CLI did not degrade without the optional PTY binary: ${JSON.stringify(noOptionalCapability)}`, ); - await verifyInstalledPackageCanRepack(prefix, environment); + await verifyInstalledPackageCanRepack(commands, prefix, environment); return { cli, packageRunnerCli }; } -const verifyInstalledPackageCanRepack = async (prefix, environment) => { +const verifyInstalledPackageCanRepack = async ( + commands, + prefix, + environment, +) => { const installedPackageRoot = join(prefix, "lib/node_modules/rea-agents"); const repackedTarball = ( - await execNpm(["pack", "--silent"], { + await commands.execNpm(["pack", "--silent"], { cwd: installedPackageRoot, env: environment, }) diff --git a/scripts/verify-package-managed.mjs b/scripts/verify-package-managed.mjs index 87b81bf6fe..28ea7785ab 100644 --- a/scripts/verify-package-managed.mjs +++ b/scripts/verify-package-managed.mjs @@ -4,7 +4,7 @@ import { join } from "node:path"; import { createEvidence } from "../dist/domain/evidence.js"; import { buildManagedPeFixture } from "./fixtures/managed/pe.mjs"; import { functionDossier } from "./fixtures/managed/function-dossier.mjs"; -import { json, run } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; const buildManagedFixtures = async (workspace) => { const managedPath = join(workspace, "managed-fixture.exe"); @@ -38,9 +38,14 @@ const buildManagedFixtures = async (workspace) => { return { managedPath, managedRightPath }; }; -const verifyManagedArtifact = async ({ cli, managedPath, environment }) => { +const verifyManagedArtifact = async ({ + commands, + cli, + managedPath, + environment, +}) => { const managedArtifact = json( - await run( + await commands.run( cli, ["inspect-managed-artifact", managedPath, "--json"], environment, @@ -55,9 +60,14 @@ const verifyManagedArtifact = async ({ cli, managedPath, environment }) => { return managedArtifact; }; -const verifyManagedMembers = async ({ cli, managedPath, environment }) => { +const verifyManagedMembers = async ({ + commands, + cli, + managedPath, + environment, +}) => { const managedMembers = json( - await run( + await commands.run( cli, ["inspect-managed-members", managedPath, "--json"], environment, @@ -72,6 +82,7 @@ const verifyManagedMembers = async ({ cli, managedPath, environment }) => { }; const verifyManagedReconstruction = async ({ + commands, cli, managedMembers, managedMethod, @@ -101,7 +112,7 @@ const verifyManagedReconstruction = async ({ notes: ["packaged synthetic reconstruction import"], }; const managedReconstruction = json( - await run( + await commands.run( cli, [ "import-managed-reconstruction", @@ -121,9 +132,14 @@ const verifyManagedReconstruction = async ({ throw new Error("packaged managed reconstruction import CLI failed"); }; -const verifyManagedBoundaries = async ({ cli, managedPath, environment }) => { +const verifyManagedBoundaries = async ({ + commands, + cli, + managedPath, + environment, +}) => { const managedBoundaries = json( - await run( + await commands.run( cli, ["inspect-managed-native-boundaries", managedPath, "--json"], environment, @@ -140,6 +156,7 @@ const verifyManagedBoundaries = async ({ cli, managedPath, environment }) => { }; const verifyManagedApplicationGraph = async ({ + commands, cli, managedArtifact, managedMembers, @@ -158,7 +175,7 @@ const verifyManagedApplicationGraph = async ({ ), }; const managedApplicationGraph = json( - await run( + await commands.run( cli, [ "project-managed-application-graph", @@ -209,6 +226,7 @@ const managedEvidence = (output, operation) => { }; const verifyManagedNativeVerification = async ({ + commands, cli, managedBoundaries, environment, @@ -243,7 +261,7 @@ const verifyManagedNativeVerification = async ({ ], }; const managedNativeVerification = json( - await run( + await commands.run( cli, [ "verify-managed-native-boundaries", @@ -265,13 +283,14 @@ const verifyManagedNativeVerification = async ({ }; const verifyManagedComparison = async ({ + commands, cli, managedPath, managedRightPath, environment, }) => { const managedComparison = json( - await run( + await commands.run( cli, ["compare-managed-members", managedPath, managedRightPath, "--json"], environment, @@ -287,32 +306,37 @@ const verifyManagedComparison = async ({ }; /** Exercise all packaged managed static and workflow CLIs. */ -export async function verifyManaged({ cli, workspace, environment }) { +export async function verifyManaged({ commands, cli, workspace, environment }) { const { managedPath, managedRightPath } = await buildManagedFixtures(workspace); const managedArtifact = await verifyManagedArtifact({ + commands, cli, managedPath, environment, }); const managedMembers = await verifyManagedMembers({ + commands, cli, managedPath, environment, }); const managedMethod = managedMembers.normalized_result.methods[0]; await verifyManagedReconstruction({ + commands, cli, managedMembers, managedMethod, environment, }); const managedBoundaries = await verifyManagedBoundaries({ + commands, cli, managedPath, environment, }); await verifyManagedApplicationGraph({ + commands, cli, managedArtifact, managedMembers, @@ -320,11 +344,13 @@ export async function verifyManaged({ cli, workspace, environment }) { environment, }); await verifyManagedNativeVerification({ + commands, cli, managedBoundaries, environment, }); await verifyManagedComparison({ + commands, cli, managedPath, managedRightPath, diff --git a/scripts/verify-package-mcp.mjs b/scripts/verify-package-mcp.mjs index a8fc01499e..12bb7bc44b 100644 --- a/scripts/verify-package-mcp.mjs +++ b/scripts/verify-package-mcp.mjs @@ -4,8 +4,6 @@ import { } from "./lib/mcp-verifier-results.mjs"; import assert from "node:assert/strict"; import { join } from "node:path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { readFile, rm } from "node:fs/promises"; import { Client } from "@modelcontextprotocol/client"; @@ -14,8 +12,7 @@ import { resolveMcpStartupBudgets } from "../dist/config/mcpStartupBudgets.js"; import { parseEvidence } from "../dist/domain/evidence.js"; import * as prompts from "./verify-package-prompts.mjs"; import { json, verifyCompleteToolCatalog } from "./lib/verify-package-core.mjs"; - -const execute = promisify(execFile); +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; const verifyMcpToolsAndPrompts = async (client, mcpOptions) => { await verifyCompleteToolCatalog(client, mcpOptions); @@ -248,6 +245,7 @@ const verifyMcpInlineArtifactEvidence = async ( /** Connect to the packaged MCP server and verify catalog and Evidence composition. */ export async function verifyPackageMcp({ + commands, cli, environment, evidenceRoot, @@ -255,7 +253,7 @@ export async function verifyPackageMcp({ }) { const diagnosed = json( ( - await execute(cli, ["mcp", "doctor", "--json"], { + await commands.exec(cli, ["mcp", "doctor", "--json"], { env: environment, timeout: resolveMcpStartupBudgets(environment).doctor.milliseconds, }) @@ -280,19 +278,39 @@ export async function verifyPackageMcp({ mcpStderr += chunk.toString(); }); const client = new Client({ name: "package-smoke", version: "1.0.0" }); - try { - await client.connect(transport); - const mcpOptions = { timeout: 15_000 }; - await verifyMcpToolsAndPrompts(client, mcpOptions); - await verifyMcpTargetFree(client, mcpOptions); - await verifyMcpUnknownProvider(client, mcpOptions); - await verifyMcpBinaryLifecycle(client, mcpOptions); - await verifyMcpInlineArtifactEvidence(client, mcpOptions, artifactArchive); - await verifyMcpEvidenceBundle(client, mcpOptions, evidenceRoot); - } catch (cause) { - throw new Error(`packaged MCP smoke failed: ${mcpStderr}`, { cause }); - } finally { - await client.close(); - await transport.close(); - } + await withVerifierCleanup(async () => { + try { + await client.connect(transport); + const mcpOptions = { timeout: 15_000 }; + await verifyMcpToolsAndPrompts(client, mcpOptions); + await verifyMcpTargetFree(client, mcpOptions); + await verifyMcpUnknownProvider(client, mcpOptions); + await verifyMcpBinaryLifecycle(client, mcpOptions); + await verifyMcpInlineArtifactEvidence( + client, + mcpOptions, + artifactArchive, + ); + await verifyMcpEvidenceBundle(client, mcpOptions, evidenceRoot); + } catch (cause) { + throw new Error(`packaged MCP smoke failed: ${mcpStderr}`, { cause }); + } + }, [ + async () => { + try { + await client.close(); + } catch (cause) { + commands.adopt(client, "packaged MCP client"); + throw cause; + } + }, + async () => { + try { + await transport.close(); + } catch (cause) { + commands.adopt(transport, "packaged MCP transport"); + throw cause; + } + }, + ]); } diff --git a/scripts/verify-package-pack.mjs b/scripts/verify-package-pack.mjs index 993061f657..1c75d4ac1b 100644 --- a/scripts/verify-package-pack.mjs +++ b/scripts/verify-package-pack.mjs @@ -2,27 +2,30 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { FULL_CATALOG_IDENTITY } from "../dist/catalogIdentity.js"; -import { exec, execNpm } from "./lib/verify-package-core.mjs"; import { parseTarListing } from "./lib/verify-package-listing.mjs"; import { verifyPackedBridge } from "./verify-packed-bridge.mjs"; /** Create a tarball and assert production packaging constraints. */ -export async function verifyPackagePack({ root, workspace }) { +export async function verifyPackagePack({ root, workspace, commands }) { const packDirectory = join(workspace, "pack"); await mkdir(packDirectory); const tarball = join( packDirectory, ( - await execNpm(["pack", "--silent", "--pack-destination", packDirectory], { - cwd: root, - }) + await commands.execNpm( + ["pack", "--silent", "--pack-destination", packDirectory], + { + cwd: root, + }, + ) ).stdout.trim(), ); const packedFiles = parseTarListing( - (await exec("tar", ["-tf", tarball])).stdout, + (await commands.exec("tar", ["-tf", tarball])).stdout, ); const packedManifest = JSON.parse( - (await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout, + (await commands.exec("tar", ["-xOf", tarball, "package/package.json"])) + .stdout, ); if ( packedManifest.scripts?.postinstall !== undefined || @@ -46,7 +49,7 @@ export async function verifyPackagePack({ root, workspace }) { if (packedFiles.some((path) => path.startsWith("package/.cache/"))) throw new Error("package included development cache or test metadata"); const skill = ( - await exec("tar", [ + await commands.exec("tar", [ "-xOf", tarball, "package/skills/reverse-engineer-anything/SKILL.md", @@ -68,6 +71,6 @@ export async function verifyPackagePack({ root, workspace }) { ) { throw new Error("package contained generated Python bytecode"); } - await verifyPackedBridge({ tarball, packedFiles }); + await verifyPackedBridge({ tarball, packedFiles, commands }); return { tarball }; } diff --git a/scripts/verify-package-platform.d.mts b/scripts/verify-package-platform.d.mts index 631ddc93b1..c7d5cec1c0 100644 --- a/scripts/verify-package-platform.d.mts +++ b/scripts/verify-package-platform.d.mts @@ -1,3 +1,5 @@ +import type { PackageVerifierCommands } from "./lib/package-command-owner.mjs"; + /** Validate the expected fail-closed Linux fixture outcome. */ export function assertLinuxPackageProviderFailure(execution: { readonly status: number | null; @@ -6,12 +8,8 @@ export function assertLinuxPackageProviderFailure(execution: { /** Run platform-specific packaged analysis checks. */ export function verifyPackagePlatform(input: { + readonly commands: Pick; readonly cli: string; readonly environment: Readonly>; readonly platform?: string; - readonly runCommand?: ( - command: string, - arguments_: readonly string[], - environment: Readonly>, - ) => Promise; }): Promise; diff --git a/scripts/verify-package-platform.mjs b/scripts/verify-package-platform.mjs index 11cb8fd245..4f9027d75f 100644 --- a/scripts/verify-package-platform.mjs +++ b/scripts/verify-package-platform.mjs @@ -1,14 +1,14 @@ -import { json, run, runWithStatus } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; /** Run platform-specific deep-analysis smoke tests. */ export async function verifyPackagePlatform({ + commands, cli, environment, platform = process.platform, - runCommand = run, }) { if (platform === "linux") { - const unsupportedExecution = await runWithStatus( + const unsupportedExecution = await commands.runWithStatus( cli, ["analyze", process.execPath, "--provider", "hopper", "--json"], environment, @@ -16,7 +16,7 @@ export async function verifyPackagePlatform({ assertLinuxPackageProviderFailure(unsupportedExecution); } else { const overview = json( - await runCommand( + await commands.run( cli, ["analyze", process.execPath, "--provider", "hopper", "--json"], environment, @@ -27,7 +27,7 @@ export async function verifyPackagePlatform({ `packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`, ); const inspected = json( - await runCommand( + await commands.run( cli, ["inspect", process.execPath, "--provider", "hopper", "--json"], environment, @@ -36,7 +36,7 @@ export async function verifyPackagePlatform({ if (!hasPositiveProcedureCount(inspected)) throw new Error("packaged inspect CLI failed"); const functionResult = json( - await runCommand( + await commands.run( cli, [ "function", @@ -58,7 +58,7 @@ export async function verifyPackagePlatform({ `packaged function CLI failed: ${JSON.stringify(functionResult)}`, ); const xrefs = json( - await runCommand( + await commands.run( cli, ["xrefs", process.execPath, "0x1000", "--provider", "hopper", "--json"], environment, @@ -70,7 +70,7 @@ export async function verifyPackagePlatform({ ) throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`); const trace = json( - await runCommand( + await commands.run( cli, [ "trace", diff --git a/scripts/verify-package-setup.d.mts b/scripts/verify-package-setup.d.mts index d6f0ce53e3..31a2112866 100644 --- a/scripts/verify-package-setup.d.mts +++ b/scripts/verify-package-setup.d.mts @@ -1,3 +1,5 @@ +import type { PackageVerifierCommands } from "./lib/package-command-owner.mjs"; + /** Validate a live registration and its byte-identical pre-setup backup. */ export function assertClientConfig( cli: string, @@ -7,3 +9,21 @@ export function assertClientConfig( /** Return true only when stty output positively reports canonical input and echo. */ export function terminalModesRestored(termios: unknown): boolean; + +/** Verify setup using the parent's explicit command owner. */ +export function verifyPackageSetup(input: { + readonly commands: Pick; + readonly cli: string; + readonly packageRunnerCli: string; + readonly environment: NodeJS.ProcessEnv; + readonly home: string; + readonly npxLog: string; + readonly claudeConfig: string; + readonly codexConfig: string; + readonly cursorConfig: string; + readonly codexTarget: string; + readonly cursorTarget: string; + readonly supportedSetupHost: boolean; + readonly hopperSetupSupported: boolean | undefined; + readonly root: string; +}): Promise; diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs index e44c4f5c88..db241d8f95 100644 --- a/scripts/verify-package-setup.mjs +++ b/scripts/verify-package-setup.mjs @@ -13,12 +13,7 @@ import { spawn } from "@lydell/node-pty"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; import { PRODUCT_IDENTITY } from "../dist/identity.js"; -import { - json, - pathExists, - run, - runWithStatus, -} from "./lib/verify-package-core.mjs"; +import { json, pathExists } from "./lib/verify-package-core.mjs"; import { parse as parseJsonc } from "jsonc-parser"; import { skillReferenceIssues } from "./lib/docs-facts.mjs"; import { setupPlanFailureMessage } from "./verify-package-setup-plan.mjs"; @@ -46,8 +41,8 @@ const CODEX_ORIGINAL = [ "", ].join("\n"); -const verifyMcpAdd = async ({ cli, environment, npxLog }) => { - await run(cli, ["mcp", "add"], environment); +const verifyMcpAdd = async ({ commands, cli, environment, npxLog }) => { + await commands.run(cli, ["mcp", "add"], environment); const mcpRegistration = await readFile(npxLog, "utf8"); const expected = `add-mcp npx -y ${PRODUCT_IDENTITY.registrationPackageSpecifier} mcp --name rea`; if (!mcpRegistration.includes(expected)) @@ -235,6 +230,7 @@ const verifyRedirectedStdoutSetup = async ({ }); const verifySetupPlan = async ({ + commands, cli, environment, claudeConfig, @@ -244,7 +240,7 @@ const verifySetupPlan = async ({ opencodeOriginal, supportedSetupHost, }) => { - const plannedExecution = await runWithStatus( + const plannedExecution = await commands.runWithStatus( cli, ["setup", "--all-detected", "--dry-run", "--json"], environment, @@ -287,12 +283,17 @@ const verifySetupPlan = async ({ return { planned }; }; -const verifyAbsentOpenCodeSetup = async ({ cli, environment, home }) => { +const verifyAbsentOpenCodeSetup = async ({ + commands, + cli, + environment, + home, +}) => { const xdgConfigHome = join(home, ".xdg-opencode-absent"); const configPath = join(xdgConfigHome, "opencode", "opencode.json"); if (await pathExists(configPath)) throw new Error("packaged OpenCode first-run fixture was not absent"); - const execution = await runWithStatus( + const execution = await commands.runWithStatus( cli, ["setup", "--yes", "--client", "opencode", "--skill=false", "--json"], { ...environment, XDG_CONFIG_HOME: xdgConfigHome }, @@ -313,7 +314,12 @@ const verifyAbsentOpenCodeSetup = async ({ cli, environment, home }) => { await rm(xdgConfigHome, { recursive: true, force: true }); }; -const verifyAbsentClaudeCodeSetup = async ({ cli, environment, home }) => { +const verifyAbsentClaudeCodeSetup = async ({ + commands, + cli, + environment, + home, +}) => { const freshHome = join(home, ".claude-code-absent-home"); const configPath = join(freshHome, ".claude.json"); const markerPath = join(freshHome, ".claude"); @@ -336,7 +342,11 @@ const verifyAbsentClaudeCodeSetup = async ({ cli, environment, home }) => { "--skill=false", "--json", ]; - const firstExecution = await runWithStatus(cli, args, childEnvironment); + const firstExecution = await commands.runWithStatus( + cli, + args, + childEnvironment, + ); const first = json(firstExecution.stdout); const config = json(await readFile(configPath, "utf8")); const registration = first.doctor.identity?.registrations?.find( @@ -355,7 +365,11 @@ const verifyAbsentClaudeCodeSetup = async ({ cli, environment, home }) => { throw new Error( `packaged setup did not configure an explicitly selected Claude Code with no marker directory: ${JSON.stringify(first)}`, ); - const repeatExecution = await runWithStatus(cli, args, childEnvironment); + const repeatExecution = await commands.runWithStatus( + cli, + args, + childEnvironment, + ); const repeat = json(repeatExecution.stdout); if ( repeatExecution.status !== 0 || @@ -370,6 +384,7 @@ const verifyAbsentClaudeCodeSetup = async ({ cli, environment, home }) => { }; const verifySetupApply = async ({ + commands, cli, environment, supportedSetupHost, @@ -378,19 +393,19 @@ const verifySetupApply = async ({ const status = "ready"; const hopperStatus = hopperSetupSupported === true ? "needs_confirmation" : "needs_human"; - const firstExecution = await runWithStatus( + const firstExecution = await commands.runWithStatus( cli, ["setup", "--yes", "--all-detected", "--json"], environment, ); const first = json(firstExecution.stdout); - const alignedExecution = await runWithStatus( + const alignedExecution = await commands.runWithStatus( cli, ["setup", "--all-detected", "--json"], environment, ); const aligned = json(alignedExecution.stdout); - const secondExecution = await runWithStatus( + const secondExecution = await commands.runWithStatus( cli, [ "setup", @@ -520,8 +535,8 @@ const assertSkill = async ({ skillPath, siblingSkillPath, root }) => { throw new Error("packaged skill installation modified a sibling skill"); }; -const assertAlignedDoctor = async (cli, environment) => { - const alignedDoctorExecution = await runWithStatus( +const assertAlignedDoctor = async (commands, cli, environment) => { + const alignedDoctorExecution = await commands.runWithStatus( cli, ["doctor", "--json"], environment, @@ -540,6 +555,7 @@ const assertAlignedDoctor = async (cli, environment) => { }; const verifyConfigReadback = async ({ + commands, cli, environment, claudeConfig, @@ -575,10 +591,11 @@ const verifyConfigReadback = async ({ throw new Error( "packaged OpenCode backup did not preserve the original JSONC", ); - await assertAlignedDoctor(cli, environment); + await assertAlignedDoctor(commands, cli, environment); }; const verifySetupFailureRecovery = async ({ + commands, cli, environment, claudeConfig, @@ -587,7 +604,7 @@ const verifySetupFailureRecovery = async ({ }) => { await writeFile(claudeConfig, "malformed"); await writeFile(cursorConfig, '{"existing":true}\n'); - const failedExecution = await runWithStatus( + const failedExecution = await commands.runWithStatus( cli, ["setup", "--yes", "--all-detected", "--json"], environment, @@ -607,7 +624,7 @@ const verifySetupFailureRecovery = async ({ `packaged setup mutated clients after a preflight failure: ${JSON.stringify(failed)}`, ); await writeFile(claudeConfig, "{}\n"); - const recoveredExecution = await runWithStatus( + const recoveredExecution = await commands.runWithStatus( cli, ["setup", "--yes", "--all-detected", "--json"], environment, @@ -621,12 +638,12 @@ const verifySetupFailureRecovery = async ({ throw new Error("packaged setup did not recover"); }; -const verifyDanglingSymlink = async ({ cli, environment, home }) => { +const verifyDanglingSymlink = async ({ commands, cli, environment, home }) => { const geminiDir = join(home, ".gemini"); const geminiConfig = join(geminiDir, "settings.json"); await mkdir(geminiDir, { recursive: true }); await symlink(join(home, "missing-gemini.json"), geminiConfig); - const danglingExecution = await runWithStatus( + const danglingExecution = await commands.runWithStatus( cli, ["setup", "--yes", "--client", "gemini_cli", "--json"], environment, @@ -649,6 +666,7 @@ const verifyDanglingSymlink = async ({ cli, environment, home }) => { }; const verifyUninstall = async ({ + commands, cli, environment, cursorConfig, @@ -657,7 +675,7 @@ const verifyUninstall = async ({ codexTarget, opencodeConfig, }) => { - const uninstallExecution = await runWithStatus( + const uninstallExecution = await commands.runWithStatus( cli, ["uninstall", "--json"], environment, @@ -707,6 +725,7 @@ const verifyUninstall = async ({ /** Verify setup transactions, skill installation, and uninstall for supported hosts. */ export async function verifyPackageSetup({ + commands, cli, packageRunnerCli, environment, @@ -721,7 +740,7 @@ export async function verifyPackageSetup({ hopperSetupSupported, root, }) { - await verifyMcpAdd({ cli, environment, npxLog }); + await verifyMcpAdd({ commands, cli, environment, npxLog }); const skillPath = join( home, ".agents/skills/reverse-engineer-anything/SKILL.md", @@ -764,6 +783,7 @@ export async function verifyPackageSetup({ unchangedPaths, }); await verifySetupPlan({ + commands, cli, environment, claudeConfig, @@ -774,15 +794,17 @@ export async function verifyPackageSetup({ supportedSetupHost, }); const apply = await verifySetupApply({ + commands, cli, environment, supportedSetupHost, hopperSetupSupported, }); if (supportedSetupHost) { - await verifyAbsentOpenCodeSetup({ cli, environment, home }); - await verifyAbsentClaudeCodeSetup({ cli, environment, home }); + await verifyAbsentOpenCodeSetup({ commands, cli, environment, home }); + await verifyAbsentClaudeCodeSetup({ commands, cli, environment, home }); await verifyConfigReadback({ + commands, cli, environment, claudeConfig, @@ -798,14 +820,16 @@ export async function verifyPackageSetup({ cursorOriginal, }); await verifySetupFailureRecovery({ + commands, cli, environment, claudeConfig, cursorConfig, status: apply.status, }); - await verifyDanglingSymlink({ cli, environment, home }); + await verifyDanglingSymlink({ commands, cli, environment, home }); await verifyUninstall({ + commands, cli, environment, cursorConfig, diff --git a/scripts/verify-package-unknown-provider.mjs b/scripts/verify-package-unknown-provider.mjs index 68690d55c9..775e6426ec 100644 --- a/scripts/verify-package-unknown-provider.mjs +++ b/scripts/verify-package-unknown-provider.mjs @@ -1,8 +1,8 @@ -import { json, runWithStatus } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; /** Verify that an unknown --provider is rejected by the packaged CLI. */ -export async function verifyUnknownProvider({ cli, environment }) { - const unknownProviderExecution = await runWithStatus( +export async function verifyUnknownProvider({ commands, cli, environment }) { + const unknownProviderExecution = await commands.runWithStatus( cli, ["analyze", process.execPath, "--provider", "missing-provider", "--json"], environment, diff --git a/scripts/verify-package-update.mjs b/scripts/verify-package-update.mjs index 1846974a44..bf39f12cdf 100644 --- a/scripts/verify-package-update.mjs +++ b/scripts/verify-package-update.mjs @@ -1,108 +1,12 @@ -import { createHash, randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; -import { exec, json } from "./lib/verify-package-core.mjs"; +import { json } from "./lib/verify-package-core.mjs"; import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; -/** Keep the live updater owner and its workspace when process cleanup is unproven. */ -export class PackageUpdateCleanupFailure extends AggregateError { - constructor(primary, cleanupOwner, result, workspace) { - const cleanup = new Error(result.reason); - super( - primary === undefined ? [cleanup] : [primary, cleanup], - `Package updater cleanup incomplete; retained ${workspace}`, - { cause: primary }, - ); - this.cleanupOwner = cleanupOwner; - this.workspace = workspace; - } -} - -/** Own the helper and installer descendants until output and process cleanup finish. */ -async function runUpdater({ - harness, - environment, - signal, - workspace, - retainWorkspace, -}) { - // Only updater execution needs built process ownership; importing this fixture - // does not make unrelated source-only verifier helpers depend on dist. - const { ProviderProcessSupervisor, spawnOwnedProviderProcess } = - await import("../dist/process/ProviderProcess.js"); - const { cleanupOwnedProcessGroup } = - await import("../dist/process/ProcessOwnership.js"); - const launch = await spawnOwnedProviderProcess({ - command: process.execPath, - arguments: [harness], - expectedCommand: null, - hostEnvironment: environment, - cwd: workspace, - runId: randomUUID(), - sweepTokenOwnedProcesses: true, - signal, - }); - let collectionFailure; - const supervisor = new ProviderProcessSupervisor( - { - ...launch, - ownsProcessLifetime: true, - cleanup: - launch.cleanup ?? (() => cleanupOwnedProcessGroup(launch.ownership)), - }, - { - maxDiagnosticBytes: 16 * 1024 * 1024, - onDiagnostic(event) { - if (event.type === "output" && event.truncated) - collectionFailure ??= new Error( - "Package updater exceeded its diagnostic capacity", - ); - if (event.type === "error") - collectionFailure ??= new Error(event.message, { - cause: event.cause, - }); - }, - }, - ); - let failure; - let stdout; - try { - while (!(await supervisor.waitForOutputClose(50))) { - signal.throwIfAborted(); - if (collectionFailure !== undefined) throw collectionFailure; - } - signal.throwIfAborted(); - if (collectionFailure !== undefined) throw collectionFailure; - const snapshot = supervisor.snapshot(); - if ( - snapshot.diagnosticTruncated || - snapshot.exitCode !== 0 || - snapshot.signal !== null - ) - throw new Error( - `Package updater failed: ${snapshot.stdout.text}\n${snapshot.stderr.text}`, - ); - stdout = snapshot.stdout.text; - } catch (cause) { - failure = { cause }; - } - const stopped = await supervisor.stop(); - if (stopped.status === "incomplete") { - retainWorkspace?.(); - throw new PackageUpdateCleanupFailure( - failure?.cause, - supervisor, - stopped, - workspace, - ); - } - if (failure !== undefined) throw failure.cause; - return stdout; -} - /** Exercise the packaged updater with real npm and a local release registry fixture. */ export async function verifyPackageUpdate({ prefix, @@ -110,7 +14,7 @@ export async function verifyPackageUpdate({ tarball, workspace, environment, - retainWorkspace, + commands, }) { const metadataPath = join(packageRoot, "package.json"); const metadata = json(await readFile(metadataPath, "utf8")); @@ -126,8 +30,8 @@ export async function verifyPackageUpdate({ const fixture = new AbortController(); const server = createServer((request, response) => { void serveRegistry(request, response).catch((error) => { - fixtureFailure ??= error; - fixture.abort(fixtureFailure); + fixtureFailure ??= { cause: error }; + fixture.abort(fixtureFailure.cause); if (response.headersSent) { response.destroy(); return; @@ -222,7 +126,7 @@ export async function verifyPackageUpdate({ npm_config_registry: registry, }; const previous = ( - await exec(process.execPath, [entry, "--version"], { + await commands.exec(process.execPath, [entry, "--version"], { env: runtimeEnvironment, }) ).stdout.trim(); @@ -241,13 +145,14 @@ if (result.status === "failed") process.exitCode = 1; `, ); const result = json( - await runUpdater({ - harness, - environment: runtimeEnvironment, - signal: fixture.signal, - workspace, - retainWorkspace, - }), + ( + await commands.exec(process.execPath, [harness], { + env: runtimeEnvironment, + cwd: workspace, + signal: fixture.signal, + diagnosticBytes: 16 * 1024 * 1024, + }) + ).stdout, ); if ( result.status !== "updated" || @@ -265,14 +170,14 @@ if (result.status === "failed") process.exitCode = 1; "Update applied integration maintenance without approval", ); const current = ( - await exec(process.execPath, [entry, "--version"], { + await commands.exec(process.execPath, [entry, "--version"], { env: runtimeEnvironment, }) ).stdout.trim(); if (current !== metadata.version) throw new Error(`Updated executable reported ${current}`); const help = ( - await exec(process.execPath, [entry, "--help"], { + await commands.exec(process.execPath, [entry, "--help"], { env: runtimeEnvironment, }) ).stdout; @@ -280,18 +185,15 @@ if (result.status === "failed") process.exitCode = 1; throw new Error( "Updated CLI did not expose update as its sole maintenance command", ); - if (fixtureFailure !== undefined) throw fixtureFailure; + if (fixtureFailure !== undefined) throw fixtureFailure.cause; return { installed_version: result.installedVersion, exact_release: true, maintenance_read_only: true, }; } catch (error) { - // A retained process owner takes precedence over its triggering fixture - // failure; otherwise report the first registry/proxy failure exactly. - throw error instanceof PackageUpdateCleanupFailure - ? error - : (fixtureFailure ?? error); + // The registry owns its failure; command cleanup remains with commands. + throw fixtureFailure === undefined ? error : fixtureFailure.cause; } }, [ async () => { diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index d20f834f6c..3761a3aa9f 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,8 +1,6 @@ import { mkdir, mkdtemp, rm, unlink } from "node:fs/promises"; -import { execFile } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; import { PRODUCT_IDENTITY } from "../dist/identity.js"; @@ -22,6 +20,7 @@ import { verifyPackageSetup } from "./verify-package-setup.mjs"; import { verifyRepositorySkill } from "./verify-repository-skill.mjs"; import { verifyManaged } from "./verify-package-managed.mjs"; import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs"; +import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs"; import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; import { @@ -35,7 +34,6 @@ import { } from "./lib/mcp-startup-probe.mjs"; const root = process.cwd(); -const execFileAsync = promisify(execFile); const retentionDirectory = process.env.REA_VERIFIED_PACKAGE_DIRECTORY; const verifierRun = createVerifierRun(); const workspace = await mkdtemp(join(tmpdir(), "rea-package-")); @@ -49,7 +47,11 @@ let repositorySkill; let report; let retainedPackage; let retentionMetadata; -let updateWorkspaceRetained = false; +let commands; +const releaseWorkspace = () => + runStage("workspace cleanup", () => + rm(workspace, { recursive: true, force: true }), + ); const runStage = async (name, action) => { const started = performance.now(); @@ -62,9 +64,12 @@ const runStage = async (name, action) => { }; await withVerifierCleanup(async () => { + commands = await createPackageVerifierCommands({ + workspace: { path: workspace, close: releaseWorkspace }, + }); if (retentionDirectory !== undefined && retentionDirectory !== "") { const sourceSha = ( - await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root }) + await commands.exec("git", ["rev-parse", "HEAD"], { cwd: root }) ).stdout.trim(); retentionMetadata = createVerifiedPackageMetadata({ sourceSha, @@ -84,10 +89,10 @@ await withVerifierCleanup(async () => { }); } repositorySkill = await runStage("repository skill", () => - verifyRepositorySkill({ root, workspace }), + verifyRepositorySkill({ commands, root, workspace }), ); ({ tarball } = await runStage("package archive", () => - verifyPackagePack({ root, workspace }), + verifyPackagePack({ commands, root, workspace }), )); const environmentData = await runStage("package environment", () => verifyPackageEnvironment({ @@ -99,6 +104,7 @@ await withVerifierCleanup(async () => { ); const { cli, packageRunnerCli } = await runStage("package install", () => verifyPackageInstall({ + commands, tarball, prefix: environmentData.prefix, workspace, @@ -107,6 +113,7 @@ await withVerifierCleanup(async () => { ); update = await runStage("package update", () => verifyPackageUpdate({ + commands, prefix: environmentData.prefix, packageRoot: join( environmentData.prefix, @@ -117,9 +124,6 @@ await withVerifierCleanup(async () => { tarball, workspace, environment: environmentData.environment, - retainWorkspace() { - updateWorkspaceRetained = true; - }, }), ); mcpStartup = await runStage("MCP startup", () => @@ -147,12 +151,14 @@ await withVerifierCleanup(async () => { "package discovery", () => verifyPackageDiscovery({ + commands, cli, environment: environmentData.environment, }), ); const { artifactArchive } = await runStage("artifact and Electron CLI", () => verifyPackageArtifactAndElectron({ + commands, cli, workspace, environment: environmentData.environment, @@ -160,6 +166,7 @@ await withVerifierCleanup(async () => { ); await runStage("managed analysis CLI", () => verifyManaged({ + commands, cli, workspace, environment: environmentData.environment, @@ -167,24 +174,28 @@ await withVerifierCleanup(async () => { ); await runStage("unknown provider CLI", () => verifyUnknownProvider({ + commands, cli, environment: environmentData.environment, }), ); await runStage("platform CLI", () => verifyPackagePlatform({ + commands, cli, environment: environmentData.environment, }), ); await runStage("capabilities and search CLI", () => verifyPackageCapabilitiesAndSearch({ + commands, cli, environment: environmentData.environment, }), ); await runStage("evidence CLI", () => verifyPackageEvidence({ + commands, cli, evidenceRoot, referenceRoot, @@ -193,6 +204,7 @@ await withVerifierCleanup(async () => { ); await runStage("setup and uninstall CLI", () => verifyPackageSetup({ + commands, cli, packageRunnerCli, environment: environmentData.environment, @@ -210,6 +222,7 @@ await withVerifierCleanup(async () => { ); await runStage("MCP tools and evidence", () => verifyPackageMcp({ + commands, cli, environment: environmentData.environment, evidenceRoot, @@ -259,18 +272,7 @@ await withVerifierCleanup(async () => { metadata: retentionMetadata, }); } -}, [ - async () => { - if (updateWorkspaceRetained) - process.stderr.write( - `Package updater cleanup incomplete; retained ${workspace}\n`, - ); - else - await runStage("workspace cleanup", () => - rm(workspace, { recursive: true, force: true }), - ); - }, -]); +}, [() => (commands === undefined ? releaseWorkspace() : commands.close())]); const verifierLineage = await runStage("verifier lineage", () => completeVerifierRun(verifierRun), ); diff --git a/scripts/verify-packaged-ghidra-windows.mjs b/scripts/verify-packaged-ghidra-windows.mjs index 68f7746022..d33690afbe 100644 --- a/scripts/verify-packaged-ghidra-windows.mjs +++ b/scripts/verify-packaged-ghidra-windows.mjs @@ -11,7 +11,6 @@ import { } from "./lib/mcp-verifier-results.mjs"; import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs"; import { verifyWindowsGhidraByteSearch } from "./lib/ghidra-windows-byte-search.mjs"; -import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtemp, @@ -23,18 +22,20 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; -import { promisify } from "node:util"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { execNpm } from "./lib/verify-package-core.mjs"; +import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs"; +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; if (process.platform !== "win32" || process.arch !== "x64") throw new Error( "Packaged Windows Ghidra acceptance requires a Windows x64 host.", ); -const exec = promisify(execFile); const arguments_ = process.argv.slice(2); const internalWorkspace = arguments_[0] === "--internal-workspace"; const fixtureArguments = internalWorkspace ? arguments_.slice(2) : arguments_; @@ -48,24 +49,35 @@ const [packageRootArgument, targetArgument, reportArgument] = // Keep installation cleanup in a parent that never loads that addon. if (!internalWorkspace) { const ownedWorkspace = await mkdtemp(join(tmpdir(), "rea-packaged-ghidra-")); + const workspaceOwner = { + path: ownedWorkspace, + close: () => rm(ownedWorkspace, { recursive: true, force: true }), + }; + let commands; let result; try { - result = await exec( - process.execPath, - [ - fileURLToPath(import.meta.url), - "--internal-workspace", - ownedWorkspace, - ...arguments_, - ], - { timeout: 900_000, maxBuffer: 8 * 1024 * 1024 }, - ); + result = await withVerifierCleanup(async () => { + commands = await createPackageVerifierCommands({ + workspace: workspaceOwner, + }); + return commands.exec( + process.execPath, + [ + fileURLToPath(import.meta.url), + "--internal-workspace", + ownedWorkspace, + ...arguments_, + ], + { timeout: 900_000, maxBuffer: 8 * 1024 * 1024 }, + ); + }, [ + () => + commands === undefined ? workspaceOwner.close() : commands.close(), + ]); } catch (cause) { if (typeof cause.stdout === "string") process.stdout.write(cause.stdout); if (typeof cause.stderr === "string") process.stderr.write(cause.stderr); throw cause; - } finally { - await rm(ownedWorkspace, { recursive: true, force: true }); } const report = JSON.parse(result.stdout); report.workspaceCleanup = true; @@ -78,453 +90,511 @@ if (!internalWorkspace) { } assert.ok(arguments_[1] !== undefined, "Missing verifier workspace."); const workspace = resolve(arguments_[1]); -let packageRoot = resolve(packageRootArgument ?? "."); -// The default lane verifies the npm artifact in an isolated prefix. Explicit -// package roots support an already-installed artifact without a second install. -if (packageRootArgument === undefined) { +let commands; +const closeCommands = () => commands?.close() ?? Promise.resolve(); +try { + commands = await createPackageVerifierCommands({ + // The parent owns removal after this process unloads the installed addon. + workspace: { path: workspace, close: () => Promise.resolve() }, + }); + let packageRoot = resolve(packageRootArgument ?? "."); + // The default lane verifies the npm artifact in an isolated prefix. Explicit + // package roots support an already-installed artifact without a second install. + if (packageRootArgument === undefined) { + try { + const packed = JSON.parse( + ( + await commands.execNpm( + [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + workspace, + ], + { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, + ) + ).stdout, + ); + assert.equal( + packed.length, + 1, + "The package lane must produce one artifact.", + ); + const prefix = join(workspace, "installed"); + await commands.execNpm( + [ + "install", + "--prefix", + prefix, + "--ignore-scripts", + "--omit=dev", + "--no-audit", + "--no-fund", + join(workspace, packed[0].filename), + ], + { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, + ); + packageRoot = join(prefix, "node_modules", "rea-agents"); + } catch (cause) { + throw new Error("Packaged Windows verifier installation failed.", { + cause, + }); + } + } + const target = resolve( + targetArgument ?? `build/fixtures/${fixture.filename}`, + ); + let TOOL_CONTRACTS, native, token, sha256; + let parsedTarget; try { - const packed = JSON.parse( - ( - await execNpm( - [ - "pack", - "--json", - "--ignore-scripts", - "--pack-destination", - workspace, - ], - { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, - ) - ).stdout, + ({ TOOL_CONTRACTS } = await import( + pathToFileURL(join(packageRoot, "dist/contracts/toolContracts.js")) + )); + const { requireWindowsNativeAuthority } = await import( + pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js")) + ); + native = requireWindowsNativeAuthority(); + token = native.call("process_caller_token", []); + assert.equal( + token.administrator, + false, + "The real-provider lane must run as an ordinary user.", ); assert.equal( - packed.length, - 1, - "The package lane must produce one artifact.", + token.elevated, + false, + "The real-provider lane must use a non-elevated token.", ); - const prefix = join(workspace, "installed"); - await execNpm( - [ - "install", - "--prefix", - prefix, - "--ignore-scripts", - "--omit=dev", - "--no-audit", - "--no-fund", - join(workspace, packed[0].filename), - ], - { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, + const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); + sha256 = digest(await readFile(target)); + const { parseBinaryTarget } = await import( + pathToFileURL( + join(packageRoot, "dist/application/BinaryTargetResolver.js"), + ) ); - packageRoot = join(prefix, "node_modules", "rea-agents"); + parsedTarget = await parseBinaryTarget(target); + assert.equal(parsedTarget.ok, true); + assert.equal(parsedTarget.value.format, "pe"); + assert.equal(parsedTarget.value.managed, false); + if (targetArgument === undefined) { + assert.equal(parsedTarget.value.architecture, fixture.architecture); + assert.equal( + parsedTarget.value.executableRole, + dllFixture ? "shared-library" : "application", + ); + } } catch (cause) { - throw new Error("Packaged Windows verifier installation failed.", { + throw new Error("Packaged Windows verifier prerequisites failed.", { cause, }); } -} -const target = resolve(targetArgument ?? `build/fixtures/${fixture.filename}`); -let TOOL_CONTRACTS, native, token, sha256; -let parsedTarget; -try { - ({ TOOL_CONTRACTS } = await import( - pathToFileURL(join(packageRoot, "dist/contracts/toolContracts.js")) - )); - const { requireWindowsNativeAuthority } = await import( - pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js")) - ); - native = requireWindowsNativeAuthority(); - token = native.call("process_caller_token", []); - assert.equal( - token.administrator, - false, - "The real-provider lane must run as an ordinary user.", - ); - assert.equal( - token.elevated, - false, - "The real-provider lane must use a non-elevated token.", - ); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); - sha256 = digest(await readFile(target)); - const { parseBinaryTarget } = await import( - pathToFileURL(join(packageRoot, "dist/application/BinaryTargetResolver.js")) - ); - parsedTarget = await parseBinaryTarget(target); - assert.equal(parsedTarget.ok, true); - assert.equal(parsedTarget.value.format, "pe"); - assert.equal(parsedTarget.value.managed, false); - if (targetArgument === undefined) { - assert.equal(parsedTarget.value.architecture, fixture.architecture); - assert.equal( - parsedTarget.value.executableRole, - dllFixture ? "shared-library" : "application", - ); - } -} catch (cause) { - throw new Error("Packaged Windows verifier prerequisites failed.", { cause }); -} -const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); -const runtimeParent = join(workspace, "runtime with spaces"); -await mkdir(runtimeParent); -const callerDirectory = join(workspace, "caller cwd with spaces"); -// Keep sorted for comparison with the final caller-directory listing. -const scriptCollisions = [ - "ReaGhidraAnalysisOptions.java", - "ReaGhidraApplySeeds.java", - "ReaGhidraBridge.java", - "ReaGhidraNoReturnFix.java", - "ReaGhidraPrepareCom.java", - "ReaGhidraPrepareRaw.java", -]; -await mkdir(callerDirectory); -for (const name of scriptCollisions) await mkdir(join(callerDirectory, name)); -const environment = { - ...Object.fromEntries( - Object.entries(process.env).filter( - ([name]) => name.toLowerCase() !== "comspec", + const runtimeParent = join(workspace, "runtime with spaces"); + await mkdir(runtimeParent); + const callerDirectory = join(workspace, "caller cwd with spaces"); + // Keep sorted for comparison with the final caller-directory listing. + const scriptCollisions = [ + "ReaGhidraAnalysisOptions.java", + "ReaGhidraApplySeeds.java", + "ReaGhidraBridge.java", + "ReaGhidraNoReturnFix.java", + "ReaGhidraPrepareCom.java", + "ReaGhidraPrepareRaw.java", + ]; + await mkdir(callerDirectory); + for (const name of scriptCollisions) await mkdir(join(callerDirectory, name)); + const environment = { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => name.toLowerCase() !== "comspec", + ), ), - ), - ComSpec: ( - process.env.ComSpec ?? - join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe") - ).replaceAll("\\", "/"), - REA_LOG_LEVEL: "error", - GHIDRA_HEADLESS_MAXMEM: "512m", - NODE_OPTIONS: "--max-old-space-size=512", - TEMP: runtimeParent.replaceAll("\\", "/"), - TMP: runtimeParent.replaceAll("\\", "/"), - HOME: workspace, - USERPROFILE: workspace, - APPDATA: join(workspace, "appdata"), - LOCALAPPDATA: join(workspace, "localappdata"), -}; -const entry = join(packageRoot, "scripts/rea.mjs"); -const report = { - ok: false, - platform: "win32", - architecture: "x64", - nonAdmin: true, - targetSha256: sha256, - targetArchitecture: parsedTarget.value.architecture, - targetRole: parsedTarget.value.executableRole, - cli: {}, - mcpOperations: [], - nativeControls: "available", - runtimeCleanup: false, -}; -const assertCleanup = async () => { - const entries = await readdir(runtimeParent); - report.runtimeResidualCount = entries.length; - report.runtimeResidualKinds = entries.map((name) => - name.startsWith("rea-") - ? "rea-runtime" - : name.startsWith("hsperfdata_") - ? "jvm-performance-data" - : "other", - ); - assert.equal(entries.length, 0, "Provider runtime cleanup left files."); -}; -try { - const providers = expandProviderOutput( - JSON.parse( - ( - await exec(process.execPath, [entry, "providers", "--json"], { - env: environment, - cwd: callerDirectory, - timeout: 30_000, - }) - ).stdout, - ), - ); - assert.equal(providers.analysis_provider_binding, null); - assert.ok(Array.isArray(providers.analysis_provider_candidates)); - const ghidra = providers.analysis_provider_candidates.find( - ({ provider }) => provider.id === "ghidra", - ); - assert.ok(ghidra !== undefined); - assert.equal(ghidra.selected, false); - assert.equal(ghidra.target_support.status, "unknown"); - report.cli.providers = true; - const inspected = await exec( - process.execPath, - [entry, "inspect", target, "--provider", "ghidra", "--format", "json"], - { - env: environment, - cwd: callerDirectory, - timeout: 360_000, - maxBuffer: 8 * 1024 * 1024, - }, - ); - const inspection = JSON.parse(inspected.stdout); - assert.ok(inspection.error === undefined); - report.cli.inspect = true; - await assertCleanup(); - const transport = new StdioClientTransport({ - command: process.execPath, - args: [entry, "mcp"], - env: environment, - cwd: callerDirectory, - stderr: "pipe", - }); - const client = new Client({ - name: "rea-packaged-windows-ghidra", - version: "1.0.0", - }); - const call = async (name, arguments_) => { - report.lastOperation = name; - if (reportArgument !== undefined) - await writeFile(reportArgument, `${JSON.stringify(report)}\n`); - const contract = TOOL_CONTRACTS.find((value) => value.name === name); - assert.ok(contract !== undefined, `Missing canonical contract: ${name}`); - const argumentsParsed = contract.inputSchema.parse(arguments_); - const result = await client.callTool( - { name, arguments: argumentsParsed }, - { timeout: 360_000 }, + ComSpec: ( + process.env.ComSpec ?? + join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe") + ).replaceAll("\\", "/"), + REA_LOG_LEVEL: "error", + GHIDRA_HEADLESS_MAXMEM: "512m", + NODE_OPTIONS: "--max-old-space-size=512", + TEMP: runtimeParent.replaceAll("\\", "/"), + TMP: runtimeParent.replaceAll("\\", "/"), + HOME: workspace, + USERPROFILE: workspace, + APPDATA: join(workspace, "appdata"), + LOCALAPPDATA: join(workspace, "localappdata"), + }; + const entry = join(packageRoot, "scripts/rea.mjs"); + const report = { + ok: false, + platform: "win32", + architecture: "x64", + nonAdmin: true, + targetSha256: sha256, + targetArchitecture: parsedTarget.value.architecture, + targetRole: parsedTarget.value.executableRole, + cli: {}, + mcpOperations: [], + nativeControls: "available", + runtimeCleanup: false, + }; + const assertCleanup = async () => { + const entries = await readdir(runtimeParent); + report.runtimeResidualCount = entries.length; + report.runtimeResidualKinds = entries.map((name) => + name.startsWith("rea-") + ? "rea-runtime" + : name.startsWith("hsperfdata_") + ? "jvm-performance-data" + : "other", ); - assert.notEqual(result.isError, true, `Packaged operation failed: ${name}`); - const projected = requireMcpOperationResult(result, name, contract); - report.mcpOperations.push(name); - return projected; + assert.equal(entries.length, 0, "Provider runtime cleanup left files."); }; try { - await client.connect(transport); - await call("open_binary", { path: target }); - await call("binary_overview", {}); - const activators = ( - await readdir(runtimeParent, { recursive: true }) - ).filter((path) => basename(path) === "ReaGhidraBundleActivator.class"); - assert.equal(activators.length, 1); - const [activator] = activators; - assert.ok(activator !== undefined); - const bundleDirectory = dirname(join(runtimeParent, activator)); - const manifest = await readFile( - join(bundleDirectory, "META-INF", "MANIFEST.MF"), - "utf8", - ); - assert.match(manifest, /^Bundle-Activator: ReaGhidraBundleActivator\r?$/mu); - await assert.rejects( - readFile(join(bundleDirectory, "GeneratedActivator.class")), - { code: "ENOENT" }, - ); - report.bundleActivator = "ReaGhidraBundleActivator"; - assert.equal((await call("list_documents", {})).length, 1); - await call("list_names", {}); - const procedures = await call("list_procedures", {}); - assert.ok(procedures.length > 0); - if (targetArgument === undefined) { - const calleeAddress = fixture.calleeAddress; - const calleeCode = await call("procedure_pseudo_code", { - procedure: calleeAddress, - }); - assert.match(calleeCode, /\b(?:42|0x2a)\b/iu); - if (dllFixture) { - const callerAddress = fixture.callerAddress; - assert.ok( - ( - await call("procedure_callees", { procedure: callerAddress }) - ).includes(calleeAddress), - ); - assert.match( - await call("procedure_pseudo_code", { procedure: callerAddress }), - /\+\s*1\b/u, - ); - } - } - assert.ok((await call("list_segments", {})).length > 0); - await call("list_strings", {}); - const procedure = - procedures.find((item) => item.procedure?.external === false) ?? - procedures[0]; - assert.ok(procedure !== undefined); - const loadImage = await call("inspect_native_load_image", {}); - assert.equal( - loadImage.status, - "unsupported", - "PE load-image attestation must remain explicitly unsupported.", - ); - assert.ok(loadImage.observations !== undefined); - const memory = await call("read_bytes", { - address: procedure.address, - length: 16, - }); - const mapping = await call("address_to_file_offset", { - address: procedure.address, - }); - const sourceBytes = await readFile(target); - assert.equal(memory.complete, true); - assert.equal(memory.returned_bytes, 16); - assert.equal( - memory.bytes_hex, - sourceBytes - .subarray(mapping.file_offset, mapping.file_offset + 16) - .toString("hex"), - ); - await call("address_name", { address: procedure.address }); - assert.equal( - await call("procedure_address", { procedure: procedure.value }), - procedure.address, - ); - await call("resolve_containing_procedure", { address: procedure.address }); - await call("search_procedures", { pattern: procedure.value }); - await call("search_strings", { pattern: "rea" }); - report.byteSearch = await verifyWindowsGhidraByteSearch({ - invoke: call, - sourceBytes, - imageBase: fixture.imageBase, - sourceOwnedFixture: targetArgument === undefined, - fallbackAddress: procedure.address, - fallbackFileOffset: mapping.file_offset, - runCliPage: async (query) => { - const cliArguments = [entry, "search-bytes", target, query.pattern]; - for (const [option, value] of [ - ["--mask", query.mask], - ["--start", query.start], - ["--end", query.end], - ["--limit", String(query.limit)], - ]) { - if (value !== undefined) cliArguments.push(option, value); - } - cliArguments.push("--provider", "ghidra", "--json"); - const result = await exec(process.execPath, cliArguments, { - env: environment, - cwd: callerDirectory, - timeout: 360_000, - maxBuffer: 8 * 1024 * 1024, - }); - const evidence = JSON.parse(result.stdout); - assert.ok(evidence.error === undefined, "CLI byte search failed"); - return evidence.normalized_result; - }, - }); - for (const name of [ - "procedure_assembly", - "procedure_callees", - "procedure_callers", - "procedure_info", - "procedure_pseudo_code", - "read_function_instructions", - "procedure_references", - "analyze_function", - ]) - await call(name, { procedure: procedure.value }); - await call("xrefs", { address: procedure.address }); - await call("inspect_native_data_type", { type: "/undefined8" }); - await call("inspect_native_instruction", { address: procedure.address }); - await call("resolve_native_call_targets", { address: procedure.address }); - // Explicit caller-selected targets need not contain our fixture instructions. - const controlledTargets = [ - "rea-ghidra-windows.exe", - "rea-ghidra-windows-x86.exe", - ].map((name) => resolve("build", "fixtures", name)); - if (controlledTargets.includes(target)) - report.compactReferences = await verifyGhidraCompactReferences( - call, - target === controlledTargets[1], - async (command, address) => { - const result = await exec( + const providers = expandProviderOutput( + JSON.parse( + ( + await commands.exec( process.execPath, - [entry, command, target, address, "--provider", "ghidra", "--json"], + [entry, "providers", "--json"], { env: environment, cwd: callerDirectory, - timeout: 360_000, - maxBuffer: 8 * 1024 * 1024, + timeout: 30_000, }, + ) + ).stdout, + ), + ); + assert.equal(providers.analysis_provider_binding, null); + assert.ok(Array.isArray(providers.analysis_provider_candidates)); + const ghidra = providers.analysis_provider_candidates.find( + ({ provider }) => provider.id === "ghidra", + ); + assert.ok(ghidra !== undefined); + assert.equal(ghidra.selected, false); + assert.equal(ghidra.target_support.status, "unknown"); + report.cli.providers = true; + const inspected = await commands.exec( + process.execPath, + [entry, "inspect", target, "--provider", "ghidra", "--format", "json"], + { + env: environment, + cwd: callerDirectory, + timeout: 360_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + const inspection = JSON.parse(inspected.stdout); + assert.ok(inspection.error === undefined); + report.cli.inspect = true; + await assertCleanup(); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entry, "mcp"], + env: environment, + cwd: callerDirectory, + stderr: "pipe", + }); + const client = new Client({ + name: "rea-packaged-windows-ghidra", + version: "1.0.0", + }); + const closeClient = async () => { + try { + await client.close(); + } catch (cause) { + commands.adopt(client, entry); + throw cause; + } + }; + const closeTransport = async () => { + try { + await transport.close(); + } catch (cause) { + commands.adopt(transport, entry); + throw cause; + } + }; + const call = async (name, arguments_) => { + report.lastOperation = name; + if (reportArgument !== undefined) + await writeFile(reportArgument, `${JSON.stringify(report)}\n`); + const contract = TOOL_CONTRACTS.find((value) => value.name === name); + assert.ok(contract !== undefined, `Missing canonical contract: ${name}`); + const argumentsParsed = contract.inputSchema.parse(arguments_); + const result = await client.callTool( + { name, arguments: argumentsParsed }, + { timeout: 360_000 }, + ); + assert.notEqual( + result.isError, + true, + `Packaged operation failed: ${name}`, + ); + const projected = requireMcpOperationResult(result, name, contract); + report.mcpOperations.push(name); + return projected; + }; + try { + await client.connect(transport); + await call("open_binary", { path: target }); + await call("binary_overview", {}); + const activators = ( + await readdir(runtimeParent, { recursive: true }) + ).filter((path) => basename(path) === "ReaGhidraBundleActivator.class"); + assert.equal(activators.length, 1); + const [activator] = activators; + assert.ok(activator !== undefined); + const bundleDirectory = dirname(join(runtimeParent, activator)); + const manifest = await readFile( + join(bundleDirectory, "META-INF", "MANIFEST.MF"), + "utf8", + ); + assert.match( + manifest, + /^Bundle-Activator: ReaGhidraBundleActivator\r?$/mu, + ); + await assert.rejects( + readFile(join(bundleDirectory, "GeneratedActivator.class")), + { code: "ENOENT" }, + ); + report.bundleActivator = "ReaGhidraBundleActivator"; + assert.equal((await call("list_documents", {})).length, 1); + await call("list_names", {}); + const procedures = await call("list_procedures", {}); + assert.ok(procedures.length > 0); + if (targetArgument === undefined) { + const calleeAddress = fixture.calleeAddress; + const calleeCode = await call("procedure_pseudo_code", { + procedure: calleeAddress, + }); + assert.match(calleeCode, /\b(?:42|0x2a)\b/iu); + if (dllFixture) { + const callerAddress = fixture.callerAddress; + assert.ok( + ( + await call("procedure_callees", { procedure: callerAddress }) + ).includes(calleeAddress), ); + assert.match( + await call("procedure_pseudo_code", { procedure: callerAddress }), + /\+\s*1\b/u, + ); + } + } + assert.ok((await call("list_segments", {})).length > 0); + await call("list_strings", {}); + const procedure = + procedures.find((item) => item.procedure?.external === false) ?? + procedures[0]; + assert.ok(procedure !== undefined); + const loadImage = await call("inspect_native_load_image", {}); + assert.equal( + loadImage.status, + "unsupported", + "PE load-image attestation must remain explicitly unsupported.", + ); + assert.ok(loadImage.observations !== undefined); + const memory = await call("read_bytes", { + address: procedure.address, + length: 16, + }); + const mapping = await call("address_to_file_offset", { + address: procedure.address, + }); + const sourceBytes = await readFile(target); + assert.equal(memory.complete, true); + assert.equal(memory.returned_bytes, 16); + assert.equal( + memory.bytes_hex, + sourceBytes + .subarray(mapping.file_offset, mapping.file_offset + 16) + .toString("hex"), + ); + await call("address_name", { address: procedure.address }); + assert.equal( + await call("procedure_address", { procedure: procedure.value }), + procedure.address, + ); + await call("resolve_containing_procedure", { + address: procedure.address, + }); + await call("search_procedures", { pattern: procedure.value }); + await call("search_strings", { pattern: "rea" }); + report.byteSearch = await verifyWindowsGhidraByteSearch({ + invoke: call, + sourceBytes, + imageBase: fixture.imageBase, + sourceOwnedFixture: targetArgument === undefined, + fallbackAddress: procedure.address, + fallbackFileOffset: mapping.file_offset, + runCliPage: async (query) => { + const cliArguments = [entry, "search-bytes", target, query.pattern]; + for (const [option, value] of [ + ["--mask", query.mask], + ["--start", query.start], + ["--end", query.end], + ["--limit", String(query.limit)], + ]) { + if (value !== undefined) cliArguments.push(option, value); + } + cliArguments.push("--provider", "ghidra", "--json"); + const result = await commands.exec(process.execPath, cliArguments, { + env: environment, + cwd: callerDirectory, + timeout: 360_000, + maxBuffer: 8 * 1024 * 1024, + }); const evidence = JSON.parse(result.stdout); - assert.ok(evidence.error === undefined, `CLI ${command} failed`); - const contract = TOOL_CONTRACTS.find( - ({ name }) => name === command.replaceAll("-", "_"), - ); - assert.ok(contract, `Missing CLI ${command} contract`); - contract.outputSchema.parse(evidence); + assert.ok(evidence.error === undefined, "CLI byte search failed"); return evidence.normalized_result; }, + }); + for (const name of [ + "procedure_assembly", + "procedure_callees", + "procedure_callers", + "procedure_info", + "procedure_pseudo_code", + "read_function_instructions", + "procedure_references", + "analyze_function", + ]) + await call(name, { procedure: procedure.value }); + await call("xrefs", { address: procedure.address }); + await call("inspect_native_data_type", { type: "/undefined8" }); + await call("inspect_native_instruction", { address: procedure.address }); + await call("resolve_native_call_targets", { address: procedure.address }); + // Explicit caller-selected targets need not contain our fixture instructions. + const controlledTargets = [ + "rea-ghidra-windows.exe", + "rea-ghidra-windows-x86.exe", + ].map((name) => resolve("build", "fixtures", name)); + if (controlledTargets.includes(target)) + report.compactReferences = await verifyGhidraCompactReferences( + call, + target === controlledTargets[1], + async (command, address) => { + const result = await commands.exec( + process.execPath, + [ + entry, + command, + target, + address, + "--provider", + "ghidra", + "--json", + ], + { + env: environment, + cwd: callerDirectory, + timeout: 360_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + const evidence = JSON.parse(result.stdout); + assert.ok(evidence.error === undefined, `CLI ${command} failed`); + const contract = TOOL_CONTRACTS.find( + ({ name }) => name === command.replaceAll("-", "_"), + ); + assert.ok(contract, `Missing CLI ${command} contract`); + contract.outputSchema.parse(evidence); + return evidence.normalized_result; + }, + ); + const { GHIDRA_MUTATING_OPERATIONS } = await import( + pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js")) ); - const { GHIDRA_MUTATING_OPERATIONS } = await import( - pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js")) - ); - const { GHIDRA_OPERATIONS } = await import( - pathToFileURL( - join(packageRoot, "dist/ghidra/GhidraProviderCapabilities.js"), - ) - ); - const expected = GHIDRA_OPERATIONS.filter( - (name) => !GHIDRA_MUTATING_OPERATIONS.has(name), - ); - const expectedOperations = new Set([ - ...expected, - "open_binary", - "binary_overview", - "binary_session", - "close_binary", - ]); - const observedOperations = new Set(report.mcpOperations); - const missingOperations = expected.filter( - (name) => !observedOperations.has(name), - ); - const unexpectedOperations = [...observedOperations].filter( - (name) => !expectedOperations.has(name), + const { GHIDRA_OPERATIONS } = await import( + pathToFileURL( + join(packageRoot, "dist/ghidra/GhidraProviderCapabilities.js"), + ) + ); + const expected = GHIDRA_OPERATIONS.filter( + (name) => !GHIDRA_MUTATING_OPERATIONS.has(name), + ); + const expectedOperations = new Set([ + ...expected, + "open_binary", + "binary_overview", + "binary_session", + "close_binary", + ]); + const observedOperations = new Set(report.mcpOperations); + const missingOperations = expected.filter( + (name) => !observedOperations.has(name), + ); + const unexpectedOperations = [...observedOperations].filter( + (name) => !expectedOperations.has(name), + ); + assert.deepEqual( + { missing: missingOperations, unexpected: unexpectedOperations }, + { missing: [], unexpected: [] }, + "Packaged verification operation coverage drifted.", + ); + const session = await call("binary_session", {}); + assert.equal( + session.analysis_provider_binding?.analysis_profile?.parameters + ?.executable_role, + parsedTarget.value.executableRole, + "MCP profile must preserve the selected PE role.", + ); + assert.equal( + session.analysis_provider_binding?.selection_source, + "auto-single-candidate", + "Packaged MCP verification must exercise automatic provider selection.", + ); + assert.equal( + session.analysis_provider_binding?.provider?.id, + "ghidra", + "The sole available Windows analysis provider must be Ghidra.", + ); + await call("close_binary", {}); + await assertCleanup(); + } catch (cause) { + await withVerifierCleanup( + () => Promise.reject(cause), + [closeClient, closeTransport], + ); + } + await closeVerifierResources([closeClient, closeTransport]); + assert.equal( + digest(await readFile(target)), + sha256, + "Analysis modified the selected target.", ); assert.deepEqual( - { missing: missingOperations, unexpected: unexpectedOperations }, - { missing: [], unexpected: [] }, - "Packaged verification operation coverage drifted.", + native.call("process_caller_token", []), + token, + "Analysis changed caller token authority.", ); - const session = await call("binary_session", {}); - assert.equal( - session.analysis_provider_binding?.analysis_profile?.parameters - ?.executable_role, - parsedTarget.value.executableRole, - "MCP profile must preserve the selected PE role.", + assert.deepEqual( + (await readdir(callerDirectory)).sort(), + [...scriptCollisions].sort(), ); - assert.equal( - session.analysis_provider_binding?.selection_source, - "auto-single-candidate", - "Packaged MCP verification must exercise automatic provider selection.", - ); - assert.equal( - session.analysis_provider_binding?.provider?.id, - "ghidra", - "The sole available Windows analysis provider must be Ghidra.", - ); - await call("close_binary", {}); - await assertCleanup(); - } finally { - await client.close(); + for (const name of scriptCollisions) + assert.deepEqual(await readdir(join(callerDirectory, name)), []); + report.callerScriptCollisionsPreserved = true; + report.runtimeCleanup = true; + // Only the parent can attest success after removing the installation. + if (reportArgument !== undefined) + await writeFile(reportArgument, `${JSON.stringify(report)}\n`); + process.stdout.write(`${JSON.stringify(report)}\n`); + } catch (cause) { + report.failure = { + name: cause.name, + message: cause.message + .replaceAll(packageRoot, "") + .replaceAll(target, "") + .replaceAll(workspace, ""), + }; + if (reportArgument !== undefined) + await writeFile(reportArgument, `${JSON.stringify(report)}\n`); + throw cause; } - assert.equal( - digest(await readFile(target)), - sha256, - "Analysis modified the selected target.", - ); - assert.deepEqual( - native.call("process_caller_token", []), - token, - "Analysis changed caller token authority.", - ); - assert.deepEqual( - (await readdir(callerDirectory)).sort(), - [...scriptCollisions].sort(), - ); - for (const name of scriptCollisions) - assert.deepEqual(await readdir(join(callerDirectory, name)), []); - report.callerScriptCollisionsPreserved = true; - report.runtimeCleanup = true; - // Only the parent can attest success after removing the installation. - if (reportArgument !== undefined) - await writeFile(reportArgument, `${JSON.stringify(report)}\n`); - process.stdout.write(`${JSON.stringify(report)}\n`); } catch (cause) { - report.failure = { - name: cause.name, - message: cause.message - .replaceAll(packageRoot, "") - .replaceAll(target, "") - .replaceAll(workspace, ""), - }; - if (reportArgument !== undefined) - await writeFile(reportArgument, `${JSON.stringify(report)}\n`); - throw cause; + await withVerifierCleanup(() => Promise.reject(cause), [closeCommands]); } +await closeCommands(); diff --git a/scripts/verify-packaged-wasm-artifact.mjs b/scripts/verify-packaged-wasm-artifact.mjs index 47a8053837..750e013e3f 100644 --- a/scripts/verify-packaged-wasm-artifact.mjs +++ b/scripts/verify-packaged-wasm-artifact.mjs @@ -1,60 +1,23 @@ #!/usr/bin/env node import { join } from "node:path"; import { mkdir } from "node:fs/promises"; -import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; -import { - OwnedCommandFailure, - runOwnedCommand, -} from "../dist/process/OwnedCommand.js"; import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; -import { execNpm } from "./lib/verify-package-core.mjs"; +import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs"; import { WABT_WORKFLOW_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs"; import { verifyPackagePack } from "./verify-package-pack.mjs"; -/** Preserve the actual dependent process/root owner across failed cleanup. */ -class PackagedWasmCleanupFailure extends Error { - constructor(cause, cleanupOwner, runtimeRoot) { - super( - `Packaged WASM verifier cleanup incomplete; retained ${runtimeRoot.path}: ${cause instanceof Error ? cause.message : String(cause)}`, - { cause }, - ); - this.name = "PackagedWasmCleanupFailure"; - this.cleanupOwner = cleanupOwner; - this.runtimeRoot = runtimeRoot; - } -} - const root = await PrivateRuntimeRoot.create({ prefix: "rea-wabt-package-verifier-", }); -let commandCleanupOwner; -let closing; -const cleanupOwner = { - close() { - if (closing !== undefined) return closing; - let attempt; - attempt = (async () => { - if (commandCleanupOwner !== undefined) { - const stopped = await commandCleanupOwner.stop(); - if (stopped.status === "incomplete") - throw new Error(stopped.reason, { cause: stopped }); - commandCleanupOwner = undefined; - } - await root.close(); - })().catch((cause) => { - if (closing === attempt) closing = undefined; - throw new PackagedWasmCleanupFailure(cause, cleanupOwner, root); - }); - closing = attempt; - return attempt; - }, -}; +let commands; const verifiedOutput = await withVerifierCleanup(async () => { + commands = await createPackageVerifierCommands({ workspace: root }); const { tarball } = await verifyPackagePack({ root: process.cwd(), workspace: root.path, + commands, }); const prefix = join(root.path, "prefix"); const home = join(root.path, "home"); @@ -66,7 +29,7 @@ const verifiedOutput = await withVerifierCleanup(async () => { XDG_CONFIG_HOME: join(home, ".config"), XDG_CACHE_HOME: join(home, ".cache"), }; - await execNpm( + await commands.execNpm( [ "install", "--global", @@ -88,32 +51,20 @@ const verifiedOutput = await withVerifierCleanup(async () => { "scripts", "rea.mjs", ); - let verified; - try { - verified = await runOwnedCommand( - { - command: process.execPath, - arguments: [ - fileURLToPath(new URL("./verify-wasm-artifact.mjs", import.meta.url)), - entrypoint, - "--require-tools", - ], - cwd: process.cwd(), - hostEnvironment: environment, - expectedCommand: process.execPath, - runId: `rea-packaged-wabt-${randomUUID()}`, - sweepTokenOwnedProcesses: true, - }, - { - timeoutMs: WABT_WORKFLOW_TIMEOUT_MS, - diagnosticBytes: 4 * 1024 * 1024, - }, - ); - } catch (cause) { - if (cause instanceof OwnedCommandFailure) - commandCleanupOwner = cause.cleanupOwner; - throw cause; - } - return verified.stdout.text; -}, [() => cleanupOwner.close()]); + const verified = await commands.exec( + process.execPath, + [ + fileURLToPath(new URL("./verify-wasm-artifact.mjs", import.meta.url)), + entrypoint, + "--require-tools", + ], + { + cwd: process.cwd(), + env: environment, + timeout: WABT_WORKFLOW_TIMEOUT_MS, + diagnosticBytes: 4 * 1024 * 1024, + }, + ); + return verified.stdout; +}, [() => (commands === undefined ? root.close() : commands.close())]); process.stdout.write(verifiedOutput); diff --git a/scripts/verify-packed-bridge.d.mts b/scripts/verify-packed-bridge.d.mts index f136227104..57bcd55a3b 100644 --- a/scripts/verify-packed-bridge.d.mts +++ b/scripts/verify-packed-bridge.d.mts @@ -1,5 +1,8 @@ +import type { PackageVerifierCommands } from "./lib/verify-package-commands.mjs"; + /** Validate required shipped bridges and parse packed Python without extraction. */ export function verifyPackedBridge(input: { readonly tarball: string; readonly packedFiles: readonly string[]; + readonly commands: PackageVerifierCommands; }): Promise; diff --git a/scripts/verify-packed-bridge.mjs b/scripts/verify-packed-bridge.mjs index 70d2aa65c3..8107be4f99 100644 --- a/scripts/verify-packed-bridge.mjs +++ b/scripts/verify-packed-bridge.mjs @@ -1,13 +1,10 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { GHIDRA_BRIDGE_SOURCES } from "../src/ghidra/GhidraBridgeSources.ts"; -const exec = promisify(execFile); const packedHopperBridge = "package/bridge/hopper_bridge.py"; const packedCutterBridge = "package/bridge/cutter/rea_bridge.py"; /** Check shipped bridge files; real-provider lanes verify runtime behavior. */ -export async function verifyPackedBridge({ tarball, packedFiles }) { +export async function verifyPackedBridge({ tarball, packedFiles, commands }) { for (const path of [ packedHopperBridge, packedCutterBridge, @@ -20,7 +17,7 @@ export async function verifyPackedBridge({ tarball, packedFiles }) { // Read the packed source without extracting into the caller's leased root. // Only the interpreter's standard library is needed; caller-local modules // and site startup hooks must not replace the parser or execute here. - await exec("python3", [ + await commands.exec("python3", [ "-I", "-S", "-c", diff --git a/scripts/verify-repository-skill.mjs b/scripts/verify-repository-skill.mjs index 861d633fb4..62960ceded 100644 --- a/scripts/verify-repository-skill.mjs +++ b/scripts/verify-repository-skill.mjs @@ -1,15 +1,15 @@ import { cp, mkdir, readFile, readdir } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; -import { exec, execNpm, pathExists } from "./lib/verify-package-core.mjs"; +import { pathExists } from "./lib/verify-package-core.mjs"; /** Install the separately locked Skills CLI so verification resolves no registry metadata. */ -const lockedSkillsCli = async (root, workspace) => { +const lockedSkillsCli = async (commands, root, workspace) => { const tool = join(workspace, "skills-cli"); await mkdir(tool, { recursive: true }); for (const file of ["package.json", "package-lock.json"]) await cp(join(root, "scripts/verify-tools/skills", file), join(tool, file)); - await execNpm( + await commands.execNpm( ["ci", "--prefer-offline", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: tool, timeout: 120_000 }, ); @@ -24,12 +24,12 @@ const lockedSkillsCli = async (root, workspace) => { }; /** Exercise Skills' default root discovery without ignored build outputs. */ -export async function verifyRepositorySkill({ root, workspace }) { +export async function verifyRepositorySkill({ root, workspace, commands }) { const snapshot = join(workspace, "repository"); const consumer = join(workspace, "skill-consumer"); const home = join(workspace, "skill-consumer-home"); const tracked = ( - await exec( + await commands.exec( "git", ["ls-files", "--cached", "--format=%(objectmode) %(path)", "-z"], { cwd: root }, @@ -54,10 +54,11 @@ export async function verifyRepositorySkill({ root, workspace }) { await mkdir(consumer, { recursive: true }); await mkdir(home, { recursive: true }); const { version: skillsVersion, entry } = await lockedSkillsCli( + commands, root, workspace, ); - await exec( + await commands.exec( process.execPath, [ entry, diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs index c546ec04d5..05b95b324f 100644 --- a/scripts/verify-windows-package.mjs +++ b/scripts/verify-windows-package.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { execFile } from "node:child_process"; import { access, mkdir, @@ -13,30 +12,35 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parseJsonc } from "jsonc-parser"; import { supportedClients } from "../dist/application/SupportedClients.js"; -import { promisify } from "node:util"; import { GHIDRA_BRIDGE_SOURCES } from "../src/ghidra/GhidraBridgeSources.ts"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { - execNpm, - verifyCompleteToolCatalog, -} from "./lib/verify-package-core.mjs"; +import { verifyCompleteToolCatalog } from "./lib/verify-package-core.mjs"; +import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs"; +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; import { verifyPackageUpdate } from "./verify-package-update.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; -const exec = promisify(execFile); const verifierRun = createVerifierRun(); const root = process.cwd(); const workspace = await mkdtemp(join(tmpdir(), "rea-windows-package-")); +const workspaceOwner = { + path: workspace, + close: () => rm(workspace, { recursive: true, force: true }), +}; const prefix = join(workspace, "prefix"); +let commands; +const closeWorkspace = () => + commands === undefined ? workspaceOwner.close() : commands.close(); let report; try { + commands = await createPackageVerifierCommands({ workspace: workspaceOwner }); const packed = JSON.parse( ( - await execNpm( + await commands.execNpm( ["pack", "--json", "--silent", "--pack-destination", workspace], { cwd: root, windowsHide: true }, ) @@ -69,7 +73,7 @@ try { const tarball = join(workspace, packageResult.filename); await access(tarball); - await execNpm( + await commands.execNpm( ["install", "--no-package-lock", "--no-save", "--prefix", prefix, tarball], { cwd: workspace, windowsHide: true }, ); @@ -84,7 +88,7 @@ try { ...process.env, REA_ANALYSIS_PROVIDER: "auto", }; - const help = await exec(process.execPath, [entry, "--help"], { + const help = await commands.exec(process.execPath, [entry, "--help"], { env: environment, windowsHide: true, }); @@ -103,12 +107,27 @@ try { }); const client = new Client({ name: "rea-windows-package", version: "1.0.0" }); let toolCount = 0; - try { + await withVerifierCleanup(async () => { await client.connect(server); toolCount = (await verifyCompleteToolCatalog(client)).length; - } finally { - await client.close(); - } + }, [ + async () => { + try { + await client.close(); + } catch (cause) { + commands.adopt(client, entry); + throw cause; + } + }, + async () => { + try { + await server.close(); + } catch (cause) { + commands.adopt(server, entry); + throw cause; + } + }, + ]); const home = join(workspace, "home"); const setupEnvironment = { @@ -148,7 +167,7 @@ try { ]; const setup = JSON.parse( ( - await exec(process.execPath, [entry, ...setupArgs], { + await commands.exec(process.execPath, [entry, ...setupArgs], { env: setupEnvironment, windowsHide: true, }) @@ -188,7 +207,7 @@ try { } const repeated = JSON.parse( ( - await exec(process.execPath, [entry, ...setupArgs], { + await commands.exec(process.execPath, [entry, ...setupArgs], { env: setupEnvironment, windowsHide: true, }) @@ -202,7 +221,7 @@ try { throw new Error("Packaged agent setup is not idempotent"); const uninstalled = JSON.parse( ( - await exec(process.execPath, [entry, "uninstall", "--json"], { + await commands.exec(process.execPath, [entry, "uninstall", "--json"], { env: setupEnvironment, windowsHide: true, }) @@ -215,11 +234,12 @@ try { throw new Error(`Packaged uninstall did not remove ${id}`); if (process.platform !== "win32") - await execNpm( + await commands.execNpm( ["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball], { cwd: workspace, windowsHide: true }, ); const update = await verifyPackageUpdate({ + commands, prefix, packageRoot: process.platform === "win32" @@ -241,9 +261,10 @@ try { uninstall: true, update, }; -} finally { - await rm(workspace, { recursive: true, force: true }); +} catch (cause) { + await withVerifierCleanup(() => Promise.reject(cause), [closeWorkspace]); } +await closeWorkspace(); process.stdout.write( `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`, ); diff --git a/src/apktool/ApktoolDecodeTimeout.test.ts b/src/apktool/ApktoolDecodeTimeout.test.ts index 3246d9d8d5..14c9439616 100644 --- a/src/apktool/ApktoolDecodeTimeout.test.ts +++ b/src/apktool/ApktoolDecodeTimeout.test.ts @@ -13,7 +13,10 @@ import { OwnedCommandFailure, runOwnedCommand, } from "../process/OwnedCommand.js"; -import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; +import type { + ProcessDiagnosticBudget, + ProviderProcessSnapshot, +} from "../process/ProviderProcess.js"; import { ApktoolProvider } from "./ApktoolProvider.js"; vi.mock("../process/OwnedCommand.js", async (importOriginal) => ({ @@ -37,8 +40,8 @@ const MANIFEST = [ "", ].join("\n"); -const decodeTimeouts: number[] = []; -const outputBudgets: number[] = []; +const decodeTimeouts: (number | null)[] = []; +const outputBudgets: ProcessDiagnosticBudget[] = []; afterEach(() => { decodeTimeouts.length = 0; diff --git a/src/native/NativeCommandDeadline.test.ts b/src/native/NativeCommandDeadline.test.ts index 9fdc616d39..421f47e350 100644 --- a/src/native/NativeCommandDeadline.test.ts +++ b/src/native/NativeCommandDeadline.test.ts @@ -72,7 +72,7 @@ describe("native_command_timeout_ms", () => { const calls: { command: string; arguments: readonly string[]; - timeoutMs: number; + timeoutMs: number | null; }[] = []; vi.mocked(runOwnedCommand).mockImplementation(async (spawn, limits) => { calls.push({ @@ -99,7 +99,7 @@ describe("native_command_timeout_ms", () => { }); it("uses 60000 ms for xcrun resolution when the caller omits the deadline", async () => { - const timeouts: number[] = []; + const timeouts: (number | null)[] = []; vi.mocked(runOwnedCommand).mockImplementation(async (_spawn, limits) => { timeouts.push(limits.timeoutMs); throw new OwnedCommandFailure("timeout", "Command deadline elapsed."); diff --git a/src/process/OwnedCommand.ts b/src/process/OwnedCommand.ts index 33b1695892..bb6a7219ea 100644 --- a/src/process/OwnedCommand.ts +++ b/src/process/OwnedCommand.ts @@ -9,6 +9,7 @@ import { pipeline } from "node:stream/promises"; import type { OwnedProviderProcessSpawnOptions, ProviderProcessSnapshot, + ProcessDiagnosticBudget, ProviderProcessStopResult, SpawnedOwnedProviderProcess, } from "./ProviderProcess.js"; @@ -82,8 +83,8 @@ export const ownedCommandResources = ( /** Output handling for one owned command. */ interface OwnedCommandLimits { - /** Retained stderr bytes, plus stdout bytes unless a sink consumes stdout. */ - readonly diagnosticBytes: number; + /** Retained output budget; stdout is excluded when a sink consumes it. */ + readonly diagnosticBytes: ProcessDiagnosticBudget; /** Accept an ordinary nonzero exit after output closes without other process errors. */ readonly acceptNonZeroExit?: boolean; /** Interpret complete physical output synchronously before awaited final cleanup. */ @@ -158,7 +159,10 @@ function throwCommandPreparationFailure( /** Execute a single owned process with independent cleanup and bounded diagnostic retention. */ export const runOwnedCommand = async ( spawn: OwnedProviderProcessSpawnOptions, - limits: OwnedCommandLimits & { readonly timeoutMs: number }, + limits: OwnedCommandLimits & { + /** A positive absolute deadline, or null to keep only caller cancellation. */ + readonly timeoutMs: number | null; + }, options: { readonly signal?: AbortSignal | undefined; /** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */ @@ -180,18 +184,27 @@ export const runOwnedCommand = async ( null, { cancellation: { reason: callerSignal.reason } }, ); - const deadline = new ProviderStartupDeadline(limits.timeoutMs, callerSignal); + const deadline = + limits.timeoutMs === null + ? undefined + : new ProviderStartupDeadline(limits.timeoutMs, callerSignal); + const control = deadline ?? { + signal: callerSignal ?? new AbortController().signal, + get interruption(): "cancelled" | undefined { + return callerSignal?.aborted === true ? "cancelled" : undefined; + }, + }; try { return await collectOwnedCommand( spawn, limits, options.launcher ?? spawnOwnedProviderProcess, - deadline, + control, callerSignal, options.retainCleanup, ); } finally { - deadline.dispose(); + deadline?.dispose(); } }; @@ -201,7 +214,7 @@ const collectOwnedCommand = async ( launcher: ( input: OwnedProviderProcessSpawnOptions, ) => Promise, - deadline: ProviderStartupDeadline, + deadline: Pick, callerSignal?: AbortSignal, retainCleanup?: ( owner: ProviderProcessSupervisor, diff --git a/src/process/OwnedCommandPreparation.test.ts b/src/process/OwnedCommandPreparation.test.ts index 9c1006b73a..e81c12c302 100644 --- a/src/process/OwnedCommandPreparation.test.ts +++ b/src/process/OwnedCommandPreparation.test.ts @@ -184,3 +184,79 @@ it("bounds simulated Linux preparation by the command deadline before launch", a await expected; expect(enteredLaunch).toBe(false); }); + +it.each([false, true])( + "keeps no-deadline preparation pending without a timer (caller signal: %s)", + async (withCallerSignal) => { + vi.useFakeTimers(); + const controller = new AbortController(); + const prepared = createDeferred(); + const failure = new Error("authored preparation failure"); + let preparationSignal: AbortSignal | undefined; + const pending = runOwnedCommand( + { + command: "never-launched", + arguments: [], + runId: "authored-no-deadline-prepare", + }, + { timeoutMs: null, diagnosticBytes: 1024 }, + { + ...(withCallerSignal ? { signal: controller.signal } : {}), + launcher: async (input) => { + preparationSignal = input.signal; + await prepared.promise; + throw failure; + }, + }, + ); + const expected = expect(pending).rejects.toBe(failure); + await vi.advanceTimersByTimeAsync(10_000); + expect(preparationSignal?.aborted).toBe(false); + if (withCallerSignal) expect(preparationSignal).toBe(controller.signal); + expect(vi.getTimerCount()).toBe(0); + prepared.resolve(); + await expected; + }, +); + +it("cancels no-deadline preparation with the caller's exact opaque reason", async () => { + const controller = new AbortController(); + const reason = new DarwinProcessOwnershipInspectionError( + "opaque caller reason", + ); + const started = createDeferred(); + const pending = runOwnedCommand( + { + command: "never-launched", + arguments: [], + runId: "authored-no-deadline-cancel", + }, + { timeoutMs: null, diagnosticBytes: 1024 }, + { + signal: controller.signal, + launcher: async (input) => { + expect(input.signal).toBe(controller.signal); + started.resolve(); + await new Promise((_resolve, reject) => + input.signal?.addEventListener( + "abort", + () => reject(input.signal?.reason), + { + once: true, + }, + ), + ); + throw new Error("Cancelled preparation must not launch"); + }, + }, + ); + const expected = expect(pending).rejects.toMatchObject({ + reason: "cancelled", + cancellation: { reason }, + preparationFailure: undefined, + snapshot: null, + }); + await started.promise; + controller.abort(reason); + await expected; +}); diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 1710e6dc91..390be712e5 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -193,12 +193,17 @@ export type ProviderProcessDiagnostic = readonly cause?: Error; }; +/** Aggregate byte budget, or independent retained stdout and stderr byte budgets. */ +export type ProcessDiagnosticBudget = + | number + | { readonly stdout: number; readonly stderr: number }; + /** Options for process lifecycle diagnostics. */ export interface ProviderProcessSupervisorOptions { /** Retain stdout for diagnostics; disable when it carries a parsed protocol. Defaults to true. */ readonly captureStdout?: boolean; - /** Aggregate retained stdout/stderr byte budget; overflow is drained and reported, not retained. */ - readonly maxDiagnosticBytes?: number; + /** Retained output budget; overflow is drained and reported, not retained. */ + readonly maxDiagnosticBytes?: ProcessDiagnosticBudget; /** * Single-line token ending at a non-ASCII-word byte or EOF, located before retention * applies, so its latest occurrence survives an exhausted budget. @@ -395,7 +400,10 @@ export class ProviderProcessSupervisor { #stopPromise: Promise | undefined; #stopRequested = false; #disposed = false; - #maxDiagnosticBytes: number | undefined; + #maxAggregateDiagnosticBytes: number | undefined; + readonly #maxStreamDiagnosticBytes: + | Exclude + | undefined; #outputEpoch = 0; constructor( @@ -403,7 +411,13 @@ export class ProviderProcessSupervisor { options: ProviderProcessSupervisorOptions = {}, ) { this.#options = options; - this.#maxDiagnosticBytes = options.maxDiagnosticBytes; + const budget = options.maxDiagnosticBytes; + if (typeof budget === "number") this.#maxAggregateDiagnosticBytes = budget; + else if (budget !== undefined) + this.#maxStreamDiagnosticBytes = { + stdout: budget.stdout, + stderr: budget.stderr, + }; this.#resetMarkers(); if (options.captureStdout !== false) this.#attach(launch.process.stdout, "stdout"); @@ -593,10 +607,10 @@ export class ProviderProcessSupervisor { Number.MAX_SAFE_INTEGER, retained + maxBytes, ); - this.#maxDiagnosticBytes = - this.#maxDiagnosticBytes === undefined + this.#maxAggregateDiagnosticBytes = + this.#maxAggregateDiagnosticBytes === undefined ? requestedLimit - : Math.min(this.#maxDiagnosticBytes, requestedLimit); + : Math.min(this.#maxAggregateDiagnosticBytes, requestedLimit); } /** Unreference inherited output pipes without closing streams shared with descendants. */ @@ -797,14 +811,25 @@ export class ProviderProcessSupervisor { this.#stderrMarker?.scan(bytes, this.#stderrObservedBytes); this.#stderrObservedBytes += bytes.byteLength; } - const remaining = - this.#maxDiagnosticBytes === undefined + const aggregateRemaining = + this.#maxAggregateDiagnosticBytes === undefined ? bytes.byteLength : Math.max( 0, - this.#maxDiagnosticBytes - this.#stdout.bytes - this.#stderr.bytes, + this.#maxAggregateDiagnosticBytes - + this.#stdout.bytes - + this.#stderr.bytes, ); - const retained = Math.min(bytes.byteLength, remaining); + const streamLimit = this.#maxStreamDiagnosticBytes?.[stream]; + const streamRemaining = + streamLimit === undefined + ? bytes.byteLength + : Math.max(0, streamLimit - capture.bytes); + const retained = Math.min( + bytes.byteLength, + aggregateRemaining, + streamRemaining, + ); if (retained < bytes.byteLength) this.#diagnosticTruncated = true; if (retained > 0) capture.append(bytes.subarray(0, retained)); this.#options.onDiagnostic?.({ diff --git a/src/process/ProviderProcessOutput.test.ts b/src/process/ProviderProcessOutput.test.ts index 9a6915f54d..b8a8235c7d 100644 --- a/src/process/ProviderProcessOutput.test.ts +++ b/src/process/ProviderProcessOutput.test.ts @@ -1,7 +1,10 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { expect, it, vi } from "vitest"; -import { ProviderProcessSupervisor } from "./ProviderProcess.js"; +import { + ProviderProcessSupervisor, + type ProcessDiagnosticBudget, +} from "./ProviderProcess.js"; import { redactCapturedTransportCredential } from "./ProviderDiagnosticRedaction.js"; it("retains final stderr between verified exit and output close during stop", async () => { @@ -332,6 +335,169 @@ it("scopes output checkpoints to raw bytes on one supervisor and epoch", () => { } }); +it.each<{ budget: ProcessDiagnosticBudget; stderr: string }>([ + { budget: 4, stderr: "" }, + { budget: { stdout: 4, stderr: 4 }, stderr: "wxyz" }, +])( + "distinguishes aggregate and per-stream output budgets: $budget", + ({ budget, stderr }) => { + const producer = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: () => false, + }); + const supervisor = new ProviderProcessSupervisor( + { process: producer, ownsProcessLifetime: false }, + { maxDiagnosticBytes: budget }, + ); + try { + const checkpoint = supervisor.checkpointOutput(); + producer.stdout.write("abcd"); + producer.stderr.write("wxyz"); + expect(supervisor.snapshot().stdout).toEqual({ + text: "abcd", + bytes: 4, + observedBytes: 4, + }); + expect(supervisor.snapshot().stderr).toEqual({ + text: stderr, + bytes: stderr.length, + observedBytes: 4, + }); + expect(supervisor.snapshot().diagnosticTruncated === true).toBe( + stderr === "", + ); + expect(supervisor.snapshotOutputSince(checkpoint).complete).toBe( + stderr !== "", + ); + } finally { + supervisor.dispose(); + producer.stdout.destroy(); + producer.stderr.destroy(); + } + }, +); + +it.each(["stdout", "stderr"] as const)( + "drains and reports overflowing %s without consuming the other stream's budget", + (stream) => { + const producer = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: () => false, + }); + const events: unknown[] = []; + const supervisor = new ProviderProcessSupervisor( + { process: producer, ownsProcessLifetime: false }, + { + maxDiagnosticBytes: { stdout: 4, stderr: 4 }, + onDiagnostic: (event) => { + if (event.type === "output") events.push(event); + }, + }, + ); + const other = stream === "stdout" ? "stderr" : "stdout"; + try { + producer[stream].write("abcde"); + producer[stream].write("fg"); + producer[other].write("wxyz"); + expect(supervisor.snapshot()).toMatchObject({ + diagnosticTruncated: true, + [stream]: { text: "abcd", bytes: 4, observedBytes: 7 }, + [other]: { text: "wxyz", bytes: 4, observedBytes: 4 }, + }); + expect(events).toEqual([ + { type: "output", stream, bytes: 5, totalBytes: 5, truncated: true }, + { type: "output", stream, bytes: 2, totalBytes: 7, truncated: true }, + { type: "output", stream: other, bytes: 4, totalBytes: 4 }, + ]); + } finally { + supervisor.dispose(); + producer.stdout.destroy(); + producer.stderr.destroy(); + } + }, +); + +it("tightens aggregate retention without replacing or relaxing the original stream caps", () => { + const producer = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: () => false, + }); + const supervisor = new ProviderProcessSupervisor( + { process: producer, ownsProcessLifetime: false }, + { maxDiagnosticBytes: { stdout: 4, stderr: 10 } }, + ); + try { + producer.stdout.write("abc"); + producer.stderr.write("de"); + supervisor.limitAdditionalOutputBytes(4); + producer.stdout.write("fgh"); + producer.stderr.write("ijklmn"); + expect(supervisor.snapshot()).toMatchObject({ + diagnosticTruncated: true, + stdout: { text: "abcf", bytes: 4, observedBytes: 6 }, + stderr: { text: "deijk", bytes: 5, observedBytes: 8 }, + }); + supervisor.limitAdditionalOutputBytes(100); + producer.stderr.write("op"); + expect(supervisor.snapshot().stderr).toEqual({ + text: "deijk", + bytes: 5, + observedBytes: 10, + }); + } finally { + supervisor.dispose(); + producer.stdout.destroy(); + producer.stderr.destroy(); + } +}); + +it.each(["stdout", "stderr"] as const)( + "retains raw UTF-8 and checkpoint facts while scanning %s markers past its stream cap", + (stream) => { + const producer = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: () => false, + }); + const supervisor = new ProviderProcessSupervisor( + { process: producer, ownsProcessLifetime: false }, + { maxDiagnosticBytes: { stdout: 4, stderr: 4 }, outputToken: "DONE" }, + ); + try { + const checkpoint = supervisor.checkpointOutput(); + const bytes = Buffer.from("é🧭 DONE\n"); + producer[stream].write(bytes.subarray(0, 3)); + producer[stream].write(bytes.subarray(3)); + const scoped = supervisor.snapshotOutputSince(checkpoint); + expect(scoped.complete).toBe(false); + expect(scoped.snapshot[stream]).toEqual({ + text: "é\ufffd", + bytes: 4, + observedBytes: 12, + invalidUtf8: true, + }); + expect(scoped.markers).toEqual([ + { stream, offset: 7, line: "é🧭 DONE", truncated: false }, + ]); + } finally { + supervisor.dispose(); + producer.stdout.destroy(); + producer.stderr.destroy(); + } + }, +); + it.each(["stdout", "stderr"] as const)( "redacts a budget-cut credential in scoped %s after prior output", (stream) => { diff --git a/tests/boundary/filesystem/ghidraBridgeSources.test.ts b/tests/boundary/filesystem/ghidraBridgeSources.test.ts index e0c4571e61..45aa07136d 100644 --- a/tests/boundary/filesystem/ghidraBridgeSources.test.ts +++ b/tests/boundary/filesystem/ghidraBridgeSources.test.ts @@ -210,7 +210,15 @@ it("rejects an omitted auxiliary source at the actual package verification bound "--input-type=module", "-e", `import { verifyPackedBridge } from "./scripts/verify-packed-bridge.mjs"; - await verifyPackedBridge({ tarball: "unused-because-admission-must-fail", packedFiles: JSON.parse(process.argv[1]) });`, + import { createPackageVerifierCommands } from "./scripts/lib/verify-package-commands.mjs"; + import { withVerifierCleanup } from "./scripts/lib/verifier-cleanup.mjs"; + import { PrivateRuntimeRoot } from "./dist/process/PrivateRuntimeRoot.js"; + const root = await PrivateRuntimeRoot.create({ prefix: "rea-bridge-admission-" }); + let commands; + await withVerifierCleanup(async () => { + commands = await createPackageVerifierCommands({ workspace: root }); + await verifyPackedBridge({ tarball: "unused-because-admission-must-fail", packedFiles: JSON.parse(process.argv[1]), commands }); + }, [() => commands === undefined ? root.close() : commands.close()]);`, JSON.stringify(packedFiles), ]), ).rejects.toMatchObject({ diff --git a/tests/boundary/process/ownedCommand.test.ts b/tests/boundary/process/ownedCommand.test.ts index a67e744aee..f71204233b 100644 --- a/tests/boundary/process/ownedCommand.test.ts +++ b/tests/boundary/process/ownedCommand.test.ts @@ -95,17 +95,20 @@ it("bounds retained diagnostics across both streams during a real output burst", } }); -it("collects a real owned command and verifies its exit before returning diagnostics", async () => { - const result = await runOwnedCommand( - command('process.stdout.write("observed-output")'), - { timeoutMs: 2_000, diagnosticBytes: 1024 }, - ); - expect(result).toMatchObject({ - exitCode: 0, - signal: null, - stdout: { text: "observed-output", bytes: 15 }, - }); -}); +it.each([2_000, null])( + "collects a real owned command and verifies its exit before returning diagnostics (timeout %s)", + async (timeoutMs) => { + const result = await runOwnedCommand( + command('process.stdout.write("observed-output")'), + { timeoutMs, diagnosticBytes: 1024 }, + ); + expect(result).toMatchObject({ + exitCode: 0, + signal: null, + stdout: { text: "observed-output", bytes: 15 }, + }); + }, +); it.each([0, 2])( "observes complete accepted output once before final cleanup (exit %s)", @@ -155,50 +158,56 @@ it("does not interpret output rejected by its complete-output budget", async () expect(observeCompleted).not.toHaveBeenCalled(); }); -it("retains late cancellation separately from a physical failure with refused cleanup", async () => { - const controller = new AbortController(); - const callerReason = { request: "cancel during refused finalization" }; - let denied = true; - let failure: OwnedCommandFailure | undefined; - try { - await runOwnedCommand( - command("process.exit(2)"), - { timeoutMs: 2000, diagnosticBytes: 1024 }, - { - signal: controller.signal, - launcher: async (input) => { - const acquired = await spawnOwnedProviderProcess(input); - return { - ...acquired, - cleanup: async () => { - if (denied) { - controller.abort(callerReason); - return { cleaned: false, reason: "controlled cleanup refusal" }; - } - return cleanupOwnedProcessGroup(acquired.ownership); - }, - }; +it.each([2000, null])( + "retains late cancellation separately from a physical failure with refused cleanup (timeout %s)", + async (timeoutMs) => { + const controller = new AbortController(); + const callerReason = { request: "cancel during refused finalization" }; + let denied = true; + let failure: OwnedCommandFailure | undefined; + try { + await runOwnedCommand( + command("process.exit(2)"), + { timeoutMs, diagnosticBytes: 1024 }, + { + signal: controller.signal, + launcher: async (input) => { + const acquired = await spawnOwnedProviderProcess(input); + return { + ...acquired, + cleanup: async () => { + if (denied) { + controller.abort(callerReason); + return { + cleaned: false, + reason: "controlled cleanup refusal", + }; + } + return cleanupOwnedProcessGroup(acquired.ownership); + }, + }; + }, }, - }, - ).catch((cause: unknown) => { - if (!(cause instanceof OwnedCommandFailure)) throw cause; - failure = cause; - }); - expect(failure).toMatchObject({ - reason: "process", - snapshot: { exitCode: 2 }, - cleanupFailure: "controlled cleanup refusal", - }); - expect(failure?.cancellation?.reason).toBe(callerReason); - denied = false; - expect(await failure?.cleanupOwner?.stop()).toEqual({ - status: "verified-cleanup", - }); - } finally { - denied = false; - await failure?.cleanupOwner?.stop(); - } -}); + ).catch((cause: unknown) => { + if (!(cause instanceof OwnedCommandFailure)) throw cause; + failure = cause; + }); + expect(failure).toMatchObject({ + reason: "process", + snapshot: { exitCode: 2 }, + cleanupFailure: "controlled cleanup refusal", + }); + expect(failure?.cancellation?.reason).toBe(callerReason); + denied = false; + expect(await failure?.cleanupOwner?.stop()).toEqual({ + status: "verified-cleanup", + }); + } finally { + denied = false; + await failure?.cleanupOwner?.stop(); + } + }, +); it("keeps an exit failure observed before cancellation primary across delayed classification", async () => { const controller = new AbortController(); @@ -576,38 +585,41 @@ it.skipIf(process.platform === "win32")( }, ); -it("cancels a real acquired process and independently releases it", async () => { - const controller = new AbortController(); - const callerReason = { - request: "active cancellation", - cleanup: "caller metadata", - }; - let pid: number | undefined; - const result = runOwnedCommand( - command( - 'process.stdout.write("ready\\n"); setInterval(() => undefined, 1000)', - ), - { timeoutMs: 5_000, diagnosticBytes: 1024 }, - { - signal: controller.signal, - launcher: async (input) => { - const process = await spawnOwnedProviderProcess(input); - pid = process.process.pid; - await waitForProviderProcessReady(process.process); - setImmediate(() => controller.abort(callerReason)); - return process; +it.each([5_000, null])( + "cancels a real acquired process and independently releases it (timeout %s)", + async (timeoutMs) => { + const controller = new AbortController(); + const callerReason = { + request: "active cancellation", + cleanup: "caller metadata", + }; + let pid: number | undefined; + const result = runOwnedCommand( + command( + 'process.stdout.write("ready\\n"); setInterval(() => undefined, 1000)', + ), + { timeoutMs, diagnosticBytes: 1024 }, + { + signal: controller.signal, + launcher: async (input) => { + const process = await spawnOwnedProviderProcess(input); + pid = process.process.pid; + await waitForProviderProcessReady(process.process); + setImmediate(() => controller.abort(callerReason)); + return process; + }, }, - }, - ); - await expect(result).rejects.toMatchObject({ - reason: "cancelled", - cleanupFailure: null, - cancellation: { reason: callerReason }, - }); - if (pid === undefined) throw new Error("Process was not acquired"); - const acquiredPid = pid; - expect(() => process.kill(acquiredPid, 0)).toThrow(); -}); + ); + await expect(result).rejects.toMatchObject({ + reason: "cancelled", + cleanupFailure: null, + cancellation: { reason: callerReason }, + }); + if (pid === undefined) throw new Error("Process was not acquired"); + const acquiredPid = pid; + expect(() => process.kill(acquiredPid, 0)).toThrow(); + }, +); it("returns a timeout for a running process after independent cleanup", async () => { await expect( diff --git a/tests/boundary/process/packageNpmInvocation.test.ts b/tests/boundary/process/packageNpmInvocation.test.ts index a3afae696f..f5150975e5 100644 --- a/tests/boundary/process/packageNpmInvocation.test.ts +++ b/tests/boundary/process/packageNpmInvocation.test.ts @@ -1,49 +1,61 @@ -import { execFile } from "node:child_process"; import { copyFile, mkdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { promisify } from "node:util"; -import { expect, it } from "vitest"; +import { expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; -const execute = promisify(execFile); - -it("runs the caller-selected npm entry through Node without rewriting its inputs", async () => { - const root = await createTestTempDirectory("rea-npm-invocation-"); - const npmEntry = join(root, "selected npm entry.mjs"); - await writeFile( - npmEntry, - `process.stdout.write(JSON.stringify({ arguments: process.argv.slice(2), cwd: process.cwd(), marker: process.env.REA_NPM_TEST_MARKER }));\n`, - ); - const helper = pathToFileURL( - resolve("scripts/lib/verify-package-core.mjs"), - ).href; - const program = ` - const { execNpm } = await import(${JSON.stringify(helper)}); - const result = await execNpm(["pack", "value with spaces"], { - cwd: ${JSON.stringify(root)}, - env: { ...process.env, npm_execpath: ${JSON.stringify(npmEntry)}, REA_NPM_TEST_MARKER: "caller-selected" }, +processTest( + "runs the caller-selected npm entry through Node without rewriting its inputs", + async ({ processes }) => { + const root = await createTestTempDirectory("rea-npm-invocation-"); + const npmEntry = join(root, "selected npm entry.mjs"); + await writeFile( + npmEntry, + `process.stdout.write(JSON.stringify({ arguments: process.argv.slice(2), cwd: process.cwd(), marker: process.env.REA_NPM_TEST_MARKER }));\n`, + ); + const helper = pathToFileURL( + resolve("scripts/lib/verify-package-commands.mjs"), + ).href; + const cleanup = pathToFileURL( + resolve("scripts/lib/verifier-cleanup.mjs"), + ).href; + const program = ` + const { createPackageVerifierCommands } = await import(${JSON.stringify(helper)}); + const { withVerifierCleanup } = await import(${JSON.stringify(cleanup)}); + const commands = await createPackageVerifierCommands({ + workspace: { path: ${JSON.stringify(root)}, close: async () => {} }, }); + const result = await withVerifierCleanup( + () => commands.execNpm(["pack", "value with spaces"], { + cwd: ${JSON.stringify(root)}, + env: { ...process.env, npm_execpath: ${JSON.stringify(npmEntry)}, REA_NPM_TEST_MARKER: "caller-selected" }, + }), + [() => commands.close()], + ); process.stdout.write(result.stdout); `; - const result = await execute( - process.execPath, - ["--input-type=module", "--eval", program], - { windowsHide: true }, - ); - expect(JSON.parse(result.stdout)).toEqual({ - arguments: ["pack", "value with spaces"], - cwd: root, - marker: "caller-selected", - }); -}); + const result = await processes.run( + process.execPath, + ["--input-type=module", "--eval", program], + { windowsHide: true, timeoutMs: 20_000 }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.signal, result.stderr).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual({ + arguments: ["pack", "value with spaces"], + cwd: root, + marker: "caller-selected", + }); + }, +); -const windowsIt = process.platform === "win32" ? it : it.skip; +const windowsIt = processTest.skipIf(process.platform !== "win32"); windowsIt( "uses the npm entry adjacent to Node when none is selected", - async () => { + async ({ processes }) => { const root = await createTestTempDirectory("rea-adjacent-npm-"); const node = join(root, "node.exe"); const npmEntry = join(root, "node_modules/npm/bin/npm-cli.js"); @@ -56,25 +68,37 @@ windowsIt( ), ]); const helper = pathToFileURL( - resolve("scripts/lib/verify-package-core.mjs"), + resolve("scripts/lib/verify-package-commands.mjs"), + ).href; + const cleanup = pathToFileURL( + resolve("scripts/lib/verifier-cleanup.mjs"), ).href; const program = ` - const { execNpm } = await import(${JSON.stringify(helper)}); + const { createPackageVerifierCommands } = await import(${JSON.stringify(helper)}); + const { withVerifierCleanup } = await import(${JSON.stringify(cleanup)}); + const commands = await createPackageVerifierCommands({ + workspace: { path: ${JSON.stringify(root)}, close: async () => {} }, + }); const environment = { ...process.env }; delete environment.npm_execpath; - const result = await execNpm(["pack", "value with spaces"], { - cwd: ${JSON.stringify(root)}, - env: environment, - }); + const result = await withVerifierCleanup( + () => commands.execNpm(["pack", "value with spaces"], { + cwd: ${JSON.stringify(root)}, + env: environment, + }), + [() => commands.close()], + ); process.stdout.write(result.stdout); `; const environment = { ...process.env }; delete environment.npm_execpath; - const result = await execute( + const result = await processes.run( node, ["--input-type=module", "--eval", program], - { env: environment, windowsHide: true }, + { env: environment, windowsHide: true, timeoutMs: 20_000 }, ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.signal, result.stderr).toBeNull(); expect(JSON.parse(result.stdout)).toEqual({ arguments: ["pack", "value with spaces"], cwd: root, diff --git a/tests/boundary/process/packagePackIsolation.test.ts b/tests/boundary/process/packagePackIsolation.test.ts index 0ad8a900ce..8d6875ae8a 100644 --- a/tests/boundary/process/packagePackIsolation.test.ts +++ b/tests/boundary/process/packagePackIsolation.test.ts @@ -38,19 +38,30 @@ it("keeps npm pack output in the owned workspace when package validation fails", const packerUrl = pathToFileURL( resolve("scripts/verify-package-pack.mjs"), ).href; + const commandsUrl = pathToFileURL( + resolve("scripts/lib/verify-package-commands.mjs"), + ).href; + const cleanupUrl = pathToFileURL( + resolve("scripts/lib/verifier-cleanup.mjs"), + ).href; const harness = ` import { rm } from "node:fs/promises"; +import { createPackageVerifierCommands } from ${JSON.stringify(commandsUrl)}; +import { withVerifierCleanup } from ${JSON.stringify(cleanupUrl)}; const workspace = ${JSON.stringify(workspace)}; +const scratch = { path: workspace, close: () => rm(workspace, { recursive: true, force: true }) }; +let commands; let failure; +await withVerifierCleanup(async () => { +commands = await createPackageVerifierCommands({ workspace: scratch }); try { const { verifyPackagePack } = await import(${JSON.stringify(packerUrl)}); - await verifyPackagePack({ root: ${JSON.stringify(packageRoot)}, workspace }); + await verifyPackagePack({ root: ${JSON.stringify(packageRoot)}, workspace, commands }); } catch (cause) { if (!(cause instanceof Error) || cause.message !== ${JSON.stringify(expectedFailure)}) throw cause; failure = cause.message; -} finally { - await rm(workspace, { recursive: true, force: true }); } +}, [() => commands === undefined ? scratch.close() : commands.close()]); if (failure !== ${JSON.stringify(expectedFailure)}) throw new Error("Expected package metadata validation to fail after packing"); process.stdout.write(JSON.stringify({ failure })); `; diff --git a/tests/boundary/process/packageTarListing.test.ts b/tests/boundary/process/packageTarListing.test.ts index 02a84327c7..c2998e7d7e 100644 --- a/tests/boundary/process/packageTarListing.test.ts +++ b/tests/boundary/process/packageTarListing.test.ts @@ -33,13 +33,9 @@ async function verifyListing(listing: string) { const { stdout } = await execFileAsync(process.execPath, [ "--input-type=module", "--eval", - `import childProcess from "node:child_process"; -import { syncBuiltinESMExports } from "node:module"; -import { promisify } from "node:util"; -import { FULL_CATALOG_IDENTITY } from ${JSON.stringify(catalogUrl)}; + `import { FULL_CATALOG_IDENTITY } from ${JSON.stringify(catalogUrl)}; let bridgeParsed = false; -childProcess.execFile = () => { throw new Error("Expected promisified execution"); }; -childProcess.execFile[promisify.custom] = async (command, args) => { +const exec = async (command, args) => { let stdout; if ((command === "npm" && args[0] === "pack") || (command === process.execPath && args[1] === "pack")) stdout = "fixture.tgz\\n"; else if (command === "tar" && args[0] === "-tf") stdout = ${JSON.stringify(listing)}; @@ -52,10 +48,10 @@ childProcess.execFile[promisify.custom] = async (command, args) => { else throw new Error("Unexpected command: " + command + " " + JSON.stringify(args)); return { stdout, stderr: "" }; }; -syncBuiltinESMExports(); +const commands = { exec, execNpm: (args) => exec("npm", args) }; const { verifyPackagePack } = await import(${JSON.stringify(packerUrl)}); try { - const result = await verifyPackagePack({ root: process.cwd(), workspace: ${JSON.stringify(workspace)} }); + const result = await verifyPackagePack({ root: process.cwd(), workspace: ${JSON.stringify(workspace)}, commands }); process.stdout.write(JSON.stringify({ ...result, bridgeParsed })); } catch (cause) { process.stdout.write(JSON.stringify({ error: cause.message, bridgeParsed })); diff --git a/tests/boundary/process/packageUpdateRegistryFailure.test.ts b/tests/boundary/process/packageUpdateRegistryFailure.test.ts index 0f6e5afc4d..aef11c0641 100644 --- a/tests/boundary/process/packageUpdateRegistryFailure.test.ts +++ b/tests/boundary/process/packageUpdateRegistryFailure.test.ts @@ -72,7 +72,16 @@ it("retains upstream body failure and joins the active updater and installer chi const verifierUrl = pathToFileURL( resolve("scripts/verify-package-update.mjs"), ).href; + const commandsUrl = pathToFileURL( + resolve("scripts/lib/verify-package-commands.mjs"), + ).href; + const cleanupUrl = pathToFileURL( + resolve("scripts/lib/verifier-cleanup.mjs"), + ).href; const harness = ` +import { readFile, rm } from "node:fs/promises"; +import { createPackageVerifierCommands } from ${JSON.stringify(commandsUrl)}; +import { withVerifierCleanup } from ${JSON.stringify(cleanupUrl)}; globalThis.fetch = async () => ({ status: 200, headers: new Headers({ "content-type": "application/json" }), @@ -81,7 +90,12 @@ globalThis.fetch = async () => ({ }, }); const { verifyPackageUpdate } = await import(${JSON.stringify(verifierUrl)}); +const scratch = { path: ${JSON.stringify(workspace)}, close: () => rm(${JSON.stringify(workspace)}, { recursive: true, force: true }) }; +let commands; let failure; +let pids; +await withVerifierCleanup(async () => { +commands = await createPackageVerifierCommands({ workspace: scratch }); try { await verifyPackageUpdate({ prefix: ${JSON.stringify(prefix)}, @@ -89,12 +103,13 @@ try { tarball: ${JSON.stringify(tarball)}, workspace: ${JSON.stringify(workspace)}, environment: { PATH: process.env.PATH }, + commands, }); } catch (cause) { failure = cause instanceof Error ? cause.message : String(cause); } -const { readFile } = await import("node:fs/promises"); -const pids = JSON.parse(await readFile(${JSON.stringify(descendants)}, "utf8")); +pids = JSON.parse(await readFile(${JSON.stringify(descendants)}, "utf8")); +}, [() => commands === undefined ? scratch.close() : commands.close()]); let livePids; if (process.platform === "win32") { livePids = pids.filter((pid) => { diff --git a/tests/boundary/process/packageVerifierCommandOwnership.test.ts b/tests/boundary/process/packageVerifierCommandOwnership.test.ts new file mode 100644 index 0000000000..533d400e86 --- /dev/null +++ b/tests/boundary/process/packageVerifierCommandOwnership.test.ts @@ -0,0 +1,230 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { liveProcesses } from "../../../src/process/ProcessOwnershipProcessTree.js"; +import { systemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; + +const descendantSource = ` +import { appendFileSync, existsSync, writeFileSync } from "node:fs"; +const record = (entry) => appendFileSync(process.env.OWNER_EVENTS, JSON.stringify(entry) + "\\n"); +const finish = (reason) => { + record({ kind: "descendant-exit", reason, rootExists: existsSync(process.env.OWNER_ROOT) }); + process.exit(0); +}; +process.on("SIGTERM", () => finish("SIGTERM")); +writeFileSync(process.env.OWNER_DESCENDANT, String(process.pid)); +process.send({ pid: process.pid }); +setInterval(() => { + if (existsSync(process.env.OWNER_RELEASE)) finish("fixture-release"); +}, 25); +setTimeout(() => finish("fixture-deadline"), 20000); +`; + +const npmSource = ` +import { spawn } from "node:child_process"; +import { appendFileSync, existsSync, writeFileSync } from "node:fs"; +process.once("SIGTERM", () => { + appendFileSync(process.env.OWNER_EVENTS, JSON.stringify({ kind: "leader-exit", rootExists: existsSync(process.env.OWNER_ROOT) }) + "\\n"); + process.kill(process.pid, "SIGTERM"); +}); +const child = spawn(process.execPath, [process.env.OWNER_DESCENDANT_ENTRY], { + stdio: ["ignore", "ignore", "ignore", "ipc"], +}); +const ready = await new Promise((resolve, reject) => { + child.once("message", resolve); + child.once("error", reject); +}); +writeFileSync(process.env.OWNER_READY, JSON.stringify({ pids: [process.pid, ready.pid] })); +process.stdout.write("npm descendant ready\\n"); +process.stderr.write("selected npm diagnostics\\n"); +setInterval(() => {}, 1000); +`; + +const reportSchema = z.object({ + pids: z.array(z.number().int().positive()).length(2), + liveAtRemoval: z.array(z.number().int().positive()), + rootRemoved: z.boolean(), + failure: z.object({ + reason: z.literal("timeout"), + stdout: z.string(), + stderr: z.string(), + }), +}); + +const stopReceiptSchema = z.object({ + kind: z.enum(["leader-exit", "descendant-exit"]), + rootExists: z.boolean(), + reason: z.string().optional(), +}); + +const releaseDescendant = async (release: string, receipt: string) => { + await writeFile(release, "release this authored descendant"); + const pid = await readFile(receipt, "utf8").then(Number, (cause: unknown) => { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return undefined; + throw cause; + }); + if (pid === undefined) return; + const deadline = Date.now() + 7_500; + while ( + liveProcesses(await systemProcessOwnershipHost.listProcesses()).some( + (entry) => entry.pid === pid, + ) + ) { + if (Date.now() >= deadline) + throw new Error( + "Authored npm descendant did not consume its release marker", + ); + await delay(25); + } +}; + +// Actual npm-entry/descendant processes and actual scratch removal; no package +// install, native engine, or WABT behavior is simulated or claimed. +processTest.skipIf(process.platform === "win32")( + "joins a timed-out npm entry and its acknowledged descendant before removing the verifier root", + { timeout: 30_000 }, + async ({ processes }) => { + const root = await createTestTempDirectory("rea-package-command-owner-"); + const scratch = join(root, "scratch"); + const entry = join(root, "selected npm entry.mjs"); + const descendant = join(root, "descendant.mjs"); + const release = join(root, "release-descendant"); + const receipt = join(root, "descendant.pid"); + const ready = join(root, "ready.json"); + const events = join(root, "events.jsonl"); + const driver = join(root, "driver.mjs"); + await writeFile(entry, npmSource); + await writeFile(descendant, descendantSource); + const commandsModule = pathToFileURL( + resolve("scripts/lib/verify-package-commands.mjs"), + ).href; + const cleanup = pathToFileURL( + resolve("scripts/lib/verifier-cleanup.mjs"), + ).href; + const observations = pathToFileURL( + resolve("dist/process/ProcessOwnershipObservation.js"), + ).href; + const tree = pathToFileURL( + resolve("dist/process/ProcessOwnershipProcessTree.js"), + ).href; + await writeFile( + driver, + ` +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import { createPackageVerifierCommands } from ${JSON.stringify(commandsModule)}; +import { withVerifierCleanup } from ${JSON.stringify(cleanup)}; +import { systemProcessOwnershipHost } from ${JSON.stringify(observations)}; +import { liveProcesses } from ${JSON.stringify(tree)}; +const scratch = process.env.OWNER_ROOT; +await mkdir(scratch); +let commands, recoveryOwner, pids, liveAtRemoval, failure, diagnostics; +const workspace = { + path: scratch, + async close() { + const ready = await readFile(process.env.OWNER_READY, "utf8").then(JSON.parse, cause => { + if (cause.code === "ENOENT") return { pids: [] }; + throw cause; + }); + pids = ready.pids; + liveAtRemoval = liveProcesses(await systemProcessOwnershipHost.listProcesses()).filter(({ pid }) => pids.includes(pid)).map(({ pid }) => pid); + await rm(scratch, { recursive: true, force: true }); + }, +}; +try { + await withVerifierCleanup( + async () => { + commands = await createPackageVerifierCommands({ workspace }); + await commands.execNpm(["pack", "caller value with spaces"], { cwd: scratch, env: process.env, timeout: 1500 }); + }, + [async () => { + if (commands === undefined) await rm(scratch, { recursive: true, force: true }); + else await commands.close(); + }], + ); +} catch (cause) { + const failures = cause instanceof AggregateError ? cause.errors : [cause]; + // Capture exact cleanup authority before projecting facts or making assertions. + recoveryOwner = failures.find(error => typeof error?.cleanupOwner?.close === "function")?.cleanupOwner ?? commands; + const primary = failures.find(error => error?.snapshot !== undefined && typeof error?.reason === "string"); + failure = { reason: primary?.reason, stdout: primary?.snapshot?.stdout.text, stderr: primary?.snapshot?.stderr.text }; + diagnostics = failures.map(error => ({ + name: error?.name ?? typeof error, + message: error?.message ?? String(error), + reason: error?.reason, + snapshotAvailable: error?.snapshot !== undefined && error?.snapshot !== null, + stack: error?.stack, + cleanupFailure: error?.cleanupFailure, + processError: error?.processError?.message, + preparationFailure: error?.preparationFailure?.message, + cause: { + name: error?.cause?.name, + message: error?.cause?.message ?? String(error?.cause), + stack: error?.cause?.stack, + cause: error?.cause?.cause?.message ?? String(error?.cause?.cause), + }, + })); +} finally { + if (recoveryOwner !== undefined) await recoveryOwner.close(); + else if (commands !== undefined) await commands.close(); + else await rm(scratch, { recursive: true, force: true }); +} +process.stdout.write(JSON.stringify({ pids, liveAtRemoval, rootRemoved: !existsSync(scratch), failure, diagnostics })); +`, + ); + try { + const result = await processes.run(process.execPath, [driver], { + timeoutMs: 20_000, + env: { + ...process.env, + npm_execpath: entry, + OWNER_ROOT: scratch, + OWNER_DESCENDANT_ENTRY: descendant, + OWNER_DESCENDANT: receipt, + OWNER_RELEASE: release, + OWNER_READY: ready, + OWNER_EVENTS: events, + }, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.signal, result.stderr).toBeNull(); + const parsed = reportSchema.safeParse(JSON.parse(result.stdout)); + expect(parsed.success, result.stdout).toBe(true); + if (!parsed.success) throw parsed.error; + const report = parsed.data; + expect(report.failure).toEqual({ + reason: "timeout", + stdout: "npm descendant ready\n", + stderr: "selected npm diagnostics\n", + }); + expect(report.liveAtRemoval).toEqual([]); + expect(report.rootRemoved).toBe(true); + const receipts = await readFile(events, "utf8").catch( + (cause: unknown) => { + if ( + cause instanceof Error && + "code" in cause && + cause.code === "ENOENT" + ) + return ""; + throw cause; + }, + ); + const stopped = receipts + .trim() + .split("\n") + .filter((line) => line !== "") + .map((line) => stopReceiptSchema.parse(JSON.parse(line))); + for (const receipt of stopped) expect(receipt.rootExists).toBe(true); + } finally { + await releaseDescendant(release, receipt); + } + }, +); diff --git a/tests/boundary/process/packageVerifierCommandSemantics.test.ts b/tests/boundary/process/packageVerifierCommandSemantics.test.ts new file mode 100644 index 0000000000..2fda609923 --- /dev/null +++ b/tests/boundary/process/packageVerifierCommandSemantics.test.ts @@ -0,0 +1,459 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createPackageVerifierCommands } from "../../../scripts/lib/verify-package-commands.mjs"; +import { systemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { liveProcesses } from "../../../src/process/ProcessOwnershipProcessTree.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const observed = (promise: Promise) => + promise.then( + (value) => ({ ok: true as const, value }), + (cause: unknown) => ({ ok: false as const, cause }), + ); + +const commandFixture = async ( + beforeWorkspaceClose?: (path: string) => void | Promise, +) => { + const parent = await createTestTempDirectory( + "rea-package-command-semantics-", + ); + const path = join(parent, "workspace"); + await mkdir(path); + let closeCalls = 0; + const workspace = { + path, + async close() { + closeCalls += 1; + await beforeWorkspaceClose?.(path); + await rm(path, { recursive: true, force: true }); + }, + }; + const commands = await createPackageVerifierCommands({ workspace }); + return { parent, workspace, commands, closeCalls: () => closeCalls }; +}; + +it("admits complete stdout and stderr independently of their combined size", async () => { + const fixture = await commandFixture(); + let output; + try { + output = await fixture.commands.exec( + process.execPath, + [ + "-e", + 'process.stdout.write("o".repeat(48)); process.stderr.write("e".repeat(48));', + ], + { maxBuffer: 64 }, + ); + } finally { + await fixture.commands.close(); + } + + expect(output).toEqual({ stdout: "o".repeat(48), stderr: "e".repeat(48) }); + expect(existsSync(fixture.workspace.path)).toBe(false); + expect(fixture.closeCalls()).toBe(1); +}); + +it("enforces an explicit aggregate diagnostic budget across both streams", async () => { + const fixture = await commandFixture(); + let outcome; + try { + outcome = await observed( + fixture.commands.exec( + process.execPath, + [ + "-e", + 'process.stdout.write("o".repeat(48)); process.stderr.write("e".repeat(48));', + ], + { diagnosticBytes: 64 }, + ), + ); + } finally { + await fixture.commands.close(); + } + + expect(outcome).toEqual({ + ok: false, + cause: expect.objectContaining({ reason: "output-limit" }), + }); + expect(existsSync(fixture.workspace.path)).toBe(false); + expect(fixture.closeCalls()).toBe(1); +}); + +it("rejects either overflowing stream even when the command exits with status one", async () => { + const fixture = await commandFixture(); + const outcomes = []; + try { + for (const stream of ["stdout", "stderr"] as const) + outcomes.push( + await observed( + fixture.commands.runWithStatus(process.execPath, [ + "-e", + `process.${stream}.write("x".repeat(1024 * 1024 + 1)); process.exitCode = 1;`, + ]), + ), + ); + } finally { + await fixture.commands.close(); + } + + expect(outcomes).toEqual([ + { ok: false, cause: expect.objectContaining({ reason: "output-limit" }) }, + { ok: false, cause: expect.objectContaining({ reason: "output-limit" }) }, + ]); + expect(existsSync(fixture.workspace.path)).toBe(false); +}); + +it("returns exact stdout for clean exit one and rejects exit two", async () => { + const fixture = await commandFixture(); + let first; + let second; + try { + first = await fixture.commands.runWithStatus(process.execPath, [ + "-e", + 'process.stdout.write("selected stdout\\n "); process.stderr.write("exit one diagnostics"); process.exitCode = 1;', + ]); + second = await observed( + fixture.commands.runWithStatus(process.execPath, [ + "-e", + 'process.stdout.write("exit two stdout"); process.stderr.write("exit two diagnostics"); process.exitCode = 2;', + ]), + ); + } finally { + await fixture.commands.close(); + } + + expect(first).toEqual({ stdout: "selected stdout\n ", status: 1 }); + expect(second).toEqual({ + ok: false, + cause: expect.objectContaining({ + name: "PackageCommandExitError", + code: 2, + stdout: "exit two stdout", + stderr: "exit two diagnostics", + }), + }); +}); + +it.skipIf(process.platform === "win32")( + "rejects a signal exit instead of reporting status one", + async () => { + const fixture = await commandFixture(); + let outcome; + try { + outcome = await observed( + fixture.commands.runWithStatus(process.execPath, [ + "-e", + 'process.kill(process.pid, "SIGTERM");', + ]), + ); + } finally { + await fixture.commands.close(); + } + + expect(outcome).toEqual({ + ok: false, + cause: expect.objectContaining({ + reason: "process", + snapshot: expect.objectContaining({ signal: "SIGTERM" }), + }), + }); + }, +); + +it("uses the exact selected environment and npm entry without rewriting arguments", async () => { + const fixture = await commandFixture(); + const entry = join(fixture.parent, "selected npm entry.mjs"); + const sentinel = "REA_PACKAGE_COMMAND_AMBIENT_SENTINEL"; + const previous = process.env[sentinel]; + const source = `process.stdout.write(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), selected: process.env.REA_PACKAGE_COMMAND_SELECTED, ambient: process.env.${sentinel} ?? null }));`; + await writeFile(entry, source); + const arguments_ = [ + "pack", + "value with spaces", + "--literal=$HOME", + "quote'and\"double", + ]; + const environment = { + ...(process.platform === "win32" + ? { SystemRoot: process.env.SystemRoot } + : {}), + REA_PACKAGE_COMMAND_SELECTED: "caller-selected", + npm_execpath: entry, + }; + let direct; + let npm; + process.env[sentinel] = "ambient-only"; + try { + direct = await fixture.commands.exec(process.execPath, ["-e", source], { + cwd: fixture.workspace.path, + env: environment, + }); + npm = await fixture.commands.execNpm(arguments_, { + cwd: fixture.workspace.path, + env: environment, + }); + } finally { + if (previous === undefined) delete process.env[sentinel]; + else process.env[sentinel] = previous; + await fixture.commands.close(); + } + + expect(JSON.parse(direct!.stdout)).toEqual({ + args: [], + cwd: fixture.workspace.path, + selected: "caller-selected", + ambient: null, + }); + expect(JSON.parse(npm!.stdout)).toEqual({ + args: arguments_, + cwd: fixture.workspace.path, + selected: "caller-selected", + ambient: null, + }); + expect(direct?.stderr).toBe(""); + expect(npm?.stderr).toBe(""); +}); + +it("allows delayed commands with omitted and explicit zero timeouts", async () => { + const fixture = await commandFixture(); + const outputs = []; + try { + for (const options of [{}, { timeout: 0 }]) + outputs.push( + await fixture.commands.exec( + process.execPath, + ["-e", 'setTimeout(() => process.stdout.write("delayed\\n"), 75);'], + options, + ), + ); + } finally { + await fixture.commands.close(); + } + + expect(outputs).toEqual([ + { stdout: "delayed\n", stderr: "" }, + { stdout: "delayed\n", stderr: "" }, + ]); +}); + +const waitForMarker = async (path: string): Promise => { + const deadline = Date.now() + 5_000; + while (!existsSync(path)) { + if (Date.now() >= deadline) + throw new Error("Authored command did not become ready"); + await delay(10); + } +}; + +it.skipIf(process.platform === "win32")( + "shares concurrent close and joins the active command before removing its workspace", + async () => { + let acknowledgedPid: number | undefined; + let liveAtWorkspaceClose: readonly number[] | undefined; + let rootExistsAtCensus = false; + const fixture = await commandFixture(async (path) => { + rootExistsAtCensus = existsSync(path); + liveAtWorkspaceClose = liveProcesses( + await systemProcessOwnershipHost.listProcesses(), + ) + .filter(({ pid }) => pid === acknowledgedPid) + .map(({ pid }) => pid); + }); + const ready = join(fixture.parent, "ready"); + const source = ` +require("node:fs").writeFileSync(${JSON.stringify(ready)}, JSON.stringify({ pid: process.pid })); +setInterval(() => {}, 1000); +setTimeout(() => process.exit(0), 10000); +`; + const running = observed( + fixture.commands.exec(process.execPath, ["-e", source]), + ); + let sameClose = false; + let outcome; + try { + await waitForMarker(ready); + ({ pid: acknowledgedPid } = z + .object({ pid: z.number().int().positive() }) + .parse(JSON.parse(await readFile(ready, "utf8")))); + const first = fixture.commands.close(); + const second = fixture.commands.close(); + sameClose = first === second; + await Promise.all([first, second]); + outcome = await running; + } finally { + await fixture.commands.close(); + await running; + } + expect(sameClose).toBe(true); + expect(outcome).toEqual({ + ok: false, + cause: expect.objectContaining({ reason: "cancelled" }), + }); + expect(acknowledgedPid).toBeGreaterThan(0); + expect(liveAtWorkspaceClose).toEqual([]); + expect(rootExistsAtCensus).toBe(true); + expect(existsSync(fixture.workspace.path)).toBe(false); + expect(fixture.closeCalls()).toBe(1); + }, +); + +it("retains the same adopted capability and workspace after failed close, then releases each once", async () => { + const events: string[] = []; + const fixture = await commandFixture(() => { + events.push("workspace"); + }); + const child = join(fixture.workspace.path, "adopted"); + const forbidden = join(fixture.parent, "forbidden-command"); + await mkdir(child); + const owners: object[] = []; + let attempts = 0; + const capability = { + async close() { + owners.push(this); + attempts += 1; + if (attempts === 1) { + events.push("adopted-failed"); + throw new Error("retry this exact adopted directory capability"); + } + await rm(child, { recursive: true, force: true }); + events.push("adopted-released"); + }, + }; + fixture.commands.adopt(capability, child); + fixture.commands.adopt(capability, child); + let failure: unknown; + let retained; + let blockedBefore; + let blockedAfter; + try { + const closed = await observed(fixture.commands.close()); + if (!closed.ok) failure = closed.cause; + retained = { + root: existsSync(fixture.workspace.path), + child: existsSync(child), + rootCloseCalls: fixture.closeCalls(), + }; + const arguments_ = [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(forbidden)}, "unexpected launch");`, + ]; + blockedBefore = await observed( + fixture.commands.exec(process.execPath, arguments_, { + cwd: fixture.parent, + }), + ); + await fixture.commands.close(); + blockedAfter = await observed( + fixture.commands.exec(process.execPath, arguments_, { + cwd: fixture.parent, + }), + ); + } finally { + await fixture.commands.close(); + } + + expect(failure).toMatchObject({ + name: "PackageVerifierCleanupFailure", + path: fixture.workspace.path, + }); + if ( + !(failure instanceof Error) || + !("cleanupOwner" in failure) || + !("workspace" in failure) + ) + throw new Error("Expected retained package cleanup authority"); + expect(failure.cleanupOwner).toBe(fixture.commands); + expect(failure.workspace).toBe(fixture.workspace); + expect(retained).toEqual({ root: true, child: true, rootCloseCalls: 0 }); + expect(owners).toHaveLength(2); + expect(owners[0]).toBe(capability); + expect(owners[1]).toBe(capability); + expect(events).toEqual(["adopted-failed", "adopted-released", "workspace"]); + expect(fixture.closeCalls()).toBe(1); + expect(existsSync(fixture.workspace.path)).toBe(false); + expect(existsSync(forbidden)).toBe(false); + for (const outcome of [blockedBefore, blockedAfter]) + expect(outcome).toEqual({ + ok: false, + cause: expect.objectContaining({ + message: "Package command owner is closed", + }), + }); +}); + +it("rejects a new cleanup owner once workspace release has started", async () => { + let markReleaseStarted!: () => void; + const releaseStarted = new Promise((resolve) => { + markReleaseStarted = resolve; + }); + let unblockWorkspaceClose!: () => void; + const allowRelease = new Promise((resolve) => { + unblockWorkspaceClose = resolve; + }); + const events: string[] = []; + const fixture = await commandFixture(async () => { + events.push("workspace-release-started"); + markReleaseStarted(); + await allowRelease; + events.push("workspace-close-unblocked"); + }); + // This resource belongs to the caller until adoption succeeds. Keep it + // outside the verifier workspace so workspace removal cannot conceal a leak. + const path = join(fixture.parent, "caller-owned-directory"); + await mkdir(path); + const releases: object[] = []; + const capability = { + async close() { + releases.push(this); + await rm(path, { recursive: true, force: true }); + events.push("caller-owner-released"); + }, + }; + const closing = fixture.commands.close(); + let rejection: unknown; + let retained; + try { + await releaseStarted; + try { + fixture.commands.adopt(capability, path); + } catch (cause: unknown) { + rejection = cause; + } + retained = { + workspace: existsSync(fixture.workspace.path), + callerResource: existsSync(path), + callerCloseCalls: releases.length, + }; + } finally { + try { + await capability.close(); + } finally { + unblockWorkspaceClose(); + await closing; + await fixture.commands.close(); + } + } + + expect(rejection).toBeInstanceOf(Error); + expect(retained).toEqual({ + workspace: true, + callerResource: true, + callerCloseCalls: 0, + }); + expect(releases).toHaveLength(1); + expect(releases[0]).toBe(capability); + expect(events).toEqual([ + "workspace-release-started", + "caller-owner-released", + "workspace-close-unblocked", + ]); + expect(existsSync(path)).toBe(false); + expect(existsSync(fixture.workspace.path)).toBe(false); + expect(fixture.closeCalls()).toBe(1); +}); diff --git a/tests/boundary/process/packagedWasmVerifierOwnership.test.ts b/tests/boundary/process/packagedWasmVerifierOwnership.test.ts index d9ecefe3b5..5fcadfc707 100644 --- a/tests/boundary/process/packagedWasmVerifierOwnership.test.ts +++ b/tests/boundary/process/packagedWasmVerifierOwnership.test.ts @@ -20,9 +20,12 @@ const builtModule = (path: string): string => pathToFileURL(resolve("dist", path)).href; const PACKAGE_PACK_FIXTURE = ` +import assert from "node:assert/strict"; import { appendFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -export async function verifyPackagePack({ root, workspace }) { +import { packageCommands } from "./lib/verify-package-commands.mjs"; +export async function verifyPackagePack({ root, workspace, commands }) { + assert.equal(commands, packageCommands); appendFileSync(process.env.OWNERSHIP_EVENTS, JSON.stringify({ kind: "pack", root, workspace }) + "\\n"); const tarball = join(workspace, "authored.tgz"); writeFileSync(tarball, "authored archive boundary"); @@ -30,15 +33,13 @@ export async function verifyPackagePack({ root, workspace }) { } `; -const PACKAGE_INSTALL_FIXTURE = ` +const NPM_INSTALL_FIXTURE = ` import assert from "node:assert/strict"; import { appendFileSync } from "node:fs"; -export async function execNpm(args, { env }) { - assert.equal(args[0], "install"); - assert.ok(args.includes("--ignore-scripts")); - appendFileSync(process.env.OWNERSHIP_EVENTS, JSON.stringify({ kind: "install", prefix: args[args.indexOf("--prefix") + 1], home: env.HOME }) + "\\n"); - return { stdout: "", stderr: "" }; -} +const args = process.argv.slice(2); +assert.equal(args[0], "install"); +assert.ok(args.includes("--ignore-scripts")); +appendFileSync(process.env.OWNERSHIP_EVENTS, JSON.stringify({ kind: "install", prefix: args[args.indexOf("--prefix") + 1], home: process.env.HOME }) + "\\n"); `; const PRIVATE_ROOT_OBSERVER = ` @@ -65,15 +66,20 @@ export const PrivateRuntimeRoot = { }; `; -const OWNED_COMMAND_FIXTURE = ` +const PACKAGE_COMMANDS_FIXTURE = ` import { appendFileSync, existsSync } from "node:fs"; -import { runOwnedCommand as actualRun, OwnedCommandFailure } from ${JSON.stringify(builtModule("process/OwnedCommand.js"))}; +import { fileURLToPath } from "node:url"; +import { createPackageVerifierCommands as createActualCommands } from ${JSON.stringify(pathToFileURL(resolve("scripts/lib/verify-package-commands.mjs")).href)}; import { spawnOwnedProviderProcess } from ${JSON.stringify(builtModule("process/ProviderProcess.js"))}; import { cleanupOwnedProcessGroup } from ${JSON.stringify(builtModule("process/ProcessOwnership.js"))}; -export { OwnedCommandFailure }; -export const runOwnedCommand = (spawn, limits) => actualRun(spawn, limits, { +const nestedVerifier = fileURLToPath(new URL("../verify-wasm-artifact.mjs", import.meta.url)); +export let packageCommands; +export async function createPackageVerifierCommands(options) { +packageCommands = await createActualCommands({ + ...options, launcher: async (input) => { const acquired = await spawnOwnedProviderProcess(input); + if (!input.arguments.includes(nestedVerifier)) return acquired; return { ...acquired, cleanup: async () => { @@ -85,6 +91,8 @@ export const runOwnedCommand = (spawn, limits) => actualRun(spawn, limits, { }; }, }); +return packageCommands; +} `; const NESTED_VERIFIER_FIXTURE = ` @@ -121,29 +129,32 @@ const HARNESS_FIXTURE = ` import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; -import { OwnedCommandFailure, prepareOwnedCommand } from ${JSON.stringify(builtModule("process/OwnedCommand.js"))}; +import { prepareOwnedCommand } from ${JSON.stringify(builtModule("process/OwnedCommand.js"))}; import { systemProcessOwnershipHost } from ${JSON.stringify(builtModule("process/ProcessOwnershipObservation.js"))}; import { liveProcesses } from ${JSON.stringify(builtModule("process/ProcessOwnershipProcessTree.js"))}; import { withVerifierCleanup } from "./scripts/lib/verifier-cleanup.mjs"; +import { packageCommands } from "./scripts/lib/verify-package-commands.mjs"; await prepareOwnedCommand(); let failure; try { await import("./scripts/verify-packaged-wasm-artifact.mjs"); } catch (cause) { failure = cause; } // Capture actual capabilities before any fixture parsing or assertion can fail. const failures = failure instanceof AggregateError ? failure.errors : [failure]; -const primary = failures.find((error) => error instanceof OwnedCommandFailure); +const primary = failures.find((error) => error !== null && typeof error === "object" && + error.reason === "timeout" && error.snapshot !== null && typeof error.snapshot === "object"); const cleanup = failures.find((error) => error !== null && typeof error === "object" && - (error.runtimeRoot !== undefined || typeof error.cleanupOwner?.close === "function")); + typeof error.cleanupOwner?.close === "function"); const compositeOwner = typeof cleanup?.cleanupOwner?.close === "function" ? cleanup.cleanupOwner : undefined; const retainedProcess = primary?.cleanupOwner; -const retainedRoot = cleanup?.runtimeRoot; +const retainedRoot = cleanup?.workspace; await withVerifierCleanup(async () => { if (process.env.OWNERSHIP_HARNESS_ASSERT === "1") throw new Error("authored harness assertion failure"); const events = (await readFile(process.env.OWNERSHIP_EVENTS, "utf8")).trim().split("\\n").map(JSON.parse); const ready = events.find((entry) => entry.kind === "ready"); assert.ok(ready, "The actual descendant must acknowledge startup before the command deadline"); -assert.ok(primary instanceof OwnedCommandFailure); +assert.ok(primary, "The owned nested command must fail with its timeout snapshot"); assert.equal(primary.reason, "timeout"); +assert.ok(primary.snapshot.stdout.text.includes("nested ready")); const pids = [ready.leader, ready.descendant]; const remaining = async () => liveProcesses(await systemProcessOwnershipHost.listProcesses()).filter(({ pid }) => pids.includes(pid)).map(({ pid }) => pid); const result = { reason: primary.reason, runtime: ready.runtime, rootExists: existsSync(ready.runtime), livePids: await remaining(), pids, runId: ready.runId, descendantRunId: ready.descendantRunId }; @@ -151,9 +162,10 @@ if (process.env.OWNERSHIP_DENY === "1") { assert.ok(failure instanceof AggregateError); assert.equal(failure.cause, primary); assert.equal(failure.errors[1], cleanup); - assert.equal(cleanup.name, "PackagedWasmCleanupFailure"); - assert.equal(cleanup.runtimeRoot.path, ready.runtime); + assert.equal(cleanup.name, "PackageVerifierCleanupFailure"); + assert.equal(cleanup.workspace.path, ready.runtime); assert.equal(typeof cleanup.cleanupOwner.close, "function"); + assert.equal(cleanup.cleanupOwner, packageCommands); assert.ok(primary.cleanupOwner); result.compositeOwner = true; } @@ -207,7 +219,7 @@ const expectHarnessFailureCleanup = async ( .map((line) => JSON.parse(line)); const ready = events.find(({ kind }) => kind === "ready"); expect(ready).toBeDefined(); - expect(ready.runId).toMatch(/^rea-packaged-wabt-/); + expect(ready.runId).toMatch(/^rea-package-command-/); expect(ready.descendantRunId).toBe(ready.runId); expectRootCleanupAfterProcessExit(events, ready.runtime, [ ready.leader, @@ -251,10 +263,10 @@ it.skipIf(process.platform === "win32").each([ ), workspace.write("scripts/verify-package-pack.mjs", PACKAGE_PACK_FIXTURE), workspace.write( - "scripts/lib/verify-package-core.mjs", - PACKAGE_INSTALL_FIXTURE, + "scripts/lib/verify-package-commands.mjs", + PACKAGE_COMMANDS_FIXTURE, ), - workspace.write("dist/process/OwnedCommand.js", OWNED_COMMAND_FIXTURE), + workspace.write("scripts/npm-fixture.mjs", NPM_INSTALL_FIXTURE), workspace.write( "scripts/verify-wasm-artifact.mjs", NESTED_VERIFIER_FIXTURE, @@ -279,6 +291,7 @@ it.skipIf(process.platform === "win32").each([ TMPDIR: runtimeParent, TMP: runtimeParent, TEMP: runtimeParent, + npm_execpath: workspace.path("scripts/npm-fixture.mjs"), OWNERSHIP_EVENTS: events, OWNERSHIP_REPORT: report, OWNERSHIP_ALLOW: allow, @@ -303,7 +316,7 @@ it.skipIf(process.platform === "win32").each([ const observed = JSON.parse(await readFile(report, "utf8")); expect(observed.reason).toBe("timeout"); expect(observed.pids).toHaveLength(2); - expect(observed.runId).toMatch(/^rea-packaged-wabt-/); + expect(observed.runId).toMatch(/^rea-package-command-/); expect(observed.descendantRunId).toBe(observed.runId); expect(observed.rootExists).toBe(denied); expect(observed.livePids.sort()).toEqual( diff --git a/tests/boundary/process/packedBridgeVerification.test.ts b/tests/boundary/process/packedBridgeVerification.test.ts index 344d17671e..d21b6b9c77 100644 --- a/tests/boundary/process/packedBridgeVerification.test.ts +++ b/tests/boundary/process/packedBridgeVerification.test.ts @@ -9,16 +9,24 @@ import { } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; -import { expect, it } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; import { z } from "zod"; +import { + createPackageVerifierCommands, + type PackageVerifierCommands, +} from "../../../scripts/lib/verify-package-commands.mjs"; +import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { verifyPackedBridge } from "../../../scripts/verify-packed-bridge.mjs"; import { PrivateRuntimeRoot, privateRuntimeRootCapability, } from "../../../src/process/PrivateRuntimeRoot.js"; import { GHIDRA_BRIDGE_SOURCES } from "../../../src/ghidra/GhidraBridgeSources.js"; -import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; const exec = promisify(execFile); const packedFiles = [ @@ -33,7 +41,15 @@ const validSource = '# Unicode source: 雨\nraise RuntimeError("Packed source must never execute")\n'; async function fixture(source: string = validSource, omitHopper = false) { - const directory = await createTestTempDirectory("rea-packed-bridge-"); + const scratch = await createTestWorkspace("rea-packed-bridge-"); + const directory = scratch.root; + let commands: PackageVerifierCommands | undefined; + onTestFinished(() => + commands === undefined ? removeTestWorkspace(directory) : commands.close(), + ); + commands = await createPackageVerifierCommands({ + workspace: { path: directory, close: () => removeTestWorkspace(directory) }, + }); const staging = join(directory, "source"); const workspace = join(directory, "workspace"); await mkdir(workspace); @@ -45,7 +61,7 @@ async function fixture(source: string = validSource, omitHopper = false) { } const tarball = join(directory, "packed bridges.tgz"); await exec("tar", ["-czf", tarball, "-C", staging, "package"]); - return { workspace, tarball, packedFiles }; + return { workspace, tarball, packedFiles, commands }; } it("parses packed Python without executing it or extracting archive members", async () => { @@ -88,12 +104,20 @@ it.each(["cwd", "pythonpath", "sitecustomize"] as const)( "--input-type=module", "--eval", `import { verifyPackedBridge } from ${JSON.stringify(verifier)}; +import { createPackageVerifierCommands } from ${JSON.stringify(new URL("../../../scripts/lib/verify-package-commands.mjs", import.meta.url).href)}; +import { withVerifierCleanup } from ${JSON.stringify(new URL("../../../scripts/lib/verifier-cleanup.mjs", import.meta.url).href)}; +import { PrivateRuntimeRoot } from ${JSON.stringify(new URL("../../../dist/process/PrivateRuntimeRoot.js", import.meta.url).href)}; +const root = await PrivateRuntimeRoot.create({ prefix: "rea-packed-python-command-" }); +let commands; +await withVerifierCleanup(async () => { +commands = await createPackageVerifierCommands({ workspace: root }); try { - await verifyPackedBridge(JSON.parse(process.env.REA_PACKED_VERIFY_INPUT)); + await verifyPackedBridge({ ...JSON.parse(process.env.REA_PACKED_VERIFY_INPUT), commands }); console.log(JSON.stringify({ accepted: true })); } catch (error) { console.log(JSON.stringify({ accepted: false, stderr: String(error.stderr) })); -}`, +} +}, [() => commands === undefined ? root.close() : commands.close()]);`, ], { cwd: kind === "cwd" ? modules : input.workspace, @@ -105,7 +129,10 @@ try { ), PYTHONPATH: kind === "cwd" ? "" : modules, REA_PACKED_IMPORT_MARKER: marker, - REA_PACKED_VERIFY_INPUT: JSON.stringify(input), + REA_PACKED_VERIFY_INPUT: JSON.stringify({ + tarball: input.tarball, + packedFiles: input.packedFiles, + }), }, }, ); @@ -160,12 +187,14 @@ it.each(["valid", "invalid"] as const)( const root = await PrivateRuntimeRoot.create({ prefix: "rea-packed-bridge-lease-", }); - try { + let commands: PackageVerifierCommands | undefined; + await withVerifierCleanup(async () => { + commands = await createPackageVerifierCommands({ workspace: root }); const pack = join(root.path, "pack"); await mkdir(pack); const tarball = join(pack, "packed bridges.tgz"); await copyFile(input.tarball, tarball); - const parsed = verifyPackedBridge({ tarball, packedFiles }); + const parsed = verifyPackedBridge({ tarball, packedFiles, commands }); if (kind === "valid") await parsed; else await expect(parsed).rejects.toMatchObject({ @@ -173,9 +202,7 @@ it.each(["valid", "invalid"] as const)( }); expect(await readdir(root.path)).toEqual(["pack"]); expect(await readFile(tarball)).toEqual(await readFile(input.tarball)); - } finally { - await root.close(); - } + }, [() => (commands === undefined ? root.close() : commands.close())]); await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); }, ); diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts index e54c6b7589..7be3eac0d8 100644 --- a/tests/composition/reverseEngineering.test.ts +++ b/tests/composition/reverseEngineering.test.ts @@ -578,7 +578,7 @@ it("forwards selected and omitted deadlines through the production command runne const directory = await mkdtemp(join(tmpdir(), "rea-owned-timeout-")); const path = join(directory, "fixture.bin"); await writeFile(path, "before"); - const timeouts: number[] = []; + const timeouts: (number | null)[] = []; const service = new ReverseEngineeringService({ runOwned: async (_spawn, limits) => { timeouts.push(limits.timeoutMs); diff --git a/tests/evaluation/package/packagePlatformDiagnostics.test.ts b/tests/evaluation/package/packagePlatformDiagnostics.test.ts index 1f4ea4644f..d0d04298eb 100644 --- a/tests/evaluation/package/packagePlatformDiagnostics.test.ts +++ b/tests/evaluation/package/packagePlatformDiagnostics.test.ts @@ -54,8 +54,13 @@ const verifyMacOS = async ( cli: "fixture-cli", environment: {}, platform: "darwin", - runCommand: async (_cli, arguments_) => - JSON.stringify(outputs[arguments_[0]!]) ?? "", + commands: { + run: async (_cli, arguments_) => + JSON.stringify(outputs[arguments_[0]!]) ?? "", + runWithStatus: async () => { + throw new Error("Unexpected status command in macOS fixture"); + }, + }, }); };