From 86e18754f766cb92feb4d7a4006df64229eba63f Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 22 Apr 2026 19:22:21 -0700 Subject: [PATCH 1/3] fix(cli): allow --proxy-only with URL-only --fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recordBase URL check was previously guarded by `if (values.record || values["proxy-only"])` and rejected any first --fixtures value starting with http(s):// as an invalid record destination. For --record mode this is correct — recording writes JSON files to disk and needs a writable base path. For --proxy-only mode it is overbroad: proxy-only forwards unmatched requests without saving, and recorder.ts/agui-recorder.ts already skip all disk writes when proxyOnly is set. All-URL --fixtures invocations should therefore be valid. The URL-rejection now fires only for --record (and --agui-record). In --proxy-only mode with a URL first fixture, fixturePath is left undefined on the RecordConfig — the recorder code path that would consume it is skipped by the existing proxyOnly guard, so no dead filesystem writes are attempted. This unblocks the showcase-aimock Railway service which needs to run aimock in proxy-only mode against two remote GitHub raw fixture URLs with no local fallback. The previous workaround was a shell-wrapped startCommand that mkdirs a dummy /tmp/empty-fixtures before exec-ing aimock. --- src/cli.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f5fb8b3..600e90c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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"], }; } @@ -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 }; From 4dcb95b2292eb7ac183b02a0688ef4564339a3ee Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 22 Apr 2026 19:22:31 -0700 Subject: [PATCH 2/3] test(cli): red-green coverage for --proxy-only with URL-only --fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new cases under a new describe block "CLI: --proxy-only with URL-only --fixtures": - --proxy-only + URL-only --fixtures starts successfully, loads the remote fixture, and does not emit the recordBase rejection error. - --record + URL-only --fixtures still errors (regression guard — record writes to disk, URLs are genuinely unsupported as write targets). - --proxy-only + mixed local and URL --fixtures loads both (2 fixtures), preserving argv order (baseline coverage for the combined path). - --agui-proxy-only + URL-only --fixtures starts successfully (parallel AG-UI path gets the same fix). Tests were verified red against the pre-fix cli.ts (the two URL-only cases timed out waiting for "listening on" because the CLI exited with the recordBase error) and green against the fixed cli.ts. --- src/__tests__/cli.test.ts | 159 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index f3a41cb..0003249 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -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((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((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((resolve) => { + child.cp.on("close", () => resolve()); + }); + } finally { + await fixtureServer.close(); + await aguiUpstream.close(); + } + }); +}); From 5dcf0618b4cd50639ff7dc36bbfea70c69acb47d Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 22 Apr 2026 19:22:39 -0700 Subject: [PATCH 3/3] chore: release v1.14.8 Bumps @copilotkit/aimock to 1.14.8 with CHANGELOG entry for the --proxy-only URL-only --fixtures fix. --- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d065108..996292b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 5c9691c..55eaaef 100644 --- a/package.json +++ b/package.json @@ -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": [