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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Scanner public API contract tests and `examples/scanner-regression/`
fixtures covering high-readiness, minimal, missing-confirmation,
origin-mismatch, and invalid manifest behavior.
- CLI regression coverage for example manifests, generated SDK output,
OpenAPI-generated manifests, and public `mcp-config`/`version`
behavior.

## [0.4.0] — release-prepared — HTTP MCP Transport + Auth

Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Examples

Self-contained examples showing how to integrate AgentBridge in different contexts.
The CLI regression suite validates the manifest and OpenAPI examples
that adopters are most likely to copy.

| Directory | What it shows |
|---|---|
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"typecheck:clean": "node scripts/clean-dist.mjs && npm run typecheck",
"clean:dist": "node scripts/clean-dist.mjs",
"pack:dry-run": "node scripts/pack-check.mjs",
"validate:examples": "node scripts/validate-examples.mjs",
"smoke:external": "node scripts/external-adopter-smoke.mjs",
"smoke:http": "node scripts/http-mcp-smoke.mjs"
},
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ For everything else (Claude Desktop, Cursor, custom):

Print the CLI version.

## Regression-tested examples

CLI regression coverage exercises the repo examples that adopters are
most likely to copy:

- `examples/adopter-quickstart/manifest.basic.json`
- `examples/adopter-quickstart/manifest.production-shaped.json`
- `examples/scanner-regression/*.json` (valid fixtures pass; the
intentionally invalid fixture fails safely)
- `examples/openapi-store/store.openapi.json`
- `examples/openapi-regression/catalog-regression.openapi.json`
- `examples/sdk-basic/manifest.ts` generated to JSON and validated
- `agentbridge mcp-config`, including stdio, Codex, Claude Desktop,
Cursor / generic JSON, and the v0.4.0 HTTP transport block

After building the workspace, run the same example validation pass
manually with:

```bash
npm run validate:examples
```

## Exit codes

- `0` — success
Expand Down
185 changes: 185 additions & 0 deletions packages/cli/src/tests/examples-regression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { promises as fs, readFileSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { runCli } from "../index";

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../../..");
const cliPackageJson = JSON.parse(
readFileSync(path.join(repoRoot, "packages/cli/package.json"), "utf8"),
) as { version: string };

function fixturePath(...parts: string[]): string {
return path.join(repoRoot, ...parts);
}

function captureStdio(): {
out: string[];
err: string[];
restore: () => void;
} {
const out: string[] = [];
const err: string[] = [];
const origOut = process.stdout.write.bind(process.stdout);
const origErr = process.stderr.write.bind(process.stderr);
process.stdout.write = ((chunk: unknown) => {
out.push(String(chunk));
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: unknown) => {
err.push(String(chunk));
return true;
}) as typeof process.stderr.write;
return {
out,
err,
restore: () => {
process.stdout.write = origOut;
process.stderr.write = origErr;
},
};
}

function assertNoRealLookingSecrets(output: string): void {
expect(output).not.toMatch(/Bearer\s+[A-Za-z0-9._~+/=-]{24,}/);
expect(output).not.toMatch(/sk_[A-Za-z0-9]{16,}/);
expect(output).not.toMatch(/gh[pousr]_[A-Za-z0-9_]{20,}/);
expect(output).not.toContain("codex-test-secret-token-should-not-leak");
}

describe("CLI public example regressions", () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agentbridge-cli-examples-"));
});

afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});

it("version reports the CLI package metadata version", async () => {
const cap = captureStdio();
const code = await runCli({ argv: ["version"] });
cap.restore();

expect(code).toBe(0);
expect(cap.out.join("").trim()).toBe(cliPackageJson.version);
});

