Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
29 changes: 24 additions & 5 deletions scripts/audit-package.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -8,6 +8,8 @@ interface PackFile { path: string; size: number }
interface PackResult { filename: string; files: Array<PackFile> }

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/,
Expand All @@ -22,10 +24,17 @@ function command(commandName: string, args: Array<string>, cwd: string): string
return result.stdout;
}

function assertExactBin(packageJson: Record<string, unknown>, 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<void> {
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"])) {
Expand All @@ -43,6 +52,8 @@ async function main(): Promise<void> {
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");
Expand All @@ -53,15 +64,23 @@ async function main(): Promise<void> {
}
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 <file|->") || !help.includes("agentcommunity auth <login|status|logout|revoke>")) 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 <email>") || !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 });
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand Down
30 changes: 29 additions & 1 deletion src/__tests__/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
});
});
71 changes: 64 additions & 7 deletions src/__tests__/package-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>, 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" },
Expand All @@ -19,20 +28,68 @@ 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");
expect(workflow).toContain("22.14.0");
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");
});
});
Loading
Loading