diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8cc64ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Publish verified package + runs-on: ubuntu-24.04 + steps: + - name: Check out tagged source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Use an OIDC-capable npm CLI + run: npm install --global npm@12.0.2 + + - name: Require an exact stable release tag + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + + const packageJson = JSON.parse(readFileSync("package.json", "utf8")); + if (!/^\d+\.\d+\.\d+$/.test(packageJson.version)) { + throw new Error(`Release version must be stable SemVer, received ${packageJson.version}`); + } + const expectedTag = `v${packageJson.version}`; + if (process.env.RELEASE_TAG !== expectedTag) { + throw new Error(`Tag ${process.env.RELEASE_TAG} does not match package version ${expectedTag}`); + } + NODE + + - name: Install locked dependencies + run: npm ci + + - name: Run release gates + run: | + npm run lint + npm test + npm run typecheck + npm run build + npm run contracts:check + npm run package:audit + + - name: Pack the release artifact + 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);')" + sha256sum "$tarball" + printf 'tarball=%s\n' "$tarball" >> "$GITHUB_OUTPUT" + + - name: Publish the exact artifact with OIDC provenance + run: npm publish "${{ steps.pack.outputs.tarball }}" --access public --provenance diff --git a/AGENTS.md b/AGENTS.md index 24887d9..a7de26e 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 metadata, scan packed files for likely secrets, install that tarball in a clean temporary project, and run `npx --no-install agentcommunity --help`. CI covers Node 22.14, 24, and 26 on Ubuntu 24.04 and macOS. +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. ## Security and release gates @@ -45,4 +45,4 @@ Auth discovery always starts from an unauthenticated exact `/api` challenge and The credential store is POSIX-only. Require user-owned non-symlink `0700` directories and user-owned regular single-link `0600` files; reject unsafe parents, modes, owners, symlinks, hardlinks, and locks. Preserve bounded locking, conservative stale-lock checks, same-directory exclusive/no-follow temp creation, fsync-before-rename, atomic rename, directory fsync, conditional refresh/removal, and cleanup on interruption. -There is intentionally no `release.yml`. Do not publish, push, deploy, create credentials, run live auth, or add OIDC permissions without explicit owner authorization. Keep these states distinct in docs and reports: source complete, npm package published, PAGE endpoint deployed/production-capable, PAGE linked/discoverable. The current batch and agent-auth source await PAGE production deployment. A live auth smoke additionally requires an owner-authorized dedicated test account. +The tag-only `release.yml` is the sole publishing path: ordinary CI has no publish permission, while the release job uses npm trusted publishing with OIDC provenance and publishes only its already-gated tarball. Do not publish, push, deploy, create credentials, run live auth, or change the release/OIDC boundary without explicit owner authorization. Keep these states distinct in docs and reports: source complete, npm package published, PAGE endpoint deployed/production-capable, PAGE linked/discoverable. The current batch and agent-auth source await PAGE production deployment. A live auth smoke additionally requires an owner-authorized dedicated test account. diff --git a/package.json b/package.json index 57a2dca..39b5bb9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Official command-line client for Agent Community public data and user-claimed authorization", "type": "module", "bin": { - "agentcommunity": "./dist/cli.js" + "agentcommunity": "dist/cli.js" }, "files": [ "dist/", diff --git a/scripts/audit-package.ts b/scripts/audit-package.ts index 9809f91..dfc4f85 100644 --- a/scripts/audit-package.ts +++ b/scripts/audit-package.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { lstat, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -8,6 +8,8 @@ interface PackFile { path: string; size: number } interface PackResult { filename: string; files: Array } const expectedFiles = ["LICENSE", "README.md", "SECURITY.md", "dist/cli.js", "package.json"]; +const expectedBin = { agentcommunity: "dist/cli.js" }; +const installedBinRelativePath = "node_modules/.bin/agentcommunity"; const secretPatterns = [ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, /\bnpm_[A-Za-z0-9]{20,}\b/, @@ -22,10 +24,17 @@ function command(commandName: string, args: Array, cwd: string): string return result.stdout; } +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.`); + } +} + async function main(): Promise { const repositoryRoot = new URL("../", import.meta.url).pathname; const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")); - if (packageJson.name !== "@agentcommunity/cli" || packageJson.bin?.agentcommunity !== "./dist/cli.js" || packageJson.exports !== undefined) { + assertExactBin(packageJson, "Source"); + if (packageJson.name !== "@agentcommunity/cli" || packageJson.exports !== undefined) { throw new Error("Package metadata is outside the CLI-only boundary."); } if (packageJson.engines?.node !== "^22.14.0 || ^24.0.0 || ^26.0.0" || JSON.stringify(packageJson.os) !== JSON.stringify(["darwin", "linux"])) { @@ -43,6 +52,8 @@ async function main(): Promise { 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)); + const packedManifest = JSON.parse(command("tar", ["-xOf", tarballPath, "package/package.json"], repositoryRoot)); + assertExactBin(packedManifest, "Packed tarball"); const tarball = await readFile(tarballPath); const tarballSha256 = createHash("sha256").update(tarball).digest("hex"); const executable = await readFile(join(repositoryRoot, "dist/cli.js"), "utf8"); @@ -53,15 +64,23 @@ async function main(): Promise { } 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); - const smoke = spawnSync("npx", ["--no-install", "agentcommunity", "--help"], { cwd: project, encoding: "utf8", maxBuffer: 1024 * 1024 }); + const installedPackageRoot = join(project, "node_modules/@agentcommunity/cli"); + const installedManifest = JSON.parse(await readFile(join(installedPackageRoot, "package.json"), "utf8")); + assertExactBin(installedManifest, "Installed"); + const installedBin = join(project, installedBinRelativePath); + if (!(await lstat(installedBin)).isSymbolicLink()) throw new Error("Clean-install binary shim is not a symbolic link."); + const resolvedBin = await realpath(installedBin); + const expectedResolvedBin = await realpath(join(installedPackageRoot, expectedBin.agentcommunity)); + if (resolvedBin !== expectedResolvedBin) throw new Error("Clean-install binary shim resolves outside the packed executable."); + const smoke = spawnSync(installedBin, ["--help"], { cwd: project, encoding: "utf8", maxBuffer: 1024 * 1024 }); const help = `${smoke.stdout}${smoke.stderr}`; if (smoke.status !== 0 || !help.includes("Agent Community CLI") || !help.includes("agentcommunity batch ") || !help.includes("agentcommunity auth ")) throw new Error(`Clean-install binary help smoke failed. Output:\n${help}`); - const authSmoke = spawnSync("npx", ["--no-install", "agentcommunity", "auth", "--help"], { cwd: project, encoding: "utf8", maxBuffer: 1024 * 1024 }); + const authSmoke = spawnSync(installedBin, ["auth", "--help"], { cwd: project, encoding: "utf8", maxBuffer: 1024 * 1024 }); const authHelp = `${authSmoke.stdout}${authSmoke.stderr}`; if (authSmoke.status !== 0 || !authHelp.includes("auth login --login-hint ") || !authHelp.includes("auth revoke")) { throw new Error(`Clean-install binary auth-help smoke failed. Output:\n${authHelp}`); } - process.stdout.write(`${JSON.stringify({ filename: result.filename, sha256: tarballSha256, files: result.files, clean_install_help: "passed", clean_install_auth_help: "passed" }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ filename: result.filename, sha256: tarballSha256, files: result.files, packed_bin: packedManifest.bin, installed_bin: installedManifest.bin, installed_bin_resolution: resolvedBin, clean_install_help: "passed", clean_install_auth_help: "passed" }, null, 2)}\n`); } finally { await rm(destination, { recursive: true, force: true }); await rm(project, { recursive: true, force: true }); diff --git a/src/__tests__/commands.test.ts b/src/__tests__/commands.test.ts index ed610a2..d727d74 100644 --- a/src/__tests__/commands.test.ts +++ b/src/__tests__/commands.test.ts @@ -57,6 +57,26 @@ describe("the seven read-only commands", () => { expect(humanHarness.output().stdout.length).toBeGreaterThan(0); }); + test("accepts PAGE's additive NLWeb fields and preserves the JSON payload", async () => { + const payload = { + query_id: "ask_550e8400-e29b-41d4-a716-446655440000", + query: "What is AID?", + site: "agentcommunity.org", + mode: "list", + total_results: 0, + answer: "AID is a discovery format.", + content: [], + results: [], + _meta: { mode: "list", response_type: "answer", site: "agentcommunity.org", version: "0.55" }, + }; + const requestJson = vi.fn().mockImplementation(async (request) => request.validate(payload)); + const resultHarness = harness({ http: { requestJson } }); + + expect(await runCli(["docs", "ask", "What is AID?", "--json"], resultHarness.dependencies)).toBe(0); + expect(JSON.parse(resultHarness.output().stdout)).toEqual(payload); + expect(resultHarness.output().stderr).toBe(""); + }); + test("posts the validated batch unchanged, preserves order, and exits 8 for mixed results", async () => { const request = { items: [ { id: "first", operation: "content.list", arguments: {} }, diff --git a/src/__tests__/contracts.test.ts b/src/__tests__/contracts.test.ts index e251465..53f4bf6 100644 --- a/src/__tests__/contracts.test.ts +++ b/src/__tests__/contracts.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { verifyContractDirectory } from "../contracts.js"; +import { docsAnswerSchema, verifyContractDirectory } from "../contracts.js"; const repositoryRoot = new URL("../../", import.meta.url); @@ -27,3 +27,31 @@ describe("vendored PAGE contracts", () => { await expect(verifyContractDirectory(new URL(`file://${temp}/`))).rejects.toMatchObject({ code: "contract_mismatch" }); }); }); + +describe("NLWeb answer compatibility", () => { + const legacyAnswer = { + _meta: { version: "0.55", response_type: "answer", mode: "list", site: "agentcommunity.org" }, + query: "What is AID?", + answer: "AID is a discovery format.", + content: [], + results: [], + }; + + test("accepts both the deployed envelope and PAGE's additive reference fields", () => { + expect(docsAnswerSchema.parse(legacyAnswer)).toEqual(legacyAnswer); + const currentAnswer = { + ...legacyAnswer, + query_id: "ask_550e8400-e29b-41d4-a716-446655440000", + site: "agentcommunity.org", + mode: "list", + total_results: 0, + }; + expect(docsAnswerSchema.parse(currentAnswer)).toEqual(currentAnswer); + }); + + test("keeps rejecting unrecognized or inconsistent additive fields", () => { + expect(docsAnswerSchema.safeParse({ ...legacyAnswer, unexpected: true }).success).toBe(false); + expect(docsAnswerSchema.safeParse({ ...legacyAnswer, total_results: 1 }).success).toBe(false); + expect(docsAnswerSchema.safeParse({ ...legacyAnswer, query_id: "unbounded-id" }).success).toBe(false); + }); +}); diff --git a/src/__tests__/package-boundary.test.ts b/src/__tests__/package-boundary.test.ts index 8b2930b..631ef57 100644 --- a/src/__tests__/package-boundary.test.ts +++ b/src/__tests__/package-boundary.test.ts @@ -1,13 +1,22 @@ -import { readFile } from "node:fs/promises"; +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 { describe, expect, test } from "vitest"; const root = new URL("../../", import.meta.url); +function command(commandName: string, args: Array, cwd: string): string { + const result = spawnSync(commandName, args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + expect(result.status, `${commandName} ${args.join(" ")}\n${result.stderr}`).toBe(0); + return result.stdout; +} + describe("package and CI boundaries", () => { test("declares a CLI-only package for the maintained Node and OS matrix", async () => { const packageJson = JSON.parse(await readFile(new URL("package.json", root), "utf8")); expect(packageJson).toMatchObject({ - name: "@agentcommunity/cli", type: "module", bin: { agentcommunity: "./dist/cli.js" }, + name: "@agentcommunity/cli", type: "module", bin: { agentcommunity: "dist/cli.js" }, files: ["dist/", "README.md", "LICENSE", "SECURITY.md"], engines: { node: "^22.14.0 || ^24.0.0 || ^26.0.0" }, os: ["darwin", "linux"], publishConfig: { access: "public" }, @@ -19,7 +28,45 @@ describe("package and CI boundaries", () => { expect(packageJson.scripts.postinstall).toBeUndefined(); }); - test("CI source contains all six required jobs and no publish permission or release workflow", async () => { + test("retains the exact binary mapping in the packed manifest and clean install", async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), "agentcommunity-package-boundary-")); + const source = join(fixtureRoot, "source"); + const packed = join(fixtureRoot, "packed"); + const project = join(fixtureRoot, "project"); + try { + await mkdir(join(source, "dist"), { recursive: true }); + await mkdir(packed); + await mkdir(project); + const packageJson = JSON.parse(await readFile(new URL("package.json", root), "utf8")); + 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`); + } + const executable = join(source, "dist/cli.js"); + 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" }); + + 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); + const installedManifest = JSON.parse(await readFile(join(project, "node_modules/@agentcommunity/cli/package.json"), "utf8")); + expect(installedManifest.bin).toEqual({ agentcommunity: "dist/cli.js" }); + const installedBin = join(project, "node_modules/.bin/agentcommunity"); + expect((await lstat(installedBin)).isSymbolicLink()).toBe(true); + expect(await realpath(installedBin)).toBe(await realpath(join(project, "node_modules/@agentcommunity/cli/dist/cli.js"))); + expect(command(installedBin, [], project).trim()).toBe("packed-binary-ok"); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } + }, 30_000); + + test("keeps ordinary CI non-publishing and requires the separately gated OIDC release", async () => { const workflow = await readFile(new URL(".github/workflows/ci.yml", root), "utf8"); expect(workflow).toContain("ubuntu-24.04"); expect(workflow).toContain("macos-14"); @@ -27,12 +74,22 @@ describe("package and CI boundaries", () => { expect(workflow).toContain('"24"'); expect(workflow).toContain('"26"'); expect(workflow).not.toContain("id-token: write"); - await expect(readFile(new URL(".github/workflows/release.yml", root), "utf8")).rejects.toThrow(); + expect(workflow).not.toMatch(/npm publish|NPM_TOKEN|NODE_AUTH_TOKEN/); + + const release = await readFile(new URL(".github/workflows/release.yml", root), "utf8"); + expect(release).toContain("tags:"); + expect(release).toContain('"v*.*.*"'); + expect(release).toContain("id-token: write"); + expect(release).toContain("npm run package:audit"); + expect(release).toContain("npm pack --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,}/); }); - test("the packed-tarball audit smoke checks both root and auth help", async () => { + test("the packed-tarball audit inspects its manifest and executes the resolved install shim", async () => { const audit = await readFile(new URL("scripts/audit-package.ts", root), "utf8"); - expect(audit).toContain('["--no-install", "agentcommunity", "--help"]'); - expect(audit).toContain('["--no-install", "agentcommunity", "auth", "--help"]'); + expect(audit).toContain('"package/package.json"'); + expect(audit).toContain('"node_modules/.bin/agentcommunity"'); + expect(audit).toContain("realpath"); }); }); diff --git a/src/contracts.ts b/src/contracts.ts index f982180..1b7c554 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -20,13 +20,20 @@ const articleSchema = z.object({ }).strict(); export const docsAnswerSchema = z.object({ _meta: z.object({ version: z.literal("0.55"), response_type: z.enum(["answer", "capability"]), mode: z.literal("list"), site: z.literal("agentcommunity.org") }).strict(), + query_id: z.string().regex(/^ask_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/).optional(), query: z.string().max(500), answer: z.string().max(1500), + site: z.literal("agentcommunity.org").optional(), mode: z.literal("list").optional(), + total_results: z.number().int().min(0).max(10).optional(), content: z.array(articleSchema).max(10), results: z.array(z.object({ url: z.string().url(), site: z.literal("agentcommunity.org"), name: z.string().max(200), description: z.string().max(500), schema_object: articleSchema, }).strict()).max(10), -}).strict(); +}).strict().superRefine((value, context) => { + if (value.total_results !== undefined && value.total_results !== value.results.length) { + context.addIssue({ code: "custom", message: "total_results_mismatch", path: ["total_results"] }); + } +}); export const statsSchema = z.object({ member_count: z.number().int(), note: z.string() }).strict(); export const memberSchema = z.object({