it("mcp-config preserves stdio, desktop-client, Codex, generic, and HTTP snippets without leaking tokens", async () => {
const previous = process.env.AGENTBRIDGE_HTTP_AUTH_TOKEN;
process.env.AGENTBRIDGE_HTTP_AUTH_TOKEN = "codex-test-secret-token-should-not-leak";

const cap = captureStdio();
const code = await runCli({ argv: ["mcp-config"] });
cap.restore();
if (previous === undefined) {
delete process.env.AGENTBRIDGE_HTTP_AUTH_TOKEN;
} else {
process.env.AGENTBRIDGE_HTTP_AUTH_TOKEN = previous;
}

const output = cap.out.join("");
expect(code).toBe(0);
expect(output).toContain("Raw stdio command");
expect(output).toContain("npx -y @marmarlabs/agentbridge-mcp-server");
expect(output).toContain("OpenAI Codex");
expect(output).toContain("codex mcp add agentbridge");
expect(output).toContain("[mcp_servers.agentbridge]");
expect(output).toContain("Claude Desktop");
expect(output).toContain("Cursor / generic MCP JSON");
expect(output).toContain('"mcpServers"');
expect(output).toContain("HTTP transport (experimental, v0.4.0");
expect(output).toContain("AGENTBRIDGE_TRANSPORT=http");
expect(output).toContain("AGENTBRIDGE_HTTP_AUTH_TOKEN=$(openssl rand -hex 32)");
expect(output).toContain('"streamable-http"');
expect(output).toContain('"Authorization": "Bearer ${AGENTBRIDGE_HTTP_AUTH_TOKEN}"');
expect(output).toContain("examples/http-client-config/");
assertNoRealLookingSecrets(output);
});

it.each([
"examples/adopter-quickstart/manifest.basic.json",
"examples/adopter-quickstart/manifest.production-shaped.json",
"examples/scanner-regression/manifest.good.json",
"examples/scanner-regression/manifest.minimal-valid.json",
"examples/scanner-regression/manifest.missing-confirmation.json",
"examples/scanner-regression/manifest.origin-mismatch.json",
])("validates example manifest %s", async (manifestPath) => {
const cap = captureStdio();
const code = await runCli({ argv: ["validate", fixturePath(manifestPath)] });
cap.restore();

const output = cap.out.join("") + cap.err.join("");
expect(code).toBe(0);
expect(output).toContain("valid manifest");
assertNoRealLookingSecrets(output);
});

it("fails safely for the scanner invalid fixture", async () => {
const cap = captureStdio();
const code = await runCli({
argv: ["validate", fixturePath("examples/scanner-regression/manifest.invalid.json")],
});
cap.restore();

const output = cap.out.join("") + cap.err.join("");
expect(code).toBe(1);
expect(output).toContain("manifest failed validation");
expect(output).toContain("baseUrl");
expect(output).toContain("requiresConfirmation");
assertNoRealLookingSecrets(output);
});

it.each([
{
name: "openapi-store",
source: "examples/openapi-store/store.openapi.json",
expectedName: "Acme Store API",
expectedAction: "list_products",
skipped: false,
},
{
name: "openapi-regression",
source: "examples/openapi-regression/catalog-regression.openapi.json",
expectedName: "Catalog Regression API",
expectedAction: "list_orders_v2",
skipped: true,
},
])(
"generates and validates the $name OpenAPI example",
async ({ source, expectedName, expectedAction, skipped }) => {
const outPath = path.join(tmpDir, `${path.basename(source)}.agentbridge.json`);
const generate = captureStdio();
const generateCode = await runCli({
argv: ["generate", "openapi", fixturePath(source), "--out", outPath],
});
generate.restore();

const generateOutput = generate.out.join("") + generate.err.join("");
expect(generateCode).toBe(0);
expect(generateOutput).toContain("generated manifest");
if (skipped) {
expect(generateOutput).toContain("Skipped operations");
expect(generateOutput).toContain("HEAD /reports/{reportId}/exports");
}
assertNoRealLookingSecrets(generateOutput);

const validate = captureStdio();
const validateCode = await runCli({ argv: ["validate", outPath] });
validate.restore();
const validateOutput = validate.out.join("") + validate.err.join("");
expect(validateCode).toBe(0);
expect(validateOutput).toContain(expectedName);
assertNoRealLookingSecrets(validateOutput);

const manifest = JSON.parse(await fs.readFile(outPath, "utf8"));
expect(manifest.actions.some((action: { name: string }) => action.name === expectedAction)).toBe(
true,
);
},
);
});
81 changes: 81 additions & 0 deletions scripts/validate-examples.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env node
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";

