fix(baoyu-image-gen): require real image_gen evidence in codex-imagegen

Stop Codex from satisfying a request by copying an unrelated pre-existing
image from generated_images instead of generating a new one. Verification
now requires a PNG in this thread's generated_images dir (or an image_gen
stream item, kept as a forward-compatible signal), and the spawned-agent
instruction forbids reading or reusing history images before image_gen is
called. Removes the findCpToTarget fallback that allowed the shortcut.

Validated against a real codex exec run: image_gen leaves no stream item,
so the filesystem check is the load-bearing signal; locked in via a
condensed real-stream regression fixture.

Closes #185
This commit is contained in:
Jim Liu 宝玉
2026-06-18 14:13:43 -05:00
parent 441ca307a6
commit 51b31aa050
6 changed files with 105 additions and 64 deletions
@@ -6,7 +6,7 @@ import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { GenError, type CliOptions, type GenerateResult } from "./types.ts";
import { runCodexExec } from "./spawn.ts";
import { findCpToTarget, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
import { hasImageGenEvidence, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
import { JsonLogger } from "./logger.ts";
@@ -125,7 +125,7 @@ function buildInstruction(prompt: string, opts: CliOptions): string {
const refHint = opts.refImages.length > 0
? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`
: "";
return `You have an internal tool called image_gen for image generation. Use it.
return `You have an internal tool called image_gen for image generation. You MUST call it before doing anything else.
TASK: Generate an image with the spec below, then save to disk.
@@ -137,12 +137,15 @@ OUTPUT PATH: ${opts.outputPath}
${refHint}
STEPS:
1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.
2. Move or copy the resulting image from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
2. Move or copy ONLY the image produced by that image_gen call from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
3. Verify with: ls -la ${opts.outputPath}
4. Reply with ONLY this JSON line (no markdown fences, no other text):
{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}
HARD CONSTRAINTS:
- Do NOT search for, find, inspect, reuse, or copy any pre-existing files from $CODEX_HOME/generated_images/ or any other directory.
- Do NOT run ls/find/rg/grep/glob over $CODEX_HOME/generated_images/ before image_gen has been called.
- You MUST call image_gen first. Only after image_gen completes may you copy the newly created file from this turn.
- Do NOT use curl, wget, Python, or any external API.
- Do NOT use bash to fabricate an image; only image_gen produces real pixels.
- Use ONLY the image_gen internal tool.`;
@@ -175,13 +178,16 @@ async function attemptGenerate(
throw new GenError("agent_refused", "No thread id in event stream");
}
// verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/)
// verify image_gen ran in THIS thread. A PNG in this thread's
// generated_images dir is the real signal (image_gen does not surface as a
// stream item); the stream check is a forward-compatible fallback. The #185
// shortcut (copying an unrelated history image) yields neither.
const ver = await verifyImageGenWasInvoked(run.threadId);
if (!ver.ok) {
// secondary verify: did tool_calls include cp/mv from generated_images to our target
if (!findCpToTarget(run.toolCalls, opts.outputPath)) {
throw new GenError("no_image_gen_tool_use", `image_gen was not invoked: ${ver.reason}`);
}
if (!hasImageGenEvidence(run.toolCalls, ver.ok)) {
throw new GenError(
"no_image_gen_tool_use",
`image_gen was not invoked (no image_gen event in stream; ${ver.reason})`,
);
}
// verify output
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
import path from "node:path";
import { GenError } from "./types.ts";
import type { ToolCall } from "./types.ts";
import { hasImageGenInvocation } from "./parser.ts";
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -23,15 +24,15 @@ export async function verifyImageGenWasInvoked(threadId: string | null): Promise
}
}
export function findCpToTarget(toolCalls: ToolCall[], target: string): boolean {
return toolCalls.some(
(tc) =>
tc.tool === "shell" &&
typeof tc.command === "string" &&
(tc.command.includes(target) || tc.command.includes(path.basename(target))) &&
/\b(cp|mv|cat)\b/.test(tc.command) &&
tc.command.includes("generated_images"),
);
// Real evidence that image_gen ran in THIS thread. Codex's image_gen tool does
// not surface as a stream item, so a successful run shows only reasoning/shell/
// agent_message — `dirHasImage` (a PNG in this thread's generated_images dir) is
// what proves it. The stream check is kept as a forward-compatible signal in
// case a future Codex version emits the item. The #185 shortcut (copying an
// unrelated history image, which lives under a different thread id) yields
// neither, so it is correctly rejected.
export function hasImageGenEvidence(toolCalls: ToolCall[], dirHasImage: boolean): boolean {
return dirHasImage || hasImageGenInvocation(toolCalls);
}
export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> {