diff --git a/.github/workflows/artifact-acceptance.yml b/.github/workflows/artifact-acceptance.yml index 620add421..19c5a8658 100644 --- a/.github/workflows/artifact-acceptance.yml +++ b/.github/workflows/artifact-acceptance.yml @@ -90,6 +90,7 @@ jobs: sparse-checkout: | /scripts/public-release-acceptance.mjs /scripts/release-registry.mjs + /scripts/artifact-installation.mjs - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -98,8 +99,8 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: '1.3.11' - - name: Install pinned npm - run: npm install -g npm@11.16.0 --ignore-scripts + - name: Install pinned npm and pnpm + run: npm install -g npm@11.16.0 pnpm@10.28.2 --ignore-scripts - name: Verify publication run and retain its manifest env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 66c3c80ea..ab1d8239b 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -96,6 +96,9 @@ jobs: node scripts/release-artifacts.mjs freeze .artifacts/release npm run build:offline git diff --exit-code HEAD + - name: Install pinned pnpm for real package-manager fixtures + if: steps.restore.outputs.restored != 'true' + run: npm install --global --ignore-scripts pnpm@10.28.2 - name: Validate release candidate if: steps.restore.outputs.restored != 'true' run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb7f6ceed..53f96990e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - uses: ./.github/actions/setup + - name: Install pinned pnpm for real package-manager fixtures + run: npm install --global --ignore-scripts pnpm@10.28.2 + - name: Test scripts and other workspaces run: ./test.sh other --shard=${{ matrix.shard }}/2 diff --git a/.github/workflows/native-artifact-acceptance.yml b/.github/workflows/native-artifact-acceptance.yml index 0b603a2a7..f01e4722d 100644 --- a/.github/workflows/native-artifact-acceptance.yml +++ b/.github/workflows/native-artifact-acceptance.yml @@ -35,8 +35,8 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: '1.3.11' - - name: Install pinned npm - run: npm install -g npm@11.16.0 --ignore-scripts + - name: Install pinned npm and pnpm + run: npm install -g npm@11.16.0 pnpm@10.28.2 --ignore-scripts - name: Install controlled Linux search helpers if: runner.os == 'Linux' run: | diff --git a/docs/release/artifact-acceptance.md b/docs/release/artifact-acceptance.md index 9fdfee1e1..8438d668c 100644 --- a/docs/release/artifact-acceptance.md +++ b/docs/release/artifact-acceptance.md @@ -3,7 +3,8 @@ The release gate installs every package returned by `scripts/release-packages.mjs` from its reviewed local tarball. Each package receives a separate consumer with only its public dependency closure, ordered before the package that requires it. Consumers live outside the repository in paths containing spaces, with separate homes, configuration, projects, temporary files, and package prefixes. -Each manager starts with an empty registry cache shared only among consumers in that acceptance run, and Bun registry concurrency is bounded. +npm and Bun start with an empty registry cache shared only among consumers in that acceptance run, and Bun registry concurrency is bounded. +pnpm consumers use isolated stores, homes, configuration and global executable directories; no user store or credentials are inherited. Installed file identities are rechecked in every consumer even when downloaded dependencies are cached. Lifecycle scripts are disabled and the Diffex registry scope points to an unavailable loopback endpoint, so a missing local override fails instead of fetching an older Diffex package. External npm dependencies still resolve from the public registry. @@ -39,13 +40,15 @@ Malformed and foreign metadata, redirects, outages, no release, and checksum mis An interrupted npm download demonstrates the documented pinned repair and, when npm reports `ENOTEMPTY`, relocation of the affected CLI installation or its exact stale backup before retrying. Interruption itself can mutate npm's prefix and is not claimed to preserve the executable. Uninstall verifies executable removal and retained global and project data. -The same harness verifies Bun global installation and PATH, rejects a foreign release identity without mutation, upgrades with the real Bun subprocess, checks the installed runtime hashes, and uninstalls while retaining user and project data. +The same harness verifies Bun and pnpm global installation and PATH, rejects a foreign release identity without mutation, upgrades with real package-manager subprocesses, checks the installed runtime hashes, and uninstalls while retaining user and project data. +pnpm upgrades reuse the same store location with its contents cleared, preserving store identity while requiring fresh package downloads. +pnpm's shell executable shim and package links are checked against the isolated consumer rather than treated as npm-style direct executable symlinks. An isolated Bun global scope registry ensures that the upgrade receives the reviewed candidate bytes. `npm-lifecycle.json` records commands, identities, results, limitations, and cleanup. ## Supported distribution scope -The approved channel remains npm, installed with npm or Bun and executed on Node 22.19.0 or newer. +The approved channel remains the npm registry, installed with npm, pnpm or Bun and executed on Node 22.19.0 or newer. Bun package installation is distinct from a Bun standalone executable. Standalone publication remains deferred for every platform under the approved redistribution policy, and the manifest advertises no binary assets or binary platforms. Windows npm targets are now explicitly deferred until equivalent terminal acceptance is available. @@ -87,7 +90,7 @@ Public acceptance must compare the actual downloaded npm bytes with these retain The baseline CLI-only acceptance passed on Darwin arm64 but emitted no per-package operation or hosted-server result. The previous expanded acceptance covered eleven packages and the hosted server lifecycle before the owner reduced the public inventory on 2026-09-13. -Current acceptance validates the complete CLI-only tarball through both managers. +Current acceptance validates the complete CLI-only tarball through npm, pnpm and Bun. The owner specified that no live provider test environment is available, so real login and inference remain pending. Native runner results and exact committed-candidate evidence are recorded in the implementation pull request and accompanying acceptance record. Public downloads and public-to-public upgrades remain unexecuted until publication; local fixture inference does not establish those results. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4854570e5..15ddabe31 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Added a compact terminal activity display for npm and pnpm self-updates, while preserving package-manager warnings, failure diagnostics, and redirected logs. +- Added pnpm installation, upgrade, integrity, and removal coverage to release acceptance alongside npm and Bun. ### Fixed diff --git a/scripts/artifact-acceptance.mjs b/scripts/artifact-acceptance.mjs index ac4188808..7b972424a 100644 --- a/scripts/artifact-acceptance.mjs +++ b/scripts/artifact-acceptance.mjs @@ -1,4 +1,4 @@ -import { assertContained, installConsumer, isolatedEnvironment, verifyTarballs } from "./artifact-installation.mjs"; +import { ACCEPTANCE_MANAGERS, assertContained, installConsumer, isolatedEnvironment, verifyTarballs } from "./artifact-installation.mjs"; export { assertContained, installConsumer, isolatedEnvironment, verifyTarballs } from "./artifact-installation.mjs"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; @@ -104,10 +104,10 @@ export const operations = { "@diffexai/diffex-coding-agent-server": `const reporter = api.createDurableUsageReporter({ endpoint: "http://127.0.0.1:1/usage", sharedSecret: "local-artifact-fixture-only-32-bytes", outboxDirectory: path.join(process.env.DIFFEX_CODING_AGENT_DIR, "outbox"), fetch: () => { throw new Error("Unexpected usage"); } }); await reporter.close();`, }; -export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = ["npm", "bun"] } = {}) { +export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = ACCEPTANCE_MANAGERS } = {}) { assert( - managers.length && managers.every((manager) => ["npm", "bun"].includes(manager)), - "Expected npm or Bun acceptance", + managers.length && managers.every((manager) => ACCEPTANCE_MANAGERS.includes(manager)), + "Expected npm, pnpm or Bun acceptance", ); const packages = getPublicWorkspacePackages().map((pkg) => ({ ...cliDistribution(root).manifest, @@ -138,8 +138,10 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = assert(operations[pkg.name], `Missing representative operation: ${pkg.name}`); const consumer = join(temporary, manager, pkg.name.split("/").pop()); const selected = packageClosure(pkg.name, packages); - const env = installConsumer(consumer, manager, selected, records, tarballDirectory, cacheDirectory); - const packageRoot = join(consumer, "node_modules", pkg.name); + const env = installConsumer(consumer, manager, selected, records, tarballDirectory, + manager === "pnpm" ? join(consumer, "cache") : cacheDirectory); + // createRequire preserves an artificial filename's lexical path; use pnpm's real package root. + const packageRoot = realpathSync(join(consumer, "node_modules", pkg.name)); const imports = declaredEntrypoints(pkg, packageRoot); const source = [ `import assert from "node:assert/strict"; import path from "node:path"; import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs";`, @@ -163,7 +165,7 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = }); evidence.packages.push({ manager, - managerVersion: execFileSync(manager, ["--version"], { encoding: "utf8" }).trim(), + managerVersion: execFileSync(manager, ["--version"], { cwd: consumer, env, encoding: "utf8" }).trim(), name: pkg.name, version: pkg.version, entrypoints: imports, @@ -173,7 +175,8 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = console.log(`Accepted ${manager}: ${pkg.name}`); } const consumer = join(temporary, manager, "process acceptance"); - const env = installConsumer(consumer, manager, packages, records, tarballDirectory, cacheDirectory); + const env = installConsumer(consumer, manager, packages, records, tarballDirectory, + manager === "pnpm" ? join(consumer, "cache") : cacheDirectory); const project = join(consumer, "project"); writeFileSync(join(project, "artifact helper.txt"), "artifact-helper-confirmation\n"); const helperOptions = { cwd: project, env, encoding: "utf8", timeout: 10000 }; @@ -217,6 +220,7 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = join(tarballDirectory, cli.filename), outputDirectory, ...(managers.includes("bun") ? [] : ["--skip-bun"]), + ...(managers.includes("pnpm") ? [] : ["--skip-pnpm"]), ], { cwd: root, stdio: "inherit", timeout: 3_600_000 }, ); diff --git a/scripts/artifact-acceptance.test.mjs b/scripts/artifact-acceptance.test.mjs index 94030738b..fd87ac846 100644 --- a/scripts/artifact-acceptance.test.mjs +++ b/scripts/artifact-acceptance.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -72,7 +72,7 @@ test("declared exports include patterns and reject missing declarations and esca const env = isolatedEnvironment(consumer); assert.equal(env.NODE_PATH, undefined); assert.equal(env.NODE_OPTIONS, undefined); - assert.equal(env.HOME, join(consumer, "home")); + assert.equal(env.HOME, join(realpathSync(consumer), "home")); } finally { rmSync(directory, { recursive: true, force: true }); } diff --git a/scripts/artifact-installation.mjs b/scripts/artifact-installation.mjs index ed266bd07..8e7354e44 100644 --- a/scripts/artifact-installation.mjs +++ b/scripts/artifact-installation.mjs @@ -1,16 +1,19 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; +export const PNPM_VERSION = "10.28.2"; +export const ACCEPTANCE_MANAGERS = ["npm", "pnpm", "bun"]; + const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); const readJson = (file) => JSON.parse(readFileSync(file, "utf8")); export function resolveArtifactDirectory(value) { assert(typeof value === "string" && value.length > 0 && !value.includes("\0"), "Invalid artifact directory"); const directory = resolve(value); - for (const base of [process.cwd(), tmpdir(), homedir(), "/tmp"]) { + for (const base of [process.cwd(), tmpdir(), realpathSync(tmpdir()), homedir(), "/tmp"]) { const root = resolve(base); if (!directory.startsWith(root + sep)) continue; let current = root; @@ -47,8 +50,12 @@ export function verifyTarballs(directory, records, packages) { } } -export function isolatedEnvironment(directory) { +export function isolatedEnvironment(directory, manager = "npm") { + assert(ACCEPTANCE_MANAGERS.includes(manager), "Unsupported package manager"); directory = resolveArtifactDirectory(directory); + mkdirSync(directory, { recursive: true }); + // pnpm records absolute virtual-store paths; macOS /var and /private/var must not mix. + directory = realpathSync(directory); // Read literal allowlisted names: dynamic environment lookups can accidentally // admit credentials when this helper is reused by installation/lifecycle code. const env = Object.fromEntries(Object.entries({ @@ -59,8 +66,10 @@ export function isolatedEnvironment(directory) { PATHEXT: process.env.PATHEXT, LANG: process.env.LANG, }).filter(([, value]) => typeof value === "string")); - for (const dir of ["home", "config", "cache", "prefix", "project", "agent", "temp"]) + for (const dir of ["home", "config", "cache", "prefix", "project", "agent", "temp", "pnpm/bin"]) mkdirSync(resolveArtifactDirectory(join(directory, dir)), { recursive: true }); + writeFileSync(join(directory, "empty.npmrc"), ""); + writeFileSync(join(directory, "empty-global.npmrc"), ""); return { ...env, HOME: join(directory, "home"), @@ -73,6 +82,14 @@ export function isolatedEnvironment(directory) { npm_config_globalconfig: join(directory, "empty-global.npmrc"), npm_config_registry: "https://registry.npmjs.org", npm_config_update_notifier: "false", + ...(manager === "pnpm" ? { + PNPM_HOME: join(directory, "pnpm", "bin"), + npm_config_global_bin_dir: join(directory, "pnpm", "bin"), + npm_config_global_dir: join(directory, "pnpm", "global"), + npm_config_store_dir: join(directory, "cache", "pnpm"), + npm_config_manage_package_manager_versions: "false", + npm_config_package_manager_strict: "false", + } : {}), BUN_INSTALL_CACHE_DIR: join(directory, "cache", "bun"), TMPDIR: join(directory, "temp"), TMP: join(directory, "temp"), @@ -95,14 +112,19 @@ export function installConsumer( directory = resolveArtifactDirectory(directory); cacheDirectory = resolveArtifactDirectory(cacheDirectory); tarballDirectory = resolveArtifactDirectory(tarballDirectory); - assert(manager === "npm" || manager === "bun", "Unsupported package manager"); - const command = manager === "npm" ? "npm" : "bun"; + assert(ACCEPTANCE_MANAGERS.includes(manager), "Unsupported package manager"); + const command = manager; assert(selected.every((pkg) => typeof pkg.name === "string" && /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(pkg.name)), "Invalid package name"); mkdirSync(directory, { recursive: true }); - const env = isolatedEnvironment(directory); + const env = isolatedEnvironment(directory, manager); mkdirSync(cacheDirectory, { recursive: true }); env.npm_config_cache = join(cacheDirectory, "npm"); env.BUN_INSTALL_CACHE_DIR = join(cacheDirectory, "bun"); + if (manager === "pnpm") { + env.npm_config_store_dir = join(realpathSync(cacheDirectory), "pnpm"); + assert(!existsSync(env.npm_config_store_dir), "pnpm consumer acceptance requires an empty store"); + assertPnpmVersion(directory, env); + } writeFileSync(join(directory, ".npmrc"), "@diffexai:registry=http://127.0.0.1:9/\n"); const dependencies = Object.fromEntries( selected.map((pkg) => [ @@ -112,15 +134,22 @@ export function installConsumer( ); writeFileSync( join(directory, "package.json"), - JSON.stringify({ private: true, type: "module", dependencies, overrides: dependencies }), + JSON.stringify({ private: true, type: "module", dependencies, overrides: dependencies, pnpm: { overrides: dependencies } }), ); execFileSync( command, manager === "npm" ? ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"] - : ["install", "--production", "--ignore-scripts", "--network-concurrency=8"], + : manager === "pnpm" + ? ["install", "--prod", "--ignore-scripts", "--no-frozen-lockfile"] + : ["install", "--production", "--ignore-scripts", "--network-concurrency=8"], { cwd: directory, env, stdio: "inherit", timeout: 600_000 }, ); + verifyInstalledConsumer(directory, selected, records, tarballDirectory); + return env; +} + +export function verifyInstalledConsumer(directory, selected, records, tarballDirectory) { for (const pkg of selected) { const installed = join(directory, "node_modules", pkg.name); assertContained(installed, directory); @@ -149,5 +178,30 @@ export function installConsumer( ); } } - return env; +} + +export function assertPnpmVersion(directory, env) { + let version; + const remedy = `Install it with: npm install --global --ignore-scripts pnpm@${PNPM_VERSION}`; + try { + version = execFileSync("pnpm", ["--version"], { cwd: directory, env, encoding: "utf8", timeout: 30000 }).trim(); + } catch (cause) { + throw new Error(`Acceptance requires pnpm@${PNPM_VERSION} on PATH. ${remedy}`, { cause }); + } + assert.equal(version, PNPM_VERSION, `Acceptance requires pnpm@${PNPM_VERSION}; found ${version}. ${remedy}`); + return version; +} + +export function globalManagerLayout(manager, directory, env) { + assert(ACCEPTANCE_MANAGERS.includes(manager), "Unsupported package manager"); + const bin = manager === "npm" ? join(env.npm_config_prefix, "bin") + : manager === "pnpm" ? env.PNPM_HOME : env.BUN_INSTALL_BIN; + const modules = manager === "npm" ? join(env.npm_config_prefix, "lib/node_modules") + : manager === "pnpm" ? execFileSync("pnpm", ["root", "--global"], { + cwd: directory, env: { ...env, PATH: `${bin}:${env.PATH}` }, encoding: "utf8", timeout: 30000, + }).trim() : join(env.BUN_INSTALL_GLOBAL_DIR, "node_modules"); + assert(isAbsolute(modules), `Invalid ${manager} global module directory: ${modules}`); + const local = relative(realpathSync(directory), modules); + assert(local && !local.startsWith("..") && !isAbsolute(local), `${manager} global root escapes isolation`); + return { bin, modules, packageRoot: join(modules, "@diffexai/diffex"), executable: join(bin, "diffex") }; } diff --git a/scripts/artifact-installation.test.mjs b/scripts/artifact-installation.test.mjs index 5f51d416b..5a44c3eb5 100644 --- a/scripts/artifact-installation.test.mjs +++ b/scripts/artifact-installation.test.mjs @@ -1,9 +1,13 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; -import { installConsumer, isolatedEnvironment, resolveArtifactDirectory, verifyTarballs } from "./artifact-installation.mjs"; +import { ACCEPTANCE_MANAGERS, assertPnpmVersion, globalManagerLayout, installConsumer, isolatedEnvironment, PNPM_VERSION, resolveArtifactDirectory, verifyInstalledConsumer, verifyTarballs } from "./artifact-installation.mjs"; test("artifact directories stay within trusted roots and reject symlink components", () => { const root = mkdtempSync(join(tmpdir(), "diffex-artifact-paths-")); @@ -32,18 +36,98 @@ test("isolated installation environment copies literal non-secret allowlisted fi process.env.OPENAI_API_KEY = "synthetic-private-openai-value"; process.env.DAYTONA_API_KEY = "synthetic-private-daytona-value"; process.env.NODE_OPTIONS = "synthetic-private-node-options"; + process.env.NPM_TOKEN = "synthetic-private-npm-token"; + process.env.npm_config_userconfig = "/private/credential.npmrc"; + process.env.npm_config_store_dir = "/private/warm-store"; + process.env.PNPM_HOME = "/private/pnpm-home"; + process.env.XDG_DATA_HOME = "/private/data"; + process.env.COREPACK_NPM_TOKEN = "synthetic-private-corepack-token"; + process.env.npm_config_registry = "https://private.invalid"; const environment = isolatedEnvironment(root); assert.equal(environment.PATH, original.PATH); assert.equal(environment.OPENAI_API_KEY, undefined); assert.equal(environment.DAYTONA_API_KEY, undefined); assert.equal(environment.NODE_OPTIONS, undefined); - assert.equal(environment.HOME, join(root, "home")); + assert.equal(environment.HOME, join(realpathSync(root), "home")); + assert.equal(environment.NPM_TOKEN, undefined); + assert.equal(environment.COREPACK_NPM_TOKEN, undefined); + assert.equal(environment.XDG_DATA_HOME, undefined); + assert.equal(environment.PNPM_HOME, undefined); + const pnpm = isolatedEnvironment(root, "pnpm"); + assert.equal(pnpm.PNPM_HOME, join(realpathSync(root), "pnpm/bin")); + assert.equal(pnpm.npm_config_store_dir, join(realpathSync(root), "cache/pnpm")); + assert.equal(pnpm.npm_config_registry, "https://registry.npmjs.org"); + assert.equal(readFileSync(pnpm.npm_config_userconfig, "utf8"), ""); + assert.equal(readFileSync(pnpm.npm_config_globalconfig, "utf8"), ""); + assert(!existsSync(pnpm.npm_config_store_dir)); + assert.throws(() => assertPnpmVersion(root, { ...pnpm, PATH: root }), /npm install --global --ignore-scripts pnpm@10\.28\.2/); } finally { process.env = original; rmSync(root, { recursive: true, force: true }); } }); +test("real pnpm installs dependency-first local tarballs with private-scope registry blocked and inventories checked", { timeout: 30000 }, () => { + const root = mkdtempSync(join(tmpdir(), "diffex-pnpm-consumer-")); + try { + assert.deepEqual(ACCEPTANCE_MANAGERS, ["npm", "pnpm", "bun"]); + const packages = [ + { name: "diffex-transitive-fixture", version: "1.2.3", main: "index.cjs" }, + { name: "@diffexai/dependency", version: "1.2.3" }, + { name: "@diffexai/diffex", version: "1.2.3", bin: { diffex: "dist/cli.js" }, dependencies: { + "@diffexai/dependency": "1.2.3", + "diffex-transitive-fixture": `file:${join(root, "diffex-transitive-fixture-1.2.3.tgz")}`, + } }, + ]; + const records = packages.map((pkg) => { + const stage = join(root, pkg.name.split("/").pop()); + mkdirSync(join(stage, "package"), { recursive: true }); + const files = { "package.json": JSON.stringify(pkg), "index.js": "export const fixture = true;\n", "index.cjs": "module.exports = { fixture: 'non-hoisted transitive dependency' };\n", "dist/cli.js": "#!/usr/bin/env node\nconsole.log('fixture');\n" }; + const inventory = { files: Object.entries(files).map(([path, bytes]) => ({ path, sha256: createHash("sha256").update(bytes).digest("hex") })) }; + for (const [path, bytes] of Object.entries({ ...files, "distribution-files.json": JSON.stringify(inventory) })) { + mkdirSync(dirname(join(stage, "package", path)), { recursive: true }); + writeFileSync(join(stage, "package", path), bytes, { mode: 0o755 }); + } + const filename = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`; + execFileSync("tar", ["-czf", join(root, filename), "-C", stage, "package"]); + return { ...pkg, filename, sha256: createHash("sha256").update(readFileSync(join(root, filename))).digest("hex") }; + }); + verifyTarballs(root, records, packages); + const consumer = join(root, "consumer"); + const selected = packages.filter((pkg) => pkg.name !== "diffex-transitive-fixture"); + const env = installConsumer(consumer, "pnpm", selected, records, root); + assert.equal(assertPnpmVersion(consumer, env), PNPM_VERSION); + const lexicalPackageRoot = join(consumer, "node_modules/@diffexai/diffex"); + const packageRoot = realpathSync(lexicalPackageRoot); + assert.match(packageRoot, /[\\/]\.pnpm[\\/]/); + assert(!existsSync(join(consumer, "node_modules/diffex-transitive-fixture")), "Transitive fixture must not be hoisted to the consumer root"); + assert.throws(() => createRequire(join(lexicalPackageRoot, "package.json"))("diffex-transitive-fixture"), { code: "MODULE_NOT_FOUND" }); + const cliRequire = createRequire(join(packageRoot, "package.json")); + assert.deepEqual(cliRequire("diffex-transitive-fixture"), { fixture: "non-hoisted transitive dependency" }); + const validator = join(root, "validate.py"); + writeFileSync(validator, [ + "import importlib.util, json, sys", + "spec = importlib.util.spec_from_file_location('verifier', sys.argv[1])", + "module = importlib.util.module_from_spec(spec)", + "spec.loader.exec_module(module)", + "executable, consumer = module.installed_executable(sys.argv[2])", + "print(json.dumps([executable, str(consumer)]))", + ].join("\n")); + const terminal = JSON.parse(execFileSync("python3", [validator, + fileURLToPath(new URL("./verify-candidate-artifacts.py", import.meta.url)), + join(consumer, "node_modules/.bin/diffex")], { encoding: "utf8", env, cwd: consumer })); + assert.deepEqual(terminal, [realpathSync(join(consumer, "node_modules/@diffexai/diffex/dist/cli.js")), realpathSync(consumer)]); + const layout = globalManagerLayout("pnpm", consumer, env); + assert.equal(layout.bin, env.PNPM_HOME); + assert.match(layout.modules, /[\\/]pnpm[\\/]global[\\/]5[\\/]node_modules$/); + writeFileSync(join(consumer, "node_modules/@diffexai/diffex/index.js"), "tampered"); + assert.throws(() => verifyInstalledConsumer(consumer, selected, records, root), /Installed bytes differ/); + assert.throws(() => globalManagerLayout("unknown", consumer, env), /Unsupported/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("package manager selection is a fixed allowlist and invalid package paths fail before execution", () => { const root = mkdtempSync(join(tmpdir(), "diffex-artifact-install-")); try { diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 52f1944b8..aeea0aa75 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -13,6 +13,19 @@ test("standard CI runs for pull requests targeting main or a stacked branch", () assert.deepEqual(workflow.on.push.branches, ["main"]); }); +test("script-test jobs provision exact pnpm before exercising real package-manager fixtures", () => { + const release = parse(readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8")); + for (const job of [workflow.jobs["workspace-tests"], release.jobs["prepare-artifacts"]]) { + const setup = job.steps.findIndex((step) => step.run === "npm install --global --ignore-scripts pnpm@10.28.2"); + const tests = job.steps.findIndex((step) => step.run?.includes("./test.sh")); + assert(setup !== -1 && tests > setup, "Pinned pnpm must be available before the real fixture tests"); + } + assert.equal(release.jobs["prepare-artifacts"].steps.find((step) => step.run === "npm install --global --ignore-scripts pnpm@10.28.2").if, + "steps.restore.outputs.restored != 'true'"); + const local = readFileSync(new URL("./local-release.mjs", import.meta.url), "utf8"); + assert(local.includes('options.skipBunInstall ? ["npm", "pnpm"] : ["npm", "pnpm", "bun"]')); +}); + test("the CI aggregate requires build checks and every isolated test shard", () => { const jobs = workflow.jobs; assert.deepEqual(jobs["build-check-test"].needs, ["build-check", "workspace-tests", "coding-agent"]); diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 9c62495ef..92f79587b 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -17,6 +17,7 @@ function printUsage() { Builds and packs the publishable packages, then installs the tarballs into an isolated directory outside the repository for local release testing. +Acceptance requires npm, pnpm@10.28.2, and Bun (unless --skip-bun-install). Options: --out Output directory. Defaults to a new directory under ${tmpdir()} @@ -193,7 +194,7 @@ for (const pkg of packages) { writeFileSync(join(outDir, "manifest.json"), `${JSON.stringify(records, null, 2)}\n`); if (!options.skipInstall) { - acceptArtifacts(outDir, join(outDir, "acceptance"), { managers: options.skipBunInstall ? ["npm"] : ["npm", "bun"] }); + acceptArtifacts(outDir, join(outDir, "acceptance"), { managers: options.skipBunInstall ? ["npm", "pnpm"] : ["npm", "pnpm", "bun"] }); // Standalone compilation is deliberately excluded by the approved policy. mkdirSync(nodeInstallDirectory, { recursive: true }); diff --git a/scripts/npm-lifecycle-registry.mjs b/scripts/npm-lifecycle-registry.mjs index 2c9057244..662b935fa 100644 --- a/scripts/npm-lifecycle-registry.mjs +++ b/scripts/npm-lifecycle-registry.mjs @@ -23,6 +23,7 @@ export function createLifecycleRegistry({ metadata, bytes, getMode, onArchive }) JSON.stringify({ name: metadata.name, "dist-tags": { latest: metadata.version }, + time: { [metadata.version]: "2020-01-01T00:00:00.000Z" }, versions: { [metadata.version]: pkg }, }), ); diff --git a/scripts/npm-lifecycle-registry.test.mjs b/scripts/npm-lifecycle-registry.test.mjs index 6577a2e6f..cdab07fdb 100644 --- a/scripts/npm-lifecycle-registry.test.mjs +++ b/scripts/npm-lifecycle-registry.test.mjs @@ -55,6 +55,7 @@ test("metadata and exact archive bytes use closed, distinct sockets", { timeout: assert.equal(manifest.headers["content-type"], "application/json"); const parsed = JSON.parse(manifest.body); assert.deepEqual(parsed["dist-tags"], { latest: metadata.version }); + assert.deepEqual(parsed.time, { [metadata.version]: "2020-01-01T00:00:00.000Z" }); assert.equal(parsed.name, metadata.name); const pkg = parsed.versions[metadata.version]; assert.deepEqual(pkg, { ...metadata, dist: { tarball: `${url}/diffex.tgz`, integrity } }); diff --git a/scripts/npm-lifecycle.mjs b/scripts/npm-lifecycle.mjs index 5620cac59..e8bab44b5 100644 --- a/scripts/npm-lifecycle.mjs +++ b/scripts/npm-lifecycle.mjs @@ -17,316 +17,462 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import { isolatedEnvironment } from "./artifact-acceptance.mjs"; +import { assertPnpmVersion, globalManagerLayout, isolatedEnvironment } from "./artifact-installation.mjs"; +import { verifyInstalledFiles } from "./public-release-acceptance.mjs"; import { createLifecycleRegistry } from "./npm-lifecycle-registry.mjs"; import { findNpmRecoveryDirectory } from "./npm-recovery.mjs"; const run = promisify(execFile); const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const [archiveArgument, evidenceArgument] = process.argv.slice(2); -assert(archiveArgument && evidenceArgument, "Usage: npm-lifecycle.mjs REVIEWED_TARBALL EVIDENCE_DIRECTORY"); -const archive = resolve(archiveArgument); -const output = resolve(evidenceArgument); -const bytes = readFileSync(archive); -const metadata = JSON.parse((await run("tar", ["-xOf", archive, "package/package.json"])).stdout); -assert.equal(metadata.name, "@diffexai/diffex"); -await run( - "python3", - [ - join(root, "scripts/check-distribution-payload.py"), - archive, - "--license", - join(root, "LICENSE"), - "--name", - metadata.name, - "--version", - metadata.version, - ], - { maxBuffer: 20 * 1024 * 1024 }, -); -const inventory = JSON.parse( - (await run("tar", ["-xOf", archive, "package/distribution-files.json"], { maxBuffer: 10 * 1024 * 1024 })).stdout, -); -function verifyInstalled(directory) { - assert.deepEqual(JSON.parse(readFileSync(join(directory, "distribution-files.json"), "utf8")), inventory); - for (const file of inventory.files) - assert.equal( - createHash("sha256") - .update(readFileSync(join(directory, file.path))) - .digest("hex"), - file.sha256, - `Installed runtime bytes changed: ${file.path}`, - ); -} -const temporary = mkdtempSync(join(tmpdir(), "diffex npm lifecycle ")); -const env = isolatedEnvironment(temporary); -delete env.DIFFEX_OFFLINE; -env.npm_config_fetch_retries = "0"; -env.npm_config_audit = "false"; -env.npm_config_fund = "false"; -const prefix = env.npm_config_prefix; -env.PATH = `${join(prefix, "bin")}:${env.PATH}`; -const packageRoot = join(prefix, "lib/node_modules/@diffexai/diffex"); -const executable = join(prefix, "bin/diffex"); -const options = { cwd: join(temporary, "project"), env, timeout: 180000, maxBuffer: 10 * 1024 * 1024 }; -const events = join(temporary, "events.jsonl"); -const response = join(temporary, "response.json"); -env.DIFFEX_LIFECYCLE_EVENTS = events; -env.DIFFEX_LIFECYCLE_RESPONSE = response; -const evidence = { - sourceCommit: "", - platform: process.platform, - arch: process.arch, - node: process.version, - version: metadata.version, - sha256: createHash("sha256").update(bytes).digest("hex"), - commands: [], - checks: [], - status: "failed", - cleanup: false, - limitation: - "Real npm installs reviewed bytes; discovery and scoped registry use controlled fixtures. The predecessor changes only isolated installed version metadata. No public-to-public upgrade or real provider inference is claimed.", -}; -let mode = "valid"; -let interrupt; -let tarballRequests = 0; -const server = createLifecycleRegistry({ - metadata, - bytes, - getMode: () => mode, - onArchive: (archiveMode) => { - tarballRequests++; - if (archiveMode === "interrupt") interrupt?.(); - }, -}); -await new Promise((done) => server.listen(0, "127.0.0.1", done)); - -async function command(command, args, extra = {}) { - try { - console.log(`Lifecycle: ${command} ${args.join(" ")}`); - const packageManagerOperation = - (["npm", "bun"].includes(command) && args.includes("install")) || args.includes("--self"); - const result = await run(command, args, { - ...options, - timeout: packageManagerOperation ? 600_000 : options.timeout, - ...extra, - }); - evidence.commands.push({ command, args, exitCode: 0 }); - return result.stdout; - } catch (error) { - evidence.commands.push({ command, args, exitCode: error.code, signal: error.signal }); - throw error; - } -} -function snapshot(directory) { - const result = {}; - function visit(current, local = "") { - for (const entry of readdirSync(current, { withFileTypes: true })) { - const file = join(current, entry.name); - const key = `${local}${entry.name}`; - if (entry.isDirectory()) visit(file, `${key}/`); - else - result[key] = entry.isSymbolicLink() - ? readlinkSync(file) - : createHash("sha256").update(readFileSync(file)).digest("hex"); - } - } - visit(directory); - return createHash("sha256") - .update(JSON.stringify(Object.entries(result).sort(([a], [b]) => a.localeCompare(b)))) - .digest("hex"); -} -function fixture(value) { - writeFileSync(response, JSON.stringify(value)); - writeFileSync(events, ""); -} -const valid = { status: 200, body: JSON.stringify({ packageName: metadata.name, version: metadata.version }) }; -const guard = join(temporary, "guard.mjs"); -const update = (force = false) => - command(process.execPath, ["--import", guard, executable, "update", "--self", ...(force ? ["--force"] : [])]); -try { - writeFileSync(env.npm_config_userconfig, `@diffexai:registry=http://127.0.0.1:${server.address().port}/\n`); - evidence.npm = (await command("npm", ["--version"])).trim(); - await command("npm", ["install", "-g", "--ignore-scripts", "--registry=https://registry.npmjs.org", archive]); - verifyInstalled(packageRoot); - evidence.sourceCommit = JSON.parse(readFileSync(join(packageRoot, "distribution-files.json"), "utf8")).sourceCommit; - assert.equal((await command("diffex", ["--version"])).trim(), metadata.version); - assert((await command("diffex", ["--help"])).includes("diffex")); - await command("diffex", ["--list-models"], { env: { ...env, DIFFEX_OFFLINE: "1" } }); - writeFileSync( - guard, - `import ${JSON.stringify(pathToFileURL(join(packageRoot, "dist/core/http-dispatcher.js")).href)};\n${readFileSync(join(root, "scripts/fixtures/installed-update-guard.mjs"), "utf8")}`, +export async function acceptLifecycle(archiveArgument, evidenceArgument, { skipBun = false, skipPnpm = false } = {}) { + const archive = resolve(archiveArgument); + const output = resolve(evidenceArgument); + const bytes = readFileSync(archive); + const metadata = JSON.parse((await run("tar", ["-xOf", archive, "package/package.json"])).stdout); + assert.equal(metadata.name, "@diffexai/diffex"); + await run( + "python3", + [ + join(root, "scripts/check-distribution-payload.py"), + archive, + "--license", + join(root, "LICENSE"), + "--name", + metadata.name, + "--version", + metadata.version, + ], + { maxBuffer: 20 * 1024 * 1024 }, ); - const retained = join(env.DIFFEX_CODING_AGENT_DIR, "lifecycle-retained.json"); - writeFileSync(retained, '{"retained":"session and settings fixture"}\n'); - const projectData = join(options.cwd, ".diffex"); - mkdirSync(projectData); - writeFileSync(join(projectData, "retained.txt"), "project data"); - const before = snapshot(prefix); - for (const invalid of [ - { status: 200, body: "{" }, - { status: 200, body: JSON.stringify({ packageName: "@foreign/cli", version: metadata.version }) }, - { status: 200, body: JSON.stringify({ packageName: metadata.name, version: "1.2.3-beta.1" }) }, - { status: 302, body: "{}", headers: { location: "https://foreign.invalid" } }, - { status: 503, body: "{}" }, - { status: 404, body: '{"error":"no_published_release"}' }, - ]) { - fixture(invalid); - await assert.rejects(update(true)); - assert.deepEqual(snapshot(prefix), before); - const recorded = readFileSync(events, "utf8"); - assert(!recorded.includes('"type":"process"')); - evidence.checks.push({ invalid, installationUnchanged: true }); + const inventory = JSON.parse( + (await run("tar", ["-xOf", archive, "package/distribution-files.json"], { maxBuffer: 10 * 1024 * 1024 })).stdout, + ); + function verifyInstalled(directory) { + assert.deepEqual(JSON.parse(readFileSync(join(directory, "distribution-files.json"), "utf8")), inventory); + for (const file of inventory.files) + assert.equal( + createHash("sha256") + .update(readFileSync(join(directory, file.path))) + .digest("hex"), + file.sha256, + `Installed runtime bytes changed: ${file.path}`, + ); } - fixture(valid); - await update(); - assert.deepEqual(snapshot(prefix), before); - assert(!readFileSync(events, "utf8").includes('"type":"process"')); - // Simulate an older installed client without publishing or changing reviewed candidate bytes. - writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ ...metadata, version: "0.0.0" })); - env.npm_config_cache = join(temporary, "cache-upgrade"); - await update(); - assert.equal((await command("diffex", ["--version"])).trim(), metadata.version); - assert(readFileSync(events, "utf8").includes(`${metadata.name}@${metadata.version}`)); - assert(tarballRequests > 0, "Real npm must download the target tarball"); - evidence.checks.push({ upgrade: "0.0.0 metadata fixture to reviewed candidate", realNpm: true }); - for (const fault of ["corrupt", "interrupt"]) { - mode = fault; - env.npm_config_cache = join(temporary, `cache-${fault}`); - fixture(valid); - const stable = snapshot(prefix); - if (fault === "corrupt") await assert.rejects(update(true), /integrity|checksum|EINTEGRITY/i); - else { - const child = spawn(process.execPath, ["--import", guard, executable, "update", "--self", "--force"], { + const temporary = mkdtempSync(join(tmpdir(), "diffex npm lifecycle ")); + const env = isolatedEnvironment(temporary); + delete env.DIFFEX_OFFLINE; + env.npm_config_fetch_retries = "0"; + env.npm_config_audit = "false"; + env.npm_config_fund = "false"; + const prefix = env.npm_config_prefix; + env.PATH = `${join(prefix, "bin")}:${env.PATH}`; + const packageRoot = join(prefix, "lib/node_modules/@diffexai/diffex"); + const executable = join(prefix, "bin/diffex"); + const options = { cwd: join(temporary, "project"), env, timeout: 180000, maxBuffer: 10 * 1024 * 1024 }; + const events = join(temporary, "events.jsonl"); + const response = join(temporary, "response.json"); + env.DIFFEX_LIFECYCLE_EVENTS = events; + env.DIFFEX_LIFECYCLE_RESPONSE = response; + const evidence = { + sourceCommit: "", + platform: process.platform, + arch: process.arch, + node: process.version, + version: metadata.version, + sha256: createHash("sha256").update(bytes).digest("hex"), + commands: [], + checks: [], + status: "failed", + cleanup: false, + limitation: + "Real npm installs reviewed bytes; discovery and scoped registry use controlled fixtures. The predecessor changes only isolated installed version metadata. No public-to-public upgrade or real provider inference is claimed.", + }; + let mode = "valid"; + let interrupt; + let tarballRequests = 0; + const server = createLifecycleRegistry({ + metadata, + bytes, + getMode: () => mode, + onArchive: (archiveMode) => { + tarballRequests++; + if (archiveMode === "interrupt") interrupt?.(); + }, + }); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + + async function command(command, args, extra = {}) { + try { + console.log(`Lifecycle: ${command} ${args.join(" ")}`); + const packageManagerOperation = + (["npm", "bun"].includes(command) && args.includes("install")) || args.includes("--self"); + const result = await run(command, args, { ...options, - detached: true, - stdio: ["ignore", "pipe", "pipe"], + timeout: packageManagerOperation ? 600_000 : options.timeout, + ...extra, }); - // Keep draining both pipes, retaining only the last 64 KiB per stream. - const captured = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; - for (const name of ["stdout", "stderr"]) { - child[name].on("data", (chunk) => { - captured[name] = Buffer.concat([captured[name], chunk]).subarray(-64 * 1024); - }); - } - let interrupted = false; - interrupt = () => { - interrupted = true; - process.kill(-child.pid, "SIGTERM"); - }; - const deadline = setTimeout(() => process.kill(-child.pid, "SIGKILL"), 30000); - let result; - try { - result = await new Promise((done, reject) => { - child.on("close", (exitCode, signal) => done({ exitCode, signal })); - child.on("error", reject); - }); - } finally { - clearTimeout(deadline); - interrupt = undefined; - } - const diagnostic = { - command: "diffex update --self --force", - ...result, - boundary: "npm archive download", - interrupted, - stdout: captured.stdout.toString("utf8"), - stderr: captured.stderr.toString("utf8"), - }; - evidence.commands.push(diagnostic); - assert( - interrupted, - `Interrupted update must reach the real npm archive download; child exited before boundary: ${JSON.stringify(diagnostic)}`, - ); - } - const installationUnchanged = snapshot(prefix) === stable; - if (fault === "corrupt") assert(installationUnchanged, "Integrity failure mutated the prefix"); - mode = "valid"; - env.npm_config_cache = join(temporary, `cache-repair-${fault}`); - const repairArgs = [ - "install", - "-g", - "--ignore-scripts", - "--registry=https://registry.npmjs.org", - `${metadata.name}@${metadata.version}`, - ]; - let movedBackup = false; - try { - await command("npm", repairArgs); + evidence.commands.push({ command, args, exitCode: 0 }); + return result.stdout; } catch (error) { - if (fault !== "interrupt" || !error.stderr?.includes("ENOTEMPTY")) throw error; - const backup = findNpmRecoveryDirectory(dirname(packageRoot), error.stderr); - renameSync(backup, join(temporary, "retained-npm-recovery-backup")); - movedBackup = true; - await command("npm", repairArgs); + evidence.commands.push({ command, args, exitCode: error.code, signal: error.signal }); + throw error; } - assert.equal((await command("diffex", ["--version"])).trim(), metadata.version); - verifyInstalled(packageRoot); - evidence.checks.push({ fault, installationUnchanged, movedBackup, pinnedRepair: "passed" }); } - fixture(valid); - await update(true); - await command("npm", ["uninstall", "-g", "--ignore-scripts", metadata.name]); - assert(!existsSync(executable)); - assert(!existsSync(packageRoot)); - assert.equal(readFileSync(retained, "utf8"), '{"retained":"session and settings fixture"}\n'); - assert.equal(readFileSync(join(projectData, "retained.txt"), "utf8"), "project data"); - evidence.checks.push({ uninstall: "passed", userDataRetained: true, projectDataRetained: true }); - if (!process.argv.includes("--skip-bun")) { - env.BUN_INSTALL = join(temporary, "bun"); - env.BUN_INSTALL_BIN = join(env.BUN_INSTALL, "bin"); - env.BUN_INSTALL_GLOBAL_DIR = join(env.BUN_INSTALL, "install/global"); - env.BUN_INSTALL_CACHE_DIR = join(temporary, "bun-cache"); - env.PATH = `${env.BUN_INSTALL_BIN}:${env.PATH}`; - writeFileSync( - join(env.XDG_CONFIG_HOME, ".bunfig.toml"), - `[install.scopes]\ndiffexai = "http://127.0.0.1:${server.address().port}/"\n`, - ); - evidence.bun = (await command("bun", ["--version"])).trim(); - await command("bun", ["install", "-g", "--ignore-scripts", archive]); - assert.equal(realpathSync((await command("bun", ["pm", "bin", "-g"])).trim()), realpathSync(env.BUN_INSTALL_BIN)); - const bunPackage = join(env.BUN_INSTALL_GLOBAL_DIR, "node_modules/@diffexai/diffex"); - const bunExecutable = join(env.BUN_INSTALL_BIN, "diffex"); - verifyInstalled(bunPackage); + function snapshot(directory) { + const result = {}; + function visit(current, local = "") { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const file = join(current, entry.name); + const key = `${local}${entry.name}`; + if (entry.isDirectory()) visit(file, `${key}/`); + else + result[key] = entry.isSymbolicLink() + ? readlinkSync(file) + : createHash("sha256").update(readFileSync(file)).digest("hex"); + } + } + visit(directory); + return createHash("sha256") + .update(JSON.stringify(Object.entries(result).sort(([a], [b]) => a.localeCompare(b)))) + .digest("hex"); + } + function fixture(value) { + writeFileSync(response, JSON.stringify(value)); + writeFileSync(events, ""); + } + const valid = { status: 200, body: JSON.stringify({ packageName: metadata.name, version: metadata.version }) }; + const guard = join(temporary, "guard.mjs"); + const update = (force = false) => + command(process.execPath, ["--import", guard, executable, "update", "--self", ...(force ? ["--force"] : [])]); + try { + writeFileSync(env.npm_config_userconfig, `@diffexai:registry=http://127.0.0.1:${server.address().port}/\n`); + evidence.npm = (await command("npm", ["--version"])).trim(); + await command("npm", ["install", "-g", "--ignore-scripts", "--registry=https://registry.npmjs.org", archive]); + verifyInstalled(packageRoot); + evidence.sourceCommit = JSON.parse(readFileSync(join(packageRoot, "distribution-files.json"), "utf8")).sourceCommit; assert.equal((await command("diffex", ["--version"])).trim(), metadata.version); assert((await command("diffex", ["--help"])).includes("diffex")); + await command("diffex", ["--list-models"], { env: { ...env, DIFFEX_OFFLINE: "1" } }); writeFileSync( guard, - `import ${JSON.stringify(pathToFileURL(join(bunPackage, "dist/core/http-dispatcher.js")).href)};\n${readFileSync(join(root, "scripts/fixtures/installed-update-guard.mjs"), "utf8")}`, - ); - fixture({ status: 200, body: JSON.stringify({ packageName: "@foreign/cli", version: metadata.version }) }); - const stable = snapshot(env.BUN_INSTALL_GLOBAL_DIR); - await assert.rejects( - command(process.execPath, ["--import", guard, bunExecutable, "update", "--self", "--force"]), + `import ${JSON.stringify(pathToFileURL(join(packageRoot, "dist/core/http-dispatcher.js")).href)};\n${readFileSync(join(root, "scripts/fixtures/installed-update-guard.mjs"), "utf8")}`, ); - assert.equal(snapshot(env.BUN_INSTALL_GLOBAL_DIR), stable); + const retained = join(env.DIFFEX_CODING_AGENT_DIR, "lifecycle-retained.json"); + writeFileSync(retained, '{"retained":"session and settings fixture"}\n'); + const projectData = join(options.cwd, ".diffex"); + mkdirSync(projectData); + writeFileSync(join(projectData, "retained.txt"), "project data"); + const before = snapshot(prefix); + for (const invalid of [ + { status: 200, body: "{" }, + { status: 200, body: JSON.stringify({ packageName: "@foreign/cli", version: metadata.version }) }, + { status: 200, body: JSON.stringify({ packageName: metadata.name, version: "1.2.3-beta.1" }) }, + { status: 302, body: "{}", headers: { location: "https://foreign.invalid" } }, + { status: 503, body: "{}" }, + { status: 404, body: '{"error":"no_published_release"}' }, + ]) { + fixture(invalid); + await assert.rejects(update(true)); + assert.deepEqual(snapshot(prefix), before); + const recorded = readFileSync(events, "utf8"); + assert(!recorded.includes('"type":"process"')); + evidence.checks.push({ invalid, installationUnchanged: true }); + } fixture(valid); - writeFileSync(join(bunPackage, "package.json"), JSON.stringify({ ...metadata, version: "0.0.0" })); - await command(process.execPath, ["--import", guard, bunExecutable, "update", "--self"]); + await update(); + assert.deepEqual(snapshot(prefix), before); + assert(!readFileSync(events, "utf8").includes('"type":"process"')); + // Simulate an older installed client without publishing or changing reviewed candidate bytes. + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ ...metadata, version: "0.0.0" })); + env.npm_config_cache = join(temporary, "cache-upgrade"); + await update(); assert.equal((await command("diffex", ["--version"])).trim(), metadata.version); - verifyInstalled(bunPackage); - assert(readFileSync(events, "utf8").includes('"command":"bun"')); - await command("bun", ["uninstall", "-g", metadata.name]); - assert(!existsSync(bunExecutable)); - assert(!existsSync(bunPackage)); + assert(readFileSync(events, "utf8").includes(`${metadata.name}@${metadata.version}`)); + assert(tarballRequests > 0, "Real npm must download the target tarball"); + evidence.checks.push({ upgrade: "0.0.0 metadata fixture to reviewed candidate", realNpm: true }); + for (const fault of ["corrupt", "interrupt"]) { + mode = fault; + env.npm_config_cache = join(temporary, `cache-${fault}`); + fixture(valid); + const stable = snapshot(prefix); + if (fault === "corrupt") await assert.rejects(update(true), /integrity|checksum|EINTEGRITY/i); + else { + const child = spawn(process.execPath, ["--import", guard, executable, "update", "--self", "--force"], { + ...options, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + // Keep draining both pipes, retaining only the last 64 KiB per stream. + const captured = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + for (const name of ["stdout", "stderr"]) { + child[name].on("data", (chunk) => { + captured[name] = Buffer.concat([captured[name], chunk]).subarray(-64 * 1024); + }); + } + let interrupted = false; + interrupt = () => { + interrupted = true; + process.kill(-child.pid, "SIGTERM"); + }; + const deadline = setTimeout(() => process.kill(-child.pid, "SIGKILL"), 30000); + let result; + try { + result = await new Promise((done, reject) => { + child.on("close", (exitCode, signal) => done({ exitCode, signal })); + child.on("error", reject); + }); + } finally { + clearTimeout(deadline); + interrupt = undefined; + } + const diagnostic = { + command: "diffex update --self --force", + ...result, + boundary: "npm archive download", + interrupted, + stdout: captured.stdout.toString("utf8"), + stderr: captured.stderr.toString("utf8"), + }; + evidence.commands.push(diagnostic); + assert( + interrupted, + `Interrupted update must reach the real npm archive download; child exited before boundary: ${JSON.stringify(diagnostic)}`, + ); + } + const installationUnchanged = snapshot(prefix) === stable; + if (fault === "corrupt") assert(installationUnchanged, "Integrity failure mutated the prefix"); + mode = "valid"; + env.npm_config_cache = join(temporary, `cache-repair-${fault}`); + const repairArgs = [ + "install", + "-g", + "--ignore-scripts", + "--registry=https://registry.npmjs.org", + `${metadata.name}@${metadata.version}`, + ]; + let movedBackup = false; + try { + await command("npm", repairArgs); + } catch (error) { + if (fault !== "interrupt" || !error.stderr?.includes("ENOTEMPTY")) throw error; + const backup = findNpmRecoveryDirectory(dirname(packageRoot), error.stderr); + renameSync(backup, join(temporary, "retained-npm-recovery-backup")); + movedBackup = true; + await command("npm", repairArgs); + } + assert.equal((await command("diffex", ["--version"])).trim(), metadata.version); + verifyInstalled(packageRoot); + evidence.checks.push({ fault, installationUnchanged, movedBackup, pinnedRepair: "passed" }); + } + fixture(valid); + await update(true); + await command("npm", ["uninstall", "-g", "--ignore-scripts", metadata.name]); + assert(!existsSync(executable)); + assert(!existsSync(packageRoot)); assert.equal(readFileSync(retained, "utf8"), '{"retained":"session and settings fixture"}\n'); assert.equal(readFileSync(join(projectData, "retained.txt"), "utf8"), "project data"); - evidence.checks.push({ - manager: "bun", - install: "passed", - upgrade: "metadata fixture to reviewed candidate", - foreignIdentityRejected: true, - uninstall: "passed", - userDataRetained: true, + evidence.checks.push({ uninstall: "passed", userDataRetained: true, projectDataRetained: true }); + if (!skipBun) { + env.BUN_INSTALL = join(temporary, "bun"); + env.BUN_INSTALL_BIN = join(env.BUN_INSTALL, "bin"); + env.BUN_INSTALL_GLOBAL_DIR = join(env.BUN_INSTALL, "install/global"); + env.BUN_INSTALL_CACHE_DIR = join(temporary, "bun-cache"); + env.PATH = `${env.BUN_INSTALL_BIN}:${env.PATH}`; + writeFileSync( + join(env.XDG_CONFIG_HOME, ".bunfig.toml"), + `[install.scopes]\ndiffexai = "http://127.0.0.1:${server.address().port}/"\n`, + ); + evidence.bun = (await command("bun", ["--version"])).trim(); + await command("bun", ["install", "-g", "--ignore-scripts", archive]); + assert.equal(realpathSync((await command("bun", ["pm", "bin", "-g"])).trim()), realpathSync(env.BUN_INSTALL_BIN)); + const bunPackage = join(env.BUN_INSTALL_GLOBAL_DIR, "node_modules/@diffexai/diffex"); + const bunExecutable = join(env.BUN_INSTALL_BIN, "diffex"); + verifyInstalled(bunPackage); + assert.equal((await command("diffex", ["--version"])).trim(), metadata.version); + assert((await command("diffex", ["--help"])).includes("diffex")); + writeFileSync( + guard, + `import ${JSON.stringify(pathToFileURL(join(bunPackage, "dist/core/http-dispatcher.js")).href)};\n${readFileSync(join(root, "scripts/fixtures/installed-update-guard.mjs"), "utf8")}`, + ); + fixture({ status: 200, body: JSON.stringify({ packageName: "@foreign/cli", version: metadata.version }) }); + const stable = snapshot(env.BUN_INSTALL_GLOBAL_DIR); + await assert.rejects( + command(process.execPath, ["--import", guard, bunExecutable, "update", "--self", "--force"]), + ); + assert.equal(snapshot(env.BUN_INSTALL_GLOBAL_DIR), stable); + fixture(valid); + writeFileSync(join(bunPackage, "package.json"), JSON.stringify({ ...metadata, version: "0.0.0" })); + await command(process.execPath, ["--import", guard, bunExecutable, "update", "--self"]); + assert.equal((await command("diffex", ["--version"])).trim(), metadata.version); + verifyInstalled(bunPackage); + assert(readFileSync(events, "utf8").includes('"command":"bun"')); + await command("bun", ["uninstall", "-g", metadata.name]); + assert(!existsSync(bunExecutable)); + assert(!existsSync(bunPackage)); + assert.equal(readFileSync(retained, "utf8"), '{"retained":"session and settings fixture"}\n'); + assert.equal(readFileSync(join(projectData, "retained.txt"), "utf8"), "project data"); + evidence.checks.push({ + manager: "bun", + install: "passed", + upgrade: "metadata fixture to reviewed candidate", + foreignIdentityRejected: true, + uninstall: "passed", + userDataRetained: true, + }); + } + if (!skipPnpm) { + try { + evidence.pnpm = await acceptPnpmLifecycle(archive, metadata, inventory); + } catch (error) { + evidence.pnpm = error.pnpmEvidence; + throw error; + } + } + evidence.status = "passed"; + } finally { + server.closeAllConnections(); + await new Promise((done) => server.close(done)); + rmSync(temporary, { recursive: true, force: true }); + evidence.cleanup = !existsSync(temporary); + mkdirSync(output, { recursive: true }); + writeFileSync(join(output, "npm-lifecycle.json"), `${JSON.stringify(evidence, null, 2)}\n`); + } + console.log(`npm lifecycle ${evidence.status}: ${metadata.version}`); + return evidence; +} + +// Real pnpm processes and archive bytes; only release discovery and the scoped registry are fixtures. +export async function acceptPnpmLifecycle(archive, metadata, inventory) { + const temporary = mkdtempSync(join(tmpdir(), "diffex pnpm lifecycle ")); + const env = isolatedEnvironment(temporary, "pnpm"); + delete env.DIFFEX_OFFLINE; + env.PATH = `${env.PNPM_HOME}:${env.PATH}`; + env.npm_config_fetch_retries = "0"; + const options = { cwd: join(temporary, "project"), env, timeout: 180000, maxBuffer: 10 * 1024 * 1024 }; + const evidence = { manager: "pnpm", commands: [], checks: [], status: "failed", cleanup: false }; + const bytes = readFileSync(archive); + let mode = "valid"; + let tarballRequests = 0; + const server = createLifecycleRegistry({ metadata, bytes, getMode: () => mode, onArchive: () => tarballRequests++ }); + let failure; + const command = async (name, args) => { + const entry = { manager: "pnpm", command: name, args, exitCode: null }; + evidence.commands.push(entry); + try { + const result = await run(name, args, options); + entry.exitCode = 0; + return result.stdout.trim(); + } catch (error) { + entry.exitCode = error.code; + entry.signal = error.signal; + entry.stdout = String(error.stdout ?? "").slice(-65536); + entry.stderr = String(error.stderr ?? "").slice(-65536); + throw error; + } + }; + try { + await new Promise((done, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", done); }); + evidence.version = assertPnpmVersion(options.cwd, env); + writeFileSync(env.npm_config_userconfig, `@diffexai:registry=http://127.0.0.1:${server.address().port}/\n`); + const layout = globalManagerLayout("pnpm", temporary, env); + const retained = join(env.DIFFEX_CODING_AGENT_DIR, "retained.txt"); + const projectData = join(options.cwd, ".diffex", "retained.txt"); + mkdirSync(dirname(projectData)); + writeFileSync(retained, "retain user data"); + writeFileSync(projectData, "retain project data"); + const store = (phase) => { + // Keep the store identity in the global lockfile, but remove its contents and metadata cache. + rmSync(env.npm_config_store_dir, { recursive: true, force: true }); + rmSync(join(env.XDG_CACHE_HOME, "pnpm"), { recursive: true, force: true }); + assert(!existsSync(env.npm_config_store_dir), `Expected empty pnpm store for ${phase}`); + evidence.checks.push({ phase, emptyStore: true }); + }; + const verify = async () => { + assert.equal(await command(layout.executable, ["--version"]), metadata.version); + assert.match(await command(layout.executable, ["--help"]), /diffex/i); + verifyInstalledFiles(layout.packageRoot, inventory); + }; + const remove = async () => { + await command("pnpm", ["remove", "--global", metadata.name]); + assert(!existsSync(layout.executable) && !existsSync(layout.packageRoot)); + assert.equal(readFileSync(retained, "utf8"), "retain user data"); + assert.equal(readFileSync(projectData, "utf8"), "retain project data"); + }; + store("install"); + await command("pnpm", ["add", "--global", "--ignore-scripts", archive]); + await verify(); + const guard = join(temporary, "guard.mjs"); + env.DIFFEX_LIFECYCLE_EVENTS = join(temporary, "events.jsonl"); + env.DIFFEX_LIFECYCLE_RESPONSE = join(temporary, "response.json"); + const fixture = (body) => { + writeFileSync(env.DIFFEX_LIFECYCLE_RESPONSE, JSON.stringify({ status: 200, body: JSON.stringify(body) })); + writeFileSync(env.DIFFEX_LIFECYCLE_EVENTS, ""); + writeFileSync(guard, `import ${JSON.stringify(pathToFileURL(join(layout.packageRoot, "dist/core/http-dispatcher.js")).href)};\n${readFileSync(join(root, "scripts/fixtures/installed-update-guard.mjs"), "utf8")}`); + }; + // pnpm's global bin is a shell shim, not a JavaScript symlink. + const cliEntrypoint = join(layout.packageRoot, metadata.bin.diffex); + const update = () => command(process.execPath, ["--import", guard, cliEntrypoint, "update", "--self", "--force"]); + fixture({ packageName: "@foreign/cli", version: metadata.version }); + await assert.rejects(update()); + const rejectedEvents = readFileSync(env.DIFFEX_LIFECYCLE_EVENTS, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(rejectedEvents, [{ type: "discovery", url: "https://api.diffex.ai/api/latest-version", redirect: "manual" }]); + verifyInstalledFiles(layout.packageRoot, inventory); + evidence.checks.push({ foreignIdentityRejected: true, installationUnchanged: true }); + // Atomic replacement must not mutate pnpm's hard-linked content-addressed store. + const versionFixture = join(layout.packageRoot, "package.json.fixture"); + writeFileSync(versionFixture, JSON.stringify({ ...metadata, version: "0.0.0" })); + renameSync(versionFixture, join(layout.packageRoot, "package.json")); + store("upgrade"); + fixture({ packageName: metadata.name, version: metadata.version }); + await command(process.execPath, ["--import", guard, cliEntrypoint, "update", "--self"]); + await verify(); + assert(tarballRequests > 0, "pnpm must download reviewed target bytes using the empty upgrade store"); + assert(readFileSync(env.DIFFEX_LIFECYCLE_EVENTS, "utf8").includes('"command":"pnpm"'), "Updater did not select pnpm"); + evidence.checks.push({ upgrade: "metadata predecessor fixture", realPnpm: true, installedHashes: "passed" }); + mode = "corrupt"; + // Changing store-dir with an existing global lock needs a clean installation state. + await remove(); + store("corrupt"); + await assert.rejects(command("pnpm", ["add", "--global", "--ignore-scripts", `${metadata.name}@${metadata.version}`]), (error) => { + assert.match(`${error.stdout}\n${error.stderr}`, /ERR_PNPM_TARBALL_INTEGRITY|checksum/i); + return true; + }); + assert(!existsSync(layout.executable) && !existsSync(layout.packageRoot)); + evidence.checks.push({ corruptIntegrityRejected: true, executableAbsent: true }); + mode = "valid"; + store("repair"); + await command("pnpm", ["add", "--global", "--ignore-scripts", `${metadata.name}@${metadata.version}`]); + await verify(); + await remove(); + const requests = tarballRequests; + await command("pnpm", ["add", "--global", "--offline", "--ignore-scripts", `${metadata.name}@${metadata.version}`]); + await verify(); + assert.equal(tarballRequests, requests, "Offline reinstall must use the populated pnpm store"); + await remove(); + evidence.checks.push({ uninstall: "passed", userDataRetained: true, projectDataRetained: true, offlineCacheReuse: "passed" }); + evidence.tarballRequests = tarballRequests; + evidence.status = "passed"; + return evidence; + } catch (error) { + failure = error; + error.pnpmEvidence = evidence; + throw error; + } finally { + server.closeAllConnections(); + if (server.listening) await new Promise((done) => server.close(done)); + rmSync(temporary, { recursive: true, force: true }); + evidence.cleanup = !existsSync(temporary); + if (failure) failure.message += `\npnpm lifecycle diagnostics: ${JSON.stringify(evidence)}`; } - evidence.status = "passed"; -} finally { - server.closeAllConnections(); - await new Promise((done) => server.close(done)); - rmSync(temporary, { recursive: true, force: true }); - evidence.cleanup = !existsSync(temporary); - mkdirSync(output, { recursive: true }); - writeFileSync(join(output, "npm-lifecycle.json"), `${JSON.stringify(evidence, null, 2)}\n`); } -console.log(`npm lifecycle ${evidence.status}: ${metadata.version}`); + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [archive, output] = process.argv.slice(2); + assert(archive && output, "Usage: npm-lifecycle.mjs REVIEWED_TARBALL EVIDENCE_DIRECTORY"); + await acceptLifecycle(archive, output, { + skipBun: process.argv.includes("--skip-bun"), skipPnpm: process.argv.includes("--skip-pnpm"), + }); +} diff --git a/scripts/npm-lifecycle.test.mjs b/scripts/npm-lifecycle.test.mjs new file mode 100644 index 000000000..7f5511dc5 --- /dev/null +++ b/scripts/npm-lifecycle.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { acceptPnpmLifecycle } from "./npm-lifecycle.mjs"; + +// This fixture tests the acceptance harness, real pnpm, discovery injection and store behavior. +// It is not the production updater; retained candidate acceptance exercises that installed CLI. +const cli = `#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +const metadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +if (process.argv.includes("--version")) console.log(metadata.version); +else if (process.argv.includes("--help")) console.log("Diffex fixture"); +else if (process.argv.includes("update")) { + const response = await fetch("https://api.diffex.ai/api/latest-version", { redirect: "manual" }); + const release = await response.json(); + if (release.packageName !== metadata.name) throw new Error("Foreign release identity"); + const child = spawn("pnpm", ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", release.packageName + "@" + release.version], { stdio: "inherit", env: process.env }); + process.exitCode = await new Promise((done, reject) => { child.on("close", done); child.on("error", reject); }); +} else throw new Error("Unexpected fixture command"); +`; + +function fixture(directory, failing = false) { + const metadata = { name: "@diffexai/diffex", version: "1.2.3", type: "module", bin: { diffex: "dist/cli.js" } }; + const files = { "package.json": JSON.stringify(metadata), "dist/cli.js": failing ? "#!/usr/bin/env node\nprocess.exit(41);\n" : cli, "dist/core/http-dispatcher.js": "export {};\n" }; + const inventory = { sourceCommit: "a".repeat(40), files: Object.entries(files).map(([path, value]) => ({ path, sha256: createHash("sha256").update(value).digest("hex") })) }; + for (const [path, value] of Object.entries({ ...files, "distribution-files.json": JSON.stringify(inventory) })) { + mkdirSync(dirname(join(directory, "package", path)), { recursive: true }); + writeFileSync(join(directory, "package", path), value); + } + chmodSync(join(directory, "package/dist/cli.js"), 0o755); + const archive = join(directory, "fixture.tgz"); + execFileSync("tar", ["-czf", archive, "-C", directory, "package"]); + return { archive, metadata, inventory }; +} + +test("real pinned pnpm lifecycle uses isolated local tarballs, discovery fixtures, cold stores and offline cache reuse", { timeout: 180000 }, async () => { + const directory = mkdtempSync(join(tmpdir(), "diffex-pnpm-lifecycle-test-")); + try { + const { archive, metadata, inventory } = fixture(directory); + const evidence = await acceptPnpmLifecycle(archive, metadata, inventory); + assert.equal(evidence.status, "passed"); + assert.equal(evidence.cleanup, true); + assert.equal(evidence.version, "10.28.2"); + assert(evidence.checks.some((check) => check.offlineCacheReuse === "passed")); + assert(evidence.checks.some((check) => check.corruptIntegrityRejected)); + assert(evidence.tarballRequests >= 3); + assert.equal(createHash("sha256").update(readFileSync(archive)).digest("hex").length, 64); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("pnpm lifecycle failures carry bounded manager-specific diagnostics and cleaned isolation evidence", { timeout: 30000 }, async () => { + const directory = mkdtempSync(join(tmpdir(), "diffex-pnpm-failure-test-")); + try { + const { archive, metadata, inventory } = fixture(directory, true); + await assert.rejects(acceptPnpmLifecycle(archive, metadata, inventory), (error) => { + assert.equal(error.pnpmEvidence.status, "failed"); + assert.equal(error.pnpmEvidence.cleanup, true); + assert.equal(error.pnpmEvidence.manager, "pnpm"); + assert.equal(error.pnpmEvidence.commands.at(-1).exitCode, 41); + assert.match(error.message, /pnpm lifecycle diagnostics/); + return true; + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/public-release-acceptance.mjs b/scripts/public-release-acceptance.mjs index eaa19f153..03184acd8 100644 --- a/scripts/public-release-acceptance.mjs +++ b/scripts/public-release-acceptance.mjs @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { ACCEPTANCE_MANAGERS, assertPnpmVersion, globalManagerLayout, isolatedEnvironment } from "./artifact-installation.mjs"; import { verifyNpmArtifact } from "./release-registry.mjs"; const REGISTRY = "https://registry.npmjs.org"; @@ -121,41 +122,20 @@ export async function acceptPublicRelease(manifest, previous, output) { tarball: predecessorMetadata.dist.tarball, integrity: predecessorMetadata.dist.integrity, }; - for (const manager of ["npm", "bun"]) { + for (const manager of ACCEPTANCE_MANAGERS) { const directory = join(temporary, manager); - for (const name of ["home", "config", "cache", "prefix", "agent", "project/.diffex", "temp"]) - mkdirSync(join(directory, name), { recursive: true }); - const prefix = join(directory, "prefix"); - const bun = join(directory, "bun"); - const bin = manager === "npm" ? join(prefix, "bin") : join(bun, "bin"); - const packageRoot = - manager === "npm" - ? join(prefix, "lib/node_modules", PACKAGE) - : join(bun, "install/global/node_modules", PACKAGE); - const executable = join(bin, "diffex"); - const env = { - PATH: `${bin}:${process.env.PATH}`, - HOME: join(directory, "home"), - XDG_CONFIG_HOME: join(directory, "config"), - XDG_CACHE_HOME: join(directory, "cache"), - LANG: "en_US.UTF-8", - TERM: "xterm-256color", - TMPDIR: join(directory, "temp"), - npm_config_prefix: prefix, - npm_config_cache: join(directory, "cache/npm"), - npm_config_userconfig: join(directory, "empty.npmrc"), - npm_config_globalconfig: join(directory, "empty-global.npmrc"), - npm_config_registry: REGISTRY, - npm_config_update_notifier: "false", - npm_config_audit: "false", - npm_config_fund: "false", - BUN_INSTALL: bun, - BUN_INSTALL_BIN: bin, - BUN_INSTALL_GLOBAL_DIR: join(bun, "install/global"), - BUN_INSTALL_CACHE_DIR: join(directory, "cache/bun"), - DIFFEX_CODING_AGENT_DIR: join(directory, "agent"), - DIFFEX_NO_LOCAL_LLM: "1", - }; + const env = isolatedEnvironment(directory, manager); + delete env.DIFFEX_OFFLINE; + mkdirSync(join(directory, "project/.diffex"), { recursive: true }); + const bun = join(realpathSync(directory), "bun"); + env.BUN_INSTALL = bun; + env.BUN_INSTALL_BIN = join(bun, "bin"); + env.BUN_INSTALL_GLOBAL_DIR = join(bun, "install/global"); + if (manager === "pnpm") assertPnpmVersion(join(directory, "project"), env); + const { bin, packageRoot, executable } = globalManagerLayout(manager, directory, env); + env.PATH = `${bin}:${env.PATH}`; + env.npm_config_audit = "false"; + env.npm_config_fund = "false"; const retained = join(env.DIFFEX_CODING_AGENT_DIR, "retained.txt"); const projectData = join(directory, "project/.diffex/retained.txt"); writeFileSync(retained, "retain user data"); @@ -175,6 +155,9 @@ export async function acceptPublicRelease(manifest, previous, output) { return text.trim(); } catch (error) { item.exitCode = error.status; + item.signal = error.signal; + item.stdout = String(error.stdout ?? "").slice(-65536); + item.stderr = String(error.stderr ?? "").slice(-65536); throw error; } }; @@ -197,13 +180,18 @@ export async function acceptPublicRelease(manifest, previous, output) { // Empty manager caches prove the updater retrieves the new public artifact. env.npm_config_cache = join(directory, "cache/npm-upgrade"); env.BUN_INSTALL_CACHE_DIR = join(directory, "cache/bun-upgrade"); + if (manager === "pnpm") { + // Keep the configured store identity: pnpm rejects changing it under a live global installation. + rmSync(env.npm_config_store_dir, { recursive: true, force: true }); + assert(!existsSync(env.npm_config_store_dir)); + } const updateOutput = run(executable, ["update", "--self"]); assert(updateOutput.includes(pkg.version)); smoke(pkg.version); verifyInstalledFiles(packageRoot, inventory); const afterVersion = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version; const remove = () => { - run(manager, ["uninstall", "-g", ...(manager === "npm" ? ["--ignore-scripts"] : []), PACKAGE]); + run(manager, ["uninstall", "-g", ...(["npm", "pnpm"].includes(manager) ? ["--ignore-scripts"] : []), PACKAGE]); assert(!existsSync(executable) && !existsSync(packageRoot)); assert.equal(readFileSync(retained, "utf8"), "retain user data"); assert.equal(readFileSync(projectData, "utf8"), "retain project data"); @@ -211,6 +199,10 @@ export async function acceptPublicRelease(manifest, previous, output) { remove(); env.npm_config_cache = join(directory, "cache/npm-fresh"); env.BUN_INSTALL_CACHE_DIR = join(directory, "cache/bun-fresh"); + if (manager === "pnpm") { + rmSync(env.npm_config_store_dir, { recursive: true, force: true }); + assert(!existsSync(env.npm_config_store_dir)); + } install(pkg.version); smoke(pkg.version); verifyInstalledFiles(packageRoot, inventory); @@ -225,6 +217,8 @@ export async function acceptPublicRelease(manifest, previous, output) { uninstall: "passed", userDataRetained: true, projectDataRetained: true, + emptyUpgradeCache: true, + emptyFreshInstallCache: true, }); console.log(`Public ${manager} lifecycle passed: ${beforeVersion} -> ${afterVersion}`); } diff --git a/scripts/public-release-acceptance.test.mjs b/scripts/public-release-acceptance.test.mjs index 910fbf4e3..01e4a9e6e 100644 --- a/scripts/public-release-acceptance.test.mjs +++ b/scripts/public-release-acceptance.test.mjs @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { createHash } from "node:crypto"; +import { ACCEPTANCE_MANAGERS, PNPM_VERSION } from "./artifact-installation.mjs"; import { validateAcceptanceManifest, verifyInstalledFiles, @@ -25,6 +26,19 @@ const manifest = { ], }; +test("native and public acceptance provision the same exact pnpm release without dropping npm or Bun", () => { + assert.deepEqual(ACCEPTANCE_MANAGERS, ["npm", "pnpm", "bun"]); + assert.equal(PNPM_VERSION, "10.28.2"); + for (const name of ["native-artifact-acceptance.yml", "artifact-acceptance.yml"]) { + const workflow = readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), "utf8"); + assert(workflow.includes(`npm install -g npm@11.16.0 pnpm@${PNPM_VERSION} --ignore-scripts`)); + assert(workflow.includes("bun-version: '1.3.11'")); + assert(workflow.includes("persist-credentials: false")); + } + const publicWorkflow = readFileSync(new URL("../.github/workflows/artifact-acceptance.yml", import.meta.url), "utf8"); + assert(publicWorkflow.includes("/scripts/artifact-installation.mjs"), "Sparse public tools must include isolated pnpm support"); +}); + test("public acceptance rejects unreviewed identities and non-upgrades before installation", () => { assert.equal(validateAcceptanceManifest(manifest, "0.2.1"), manifest.packages[0]); for (const previous of ["0.2.2", "0.3.0", "0.2.1-beta.1", "--force", "0.2.1\n"]) diff --git a/scripts/test_verify_candidate_artifacts.py b/scripts/test_verify_candidate_artifacts.py index ff1154512..a1df578e2 100644 --- a/scripts/test_verify_candidate_artifacts.py +++ b/scripts/test_verify_candidate_artifacts.py @@ -41,6 +41,55 @@ def test_installed_package_bin_is_validated_without_limiting_consumer_to_output_ with patch.object(verifier, "local_path", side_effect=AssertionError("output-only guard")): self.assertEqual(verifier.installed_executable(self.executable), (str(self.cli), self.consumer)) + def pnpm_layout(self): + target = self.consumer / "node_modules/.pnpm/@diffexai+diffex@1.2.3/node_modules/@diffexai/diffex" + target.parent.mkdir(parents=True) + self.package.rename(target) + self.package.symlink_to(os.path.relpath(target, self.package.parent), target_is_directory=True) + self.executable.unlink() + self.executable.write_text('#!/bin/sh\nexec node "$basedir/../@diffexai/diffex/dist/cli.js" "$@"\n') + self.executable.chmod(0o755) + return target + + def test_pnpm_shim_resolves_only_declared_js_and_keeps_original_consumer(self): + target = self.pnpm_layout() + self.assertEqual(verifier.installed_executable(self.executable), (str(target / "dist/cli.js"), self.consumer)) + + def test_pnpm_virtual_store_and_package_links_cannot_escape(self): + target = self.pnpm_layout() + for destination in [self.base / "foreign", self.consumer / "node_modules/foreign"]: + destination.mkdir(parents=True) + self.package.unlink() + self.package.symlink_to(destination, target_is_directory=True) + with self.subTest(destination=destination), self.assertRaises(ValueError): + verifier.installed_executable(self.executable) + self.package.unlink() + self.package.symlink_to(target, target_is_directory=True) + store = self.consumer / "node_modules/.pnpm" + moved = self.base / "foreign-store" + store.rename(moved) + store.symlink_to(moved, target_is_directory=True) + with self.assertRaises(ValueError): + verifier.installed_executable(self.executable) + + def test_pnpm_shim_and_cli_links_are_rejected(self): + target = self.pnpm_layout() + for shim in ["#!/bin/sh\nexec foreign\n", "#!/bin/sh\n" + "x" * 65536, "#!/usr/bin/env node\n"]: + self.executable.write_text(shim) + with self.subTest(shim=shim[:40]), self.assertRaises(ValueError): + verifier.installed_executable(self.executable) + self.executable.write_text('#!/bin/sh\nexec node "$basedir/../@diffexai/diffex/dist/cli.js" "$@"\n') + cli = target / "dist/cli.js" + outside = self.base / "outside.js" + cli.rename(outside) + cli.symlink_to(outside) + with self.assertRaises(ValueError): + verifier.installed_executable(self.executable) + self.executable.unlink() + self.executable.symlink_to(outside) + with self.assertRaises(OSError): + verifier.installed_executable(self.executable) + def test_consumer_symlink_and_parent_traversal_are_rejected(self): linked = self.base / "linked-consumer" linked.symlink_to(self.consumer, target_is_directory=True) diff --git a/scripts/verify-candidate-artifacts.py b/scripts/verify-candidate-artifacts.py index 55b5255f2..a32c16044 100644 --- a/scripts/verify-candidate-artifacts.py +++ b/scripts/verify-candidate-artifacts.py @@ -45,11 +45,11 @@ def local_path(value): def installed_executable(value): - """Validate the operator-selected npm/Bun consumer before executing its bin. + """Validate an npm/Bun symlink or pnpm shim, then execute only the checked JS. - Consumers may be anywhere, independently of local evidence output roots. - Their intentional .bin symlink must resolve to the declared Diffex package - entrypoint, with no package/directory links redirecting code or fixture I/O. + pnpm's package link must stay within this consumer's virtual store. Its shell + shim is never executed: the declared, validated JavaScript entrypoint is used + directly. Fixture I/O always stays in the original consumer, not .pnpm. """ value = pathlib.Path(value) if ".." in value.parts or value.parts[-3:] != ("node_modules", ".bin", "diffex"): @@ -64,15 +64,38 @@ def installed_executable(value): for root in aliases) if requested_consumer != consumer and not alias_match: raise ValueError("consumer path traverses a symlink") - resolved = os.path.realpath(absolute) - if not resolved.startswith(consumer + os.path.sep): + modules = pathlib.Path(consumer) / "node_modules" + if os.path.realpath(modules) != str(modules) or os.path.realpath(modules / ".bin") != str(modules / ".bin"): + raise ValueError("installed module directories traverse a symlink") + package = modules / "@diffexai/diffex" + canonical_package = pathlib.Path(os.path.realpath(package)) + if package.is_symlink(): + store = modules / ".pnpm" + if os.path.realpath(store) != str(store): + raise ValueError("pnpm virtual store traverses a symlink") + if not canonical_package.is_relative_to(store): + raise ValueError("pnpm package escapes its installed consumer") + local = canonical_package.relative_to(store).parts + if len(local) != 4 or local[1:] != ("node_modules", "@diffexai", "diffex"): + raise ValueError("unexpected pnpm package target") + descriptor = os.open(absolute, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ValueError("invalid pnpm executable shim") + shim = stream.read(65537) + targets = [package / "dist/cli.js", canonical_package / "dist/cli.js"] + references = [f'"$basedir/{os.path.relpath(target, modules / ".bin")}"'.encode() for target in targets] + if len(shim) > 65536 or not shim.startswith(b"#!/bin/sh\n") or not any(ref in shim for ref in references): + raise ValueError("unexpected pnpm executable shim") + resolved = os.path.realpath(canonical_package / "dist/cli.js") + if resolved != str(canonical_package / "dist/cli.js"): + raise ValueError("unexpected pnpm executable target") + else: + resolved = os.path.realpath(absolute) + if resolved != str(package / "dist/cli.js") or canonical_package != package: + raise ValueError("unexpected installed executable target") + if not resolved.startswith(consumer + os.path.sep) or os.path.commonpath([consumer, resolved]) != consumer: raise ValueError("executable escapes its installed consumer") - if os.path.commonpath([consumer, resolved]) != consumer: - raise ValueError("executable escapes its installed consumer") - package = pathlib.Path(consumer) / "node_modules/@diffexai/diffex" - expected = package / "dist/cli.js" - if resolved != str(expected) or os.path.realpath(package) != str(package): - raise ValueError("unexpected installed executable target") package = pathlib.Path(resolved).parent.parent metadata_path = package / "package.json" descriptor = os.open(metadata_path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) @@ -84,7 +107,7 @@ def installed_executable(value): raise ValueError("installed package executable identity mismatch") if not pathlib.Path(resolved).is_file() or not os.access(resolved, os.X_OK): raise ValueError("installed executable is not executable") - return resolved, pathlib.Path(resolved).parents[4] + return resolved, pathlib.Path(consumer) def write_evidence(out, name, text):