From 473974723dddbb7aba7e42da73dade824c7567c2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 17:00:35 +0800 Subject: [PATCH 01/12] feat(google): enable Gemini inline image output via responseModalities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on current dev (terminal-truth parser rework). - Parse inlineData parts in both streaming and non-streaming Google adapter paths, materializing images to OPENCODEX_HOME/artifacts with async writes, per-image (50 MiB) and per-response (100 MiB) decoded byte budgets, 0o700/0o600 permissions, and full-UUID filenames. - Restrict responseModalities=["TEXT","IMAGE"] to explicit image-capable models (gemini-3.1-flash-image class) — non-image Gemini, Vertex, Claude/GPT-on-Antigravity, and thinking models are unaffected. - Whitelist responseModalities in compileGenerationConfig so the setting survives CCA/Vertex wire compilation. - Add gemini-3.1-flash-image to the Antigravity model catalog (wire IDs, picker, context windows). - CCA /v1/images/generations fallback: gated to generations only, uses signalWithTimeout, caps response via arrayBuffer (IMAGES_RESPONSE_MAX_BYTES), redacts errors via safeAntigravityHttpErrorMessage. - Markdown paths escape spaces/parentheses; tests cover special-char dirs. - Update model-count pins in provider-registry-parity and antigravity-wire. --- .../content/docs/guides/codex-integration.md | 9 +- .../src/content/docs/reference/adapters.md | 6 + src/adapters/google-wire-compiler.ts | 4 + src/adapters/google.ts | 32 +- src/images/artifacts.ts | 78 +++++ src/providers/antigravity-models.ts | 3 + src/server/images.ts | 161 +++++++++- tests/google-antigravity-wire.test.ts | 1 + tests/images/gemini-inline.test.ts | 289 ++++++++++++++++++ tests/provider-registry-parity.test.ts | 3 +- tests/server-images.test.ts | 131 ++++++++ 11 files changed, 710 insertions(+), 7 deletions(-) create mode 100644 src/images/artifacts.ts create mode 100644 tests/images/gemini-inline.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index cd4fe810af..620a6dab22 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -47,8 +47,8 @@ points at opencodex, the proxy relays those calls to the OpenAI upstream: fails closed and never falls back to a different paid upstream. Registry-managed provider ids are not accepted here; omit `images.provider` to use the built-in OpenAI tiers. - **Neither:** the proxy returns a clear error instead of a generic 404. Routed providers - (Cursor, Gemini, Kiro, …) cannot serve image generation; if you don't want the tool offered at - all, disable it in Codex with `codex features disable image_generation` + (Cursor, Gemini, Kiro, …) cannot serve the `image_generation` tool relay; if you don't want the + tool offered at all, disable it in Codex with `codex features disable image_generation` (`[features] image_generation = false` in `config.toml`). For an OpenAI-compatible custom gateway, configure a dedicated provider and select it only for @@ -75,6 +75,11 @@ The custom endpoint must accept `POST /v1/images/generations` and `/v1/images/ed OpenAI Images response shape expected by Codex. The provider's configured key replaces any caller bearer before the upstream request. +> **Note:** This refers only to the Codex `image_generation` tool (`/images/generations` relay). +> Gemini models that are image-capable produce inline images natively through the `google` adapter +> (via `responseModalities: ["TEXT", "IMAGE"]`), independent of this relay — see +> [Adapters](/reference/adapters/#google). + For a non-loopback `hostname`, Codex must send the generated API auth header. The injector therefore uses a dedicated provider instead: diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1cce676797..32463ad464 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -79,6 +79,12 @@ streams the response back **untranslated**. `functionDeclarations`. Data-URL images → `inline_data`. - Tool-call ids are synthesized when Gemini omits them. Antigravity preserves and replays real `thoughtSignature` values so reasoning continuity survives later turns. +- **Inline image output:** when the model is image-capable (`gemini-3.1-flash-image`, + `gemini-2.0-flash-preview-image-generation`, `imagen-4.0-generate-001`, or any model id matching + both `gemini` and `image`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. Returned + `inlineData` parts are materialized to `~/.config/opencodex/artifacts/` and surfaced to the client + as a markdown image link (`![image](path)`). Each image is capped at 50 MB and each response at + 100 MB of decoded data; malformed base64 payloads are rejected. ## `kiro` diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index 87100b0b00..88c482ba7d 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -137,6 +137,10 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); if (thinkingLevel) out.thinkingConfig = { thinkingLevel }; } + if (Array.isArray(value.responseModalities)) { + const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); + if (valid.length > 0) out.responseModalities = valid; + } return Object.keys(out).length > 0 ? out : undefined; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index db627a1743..a8567c62eb 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,6 +1,7 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; +import { createImageBudget, materializeInlineImage } from "../images/artifacts"; import type { AdapterEvent, OcxAssistantMessage, @@ -233,6 +234,17 @@ function usageFromGemini(usage: Record | undefined): OcxUsage | }; } +const IMAGE_CAPABLE_MODELS = new Set([ + "gemini-3.1-flash-image", + "gemini-2.0-flash-preview-image-generation", + "imagen-4.0-generate-001", +]); + +function isImageCapableModel(modelId: string): boolean { + if (IMAGE_CAPABLE_MODELS.has(modelId)) return true; + return /image/.test(modelId) && /gemini/.test(modelId); +} + export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest // can stash the CCA model/session for parseStream's reasoning-replay observation. @@ -272,6 +284,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning) : undefined; if (directFlashThinking) generationConfig.thinkingConfig = { thinkingLevel: directFlashThinking }; + if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) { + generationConfig.responseModalities = ["TEXT", "IMAGE"]; + } if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig; const method = parsed.stream ? "streamGenerateContent" : "generateContent"; @@ -387,7 +402,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let sawAnyFrame = false; let sawTerminalSignal = false; - const handleDataLine = function* (line: string): Generator { + const handleDataLine = async function* (line: string): AsyncGenerator { const payload = line.slice(5).trim(); if (!payload) return "continue"; let emittedContentEvent = false; @@ -452,6 +467,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte emittedContentEvent = true; yield { type: "text_delta", text: part.text }; } + const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; + if (inline && typeof inline.data === "string") { + const filePath = await materializeInlineImage(inline.data, imageBudget); + const escapedPath = filePath.replace(/([() ])/g, "\\$1"); + emittedContentEvent = true; + yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; + } if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; @@ -464,6 +486,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } return emittedContentEvent ? "content" : "continue"; }; + const imageBudget = createImageBudget(); try { while (true) { @@ -547,6 +570,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return [{ type: "error", message: "google response contained no candidates" }]; } let toolCallsStarted = 0; + const imageBudget = createImageBudget(); if (candidates?.[0]?.content?.parts) { // Non-streaming CCA: observe thoughtSignatures for the next turn, same as the stream path. if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession) { @@ -554,6 +578,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } for (const part of candidates[0].content.parts) { if (part.text) events.push({ type: "text_delta", text: part.text }); + const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; + if (inline && typeof inline.data === "string") { + const filePath = await materializeInlineImage(inline.data, imageBudget); + const escapedPath = filePath.replace(/([() ])/g, "\\$1"); + events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); + } if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts new file mode 100644 index 0000000000..b1ef3110db --- /dev/null +++ b/src/images/artifacts.ts @@ -0,0 +1,78 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { getConfigDir } from "../config"; + +const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; +const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; + +// Strict alphabet check: Buffer.from(..., "base64") silently ignores invalid +// characters, so malformed payloads would otherwise decode to garbage bytes. +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; + +export interface ImageBudget { + spent: number; +} + +export function createImageBudget(): ImageBudget { + return { spent: 0 }; +} + +/** + * Sniff the real image format from leading magic bytes rather than trusting an + * upstream-declared MIME type, which may be missing or spoofed. + */ +export function guessExtFromMagic(bytes: Uint8Array): string { + const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); + if (sig.startsWith("\x89PNG")) return "png"; + if (sig.startsWith("\xff\xd8\xff")) return "jpg"; + if (sig.startsWith("RIFF") && sig.slice(8, 12) === "WEBP") return "webp"; + if (sig.startsWith("GIF8")) return "gif"; + throw new Error("unrecognized image format — magic bytes do not match PNG, JPEG, WebP, or GIF"); +} + +export async function materializeInlineImage( + base64Data: string, + budget?: ImageBudget, +): Promise { + const dir = join(getConfigDir(), "artifacts"); + await mkdir(dir, { recursive: true, mode: 0o700 }); + + const normalized = base64Data.replace(/\s+/g, ""); + if (!BASE64_RE.test(normalized) || normalized.length % 4 !== 0) { + throw new Error("inline image data is not valid base64"); + } + // Validate decoded size from the base64 length *before* allocating a Buffer, so a + // malicious or broken upstream cannot force a large allocation / OOM. + const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0; + const decodedBytes = (normalized.length / 4) * 3 - padding; + if (decodedBytes === 0) throw new Error("inline image data is empty after base64 decode"); + if (decodedBytes > MAX_DECODED_BYTES_PER_IMAGE) throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`); + if (budget && budget.spent + decodedBytes > MAX_DECODED_BYTES_PER_RESPONSE) { + throw new Error(`inline image response exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response cap`); + } + + const buf = Buffer.from(normalized, "base64"); + if (budget) budget.spent += buf.length; + + // Determine the extension from the actual decoded bytes, not an upstream MIME + // label, so a spoofed or missing type cannot misname the file. + const ext = guessExtFromMagic(buf); + + const now = new Date(); + const ts = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + "-", + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + "-", + String(now.getMilliseconds()).padStart(3, "0"), + ].join(""); + const suffix = crypto.randomUUID(); + const filePath = join(dir, `img-${ts}-${suffix}.${ext}`); + + await writeFile(filePath, buf, { mode: 0o600 }); + return filePath; +} diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index e241e1e330..18b0b39612 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -13,6 +13,7 @@ const ANTIGRAVITY_WIRE_MODELS = [ "gemini-3.6-flash-high", "gemini-3.1-pro-low", "gemini-pro-agent", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", @@ -85,6 +86,7 @@ export const ANTIGRAVITY_MODEL_ALIASES: Record = { export const ANTIGRAVITY_MODELS = [ "gemini-3.6-flash", "gemini-3.1-pro", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", @@ -97,6 +99,7 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record = { "gemini-3.6-flash-high": 1_048_576, "gemini-3.1-pro-low": 1_048_576, "gemini-pro-agent": 1_048_576, + "gemini-3.1-flash-image": 1_048_576, "claude-sonnet-4-6": 200_000, "claude-opus-4-6-thinking": 1_000_000, "gpt-oss-120b-medium": 131_072, diff --git a/src/server/images.ts b/src/server/images.ts index ea6b7a9a43..549d6d1bb8 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -25,10 +25,14 @@ import { signalWithTimeout } from "../lib/abort"; import { sidecarEnter } from "../lib/sidecar-tracker"; import type { OcxConfig } from "../types"; import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar"; +import { getProviderRegistryEntry } from "../providers/registry"; import { readJsonRequestBody } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; import type { RequestLogContext } from "./request-log"; import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; +import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index"; +import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors"; +import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error"; export type ImagesEndpoint = "generations" | "edits"; @@ -42,6 +46,155 @@ const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000; */ const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024; +const CCA_IMAGE_MODEL = "gemini-3.1-flash-image"; + +async function tryCcaImageGeneration( + body: unknown, + config: OcxConfig, + logCtx: RequestLogContext, + signal: AbortSignal, + endpoint: ImagesEndpoint, +): Promise { + if (endpoint !== "generations") return undefined; + const provider = config.providers?.["google-antigravity"]; + if (!provider || provider.disabled) return undefined; + + const prompt = (body as { prompt?: unknown })?.prompt; + if (typeof prompt !== "string" || !prompt) return undefined; + + let token: string; + try { + token = await getValidAccessToken("google-antigravity"); + } catch { + // Refresh failures (revoked grant, network error, …) silently fall back to + // the OpenAI image path. The OAuth refresh error message is NOT surfaced + // here — it would leak refresh-state internals to the client. + return undefined; + } + const project = getOAuthCredentialProjectId("google-antigravity"); + if (!project) return undefined; + + logCtx.provider = "google-antigravity"; + logCtx.model = CCA_IMAGE_MODEL; + + // Pin to the registry endpoint — never use a config-level baseUrl override for OAuth token transmission. + const registryEntry = getProviderRegistryEntry("google-antigravity"); + const baseUrl = registryEntry?.baseUrl ?? "https://daily-cloudcode-pa.googleapis.com"; + const envelope = { + model: CCA_IMAGE_MODEL, + userAgent: "antigravity", + requestType: "agent", + project, + requestId: `agent-${crypto.randomUUID()}`, + request: { + contents: [{ role: "user", parts: [{ text: prompt }] }], + generationConfig: { responseModalities: ["TEXT", "IMAGE"] }, + sessionId: `ocx-img-${crypto.randomUUID().slice(0, 8)}`, + }, + }; + + const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; + const linkedSignal = signalWithTimeout(timeoutMs, signal); + let upstream: Response; + try { + upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "User-Agent": "opencodex-images/1.0", + }, + body: JSON.stringify(envelope), + signal: linkedSignal.signal, + }); + } catch (err) { + if (signal.aborted) return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + if (err instanceof Error && err.name === "TimeoutError") { + return formatErrorResponse(504, "upstream_error", "CCA image generation timed out"); + } + // Network/DNS/runtime errors may embed the request URL or headers verbatim + // (e.g. "fetch failed: https://…/v1internal:generateContent"). The token + // lives in an Authorization header, not in the URL, but sanitize defensively + // so no upstream-rejected credential or query param can reach the client, + // and strip the internal base URL host from the surfaced message. + const rawMsg = err instanceof Error ? err.message : String(err); + const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace( + /https?:\/\/[^\s"'<>]+/gi, + "[upstream-url]", + ); + return formatErrorResponse(502, "upstream_error", `CCA image generation failed: ${safeMsg}`); + } + + // Stream the upstream body with a bounded reader so an oversized or malicious + // response is rejected mid-stream rather than after a full arrayBuffer() allocation. + let payload: Uint8Array; + try { + const reader = upstream.body?.getReader(); + if (!reader) { + linkedSignal.cleanup(); + return formatErrorResponse(502, "upstream_error", "CCA image response had no body"); + } + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > IMAGES_RESPONSE_MAX_BYTES) { + await reader.cancel().catch(() => {}); + linkedSignal.cleanup(); + return formatErrorResponse(502, "upstream_error", `CCA image response too large (exceeded ${IMAGES_RESPONSE_MAX_BYTES} bytes)`); + } + chunks.push(value); + } + } finally { + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + } + payload = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + payload.set(chunk, offset); + offset += chunk.byteLength; + } + } finally { + linkedSignal.cleanup(); + } + + if (!upstream.ok) { + // Preserve auth/rate-limit signals so callers can distinguish retryable from permanent failures. + const text = new TextDecoder().decode(payload); + const status = upstream.status === 401 || upstream.status === 403 || upstream.status === 429 + ? upstream.status + : 502; + return formatErrorResponse(status, "upstream_error", safeAntigravityHttpErrorMessage(upstream.status, text)); + } + + let json: Record; + try { + json = JSON.parse(new TextDecoder().decode(payload)) as Record; + } catch { + return formatErrorResponse(502, "upstream_error", "CCA image response was not valid JSON"); + } + const resp = (json.response ?? json) as { candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[] }; + const parts = resp.candidates?.[0]?.content?.parts ?? []; + const images: { b64_json: string }[] = []; + for (const part of parts) { + if (part.inlineData?.data) images.push({ b64_json: part.inlineData.data }); + } + if (images.length === 0) { + return formatErrorResponse(502, "upstream_error", "CCA image model returned no image data"); + } + // Only `{created, data:[{b64_json}]}` is returned — no token, projectId, or + // upstream metadata leak through. The Authorization header is consumed by the + // fetch above and never copied onto this Response. + return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: images }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + export async function handleImages( req: Request, config: OcxConfig, @@ -70,14 +223,16 @@ export async function handleImages( if (typeof model === "string" && model) logCtx.model = model; if (candidates.forwardCandidates.length === 0 && !candidates.keyed) { + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + if (ccaResponse) return ccaResponse; // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent // configuration state that must surface on the first attempt. return formatErrorResponse( 400, "invalid_request_error", - "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider), " - + "but none is configured in opencodex. Routed providers cannot serve /v1/images/* — " - + "add an OpenAI provider or disable the tool with `codex features disable image_generation`.", + "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider) " + + "or a logged-in Google Antigravity (Cloud Code Assist) provider, " + + "but none is configured in opencodex. Add a provider or disable the tool with `codex features disable image_generation`.", ); } diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 65ea305641..148e40650e 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -72,6 +72,7 @@ describe("antigravity CCA envelope", () => { expect(ANTIGRAVITY_MODELS).toEqual([ "gemini-3.6-flash", "gemini-3.1-pro", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", diff --git a/tests/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts new file mode 100644 index 0000000000..ff175d7a14 --- /dev/null +++ b/tests/images/gemini-inline.test.ts @@ -0,0 +1,289 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { createImageBudget, guessExtFromMagic, materializeInlineImage } from "../../src/images/artifacts"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { createGoogleAdapter } from "../../src/adapters/google"; +import type { AdapterEvent, OcxProviderConfig } from "../../src/types"; + +let tempHome: string; +let artifactsDir: string; +let savedHome: string | undefined; + +beforeAll(() => { + savedHome = process.env.OPENCODEX_HOME; + tempHome = mkdtempSync(join(tmpdir(), "ocx-test-")); + process.env.OPENCODEX_HOME = tempHome; + artifactsDir = join(tempHome, "artifacts"); +}); + +afterAll(() => { + if (savedHome !== undefined) process.env.OPENCODEX_HOME = savedHome; + else delete process.env.OPENCODEX_HOME; + rmSync(tempHome, { recursive: true, force: true }); +}); + +// 1x1 red PNG pixel in base64 (real PNG magic bytes: 89 50 4E 47) +const TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="; + +// Minimal JPEG byte stream (magic bytes FF D8 FF E0 ...) encoded as base64. +const TINY_JPEG = "/9j/4AAQ"; + +function sseResponse(chunks: unknown[]): Response { + const body = chunks.map(c => `data: ${JSON.stringify(c)}\n`).join("\n") + "\n"; + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +function jsonResponse(obj: unknown): Response { + return new Response(JSON.stringify(obj), { status: 200, headers: { "content-type": "application/json" } }); +} + +async function collectStream(provider: OcxProviderConfig, chunks: unknown[]): Promise { + const adapter = createGoogleAdapter(provider); + const events: AdapterEvent[] = []; + for await (const ev of adapter.parseStream(sseResponse(chunks))) events.push(ev); + return events; +} + +const aiStudioProvider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" } as OcxProviderConfig; + +describe("guessExtFromMagic", () => { + test("PNG magic bytes → png extension", () => { + // PNG file header: 89 50 4E 47 0D 0A 1A 0A + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(guessExtFromMagic(pngBytes)).toBe("png"); + }); + + test("JPEG magic bytes → jpg extension", () => { + const jpgBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + expect(guessExtFromMagic(jpgBytes)).toBe("jpg"); + }); + + test("WebP magic bytes → webp extension", () => { + const webpBytes = Buffer.from("RIFF\x00\x00\x00\x00WEBP", "latin1"); + expect(guessExtFromMagic(webpBytes)).toBe("webp"); + }); + + test("GIF magic bytes → gif extension", () => { + const gifBytes = Buffer.from("GIF89a", "latin1"); + expect(guessExtFromMagic(gifBytes)).toBe("gif"); + }); + + test("unrecognized magic bytes throw (no silent 'png' fallback)", () => { + // Empty buffer and random bytes previously fell back to "png"; now they must throw. + expect(() => guessExtFromMagic(new Uint8Array())).toThrow("unrecognized image format"); + expect(() => guessExtFromMagic(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toThrow("unrecognized image format"); + }); +}); + +describe("CCA image endpoint registry pinning (token-exfiltration guard)", () => { + test("google-antigravity registry baseUrl is the official CCA endpoint", () => { + // tryCcaImageGeneration must derive the upstream URL from the registry, not from + // a config-level baseUrl override, so a tampered baseUrl cannot redirect the + // Google OAuth bearer token to an attacker-controlled host. + const entry = getProviderRegistryEntry("google-antigravity"); + expect(entry).toBeDefined(); + expect(entry!.baseUrl).toBe("https://daily-cloudcode-pa.googleapis.com"); + }); + + test("the pinned URL is never an attacker-controlled host", () => { + // Even if a caller mutates a provider config to point baseUrl at evil.example, + // the image relay ignores it: the registry entry is the source of truth. + const evilUrl = "https://evil.example.com"; + const pinned = getProviderRegistryEntry("google-antigravity")?.baseUrl; + expect(pinned).not.toBe(evilUrl); + expect(pinned).toBe("https://daily-cloudcode-pa.googleapis.com"); + }); +}); + +describe("materializeInlineImage", () => { + test("writes a file and returns an absolute path", async () => { + const result = await materializeInlineImage(TINY_PNG); + expect(isAbsolute(result)).toBe(true); + expect(existsSync(result)).toBe(true); + const buf = readFileSync(result); + expect(buf.length).toBeGreaterThan(0); + }); + + test("extension follows real bytes, not an upstream MIME label", async () => { + // TINY_PNG carries genuine PNG magic bytes → always .png regardless of label. + expect((await materializeInlineImage(TINY_PNG)).endsWith(".png")).toBe(true); + // TINY_JPEG carries genuine JPEG magic bytes → always .jpg. + expect((await materializeInlineImage(TINY_JPEG)).endsWith(".jpg")).toBe(true); + }); + + test("spoofed MIME: JPEG bytes that upstream declared as image/png → saved as .jpg", async () => { + // Before the magic-byte fix, an upstream "image/png" label forced a .png name + // even when the bytes were JPEG. The extension now comes from the actual bytes. + const path = await materializeInlineImage(TINY_JPEG); + expect(path.endsWith(".jpg")).toBe(true); + }); + + test("creates the artifacts directory if missing", async () => { + rmSync(artifactsDir, { recursive: true, force: true }); + expect(existsSync(artifactsDir)).toBe(false); + const result = await materializeInlineImage(TINY_PNG); + expect(existsSync(artifactsDir)).toBe(true); + expect(existsSync(result)).toBe(true); + }); + + test("produces unique filenames for same-millisecond calls", async () => { + const a = await materializeInlineImage(TINY_PNG); + const b = await materializeInlineImage(TINY_PNG); + expect(a).not.toBe(b); + expect(existsSync(a)).toBe(true); + expect(existsSync(b)).toBe(true); + }); + + test("throws on empty base64 data", async () => { + await expect(materializeInlineImage("")).rejects.toThrow("empty"); + }); + + test("throws on malformed nonempty base64 data", async () => { + await expect(materializeInlineImage("abc!")).rejects.toThrow("not valid base64"); + await expect(materializeInlineImage("abc")).rejects.toThrow("not valid base64"); + }); + + test("enforces per-response budget", async () => { + const budget = createImageBudget(); + budget.spent = 100 * 1024 * 1024; // already at cap + await expect(materializeInlineImage(TINY_PNG, budget)).rejects.toThrow("per-response"); + }); +}); + +describe("google adapter — inline image streaming", () => { + test("yields markdown text_delta when a chunk contains inlineData", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + expect(textEvents[0].text).toMatch(/^\n!\[image\]\(.+\.png\)\n$/); + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test("behaves unchanged when no inlineData is present (regression)", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ text: "hello world" }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 2 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + expect(textEvents[0].text).toBe("hello world"); + const done = events.find(e => e.type === "done") as Extract; + expect(done.usage?.inputTokens).toBe(3); + expect(done.usage?.outputTokens).toBe(2); + }); + + test("empty inlineData.data is rejected in streaming mode", async () => { + await expect(collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "" } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ])).rejects.toThrow("empty"); + }); +}); + +describe("google adapter — inline image non-streaming", () => { + test("parseResponse returns markdown text for inlineData parts", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ text: "Here is a cat:" }, { inlineData: { mimeType: "image/jpeg", data: TINY_JPEG } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 10 }, + })); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(2); + expect(textEvents[0].text).toBe("Here is a cat:"); + expect(textEvents[1].text).toMatch(/^\n!\[image\]\(.+\.jpg\)\n$/); + }); + + test("empty inlineData.data is rejected, not silently skipped", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + // In the non-streaming path, materializeInlineImage throws and propagates out of parseResponse. + await expect(adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "" } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }))).rejects.toThrow("empty"); + }); +}); + +describe("responseModalities gating", () => { + test("image-capable model gets responseModalities in compiled wire body", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const req = await adapter.buildRequest({ + context: { messages: [], tools: [] }, + options: {}, + modelId: "gemini-3.1-flash-image", + stream: false, + } as never); + const body = JSON.parse(req.body); + expect(body.generationConfig.responseModalities).toEqual(["TEXT", "IMAGE"]); + }); + + test("non-image model does NOT get responseModalities", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const req = await adapter.buildRequest({ + context: { messages: [], tools: [] }, + options: {}, + modelId: "gemini-3.6-flash", + stream: false, + } as never); + const body = JSON.parse(req.body); + expect(body.generationConfig).toBeUndefined(); + }); +}); + +describe("markdown path escaping with special characters", () => { + let specialHome: string; + let savedHome: string | undefined; + + beforeAll(() => { + savedHome = process.env.OPENCODEX_HOME; + specialHome = mkdtempSync(join(tmpdir(), "ocx test (dir) ")); + process.env.OPENCODEX_HOME = specialHome; + }); + + afterAll(() => { + if (savedHome !== undefined) process.env.OPENCODEX_HOME = savedHome; + else delete process.env.OPENCODEX_HOME; + rmSync(specialHome, { recursive: true, force: true }); + }); + + test("streaming: escapes spaces and parentheses in emitted markdown path", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); + expect(match).not.toBeNull(); + const mdPath = match![1]; + expect(mdPath).toContain("ocx\\ test\\ \\(dir\\)\\ "); + expect(mdPath).not.toMatch(/(? { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/webp", data: TINY_PNG } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 2, candidatesTokenCount: 3 }, + })); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); + expect(match).not.toBeNull(); + const mdPath = match![1]; + expect(mdPath).toContain("ocx\\ test\\ \\(dir\\)\\ "); + const unescaped = mdPath.replace(/\\([() ])/g, "$1"); + expect(existsSync(unescaped)).toBe(true); + }); +}); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 9dfa5e5e0e..95a5d3c041 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -608,7 +608,8 @@ describe("provider registry parity", () => { expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("claude-sonnet-4-6"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("claude-opus-4-6-thinking"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gpt-oss-120b-medium"); - expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toHaveLength(5); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gemini-3.1-flash-image"); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toHaveLength(6); // Effort ladders on collapsed base models. expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.6-flash"]).toEqual(["low", "medium", "high"]); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.1-pro"]).toEqual(["low", "high"]); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index ee24640e0b..ec1116a988 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -12,6 +12,7 @@ import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../src/codex/ro import { saveConfig } from "../src/config"; import { selectImagesProvider } from "../src/providers/openai-sidecar"; import { startServer } from "../src/server"; +import { saveCredential } from "../src/oauth/store"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -834,3 +835,133 @@ test("the proxy admission secret is never relayed to the forward upstream", asyn await upstream.stop(true); } }); + +// ── Google Antigravity (CCA) image generation fallback ── + +function ccaConfig(ccaBaseUrl?: string): OcxConfig { + return { + port: 0, + defaultProvider: "google-antigravity", + openaiProviderTierVersion: 2, + providers: { + openai: disabledOpenAiProvider, + ...(ccaBaseUrl ? { + "google-antigravity": { + adapter: "google", + baseUrl: ccaBaseUrl, + googleMode: "cloud-code-assist", + apiKey: "cca-access-token", + project: "cca-project-123", + allowPrivateNetwork: ccaBaseUrl.includes("localhost") || ccaBaseUrl.includes("127.0.0.1"), + } as OcxConfig["providers"][string], + } : {}), + }, + } as OcxConfig; +} + +test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { + const captured: CapturedRequest[] = []; + const upstream = Bun.serve({ + port: 0, + async fetch(req) { + captured.push({ + path: new URL(req.url).pathname, + headers: req.headers, + body: await req.json(), + }); + return Response.json({ + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }] }, + }], + }, + }); + }, + }); + + saveConfig(ccaConfig(upstream.url.toString().replace(/\/$/, ""))); + await saveCredential("google-antigravity", { + access: "cca-access-token", + refresh: "cca-refresh-token", + expires: Date.now() + 3_600_000, + projectId: "cca-project-123", + }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a neon cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { data: { b64_json: string }[] }; + expect(json.data).toHaveLength(1); + expect(json.data[0].b64_json).toBe("aGVsbG8="); + + expect(captured).toHaveLength(1); + expect(captured[0].path).toContain("generateContent"); + const body = captured[0].body as { model?: string; request?: { generationConfig?: { responseModalities?: string[] } } }; + expect(body.model).toBe("gemini-3.1-flash-image"); + expect(body.request?.generationConfig?.responseModalities).toEqual(["TEXT", "IMAGE"]); + expect(captured[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + +test("CCA image fallback preserves upstream 429 status", async () => { + const upstream = Bun.serve({ + port: 0, + fetch() { + return Response.json({ error: { message: "Rate limited" } }, { status: 429 }); + }, + }); + + saveConfig(ccaConfig(upstream.url.toString().replace(/\/$/, ""))); + await saveCredential("google-antigravity", { + access: "cca-access-token", + refresh: "cca-refresh-token", + expires: Date.now() + 3_600_000, + projectId: "cca-project-123", + }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(429); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + +test("CCA fallback does not serve image edits", async () => { + saveConfig(ccaConfig("https://daily-cloudcode-pa.googleapis.com")); + await saveCredential("google-antigravity", { + access: "cca-access-token", + refresh: "cca-refresh-token", + expires: Date.now() + 3_600_000, + projectId: "cca-project-123", + }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/edits", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "edit this", model: "gpt-image-2" }), + }); + // Edits should NOT hit the CCA fallback — it's text-to-image only. + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("image generation"); + } finally { + await server.stop(true); + } +}); From 9aa45a3086f973729c5e20973bdd57a9dab64b47 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 08:02:45 +0800 Subject: [PATCH 02/12] chore: adopt #424 artifacts module + destination-policy as base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace #355's standalone artifacts.ts with #424's fuller version (adds downloadImageToArtifact, SSRF protection, HTTPS enforcement). Also bring in destination-policy.ts exports needed by the module. Removes #355's duplicate guessExtFromMagic — #424's version is the canonical one. --- src/images/artifacts.ts | 114 +++++++++++++++++++++++++++------- src/lib/destination-policy.ts | 51 ++++++++++++++- 2 files changed, 141 insertions(+), 24 deletions(-) diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index b1ef3110db..0ed1a2aed7 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -1,9 +1,11 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { getConfigDir } from "../config"; +import { assessUrlDestination, assertUrlResolvesPublic } from "../lib/destination-policy"; const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; +const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB // Strict alphabet check: Buffer.from(..., "base64") silently ignores invalid // characters, so malformed payloads would otherwise decode to garbage bytes. @@ -17,24 +19,39 @@ export function createImageBudget(): ImageBudget { return { spent: 0 }; } -/** - * Sniff the real image format from leading magic bytes rather than trusting an - * upstream-declared MIME type, which may be missing or spoofed. - */ +function getArtifactsDir(): string { + return join(getConfigDir(), "artifacts"); +} + +function timestampPrefix(): string { + const now = new Date(); + return [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + "-", + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + "-", + String(now.getMilliseconds()).padStart(3, "0"), + ].join(""); +} + export function guessExtFromMagic(bytes: Uint8Array): string { const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); if (sig.startsWith("\x89PNG")) return "png"; if (sig.startsWith("\xff\xd8\xff")) return "jpg"; if (sig.startsWith("RIFF") && sig.slice(8, 12) === "WEBP") return "webp"; if (sig.startsWith("GIF8")) return "gif"; - throw new Error("unrecognized image format — magic bytes do not match PNG, JPEG, WebP, or GIF"); + return "png"; } export async function materializeInlineImage( base64Data: string, budget?: ImageBudget, ): Promise { - const dir = join(getConfigDir(), "artifacts"); + const dir = getArtifactsDir(); await mkdir(dir, { recursive: true, mode: 0o700 }); const normalized = base64Data.replace(/\s+/g, ""); @@ -54,25 +71,76 @@ export async function materializeInlineImage( const buf = Buffer.from(normalized, "base64"); if (budget) budget.spent += buf.length; - // Determine the extension from the actual decoded bytes, not an upstream MIME - // label, so a spoofed or missing type cannot misname the file. + // Sniff actual format from decoded bytes rather than trusting the declared mimeType. const ext = guessExtFromMagic(buf); + const filePath = join(dir, `img-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`); + await writeFile(filePath, buf, { mode: 0o600 }); + return filePath; +} - const now = new Date(); - const ts = [ - now.getFullYear(), - String(now.getMonth() + 1).padStart(2, "0"), - String(now.getDate()).padStart(2, "0"), - "-", - String(now.getHours()).padStart(2, "0"), - String(now.getMinutes()).padStart(2, "0"), - String(now.getSeconds()).padStart(2, "0"), - "-", - String(now.getMilliseconds()).padStart(3, "0"), - ].join(""); - const suffix = crypto.randomUUID(); - const filePath = join(dir, `img-${ts}-${suffix}.${ext}`); +export async function downloadImageToArtifact( + url: string, + budget?: ImageBudget, + signal?: AbortSignal, +): Promise { + if (url.startsWith("data:")) { + const m = /^data:([^;]+);base64,(.+)$/.exec(url); + if (!m) throw new Error("data URL is not a valid base64 image"); + return materializeInlineImage(m[2], budget); + } - await writeFile(filePath, buf, { mode: 0o600 }); + // SSRF protection: validate the provider-returned URL before fetching. + // Require HTTPS strictly — plain HTTP and all other schemes (ftp, file, …) are rejected. + let parsedUrl: URL; + try { parsedUrl = new URL(url); } catch { throw new Error("image URL is not valid"); } + if (parsedUrl.protocol !== "https:") { + throw new Error(`image URL must use HTTPS, got ${parsedUrl.protocol}`); + } + // Reject literal private/loopback/link-local/metadata addresses. + const assessment = assessUrlDestination(url); + if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") { + throw new Error(`image URL targets ${assessment.detail}`); + } + // DNS check: resolve hostname and reject if it points at private/internal space. + await assertUrlResolvesPublic(url); + const resp = await fetch(url, { signal, redirect: "error" }); + if (!resp.ok) throw new Error("image download failed: " + resp.status); + + // Stream the body with a hard byte cap so a missing/lying Content-Length or a + // compromised CDN URL cannot exhaust memory before the size check runs. + if (!resp.body) throw new Error("image download returned no body"); + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_DOWNLOAD_BYTES) { + throw new Error(`image download exceeds ${MAX_DOWNLOAD_BYTES} byte cap`); + } + chunks.push(value); + } + } finally { + try { await reader.cancel(); } catch { /* ignore cancel errors */ } + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { bytes.set(c, offset); offset += c.byteLength; } + + if (budget && budget.spent + bytes.length > MAX_DECODED_BYTES_PER_RESPONSE) { + throw new Error(`image download exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response budget`); + } + + const ext = guessExtFromMagic(bytes); + const dir = getArtifactsDir(); + await mkdir(dir, { recursive: true, mode: 0o700 }); + if (budget) budget.spent += bytes.length; + + const filePath = join(dir, `dl-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`); + await writeFile(filePath, bytes, { mode: 0o600 }); return filePath; } diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 54cffcbd19..1ec932bd4c 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -19,7 +19,7 @@ const BLOCKED_METADATA_IPV6 = new Set([ "fd00:ec2::254", ]); -type DestinationKind = +export type DestinationKind = | "public" | "hostname" | "localhost" @@ -178,3 +178,52 @@ export async function providerDestinationResolvedError( } return null; } + +export interface UrlDestinationAssessment { + kind: DestinationKind; + detail: string; +} + +/** + * Synchronous literal URL destination assessment — classifies the hostname + * without DNS resolution. Returns null for unparseable URLs. + */ +export function assessUrlDestination(url: string): UrlDestinationAssessment | null { + return assessDestination(url); +} + +/** + * Async DNS-resolved URL safety check. Resolves A/AAAA records and rejects + * if any address is loopback, private, link-local, unspecified, or metadata. + * Throws on unsafe destination; returns void on safe/public destination. + * DNS resolution failures are treated as unsafe (fail-closed). + */ +export async function assertUrlResolvesPublic(url: string): Promise { + let hostname: string; + try { + hostname = normalizeHostname(new URL(url.trim()).hostname); + } catch { + throw new Error("image URL is not a valid URL"); + } + if (!hostname) throw new Error("image URL has no hostname"); + const literalAssessment = assessDestination(url); + if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") { + throw new Error(`image URL targets ${literalAssessment.detail}`); + } + // For literal IPs and localhost, the sync path already classified them. + if (isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) return; + let addresses: { address: string }[]; + try { + addresses = await lookup(hostname, { all: true, verbatim: true }); + } catch { + // If DNS fails, we can't verify — fail-closed (unlike provider config-time validation, + // this is a runtime fetch to an untrusted URL, so be conservative). + throw new Error(`image URL hostname ${hostname} could not be resolved`); + } + for (const { address } of addresses) { + const ipKind = isIP(address); + const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; + if (!assessment || assessment.kind === "public") continue; + throw new Error(`image URL hostname ${hostname} resolves to ${assessment.detail} (${address})`); + } +} From 4f5a400c3342ba8a3aee18f61ebccd04437d13f9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 08:12:26 +0800 Subject: [PATCH 03/12] fix(google): address Wibias review blockers for #355 - Rewrite CCA tests to intercept registry host (not local URL) + sink-host regression - Strip home directory from artifact paths in google adapter markdown output - Add try/catch around materializeInlineImage to prevent stream abort on bad parts - Reject empty/whitespace prompts with 400 before OAuth token refresh - Document CCA /v1/images/generations fallback in codex-integration guide (+ locales) - Replace hardcoded artifact path in adapters docs with platform-agnostic wording - Add exclusive create (flag: wx) to artifact writes - guessExtFromMagic throws on unrecognized format (no silent png fallback) --- .../content/docs/guides/codex-integration.md | 6 + .../docs/ja/guides/codex-integration.md | 7 + .../docs/ko/guides/codex-integration.md | 7 + .../src/content/docs/reference/adapters.md | 3 +- .../docs/ru/guides/codex-integration.md | 8 + .../docs/zh-cn/guides/codex-integration.md | 7 + src/adapters/google.ts | 37 ++- src/images/artifacts.ts | 6 +- src/server/images.ts | 4 +- tests/images/gemini-inline.test.ts | 99 +++++++- tests/server-images.test.ts | 223 +++++++++++++----- 11 files changed, 325 insertions(+), 82 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 620a6dab22..8c611e381e 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -46,6 +46,12 @@ points at opencodex, the proxy relays those calls to the OpenAI upstream: `openai-responses` provider whose endpoint implements the OpenAI Images API. Explicit selection fails closed and never falls back to a different paid upstream. Registry-managed provider ids are not accepted here; omit `images.provider` to use the built-in OpenAI tiers. +- **Google Antigravity (CCA) fallback:** when neither an OpenAI forward candidate nor a keyed + provider is configured, `/v1/images/generations` (not `/images/edits`) falls back to the + Antigravity **Cloud Code Assist** endpoint using the `gemini-3.1-flash-image` model. This + requires `ocx login google-antigravity`; the OAuth token is sent only to the pinned CCA registry + host, never to a config-level `baseUrl` override. The response is returned in the same + `{created, data:[{b64_json}]}` shape Codex expects. - **Neither:** the proxy returns a clear error instead of a generic 404. Routed providers (Cursor, Gemini, Kiro, …) cannot serve the `image_generation` tool relay; if you don't want the tool offered at all, disable it in Codex with `codex features disable image_generation` diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 80c4c530c4..06e27252e4 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -47,6 +47,13 @@ ChatGPT bearer 認証で直接 POST します。注入された `base_url` が o 使う場合は省略してください。 - **両方なし:** 曖昧な 404 の代わりに明確なエラーを返します。ルーティングされる他のプロバイダー(Cursor、 Gemini、Kiro など)は既定では画像生成を提供できません。ツール自体をオフにしたい場合は Codex で +- **Google Antigravity (CCA) フォールバック:** OpenAI forward 候補も API キープロバイダーもない場合、 + `/v1/images/generations`(`/images/edits` を除く)は Antigravity **Cloud Code Assist** エンドポイントに + フォールバックし、`gemini-3.1-flash-image` モデルを使用します。`ocx login google-antigravity` が + 必要です。OAuth トークンは CCA レジストリホストにのみ送信され、設定の `baseUrl` オーバーライドには + 送信されません。レスポンスは Codex が期待する `{created, data:[{b64_json}]}` 形式で返されます。 +- **いずれもなし:** 曖昧な 404 の代わりに明確なエラーを返します。ルーティングされる他のプロバイダー(Cursor、 + Gemini、Kiro など)は画像生成を提供できません。ツール自体をオフにしたい場合は Codex で `codex features disable image_generation`(`config.toml` の `[features] image_generation = false`)を 使ってください。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index bcc3c1977b..587a4710d3 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -46,6 +46,13 @@ ChatGPT bearer 인증으로 직접 POST합니다. 주입된 `base_url`이 openco fallback하지 않습니다. 내장 프로바이더 id에는 사용하지 말고, 기본 OpenAI 경로를 쓰려면 생략하세요. - **둘 다 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor, Gemini, Kiro 등)는 기본적으로 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서 +- **Google Antigravity (CCA) 폴백:** OpenAI forward 후보와 API key 프로바이더 모두 없을 때, + `/v1/images/generations`(`/images/edits` 제외)가 Antigravity **Cloud Code Assist** 엔드포인트로 + 폴백되며 `gemini-3.1-flash-image` 모델을 사용합니다. `ocx login google-antigravity`가 필요합니다. + OAuth 토큰은 CCA 레지스트리 호스트로만 전송되며 설정의 `baseUrl` 재정의로는 가지 않습니다. + 응답은 Codex가 기대하는 `{created, data:[{b64_json}]}` 형식으로 반환됩니다. +- **모두 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor, + Gemini, Kiro 등)는 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서 `codex features disable image_generation`(`config.toml`의 `[features] image_generation = false`)을 사용하세요. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 32463ad464..aa949d55cf 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -82,7 +82,8 @@ streams the response back **untranslated**. - **Inline image output:** when the model is image-capable (`gemini-3.1-flash-image`, `gemini-2.0-flash-preview-image-generation`, `imagen-4.0-generate-001`, or any model id matching both `gemini` and `image`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. Returned - `inlineData` parts are materialized to `~/.config/opencodex/artifacts/` and surfaced to the client + `inlineData` parts are materialized to the `artifacts/` subdirectory of the configured OpenCodex + config directory and surfaced to the client as a markdown image link (`![image](path)`). Each image is capped at 50 MB and each response at 100 MB of decoded data; malformed base64 payloads are rejected. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index d6083929b5..12eda63bc7 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -52,6 +52,14 @@ fast_mode = true используются; чтобы сохранить стандартный путь OpenAI, опустите это поле. - **Ни того, ни другого:** прокси возвращает понятную ошибку вместо безликого 404. Маршрутизируемые провайдеры (Cursor, Gemini, Kiro, …) по умолчанию не могут обслуживать генерацию +- **Резерв Google Antigravity (CCA):** когда ни forward-кандидат OpenAI, ни провайдер с + API-ключом не настроены, `/v1/images/generations` (но не `/images/edits`) переключается на + эндпоинт Antigravity **Cloud Code Assist** с моделью `gemini-3.1-flash-image`. Требуется + `ocx login google-antigravity`; OAuth-токен отправляется только на закреплённый хост реестра + CCA, а не на `baseUrl` из конфигурации. Ответ возвращается в том же формате + `{created, data:[{b64_json}]}`, что ожидает Codex. +- **Ничего из перечисленного:** прокси возвращает понятную ошибку вместо безликого 404. + Маршрутизируемые провайдеры (Cursor, Gemini, Kiro, …) не могут обслуживать генерацию изображений; если вы вообще не хотите предлагать этот инструмент, отключите его в Codex командой `codex features disable image_generation` (`[features] image_generation = false` в `config.toml`). diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 9fc3a5d4a6..fef47f4a12 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -45,6 +45,13 @@ OpenAI 上游: fallback 到其他付费上游。内置 provider id 不适用于此字段;省略它即可使用默认 OpenAI 路径。 - **两者都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、 Kiro 等)默认无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行 +- **Google Antigravity(CCA)回退:** 当 OpenAI forward 候选和 API key 提供商都不存在时, + `/v1/images/generations`(不含 `/images/edits`)会回退到 Antigravity **Cloud Code Assist** + 端点,使用 `gemini-3.1-flash-image` 模型。需要 `ocx login google-antigravity`;OAuth token + 只发送到 CCA 注册端点,不会发送到配置中的 `baseUrl` 覆盖地址。返回格式与 Codex 期望的 + `{created, data:[{b64_json}]}` 一致。 +- **以上都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、 + Kiro 等)无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行 `codex features disable image_generation`(即 `config.toml` 的 `[features] image_generation = false`)。 diff --git a/src/adapters/google.ts b/src/adapters/google.ts index a8567c62eb..2c32089b5d 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,6 +1,7 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; +import { homedir } from "node:os"; import { createImageBudget, materializeInlineImage } from "../images/artifacts"; import type { AdapterEvent, @@ -245,6 +246,20 @@ function isImageCapableModel(modelId: string): boolean { return /image/.test(modelId) && /gemini/.test(modelId); } +/** + * Strip the home directory prefix from an artifact path so the absolute + * filesystem location (which embeds the username and config dir) never leaks + * into model-visible / client-visible text. An absolute home-rooted path + * becomes "~/.config/…/img.png". + */ +function sanitizeArtifactPath(filePath: string): string { + const home = homedir(); + if (home && filePath.startsWith(home)) { + return filePath.slice(home.length).replace(/^\//, "~/"); + } + return filePath; +} + export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest // can stash the CCA model/session for parseStream's reasoning-replay observation. @@ -469,10 +484,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; if (inline && typeof inline.data === "string") { - const filePath = await materializeInlineImage(inline.data, imageBudget); - const escapedPath = filePath.replace(/([() ])/g, "\\$1"); - emittedContentEvent = true; - yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; + try { + const filePath = await materializeInlineImage(inline.data, imageBudget); + const escapedPath = sanitizeArtifactPath(filePath).replace(/([() ])/g, "\\$1"); + emittedContentEvent = true; + yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; + } catch { + yield { type: "error", message: "failed to materialize inline image" }; + } } if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; @@ -580,9 +599,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (part.text) events.push({ type: "text_delta", text: part.text }); const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; if (inline && typeof inline.data === "string") { - const filePath = await materializeInlineImage(inline.data, imageBudget); - const escapedPath = filePath.replace(/([() ])/g, "\\$1"); - events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); + try { + const filePath = await materializeInlineImage(inline.data, imageBudget); + const escapedPath = sanitizeArtifactPath(filePath).replace(/([() ])/g, "\\$1"); + events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); + } catch { + events.push({ type: "error", message: "failed to materialize inline image" }); + } } if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index 0ed1a2aed7..7f49f3c982 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -44,7 +44,7 @@ export function guessExtFromMagic(bytes: Uint8Array): string { if (sig.startsWith("\xff\xd8\xff")) return "jpg"; if (sig.startsWith("RIFF") && sig.slice(8, 12) === "WEBP") return "webp"; if (sig.startsWith("GIF8")) return "gif"; - return "png"; + throw new Error("unrecognized image format — magic bytes do not match PNG, JPEG, WebP, or GIF"); } export async function materializeInlineImage( @@ -74,7 +74,7 @@ export async function materializeInlineImage( // Sniff actual format from decoded bytes rather than trusting the declared mimeType. const ext = guessExtFromMagic(buf); const filePath = join(dir, `img-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`); - await writeFile(filePath, buf, { mode: 0o600 }); + await writeFile(filePath, buf, { mode: 0o600, flag: "wx" }); return filePath; } @@ -141,6 +141,6 @@ export async function downloadImageToArtifact( if (budget) budget.spent += bytes.length; const filePath = join(dir, `dl-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`); - await writeFile(filePath, bytes, { mode: 0o600 }); + await writeFile(filePath, bytes, { mode: 0o600, flag: "wx" }); return filePath; } diff --git a/src/server/images.ts b/src/server/images.ts index 549d6d1bb8..f71cdec33b 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -60,7 +60,9 @@ async function tryCcaImageGeneration( if (!provider || provider.disabled) return undefined; const prompt = (body as { prompt?: unknown })?.prompt; - if (typeof prompt !== "string" || !prompt) return undefined; + if (typeof prompt !== "string" || !prompt.trim()) { + return formatErrorResponse(400, "invalid_request_error", "prompt is required and must not be empty"); + } let token: string; try { diff --git a/tests/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts index ff175d7a14..abc8e4fcf3 100644 --- a/tests/images/gemini-inline.test.ts +++ b/tests/images/gemini-inline.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; import { createImageBudget, guessExtFromMagic, materializeInlineImage } from "../../src/images/artifacts"; import { getProviderRegistryEntry } from "../../src/providers/registry"; @@ -179,11 +179,14 @@ describe("google adapter — inline image streaming", () => { expect(done.usage?.outputTokens).toBe(2); }); - test("empty inlineData.data is rejected in streaming mode", async () => { - await expect(collectStream(aiStudioProvider, [ + test("empty inlineData.data yields an error event but does not abort the stream", async () => { + const events = await collectStream(aiStudioProvider, [ { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "" } }] } }] }, { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, - ])).rejects.toThrow("empty"); + ]); + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); + // Stream still reaches a terminal done event. + expect(events.some(e => e.type === "done")).toBe(true); }); }); @@ -201,13 +204,13 @@ describe("google adapter — inline image non-streaming", () => { expect(textEvents[1].text).toMatch(/^\n!\[image\]\(.+\.jpg\)\n$/); }); - test("empty inlineData.data is rejected, not silently skipped", async () => { + test("empty inlineData.data yields an error event, not a rejection", async () => { const adapter = createGoogleAdapter(aiStudioProvider); - // In the non-streaming path, materializeInlineImage throws and propagates out of parseResponse. - await expect(adapter.parseResponse(jsonResponse({ + const events = await adapter.parseResponse(jsonResponse({ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "" } }] }, finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }))).rejects.toThrow("empty"); + })); + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); }); }); @@ -287,3 +290,83 @@ describe("markdown path escaping with special characters", () => { expect(existsSync(unescaped)).toBe(true); }); }); + +describe("artifact path sanitization (no home-directory leak)", () => { + let underHome: string; + let savedHome: string | undefined; + + beforeAll(() => { + savedHome = process.env.OPENCODEX_HOME; + // Place OPENCODEX_HOME *under* the real home so sanitizeArtifactPath kicks in. + underHome = mkdtempSync(join(homedir(), ".ocx-test-leak-")); + process.env.OPENCODEX_HOME = underHome; + }); + + afterAll(() => { + if (savedHome !== undefined) process.env.OPENCODEX_HOME = savedHome; + else delete process.env.OPENCODEX_HOME; + rmSync(underHome, { recursive: true, force: true }); + }); + + test("streaming: emitted path does not contain the raw home directory", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const md = textEvents[0].text; + // The username segment (e.g. /Users/tizerluo) must NOT appear in model-visible text. + expect(md).not.toContain(homedir()); + expect(md).not.toMatch(/\/Users\/|\/home\//); + // Path is abbreviated to ~/... + expect(md).toContain("~/"); + }); + + test("non-streaming: emitted path does not contain the raw home directory", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + })); + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const md = textEvents[0].text; + expect(md).not.toContain(homedir()); + expect(md).not.toMatch(/\/Users\/|\/home\//); + expect(md).toContain("~/"); + }); +}); + +describe("malformed inlineData does not abort the stream", () => { + test("streaming: sibling text + bad inline yields text_delta AND error event", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [ + { text: "before image" }, + { inlineData: { mimeType: "image/png", data: "!!!not-base64!!!" } }, + ] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + // The text part before the bad image is still emitted. + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.some(e => e.text === "before image")).toBe(true); + // An error event is emitted for the bad image. + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); + // The stream terminates normally. + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test("non-streaming: sibling text + bad inline yields text_delta AND error event", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [ + { text: "hello" }, + { inlineData: { mimeType: "image/png", data: "!!!not-base64!!!" } }, + ] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + })); + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.some(e => e.text === "hello")).toBe(true); + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); + }); +}); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index ec1116a988..9610078df7 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -838,54 +838,87 @@ test("the proxy admission secret is never relayed to the forward upstream", asyn // ── Google Antigravity (CCA) image generation fallback ── -function ccaConfig(ccaBaseUrl?: string): OcxConfig { +/** + * CCA config for image tests. The config-level baseUrl is deliberately set to an + * attacker host to prove the CCA path pins to the registry entry + * (daily-cloudcode-pa.googleapis.com) and ignores this override. The OAuth token + * comes from the credential store via getValidAccessToken, not from config apiKey. + */ +function ccaConfig(): OcxConfig { return { port: 0, defaultProvider: "google-antigravity", openaiProviderTierVersion: 2, providers: { openai: disabledOpenAiProvider, - ...(ccaBaseUrl ? { - "google-antigravity": { - adapter: "google", - baseUrl: ccaBaseUrl, - googleMode: "cloud-code-assist", - apiKey: "cca-access-token", - project: "cca-project-123", - allowPrivateNetwork: ccaBaseUrl.includes("localhost") || ccaBaseUrl.includes("127.0.0.1"), - } as OcxConfig["providers"][string], - } : {}), + "google-antigravity": { + adapter: "google", + baseUrl: "https://attacker.example.com", + googleMode: "cloud-code-assist", + } as OcxConfig["providers"][string], }, } as OcxConfig; } -test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { - const captured: CapturedRequest[] = []; - const upstream = Bun.serve({ - port: 0, - async fetch(req) { - captured.push({ - path: new URL(req.url).pathname, - headers: req.headers, - body: await req.json(), - }); - return Response.json({ - response: { - candidates: [{ - content: { parts: [{ inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }] }, - }], - }, - }); +interface CcaFetchRequest { + url: string; + headers: Headers; + body: unknown; +} + +/** + * Stub globalThis.fetch for CCA image tests: requests to the registry host + * (daily-cloudcode-pa.googleapis.com) get a canned response and are recorded in + * `registryHits`; requests to any other non-localhost host are recorded in + * `otherHits` (to prove the attacker host is never contacted); localhost requests + * pass through to the real network stack (the test proxy server). + */ +function ccaFetchMock( + registryHits: CcaFetchRequest[], + otherHits: CcaFetchRequest[], + response?: { status?: number; payload?: unknown }, +) { + const status = response?.status ?? 200; + const payload = response?.payload ?? { + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }] }, + }], }, - }); + }; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + const headers = new Headers(init?.headers); + let parsedBody: unknown; + if (init?.body && typeof init.body === "string") { + try { parsedBody = JSON.parse(init.body); } catch { /* non-JSON body */ } + } + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + registryHits.push({ url: requestUrl, headers, body: parsedBody }); + return Response.json(payload, { status }); + } + if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") { + otherHits.push({ url: requestUrl, headers, body: parsedBody }); + } + return originalFetch(input, init); + }) as typeof fetch; +} - saveConfig(ccaConfig(upstream.url.toString().replace(/\/$/, ""))); - await saveCredential("google-antigravity", { - access: "cca-access-token", - refresh: "cca-refresh-token", - expires: Date.now() + 3_600_000, - projectId: "cca-project-123", - }); +const CCA_CREDENTIAL = { + access: "cca-access-token", + refresh: "cca-refresh-token", + expires: Date.now() + 3_600_000, + projectId: "cca-project-123", +} as const; + +test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); const server = startServer(0); try { @@ -899,33 +932,29 @@ test("CCA image fallback generates images via Google Antigravity when no OpenAI expect(json.data).toHaveLength(1); expect(json.data[0].b64_json).toBe("aGVsbG8="); - expect(captured).toHaveLength(1); - expect(captured[0].path).toContain("generateContent"); - const body = captured[0].body as { model?: string; request?: { generationConfig?: { responseModalities?: string[] } } }; + // The CCA call MUST hit the registry host, not the config-level baseUrl. + expect(registryHits).toHaveLength(1); + expect(registryHits[0].url).toContain("daily-cloudcode-pa.googleapis.com"); + expect(registryHits[0].url).toContain("generateContent"); + const body = registryHits[0].body as { model?: string; request?: { generationConfig?: { responseModalities?: string[] } } }; expect(body.model).toBe("gemini-3.1-flash-image"); expect(body.request?.generationConfig?.responseModalities).toEqual(["TEXT", "IMAGE"]); - expect(captured[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + expect(registryHits[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + + // The attacker host (config baseUrl) must NOT receive any request. + expect(otherHits).toHaveLength(0); } finally { await server.stop(true); - await upstream.stop(true); } }); test("CCA image fallback preserves upstream 429 status", async () => { - const upstream = Bun.serve({ - port: 0, - fetch() { - return Response.json({ error: { message: "Rate limited" } }, { status: 429 }); - }, - }); + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { status: 429, payload: { error: { message: "Rate limited" } } }); - saveConfig(ccaConfig(upstream.url.toString().replace(/\/$/, ""))); - await saveCredential("google-antigravity", { - access: "cca-access-token", - refresh: "cca-refresh-token", - expires: Date.now() + 3_600_000, - projectId: "cca-project-123", - }); + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); const server = startServer(0); try { @@ -935,20 +964,17 @@ test("CCA image fallback preserves upstream 429 status", async () => { body: JSON.stringify({ prompt: "a cat" }), }); expect(response.status).toBe(429); + // The registry host was hit, not the attacker host. + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); } finally { await server.stop(true); - await upstream.stop(true); } }); test("CCA fallback does not serve image edits", async () => { - saveConfig(ccaConfig("https://daily-cloudcode-pa.googleapis.com")); - await saveCredential("google-antigravity", { - access: "cca-access-token", - refresh: "cca-refresh-token", - expires: Date.now() + 3_600_000, - projectId: "cca-project-123", - }); + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); const server = startServer(0); try { @@ -965,3 +991,76 @@ test("CCA fallback does not serve image edits", async () => { await server.stop(true); } }); + +test("CCA image fallback never sends Authorization to a tampered config baseUrl (sink-host regression)", async () => { + const registryHits: CcaFetchRequest[] = []; + const attackerHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, attackerHits); + + // ccaConfig already sets baseUrl to https://attacker.example.com — if the pin + // were ever removed, this host would receive the OAuth bearer token. + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(200); + + // The registry host received the request with the OAuth bearer token. + expect(registryHits).toHaveLength(1); + expect(registryHits[0].url).toContain("daily-cloudcode-pa.googleapis.com"); + expect(registryHits[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + + // The attacker host received ZERO requests — no Authorization header leak. + const authLeak = attackerHits.filter(r => r.headers.get("authorization")); + expect(attackerHits).toHaveLength(0); + expect(authLeak).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA image fallback rejects an empty prompt with 400 before any OAuth work", async () => { + saveConfig(ccaConfig()); + // Deliberately do NOT save a google-antigravity credential: if the prompt + // check did not fire first, getValidAccessToken would throw, and the request + // would fall through to the misleading "no provider configured" 400 — not the + // "prompt is required" message asserted below. + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "", model: "gpt-image-2" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("prompt is required"); + } finally { + await server.stop(true); + } +}); + +test("CCA image fallback rejects a whitespace-only prompt with 400", async () => { + saveConfig(ccaConfig()); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: " ", model: "gpt-image-2" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("prompt is required"); + } finally { + await server.stop(true); + } +}); From 87874e42cdaface15332844011353a04a7d63d17 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 09:51:29 +0800 Subject: [PATCH 04/12] fix(google): address Wibias R2 review blockers for #355 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Emit file: URI instead of ~/ for artifact paths (resolvable + privacy) - Remove imagen-4.0-generate-001 from IMAGE_CAPABLE_MODELS (wrong API) - Try CCA fallback when OpenAI candidate exists but auth fails - Return 502 on OAuth refresh failure (not misleading 400 'none configured') - Reuse ANTIGRAVITY_REQUEST_UA instead of hard-coded UA - Wrap fetch+body-read in try/finally for linkedSignal.cleanup() - Catch body-read timeout/abort → 504/499 - Preserve CCA 4xx statuses instead of collapsing to 502 - Add UUID collision retry on exclusive-create (flag: wx) --- src/adapters/google.ts | 25 ++-- src/images/artifacts.ts | 33 ++++- src/server/images.ts | 204 ++++++++++++++++------------- tests/images/gemini-inline.test.ts | 69 ++++++---- tests/server-images.test.ts | 182 +++++++++++++++++++++++++ 5 files changed, 378 insertions(+), 135 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 2c32089b5d..48ba0b0186 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,7 +1,6 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; -import { homedir } from "node:os"; import { createImageBudget, materializeInlineImage } from "../images/artifacts"; import type { AdapterEvent, @@ -235,10 +234,11 @@ function usageFromGemini(usage: Record | undefined): OcxUsage | }; } +// Note: imagen-* models use a different API surface (prediction/image-generation +// schema) and must NOT be treated as responseModalities-capable Gemini models. const IMAGE_CAPABLE_MODELS = new Set([ "gemini-3.1-flash-image", "gemini-2.0-flash-preview-image-generation", - "imagen-4.0-generate-001", ]); function isImageCapableModel(modelId: string): boolean { @@ -247,17 +247,14 @@ function isImageCapableModel(modelId: string): boolean { } /** - * Strip the home directory prefix from an artifact path so the absolute - * filesystem location (which embeds the username and config dir) never leaks - * into model-visible / client-visible text. An absolute home-rooted path - * becomes "~/.config/…/img.png". + * Emit a file: URI so markdown renderers (including Codex) can resolve and open + * the image. The previous "~/" prefix approach was not expanded by clients, + * silently breaking the feature. encodeURI percent-encodes special path + * characters so they don't appear verbatim in model-visible text, while the + * URI remains resolvable by file: URI handlers on the local machine. */ -function sanitizeArtifactPath(filePath: string): string { - const home = homedir(); - if (home && filePath.startsWith(home)) { - return filePath.slice(home.length).replace(/^\//, "~/"); - } - return filePath; +function artifactFileUrl(filePath: string): string { + return "file:" + encodeURI(filePath); } export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { @@ -486,7 +483,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (inline && typeof inline.data === "string") { try { const filePath = await materializeInlineImage(inline.data, imageBudget); - const escapedPath = sanitizeArtifactPath(filePath).replace(/([() ])/g, "\\$1"); + const escapedPath = artifactFileUrl(filePath).replace(/([()])/g, "\\$1"); emittedContentEvent = true; yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; } catch { @@ -601,7 +598,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (inline && typeof inline.data === "string") { try { const filePath = await materializeInlineImage(inline.data, imageBudget); - const escapedPath = sanitizeArtifactPath(filePath).replace(/([() ])/g, "\\$1"); + const escapedPath = artifactFileUrl(filePath).replace(/([()])/g, "\\$1"); events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); } catch { events.push({ type: "error", message: "failed to materialize inline image" }); diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index 7f49f3c982..e457b3c362 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -38,6 +38,31 @@ function timestampPrefix(): string { ].join(""); } +/** + * Write a buffer to a unique artifact file using `flag: "wx"` (exclusive create). + * Collisions on the random UUID suffix are astronomically unlikely, but `wx` + * would surface them as EEXIST; retry a few times with a fresh UUID before + * giving up so a fluke name clash can never fail an image write. + */ +async function writeArtifactUnique( + dir: string, + prefix: string, + buf: Uint8Array, + ext: string, +): Promise { + for (let attempt = 0; ; attempt++) { + const suffix = attempt === 0 ? crypto.randomUUID() : `${crypto.randomUUID()}-${attempt}`; + const filePath = join(dir, `${prefix}${timestampPrefix()}-${suffix}.${ext}`); + try { + await writeFile(filePath, buf, { mode: 0o600, flag: "wx" }); + return filePath; + } catch (e) { + if (e instanceof Error && "code" in e && (e as { code: string }).code === "EEXIST" && attempt < 3) continue; + throw e; + } + } +} + export function guessExtFromMagic(bytes: Uint8Array): string { const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); if (sig.startsWith("\x89PNG")) return "png"; @@ -73,9 +98,7 @@ export async function materializeInlineImage( // Sniff actual format from decoded bytes rather than trusting the declared mimeType. const ext = guessExtFromMagic(buf); - const filePath = join(dir, `img-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`); - await writeFile(filePath, buf, { mode: 0o600, flag: "wx" }); - return filePath; + return writeArtifactUnique(dir, "img-", buf, ext); } export async function downloadImageToArtifact( @@ -140,7 +163,5 @@ export async function downloadImageToArtifact( await mkdir(dir, { recursive: true, mode: 0o700 }); if (budget) budget.spent += bytes.length; - const filePath = join(dir, `dl-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`); - await writeFile(filePath, bytes, { mode: 0o600, flag: "wx" }); - return filePath; + return writeArtifactUnique(dir, "dl-", bytes, ext); } diff --git a/src/server/images.ts b/src/server/images.ts index f71cdec33b..93107fca87 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -33,6 +33,7 @@ import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index"; import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors"; import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error"; +import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; export type ImagesEndpoint = "generations" | "edits"; @@ -68,10 +69,9 @@ async function tryCcaImageGeneration( try { token = await getValidAccessToken("google-antigravity"); } catch { - // Refresh failures (revoked grant, network error, …) silently fall back to - // the OpenAI image path. The OAuth refresh error message is NOT surfaced - // here — it would leak refresh-state internals to the client. - return undefined; + // A transient OAuth/network refresh failure is not a permanent config issue. + // Surface a 502 so the caller does not misdiagnose it as "no provider configured". + return formatErrorResponse(502, "upstream_error", "CCA image generation failed: OAuth token refresh failed"); } const project = getOAuthCredentialProjectId("google-antigravity"); if (!project) return undefined; @@ -99,102 +99,115 @@ async function tryCcaImageGeneration( const linkedSignal = signalWithTimeout(timeoutMs, signal); let upstream: Response; try { - upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}`, - "User-Agent": "opencodex-images/1.0", - }, - body: JSON.stringify(envelope), - signal: linkedSignal.signal, - }); - } catch (err) { - if (signal.aborted) return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); - if (err instanceof Error && err.name === "TimeoutError") { - return formatErrorResponse(504, "upstream_error", "CCA image generation timed out"); + try { + upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "User-Agent": ANTIGRAVITY_REQUEST_UA, + }, + body: JSON.stringify(envelope), + signal: linkedSignal.signal, + }); + } catch (err) { + if (signal.aborted) return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + if (err instanceof Error && err.name === "TimeoutError") { + return formatErrorResponse(504, "upstream_error", "CCA image generation timed out"); + } + // Network/DNS/runtime errors may embed the request URL or headers verbatim + // (e.g. "fetch failed: https://…/v1internal:generateContent"). The token + // lives in an Authorization header, not in the URL, but sanitize defensively + // so no upstream-rejected credential or query param can reach the client, + // and strip the internal base URL host from the surfaced message. + const rawMsg = err instanceof Error ? err.message : String(err); + const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace( + /https?:\/\/[^\s"'<>]+/gi, + "[upstream-url]", + ); + return formatErrorResponse(502, "upstream_error", `CCA image generation failed: ${safeMsg}`); } - // Network/DNS/runtime errors may embed the request URL or headers verbatim - // (e.g. "fetch failed: https://…/v1internal:generateContent"). The token - // lives in an Authorization header, not in the URL, but sanitize defensively - // so no upstream-rejected credential or query param can reach the client, - // and strip the internal base URL host from the surfaced message. - const rawMsg = err instanceof Error ? err.message : String(err); - const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace( - /https?:\/\/[^\s"'<>]+/gi, - "[upstream-url]", - ); - return formatErrorResponse(502, "upstream_error", `CCA image generation failed: ${safeMsg}`); - } - // Stream the upstream body with a bounded reader so an oversized or malicious - // response is rejected mid-stream rather than after a full arrayBuffer() allocation. - let payload: Uint8Array; - try { - const reader = upstream.body?.getReader(); - if (!reader) { - linkedSignal.cleanup(); - return formatErrorResponse(502, "upstream_error", "CCA image response had no body"); - } - const chunks: Uint8Array[] = []; - let total = 0; + // Stream the upstream body with a bounded reader so an oversized or malicious + // response is rejected mid-stream rather than after a full arrayBuffer() allocation. + let payload: Uint8Array; try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > IMAGES_RESPONSE_MAX_BYTES) { - await reader.cancel().catch(() => {}); - linkedSignal.cleanup(); - return formatErrorResponse(502, "upstream_error", `CCA image response too large (exceeded ${IMAGES_RESPONSE_MAX_BYTES} bytes)`); + const reader = upstream.body?.getReader(); + if (!reader) { + return formatErrorResponse(502, "upstream_error", "CCA image response had no body"); + } + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > IMAGES_RESPONSE_MAX_BYTES) { + await reader.cancel().catch(() => {}); + return formatErrorResponse(502, "upstream_error", `CCA image response too large (exceeded ${IMAGES_RESPONSE_MAX_BYTES} bytes)`); + } + chunks.push(value); } - chunks.push(value); + } finally { + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + } + payload = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + payload.set(chunk, offset); + offset += chunk.byteLength; + } + } catch (err) { + // Body-read timeout/abort: when CCA returns headers then stalls, the linked + // signal's timeout aborts reader.read(), which rejects here. Map it the same + // way as the fetch catch above so the rejection never escapes this function. + if (signal.aborted) return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + if (err instanceof Error && err.name === "TimeoutError") { + return formatErrorResponse(504, "upstream_error", "CCA image response timed out during body read"); + } + const rawMsg = err instanceof Error ? err.message : String(err); + const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace(/https?:\/\/[^\s"'<>]+/gi, "[upstream-url]"); + return formatErrorResponse(502, "upstream_error", `CCA image body read failed: ${safeMsg}`); + } + + if (!upstream.ok) { + // Preserve auth/rate-limit and other permanent 4xx signals so callers can + // distinguish retryable from permanent failures. Remaining 5xx collapse to 502. + const text = new TextDecoder().decode(payload); + const safeMsg = safeAntigravityHttpErrorMessage(upstream.status, text); + if (upstream.status >= 400 && upstream.status < 500) { + return formatErrorResponse(upstream.status, "upstream_error", safeMsg); } - } finally { - try { await reader.cancel(); } catch { /* ignore */ } - reader.releaseLock(); + return formatErrorResponse(502, "upstream_error", safeMsg); + } + + let json: Record; + try { + json = JSON.parse(new TextDecoder().decode(payload)) as Record; + } catch { + return formatErrorResponse(502, "upstream_error", "CCA image response was not valid JSON"); + } + const resp = (json.response ?? json) as { candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[] }; + const parts = resp.candidates?.[0]?.content?.parts ?? []; + const images: { b64_json: string }[] = []; + for (const part of parts) { + if (part.inlineData?.data) images.push({ b64_json: part.inlineData.data }); } - payload = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - payload.set(chunk, offset); - offset += chunk.byteLength; + if (images.length === 0) { + return formatErrorResponse(502, "upstream_error", "CCA image model returned no image data"); } + // Only `{created, data:[{b64_json}]}` is returned — no token, projectId, or + // upstream metadata leak through. The Authorization header is consumed by the + // fetch above and never copied onto this Response. + return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: images }), { + status: 200, + headers: { "content-type": "application/json" }, + }); } finally { linkedSignal.cleanup(); } - - if (!upstream.ok) { - // Preserve auth/rate-limit signals so callers can distinguish retryable from permanent failures. - const text = new TextDecoder().decode(payload); - const status = upstream.status === 401 || upstream.status === 403 || upstream.status === 429 - ? upstream.status - : 502; - return formatErrorResponse(status, "upstream_error", safeAntigravityHttpErrorMessage(upstream.status, text)); - } - - let json: Record; - try { - json = JSON.parse(new TextDecoder().decode(payload)) as Record; - } catch { - return formatErrorResponse(502, "upstream_error", "CCA image response was not valid JSON"); - } - const resp = (json.response ?? json) as { candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[] }; - const parts = resp.candidates?.[0]?.content?.parts ?? []; - const images: { b64_json: string }[] = []; - for (const part of parts) { - if (part.inlineData?.data) images.push({ b64_json: part.inlineData.data }); - } - if (images.length === 0) { - return formatErrorResponse(502, "upstream_error", "CCA image model returned no image data"); - } - // Only `{created, data:[{b64_json}]}` is returned — no token, projectId, or - // upstream metadata leak through. The Authorization header is consumed by the - // fetch above and never copied onto this Response. - return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: images }), { - status: 200, - headers: { "content-type": "application/json" }, - }); } export async function handleImages( @@ -273,8 +286,12 @@ export async function handleImages( // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`). url = `${provider.baseUrl}/images/${endpoint}`; } else if (forwardAuthError) { - // A configured OpenAI pool mode owns its authentication failure. Do not hide a - // broken/expired pool behind separately billed API-key image generation. + // Before surfacing the OpenAI auth failure, try CCA — the user may have a + // valid Google Antigravity login even though their OpenAI pool is broken. + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + if (ccaResponse) return ccaResponse; + // No CCA either: a configured OpenAI pool mode owns its authentication failure. + // Do not hide a broken/expired pool behind separately billed API-key image generation. return forwardAuthError; } else if (candidates.keyed) { const { provider, apiKey, providerName } = candidates.keyed; @@ -284,6 +301,9 @@ export async function handleImages( // Keyed providers tolerate baseUrl with or without /v1 (mirrors openai-responses.ts). url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`; } else { + // No usable OpenAI credential — try CCA before giving up. + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + if (ccaResponse) return ccaResponse; return formatErrorResponse( 401, "authentication_error", diff --git a/tests/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts index abc8e4fcf3..337c4e0cfe 100644 --- a/tests/images/gemini-inline.test.ts +++ b/tests/images/gemini-inline.test.ts @@ -238,6 +238,21 @@ describe("responseModalities gating", () => { const body = JSON.parse(req.body); expect(body.generationConfig).toBeUndefined(); }); + + test("Imagen models do NOT get responseModalities (different API schema)", async () => { + // imagen-* uses the prediction/image-generation endpoint, not + // responseModalities — listing it here would send a Gemini image-capable + // request to a model that cannot handle it. + const adapter = createGoogleAdapter(aiStudioProvider); + const req = await adapter.buildRequest({ + context: { messages: [], tools: [] }, + options: {}, + modelId: "imagen-4.0-generate-001", + stream: false, + } as never); + const body = JSON.parse(req.body); + expect(body.generationConfig?.responseModalities).toBeUndefined(); + }); }); describe("markdown path escaping with special characters", () => { @@ -256,7 +271,7 @@ describe("markdown path escaping with special characters", () => { rmSync(specialHome, { recursive: true, force: true }); }); - test("streaming: escapes spaces and parentheses in emitted markdown path", async () => { + test("streaming: file: URI percent-encodes spaces and escapes parens", async () => { const events = await collectStream(aiStudioProvider, [ { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, @@ -267,13 +282,19 @@ describe("markdown path escaping with special characters", () => { const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); expect(match).not.toBeNull(); const mdPath = match![1]; - expect(mdPath).toContain("ocx\\ test\\ \\(dir\\)\\ "); - expect(mdPath).not.toMatch(/(? { + // file: prefix so clients can resolve the link + expect(mdPath.startsWith("file:")).toBe(true); + // Spaces are percent-encoded by encodeURI, never literal + expect(mdPath).toContain("%20"); + expect(mdPath).not.toMatch(/(? { const adapter = createGoogleAdapter(aiStudioProvider); const events = await adapter.parseResponse(jsonResponse({ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/webp", data: TINY_PNG } }] }, finishReason: "STOP" }], @@ -285,19 +306,22 @@ describe("markdown path escaping with special characters", () => { const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); expect(match).not.toBeNull(); const mdPath = match![1]; - expect(mdPath).toContain("ocx\\ test\\ \\(dir\\)\\ "); - const unescaped = mdPath.replace(/\\([() ])/g, "$1"); - expect(existsSync(unescaped)).toBe(true); + expect(mdPath.startsWith("file:")).toBe(true); + expect(mdPath).toContain("%20"); + expect(mdPath).not.toMatch(/(? { +describe("artifact path is a resolvable file: URI", () => { let underHome: string; let savedHome: string | undefined; beforeAll(() => { savedHome = process.env.OPENCODEX_HOME; - // Place OPENCODEX_HOME *under* the real home so sanitizeArtifactPath kicks in. + // Place OPENCODEX_HOME *under* the real home so the path includes the username segment. underHome = mkdtempSync(join(homedir(), ".ocx-test-leak-")); process.env.OPENCODEX_HOME = underHome; }); @@ -308,7 +332,7 @@ describe("artifact path sanitization (no home-directory leak)", () => { rmSync(underHome, { recursive: true, force: true }); }); - test("streaming: emitted path does not contain the raw home directory", async () => { + test("streaming: emitted path is a resolvable file: URI", async () => { const events = await collectStream(aiStudioProvider, [ { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, @@ -316,14 +340,14 @@ describe("artifact path sanitization (no home-directory leak)", () => { const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; expect(textEvents.length).toBe(1); const md = textEvents[0].text; - // The username segment (e.g. /Users/tizerluo) must NOT appear in model-visible text. - expect(md).not.toContain(homedir()); - expect(md).not.toMatch(/\/Users\/|\/home\//); - // Path is abbreviated to ~/... - expect(md).toContain("~/"); + // Output must be a resolvable file: URI (fixes the ~/ regression where clients + // could not open the link). A file: URI is inherently an absolute path. + expect(md).toContain("file:"); + // The old ~/ abbreviation must NOT be used (it broke resolution). + expect(md).not.toContain("~/"); }); - test("non-streaming: emitted path does not contain the raw home directory", async () => { + test("non-streaming: emitted path is a resolvable file: URI", async () => { const adapter = createGoogleAdapter(aiStudioProvider); const events = await adapter.parseResponse(jsonResponse({ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] }, finishReason: "STOP" }], @@ -332,9 +356,8 @@ describe("artifact path sanitization (no home-directory leak)", () => { const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; expect(textEvents.length).toBe(1); const md = textEvents[0].text; - expect(md).not.toContain(homedir()); - expect(md).not.toMatch(/\/Users\/|\/home\//); - expect(md).toContain("~/"); + expect(md).toContain("file:"); + expect(md).not.toContain("~/"); }); }); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 9610078df7..3650a3bf2a 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -14,6 +14,7 @@ import { selectImagesProvider } from "../src/providers/openai-sidecar"; import { startServer } from "../src/server"; import { saveCredential } from "../src/oauth/store"; import type { OcxConfig } from "../src/types"; +import { ANTIGRAVITY_REQUEST_UA } from "../src/adapters/google-antigravity-wire"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -940,6 +941,10 @@ test("CCA image fallback generates images via Google Antigravity when no OpenAI expect(body.model).toBe("gemini-3.1-flash-image"); expect(body.request?.generationConfig?.responseModalities).toEqual(["TEXT", "IMAGE"]); expect(registryHits[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + // The CCA image request must use the shared Antigravity User-Agent (not a + // bespoke "opencodex-images/1.0"), so the request fingerprint matches the + // OAuth credential. + expect(registryHits[0].headers.get("user-agent")).toBe(ANTIGRAVITY_REQUEST_UA); // The attacker host (config baseUrl) must NOT receive any request. expect(otherHits).toHaveLength(0); @@ -1064,3 +1069,180 @@ test("CCA image fallback rejects a whitespace-only prompt with 400", async () => await server.stop(true); } }); + +test("CCA fallback serves images when OpenAI forward auth fails but Google Antigravity is logged in", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + // OpenAI forward provider in pool mode, but pool-a has NO stored credential → + // forward auth resolution throws CodexAuthContextError. Without the CCA + // fallback the user gets a 401 even though they have a valid Google login. + saveConfig({ + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalOpenAiProvider, codexAccountMode: "pool" }, + "google-antigravity": { + adapter: "google", + baseUrl: "https://attacker.example.com", + googleMode: "cloud-code-assist", + } as OcxConfig["providers"][string], + }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "pool@example.test", isMain: false, chatgptAccountId: "acct-pool-a" }, + ], + activeCodexAccountId: "pool-a", + } as OcxConfig); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer caller-token" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + // OpenAI forward auth failed, but CCA picked up the slack. + expect(response.status).toBe(200); + const json = await response.json() as { data: { b64_json: string }[] }; + expect(json.data).toHaveLength(1); + expect(json.data[0].b64_json).toBe("aGVsbG8="); + + // CCA was called on the registry host, not the attacker host. + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA OAuth refresh failure returns 502, not a misleading 400 'none configured'", async () => { + saveConfig(ccaConfig()); + // Deliberately do NOT save a google-antigravity credential. The provider IS + // configured, but getValidAccessToken will throw OAuthLoginRequiredError. + // This must surface as a 502 upstream error, NOT the permanent 400 + // "none configured" message that implies the user forgot to add a provider. + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("OAuth token refresh failed"); + } finally { + await server.stop(true); + } +}); + +test("CCA fetch network failure returns 502 without leaking the timeout timer", async () => { + // Mock: CCA fetch always fails with a network error. The bug was that the + // fetch catch returned 502 without calling linkedSignal.cleanup(), leaving + // the timeout timer alive. With a short timeout this would keep the process + // alive. The fix wraps everything in try/finally so cleanup always runs. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + throw new TypeError("fetch failed: connection refused"); + } + return originalFetch(input, init); + }) as typeof fetch; + + saveConfig({ ...ccaConfig(), images: { timeoutMs: 10_000 } } as OcxConfig); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("CCA image generation failed"); + } finally { + await server.stop(true); + } +}, 5_000); + +test("CCA body-read timeout returns 504 when upstream stalls after sending headers", async () => { + // Mock: CCA returns 200 OK headers immediately but the body stream never + // produces data. The linked signal's timeout aborts reader.read(), which + // must be caught and mapped to 504 — previously the rejection escaped + // tryCcaImageGeneration entirely. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + const fetchSignal = init?.signal; + const stalledBody = new ReadableStream({ + start(controller) { + // Never produce data — stall until the fetch signal aborts, then + // error the stream so reader.read() rejects with the abort reason. + if (fetchSignal) { + if (fetchSignal.aborted) { + controller.error(fetchSignal.reason); + } else { + fetchSignal.addEventListener("abort", () => controller.error(fetchSignal.reason), { once: true }); + } + } + }, + }); + return new Response(stalledBody, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + saveConfig({ ...ccaConfig(), images: { timeoutMs: 100 } } as OcxConfig); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(504); + const json = await response.json() as { error: { message: string } }; + // Either the body-read timeout message or the general timeout message. + expect(json.error.message).toMatch(/body read|timed out/i); + } finally { + await server.stop(true); + } +}, 5_000); + +test("CCA image fallback preserves upstream 400 (not collapsed to 502)", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { status: 400, payload: { error: { message: "Invalid prompt content" } } }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + // 400 must be forwarded, not collapsed to 502. + expect(response.status).toBe(400); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); From cc131ee56d285553633f0b3ae985c215fad54dcc Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 10:31:10 +0800 Subject: [PATCH 05/12] feat(images): add artifact retention policy (port from #424) - pruneOldArtifacts() caps artifacts/ to 200 files, deleting oldest by mtime - Runs after each successful write in materializeInlineImage + downloadImageToArtifact - Best-effort: errors caught and warned, never fails the write --- .../src/content/docs/reference/adapters.md | 3 +- src/images/artifacts.ts | 55 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index aa949d55cf..1890913253 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -85,7 +85,8 @@ streams the response back **untranslated**. `inlineData` parts are materialized to the `artifacts/` subdirectory of the configured OpenCodex config directory and surfaced to the client as a markdown image link (`![image](path)`). Each image is capped at 50 MB and each response at - 100 MB of decoded data; malformed base64 payloads are rejected. + 100 MB of decoded data; malformed base64 payloads are rejected. Artifacts are pruned automatically + when the count exceeds 200 files. ## `kiro` diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index e457b3c362..b2a7b2ac4f 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -1,3 +1,4 @@ +import { mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { getConfigDir } from "../config"; @@ -7,6 +8,9 @@ const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB +/** Default cap on files retained under artifacts/. Oldest files are pruned when exceeded. */ +export const DEFAULT_ARTIFACT_KEEP_COUNT = 200; + // Strict alphabet check: Buffer.from(..., "base64") silently ignores invalid // characters, so malformed payloads would otherwise decode to garbage bytes. const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; @@ -23,6 +27,45 @@ function getArtifactsDir(): string { return join(getConfigDir(), "artifacts"); } +/** + * Best-effort retention cap: when the artifact directory holds more than `maxFiles`, + * delete the oldest (by mtime) until the count is back under the limit. Synchronous + * on purpose — it runs right after each successful write and touches at most a handful + * of files. All errors are swallowed and logged so a prune failure never breaks an image write. + */ +export function pruneOldArtifacts(dir: string, maxFiles: number): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch (e) { + console.warn(`[images] prune: could not read ${dir}:`, e instanceof Error ? e.message : e); + return; + } + if (entries.length <= maxFiles) return; + + let stats: Array<{ name: string; mtime: number }>; + try { + stats = entries.map(name => { + const st = statSync(join(dir, name)); + return { name, mtime: st.mtimeMs }; + }); + } catch (e) { + console.warn(`[images] prune: could not stat files in ${dir}:`, e instanceof Error ? e.message : e); + return; + } + + // Sort oldest-first, delete the excess. + stats.sort((a, b) => a.mtime - b.mtime); + const toDelete = stats.slice(0, stats.length - maxFiles); + for (const { name } of toDelete) { + try { + unlinkSync(join(dir, name)); + } catch (e) { + console.warn(`[images] prune: could not delete ${name}:`, e instanceof Error ? e.message : e); + } + } +} + function timestampPrefix(): string { const now = new Date(); return [ @@ -75,6 +118,7 @@ export function guessExtFromMagic(bytes: Uint8Array): string { export async function materializeInlineImage( base64Data: string, budget?: ImageBudget, + keepCount?: number, ): Promise { const dir = getArtifactsDir(); await mkdir(dir, { recursive: true, mode: 0o700 }); @@ -98,18 +142,21 @@ export async function materializeInlineImage( // Sniff actual format from decoded bytes rather than trusting the declared mimeType. const ext = guessExtFromMagic(buf); - return writeArtifactUnique(dir, "img-", buf, ext); + const filePath = await writeArtifactUnique(dir, "img-", buf, ext); + pruneOldArtifacts(dir, keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT); + return filePath; } export async function downloadImageToArtifact( url: string, budget?: ImageBudget, signal?: AbortSignal, + keepCount?: number, ): Promise { if (url.startsWith("data:")) { const m = /^data:([^;]+);base64,(.+)$/.exec(url); if (!m) throw new Error("data URL is not a valid base64 image"); - return materializeInlineImage(m[2], budget); + return materializeInlineImage(m[2], budget, keepCount); } // SSRF protection: validate the provider-returned URL before fetching. @@ -163,5 +210,7 @@ export async function downloadImageToArtifact( await mkdir(dir, { recursive: true, mode: 0o700 }); if (budget) budget.spent += bytes.length; - return writeArtifactUnique(dir, "dl-", bytes, ext); + const filePath = await writeArtifactUnique(dir, "dl-", bytes, ext); + pruneOldArtifacts(dir, keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT); + return filePath; } From bcdde2c0b84e6acf41415711b9a9b1836c72ccc7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 11:53:36 +0800 Subject: [PATCH 06/12] fix(google): address Wibias R3 review blockers for #355 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use pathToFileURL for standard file: URIs (Windows + RFC8089 compliant) - Validate CCA envelope: Array.isArray(parts) + per-part object check - Map OAuthLoginRequiredError → 401 (login required), not 502 (refresh failed) - Fix body-read timeout: check linkedSignal.signal.aborted before parent signal - Remove Imagen from adapters.md docs - Document CCA fallback also fires after OpenAI auth failure (+ locales) - Document artifacts emitted as file: URIs --- .../content/docs/guides/codex-integration.md | 4 +- .../docs/ja/guides/codex-integration.md | 4 +- .../docs/ko/guides/codex-integration.md | 4 +- .../src/content/docs/reference/adapters.md | 14 ++--- .../docs/ru/guides/codex-integration.md | 4 +- .../docs/zh-cn/guides/codex-integration.md | 3 +- src/adapters/google.ts | 14 ++--- src/server/images.ts | 27 +++++++--- tests/images/gemini-inline.test.ts | 32 ++++++++--- tests/server-images.test.ts | 54 +++++++++++++++---- 10 files changed, 116 insertions(+), 44 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 8c611e381e..fab358a746 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -48,7 +48,9 @@ points at opencodex, the proxy relays those calls to the OpenAI upstream: are not accepted here; omit `images.provider` to use the built-in OpenAI tiers. - **Google Antigravity (CCA) fallback:** when neither an OpenAI forward candidate nor a keyed provider is configured, `/v1/images/generations` (not `/images/edits`) falls back to the - Antigravity **Cloud Code Assist** endpoint using the `gemini-3.1-flash-image` model. This + Antigravity **Cloud Code Assist** endpoint using the `gemini-3.1-flash-image` model. The fallback + also fires after OpenAI auth resolution fails (e.g. an expired or missing ChatGPT credential), + not only when no OpenAI candidate is configured. This requires `ocx login google-antigravity`; the OAuth token is sent only to the pinned CCA registry host, never to a config-level `baseUrl` override. The response is returned in the same `{created, data:[{b64_json}]}` shape Codex expects. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 06e27252e4..964cc68192 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -49,7 +49,9 @@ ChatGPT bearer 認証で直接 POST します。注入された `base_url` が o Gemini、Kiro など)は既定では画像生成を提供できません。ツール自体をオフにしたい場合は Codex で - **Google Antigravity (CCA) フォールバック:** OpenAI forward 候補も API キープロバイダーもない場合、 `/v1/images/generations`(`/images/edits` を除く)は Antigravity **Cloud Code Assist** エンドポイントに - フォールバックし、`gemini-3.1-flash-image` モデルを使用します。`ocx login google-antigravity` が + フォールバックし、`gemini-3.1-flash-image` モデルを使用します。OpenAI 認証の解決に失敗した場合 + (例: ChatGPT 認証情報が期限切れまたは不在)も同様にフォールバックが発火し、OpenAI 候補が全くない + 場合のみではありません。`ocx login google-antigravity` が 必要です。OAuth トークンは CCA レジストリホストにのみ送信され、設定の `baseUrl` オーバーライドには 送信されません。レスポンスは Codex が期待する `{created, data:[{b64_json}]}` 形式で返されます。 - **いずれもなし:** 曖昧な 404 の代わりに明確なエラーを返します。ルーティングされる他のプロバイダー(Cursor、 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 587a4710d3..112934a085 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -48,7 +48,9 @@ ChatGPT bearer 인증으로 직접 POST합니다. 주입된 `base_url`이 openco Gemini, Kiro 등)는 기본적으로 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서 - **Google Antigravity (CCA) 폴백:** OpenAI forward 후보와 API key 프로바이더 모두 없을 때, `/v1/images/generations`(`/images/edits` 제외)가 Antigravity **Cloud Code Assist** 엔드포인트로 - 폴백되며 `gemini-3.1-flash-image` 모델을 사용합니다. `ocx login google-antigravity`가 필요합니다. + 폴백되며 `gemini-3.1-flash-image` 모델을 사용합니다. OpenAI 인증 해석이 실패할 때(예: ChatGPT 자격 + 증명이 만료되거나 누락된 경우)에도 동일하게 폴백이 트리거되며, OpenAI 후보가 아예 없을 때만 + 발생하는 것은 아닙니다. `ocx login google-antigravity`가 필요합니다. OAuth 토큰은 CCA 레지스트리 호스트로만 전송되며 설정의 `baseUrl` 재정의로는 가지 않습니다. 응답은 Codex가 기대하는 `{created, data:[{b64_json}]}` 형식으로 반환됩니다. - **모두 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor, diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1890913253..3aa26beef0 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -80,13 +80,13 @@ streams the response back **untranslated**. - Tool-call ids are synthesized when Gemini omits them. Antigravity preserves and replays real `thoughtSignature` values so reasoning continuity survives later turns. - **Inline image output:** when the model is image-capable (`gemini-3.1-flash-image`, - `gemini-2.0-flash-preview-image-generation`, `imagen-4.0-generate-001`, or any model id matching - both `gemini` and `image`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. Returned - `inlineData` parts are materialized to the `artifacts/` subdirectory of the configured OpenCodex - config directory and surfaced to the client - as a markdown image link (`![image](path)`). Each image is capped at 50 MB and each response at - 100 MB of decoded data; malformed base64 payloads are rejected. Artifacts are pruned automatically - when the count exceeds 200 files. + `gemini-2.0-flash-preview-image-generation`, or any model id matching both `gemini` and `image`), + the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. Returned `inlineData` parts are + materialized to the `artifacts/` subdirectory of the configured OpenCodex config directory and + surfaced to the client as a markdown image link referencing a `file:` URI + (`![image](file:///...)`). Each image is capped at 50 MB and each response at 100 MB of decoded + data; malformed base64 payloads are rejected. Artifacts are pruned automatically when the count + exceeds 200 files. ## `kiro` diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 12eda63bc7..7983541f6f 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -54,7 +54,9 @@ fast_mode = true Маршрутизируемые провайдеры (Cursor, Gemini, Kiro, …) по умолчанию не могут обслуживать генерацию - **Резерв Google Antigravity (CCA):** когда ни forward-кандидат OpenAI, ни провайдер с API-ключом не настроены, `/v1/images/generations` (но не `/images/edits`) переключается на - эндпоинт Antigravity **Cloud Code Assist** с моделью `gemini-3.1-flash-image`. Требуется + эндпоинт Antigravity **Cloud Code Assist** с моделью `gemini-3.1-flash-image`. Этот же резерв + срабатывает и при сбое разрешения аутентификации OpenAI (например, истёкшая или отсутствующая + учётная запись ChatGPT), а не только при отсутствии кандидата OpenAI. Требуется `ocx login google-antigravity`; OAuth-токен отправляется только на закреплённый хост реестра CCA, а не на `baseUrl` из конфигурации. Ответ возвращается в том же формате `{created, data:[{b64_json}]}`, что ожидает Codex. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index fef47f4a12..7bc6f78f0f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -47,7 +47,8 @@ OpenAI 上游: Kiro 等)默认无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行 - **Google Antigravity(CCA)回退:** 当 OpenAI forward 候选和 API key 提供商都不存在时, `/v1/images/generations`(不含 `/images/edits`)会回退到 Antigravity **Cloud Code Assist** - 端点,使用 `gemini-3.1-flash-image` 模型。需要 `ocx login google-antigravity`;OAuth token + 端点,使用 `gemini-3.1-flash-image` 模型。当 OpenAI 认证解析失败(例如 ChatGPT 凭证过期或缺失)时, + 该回退同样会触发,而不仅仅在没有任何 OpenAI 候选时。需要 `ocx login google-antigravity`;OAuth token 只发送到 CCA 注册端点,不会发送到配置中的 `baseUrl` 覆盖地址。返回格式与 Codex 期望的 `{created, data:[{b64_json}]}` 一致。 - **以上都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、 diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 48ba0b0186..6a6249b9ae 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,6 +1,7 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; import { createImageBudget, materializeInlineImage } from "../images/artifacts"; import type { AdapterEvent, @@ -247,14 +248,15 @@ function isImageCapableModel(modelId: string): boolean { } /** - * Emit a file: URI so markdown renderers (including Codex) can resolve and open - * the image. The previous "~/" prefix approach was not expanded by clients, - * silently breaking the feature. encodeURI percent-encodes special path - * characters so they don't appear verbatim in model-visible text, while the - * URI remains resolvable by file: URI handlers on the local machine. + * Emit a standard file: URI (via node:url pathToFileURL) so markdown renderers + * (including Codex) can resolve and open the image. The previous "~/" prefix + * approach was not expanded by clients, and a hand-rolled `"file:" + encodeURI` + * produced non-standard URIs that break on Windows (file:C:%5C… should be + * file:///C:/…) and mishandle '#'/':' in paths. pathToFileURL follows the + * WHATWG URL spec for correct, cross-platform file: URIs. */ function artifactFileUrl(filePath: string): string { - return "file:" + encodeURI(filePath); + return pathToFileURL(filePath).href; } export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { diff --git a/src/server/images.ts b/src/server/images.ts index 93107fca87..6889dab707 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -68,9 +68,12 @@ async function tryCcaImageGeneration( let token: string; try { token = await getValidAccessToken("google-antigravity"); - } catch { - // A transient OAuth/network refresh failure is not a permanent config issue. - // Surface a 502 so the caller does not misdiagnose it as "no provider configured". + } catch (err) { + // Missing/revoked credential → 401 (re-login required); transient refresh/network → 502. + const errName = err instanceof Error ? err.name : ""; + if (errName === "OAuthLoginRequiredError") { + return formatErrorResponse(401, "invalid_request_error", "Google Antigravity login required: run 'ocx login google-antigravity'"); + } return formatErrorResponse(502, "upstream_error", "CCA image generation failed: OAuth token refresh failed"); } const project = getOAuthCredentialProjectId("google-antigravity"); @@ -161,12 +164,16 @@ async function tryCcaImageGeneration( } } catch (err) { // Body-read timeout/abort: when CCA returns headers then stalls, the linked - // signal's timeout aborts reader.read(), which rejects here. Map it the same - // way as the fetch catch above so the rejection never escapes this function. - if (signal.aborted) return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); - if (err instanceof Error && err.name === "TimeoutError") { + // signal's timeout aborts reader.read(), which rejects here. The rejection + // often surfaces as AbortError (not TimeoutError), so distinguish by signal + // state, not error name. Linked deadline won → 504 (upstream stall); parent + // abort → 499 (client cancelled); anything else → 502 body-read failure. + if (linkedSignal.signal.aborted) { return formatErrorResponse(504, "upstream_error", "CCA image response timed out during body read"); } + if (signal.aborted) { + return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + } const rawMsg = err instanceof Error ? err.message : String(err); const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace(/https?:\/\/[^\s"'<>]+/gi, "[upstream-url]"); return formatErrorResponse(502, "upstream_error", `CCA image body read failed: ${safeMsg}`); @@ -190,9 +197,13 @@ async function tryCcaImageGeneration( return formatErrorResponse(502, "upstream_error", "CCA image response was not valid JSON"); } const resp = (json.response ?? json) as { candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[] }; - const parts = resp.candidates?.[0]?.content?.parts ?? []; + const parts = resp.candidates?.[0]?.content?.parts; + if (!Array.isArray(parts)) { + return formatErrorResponse(502, "upstream_error", "CCA image response had no valid parts array"); + } const images: { b64_json: string }[] = []; for (const part of parts) { + if (!part || typeof part !== "object") continue; if (part.inlineData?.data) images.push({ b64_json: part.inlineData.data }); } if (images.length === 0) { diff --git a/tests/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts index 337c4e0cfe..f4fceaf571 100644 --- a/tests/images/gemini-inline.test.ts +++ b/tests/images/gemini-inline.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { createImageBudget, guessExtFromMagic, materializeInlineImage } from "../../src/images/artifacts"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import { createGoogleAdapter } from "../../src/adapters/google"; @@ -282,16 +283,17 @@ describe("markdown path escaping with special characters", () => { const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); expect(match).not.toBeNull(); const mdPath = match![1]; - // file: prefix so clients can resolve the link + // Standard file: URI prefix so clients can resolve the link expect(mdPath.startsWith("file:")).toBe(true); - // Spaces are percent-encoded by encodeURI, never literal + // Spaces are percent-encoded by pathToFileURL, never literal expect(mdPath).toContain("%20"); expect(mdPath).not.toMatch(/(? { @@ -307,11 +309,15 @@ describe("markdown path escaping with special characters", () => { expect(match).not.toBeNull(); const mdPath = match![1]; expect(mdPath.startsWith("file:")).toBe(true); + // Spaces are percent-encoded by pathToFileURL, never literal expect(mdPath).toContain("%20"); expect(mdPath).not.toMatch(/(? { expect(md).toContain("file:"); // The old ~/ abbreviation must NOT be used (it broke resolution). expect(md).not.toContain("~/"); + // Round-trip with fileURLToPath to prove it's a standard file: URI. + const match = md.match(/^\n!\[image\]\((.+)\)\n$/); + expect(match).not.toBeNull(); + const unescaped = match![1].replace(/\\([()])/g, "$1"); + expect(existsSync(fileURLToPath(unescaped))).toBe(true); }); test("non-streaming: emitted path is a resolvable file: URI", async () => { @@ -358,6 +369,11 @@ describe("artifact path is a resolvable file: URI", () => { const md = textEvents[0].text; expect(md).toContain("file:"); expect(md).not.toContain("~/"); + // Round-trip with fileURLToPath to prove it's a standard file: URI. + const match = md.match(/^\n!\[image\]\((.+)\)\n$/); + expect(match).not.toBeNull(); + const unescaped = match![1].replace(/\\([()])/g, "$1"); + expect(existsSync(fileURLToPath(unescaped))).toBe(true); }); }); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 3650a3bf2a..0c459aaafa 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1119,12 +1119,13 @@ test("CCA fallback serves images when OpenAI forward auth fails but Google Antig } }); -test("CCA OAuth refresh failure returns 502, not a misleading 400 'none configured'", async () => { +test("CCA OAuth no credential saved returns 401 (login required), not a misleading 502/400", async () => { saveConfig(ccaConfig()); // Deliberately do NOT save a google-antigravity credential. The provider IS // configured, but getValidAccessToken will throw OAuthLoginRequiredError. - // This must surface as a 502 upstream error, NOT the permanent 400 - // "none configured" message that implies the user forgot to add a provider. + // This must surface as a 401 "login required" — NOT 502 (which implies a + // transient refresh/network failure) or the permanent 400 "none configured" + // message that implies the user forgot to add a provider. const server = startServer(0); try { @@ -1133,9 +1134,9 @@ test("CCA OAuth refresh failure returns 502, not a misleading 400 'none configur headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), }); - expect(response.status).toBe(502); + expect(response.status).toBe(401); const json = await response.json() as { error: { message: string } }; - expect(json.error.message).toContain("OAuth token refresh failed"); + expect(json.error.message).toContain("login required"); } finally { await server.stop(true); } @@ -1176,8 +1177,10 @@ test("CCA fetch network failure returns 502 without leaking the timeout timer", test("CCA body-read timeout returns 504 when upstream stalls after sending headers", async () => { // Mock: CCA returns 200 OK headers immediately but the body stream never // produces data. The linked signal's timeout aborts reader.read(), which - // must be caught and mapped to 504 — previously the rejection escaped - // tryCcaImageGeneration entirely. + // must be caught and mapped to 504. The abort surfaces as a generic + // AbortError (not TimeoutError) — just like in production Bun — so the + // signal-state check (linkedSignal.signal.aborted) is what maps it, not + // err.name matching. globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const url = new URL(requestUrl); @@ -1186,12 +1189,14 @@ test("CCA body-read timeout returns 504 when upstream stalls after sending heade const stalledBody = new ReadableStream({ start(controller) { // Never produce data — stall until the fetch signal aborts, then - // error the stream so reader.read() rejects with the abort reason. + // error the stream as AbortError (the typical rejection Bun's + // stream layer produces on linked-signal abort, NOT TimeoutError). + const abortError = new DOMException("The operation was aborted.", "AbortError"); if (fetchSignal) { if (fetchSignal.aborted) { - controller.error(fetchSignal.reason); + controller.error(abortError); } else { - fetchSignal.addEventListener("abort", () => controller.error(fetchSignal.reason), { once: true }); + fetchSignal.addEventListener("abort", () => controller.error(abortError), { once: true }); } } }, @@ -1246,3 +1251,32 @@ test("CCA image fallback preserves upstream 400 (not collapsed to 502)", async ( await server.stop(true); } }); + +test("CCA image response with non-array parts returns 502 (envelope validation)", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + // A truthy but non-array parts object would throw inside for...of before the + // Array.isArray guard was added. It must be caught and surfaced as 502. + ccaFetchMock(registryHits, otherHits, { + payload: { response: { candidates: [{ content: { parts: "not-an-array" } }] } }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("no valid parts array"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); From 62ebc6db600834e562298130152769d6f895da64 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 21:58:28 +0800 Subject: [PATCH 07/12] fix(google): address Wibias R4 review blockers for #355 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4 findings fixed: 1. Body-read client cancellation now reports 499 (not 504): signalWithTimeout propagates parent abort into the linked signal, so both are aborted on client cancel. Check parent signal first in the body-read catch block. 2. OAuth preflight now runs inside the image deadline: Move signalWithTimeout creation before getValidAccessToken so token refresh and project discovery are bounded by timeoutMs and can be cancelled by client abort. Add 499/504 checks after OAuth preflight. 4. PNG magic bytes: validate complete 8-byte signature (89 50 4E 47 0D 0A 1A 0A) instead of just the first 4 bytes. 5. Inline payload size pre-check: reject oversized base64 strings in the adapter before normalization copies them, preventing large allocations before the decoded-size guard runs. Finding 3 (file: URL for remote clients) is a design discussion, not a code bug — addressed in PR review response. --- src/adapters/google.ts | 36 ++++++++++++-------- src/images/artifacts.ts | 11 ++++++- src/server/images.ts | 35 +++++++++++++++----- tests/images/gemini-inline.test.ts | 12 +++++++ tests/server-images.test.ts | 53 ++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 24 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 6a6249b9ae..40300419a9 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -2,7 +2,7 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./bas import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; -import { createImageBudget, materializeInlineImage } from "../images/artifacts"; +import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts"; import type { AdapterEvent, OcxAssistantMessage, @@ -483,13 +483,17 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; if (inline && typeof inline.data === "string") { - try { - const filePath = await materializeInlineImage(inline.data, imageBudget); - const escapedPath = artifactFileUrl(filePath).replace(/([()])/g, "\\$1"); - emittedContentEvent = true; - yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; - } catch { - yield { type: "error", message: "failed to materialize inline image" }; + if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) { + yield { type: "error", message: "inline image exceeds per-image size cap" }; + } else { + try { + const filePath = await materializeInlineImage(inline.data, imageBudget); + const escapedPath = artifactFileUrl(filePath).replace(/([()])/g, "\\$1"); + emittedContentEvent = true; + yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; + } catch { + yield { type: "error", message: "failed to materialize inline image" }; + } } } if (part.functionCall) { @@ -598,12 +602,16 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (part.text) events.push({ type: "text_delta", text: part.text }); const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; if (inline && typeof inline.data === "string") { - try { - const filePath = await materializeInlineImage(inline.data, imageBudget); - const escapedPath = artifactFileUrl(filePath).replace(/([()])/g, "\\$1"); - events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); - } catch { - events.push({ type: "error", message: "failed to materialize inline image" }); + if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) { + events.push({ type: "error", message: "inline image exceeds per-image size cap" }); + } else { + try { + const filePath = await materializeInlineImage(inline.data, imageBudget); + const escapedPath = artifactFileUrl(filePath).replace(/([()])/g, "\\$1"); + events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); + } catch { + events.push({ type: "error", message: "failed to materialize inline image" }); + } } } if (part.functionCall) { diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index b2a7b2ac4f..c7afbec280 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -8,6 +8,15 @@ const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB +/** + * Upper bound on the raw base64 string length before it is decoded. Base64 + * encoding expands 3 decoded bytes to 4 encoded chars, so this corresponds to + * MAX_DECODED_BYTES_PER_IMAGE. Checking this in the adapter (before calling + * materializeInlineImage) rejects oversized payloads before normalization + * copies them — see Wibias R4 finding 5. + */ +export const MAX_ENCODED_BYTES_PER_IMAGE = Math.ceil(MAX_DECODED_BYTES_PER_IMAGE * 4 / 3); + /** Default cap on files retained under artifacts/. Oldest files are pruned when exceeded. */ export const DEFAULT_ARTIFACT_KEEP_COUNT = 200; @@ -108,7 +117,7 @@ async function writeArtifactUnique( export function guessExtFromMagic(bytes: Uint8Array): string { const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); - if (sig.startsWith("\x89PNG")) return "png"; + if (sig.startsWith("\x89PNG\r\n\x1a\n")) return "png"; if (sig.startsWith("\xff\xd8\xff")) return "jpg"; if (sig.startsWith("RIFF") && sig.slice(8, 12) === "WEBP") return "webp"; if (sig.startsWith("GIF8")) return "gif"; diff --git a/src/server/images.ts b/src/server/images.ts index 6889dab707..a780bb89ec 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -65,10 +65,15 @@ async function tryCcaImageGeneration( return formatErrorResponse(400, "invalid_request_error", "prompt is required and must not be empty"); } + // Create the deadline before credential resolution so the timeout covers + // OAuth token refresh and project discovery, not just the upstream fetch. + const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; + const linkedSignal = signalWithTimeout(timeoutMs, signal); let token: string; try { token = await getValidAccessToken("google-antigravity"); } catch (err) { + linkedSignal.cleanup(); // Missing/revoked credential → 401 (re-login required); transient refresh/network → 502. const errName = err instanceof Error ? err.name : ""; if (errName === "OAuthLoginRequiredError") { @@ -76,8 +81,22 @@ async function tryCcaImageGeneration( } return formatErrorResponse(502, "upstream_error", "CCA image generation failed: OAuth token refresh failed"); } + // Client cancellation or deadline expiry during OAuth preflight. + // Parent abort propagates into the linked signal, so check parent first (499) + // before the linked signal (504). + if (signal.aborted) { + linkedSignal.cleanup(); + return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + } + if (linkedSignal.signal.aborted) { + linkedSignal.cleanup(); + return formatErrorResponse(504, "upstream_error", "CCA image generation timed out during authentication"); + } const project = getOAuthCredentialProjectId("google-antigravity"); - if (!project) return undefined; + if (!project) { + linkedSignal.cleanup(); + return undefined; + } logCtx.provider = "google-antigravity"; logCtx.model = CCA_IMAGE_MODEL; @@ -97,9 +116,6 @@ async function tryCcaImageGeneration( sessionId: `ocx-img-${crypto.randomUUID().slice(0, 8)}`, }, }; - - const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; - const linkedSignal = signalWithTimeout(timeoutMs, signal); let upstream: Response; try { try { @@ -166,14 +182,15 @@ async function tryCcaImageGeneration( // Body-read timeout/abort: when CCA returns headers then stalls, the linked // signal's timeout aborts reader.read(), which rejects here. The rejection // often surfaces as AbortError (not TimeoutError), so distinguish by signal - // state, not error name. Linked deadline won → 504 (upstream stall); parent - // abort → 499 (client cancelled); anything else → 502 body-read failure. - if (linkedSignal.signal.aborted) { - return formatErrorResponse(504, "upstream_error", "CCA image response timed out during body read"); - } + // state, not error name. Parent abort propagates into the linked signal, so + // check the parent first: parent abort → 499 (client cancelled); linked-only + // abort → 504 (upstream stall); anything else → 502 body-read failure. if (signal.aborted) { return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); } + if (linkedSignal.signal.aborted) { + return formatErrorResponse(504, "upstream_error", "CCA image response timed out during body read"); + } const rawMsg = err instanceof Error ? err.message : String(err); const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace(/https?:\/\/[^\s"'<>]+/gi, "[upstream-url]"); return formatErrorResponse(502, "upstream_error", `CCA image body read failed: ${safeMsg}`); diff --git a/tests/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts index f4fceaf571..68038d6069 100644 --- a/tests/images/gemini-inline.test.ts +++ b/tests/images/gemini-inline.test.ts @@ -76,6 +76,18 @@ describe("guessExtFromMagic", () => { expect(() => guessExtFromMagic(new Uint8Array())).toThrow("unrecognized image format"); expect(() => guessExtFromMagic(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toThrow("unrecognized image format"); }); + + test("truncated PNG signature (first 4 bytes only) is rejected", () => { + // Regression for Wibias R4 finding 4: \x89PNG is only the first 4 bytes of the + // 8-byte PNG signature (89 50 4E 47 0D 0A 1A 0A). The old startsWith("\x89PNG") + // accepted malformed data with a truncated/fake signature. The full 8-byte + // signature must now be validated. + const truncated = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x00, 0x00]); + expect(() => guessExtFromMagic(truncated)).toThrow("unrecognized image format"); + // First 4 bytes matching but followed by non-PNG data is also rejected. + const fake = Buffer.from("\x89PNGXXXX", "latin1"); + expect(() => guessExtFromMagic(fake)).toThrow("unrecognized image format"); + }); }); describe("CCA image endpoint registry pinning (token-exfiltration guard)", () => { diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 0c459aaafa..ec7aa2190b 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1228,6 +1228,59 @@ test("CCA body-read timeout returns 504 when upstream stalls after sending heade } }, 5_000); +test("CCA body-read client cancellation returns 499, not 504", async () => { + // Regression for Wibias R4 finding 1: when the client aborts during the body-read + // phase, both the parent signal and the linked signal are aborted (parent abort + // propagates into the linked signal). The body-read catch must check the PARENT + // signal first (499) before the linked signal (504), otherwise client cancellation + // is misreported as an upstream timeout. + // + // We call handleImages directly (not via server fetch) because a client-side + // fetch abort tears down the connection before the server response can be read. + const { handleImages } = await import("../src/server/images"); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + const fetchSignal = init?.signal; + const stalledBody = new ReadableStream({ + start(controller) { + const abortError = new DOMException("The operation was aborted.", "AbortError"); + if (fetchSignal) { + if (fetchSignal.aborted) controller.error(abortError); + else fetchSignal.addEventListener("abort", () => controller.error(abortError), { once: true }); + } + }, + }); + return new Response(stalledBody, { status: 200, headers: { "content-type": "application/json" } }); + } + return originalFetch(input, init); + }) as typeof fetch; + + // Use a long timeout so the deadline does NOT fire — only the client abort triggers. + const cfg = { ...ccaConfig(), images: { timeoutMs: 30_000 } } as OcxConfig; + saveConfig(cfg); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const ctrl = new AbortController(); + const req = new Request("http://localhost:0/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + signal: ctrl.signal, + }); + const logCtx = { model: "", provider: "" } as never; + + // Start the handler — it enters the body-read loop and stalls on the mocked body. + const responsePromise = handleImages(req, cfg, "generations", logCtx); + // Abort the parent signal after the body read has started. + setTimeout(() => ctrl.abort(), 100); + const response = await responsePromise; + expect(response.status).toBe(499); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("canceled"); +}, 5_000); + test("CCA image fallback preserves upstream 400 (not collapsed to 502)", async () => { const registryHits: CcaFetchRequest[] = []; const otherHits: CcaFetchRequest[] = []; From 92624fdb504afc8a0d79ed06814b7ea312dc07b5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 23:48:12 +0800 Subject: [PATCH 08/12] fix(google): address Wibias R5 review blockers for #355 R5 findings fixed: 2. OAuth preflight signal propagation: wrap getValidAccessToken in abortableRace against linkedSignal.signal so client abort / timeout during token refresh surfaces as 499/504 instead of hanging. 5. HTTP-layer payload limit: add Content-Length pre-check in parseResponse before response.json() buffers the body. Codex P2-1: Reject malformed CCA inlineData.data (non-string types were forwarded as fake b64_json). Codex P2-2: Fix duplicate 'neither' bullet in JA/KO/RU/ZH locale docs. Codex P2-3: Exempt gemini-*-image chat models from routed catalog media-generation filter (shouldExposeRoutedModel). Codex P2-4: Map CCA safety blocks (finishReason SAFETY/BLOCKLIST/etc) to non-retryable 400 instead of retried 502. Finding 3 (file: URL local-only) addressed in review response as a design positioning decision, not a code bug. --- .../docs/ja/guides/codex-integration.md | 2 - .../docs/ko/guides/codex-integration.md | 2 - .../docs/ru/guides/codex-integration.md | 2 - .../docs/zh-cn/guides/codex-integration.md | 2 - src/adapters/google.ts | 20 + src/codex/catalog.ts | 2 +- src/codex/catalog/parsing.ts | 14 +- src/server/images.ts | 86 ++++- tests/codex-catalog.test.ts | 24 +- tests/google-hardening.test.ts | 26 ++ tests/server-images.test.ts | 355 ++++++++++++++++++ 11 files changed, 509 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 964cc68192..4d1a7da499 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -45,8 +45,6 @@ ChatGPT bearer 認証で直接 POST します。注入された `base_url` が o API キー方式 `openai-responses` プロバイダーを指定できます。明示的な選択が失敗しても、別の 有料上流へフォールバックしません。組み込みプロバイダー id には使わず、既定の OpenAI 経路を 使う場合は省略してください。 -- **両方なし:** 曖昧な 404 の代わりに明確なエラーを返します。ルーティングされる他のプロバイダー(Cursor、 - Gemini、Kiro など)は既定では画像生成を提供できません。ツール自体をオフにしたい場合は Codex で - **Google Antigravity (CCA) フォールバック:** OpenAI forward 候補も API キープロバイダーもない場合、 `/v1/images/generations`(`/images/edits` を除く)は Antigravity **Cloud Code Assist** エンドポイントに フォールバックし、`gemini-3.1-flash-image` モデルを使用します。OpenAI 認証の解決に失敗した場合 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 112934a085..63182b91a2 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -44,8 +44,6 @@ ChatGPT bearer 인증으로 직접 POST합니다. 주입된 `base_url`이 openco - **명시적 커스텀 프로바이더:** `images.provider`에 OpenAI Images API를 구현한 커스텀 API-key `openai-responses` 프로바이더를 지정할 수 있습니다. 명시적 선택이 실패해도 다른 유료 업스트림으로 fallback하지 않습니다. 내장 프로바이더 id에는 사용하지 말고, 기본 OpenAI 경로를 쓰려면 생략하세요. -- **둘 다 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor, - Gemini, Kiro 등)는 기본적으로 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서 - **Google Antigravity (CCA) 폴백:** OpenAI forward 후보와 API key 프로바이더 모두 없을 때, `/v1/images/generations`(`/images/edits` 제외)가 Antigravity **Cloud Code Assist** 엔드포인트로 폴백되며 `gemini-3.1-flash-image` 모델을 사용합니다. OpenAI 인증 해석이 실패할 때(예: ChatGPT 자격 diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 7983541f6f..e92939c77b 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -50,8 +50,6 @@ fast_mode = true API-key-провайдер `openai-responses`, реализующий OpenAI Images API. Ошибка явного выбора не приводит к fallback на другую платную вышестоящую сторону. Встроенные id провайдеров здесь не используются; чтобы сохранить стандартный путь OpenAI, опустите это поле. -- **Ни того, ни другого:** прокси возвращает понятную ошибку вместо безликого 404. - Маршрутизируемые провайдеры (Cursor, Gemini, Kiro, …) по умолчанию не могут обслуживать генерацию - **Резерв Google Antigravity (CCA):** когда ни forward-кандидат OpenAI, ни провайдер с API-ключом не настроены, `/v1/images/generations` (но не `/images/edits`) переключается на эндпоинт Antigravity **Cloud Code Assist** с моделью `gemini-3.1-flash-image`. Этот же резерв diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 7bc6f78f0f..ba5b31951d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -43,8 +43,6 @@ OpenAI 上游: - **显式自定义 provider:** 可将 `images.provider` 设为一个自定义 API-key `openai-responses` provider;该 endpoint 必须实现 OpenAI Images API。显式选择失败时不会 fallback 到其他付费上游。内置 provider id 不适用于此字段;省略它即可使用默认 OpenAI 路径。 -- **两者都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、 - Kiro 等)默认无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行 - **Google Antigravity(CCA)回退:** 当 OpenAI forward 候选和 API key 提供商都不存在时, `/v1/images/generations`(不含 `/images/edits`)会回退到 Antigravity **Cloud Code Assist** 端点,使用 `gemini-3.1-flash-image` 模型。当 OpenAI 认证解析失败(例如 ChatGPT 凭证过期或缺失)时, diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 40300419a9..16ae6e2091 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -235,6 +235,16 @@ function usageFromGemini(usage: Record | undefined): OcxUsage | }; } +/** + * Cap on the buffered non-streaming response body (100 MiB), matching + * IMAGES_RESPONSE_MAX_BYTES in src/server/images.ts. Checked via the + * Content-Length header before response.json() so an oversized or malicious + * body is rejected without ever being fully buffered into memory. Streaming + * responses need no such guard — SSE chunks are processed incrementally and + * each inline image is capped by MAX_ENCODED_BYTES_PER_IMAGE before decode. + */ +const MAX_RESPONSE_BYTES = 100 * 1024 * 1024; + // Note: imagen-* models use a different API surface (prediction/image-generation // schema) and must NOT be treated as responseModalities-capable Gemini models. const IMAGE_CAPABLE_MODELS = new Set([ @@ -406,6 +416,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte yield { type: "error", message: "No response body" }; return; } + // Streaming responses are processed incrementally (SSE chunks), so the full body + // is never buffered — no Content-Length pre-check is needed here. Per-image size + // protection is enforced on each chunk via MAX_ENCODED_BYTES_PER_IMAGE before + // materializeInlineImage is called (see the inline.data check below). const reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -571,6 +585,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }, async parseResponse(response: Response): Promise { + // Reject oversized responses before buffering: check Content-Length so an + // oversized or malicious body is never fully read into memory. + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { + return [{ type: "error", message: `google response too large (content-length ${contentLength} exceeds ${MAX_RESPONSE_BYTES} bytes)` }]; + } const raw = await response.json() as Record; if (raw.error) { const err = raw.error as { message?: string }; diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 97eda8b899..0b4337f5e9 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -1,6 +1,6 @@ // AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules. // Public surface preserved exactly; importers keep using "src/codex/catalog". -export { isMediaGenerationModelId, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; +export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 9022dde710..85f9ad0960 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -141,9 +141,21 @@ export function isMediaGenerationModelId(id: string): boolean { return MEDIA_GEN_ID_RE.test(id); } +/** + * Gemini image-capable chat models (e.g. gemini-3.1-flash-image, + * gemini-3-pro-image-preview) produce inline images within text responses via + * the Responses API — they are NOT standalone media-generation models like + * DALL-E or Sora and should appear in the routed catalog despite "image" in + * their id. Mirrors the same id heuristic used by isImageCapableModel in + * src/adapters/google.ts. + */ +function isGeminiImageChatModel(id: string): boolean { + return /gemini/i.test(id) && /image/i.test(id); +} + export function shouldExposeRoutedModel(model: CatalogModel): boolean { if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false; - if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true; + if (isGeminiImageChatModel(model.id)) return true; return !isMediaGenerationModelId(model.id); } diff --git a/src/server/images.ts b/src/server/images.ts index a780bb89ec..2232942432 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -49,6 +49,39 @@ const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024; const CCA_IMAGE_MODEL = "gemini-3.1-flash-image"; +/** + * Google Gemini finishReasons that indicate a permanent content/safety block. + * When any of these is present, the same prompt will always fail — retrying + * wastes paid quota. The response is surfaced as 400 (non-retryable) instead + * of 502 (which codex retries up to 5 times). + */ +const CCA_BLOCKING_FINISH_REASONS: ReadonlySet = new Set([ + "SAFETY", + "BLOCKLIST", + "PROHIBITED_CONTENT", + "SPII", +]); + +/** + * Race a promise against an abort signal. If the signal aborts first, reject + * immediately — our code stops awaiting the underlying operation even though + * the HTTP request behind it may still complete. Used to make the non-cancellable + * OAuth refresh chain responsive to client cancellation and deadline expiry. + */ +function abortableRace(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (val) => { signal.removeEventListener("abort", onAbort); resolve(val); }, + (err) => { signal.removeEventListener("abort", onAbort); reject(err); }, + ); + }); +} + async function tryCcaImageGeneration( body: unknown, config: OcxConfig, @@ -71,9 +104,26 @@ async function tryCcaImageGeneration( const linkedSignal = signalWithTimeout(timeoutMs, signal); let token: string; try { - token = await getValidAccessToken("google-antigravity"); + // Race the OAuth refresh against the deadline signal. getValidAccessToken + // chains through 4 layers (resolveAccessSnapshotForAccount → + // refreshAndPersistAccessToken → refreshGenericAccountWithLock → + // def.refresh()) that do HTTP calls without accepting a signal. Rather than + // threading signal through the entire chain, race the whole call against + // linkedSignal: when the signal aborts we stop awaiting and surface the + // cancellation immediately instead of hanging on the refresh HTTP call. + token = await abortableRace(getValidAccessToken("google-antigravity"), linkedSignal.signal); } catch (err) { linkedSignal.cleanup(); + // abortableRace rejects immediately when the signal fires, so client + // cancellation and deadline expiry surface here. Parent abort propagates + // into the linked signal, so check parent first (499) before the linked + // signal (504). + if (signal.aborted) { + return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + } + if (linkedSignal.signal.aborted) { + return formatErrorResponse(504, "upstream_error", "CCA image generation timed out during authentication"); + } // Missing/revoked credential → 401 (re-login required); transient refresh/network → 502. const errName = err instanceof Error ? err.name : ""; if (errName === "OAuthLoginRequiredError") { @@ -81,17 +131,6 @@ async function tryCcaImageGeneration( } return formatErrorResponse(502, "upstream_error", "CCA image generation failed: OAuth token refresh failed"); } - // Client cancellation or deadline expiry during OAuth preflight. - // Parent abort propagates into the linked signal, so check parent first (499) - // before the linked signal (504). - if (signal.aborted) { - linkedSignal.cleanup(); - return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); - } - if (linkedSignal.signal.aborted) { - linkedSignal.cleanup(); - return formatErrorResponse(504, "upstream_error", "CCA image generation timed out during authentication"); - } const project = getOAuthCredentialProjectId("google-antigravity"); if (!project) { linkedSignal.cleanup(); @@ -213,15 +252,32 @@ async function tryCcaImageGeneration( } catch { return formatErrorResponse(502, "upstream_error", "CCA image response was not valid JSON"); } - const resp = (json.response ?? json) as { candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[] }; - const parts = resp.candidates?.[0]?.content?.parts; + const resp = (json.response ?? json) as { + candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] }; finishReason?: string }[]; + promptFeedback?: { blockReason?: string }; + }; + // Safety blocks are permanent for the same prompt — return 400 (non-retryable) + // instead of 502 (which codex retries up to 5 times, wasting paid quota on a + // prompt that will never succeed). Two blocking signals exist in the Gemini + // API: promptFeedback.blockReason (prompt rejected before generation) and + // candidate.finishReason (generation cut off by a content filter). + const blockReason = resp.promptFeedback?.blockReason; + if (typeof blockReason === "string" && blockReason.trim()) { + return formatErrorResponse(400, "invalid_request_error", `CCA image generation blocked by safety filter (promptFeedback.blockReason: ${blockReason})`); + } + const candidate = resp.candidates?.[0]; + const finishReason = candidate?.finishReason; + if (typeof finishReason === "string" && CCA_BLOCKING_FINISH_REASONS.has(finishReason)) { + return formatErrorResponse(400, "invalid_request_error", `CCA image generation blocked by safety filter (finishReason: ${finishReason})`); + } + const parts = candidate?.content?.parts; if (!Array.isArray(parts)) { return formatErrorResponse(502, "upstream_error", "CCA image response had no valid parts array"); } const images: { b64_json: string }[] = []; for (const part of parts) { if (!part || typeof part !== "object") continue; - if (part.inlineData?.data) images.push({ b64_json: part.inlineData.data }); + if (typeof part.inlineData?.data === "string" && part.inlineData.data.length > 0) images.push({ b64_json: part.inlineData.data }); } if (images.length === 0) { return formatErrorResponse(502, "upstream_error", "CCA image model returned no image data"); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 228a660389..8275dd6f36 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog"; +import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; import { CURSOR_STATIC_MODELS, filterCursorConfiguredModelsByLiveDiscovery, @@ -2671,6 +2671,28 @@ describe("media-generation model filtering", () => { }); }); +describe("shouldExposeRoutedModel — Gemini image-capable exemption", () => { + test("exposes gemini-3.1-flash-image (image-capable chat model, not media-gen)", () => { + expect(shouldExposeRoutedModel({ provider: "google-antigravity", id: "gemini-3.1-flash-image" })).toBe(true); + }); + + test("exposes cursor gemini-3-pro-image-preview", () => { + expect(shouldExposeRoutedModel({ provider: "cursor", id: "gemini-3-pro-image-preview" })).toBe(true); + }); + + test("still filters true media-generation models", () => { + for (const id of [ + "grok-2-image", "gpt-image-1", "dall-e-3", "imagen-4", "sora-2", "veo-3", "flux", + ]) { + expect(shouldExposeRoutedModel({ provider: "openrouter", id })).toBe(false); + } + }); + + test("still filters compatibility-excluded slugs", () => { + expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "hy3-preview" })).toBe(false); + }); +}); + describe("Codex reasoning-effort capability clamp", () => { function bundledCatalogDeps(efforts: string[]) { return { diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index bec8e6fad6..8640461884 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -251,6 +251,32 @@ describe("google provider hardening", () => { } }); + test("non-streaming responses reject oversized Content-Length before buffering", async () => { + const adapter = createGoogleAdapter(provider()); + const oversized = new Response("{}", { + status: 200, + headers: { "content-length": String(101 * 1024 * 1024) }, + }); + + const events = await adapter.parseResponse!(oversized); + + expect(events).toEqual([{ type: "error", message: expect.stringContaining("google response too large") }]); + expect(events[0].type).toBe("error"); + }); + + test("non-streaming responses accept Content-Length under the cap", async () => { + const adapter = createGoogleAdapter(provider()); + const body = { candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }] }; + const response = new Response(JSON.stringify(body), { + status: 200, + headers: { "content-length": String(JSON.stringify(body).length) }, + }); + + const events = await adapter.parseResponse!(response); + expect(events.some(e => e.type === "done")).toBe(true); + expect(events.some(e => e.type === "error")).toBe(false); + }); + test("sends Gemini Flash thinkingLevel only for direct AI Studio requests", async () => { const direct = createGoogleAdapter(provider({ modelReasoningEfforts: { diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index ec7aa2190b..2733531e4c 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1305,6 +1305,88 @@ test("CCA image fallback preserves upstream 400 (not collapsed to 502)", async ( } }); +test("CCA image response with malformed inlineData.data (non-string) returns 502, not fake image data", async () => { + // Regression for codex1-malformed-inlinedata: inlineData.data that is not a + // non-empty string (e.g. a number, object, or empty string) used to pass the + // truthiness check and was forwarded as a fake b64_json. Now it must be + // silently skipped, and when no valid images remain the response is 502. + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [ + { inlineData: { mimeType: "image/png", data: 12345 } }, // number + { inlineData: { mimeType: "image/png", data: { foo: "bar" } } }, // object + { inlineData: { mimeType: "image/png", data: "" } }, // empty string + { inlineData: { mimeType: "image/png", data: null } }, // null + ] }, + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("no image data"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA image response skips malformed inlineData.data but keeps valid string image", async () => { + // When some parts have non-string data and at least one has a valid non-empty + // string, the valid image is extracted and the malformed ones are skipped. + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [ + { inlineData: { mimeType: "image/png", data: 42 } }, + { inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }, + { inlineData: { mimeType: "image/png", data: "" } }, + ] }, + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { data: { b64_json: string }[] }; + expect(json.data).toHaveLength(1); + expect(json.data[0].b64_json).toBe("aGVsbG8="); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + test("CCA image response with non-array parts returns 502 (envelope validation)", async () => { const registryHits: CcaFetchRequest[] = []; const otherHits: CcaFetchRequest[] = []; @@ -1333,3 +1415,276 @@ test("CCA image response with non-array parts returns 502 (envelope validation)" await server.stop(true); } }); + +// ── OAuth preflight cancellation (finding2-oauth-signal) ── +// getValidAccessToken chains through 4 layers of non-cancellable OAuth functions. +// The fix wraps it in abortableRace against linkedSignal so client cancellation +// and deadline expiry are surfaced promptly instead of hanging on the refresh. + +/** Expired credential that forces getValidAccessToken to trigger a token refresh. */ +const CCA_CREDENTIAL_EXPIRED = { + access: "cca-expired-token", + refresh: "cca-refresh-token", + expires: Date.now() - 60_000, + projectId: "cca-project-123", +} as const; + +/** + * Mock fetch so the Google OAuth token endpoint (oauth2.googleapis.com/token) + * hangs indefinitely — simulating a hung OAuth refresh. The registry host and + * all other hosts pass through to the real network stack (they should never be + * reached during these tests). + */ +function hungOauthFetchMock() { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "oauth2.googleapis.com") { + // Never resolve until the fetch signal aborts (just like production). + return new Promise((_resolve, reject) => { + const sig = init?.signal; + if (sig) { + if (sig.aborted) reject(new DOMException("The operation was aborted.", "AbortError")); + else sig.addEventListener("abort", () => reject(new DOMException("The operation was aborted.", "AbortError")), { once: true }); + } + }); + } + return originalFetch(input, init); + }) as typeof fetch; +} + +test("CCA client abort during OAuth preflight returns 499, not a hung response", async () => { + // Regression for finding2-oauth-signal: when the client disconnects while + // getValidAccessToken is refreshing the token, the request must return 499 + // promptly, not hang waiting for the refresh HTTP call to complete. + const { handleImages } = await import("../src/server/images"); + hungOauthFetchMock(); + + // Long timeout so the deadline does NOT fire — only the client abort triggers. + const cfg = { ...ccaConfig(), images: { timeoutMs: 30_000 } } as OcxConfig; + saveConfig(cfg); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL_EXPIRED }); + + const ctrl = new AbortController(); + const req = new Request("http://localhost:0/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + signal: ctrl.signal, + }); + const logCtx = { model: "", provider: "" } as never; + + // Start the handler — it enters getValidAccessToken which hangs on the token refresh. + const responsePromise = handleImages(req, cfg, "generations", logCtx); + // Abort the parent signal after the OAuth preflight has started. + setTimeout(() => ctrl.abort(), 100); + const response = await responsePromise; + expect(response.status).toBe(499); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("canceled"); +}, 5_000); + +test("CCA deadline expiry during OAuth preflight returns 504, not a hung response", async () => { + // Regression for finding2-oauth-signal: when the deadline fires while + // getValidAccessToken is refreshing the token, the request must return 504 + // promptly, not hang waiting for the refresh HTTP call to complete. + const { handleImages } = await import("../src/server/images"); + hungOauthFetchMock(); + + // Short timeout so the deadline fires during the hung OAuth refresh. + const cfg = { ...ccaConfig(), images: { timeoutMs: 100 } } as OcxConfig; + saveConfig(cfg); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL_EXPIRED }); + + const req = new Request("http://localhost:0/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + const logCtx = { model: "", provider: "" } as never; + + const response = await handleImages(req, cfg, "generations", logCtx); + expect(response.status).toBe(504); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("timed out"); +}, 5_000); + +// ── codex4-cca-safety-blocks ── +// Safety blocks are permanent for the same prompt. Returning 502 (upstream_error) +// causes codex to retry up to 5 times, wasting paid quota on a prompt that will +// never succeed. These must return 400 (invalid_request_error, non-retryable). + +test("CCA finishReason SAFETY returns 400 (non-retryable), not 502", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [] }, + finishReason: "SAFETY", + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { type: string; message: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toContain("safety filter"); + expect(json.error.message).toContain("SAFETY"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA promptFeedback.blockReason returns 400 (non-retryable)", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + promptFeedback: { blockReason: "SAFETY" }, + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { type: string; message: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toContain("safety filter"); + expect(json.error.message).toContain("promptFeedback"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA finishReason BLOCKLIST returns 400 (non-retryable)", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ finishReason: "BLOCKLIST" }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { type: string; message: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toContain("BLOCKLIST"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA blocked candidate with empty content.parts returns 400, not 502", async () => { + // When content is blocked, candidates may exist but content/parts is missing + // or empty. The safety check must fire before the "no valid parts" 502 path. + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + finishReason: "PROHIBITED_CONTENT", + // content is entirely absent — common when generation is blocked + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { type: string; message: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toContain("PROHIBITED_CONTENT"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA finishReason STOP with valid image is not affected by safety block logic", async () => { + // Regression: a normal STOP finishReason with a valid inline image must still + // return 200 — the safety check must not false-positive on non-blocking reasons. + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }] }, + finishReason: "STOP", + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { data: { b64_json: string }[] }; + expect(json.data).toHaveLength(1); + expect(json.data[0].b64_json).toBe("aGVsbG8="); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); From 168a516db64feb3ec39f864f480fd1c08cc93d86 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:23:54 +0200 Subject: [PATCH 09/12] fix(google): harden Gemini inline images for #355 maintainer takeover Address remaining Wibias blockers: admission bearer allows CCA/keyed without forwarding the secret, CCA n=1 + RECITATION + validated base64, explicit image model allowlists, authenticated opaque artifact HTTP route, and size caps with focused regressions. --- src/adapters/google.ts | 82 ++++++++++---- src/codex/catalog/parsing.ts | 17 +-- src/images/artifacts.ts | 93 ++++++++++++--- src/server/images.ts | 39 ++++++- src/server/index.ts | 22 ++++ tests/codex-catalog.test.ts | 5 + tests/images/gemini-inline.test.ts | 89 +++++---------- tests/server-images.test.ts | 175 +++++++++++++++++++++++++++-- 8 files changed, 400 insertions(+), 122 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 16ae6e2091..233204e653 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,8 +1,7 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; -import { pathToFileURL } from "node:url"; -import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts"; +import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts"; import type { AdapterEvent, OcxAssistantMessage, @@ -237,36 +236,34 @@ function usageFromGemini(usage: Record | undefined): OcxUsage | /** * Cap on the buffered non-streaming response body (100 MiB), matching - * IMAGES_RESPONSE_MAX_BYTES in src/server/images.ts. Checked via the - * Content-Length header before response.json() so an oversized or malicious - * body is rejected without ever being fully buffered into memory. Streaming - * responses need no such guard — SSE chunks are processed incrementally and - * each inline image is capped by MAX_ENCODED_BYTES_PER_IMAGE before decode. + * IMAGES_RESPONSE_MAX_BYTES in src/server/images.ts. Enforced by streaming the + * body with a hard byte cap before JSON.parse — Content-Length alone is not + * trusted (missing/lying headers must still reject oversized payloads). + * Streaming SSE responses also cap each data frame before JSON.parse. */ const MAX_RESPONSE_BYTES = 100 * 1024 * 1024; +const MAX_SSE_FRAME_BYTES = MAX_RESPONSE_BYTES; // Note: imagen-* models use a different API surface (prediction/image-generation // schema) and must NOT be treated as responseModalities-capable Gemini models. +// Explicit allowlist only — never `/gemini/ && /image/` (resurrects media-gen IDs). const IMAGE_CAPABLE_MODELS = new Set([ "gemini-3.1-flash-image", "gemini-2.0-flash-preview-image-generation", + "gemini-3-pro-image-preview", ]); function isImageCapableModel(modelId: string): boolean { - if (IMAGE_CAPABLE_MODELS.has(modelId)) return true; - return /image/.test(modelId) && /gemini/.test(modelId); + return IMAGE_CAPABLE_MODELS.has(modelId); } /** - * Emit a standard file: URI (via node:url pathToFileURL) so markdown renderers - * (including Codex) can resolve and open the image. The previous "~/" prefix - * approach was not expanded by clients, and a hand-rolled `"file:" + encodeURI` - * produced non-standard URIs that break on Windows (file:C:%5C… should be - * file:///C:/…) and mishandle '#'/':' in paths. pathToFileURL follows the - * WHATWG URL spec for correct, cross-platform file: URIs. + * Model-visible markdown link for a materialized artifact. Uses the authenticated + * opaque HTTP route so remote/container clients can fetch the image without host + * filesystem paths leaking into the transcript. */ -function artifactFileUrl(filePath: string): string { - return pathToFileURL(filePath).href; +function artifactMarkdownUrl(filePath: string): string { + return artifactHttpUrl(filePath).replace(/([()])/g, "\\$1"); } export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { @@ -433,6 +430,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const handleDataLine = async function* (line: string): AsyncGenerator { const payload = line.slice(5).trim(); if (!payload) return "continue"; + if (payload.length > MAX_SSE_FRAME_BYTES) { + yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` }; + return "terminate"; + } let emittedContentEvent = false; let chunk: Record; @@ -502,7 +503,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } else { try { const filePath = await materializeInlineImage(inline.data, imageBudget); - const escapedPath = artifactFileUrl(filePath).replace(/([()])/g, "\\$1"); + const escapedPath = artifactMarkdownUrl(filePath); emittedContentEvent = true; yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; } catch { @@ -585,13 +586,50 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }, async parseResponse(response: Response): Promise { - // Reject oversized responses before buffering: check Content-Length so an - // oversized or malicious body is never fully read into memory. + // Reject oversized responses before JSON parse. Prefer Content-Length when + // present and truthful; always stream-read with a hard byte cap so a missing + // or lying Content-Length cannot force a full in-memory buffer + parse. const contentLength = Number(response.headers.get("content-length")); if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { return [{ type: "error", message: `google response too large (content-length ${contentLength} exceeds ${MAX_RESPONSE_BYTES} bytes)` }]; } - const raw = await response.json() as Record; + let rawText: string; + try { + const reader = response.body?.getReader(); + if (!reader) return [{ type: "error", message: "google response had no body" }]; + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => {}); + return [{ type: "error", message: `google response too large (exceeded ${MAX_RESPONSE_BYTES} bytes)` }]; + } + chunks.push(value); + } + } finally { + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + rawText = new TextDecoder().decode(bytes); + } catch (err) { + return [{ type: "error", message: err instanceof Error ? err.message : "failed to read google response body" }]; + } + let raw: Record; + try { + raw = JSON.parse(rawText) as Record; + } catch { + return [{ type: "error", message: "google response was not valid JSON" }]; + } if (raw.error) { const err = raw.error as { message?: string }; return [{ type: "error", message: err.message ?? "upstream error" }]; @@ -627,7 +665,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } else { try { const filePath = await materializeInlineImage(inline.data, imageBudget); - const escapedPath = artifactFileUrl(filePath).replace(/([()])/g, "\\$1"); + const escapedPath = artifactMarkdownUrl(filePath); events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); } catch { events.push({ type: "error", message: "failed to materialize inline image" }); diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 85f9ad0960..96a4c0f26e 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -142,15 +142,18 @@ export function isMediaGenerationModelId(id: string): boolean { } /** - * Gemini image-capable chat models (e.g. gemini-3.1-flash-image, - * gemini-3-pro-image-preview) produce inline images within text responses via - * the Responses API — they are NOT standalone media-generation models like - * DALL-E or Sora and should appear in the routed catalog despite "image" in - * their id. Mirrors the same id heuristic used by isImageCapableModel in - * src/adapters/google.ts. + * Gemini image-capable chat models produce inline images within text responses + * via the Responses API. Explicit allowlist only — a broad `/gemini/ && /image/` + * heuristic resurrects standalone media-gen IDs (e.g. gemini-3-pro-image). */ +const GEMINI_IMAGE_CHAT_MODEL_IDS = new Set([ + "gemini-3.1-flash-image", + "gemini-2.0-flash-preview-image-generation", + "gemini-3-pro-image-preview", +]); + function isGeminiImageChatModel(id: string): boolean { - return /gemini/i.test(id) && /image/i.test(id); + return GEMINI_IMAGE_CHAT_MODEL_IDS.has(id); } export function shouldExposeRoutedModel(model: CatalogModel): boolean { diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index c7afbec280..3d8d952278 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -1,6 +1,6 @@ -import { mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; +import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, join, resolve, sep } from "node:path"; import { getConfigDir } from "../config"; import { assessUrlDestination, assertUrlResolvesPublic } from "../lib/destination-policy"; @@ -20,6 +20,11 @@ export const MAX_ENCODED_BYTES_PER_IMAGE = Math.ceil(MAX_DECODED_BYTES_PER_IMAGE /** Default cap on files retained under artifacts/. Oldest files are pruned when exceeded. */ export const DEFAULT_ARTIFACT_KEEP_COUNT = 200; +/** Opaque artifact HTTP path prefix (data-plane, API-auth gated). */ +export const ARTIFACT_HTTP_PREFIX = "/v1/opencodex/artifacts"; + +const ARTIFACT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}\.(png|jpe?g|webp|gif)$/i; + // Strict alphabet check: Buffer.from(..., "base64") silently ignores invalid // characters, so malformed payloads would otherwise decode to garbage bytes. const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; @@ -32,10 +37,77 @@ export function createImageBudget(): ImageBudget { return { spent: 0 }; } -function getArtifactsDir(): string { +export function getArtifactsDir(): string { return join(getConfigDir(), "artifacts"); } +/** + * Markdown-safe relative URL for a materialized artifact. Opaque filename only — + * never expose host filesystem paths to model-visible content. + */ +export function artifactHttpUrl(filePath: string): string { + const name = basename(filePath); + if (!ARTIFACT_ID_RE.test(name)) { + throw new Error("artifact filename is not a valid opaque id"); + } + return `${ARTIFACT_HTTP_PREFIX}/${name}`; +} + +/** + * Resolve an opaque artifact id to an absolute path under the artifacts dir. + * Rejects traversal (`..`, absolute paths, separators). + */ +export function resolveArtifactPath(id: string): string | null { + if (!ARTIFACT_ID_RE.test(id)) return null; + const dir = resolve(getArtifactsDir()); + const candidate = resolve(dir, id); + if (candidate !== dir && !candidate.startsWith(dir + sep)) return null; + if (!existsSync(candidate)) return null; + try { + if (!statSync(candidate).isFile()) return null; + } catch { + return null; + } + return candidate; +} + +export function readArtifactBytes(id: string): { bytes: Buffer; contentType: string } | null { + const path = resolveArtifactPath(id); + if (!path) return null; + const bytes = readFileSync(path); + const ext = path.split(".").pop()?.toLowerCase(); + const contentType = + ext === "png" ? "image/png" + : ext === "jpg" || ext === "jpeg" ? "image/jpeg" + : ext === "webp" ? "image/webp" + : ext === "gif" ? "image/gif" + : "application/octet-stream"; + return { bytes, contentType }; +} + +/** + * Decode + validate base64 image bytes (alphabet, size, magic). Used by CCA + * Images fallback before returning b64_json and by materializeInlineImage. + */ +export function decodeValidatedImageBase64(base64Data: string): Buffer { + const normalized = base64Data.replace(/\s+/g, ""); + if (!BASE64_RE.test(normalized) || normalized.length % 4 !== 0) { + throw new Error("inline image data is not valid base64"); + } + if (normalized.length > MAX_ENCODED_BYTES_PER_IMAGE) { + throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`); + } + const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0; + const decodedBytes = (normalized.length / 4) * 3 - padding; + if (decodedBytes === 0) throw new Error("inline image data is empty after base64 decode"); + if (decodedBytes > MAX_DECODED_BYTES_PER_IMAGE) { + throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`); + } + const buf = Buffer.from(normalized, "base64"); + guessExtFromMagic(buf); + return buf; +} + /** * Best-effort retention cap: when the artifact directory holds more than `maxFiles`, * delete the oldest (by mtime) until the count is back under the limit. Synchronous @@ -132,21 +204,10 @@ export async function materializeInlineImage( const dir = getArtifactsDir(); await mkdir(dir, { recursive: true, mode: 0o700 }); - const normalized = base64Data.replace(/\s+/g, ""); - if (!BASE64_RE.test(normalized) || normalized.length % 4 !== 0) { - throw new Error("inline image data is not valid base64"); - } - // Validate decoded size from the base64 length *before* allocating a Buffer, so a - // malicious or broken upstream cannot force a large allocation / OOM. - const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0; - const decodedBytes = (normalized.length / 4) * 3 - padding; - if (decodedBytes === 0) throw new Error("inline image data is empty after base64 decode"); - if (decodedBytes > MAX_DECODED_BYTES_PER_IMAGE) throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`); - if (budget && budget.spent + decodedBytes > MAX_DECODED_BYTES_PER_RESPONSE) { + const buf = decodeValidatedImageBase64(base64Data); + if (budget && budget.spent + buf.length > MAX_DECODED_BYTES_PER_RESPONSE) { throw new Error(`inline image response exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response cap`); } - - const buf = Buffer.from(normalized, "base64"); if (budget) budget.spent += buf.length; // Sniff actual format from decoded bytes rather than trusting the declared mimeType. diff --git a/src/server/images.ts b/src/server/images.ts index 2232942432..1eb328f78f 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -34,6 +34,7 @@ import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors"; import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error"; import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; +import { decodeValidatedImageBase64, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts"; export type ImagesEndpoint = "generations" | "edits"; @@ -60,6 +61,7 @@ const CCA_BLOCKING_FINISH_REASONS: ReadonlySet = new Set([ "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", + "RECITATION", ]); /** @@ -98,6 +100,14 @@ async function tryCcaImageGeneration( return formatErrorResponse(400, "invalid_request_error", "prompt is required and must not be empty"); } + const nRaw = (body as { n?: unknown })?.n; + if (nRaw !== undefined && nRaw !== null) { + const n = typeof nRaw === "number" ? nRaw : Number(nRaw); + if (!Number.isInteger(n) || n !== 1) { + return formatErrorResponse(400, "invalid_request_error", "CCA image generation supports n=1 only"); + } + } + // Create the deadline before credential resolution so the timeout covers // OAuth token refresh and project discovery, not just the upstream fetch. const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; @@ -277,7 +287,17 @@ async function tryCcaImageGeneration( const images: { b64_json: string }[] = []; for (const part of parts) { if (!part || typeof part !== "object") continue; - if (typeof part.inlineData?.data === "string" && part.inlineData.data.length > 0) images.push({ b64_json: part.inlineData.data }); + const data = part.inlineData?.data; + if (typeof data !== "string" || data.length === 0) continue; + if (data.length > MAX_ENCODED_BYTES_PER_IMAGE) { + return formatErrorResponse(502, "upstream_error", "CCA image payload exceeds per-image size cap"); + } + try { + decodeValidatedImageBase64(data); + } catch { + return formatErrorResponse(502, "upstream_error", "CCA image payload failed base64/magic validation"); + } + images.push({ b64_json: data }); } if (images.length === 0) { return formatErrorResponse(502, "upstream_error", "CCA image model returned no image data"); @@ -305,11 +325,18 @@ export async function handleImages( return formatErrorResponse(400, "invalid_request_error", candidates.error); } const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined; + // Admission bearer is valid proxy auth (requireApiAuth already passed) but must never be + // forwarded as OpenAI ChatGPT credentials. When the caller sent it, skip OpenAI forward + // and allow CCA / keyed paths instead of rejecting the whole request. + let skipOpenAiForwardForAdmissionBearer = false; if (!explicitKeyedProvider) { try { validateForwardAdmissionCredential(req.headers, config); } catch (err) { - if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message); - throw err; + if (err instanceof ForwardAdmissionCredentialError) { + skipOpenAiForwardForAdmissionBearer = true; + } else { + throw err; + } } } let body: unknown; @@ -321,7 +348,9 @@ export async function handleImages( const model = (body as { model?: unknown } | null)?.model; if (typeof model === "string" && model) logCtx.model = model; - if (candidates.forwardCandidates.length === 0 && !candidates.keyed) { + const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0; + + if (!canUseOpenAiForward && !candidates.keyed) { const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); if (ccaResponse) return ccaResponse; // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent @@ -340,7 +369,7 @@ export async function handleImages( // 429 image_gen while api.openai.com sits idle). let forward: Awaited>; let forwardAuthError: Response | undefined; - if (candidates.forwardCandidates.length > 0) { + if (canUseOpenAiForward) { try { forward = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config); if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config); diff --git a/src/server/index.ts b/src/server/index.ts index 05c9b2b4fa..2ab8fdc66b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -498,6 +498,28 @@ export function startServer(port?: number) { return withCors(response, req, config); } + if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) { + const apiAuthError = requireApiAuth(req, config, "data-plane"); + if (apiAuthError) return withCors(apiAuthError, req, config); + if (!isAllowedRequestOrigin(req, config)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config); + } + const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length)); + const { readArtifactBytes } = await import("../images/artifacts"); + const artifact = readArtifactBytes(id); + if (!artifact) { + return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config); + } + return withCors(new Response(new Uint8Array(artifact.bytes), { + status: 200, + headers: { + "content-type": artifact.contentType, + "cache-control": "private, max-age=3600", + "x-content-type-options": "nosniff", + }, + }), req, config); + } + if (url.pathname === "/v1/alpha/search" && req.method === "POST") { disableResponsesRequestTimeout(req, requestServer); if (isDraining()) { diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 8275dd6f36..179e8a1bc1 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2680,6 +2680,11 @@ describe("shouldExposeRoutedModel — Gemini image-capable exemption", () => { expect(shouldExposeRoutedModel({ provider: "cursor", id: "gemini-3-pro-image-preview" })).toBe(true); }); + test("does not resurrect standalone media-gen gemini image ids", () => { + expect(shouldExposeRoutedModel({ provider: "google-antigravity", id: "gemini-3-pro-image" })).toBe(false); + expect(shouldExposeRoutedModel({ provider: "openrouter", id: "gemini-3-pro-image" })).toBe(false); + }); + test("still filters true media-generation models", () => { for (const id of [ "grok-2-image", "gpt-image-1", "dall-e-3", "imagen-4", "sora-2", "veo-3", "flux", diff --git a/tests/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts index 68038d6069..22be6d3219 100644 --- a/tests/images/gemini-inline.test.ts +++ b/tests/images/gemini-inline.test.ts @@ -2,7 +2,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { createImageBudget, guessExtFromMagic, materializeInlineImage } from "../../src/images/artifacts"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import { createGoogleAdapter } from "../../src/adapters/google"; @@ -268,23 +267,8 @@ describe("responseModalities gating", () => { }); }); -describe("markdown path escaping with special characters", () => { - let specialHome: string; - let savedHome: string | undefined; - - beforeAll(() => { - savedHome = process.env.OPENCODEX_HOME; - specialHome = mkdtempSync(join(tmpdir(), "ocx test (dir) ")); - process.env.OPENCODEX_HOME = specialHome; - }); - - afterAll(() => { - if (savedHome !== undefined) process.env.OPENCODEX_HOME = savedHome; - else delete process.env.OPENCODEX_HOME; - rmSync(specialHome, { recursive: true, force: true }); - }); - - test("streaming: file: URI percent-encodes spaces and escapes parens", async () => { +describe("markdown emits authenticated opaque artifact URLs", () => { + test("streaming: markdown uses /v1/opencodex/artifacts/, not file: or host paths", async () => { const events = await collectStream(aiStudioProvider, [ { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, @@ -295,20 +279,16 @@ describe("markdown path escaping with special characters", () => { const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); expect(match).not.toBeNull(); const mdPath = match![1]; - // Standard file: URI prefix so clients can resolve the link - expect(mdPath.startsWith("file:")).toBe(true); - // Spaces are percent-encoded by pathToFileURL, never literal - expect(mdPath).toContain("%20"); - expect(mdPath).not.toMatch(/(? { + expect(mdPath.startsWith("/v1/opencodex/artifacts/")).toBe(true); + expect(mdPath).not.toContain("file:"); + expect(mdPath).not.toContain("~/"); + expect(mdPath).not.toMatch(/[A-Za-z]:\\/); + expect(mdPath).not.toContain(tempHome); + const id = mdPath.slice("/v1/opencodex/artifacts/".length); + expect(existsSync(join(artifactsDir, id))).toBe(true); + }); + + test("non-streaming: markdown uses /v1/opencodex/artifacts/, not file: or host paths", async () => { const adapter = createGoogleAdapter(aiStudioProvider); const events = await adapter.parseResponse(jsonResponse({ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/webp", data: TINY_PNG } }] }, finishReason: "STOP" }], @@ -320,26 +300,20 @@ describe("markdown path escaping with special characters", () => { const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); expect(match).not.toBeNull(); const mdPath = match![1]; - expect(mdPath.startsWith("file:")).toBe(true); - // Spaces are percent-encoded by pathToFileURL, never literal - expect(mdPath).toContain("%20"); - expect(mdPath).not.toMatch(/(? { +describe("artifact markdown never leaks OPENCODEX_HOME paths", () => { let underHome: string; let savedHome: string | undefined; beforeAll(() => { savedHome = process.env.OPENCODEX_HOME; - // Place OPENCODEX_HOME *under* the real home so the path includes the username segment. underHome = mkdtempSync(join(homedir(), ".ocx-test-leak-")); process.env.OPENCODEX_HOME = underHome; }); @@ -350,7 +324,7 @@ describe("artifact path is a resolvable file: URI", () => { rmSync(underHome, { recursive: true, force: true }); }); - test("streaming: emitted path is a resolvable file: URI", async () => { + test("streaming: no home/username path segments in markdown", async () => { const events = await collectStream(aiStudioProvider, [ { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, @@ -358,19 +332,14 @@ describe("artifact path is a resolvable file: URI", () => { const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; expect(textEvents.length).toBe(1); const md = textEvents[0].text; - // Output must be a resolvable file: URI (fixes the ~/ regression where clients - // could not open the link). A file: URI is inherently an absolute path. - expect(md).toContain("file:"); - // The old ~/ abbreviation must NOT be used (it broke resolution). + expect(md).toContain("/v1/opencodex/artifacts/"); + expect(md).not.toContain("file:"); expect(md).not.toContain("~/"); - // Round-trip with fileURLToPath to prove it's a standard file: URI. - const match = md.match(/^\n!\[image\]\((.+)\)\n$/); - expect(match).not.toBeNull(); - const unescaped = match![1].replace(/\\([()])/g, "$1"); - expect(existsSync(fileURLToPath(unescaped))).toBe(true); + expect(md).not.toContain(underHome); + expect(md).not.toContain(homedir()); }); - test("non-streaming: emitted path is a resolvable file: URI", async () => { + test("non-streaming: no home/username path segments in markdown", async () => { const adapter = createGoogleAdapter(aiStudioProvider); const events = await adapter.parseResponse(jsonResponse({ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] }, finishReason: "STOP" }], @@ -379,13 +348,9 @@ describe("artifact path is a resolvable file: URI", () => { const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; expect(textEvents.length).toBe(1); const md = textEvents[0].text; - expect(md).toContain("file:"); - expect(md).not.toContain("~/"); - // Round-trip with fileURLToPath to prove it's a standard file: URI. - const match = md.match(/^\n!\[image\]\((.+)\)\n$/); - expect(match).not.toBeNull(); - const unescaped = match![1].replace(/\\([()])/g, "$1"); - expect(existsSync(fileURLToPath(unescaped))).toBe(true); + expect(md).toContain("/v1/opencodex/artifacts/"); + expect(md).not.toContain("file:"); + expect(md).not.toContain(underHome); }); }); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 2733531e4c..8d5baf74ad 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -823,14 +823,17 @@ test("the proxy admission secret is never relayed to the forward upstream", asyn const server = startServer(0); try { // Authorization carries the proxy's OWN admission token — it authenticates the caller to the - // proxy, but must be stripped before upstream selection (else it would leak to chatgpt.com). + // proxy, but must never be forwarded as ChatGPT credentials. OpenAI forward is skipped; a + // configured keyed provider may still serve the request with its own apiKey. const response = await fetch(`http://127.0.0.1:${server.port}/v1/images/generations`, { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer local-secret" }, body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), }); - expect(response.status).toBe(401); - expect(captured).toHaveLength(0); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0].headers.get("authorization")).toBe("Bearer sk-platform-key"); + expect([...captured[0].headers.values()].some(v => v.includes("local-secret"))).toBe(false); } finally { await server.stop(true); await upstream.stop(true); @@ -867,6 +870,8 @@ interface CcaFetchRequest { body: unknown; } +const CCA_TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="; + /** * Stub globalThis.fetch for CCA image tests: requests to the registry host * (daily-cloudcode-pa.googleapis.com) get a canned response and are recorded in @@ -883,7 +888,7 @@ function ccaFetchMock( const payload = response?.payload ?? { response: { candidates: [{ - content: { parts: [{ inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }] }, + content: { parts: [{ inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }] }, }], }, }; @@ -931,7 +936,7 @@ test("CCA image fallback generates images via Google Antigravity when no OpenAI expect(response.status).toBe(200); const json = await response.json() as { data: { b64_json: string }[] }; expect(json.data).toHaveLength(1); - expect(json.data[0].b64_json).toBe("aGVsbG8="); + expect(json.data[0].b64_json).toBe(CCA_TINY_PNG); // The CCA call MUST hit the registry host, not the config-level baseUrl. expect(registryHits).toHaveLength(1); @@ -1109,7 +1114,7 @@ test("CCA fallback serves images when OpenAI forward auth fails but Google Antig expect(response.status).toBe(200); const json = await response.json() as { data: { b64_json: string }[] }; expect(json.data).toHaveLength(1); - expect(json.data[0].b64_json).toBe("aGVsbG8="); + expect(json.data[0].b64_json).toBe(CCA_TINY_PNG); // CCA was called on the registry host, not the attacker host. expect(registryHits).toHaveLength(1); @@ -1358,7 +1363,7 @@ test("CCA image response skips malformed inlineData.data but keeps valid string candidates: [{ content: { parts: [ { inlineData: { mimeType: "image/png", data: 42 } }, - { inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }, + { inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }, { inlineData: { mimeType: "image/png", data: "" } }, ] }, }], @@ -1379,7 +1384,7 @@ test("CCA image response skips malformed inlineData.data but keeps valid string expect(response.status).toBe(200); const json = await response.json() as { data: { b64_json: string }[] }; expect(json.data).toHaveLength(1); - expect(json.data[0].b64_json).toBe("aGVsbG8="); + expect(json.data[0].b64_json).toBe(CCA_TINY_PNG); expect(registryHits).toHaveLength(1); expect(otherHits).toHaveLength(0); } finally { @@ -1661,7 +1666,7 @@ test("CCA finishReason STOP with valid image is not affected by safety block log payload: { response: { candidates: [{ - content: { parts: [{ inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }] }, + content: { parts: [{ inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }] }, finishReason: "STOP", }], }, @@ -1681,10 +1686,160 @@ test("CCA finishReason STOP with valid image is not affected by safety block log expect(response.status).toBe(200); const json = await response.json() as { data: { b64_json: string }[] }; expect(json.data).toHaveLength(1); - expect(json.data[0].b64_json).toBe("aGVsbG8="); + expect(json.data[0].b64_json).toBe(CCA_TINY_PNG); expect(registryHits).toHaveLength(1); expect(otherHits).toHaveLength(0); } finally { await server.stop(true); } }); + +test("CCA-only request with proxy admission bearer succeeds and never sends it upstream", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-admission-secret"; + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer proxy-admission-secret", + }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(200); + expect(registryHits).toHaveLength(1); + expect([...registryHits[0].headers.values()].some(v => v.includes("proxy-admission-secret"))).toBe(false); + expect(registryHits[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + } finally { + await server.stop(true); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + } +}); + +test("CCA rejects n>1 before contacting Google", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat", n: 2 }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toMatch(/n=1/i); + expect(registryHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA RECITATION finishReason returns non-retryable 400", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ content: { parts: [] }, finishReason: "RECITATION" }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "copyrighted stuff" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toMatch(/RECITATION|safety/i); + } finally { + await server.stop(true); + } +}); + +test("CCA rejects invalid base64 and non-image bytes instead of returning b64_json", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }] }, + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toMatch(/base64|magic|validation/i); + } finally { + await server.stop(true); + } +}); + +test("GET /v1/opencodex/artifacts/:id serves opaque artifacts with API auth", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-admission-secret"; + const { materializeInlineImage, createImageBudget, artifactHttpUrl } = await import("../src/images/artifacts"); + const filePath = await materializeInlineImage(CCA_TINY_PNG, createImageBudget()); + const urlPath = artifactHttpUrl(filePath); + + // Non-loopback bind makes data-plane auth mandatory (same as production remote binds). + saveConfig({ ...ccaConfig(), hostname: "0.0.0.0" }); + const server = startServer(0); + try { + const denied = await fetch(`http://127.0.0.1:${server.port}${urlPath}`); + expect(denied.status).toBe(401); + + const wrong = await fetch(`http://127.0.0.1:${server.port}${urlPath}`, { + headers: { authorization: "Bearer wrong-secret" }, + }); + expect(wrong.status).toBe(401); + + const ok = await fetch(`http://127.0.0.1:${server.port}${urlPath}`, { + headers: { authorization: "Bearer proxy-admission-secret" }, + }); + expect(ok.status).toBe(200); + expect(ok.headers.get("content-type")).toBe("image/png"); + const bytes = new Uint8Array(await ok.arrayBuffer()); + expect(bytes[0]).toBe(0x89); + expect(bytes[1]).toBe(0x50); + + const traversal = await fetch(`http://127.0.0.1:${server.port}/v1/opencodex/artifacts/../package.json`, { + headers: { authorization: "Bearer proxy-admission-secret" }, + }); + expect(traversal.status).toBe(404); + } finally { + await server.stop(true); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + } +}); From a9527673f30e0ad6c55feedc53994da141c800df Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:37:23 +0200 Subject: [PATCH 10/12] fix(google): sync Antigravity reconcile expectation for image model Include gemini-3.1-flash-image in the OAuth preset migration assertion so Cross-platform CI matches the Antigravity picker seed. --- tests/oauth-provider-reconcile.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index c9bc5272c6..2467552684 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -51,6 +51,7 @@ describe("OAuth provider reconciliation", () => { expect(provider.models).toEqual([ "gemini-3.6-flash", "gemini-3.1-pro", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", From e75c8b535391e333d5f668815ae06be0706529d5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:41:05 +0200 Subject: [PATCH 11/12] fix(images): return clear CCA project-discovery error When Antigravity is logged in but has no Cloud Code Assist project id, surface a project-discovery 400 instead of falling through to the generic provider-not-configured message. --- src/server/images.ts | 6 +++++- tests/server-images.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/server/images.ts b/src/server/images.ts index 1eb328f78f..533098bb1e 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -144,7 +144,11 @@ async function tryCcaImageGeneration( const project = getOAuthCredentialProjectId("google-antigravity"); if (!project) { linkedSignal.cleanup(); - return undefined; + return formatErrorResponse( + 400, + "invalid_request_error", + "Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`).", + ); } logCtx.provider = "google-antigravity"; diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 8d5baf74ad..437c76c25f 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1723,6 +1723,32 @@ test("CCA-only request with proxy admission bearer succeeds and never sends it u } }); +test("CCA logged-in without projectId returns project-discovery error, not provider-missing 400", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + saveConfig(ccaConfig()); + const { projectId: _omit, ...noProject } = CCA_CREDENTIAL; + await saveCredential("google-antigravity", { ...noProject }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toMatch(/Cloud Code Assist project/i); + expect(json.error.message).not.toMatch(/none is configured/i); + expect(registryHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + test("CCA rejects n>1 before contacting Google", async () => { const registryHits: CcaFetchRequest[] = []; const otherHits: CcaFetchRequest[] = []; From 379cb3c9da8e9d61e46c207f0469cf3d57501d2c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:55:16 +0200 Subject: [PATCH 12/12] fix(google): address tip Codex P2s for #355 Cancel oversized non-stream bodies on the Content-Length path, cap SSE accumulation before a newline, stream artifacts via Bun.file, and sync the adapters reference docs with the explicit allowlist and opaque HTTP route. --- .../src/content/docs/reference/adapters.md | 17 +++++++++-------- src/adapters/google.ts | 8 ++++++++ src/server/index.ts | 18 +++++++++++++----- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 3aa26beef0..e2ab3ebee7 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -79,14 +79,15 @@ streams the response back **untranslated**. `functionDeclarations`. Data-URL images → `inline_data`. - Tool-call ids are synthesized when Gemini omits them. Antigravity preserves and replays real `thoughtSignature` values so reasoning continuity survives later turns. -- **Inline image output:** when the model is image-capable (`gemini-3.1-flash-image`, - `gemini-2.0-flash-preview-image-generation`, or any model id matching both `gemini` and `image`), - the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. Returned `inlineData` parts are - materialized to the `artifacts/` subdirectory of the configured OpenCodex config directory and - surfaced to the client as a markdown image link referencing a `file:` URI - (`![image](file:///...)`). Each image is capped at 50 MB and each response at 100 MB of decoded - data; malformed base64 payloads are rejected. Artifacts are pruned automatically when the count - exceeds 200 files. +- **Inline image output:** when the model is one of the explicit image-capable chat IDs + (`gemini-3.1-flash-image`, `gemini-2.0-flash-preview-image-generation`, or + `gemini-3-pro-image-preview`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. + Standalone media-generation IDs such as `gemini-3-pro-image` are not included. Returned + `inlineData` parts are materialized under the configured OpenCodex `artifacts/` directory and + surfaced as markdown image links to the authenticated opaque route + `/v1/opencodex/artifacts/` (not `file:` URIs or host filesystem paths). Each image is capped + at 50 MB and each response at 100 MB of decoded data; malformed base64 payloads are rejected. + Artifacts are pruned automatically when the count exceeds 200 files. ## `kiro` diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 233204e653..abeb346206 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -530,6 +530,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); + // Cap incomplete frames before waiting for a newline — otherwise a single + // unterminated data: payload can grow without bound. + if (buffer.length > MAX_SSE_FRAME_BYTES) { + yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` }; + try { await reader.cancel(); } catch { /* ignore */ } + return; + } const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; @@ -591,6 +598,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // or lying Content-Length cannot force a full in-memory buffer + parse. const contentLength = Number(response.headers.get("content-length")); if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { + try { await response.body?.cancel(); } catch { /* ignore */ } return [{ type: "error", message: `google response too large (content-length ${contentLength} exceeds ${MAX_RESPONSE_BYTES} bytes)` }]; } let rawText: string; diff --git a/src/server/index.ts b/src/server/index.ts index 2ab8fdc66b..2a2d36c7fc 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -505,15 +505,23 @@ export function startServer(port?: number) { return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config); } const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length)); - const { readArtifactBytes } = await import("../images/artifacts"); - const artifact = readArtifactBytes(id); - if (!artifact) { + const { resolveArtifactPath } = await import("../images/artifacts"); + const artifactPath = resolveArtifactPath(id); + if (!artifactPath) { return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config); } - return withCors(new Response(new Uint8Array(artifact.bytes), { + const file = Bun.file(artifactPath); + const ext = artifactPath.split(".").pop()?.toLowerCase(); + const contentType = + ext === "png" ? "image/png" + : ext === "jpg" || ext === "jpeg" ? "image/jpeg" + : ext === "webp" ? "image/webp" + : ext === "gif" ? "image/gif" + : "application/octet-stream"; + return withCors(new Response(file, { status: 200, headers: { - "content-type": artifact.contentType, + "content-type": contentType, "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff", },