diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index da262028..168b342d 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.0.4", + "version": "1.0.5", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index d647561b..f58fcbba 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.0.5 + +- Add managed image generation: `/codex:imagegen` plus an `imagegen` companion + subcommand, run over the same single serialized app-server connection the + plugin already uses for code tasks. Flags: `--out`, `--image ` + (edit from reference images), `--background`, `--force`, `--model`. The path + captures the `imageGeneration` item the moment it arrives, writes the bytes to + `--out`, then interrupts the turn so the model's post-image shell tail never + runs. Reusing the one serialized connection also avoids the 403/429 contention + that bare parallel Codex CLI calls caused. Fixes #356. + ## 1.0.0 - Initial version of the Codex plugin for Claude Code diff --git a/plugins/codex/commands/imagegen.md b/plugins/codex/commands/imagegen.md new file mode 100644 index 00000000..e601d97f --- /dev/null +++ b/plugins/codex/commands/imagegen.md @@ -0,0 +1,30 @@ +--- +description: Generate an image with Codex (managed and serialized) and save it to a file +argument-hint: '[--out ] [--force] [--image ] [--background] [--model ] [what the image should be]' +allowed-tools: Bash(node:*) +--- + +Generate an image through the Codex companion's managed, serialized image path and return its output verbatim. + +Raw user request: +$ARGUMENTS + +Run one `Bash` call: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" imagegen "$ARGUMENTS" +``` + +How it works: + +- `imagegen` runs a single Codex app-server turn, captures the generated image the moment it is ready, writes it to `--out` (or reports the Codex copy under `~/.codex/generated_images/`), and interrupts the turn so the model's post-image steps do not run. +- It is serialized like every other companion job, so it never runs Codex concurrently. That is what avoids the 403/429 websocket contention and wasted quota that raw parallel `codex exec` causes. +- Pass a reference image for editing with `--image ref.png`. Comma-separate several: `--image a.png,b.png`. + +Operating rules: + +- Default to foreground. With `--background`, the job is queued: report the job id and tell the user they can watch it with `/codex:status ` and fetch it with `/codex:result `. +- `--out` refuses to overwrite an existing file. If the user wants to replace it, add `--force`. +- Return the companion stdout to the user. Do not paraphrase the `Saved image:` path. +- If the companion reports that Codex is missing or unauthenticated, tell the user to run `/codex:setup`. +- If the user gave no prompt, ask what the image should be. diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 19e33b81..1f4788da 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -1,6 +1,18 @@ { - "description": "Optional stop-time review gate for Codex Companion.", + "description": "Stop-time review gate + managed image-gen routing for Codex Companion.", "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/imagegen-route-hook.mjs\"", + "timeout": 5 + } + ] + } + ], "SessionStart": [ { "hooks": [ diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd5..e936388e 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -17,6 +17,7 @@ import { interruptAppServerTurn, parseStructuredOutput, readOutputSchema, + runAppServerImageGen, runAppServerReview, runAppServerTurn } from "./lib/codex.mjs"; @@ -59,7 +60,8 @@ import { renderJobStatusReport, renderSetupReport, renderStatusReport, - renderTaskResult + renderTaskResult, + renderImageGenResult } from "./lib/render.mjs"; const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); @@ -69,6 +71,7 @@ const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; +const IMAGE_JOB_TITLE = "Codex Image"; function printUsage() { console.log( @@ -78,6 +81,7 @@ function printUsage() { " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs imagegen [--background] [--out ] [--force] [--image ] [--model ] [prompt]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", " node scripts/codex-companion.mjs cancel [job-id] [--json]" @@ -558,6 +562,9 @@ function getJobKindLabel(kind, jobClass) { if (kind === "adversarial-review") { return "adversarial-review"; } + if (jobClass === "image") { + return "image"; + } return jobClass === "review" ? "review" : "rescue"; } @@ -822,21 +829,110 @@ async function handleTaskWorker(argv) { logFile: storedJob.logFile ?? null } ); + const runner = + storedJob.jobClass === "image" + ? () => executeImageGenRun({ ...request, onProgress: progress }) + : () => executeTaskRun({ ...request, onProgress: progress }); await runTrackedJob( { ...storedJob, workspaceRoot, logFile }, - () => - executeTaskRun({ - ...request, - onProgress: progress - }), + runner, { logFile } ); } +async function executeImageGenRun(request) { + ensureCodexAvailable(request.cwd); + + const result = await runAppServerImageGen(request.cwd, { + prompt: request.prompt, + images: request.images, + model: request.model, + outPath: request.outPath, + overwrite: request.overwrite, + onProgress: request.onProgress + }); + + const rendered = renderImageGenResult(result, { title: IMAGE_JOB_TITLE }); + const payload = { + status: 0, + threadId: result.threadId, + outPath: result.outPath, + savedPath: result.savedPath, + revisedPrompt: result.revisedPrompt + }; + + return { + exitStatus: 0, + threadId: result.threadId, + turnId: result.turnId, + payload, + rendered, + summary: `Image saved to ${result.outPath ?? result.savedPath ?? "(unknown path)"}.`, + jobTitle: IMAGE_JOB_TITLE, + jobClass: "image", + write: true + }; +} + +function buildImageGenJob(workspaceRoot, summary) { + return createCompanionJob({ + prefix: "image", + kind: "image", + title: IMAGE_JOB_TITLE, + workspaceRoot, + jobClass: "image", + summary, + write: true + }); +} + +function buildImageGenRequest({ cwd, model, prompt, images, outPath, overwrite, jobId }) { + return { cwd, model, prompt, images, outPath, overwrite, jobId }; +} + +async function handleImageGen(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["model", "cwd", "out", "image", "prompt-file"], + booleanOptions: ["json", "background", "force"], + aliasMap: { m: "model", o: "out" } + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const model = normalizeRequestedModel(options.model); + const prompt = readTaskPrompt(cwd, options, positionals).trim(); + if (!prompt) { + throw new Error("Provide an image prompt (positional text, --prompt-file, or piped stdin)."); + } + const outPath = options.out ? path.resolve(cwd, options.out) : null; + const images = options.image + ? options.image.split(",").map((value) => value.trim()).filter(Boolean) + : []; + const overwrite = Boolean(options.force); + const summary = `Image: ${shorten(prompt, 80)}`; + + if (options.background) { + ensureCodexAvailable(cwd); + const job = buildImageGenJob(workspaceRoot, summary); + const request = buildImageGenRequest({ cwd, model, prompt, images, outPath, overwrite, jobId: job.id }); + const { payload } = enqueueBackgroundTask(cwd, job, request); + outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); + return; + } + + const job = buildImageGenJob(workspaceRoot, summary); + await runForegroundCommand( + job, + (progress) => + executeImageGenRun({ cwd, model, prompt, images, outPath, overwrite, jobId: job.id, onProgress: progress }), + { json: options.json } + ); +} + async function handleStatus(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"], @@ -1000,6 +1096,9 @@ async function main() { case "task": await handleTask(argv); break; + case "imagegen": + await handleImageGen(argv); + break; case "task-worker": await handleTaskWorker(argv); break; diff --git a/plugins/codex/scripts/imagegen-route-hook.mjs b/plugins/codex/scripts/imagegen-route-hook.mjs new file mode 100644 index 00000000..dea347ba --- /dev/null +++ b/plugins/codex/scripts/imagegen-route-hook.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// PreToolUse(Bash) — route ALL image generation through the managed /codex:imagegen path. +// +// Image generation must go through codex-companion's serialized app-server path. +// Every other route — garden gpt-image-2 generate.js / edit.js, baoyu-image-gen, +// image2_asset.py, a bare `codex exec ... imagegen`, or a direct POST to /v1/images +// — bypasses the single serialized connection, which is the websocket contention +// (403/429) and quota burn the managed path exists to prevent. +// +// This hook prefers REWRITE: when it can map a foreign command's prompt / output / +// reference flags onto the companion's `imagegen` subcommand, it rewrites the command +// in place (hookSpecificOutput.updatedInput) so it transparently runs through the +// managed path — no extra agent round-trip. When the mapping is not safe (prompt only +// in a file, free-text `codex exec`, raw curl), it falls back to BLOCK + redirect and +// lets the agent re-issue. It never rewrites into a guess. +// +// Coding `codex exec` (rescue / executor) and the companion's own imagegen are left +// untouched. + +import { readFileSync } from "node:fs"; + +let raw = ""; +try { + raw = readFileSync(0, "utf8"); +} catch { + process.exit(0); +} + +let cmd = ""; +try { + cmd = (JSON.parse(raw).tool_input?.command ?? "").toString(); +} catch { + process.exit(0); +} +if (!cmd.trim()) process.exit(0); + +// Already the managed path — let it run. +if (/codex-companion\.mjs[\s\S]*\bimagegen\b/.test(cmd)) process.exit(0); + +const COMPANION = `${process.env.CLAUDE_PLUGIN_ROOT ?? ""}/scripts/codex-companion.mjs`; + +// Foreign image-gen tools and how their flags map onto the companion. +// out: flag whose value is the OUTPUT path -> companion --out +// ref: flag whose value is a REFERENCE image -> companion --image +// promptFileOnly: flags that supply the prompt via a file (can't inline) -> block +const TOOLS = [ + { re: /gpt-image-2[/\\][^\s]*\bgenerate\.js/i, prompt: ["--prompt"], out: ["--image"], ref: [], promptFileOnly: ["--promptfile"] }, + { re: /gpt-image-2[/\\][^\s]*\bedit\.js/i, prompt: ["--prompt"], out: [], ref: ["--image"], promptFileOnly: ["--promptfile"] }, + { re: /baoyu-image-gen[/\\][^\s]*main\.ts/i, prompt: ["--prompt"], out: ["--image"], ref: ["--ref", "--reference"], promptFileOnly: ["--promptfiles"] }, + { re: /image2_asset\.py/i, prompt: ["--prompt"], out: ["--output"], ref: ["--image"], promptFileOnly: [] }, +]; + +const tool = TOOLS.find((t) => t.re.test(cmd)); +if (tool) { + const prompt = flag(cmd, tool.prompt); + if (!prompt) block(promptOnlyInFile(cmd, tool) ? "a file-only prompt that cannot be inlined" : "an image-gen command with no inlinable --prompt"); + const out = flag(cmd, tool.out); + const ref = flag(cmd, tool.ref); + const passBg = /(^|\s)--background(\s|$)/.test(cmd) ? " --background" : ""; + const parts = [`node ${shq(COMPANION)} imagegen`]; + if (out) parts.push(`--out ${shq(out)}`); + if (ref) parts.push(`--image ${shq(ref)}`); + const rewritten = parts.join(" ") + passBg + ` ${shq(prompt)}`; + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: "Rerouted through the managed /codex:imagegen path.", + updatedInput: { command: rewritten }, + }, + })); + process.exit(0); +} + +// Routes we cannot safely rewrite -> block + redirect. +const stripped = cmd.replace(/'[^']*'/g, "").replace(/"[^"]*"/g, ""); +// Use STRONG image-gen intent phrases only. Bare "imagegen" / "gpt-image" collide +// with file and test names (e.g. `codex exec "fix the imagegen-route-hook test"`), +// so they must NOT trip the block — only an explicit generate-an-image instruction does. +if (/codex\s+exec/.test(stripped) && + /\bimage generation\b|\bgenerate (?:the |an |a )?image\b|built[- ]in image|generated_images/i.test(cmd)) { + block("a bare `codex exec` image generation"); +} +if (/\/v1\/images\/(generations|edits)/i.test(cmd)) { + block("a direct POST to /v1/images (OpenAI-compatible image API)"); +} + +process.exit(0); + +// ── helpers ────────────────────────────────────────────────────────────────── +function flag(command, names) { + for (const n of names) { + const re = new RegExp(`${n.replace(/[-]/g, "\\$&")}(?:\\s+|=)(?:"([^"]*)"|'([^']*)'|(\\S+))`); + const m = command.match(re); + if (m) return m[1] ?? m[2] ?? m[3]; + } + return null; +} + +function promptOnlyInFile(command, tool) { + return tool.promptFileOnly.some((f) => flag(command, [f]) !== null); +} + +function shq(s) { + return `'${String(s).replace(/'/g, `'\\''`)}'`; +} + +function block(why) { + process.stderr.write( +`BLOCKED — image generation must go through the managed /codex:imagegen path. + +Detected: ${why}. +This route can't be auto-rewritten safely, so it's blocked rather than guessed. + +Do instead: + - Generate via the managed path: + /codex:imagegen --out + (edit with --image ref.png; queue with --background) + - To recover an image a prior run already produced: + ls -t ~/.codex/generated_images/*/*.png | head +`); + process.exit(2); +} diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index f2fe88bd..15d1ffb0 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -34,6 +34,8 @@ * onProgress: ProgressReporter | null * }} TurnCaptureState */ +import fs from "node:fs"; +import path from "node:path"; import { readJsonFile } from "./fs.mjs"; import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs"; import { loadBrokerSession } from "./broker-lifecycle.mjs"; @@ -1085,4 +1087,244 @@ export function readOutputSchema(schemaPath) { return readJsonFile(schemaPath); } +// ── Managed image generation ───────────────────────────────────────────────── +// codex app-server can generate images: the active provider advertises +// `imageGeneration` and the model emits an `imageGeneration` thread item that +// carries the base64 PNG (`result`) and a `savedPath`. The catch is that after +// producing the image the model often keeps going, running shell commands to +// verify or convert the file, which makes a plain task turn look like it hangs. +// The managed path below captures the image item the moment it arrives, writes +// the bytes to the requested path, then interrupts the turn so the wasteful tail +// never runs. The single app-server connection is serialized by the broker, so +// image jobs never run concurrently (the failure mode that triggers 403/429). + +const DEFAULT_IMAGE_TIMEOUT_MS = 180000; + +function resolveImageTimeoutMs(optionValue) { + if (typeof optionValue === "number" && Number.isFinite(optionValue) && optionValue > 0) { + return optionValue; + } + const raw = process.env.CODEX_PLUGIN_IMAGE_TIMEOUT_MS; + if (raw != null && raw !== "") { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return DEFAULT_IMAGE_TIMEOUT_MS; +} + +function composeImagePrompt(prompt) { + return ( + "Use your built-in image generation to create the image described below, " + + "then stop. Do not run shell commands, write code, or open files.\n\n" + + prompt + ); +} + +/** @returns {UserInput[]} */ +function buildImageTurnInput(prompt, images = [], cwd = process.cwd()) { + /** @type {UserInput[]} */ + const input = [{ type: "text", text: composeImagePrompt(prompt), text_elements: [] }]; + for (const ref of Array.isArray(images) ? images : []) { + if (typeof ref === "string" && ref.trim()) { + input.push({ type: "localImage", path: path.resolve(cwd, ref.trim()) }); + } + } + return input; +} + +// Write the generated image to `outPath`, preferring the inline base64 bytes and +// falling back to the app-server's own `savedPath`. Returns null until the item +// actually carries image data (the `item/started` event has an empty result), so +// the caller only settles once a real image exists. +function saveImageFromItem(item, outPath) { + const savedPath = typeof item?.savedPath === "string" && item.savedPath ? item.savedPath : null; + const base64 = typeof item?.result === "string" && item.result.length > 0 ? item.result : null; + if (!base64 && !(savedPath && fs.existsSync(savedPath))) { + return null; + } + const revisedPrompt = typeof item?.revisedPrompt === "string" ? item.revisedPrompt : null; + let written = null; + if (outPath) { + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + if (base64) { + fs.writeFileSync(outPath, Buffer.from(base64, "base64")); + written = outPath; + } else if (savedPath) { + fs.copyFileSync(savedPath, outPath); + written = outPath; + } + } + return { outPath: written ?? savedPath, savedPath, revisedPrompt }; +} + +async function captureImageTurn(client, threadId, input, options = {}) { + const previousHandler = client.notificationHandler; + const timeoutMs = resolveImageTimeoutMs(options.timeoutMs); + let turnId = null; + let settled = false; + let resolveDone; + let rejectDone; + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + const seconds = Math.round(timeoutMs / 1000); + rejectDone( + new Error( + `Codex produced no image within ${seconds}s. Set CODEX_PLUGIN_IMAGE_TIMEOUT_MS to adjust the window.` + ) + ); + }, timeoutMs); + timer.unref?.(); + + const settleOk = (result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolveDone(result); + }; + const settleErr = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + rejectDone(error); + }; + + // Stop the turn so the model's post-image shell tail never runs. Fire and + // forget: the image is already captured and written, and withAppServer closes + // the connection on return, so settlement must never block on this RPC. + const stopTurn = () => { + if (turnId) { + client.request("turn/interrupt", { threadId, turnId }).catch(() => {}); + } + }; + + client.setNotificationHandler((message) => { + try { + if (settled) { + return; + } + // The broker connection is shared and serialized; only act on our own + // thread so a sibling or subagent turn is never captured or interrupted. + const eventThreadId = message?.params?.threadId ?? message?.params?.thread?.id ?? null; + if (eventThreadId && eventThreadId !== threadId) { + return; + } + // Learn the turn id from notifications (turn/started carries it under + // params.turn.id, item events under params.turnId), so the interrupt can + // fire even if the image item arrives before the turn/start response. + if (!turnId) { + turnId = extractTurnId(message); + } + const method = message?.method; + const item = message?.params?.item; + if ((method === "item/completed" || method === "item/started") && item?.type === "imageGeneration") { + const saved = saveImageFromItem(item, options.outPath ?? null); + if (saved) { + emitProgress(options.onProgress, "Image ready; stopping the turn.", "completing"); + settleOk({ ...saved, threadId, turnId }); + stopTurn(); + } + return; + } + if (method === "turn/completed") { + const status = message?.params?.turn?.status; + // Our own interrupt resolves the turn as "interrupted"; that is success. + if (status !== "interrupted") { + settleErr(new Error(`Codex finished the turn (${status ?? "unknown"}) without generating an image.`)); + } + return; + } + if (method === "error") { + const err = message?.params?.error; + settleErr(new Error((err && typeof err.message === "string" && err.message) || "Codex image turn failed.")); + } + } catch (handlerError) { + // A handler failure — e.g. saveImageFromItem cannot write --out (parent is a + // file, permission denied, target is a directory) — must settle the promise + // with the real error, not hang until the 180s timeout while falsely + // reporting "produced no image". settleErr is a no-op once already settled. + settleErr(handlerError instanceof Error ? handlerError : new Error(String(handlerError))); + } + }); + + try { + const response = await client.request("turn/start", { + threadId, + input, + model: options.model ?? null, + effort: options.effort ?? null, + outputSchema: null + }); + if (!turnId) { + turnId = response.turn?.id ?? null; + } + // Surface the turn id so a tracked job persists it for graceful `/codex:cancel`. + if (turnId) { + emitProgress(options.onProgress, `Image turn started (${turnId}).`, "starting", { threadId, turnId }); + } + if (response.turn?.status && response.turn.status !== "inProgress" && !settled) { + settleErr(new Error("Codex finished the turn without generating an image.")); + } + return await done; + } finally { + clearTimeout(timer); + client.setNotificationHandler(previousHandler ?? null); + } +} + +export async function runAppServerImageGen(cwd, options = {}) { + const prompt = options.prompt?.trim(); + if (!prompt) { + throw new Error("An image prompt is required for image generation."); + } + const outPath = options.outPath ? path.resolve(cwd, options.outPath) : null; + if (outPath && !options.overwrite && fs.existsSync(outPath)) { + throw new Error(`Refusing to overwrite ${outPath}. Pass --force to replace it.`); + } + + return withAppServer(cwd, async (client) => { + emitProgress(options.onProgress, "Starting Codex image thread.", "starting"); + // read-only: the managed path decodes the base64 and writes the file itself, + // and the prompt forbids shell/file actions, so the model needs no write + // access to the workspace. This matches the task and review paths. + const startResponse = await startThread(client, cwd, { + model: options.model, + sandbox: "read-only", + ephemeral: true + }); + const threadId = startResponse.thread.id; + emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", { threadId }); + + const input = buildImageTurnInput(prompt, options.images, cwd); + const result = await captureImageTurn(client, threadId, input, { + outPath, + model: options.model, + effort: options.effort, + timeoutMs: options.timeoutMs, + onProgress: options.onProgress + }); + + return { + threadId, + turnId: result.turnId, + outPath: result.outPath, + savedPath: result.savedPath, + revisedPrompt: result.revisedPrompt + }; + }); +} + export { DEFAULT_CONTINUE_PROMPT, TASK_THREAD_PREFIX }; diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec18523..38d6812a 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -322,6 +322,20 @@ export function renderTaskResult(parsedResult, meta) { return `${message}\n`; } +export function renderImageGenResult(result, meta = {}) { + const title = meta.title ?? "Codex Image"; + const outPath = result?.outPath ?? result?.savedPath ?? null; + const lines = [`# ${title}`, ""]; + lines.push(outPath ? `Saved image: ${outPath}` : "No image was saved."); + if (result?.savedPath && result.savedPath !== outPath) { + lines.push(`Codex copy: ${result.savedPath}`); + } + if (result?.revisedPrompt) { + lines.push("", "Revised prompt:", result.revisedPrompt); + } + return `${lines.join("\n")}\n`; +} + export function renderStatusReport(report) { const lines = [ "# Codex Status", diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 3724ffa4..ac8ed214 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -75,6 +75,7 @@ test("continue is not exposed as a user-facing command", () => { assert.deepEqual(commandFiles, [ "adversarial-review.md", "cancel.md", + "imagegen.md", "rescue.md", "result.md", "review.md", diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index debcadce..16643660 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -380,7 +380,8 @@ rl.on("line", (line) => { turnId, model: message.params.model ?? null, effort: message.params.effort ?? null, - prompt + prompt, + input: message.params.input ?? null }; saveState(state); send({ id: message.id, result: { turn: buildTurn(turnId) } }); @@ -533,6 +534,29 @@ rl.on("line", (line) => { interruptibleTurns.set(turnId, { threadId: thread.id, timer }); } else if (BEHAVIOR === "slow-task") { emitTurnCompletedLater(thread.id, turnId, items, 400); + } else if (BEHAVIOR === "imagegen" || BEHAVIOR === "imagegen-saved-only") { + // result carries a real PNG (RESULT_B64); savedPath holds DISTINCT + // sentinel bytes, so a test can prove the written bytes came from the + // item's base64 result and not from copying savedPath. + // imagegen-saved-only sends an empty result to exercise the copy fallback. + const RESULT_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const savedPath = path.join(path.dirname(STATE_PATH), "fake-image-" + turnId + ".png"); + fs.writeFileSync(savedPath, "SAVED_FALLBACK_SENTINEL\\n"); + const resultField = BEHAVIOR === "imagegen-saved-only" ? "" : RESULT_B64; + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ method: "item/started", params: { threadId: thread.id, turnId, item: { type: "imageGeneration", id: "ig_" + turnId, status: "in_progress", revisedPrompt: null, result: "" } } }); + send({ method: "item/completed", params: { threadId: thread.id, turnId, item: { type: "imageGeneration", id: "ig_" + turnId, status: "completed", revisedPrompt: "A tiny test image.", result: resultField, savedPath } } }); + // Intentionally NO turn/completed: emulate the post-image tail loop so + // the client must interrupt the turn after capturing the image. + } else if (BEHAVIOR === "imagegen-no-image") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } }); + } else if (BEHAVIOR === "imagegen-error") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ method: "error", params: { error: { message: "image generation is not available for this account" } } }); + } else if (BEHAVIOR === "imagegen-silent") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + // no item and no completion: exercise the startup-timeout backstop. } else { emitTurnCompleted(thread.id, turnId, items); } diff --git a/tests/helpers.mjs b/tests/helpers.mjs index 945ae0e7..2c534dfc 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -18,6 +18,7 @@ export function run(command, args, options = {}) { env: options.env, encoding: "utf8", input: options.input, + timeout: options.timeout, shell: process.platform === "win32" && !path.isAbsolute(command), windowsHide: true }); diff --git a/tests/imagegen-route-hook.test.mjs b/tests/imagegen-route-hook.test.mjs new file mode 100644 index 00000000..fad68b39 --- /dev/null +++ b/tests/imagegen-route-hook.test.mjs @@ -0,0 +1,99 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const HOOK = fileURLToPath(new URL("../plugins/codex/scripts/imagegen-route-hook.mjs", import.meta.url)); + +function run(command, env = {}) { + const r = spawnSync(process.execPath, [HOOK], { + input: JSON.stringify({ tool_input: { command } }), + encoding: "utf8", + env: { ...process.env, CLAUDE_PLUGIN_ROOT: "/PLUGIN", ...env }, + }); + let json = null; + try { json = r.stdout.trim() ? JSON.parse(r.stdout) : null; } catch {} + return { status: r.status, json, rewritten: json?.hookSpecificOutput?.updatedInput?.command ?? null }; +} + +const BLOCKED = 2; +const ALLOWED = 0; + +test("allows the managed companion imagegen path untouched", () => { + const r = run('node "/x/scripts/codex-companion.mjs" imagegen --out a.png "a cat"'); + assert.equal(r.status, ALLOWED); + assert.equal(r.rewritten, null); +}); + +test("rewrites garden generate.js: --image is the output -> --out", () => { + const r = run('node ~/.claude/skills/gpt-image-2/scripts/generate.js --prompt "a red fox" --image out.png'); + assert.equal(r.status, ALLOWED); + assert.match(r.rewritten, /codex-companion\.mjs' imagegen/); + assert.match(r.rewritten, /--out 'out\.png'/); + assert.match(r.rewritten, /'a red fox'$/); +}); + +test("rewrites garden edit.js: --image is the reference -> --image", () => { + const r = run('node ~/.claude/skills/gpt-image-2/scripts/edit.js --image src.png --prompt "make it night"'); + assert.equal(r.status, ALLOWED); + assert.match(r.rewritten, /--image 'src\.png'/); + assert.doesNotMatch(r.rewritten, /--out/); +}); + +test("rewrites baoyu: --image is output, --ref is reference", () => { + const r = run('bun ~/.claude/skills/baoyu-image-gen/scripts/main.ts --prompt "a logo" --image final.png --ref brand.png'); + assert.equal(r.status, ALLOWED); + assert.match(r.rewritten, /--out 'final\.png'/); + assert.match(r.rewritten, /--image 'brand\.png'/); + assert.match(r.rewritten, /'a logo'$/); +}); + +test("rewrites image2_asset.py: --output -> --out", () => { + const r = run('python scripts/image2_asset.py generate --prompt "a hero banner" --output hero.png'); + assert.equal(r.status, ALLOWED); + assert.match(r.rewritten, /--out 'hero\.png'/); + assert.match(r.rewritten, /'a hero banner'$/); +}); + +test("uses CLAUDE_PLUGIN_ROOT for the companion path", () => { + const r = run('python scripts/image2_asset.py --prompt "x"'); + assert.match(r.rewritten, /node '\/PLUGIN\/scripts\/codex-companion\.mjs' imagegen/); +}); + +test("passes --background through", () => { + const r = run('bun .../baoyu-image-gen/scripts/main.ts --prompt "x" --image o.png --background'); + assert.match(r.rewritten, /--background/); +}); + +test("blocks (no rewrite) when the prompt is only in a file", () => { + const r = run('node ~/.claude/skills/gpt-image-2/scripts/generate.js --promptfile p.md --image o.png'); + assert.equal(r.status, BLOCKED); + assert.equal(r.rewritten, null); +}); + +test("blocks a bare codex exec image generation (explicit intent, cannot map)", () => { + const r = run('codex exec --sandbox workspace-write "use your built-in image generation to draw a logo, save to x.png"'); + assert.equal(r.status, BLOCKED); +}); + +test("blocks a direct POST to /v1/images", () => { + const r = run("curl https://api.openai.com/v1/images/generations -d @body.json"); + assert.equal(r.status, BLOCKED); +}); + +test("allows a coding codex exec (no image markers)", () => { + assert.equal(run('codex exec --sandbox workspace-write "fix the failing test in foo.ts"').status, ALLOWED); +}); + +test("allows a coding codex exec whose prompt merely mentions imagegen", () => { + // Regression: a bare 'imagegen' / 'gpt-image' in a coding prompt must NOT block. + assert.equal(run('codex exec --sandbox workspace-write "fix the failing imagegen-route-hook test and the gpt-image fixture"').status, ALLOWED); +}); + +test("allows a mere quoted mention of imagegen", () => { + assert.equal(run('echo "the imagegen route hook reroutes gpt-image calls"').status, ALLOWED); +}); + +test("allows an unrelated command", () => { + assert.equal(run("ls -la /tmp").status, ALLOWED); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 90408372..30912542 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -211,6 +211,224 @@ test("task reports the actual Codex auth error when the run is rejected", () => assert.match(result.stderr, /authentication expired; run codex login/); }); +const IMAGEGEN_RESULT_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +function setupImagegenRepo(binDir, behavior) { + const repo = makeTempDir(); + installFakeCodex(binDir, behavior); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + return repo; +} + +test("imagegen writes the captured base64 image and interrupts the post-image tail", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const outPath = path.join(repo, "out", "fox.png"); + + const result = run("node", [SCRIPT, "imagegen", "--out", outPath, "a small fox charm"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Saved image:/); + // The bytes must come from the item's base64 result, not from copying savedPath + // (the fixture stores distinct sentinel bytes at savedPath). + const written = fs.readFileSync(outPath); + assert.ok( + written.equals(Buffer.from(IMAGEGEN_RESULT_B64, "base64")), + "image bytes must equal the decoded base64 result" + ); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.ok(fakeState.lastInterrupt, "the turn must be interrupted after the image arrived"); + assert.equal( + fakeState.lastInterrupt.turnId, + fakeState.lastTurnStart.turnId, + "the in-flight image turn must be the one interrupted" + ); +}); + +test("imagegen --json reports the saved image payload", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const outPath = path.join(repo, "p.png"); + + const result = run("node", [SCRIPT, "imagegen", "--out", outPath, "--json", "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.equal(payload.outPath, outPath); + assert.equal(typeof payload.savedPath, "string"); + assert.equal(payload.revisedPrompt, "A tiny test image."); +}); + +test("imagegen forwards --image references as localImage inputs", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const refA = path.join(repo, "a.png"); + const refB = path.join(repo, "b.png"); + fs.writeFileSync(refA, "x"); + fs.writeFileSync(refB, "y"); + + const result = run( + "node", + [SCRIPT, "imagegen", "--out", path.join(repo, "o.png"), "--image", `${refA},${refB}`, "edit it"], + { cwd: repo, env: buildEnv(binDir), timeout: 20000 } + ); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + const input = fakeState.lastTurnStart.input; + assert.equal(input[0].type, "text"); + const localImages = input.filter((item) => item.type === "localImage"); + assert.equal(localImages.length, 2); + assert.equal(localImages[0].path, refA); + assert.equal(localImages[1].path, refB); +}); + +test("imagegen fails fast when the turn completes without an image", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen-no-image"); + + const result = run("node", [SCRIPT, "imagegen", "--out", path.join(repo, "x.png"), "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /without generating an image/); +}); + +test("imagegen surfaces the actual Codex error message", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen-error"); + + const result = run("node", [SCRIPT, "imagegen", "--out", path.join(repo, "x.png"), "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /image generation is not available for this account/); +}); + +test("imagegen times out when Codex never produces an image", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen-silent"); + + const result = run("node", [SCRIPT, "imagegen", "--out", path.join(repo, "x.png"), "a fox"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_PLUGIN_IMAGE_TIMEOUT_MS: "800" }, + timeout: 20000 + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /produced no image within/); +}); + +test("imagegen settles with the write error instead of hanging until timeout", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + // Make --out unwritable: its parent is a file, so the write throws (ENOTDIR). + const blocker = path.join(repo, "blocker"); + fs.writeFileSync(blocker, "x"); + const outPath = path.join(blocker, "fox.png"); + + const result = run("node", [SCRIPT, "imagegen", "--out", outPath, "a fox"], { + cwd: repo, + // Short image timeout: before the fix the write error was swallowed and the + // command hung until this backstop fired ("produced no image within"). + env: { ...buildEnv(binDir), CODEX_PLUGIN_IMAGE_TIMEOUT_MS: "1500" }, + timeout: 20000 + }); + + assert.notEqual(result.status, 0); + // It must fail via the real filesystem error, NOT fall through to the timeout backstop. + assert.doesNotMatch(result.stderr, /produced no image within/); +}); + +test("imagegen copies savedPath when the item carries no base64", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen-saved-only"); + const outPath = path.join(repo, "saved.png"); + + const result = run("node", [SCRIPT, "imagegen", "--out", outPath, "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(fs.readFileSync(outPath, "utf8"), /SAVED_FALLBACK_SENTINEL/); +}); + +test("imagegen refuses to overwrite an existing --out unless --force is given", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const outPath = path.join(repo, "exists.png"); + fs.writeFileSync(outPath, "original"); + + const refused = run("node", [SCRIPT, "imagegen", "--out", outPath, "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /Refusing to overwrite/); + assert.equal(fs.readFileSync(outPath, "utf8"), "original", "the existing file must be untouched"); + + const forced = run("node", [SCRIPT, "imagegen", "--out", outPath, "--force", "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + assert.equal(forced.status, 0, forced.stderr); + assert.ok(fs.readFileSync(outPath).equals(Buffer.from(IMAGEGEN_RESULT_B64, "base64"))); +}); + +test("imagegen --background runs as a tracked job and saves the image", async () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const outPath = path.join(repo, "bg-fox.png"); + + const launched = run("node", [SCRIPT, "imagegen", "--background", "--out", outPath, "--json", "a small fox charm"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + assert.equal(launchPayload.status, "queued"); + assert.match(launchPayload.jobId, /^image-/); + + const waited = run( + "node", + [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], + { cwd: repo, env: buildEnv(binDir), timeout: 20000 } + ); + assert.equal(waited.status, 0, waited.stderr); + const waitedPayload = JSON.parse(waited.stdout); + assert.equal(waitedPayload.job.id, launchPayload.jobId); + assert.equal(waitedPayload.job.status, "completed"); + assert.ok( + fs.readFileSync(outPath).equals(Buffer.from(IMAGEGEN_RESULT_B64, "base64")), + "the background job must write the captured image bytes" + ); +}); + test("review accepts the quoted raw argument style for built-in base-branch review", () => { const repo = makeTempDir(); const binDir = makeTempDir();