From aa51b1502f796fd81e6c7ccf4afa2e27541e3562 Mon Sep 17 00:00:00 2001 From: nembal Date: Sun, 2 Aug 2026 07:47:39 +0700 Subject: [PATCH] fix: support npm 12 pack results --- .github/workflows/release.yml | 2 +- AGENTS.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/__tests__/npm-pack-result.test.ts | 30 +++++++++++ scripts/audit-package.ts | 17 +++--- scripts/npm-pack-result.ts | 66 +++++++++++++++++++++++ scripts/resolve-packed-tarball.ts | 14 +++++ src/__tests__/package-boundary.test.ts | 39 +++++++++++--- 9 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 scripts/__tests__/npm-pack-result.test.ts create mode 100644 scripts/npm-pack-result.ts create mode 100644 scripts/resolve-packed-tarball.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cc64ae..be134bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: id: pack run: | npm pack --json > pack-result.json - tarball="$(node --input-type=module -e 'import { readFileSync } from "node:fs"; const [result] = JSON.parse(readFileSync("pack-result.json", "utf8")); if (!result?.filename) throw new Error("npm pack returned no tarball"); process.stdout.write(result.filename);')" + tarball="$(./node_modules/.bin/tsx scripts/resolve-packed-tarball.ts pack-result.json)" sha256sum "$tarball" printf 'tarball=%s\n' "$tarball" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index a7de26e..3868ffc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ npm run contracts:check npm run package:audit ``` -The final package audit must inspect the exact tarball allowlist and packed manifest, require `bin.agentcommunity` to remain exactly `dist/cli.js`, scan packed files for likely secrets, install that tarball in a clean temporary project, prove the installed `.bin/agentcommunity` shim resolves to the packed executable, and run that exact shim. CI covers Node 22.14, 24, and 26 on Ubuntu 24.04 and macOS. +The final package audit must normalize and validate both supported `npm pack --json` contracts (npm 11's one-element array and npm 12's single package-keyed object), inspect the exact tarball allowlist and packed manifest, require `bin.agentcommunity` to remain exactly `dist/cli.js`, scan packed files for likely secrets, install that tarball in a clean temporary project, prove the installed `.bin/agentcommunity` shim resolves to the packed executable, and run that exact shim. CI covers Node 22.14, 24, and 26 on Ubuntu 24.04 and macOS. ## Security and release gates diff --git a/package-lock.json b/package-lock.json index 3d7f496..7dd1e22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentcommunity/cli", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentcommunity/cli", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "os": [ "darwin", diff --git a/package.json b/package.json index 39b5bb9..9c1458e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentcommunity/cli", - "version": "0.1.0", + "version": "0.1.1", "description": "Official command-line client for Agent Community public data and user-claimed authorization", "type": "module", "bin": { diff --git a/scripts/__tests__/npm-pack-result.test.ts b/scripts/__tests__/npm-pack-result.test.ts new file mode 100644 index 0000000..4028af5 --- /dev/null +++ b/scripts/__tests__/npm-pack-result.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; + +import { normalizeNpmPackResult } from "../npm-pack-result.js"; + +const result = { + name: "@agentcommunity/cli", + version: "0.1.1", + filename: "agentcommunity-cli-0.1.1.tgz", + files: [{ path: "package.json", size: 1200 }], +}; + +describe("npm pack JSON normalization", () => { + test("accepts the observed npm 11 array and npm 12 package-keyed object", () => { + expect(normalizeNpmPackResult([result], result.name, result.version)).toEqual(result); + expect(normalizeNpmPackResult({ [result.name]: result }, result.name, result.version)).toEqual(result); + }); + + test.each([ + [], + [result, result], + {}, + { [result.name]: result, extra: result }, + { [result.name]: { ...result, name: "wrong" } }, + { [result.name]: { ...result, version: "0.1.0" } }, + { [result.name]: { ...result, filename: "other.tgz" } }, + { [result.name]: { ...result, files: [{ path: "package.json", size: -1 }] } }, + ])("rejects ambiguous or malformed output: %#", (value) => { + expect(() => normalizeNpmPackResult(value, result.name, result.version)).toThrow("Invalid npm pack JSON output"); + }); +}); diff --git a/scripts/audit-package.ts b/scripts/audit-package.ts index dfc4f85..865681a 100644 --- a/scripts/audit-package.ts +++ b/scripts/audit-package.ts @@ -4,8 +4,7 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { spawnSync } from "node:child_process"; -interface PackFile { path: string; size: number } -interface PackResult { filename: string; files: Array } +import { normalizeNpmPackResult } from "./npm-pack-result.js"; const expectedFiles = ["LICENSE", "README.md", "SECURITY.md", "dist/cli.js", "package.json"]; const expectedBin = { agentcommunity: "dist/cli.js" }; @@ -24,6 +23,13 @@ function command(commandName: string, args: Array, cwd: string): string return result.stdout; } +function npmCommand(args: Array, cwd: string): string { + const npmExecPath = process.env.npm_execpath; + return npmExecPath === undefined + ? command("npm", args, cwd) + : command(process.execPath, [npmExecPath, ...args], cwd); +} + function assertExactBin(packageJson: Record, source: string): void { if (JSON.stringify(packageJson.bin) !== JSON.stringify(expectedBin)) { throw new Error(`${source} package manifest does not retain the exact agentcommunity binary mapping.`); @@ -46,9 +52,8 @@ async function main(): Promise { const destination = await mkdtemp(join(tmpdir(), "agentcommunity-pack-")); const project = await mkdtemp(join(tmpdir(), "agentcommunity-install-")); try { - const packed = JSON.parse(command("npm", ["pack", "--json", "--pack-destination", destination], repositoryRoot)) as Array; - const result = packed[0]; - if (result === undefined) throw new Error("npm pack returned no tarball."); + const packed = JSON.parse(npmCommand(["pack", "--json", "--pack-destination", destination], repositoryRoot)); + const result = normalizeNpmPackResult(packed, packageJson.name, packageJson.version); const inventory = result.files.map((file) => file.path).sort(); if (JSON.stringify(inventory) !== JSON.stringify(expectedFiles)) throw new Error(`Unexpected package inventory: ${inventory.join(", ")}`); const tarballPath = join(destination, basename(result.filename)); @@ -63,7 +68,7 @@ async function main(): Promise { for (const pattern of secretPatterns) if (pattern.test(content)) throw new Error(`Possible secret in packed file ${path}.`); } await writeFile(join(project, "package.json"), '{"name":"agentcommunity-clean-install","private":true,"version":"1.0.0"}\n'); - command("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarballPath], project); + npmCommand(["install", "--ignore-scripts", "--no-audit", "--no-fund", tarballPath], project); const installedPackageRoot = join(project, "node_modules/@agentcommunity/cli"); const installedManifest = JSON.parse(await readFile(join(installedPackageRoot, "package.json"), "utf8")); assertExactBin(installedManifest, "Installed"); diff --git a/scripts/npm-pack-result.ts b/scripts/npm-pack-result.ts new file mode 100644 index 0000000..7758e24 --- /dev/null +++ b/scripts/npm-pack-result.ts @@ -0,0 +1,66 @@ +export interface NpmPackFile { + path: string; + size: number; +} + +export interface NpmPackResult { + name: string; + version: string; + filename: string; + files: Array; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function invalidPackOutput(): never { + throw new Error("Invalid npm pack JSON output."); +} + +export function normalizeNpmPackResult( + value: unknown, + expectedName: string, + expectedVersion: string, +): NpmPackResult { + let candidate: unknown; + if (Array.isArray(value)) { + if (value.length !== 1) invalidPackOutput(); + candidate = value[0]; + } else if (isRecord(value)) { + const keys = Object.keys(value); + if (keys.length !== 1 || keys[0] !== expectedName) invalidPackOutput(); + candidate = value[expectedName]; + } else { + invalidPackOutput(); + } + + if (!isRecord(candidate)) invalidPackOutput(); + const expectedFilename = `agentcommunity-cli-${expectedVersion}.tgz`; + if ( + candidate.name !== expectedName + || candidate.version !== expectedVersion + || candidate.filename !== expectedFilename + || !Array.isArray(candidate.files) + ) { + invalidPackOutput(); + } + const files = candidate.files; + if (files.some(function (file) { + return !isRecord(file) + || typeof file.path !== "string" + || file.path.length === 0 + || typeof file.size !== "number" + || !Number.isSafeInteger(file.size) + || file.size < 0; + })) { + invalidPackOutput(); + } + + return { + name: candidate.name, + version: candidate.version, + filename: candidate.filename, + files: files as Array, + }; +} diff --git a/scripts/resolve-packed-tarball.ts b/scripts/resolve-packed-tarball.ts new file mode 100644 index 0000000..94e9ddb --- /dev/null +++ b/scripts/resolve-packed-tarball.ts @@ -0,0 +1,14 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { normalizeNpmPackResult } from "./npm-pack-result.js"; + +const [packResultPath] = process.argv.slice(2); +if (packResultPath === undefined || process.argv.length !== 3) { + throw new Error("Usage: resolve-packed-tarball "); +} + +const packageJson = JSON.parse(await readFile(join(process.cwd(), "package.json"), "utf8")); +const packResult = JSON.parse(await readFile(packResultPath, "utf8")); +const result = normalizeNpmPackResult(packResult, packageJson.name, packageJson.version); +process.stdout.write(result.filename); diff --git a/src/__tests__/package-boundary.test.ts b/src/__tests__/package-boundary.test.ts index 631ef57..e7db4bf 100644 --- a/src/__tests__/package-boundary.test.ts +++ b/src/__tests__/package-boundary.test.ts @@ -2,9 +2,13 @@ import { spawnSync } from "node:child_process"; import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vitest"; +import { normalizeNpmPackResult } from "../../scripts/npm-pack-result.js"; + const root = new URL("../../", import.meta.url); +const rootPath = fileURLToPath(root); function command(commandName: string, args: Array, cwd: string): string { const result = spawnSync(commandName, args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); @@ -24,6 +28,7 @@ describe("package and CI boundaries", () => { bugs: { url: "https://github.com/agentcommunity/cli/issues" }, }); expect(packageJson.exports).toBeUndefined(); + expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+$/); expect(packageJson.scripts.preinstall).toBeUndefined(); expect(packageJson.scripts.postinstall).toBeUndefined(); }); @@ -38,6 +43,7 @@ describe("package and CI boundaries", () => { await mkdir(packed); await mkdir(project); const packageJson = JSON.parse(await readFile(new URL("package.json", root), "utf8")); + const packageVersion = packageJson.version as string; await writeFile(join(source, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); for (const file of ["README.md", "LICENSE", "SECURITY.md"]) { await writeFile(join(source, file), `${file} fixture\n`); @@ -46,12 +52,32 @@ describe("package and CI boundaries", () => { await writeFile(executable, "#!/usr/bin/env node\nconsole.log('packed-binary-ok');\n"); await chmod(executable, 0o755); - const packResult = JSON.parse(command("npm", ["pack", "--json", "--pack-destination", packed], source)) as Array<{ filename: string }>; - const filename = packResult[0]?.filename; - expect(filename).toBe("agentcommunity-cli-0.1.0.tgz"); - const tarball = join(packed, basename(filename as string)); - const packedManifest = JSON.parse(command("tar", ["-xOf", tarball, "package/package.json"], source)); - expect(packedManifest.bin).toEqual({ agentcommunity: "dist/cli.js" }); + const packCommands = [ + { label: "npm 11", commandName: "npm", prefix: [] as Array }, + { label: "npm 12", commandName: "npx", prefix: ["--yes", "--package", "npm@12.0.2", "npm"] }, + ]; + let tarball = ""; + for (const packCommand of packCommands) { + const packDestination = join(packed, packCommand.label.replace(" ", "-")); + await mkdir(packDestination); + const rawResult = JSON.parse(command( + packCommand.commandName, + [...packCommand.prefix, "pack", "--json", "--pack-destination", packDestination], + source, + )); + const packResult = normalizeNpmPackResult(rawResult, "@agentcommunity/cli", packageVersion); + expect(packResult.filename, packCommand.label).toBe(`agentcommunity-cli-${packageVersion}.tgz`); + const packResultPath = join(packDestination, "pack-result.json"); + await writeFile(packResultPath, JSON.stringify(rawResult)); + expect(command( + join(rootPath, "node_modules/.bin/tsx"), + [join(rootPath, "scripts/resolve-packed-tarball.ts"), packResultPath], + source, + ), packCommand.label).toBe(packResult.filename); + tarball = join(packDestination, basename(packResult.filename)); + const packedManifest = JSON.parse(command("tar", ["-xOf", tarball, "package/package.json"], source)); + expect(packedManifest.bin, packCommand.label).toEqual({ agentcommunity: "dist/cli.js" }); + } await writeFile(join(project, "package.json"), '{"name":"package-boundary-install","private":true,"version":"1.0.0"}\n'); command("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], project); @@ -82,6 +108,7 @@ describe("package and CI boundaries", () => { expect(release).toContain("id-token: write"); expect(release).toContain("npm run package:audit"); expect(release).toContain("npm pack --json"); + expect(release).toContain('tarball="$(./node_modules/.bin/tsx scripts/resolve-packed-tarball.ts pack-result.json)"'); expect(release).toContain('npm publish "${{ steps.pack.outputs.tarball }}" --access public --provenance'); expect(release).not.toMatch(/NPM_TOKEN|NODE_AUTH_TOKEN|npm_[A-Za-z0-9]{20,}/); });