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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# @copilotkit/aimock

## 1.14.8

### Fixed

- `--proxy-only` mode now accepts URL-only `--fixtures` sources without requiring a local
filesystem path. Previously the first `--fixtures` value was always checked as a
record-destination base path, which rejected all-URL invocations even though proxy-only
mode doesn't write recordings to disk. The check now fires only for `--record` mode
where a writable destination is actually required. Same fix applied to the parallel
`--agui-proxy-only` CLI path. Unblocks the showcase-aimock Railway service which runs
aimock in proxy-only mode with remote GitHub raw fixture URLs and no local fallback.

## 1.14.7

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@copilotkit/aimock",
"version": "1.14.7",
"version": "1.14.8",
"description": "Mock infrastructure for AI application testing — LLM APIs, image generation, text-to-speech, transcription, video generation, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.",
"license": "MIT",
"keywords": [
Expand Down
159 changes: 159 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,3 +603,162 @@ describe.skipIf(!CLI_AVAILABLE)("CLI: remote --fixtures URLs", () => {
}
});
});

/* ================================================================== */
/* --proxy-only with URL-only --fixtures (v1.14.8 regression) */
/* ================================================================== */

describe.skipIf(!CLI_AVAILABLE)("CLI: --proxy-only with URL-only --fixtures", () => {
let cacheDir: string;
let envBackup: string | undefined;
let allowPrivateBackup: string | undefined;

beforeEach(() => {
cacheDir = mkdtempSync(join(tmpdir(), "aimock-cli-proxy-url-cache-"));
envBackup = process.env.XDG_CACHE_HOME;
process.env.XDG_CACHE_HOME = cacheDir;
// Remote-fixture SSRF denylist rejects 127.0.0.1 by default.
allowPrivateBackup = process.env.AIMOCK_ALLOW_PRIVATE_URLS;
process.env.AIMOCK_ALLOW_PRIVATE_URLS = "1";
});

afterEach(() => {
if (envBackup === undefined) delete process.env.XDG_CACHE_HOME;
else process.env.XDG_CACHE_HOME = envBackup;
if (allowPrivateBackup === undefined) delete process.env.AIMOCK_ALLOW_PRIVATE_URLS;
else process.env.AIMOCK_ALLOW_PRIVATE_URLS = allowPrivateBackup;
rmSync(cacheDir, { recursive: true, force: true });
});

it("starts successfully with --proxy-only and a URL-only --fixtures source", async () => {
const fixtureServer = await startHttpServer((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(REMOTE_FIXTURE_BODY);
});
// Dummy upstream provider target — proxy-only never proxies unmatched reqs in this test,
// but --provider-openai must be set so the recordConfig gate accepts the invocation.
const upstream = await startHttpServer((_req, res) => {
res.writeHead(200);
res.end("ok");
});
try {
const child = spawnCli([
"--proxy-only",
"--provider-openai",
upstream.url,
"--fixtures",
`${fixtureServer.url}/fx.json`,
"--port",
"0",
]);
await child.waitForOutput(/listening on/i, 8000);
// Must load the remote fixture and NOT error with the recordBase URL message.
expect(child.stdout()).toContain("Loaded 1 fixture(s)");
expect(child.stderr()).not.toMatch(
/requires a local --fixtures path for the recording destination/,
);
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.cp.on("close", () => resolve());
});
} finally {
await fixtureServer.close();
await upstream.close();
}
});

it("preserves --record rejection of URL-only --fixtures (regression guard)", async () => {
// --record writes to disk, so a URL source is genuinely unsupported. Must still error.
const { stderr, code } = await runCli(
[
"--record",
"--provider-openai",
"http://127.0.0.1:59999",
"--fixtures",
"http://127.0.0.1:59998/fx.json",
"--port",
"0",
],
{ timeout: 5000 },
);
expect(stderr).toMatch(/requires a local --fixtures path for the recording destination/);
expect(code).toBe(1);
});

