From 1442e086e4200b4a5f16024a986bbca3eba5a69c Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Fri, 29 May 2026 06:44:11 +0200 Subject: [PATCH] fix(cli): allow nullable/optional apiKey in cliMitmStartSchema (#2857) Integrated into release/v3.8.6. --- .../api/cli-tools/antigravity-mitm/route.ts | 18 ++-- src/shared/validation/schemas.ts | 3 +- tests/unit/cli-mitm-schema.test.ts | 85 +++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 tests/unit/cli-mitm-schema.test.ts diff --git a/src/app/api/cli-tools/antigravity-mitm/route.ts b/src/app/api/cli-tools/antigravity-mitm/route.ts index f296d31417..05b5940aba 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/route.ts @@ -56,21 +56,23 @@ export async function POST(request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { apiKey: rawApiKey, sudoPassword } = validation.data; - // (#523) Extract keyId BEFORE validation — Zod strips unknown fields! - const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + const { apiKey: rawApiKey, keyId: rawKeyId, sudoPassword } = validation.data; + const apiKeyId = rawKeyId ?? null; const apiKey = await resolveApiKey(apiKeyId, rawApiKey); + if (!apiKey || apiKey === "sk_omniroute") { + return NextResponse.json( + { error: "Missing apiKey: provide a valid apiKey or a resolvable keyId" }, + { status: 400 } + ); + } const { startMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager.runtime"); const isWin = process.platform === "win32"; const isRootUser = !isWin && isRoot(); const pwd = sudoPassword || getCachedPassword() || ""; - if (!apiKey || (!isWin && !pwd && !isRootUser)) { - return NextResponse.json( - { error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" }, - { status: 400 } - ); + if (!isWin && !pwd && !isRootUser) { + return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); } const result = await startMitm(apiKey, pwd); diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 16e76e516e..8316329a94 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1996,7 +1996,8 @@ export const v1betaGeminiGenerateSchema = z }); export const cliMitmStartSchema = z.object({ - apiKey: z.string().trim().min(1, "Missing apiKey"), + apiKey: z.string().trim().min(1).nullable().optional(), + keyId: z.string().trim().min(1).nullable().optional(), sudoPassword: z.string().optional(), }); diff --git a/tests/unit/cli-mitm-schema.test.ts b/tests/unit/cli-mitm-schema.test.ts new file mode 100644 index 0000000000..7c723acfcd --- /dev/null +++ b/tests/unit/cli-mitm-schema.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { cliMitmStartSchema } from "../../src/shared/validation/schemas.ts"; +import { validateBody } from "../../src/shared/validation/helpers.ts"; +import { resolveApiKey } from "../../src/shared/services/apiKeyResolver.ts"; + +test("cliMitmStartSchema accepts a non-empty string apiKey", () => { + const result = validateBody(cliMitmStartSchema, { + apiKey: "sk-test-key-value", + sudoPassword: "password123", + }); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.apiKey, "sk-test-key-value"); + assert.equal(result.data.sudoPassword, "password123"); + } +}); + +test("cliMitmStartSchema accepts a null apiKey", () => { + const result = validateBody(cliMitmStartSchema, { + apiKey: null, + sudoPassword: "", + }); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.apiKey, null); + assert.equal(result.data.sudoPassword, ""); + } +}); + +test("cliMitmStartSchema accepts an omitted apiKey", () => { + const result = validateBody(cliMitmStartSchema, { + sudoPassword: "", + }); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.apiKey, undefined); + } +}); + +test("cliMitmStartSchema accepts and parses keyId correctly", () => { + const result = validateBody(cliMitmStartSchema, { + keyId: "api-key-id-123", + sudoPassword: "password", + }); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.keyId, "api-key-id-123"); + assert.equal(result.data.apiKey, undefined); + } +}); + +test("cliMitmStartSchema accepts null keyId", () => { + const result = validateBody(cliMitmStartSchema, { + keyId: null, + sudoPassword: "", + }); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.keyId, null); + } +}); + +// Regression test: null apiKey + unresolvable keyId must yield the sentinel 'sk_omniroute', +// which the route guard must reject with a 400 rather than letting it pass to startMitm. +test("resolveApiKey returns sentinel when apiKey is null and keyId is null", async () => { + const result = await resolveApiKey(null, null); + assert.equal( + result, + "sk_omniroute", + "resolveApiKey should return the sentinel when no real key is available" + ); +}); + +test("sentinel guard condition catches sk_omniroute and null", () => { + const SENTINEL = "sk_omniroute"; + // Simulate what the route guard checks: (!apiKey || apiKey === 'sk_omniroute') + const shouldReject = (apiKey: string | null | undefined): boolean => + !apiKey || apiKey === SENTINEL; + + assert.equal(shouldReject(null), true, "null apiKey must be rejected"); + assert.equal(shouldReject(undefined), true, "undefined apiKey must be rejected"); + assert.equal(shouldReject("sk_omniroute"), true, "sentinel must be rejected"); + assert.equal(shouldReject("sk-real-key-abc"), false, "real key must be allowed"); +});