const root = resolve(new URL("..", import.meta.url).pathname);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve script root with fileURLToPath

validate:examples computes root from new URL(...).pathname, which is not a safe filesystem path on non-POSIX setups: it keeps percent-encoding (e.g. spaces as %20) and produces /C:/...-style paths on Windows. In those environments, cwd/cli point to invalid locations and the script fails before running validations. Use fileURLToPath(new URL("..", import.meta.url)) (then path.resolve) so the npm script works reliably across developer machines.

Useful? React with 👍 / 👎.

const cli = join(root, "packages/cli/dist/bin.js");
const tmp = mkdtempSync(join(tmpdir(), "agentbridge-validate-examples-"));

const validManifests = [
"examples/adopter-quickstart/manifest.basic.json",
"examples/adopter-quickstart/manifest.production-shaped.json",
"examples/openapi-store/store.agentbridge.json",
"examples/scanner-regression/manifest.good.json",
"examples/scanner-regression/manifest.minimal-valid.json",
"examples/scanner-regression/manifest.missing-confirmation.json",
"examples/scanner-regression/manifest.origin-mismatch.json",
];

const openApiFixtures = [
"examples/openapi-store/store.openapi.json",
"examples/openapi-regression/catalog-regression.openapi.json",
];

function run(args, opts = {}) {
const res = spawnSync(process.execPath, [cli, ...args], {
cwd: root,
encoding: "utf8",
...opts,
});
return res;
}

function printResult(label, res) {
if (res.stdout) process.stdout.write(res.stdout);
if (res.stderr) process.stderr.write(res.stderr);
if (res.status !== 0) {
throw new Error(`${label} failed with exit ${res.status}`);
}
}

try {
for (const manifest of validManifests) {
printResult(`validate ${manifest}`, run(["validate", manifest]));
}

const invalid = run(["validate", "examples/scanner-regression/manifest.invalid.json"]);
if (invalid.status === 0) {
throw new Error("invalid scanner fixture unexpectedly passed validation");
}
process.stdout.write("ok invalid scanner fixture failed validation as expected\n");

const sdk = spawnSync(
process.execPath,
["--import", "tsx", "examples/sdk-basic/manifest.ts"],
{ cwd: root, encoding: "utf8" },
);
if (sdk.status !== 0) {
if (sdk.stdout) process.stdout.write(sdk.stdout);
if (sdk.stderr) process.stderr.write(sdk.stderr);
throw new Error(`generate sdk-basic manifest failed with exit ${sdk.status}`);
}
const sdkOut = join(tmp, "sdk-basic.agentbridge.json");
writeFileSync(sdkOut, sdk.stdout, "utf8");
printResult("validate sdk-basic generated manifest", run(["validate", sdkOut]));

for (const source of openApiFixtures) {
const out = join(tmp, `${source.split("/").pop()}.agentbridge.json`);
printResult(`generate openapi ${source}`, run(["generate", "openapi", source, "--out", out]));
printResult(`validate generated ${source}`, run(["validate", out]));
}

const regressionOut = join(tmp, "catalog-regression.openapi.json.agentbridge.json");
const generated = JSON.parse(readFileSync(regressionOut, "utf8"));
if (!Array.isArray(generated.actions) || generated.actions.length === 0) {
throw new Error("generated OpenAPI regression manifest did not include actions");
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
Loading