diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index cd4fe810af..fab358a746 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -46,9 +46,17 @@ 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. 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. - **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 +83,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/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 80c4c530c4..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,15 @@ 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 認証の解決に失敗した場合 + (例: ChatGPT 認証情報が期限切れまたは不在)も同様にフォールバックが発火し、OpenAI 候補が全くない + 場合のみではありません。`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..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,15 @@ 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 자격 + 증명이 만료되거나 누락된 경우)에도 동일하게 폴백이 트리거되며, OpenAI 후보가 아예 없을 때만 + 발생하는 것은 아닙니다. `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 1cce676797..e2ab3ebee7 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -79,6 +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 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/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index d6083929b5..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,16 @@ 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`. Этот же резерв + срабатывает и при сбое разрешения аутентификации OpenAI (например, истёкшая или отсутствующая + учётная запись ChatGPT), а не только при отсутствии кандидата OpenAI. Требуется + `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..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,14 @@ 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 凭证过期或缺失)时, + 该回退同样会触发,而不仅仅在没有任何 OpenAI 候选时。需要 `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-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..abeb346206 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, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts"; import type { AdapterEvent, OcxAssistantMessage, @@ -233,6 +234,38 @@ 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. 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 { + return IMAGE_CAPABLE_MODELS.has(modelId); +} + +/** + * 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 artifactMarkdownUrl(filePath: string): string { + return artifactHttpUrl(filePath).replace(/([()])/g, "\\$1"); +} + 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 +305,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"; @@ -377,6 +413,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(); @@ -387,9 +427,13 @@ 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"; + 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; @@ -452,6 +496,21 @@ 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") { + 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 = artifactMarkdownUrl(filePath); + 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)}`; toolCallsStarted++; @@ -464,12 +523,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } return emittedContentEvent ? "content" : "continue"; }; + const imageBudget = createImageBudget(); try { while (true) { 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() ?? ""; @@ -526,7 +593,51 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }, async parseResponse(response: Response): Promise { - const raw = await response.json() as Record; + // 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) { + 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; + 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" }]; @@ -547,6 +658,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 +666,20 @@ 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") { + 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 = artifactMarkdownUrl(filePath); + 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)}`; toolCallsStarted++; 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..96a4c0f26e 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -141,9 +141,24 @@ export function isMediaGenerationModelId(id: string): boolean { return MEDIA_GEN_ID_RE.test(id); } +/** + * 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_IMAGE_CHAT_MODEL_IDS.has(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/images/artifacts.ts b/src/images/artifacts.ts new file mode 100644 index 0000000000..3d8d952278 --- /dev/null +++ b/src/images/artifacts.ts @@ -0,0 +1,286 @@ +import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, join, resolve, sep } 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 + +/** + * 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; + +/** 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}$/; + +export interface ImageBudget { + spent: number; +} + +export function createImageBudget(): ImageBudget { + return { spent: 0 }; +} + +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 + * 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 [ + 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(""); +} + +/** + * 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\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"; + throw new Error("unrecognized image format — magic bytes do not match PNG, JPEG, WebP, or GIF"); +} + +export async function materializeInlineImage( + base64Data: string, + budget?: ImageBudget, + keepCount?: number, +): Promise { + const dir = getArtifactsDir(); + await mkdir(dir, { recursive: true, mode: 0o700 }); + + 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`); + } + if (budget) budget.spent += buf.length; + + // Sniff actual format from decoded bytes rather than trusting the declared mimeType. + const ext = guessExtFromMagic(buf); + 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, keepCount); + } + + // 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 = await writeArtifactUnique(dir, "dl-", bytes, ext); + pruneOldArtifacts(dir, keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT); + 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})`); + } +} 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..533098bb1e 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -25,10 +25,16 @@ 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"; +import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; +import { decodeValidatedImageBase64, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts"; export type ImagesEndpoint = "generations" | "edits"; @@ -42,6 +48,276 @@ const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000; */ 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", + "RECITATION", +]); + +/** + * 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, + 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.trim()) { + 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; + const linkedSignal = signalWithTimeout(timeoutMs, signal); + let token: string; + try { + // 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") { + 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"); + if (!project) { + linkedSignal.cleanup(); + 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"; + 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)}`, + }, + }; + let upstream: Response; + try { + 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}`); + } + + // 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) { + 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); + } + } 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. The rejection + // often surfaces as AbortError (not TimeoutError), so distinguish by signal + // 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}`); + } + + 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); + } + 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 }[] }; 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; + 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"); + } + // 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(); + } +} + export async function handleImages( req: Request, config: OcxConfig, @@ -53,11 +329,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; @@ -69,15 +352,19 @@ 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 // 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`.", ); } @@ -86,7 +373,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); @@ -116,8 +403,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; @@ -127,6 +418,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/src/server/index.ts b/src/server/index.ts index 05c9b2b4fa..2a2d36c7fc 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -498,6 +498,36 @@ 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 { resolveArtifactPath } = await import("../images/artifacts"); + const artifactPath = resolveArtifactPath(id); + if (!artifactPath) { + return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config); + } + 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": 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 228a660389..179e8a1bc1 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,33 @@ 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("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", + ]) { + 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-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/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/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts new file mode 100644 index 0000000000..22be6d3219 --- /dev/null +++ b/tests/images/gemini-inline.test.ts @@ -0,0 +1,388 @@ +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 { 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"); + }); + + 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)", () => { + 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 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 } }, + ]); + 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); + }); +}); + +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 yields an error event, not a rejection", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "" } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + })); + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); + }); +}); + +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(); + }); + + 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 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 } }, + ]); + + 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.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" }], + 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.startsWith("/v1/opencodex/artifacts/")).toBe(true); + expect(mdPath).not.toContain("file:"); + expect(mdPath).not.toContain(tempHome); + const id = mdPath.slice("/v1/opencodex/artifacts/".length); + expect(existsSync(join(artifactsDir, id))).toBe(true); + }); +}); + +describe("artifact markdown never leaks OPENCODEX_HOME paths", () => { + let underHome: string; + let savedHome: string | undefined; + + beforeAll(() => { + savedHome = process.env.OPENCODEX_HOME; + 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: 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 } }, + ]); + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const md = textEvents[0].text; + expect(md).toContain("/v1/opencodex/artifacts/"); + expect(md).not.toContain("file:"); + expect(md).not.toContain("~/"); + expect(md).not.toContain(underHome); + expect(md).not.toContain(homedir()); + }); + + 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" }], + 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).toContain("/v1/opencodex/artifacts/"); + expect(md).not.toContain("file:"); + expect(md).not.toContain(underHome); + }); +}); + +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/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", 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..437c76c25f 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -12,7 +12,9 @@ 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 { 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"; @@ -821,16 +823,1049 @@ 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); } }); + +// ── Google Antigravity (CCA) image generation fallback ── + +/** + * 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, + "google-antigravity": { + adapter: "google", + baseUrl: "https://attacker.example.com", + googleMode: "cloud-code-assist", + } as OcxConfig["providers"][string], + }, + } as OcxConfig; +} + +interface CcaFetchRequest { + url: string; + headers: Headers; + 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 + * `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: CCA_TINY_PNG } }] }, + }], + }, + }; + 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; +} + +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 { + 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(CCA_TINY_PNG); + + // 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(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); + } finally { + await server.stop(true); + } +}); + +test("CCA image fallback preserves upstream 429 status", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { status: 429, payload: { error: { message: "Rate limited" } } }); + + 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(429); + // The registry host was hit, not the attacker host. + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA fallback does not serve image edits", async () => { + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + 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); + } +}); + +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); + } +}); + +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(CCA_TINY_PNG); + + // 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 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 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 { + 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(401); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("login required"); + } 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. 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); + 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 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(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; + + 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 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[] = []; + 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); + } +}); + +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: CCA_TINY_PNG } }, + { 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(CCA_TINY_PNG); + 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[] = []; + // 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); + } +}); + +// ── 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: CCA_TINY_PNG } }] }, + 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(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 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[] = []; + 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; + } +});