diff --git a/scripts/verify-package-install.mjs b/scripts/verify-package-install.mjs index 81ee5afc6..544ff9300 100644 --- a/scripts/verify-package-install.mjs +++ b/scripts/verify-package-install.mjs @@ -6,7 +6,6 @@ import { exec, json, run } from "./lib/verify-package-core.mjs"; /** Install the packed CLI globally with and without optional dependencies and verify PTY degradation. */ export async function verifyPackageInstall({ - root, tarball, prefix, workspace, @@ -14,14 +13,7 @@ export async function verifyPackageInstall({ }) { await exec( "npm", - [ - "install", - "--global", - "--ignore-scripts", - "--prefix", - prefix, - join(root, tarball), - ], + ["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball], { env: environment }, ); const cli = join(prefix, "bin", "rea"); @@ -61,7 +53,7 @@ export async function verifyPackageInstall({ "--omit=optional", "--prefix", noOptionalPrefix, - join(root, tarball), + tarball, ], { env: environment }, ); diff --git a/scripts/verify-package-pack.mjs b/scripts/verify-package-pack.mjs index 4a674075a..2d5ebed08 100644 --- a/scripts/verify-package-pack.mjs +++ b/scripts/verify-package-pack.mjs @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { CATALOG_IDENTITY } from "../dist/catalogIdentity.js"; @@ -6,15 +7,23 @@ import { verifyPackedBridge } from "./verify-packed-bridge.mjs"; /** Create a tarball and assert production packaging constraints. */ export async function verifyPackagePack({ root, workspace }) { - const tarball = ( - await exec("npm", ["pack", "--silent"], { cwd: root }) - ).stdout.trim(); - const packedFiles = ( - await exec("tar", ["-tf", join(root, tarball)]) - ).stdout.split("\n"); + const packDirectory = join(workspace, "pack"); + await mkdir(packDirectory); + const tarball = join( + packDirectory, + ( + await exec( + "npm", + ["pack", "--silent", "--pack-destination", packDirectory], + { + cwd: root, + }, + ) + ).stdout.trim(), + ); + const packedFiles = (await exec("tar", ["-tf", tarball])).stdout.split("\n"); const packedManifest = JSON.parse( - (await exec("tar", ["-xOf", join(root, tarball), "package/package.json"])) - .stdout, + (await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout, ); if ( packedManifest.scripts?.postinstall !== undefined || @@ -42,7 +51,7 @@ export async function verifyPackagePack({ root, workspace }) { const skill = ( await exec("tar", [ "-xOf", - join(root, tarball), + tarball, "package/skills/reverse-engineer-anything/SKILL.md", ]) ).stdout; diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index edd01f311..9e2544e14 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -44,7 +44,6 @@ try { referenceRoot, }); const { cli, packageRunnerCli } = await verifyPackageInstall({ - root, tarball, prefix: environmentData.prefix, workspace, @@ -58,7 +57,7 @@ try { "node_modules", PRODUCT_IDENTITY.packageName, ), - tarball: join(root, tarball), + tarball, workspace, environment: environmentData.environment, }); @@ -137,6 +136,5 @@ try { `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 4 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, update, mcpStartup, mcpModuleLoading })}\n`, ); } finally { - if (tarball) await rm(join(root, tarball), { force: true }); await rm(workspace, { recursive: true, force: true }); } diff --git a/scripts/verify-packed-bridge.mjs b/scripts/verify-packed-bridge.mjs index 854772683..a2ce50842 100644 --- a/scripts/verify-packed-bridge.mjs +++ b/scripts/verify-packed-bridge.mjs @@ -18,7 +18,7 @@ export async function verifyPackedBridge({ throw new Error("package omitted the Hopper bridge"); if (!packedFiles.includes(packedGhidraBridge)) throw new Error("package omitted the Ghidra bridge"); - await exec("tar", ["-xf", join(root, tarball), "-C", workspace]); + await exec("tar", ["-xf", tarball, "-C", workspace]); const ghidraSource = await readFile( join(workspace, packedGhidraBridge), "utf8", diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs index 35c5224ef..aa1c05b38 100644 --- a/scripts/verify-published-package.mjs +++ b/scripts/verify-published-package.mjs @@ -18,9 +18,12 @@ const version = process.argv[2]; if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version ?? "")) throw new Error("Usage: node scripts/verify-published-package.mjs "); -const serverEnvironment = { ...process.env }; -delete serverEnvironment.HOPPER_TARGET_PATH; const canaryRoot = await mkdtemp(join(tmpdir(), "rea-published-canary-")); +const serverEnvironment = { + ...process.env, + npm_config_cache: join(canaryRoot, "npm-cache"), +}; +delete serverEnvironment.HOPPER_TARGET_PATH; const transport = new StdioClientTransport({ command: "npm", args: [ @@ -91,7 +94,6 @@ process.stdout.write( ); async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) { - const previousVersion = await findPreviousPublishedVersion(targetVersion); const prefix = join(canaryRoot, "update-prefix"); const home = join(canaryRoot, "update-home"); const environment = { @@ -101,6 +103,11 @@ async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) { npm_config_prefix: prefix, PATH: `${join(prefix, "bin")}${delimiter}${process.env.PATH ?? ""}`, }; + const previousVersion = await findPreviousPublishedVersion( + targetVersion, + canaryRoot, + environment, + ); await execFileAsync( "npm", @@ -193,11 +200,11 @@ if (result.status === "failed") process.exitCode = 1; }; } -async function findPreviousPublishedVersion(targetVersion) { +async function findPreviousPublishedVersion(targetVersion, cwd, environment) { const { stdout } = await execFileAsync( "npm", ["view", "rea-agents", "versions", "--json"], - { maxBuffer: 16 * 1024 * 1024 }, + { cwd, env: environment, maxBuffer: 16 * 1024 * 1024 }, ); const versions = JSON.parse(stdout); const previous = versions diff --git a/tests/boundary/process/packagePackIsolation.test.ts b/tests/boundary/process/packagePackIsolation.test.ts new file mode 100644 index 000000000..48c88eaf2 --- /dev/null +++ b/tests/boundary/process/packagePackIsolation.test.ts @@ -0,0 +1,84 @@ +import { execFile } from "node:child_process"; +import { + access, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execFileAsync = promisify(execFile); +const expectedFailure = + "package did not retain its MCP Registry ownership marker"; + +it("keeps npm pack output in the owned workspace when package validation fails", async () => { + const testRoot = await createTestTempDirectory("rea-pack-isolation-"); + const packageRoot = join(testRoot, "source"); + const workspace = join(testRoot, "workspace"); + const npmCache = join(testRoot, "npm-cache"); + const archiveName = "rea-agents-6.0.0.tgz"; + const archivePath = join(packageRoot, archiveName); + const sentinel = Buffer.from("caller-owned archive\n"); + await Promise.all([mkdir(packageRoot), mkdir(workspace), mkdir(npmCache)]); + await Promise.all([ + writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ + name: "rea-agents", + version: "6.0.0", + mcpName: "invalid-owner", + bin: { rea: "scripts/rea.mjs", "rea-agents": "scripts/rea.mjs" }, + dependencies: { "@lydell/node-pty": "1.1.0" }, + }), + ), + writeFile(archivePath, sentinel), + ]); + const packageContentsBefore = (await readdir(packageRoot)).sort(); + const packerUrl = pathToFileURL( + resolve("scripts/verify-package-pack.mjs"), + ).href; + const harness = ` +import { rm } from "node:fs/promises"; +const workspace = ${JSON.stringify(workspace)}; +let failure; +try { + const { verifyPackagePack } = await import(${JSON.stringify(packerUrl)}); + await verifyPackagePack({ root: ${JSON.stringify(packageRoot)}, workspace }); +} catch (cause) { + if (!(cause instanceof Error) || cause.message !== ${JSON.stringify(expectedFailure)}) throw cause; + failure = cause.message; +} finally { + await rm(workspace, { recursive: true, force: true }); +} +if (failure !== ${JSON.stringify(expectedFailure)}) throw new Error("Expected package metadata validation to fail after packing"); +process.stdout.write(JSON.stringify({ failure })); +`; + const { stdout } = await execFileAsync( + process.execPath, + ["--input-type=module", "--eval", harness], + { + cwd: process.cwd(), + env: { + ...process.env, + npm_config_cache: npmCache, + TEMP: npmCache, + TMP: npmCache, + TMPDIR: npmCache, + }, + maxBuffer: 1_024 * 1_024, + }, + ); + + expect(JSON.parse(stdout)).toEqual({ failure: expectedFailure }); + expect((await readdir(packageRoot)).sort()).toEqual(packageContentsBefore); + expect(await readFile(archivePath)).toEqual(sentinel); + await expect(access(workspace)).rejects.toMatchObject({ code: "ENOENT" }); +});