it("accepts --proxy-only with mixed local + URL --fixtures", async () => {
const fixtureServer = await startHttpServer((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(REMOTE_FIXTURE_BODY);
});
const upstream = await startHttpServer((_req, res) => {
res.writeHead(200);
res.end("ok");
});
const tmp = mkdtempSync(join(tmpdir(), "cli-mixed-fixtures-"));
try {
const localPath = join(tmp, "local.json");
writeFileSync(
localPath,
JSON.stringify({
fixtures: [{ match: { userMessage: "local" }, response: { content: "local response" } }],
}),
"utf-8",
);
const child = spawnCli([
"--proxy-only",
"--provider-openai",
upstream.url,
"--fixtures",
localPath,
"--fixtures",
`${fixtureServer.url}/fx.json`,
"--port",
"0",
]);
await child.waitForOutput(/listening on/i, 8000);
expect(child.stdout()).toContain("Loaded 2 fixture(s)");
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.cp.on("close", () => resolve());
});
} finally {
await fixtureServer.close();
await upstream.close();
rmSync(tmp, { recursive: true, force: true });
}
});

it("starts successfully with --agui-proxy-only and URL-only --fixtures", async () => {
const fixtureServer = await startHttpServer((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(REMOTE_FIXTURE_BODY);
});
const aguiUpstream = await startHttpServer((_req, res) => {
res.writeHead(200);
res.end("ok");
});
try {
const child = spawnCli([
"--agui-proxy-only",
"--agui-upstream",
aguiUpstream.url,
"--fixtures",
`${fixtureServer.url}/fx.json`,
"--port",
"0",
]);
await child.waitForOutput(/listening on/i, 8000);
expect(child.stdout()).toContain("Loaded 1 fixture(s)");
expect(child.stderr()).not.toMatch(
/requires a local --fixtures path for the recording destination/,
);
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.cp.on("close", () => resolve());
});
} finally {
await fixtureServer.close();
await aguiUpstream.close();
}
});
});
28 changes: 20 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,18 +193,24 @@ if (values.record || values["proxy-only"]) {
process.exit(1);
}

// For record mode, use the first --fixtures value as the base path.
// Remote URL sources are not supported as record destinations — bail out with a clear error.
// For --record, the first --fixtures value is the base path for the recording
// destination and must be a local filesystem path — writing to a URL is not supported.
// For --proxy-only, unmatched requests are forwarded without saving, so no writable
// destination is required; URL-only --fixtures is valid in that mode.
const recordBase = fixtureValues[0];
if (/^https?:\/\//i.test(recordBase)) {
const recordBaseIsUrl = /^https?:\/\//i.test(recordBase);
if (values.record && recordBaseIsUrl) {
console.error(
`Error: --record/--proxy-only requires a local --fixtures path for the recording destination; got URL ${recordBase}`,
`Error: --record requires a local --fixtures path for the recording destination; got URL ${recordBase}`,
);
process.exit(1);
}
record = {
providers,
fixturePath: resolve(recordBase, "recorded"),
// In proxy-only mode with only URL sources, fixturePath is never consumed
// (recorder.ts skips disk writes when proxyOnly is set). Leave it undefined
// rather than resolving a URL string as a filesystem path.
fixturePath: recordBaseIsUrl ? undefined : resolve(recordBase, "recorded"),
proxyOnly: values["proxy-only"],
};
}
Expand All @@ -216,17 +222,23 @@ if (values["agui-record"] || values["agui-proxy-only"]) {
console.error("Error: --agui-record/--agui-proxy-only requires --agui-upstream");
process.exit(1);
}
// --agui-record writes recorded AG-UI fixtures to disk, so a URL source is unsupported.
// --agui-proxy-only forwards without saving, so URL-only --fixtures is valid.
const aguiBase = fixtureValues[0];
if (/^https?:\/\//i.test(aguiBase)) {
const aguiBaseIsUrl = /^https?:\/\//i.test(aguiBase);
if (values["agui-record"] && aguiBaseIsUrl) {
console.error(
`Error: --agui-record/--agui-proxy-only requires a local --fixtures path for the recording destination; got URL ${aguiBase}`,
`Error: --agui-record requires a local --fixtures path for the recording destination; got URL ${aguiBase}`,
);
process.exit(1);
}
const agui = new AGUIMock();
agui.enableRecording({
upstream: values["agui-upstream"],
fixturePath: resolve(aguiBase, "agui-recorded"),
// In proxy-only mode with a URL-only --fixtures, the AG-UI recorder never
// writes to disk (see agui-recorder.ts). Leave fixturePath undefined rather
// than resolving a URL as a filesystem path.
fixturePath: aguiBaseIsUrl ? undefined : resolve(aguiBase, "agui-recorded"),
proxyOnly: values["agui-proxy-only"],
});
aguiMount = { path: "/agui", handler: agui };
Expand Down
Loading