From e98fa33bc2f7e224945c1055ff7d1904e97cca32 Mon Sep 17 00:00:00 2001 From: Yelban Date: Wed, 20 May 2026 00:50:24 +0800 Subject: [PATCH 1/3] feat: add codex-imagegen backend for non-Codex runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the preferred_image_backend: codex-imagegen config key already referenced in several SKILL.md files but with no corresponding backend. Bridges non-Codex runtimes (Claude Code, etc.) to Codex CLI's built-in image_gen tool via 'codex exec'. Uses the user's Codex subscription — no OPENAI_API_KEY required. Reliability: - Retry with exponential backoff (default 2 retries, configurable) - 9 classified error kinds (retryable vs non-retryable) - Node child_process timeout with SIGTERM→SIGKILL ladder Correctness: - Verifies image_gen was actually invoked (not bypassed) by checking $CODEX_HOME/generated_images/{thread_id}/ for a PNG - PNG magic byte validation - Output file size sanity check Observability: - Structured JSONL logging with --log-file - Verbose stderr mode with --verbose - Returns thread_id, tool_calls, token usage in result JSON - Raw codex event stream preserved per attempt for debugging Efficiency: - Idempotency cache via --cache-dir (sha256 of prompt+aspect+refs) - Cache hit returns in <0.3s vs 50-90s cold Features: - --ref reference images (repeatable, passed through to codex exec --image) - --retries, --retry-delay, --timeout all configurable - File lock serializes concurrent invocations Testing: - 16 Bun unit tests across parser, cache, validator - Path-filtered CI workflow at .github/workflows/codex-imagegen-tests.yml - Composes with existing test.yml without conflicts Architecture: - scripts/codex-imagegen.sh: thin bash entrypoint (bun → npx -y bun fallback) - scripts/codex-imagegen/: TypeScript module - main.ts (orchestrator), types.ts (CliOptions/GenerateResult/GenError), spawn.ts (child_process), parser.ts (JSONL events), validator.ts (image_gen check + PNG magic), cache.ts (sha256 cache + FileLock), logger.ts (JSONL structured logger) Trade-offs documented in docs/codex-imagegen-backend.md: - 5-10x slower than direct OpenAI API (or near-free on cache hit) - ToS gray area: image_gen is documented for interactive use; non- interactive invocation via codex exec is not explicitly addressed. Suitable for personal low-volume use; users are responsible for ensuring their usage complies with applicable terms of service. --- .github/workflows/codex-imagegen-tests.yml | 40 +++ CLAUDE.md | 16 ++ docs/codex-imagegen-backend.md | 256 ++++++++++++++++++ scripts/codex-imagegen.sh | 19 ++ scripts/codex-imagegen/cache.test.ts | 63 +++++ scripts/codex-imagegen/cache.ts | 80 ++++++ scripts/codex-imagegen/logger.ts | 39 +++ scripts/codex-imagegen/main.ts | 295 +++++++++++++++++++++ scripts/codex-imagegen/parser.test.ts | 62 +++++ scripts/codex-imagegen/parser.ts | 81 ++++++ scripts/codex-imagegen/spawn.ts | 72 +++++ scripts/codex-imagegen/types.ts | 77 ++++++ scripts/codex-imagegen/validator.test.ts | 98 +++++++ scripts/codex-imagegen/validator.ts | 55 ++++ 14 files changed, 1253 insertions(+) create mode 100644 .github/workflows/codex-imagegen-tests.yml create mode 100644 docs/codex-imagegen-backend.md create mode 100755 scripts/codex-imagegen.sh create mode 100644 scripts/codex-imagegen/cache.test.ts create mode 100644 scripts/codex-imagegen/cache.ts create mode 100644 scripts/codex-imagegen/logger.ts create mode 100644 scripts/codex-imagegen/main.ts create mode 100644 scripts/codex-imagegen/parser.test.ts create mode 100644 scripts/codex-imagegen/parser.ts create mode 100644 scripts/codex-imagegen/spawn.ts create mode 100644 scripts/codex-imagegen/types.ts create mode 100644 scripts/codex-imagegen/validator.test.ts create mode 100644 scripts/codex-imagegen/validator.ts diff --git a/.github/workflows/codex-imagegen-tests.yml b/.github/workflows/codex-imagegen-tests.yml new file mode 100644 index 0000000..eb723b4 --- /dev/null +++ b/.github/workflows/codex-imagegen-tests.yml @@ -0,0 +1,40 @@ +name: codex-imagegen tests + +on: + push: + paths: + - 'scripts/codex-imagegen/**' + - 'scripts/codex-imagegen.sh' + - '.github/workflows/codex-imagegen-tests.yml' + pull_request: + paths: + - 'scripts/codex-imagegen/**' + - 'scripts/codex-imagegen.sh' + - '.github/workflows/codex-imagegen-tests.yml' + workflow_dispatch: + +jobs: + unit-tests: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Show Bun version + run: bun --version + + - name: Run unit tests + working-directory: scripts/codex-imagegen + run: bun test + + - name: Bundle smoke test (catches import/syntax errors) + run: bun build --target=node scripts/codex-imagegen/main.ts --outfile /tmp/main-build.js + + - name: Help output smoke test + run: bun scripts/codex-imagegen/main.ts --help diff --git a/CLAUDE.md b/CLAUDE.md index 6af7317..6ed8c86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,22 @@ Skills that prompt users for choices MUST declare the tool-selection convention Skills that render images MUST declare the backend-selection convention **inline** in exactly one place per `SKILL.md` — a `## Image Generation Tools` section near the top (after `## User Input Tools`). Do NOT link out to [docs/image-generation-tools.md](docs/image-generation-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) elsewhere in a skill are treated as examples — other runtimes substitute their local equivalent under the rule. The rule is stateless: use whatever backend is available; if multiple, ask the user once; if none, ask how to proceed. Every rendered image's full prompt must be written to a standalone `prompts/NN-*.md` file before any backend is invoked. Backend skills (`baoyu-imagine`, `baoyu-image-gen`, `baoyu-danger-gemini-web`) are exempt — they render directly rather than selecting a backend. +### `codex-imagegen` Backend + +A backend for non-Codex runtimes (e.g., Claude Code) that generates images by spawning `codex exec --json --sandbox danger-full-access` and delegating to Codex CLI's built-in `image_gen` tool. Uses the user's Codex subscription — no `OPENAI_API_KEY` required. + +Invoke via: + +```bash +./scripts/codex-imagegen.sh \ + --image \ + --prompt-file prompts/01-cover.md \ + --aspect 16:9 \ + --cache-dir ~/.cache/baoyu-codex-imagegen +``` + +Stdout emits a single JSON line: `{"status":"ok","path":...,"bytes":N,...}`. On failure, `{"status":"error","error_kind":...}`. Skills route here by setting `preferred_image_backend: codex-imagegen` in EXTEND.md. Full reference: [docs/codex-imagegen-backend.md](docs/codex-imagegen-backend.md). + ## Deprecated Skills | Skill | Note | diff --git a/docs/codex-imagegen-backend.md b/docs/codex-imagegen-backend.md new file mode 100644 index 0000000..d29bc8d --- /dev/null +++ b/docs/codex-imagegen-backend.md @@ -0,0 +1,256 @@ +# `codex-imagegen` Backend + +Generate images via Codex CLI's built-in `image_gen` tool from non-Codex runtimes (e.g., Claude Code). The wrapper spawns `codex exec --json` and lets the user's existing Codex subscription drive image generation — **no `OPENAI_API_KEY` required**. + +This backend implements the `preferred_image_backend: codex-imagegen` config key already referenced in several `SKILL.md` files across this repo. + +## Features + +| Feature | Status | +|---------|--------| +| **Reliability**: retry + exponential backoff | Default 2 retries | +| **Verification**: confirms `image_gen` was actually invoked (not bypassed) | Checks `$CODEX_HOME/generated_images/{thread_id}/` | +| **Verification**: PNG magic-byte sanity check | ✓ | +| **Idempotency cache**: reuses output for same prompt+aspect+refs | `--cache-dir` | +| **Concurrency control**: file lock prevents parallel `codex exec` collisions | Built-in | +| **Structured logging**: JSONL log file | `--log-file` | +| **Token usage returned** | Embedded in result JSON | +| **`--ref` reference images** | Repeatable | +| **Unit tests** | 16 tests (parser / cache / validator) | +| **Error classification**: retryable vs non-retryable | 9 `error_kind` values | + +## Why this backend + +| Scenario | Conventional backend | This backend | +|----------|---------------------|--------------| +| You have a Codex subscription | OpenAI Images API costs add up per image | Subscription already covers it — zero marginal API cost | +| No `OPENAI_API_KEY` available | `baoyu-imagine` needs an API key | `codex login` is enough | +| Want to use GPT Image 2 | Only via OpenAI API | Codex's `image_gen` *is* GPT Image 2 | + +## Prerequisites + +```bash +npm install -g @openai/codex +codex login # signs in with your OpenAI account (subscription) +codex --version # confirm >= 0.130 +``` + +`bun` is preferred for running the wrapper. On macOS: + +```bash +brew install oven-sh/bun/bun +``` + +If `bun` is missing, the shell entrypoint falls back to `npx -y bun`. + +## Usage + +### Direct CLI + +```bash +# Inline prompt +./scripts/codex-imagegen.sh \ + --image /tmp/cat.png \ + --prompt "A friendly orange cat, watercolor" + +# Prompt from file +./scripts/codex-imagegen.sh \ + --image cover.png \ + --prompt-file prompts/01-cover.md \ + --aspect 16:9 + +# Verbose mode for debugging +./scripts/codex-imagegen.sh -v --image dog.png --prompt "A corgi" --aspect 1:1 +``` + +On success, stdout emits a single JSON line: + +```json +{"status":"ok","path":"/tmp/cat.png","bytes":2567101,"elapsed_seconds":53} +``` + +On failure, exit code is non-zero and stderr contains the error message. + +### Enabling within image skills + +Image-generating skills (e.g., `baoyu-cover-image`, `baoyu-article-illustrator`) already support a `preferred_image_backend` preference. To route them through this backend, set the following in the corresponding `EXTEND.md`: + +```yaml +# ~/.baoyu-skills/baoyu-cover-image/EXTEND.md +preferred_image_backend: codex-imagegen +``` + +When the LLM runs the skill, it reads the preference and — guided by the `### codex-imagegen Backend` section in `CLAUDE.md` — invokes `scripts/codex-imagegen.sh`. + +> **Note**: The integration is mediated by the LLM reading `CLAUDE.md`. It is not a hard binding. If a skill does not route to the backend automatically, mentioning it explicitly in the prompt works. + +## Parameters + +| Flag | Required | Description | +|------|----------|-------------| +| `--image ` | ✓ | Output PNG path (absolute recommended; relative paths are resolved against cwd) | +| `--prompt ` | one of | Prompt string (mutually exclusive with `--prompt-file`) | +| `--prompt-file ` | one of | Read prompt from file (mutually exclusive with `--prompt`) | +| `--aspect ` | | Aspect ratio. Default `1:1`. Common: `16:9`, `9:16`, `4:3`, `2.35:1` | +| `--ref ` | | Reference image path (repeatable) | +| `--timeout ` | | `codex exec` timeout in ms. Default `300000` | +| `--retries ` | | Retry count on retryable errors. Default `2` (total attempts = retries + 1) | +| `--retry-delay ` | | Base delay between retries (exponential backoff). Default `1500` | +| `--cache-dir ` | | Enable idempotency cache (reuses output for same prompt+aspect+refs) | +| `--log-file ` | | Structured JSONL log path (appended) | +| `-v` / `--verbose` | | Mirror log entries to stderr | +| `-h` / `--help` | | Show usage | + +## Structured Output + +On success, stdout contains a single JSON line: + +```json +{ + "status": "ok", + "path": "/tmp/owl.png", + "bytes": 1693831, + "elapsed_seconds": 87, + "thread_id": "019e40e8-daef-7c60-943d-5e7bb3f6cb3d", + "attempts": 1, + "cached": false, + "usage": { + "input": 110899, + "cached_input": 83456, + "output": 457, + "reasoning": 47 + }, + "tool_calls": [ + {"tool": "shell", "status": "completed"}, + {"tool": "agent_message", "status": "completed"} + ] +} +``` + +Cache hits return with `elapsed_seconds: 0`, `cached: true`, `attempts: 0`. + +On failure, exit code is `1` and the JSON contains `error` and `error_kind`: + +```json +{ + "status": "error", + "error": "image_gen was not invoked: no PNG in ...", + "error_kind": "no_image_gen_tool_use" +} +``` + +## Error Kinds + +| `error_kind` | Retryable | Meaning | +|--------------|-----------|---------| +| `codex_not_installed` | ✗ | `codex` CLI not found | +| `invalid_args` | ✗ | Argument parsing error | +| `prompt_file_missing` | ✗ | `--prompt-file` path does not exist | +| `spawn_failed` | ✓ | `codex exec` exited non-zero | +| `timeout` | ✓ | Exceeded `--timeout` | +| `no_image_gen_tool_use` | ✓ | Agent did not invoke `image_gen` (it took another path) | +| `output_missing` | ✓ | Output file not created | +| `invalid_png` | ✓ | Output is not a valid PNG | +| `agent_refused` | ✓ | No `thread_id` in event stream (Codex refused to respond) | +| `lock_busy` | ✗ | Concurrency lock acquisition timed out | + +## Measured Performance + +| Metric | Value | +|--------|-------| +| First-run latency | 50–90 s | +| Cache-hit latency | < 0.3 s | +| Output dimensions | 1024×1024, 1672×941 (16:9), etc. — chosen by `image_gen` | +| Output format | PNG (RGB, 8-bit) | +| Token usage per call | ~110k input (~80k cached) + ~500 output | +| Quota source | Codex subscription (does not consume OpenAI API quota) | +| Default timeout | 300 s (5 min) | + +## Limitations & Risks + +1. **5–10× slower than direct API**. `codex exec` cold-starts the agent, loads the built-in `image_gen` SKILL.md, and runs reasoning before invoking the tool. Cache hits avoid this for repeated prompts. +2. **ToS gray area**. Codex's `image_gen` tool is designed for interactive use. Invoking it programmatically via `codex exec` from an external agent is not explicitly addressed by current OpenAI policies. Suggested guardrails: + - Personal, low-volume use is reasonable. + - Not recommended for production automation or high-volume batch jobs. + - Users are responsible for ensuring their usage complies with applicable terms of service. +3. **Sandbox permissions**. The wrapper passes `--sandbox danger-full-access` so the spawned agent can move the rendered PNG out of `$CODEX_HOME/generated_images/`. This is necessary because the agent must `cp`/`mv` the file to the user-specified output path. +4. **Concurrency = 1**. The file lock serializes concurrent invocations to avoid `codex exec` collisions. Parallel calls queue. + +## Troubleshooting + +| Symptom | `error_kind` | Resolution | +|---------|--------------|------------| +| `command not found: codex` | `codex_not_installed` | `npm install -g @openai/codex` | +| `codex exec` fails | `spawn_failed` | Check `codex login` status; inspect `raw_log` path | +| Timeout | `timeout` | Pass `--timeout 600000` (10 min) for slow networks | +| Agent skipped `image_gen` | `no_image_gen_tool_use` | Auto-retries; consider sharpening the prompt — abstract prompts let the agent wander | +| Output missing | `output_missing` | Agent did not `cp` to the target path; check `raw_log` for the actual save location under `generated_images/` | +| Lock held | `lock_busy` | Wait for the in-flight request to finish; or `rm ~/.cache/baoyu-codex-imagegen/codex-exec.lock` | +| Low image quality | — | Sharpen the prompt, try a different aspect, or supply `--ref` | + +## Architecture + +``` +scripts/codex-imagegen.sh # thin bash entrypoint +scripts/codex-imagegen/ +├── main.ts # parseArgs → cache → lock → retry loop → emit JSON +├── types.ts # CliOptions, GenerateResult, GenError, ErrorKind +├── spawn.ts # spawn codex exec --json --sandbox danger-full-access +├── parser.ts # parse JSONL event stream → toolCalls, usage, thread_id +├── validator.ts # verify image_gen invocation + PNG magic + file size +├── cache.ts # cacheKey(sha256), FileLock, lookup/store +├── logger.ts # JsonLogger (verbose stderr + JSONL file) +├── parser.test.ts +├── cache.test.ts +└── validator.test.ts +``` + +Run tests: + +```bash +cd scripts/codex-imagegen && bun test +``` + +## Internal Flow + +```mermaid +flowchart LR + CC[Claude Code / any caller] + WRAPPER[scripts/codex-imagegen.sh] + CODEX["codex exec --json
--sandbox danger-full-access"] + AGENT[Codex agent] + TOOL[image_gen built-in tool] + DEFAULT["$CODEX_HOME/
generated_images/{thread_id}/"] + OUT[/specified OUTPUT path/] + + CC -->|exec wrapper| WRAPPER + WRAPPER -->|stdin: instruction| CODEX + CODEX --> AGENT + AGENT -->|tool call| TOOL + TOOL -->|writes file| DEFAULT + AGENT -->|agent cp/mv| OUT + WRAPPER -->|verify + parse| CC + + classDef cc fill:#1e40af,color:#fff,stroke:#93c5fd + classDef cdx fill:#7c2d12,color:#fff,stroke:#fdba74 + class CC,WRAPPER cc + class CODEX,AGENT,TOOL cdx +``` + +## Design Decisions + +1. **Bash entrypoint + TypeScript implementation** — the shell wrapper picks the runtime (`bun` preferred, falling back to `npx -y bun`); TypeScript handles the orchestration, parsing, retry, cache, and logging. This mirrors the project's existing `scripts/*.mjs` and `skills//scripts/main.ts` pattern. +2. **`--sandbox danger-full-access`** — necessary so the spawned agent can `cp`/`mv` the rendered PNG out of `$CODEX_HOME/generated_images/` to the user-specified path. Standard sandboxes block this. +3. **Parse the JSONL event stream** — the final `agent_message` and intermediate `command_execution` events let the wrapper verify what actually happened (was `image_gen` called? did `cp` reach the right destination?), which is far more reliable than scraping freeform stdout. +4. **Infrastructure, not a skill** — this backend is a CLI utility that skills route to via `preferred_image_backend`. It belongs in `scripts/`, not `skills/`, because it has no `SKILL.md` and is never loaded directly by an agent. +5. **File lock instead of internal queue** — keeps the implementation small and works across multiple shell sessions or processes invoking the same wrapper concurrently. + +## Related Files + +| File | Role | +|------|------| +| `scripts/codex-imagegen.sh` | CLI entrypoint | +| `scripts/codex-imagegen/` | TypeScript implementation | +| `docs/codex-imagegen-backend.md` | This document | +| `CLAUDE.md` | Tells LLMs how to invoke this backend | +| `.github/workflows/codex-imagegen-tests.yml` | CI unit tests | diff --git a/scripts/codex-imagegen.sh b/scripts/codex-imagegen.sh new file mode 100755 index 0000000..e575799 --- /dev/null +++ b/scripts/codex-imagegen.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# codex-imagegen: generate images via Codex CLI's built-in image_gen tool +# Thin shell wrapper — implementation in codex-imagegen/main.ts (Bun TypeScript) +# +# Usage: ./codex-imagegen.sh --help + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if command -v bun &>/dev/null; then + BUN_X="bun" +elif command -v npx &>/dev/null; then + BUN_X="npx -y bun" +else + echo "Error: bun or npx required. Install: brew install oven-sh/bun/bun" >&2 + exit 1 +fi + +exec $BUN_X "$SCRIPT_DIR/codex-imagegen/main.ts" "$@" diff --git a/scripts/codex-imagegen/cache.test.ts b/scripts/codex-imagegen/cache.test.ts new file mode 100644 index 0000000..78dbcc4 --- /dev/null +++ b/scripts/codex-imagegen/cache.test.ts @@ -0,0 +1,63 @@ +import { test, expect } from "bun:test"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts"; + +test("cacheKey is deterministic and order-independent for refs", () => { + const k1 = cacheKey("hello", "16:9", ["a.png", "b.png"]); + const k2 = cacheKey("hello", "16:9", ["b.png", "a.png"]); + expect(k1).toBe(k2); + const k3 = cacheKey("hello", "16:9", []); + expect(k3).not.toBe(k1); + const k4 = cacheKey("hello", "1:1", []); + expect(k4).not.toBe(k3); +}); + +test("lookupCache returns null on miss, path on hit", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cig-test-")); + try { + expect(await lookupCache(dir, "abc")).toBeNull(); + const fake = path.join(dir, "abc.png"); + await writeFile(fake, Buffer.alloc(2000)); + expect(await lookupCache(dir, "abc")).toBe(fake); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("storeCache copies source into cache", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cig-test-")); + const src = path.join(dir, "src.png"); + try { + await writeFile(src, Buffer.from("xxxx".repeat(1000))); + await storeCache(dir, "key1", src); + const cached = await lookupCache(dir, "key1"); + expect(cached).not.toBeNull(); + const a = await readFile(src); + const b = await readFile(cached!); + expect(a.equals(b)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("FileLock prevents concurrent acquisition", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cig-lock-")); + try { + const lockPath = path.join(dir, "x.lock"); + const lock1 = new FileLock(lockPath); + const lock2 = new FileLock(lockPath); + await lock1.acquire(1000); + let lock2Acquired = false; + const p = lock2.acquire(500).then(() => (lock2Acquired = true)).catch(() => {}); + await new Promise((r) => setTimeout(r, 300)); + expect(lock2Acquired).toBe(false); + await lock1.release(); + await p; + expect(lock2Acquired).toBe(true); + await lock2.release(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/codex-imagegen/cache.ts b/scripts/codex-imagegen/cache.ts new file mode 100644 index 0000000..063bf35 --- /dev/null +++ b/scripts/codex-imagegen/cache.ts @@ -0,0 +1,80 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile, copyFile, stat } from "node:fs/promises"; +import { existsSync, openSync, closeSync } from "node:fs"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +export function cacheKey(prompt: string, aspect: string, refs: string[]): string { + const h = createHash("sha256"); + h.update(prompt); + h.update("|"); + h.update(aspect); + h.update("|"); + for (const r of [...refs].sort()) h.update(r); + return h.digest("hex").slice(0, 16); +} + +export async function lookupCache(cacheDir: string, key: string): Promise { + const entry = path.join(cacheDir, `${key}.png`); + try { + const s = await stat(entry); + if (s.size > 1000) return entry; + } catch {} + return null; +} + +export async function storeCache(cacheDir: string, key: string, sourcePath: string): Promise { + await mkdir(cacheDir, { recursive: true }); + const entry = path.join(cacheDir, `${key}.png`); + await copyFile(sourcePath, entry); +} + +export class FileLock { + private fd: number | null = null; + constructor(private lockPath: string) {} + + async acquire(timeoutMs = 30_000): Promise { + const start = Date.now(); + await mkdir(path.dirname(this.lockPath), { recursive: true }); + while (Date.now() - start < timeoutMs) { + try { + this.fd = openSync(this.lockPath, "wx"); + return; + } catch (e: any) { + if (e.code !== "EEXIST") throw e; + if (await this.isStale()) { + try { + await this.release(true); + } catch {} + continue; + } + await delay(200); + } + } + throw new Error(`Failed to acquire lock at ${this.lockPath} within ${timeoutMs}ms`); + } + + private async isStale(): Promise { + try { + const s = await stat(this.lockPath); + return Date.now() - s.mtimeMs > 10 * 60 * 1000; + } catch { + return true; + } + } + + async release(force = false): Promise { + if (this.fd != null) { + try { + closeSync(this.fd); + } catch {} + this.fd = null; + } + if (existsSync(this.lockPath) || force) { + const { unlink } = await import("node:fs/promises"); + try { + await unlink(this.lockPath); + } catch {} + } + } +} diff --git a/scripts/codex-imagegen/logger.ts b/scripts/codex-imagegen/logger.ts new file mode 100644 index 0000000..81f9716 --- /dev/null +++ b/scripts/codex-imagegen/logger.ts @@ -0,0 +1,39 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +export interface LogEntry { + ts: string; + level: "info" | "warn" | "error"; + event: string; + [k: string]: unknown; +} + +export class JsonLogger { + constructor(private logFile: string | null, public verbose: boolean) {} + + async log(level: LogEntry["level"], event: string, extra: Record = {}): Promise { + const entry: LogEntry = { ts: new Date().toISOString(), level, event, ...extra }; + const line = JSON.stringify(entry); + if (this.verbose) process.stderr.write(`[${level}] ${event} ${jsonExtras(extra)}\n`); + if (this.logFile) { + await mkdir(path.dirname(this.logFile), { recursive: true }); + await appendFile(this.logFile, line + "\n", "utf-8"); + } + } + + info(event: string, extra?: Record) { + return this.log("info", event, extra); + } + warn(event: string, extra?: Record) { + return this.log("warn", event, extra); + } + error(event: string, extra?: Record) { + return this.log("error", event, extra); + } +} + +function jsonExtras(extra: Record): string { + const entries = Object.entries(extra); + if (entries.length === 0) return ""; + return entries.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" "); +} diff --git a/scripts/codex-imagegen/main.ts b/scripts/codex-imagegen/main.ts new file mode 100644 index 0000000..76dfbc4 --- /dev/null +++ b/scripts/codex-imagegen/main.ts @@ -0,0 +1,295 @@ +import { readFile, mkdir, copyFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +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 { hasImageGenInvocation } from "./parser.ts"; +import { codexHome, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts"; +import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts"; +import { JsonLogger } from "./logger.ts"; + +const HELP = `codex-imagegen — generate images via Codex CLI's image_gen tool + +Usage: + codex-imagegen --image [--prompt | --prompt-file ] [options] + +Required: + --image Output PNG path + --prompt Prompt text (or use --prompt-file) + --prompt-file Read prompt from file + +Options: + --aspect Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:1 + --ref Reference image (repeatable) + --timeout Codex exec timeout in ms. Default: 300000 + --retries Retry attempts on retryable errors. Default: 2 + --retry-delay Base retry delay (exponential). Default: 1500 + --cache-dir Enable idempotency cache. Disabled by default. + --log-file Append JSONL log + -v, --verbose Verbose stderr logging + -h, --help Show this help + +Stdout: single JSON line on success or failure. +`; + +function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { + prompt: "", + outputPath: "", + aspect: "1:1", + refImages: [], + timeoutMs: 300_000, + retries: 2, + retryDelayMs: 1500, + cacheDir: null, + logFile: null, + verbose: false, + }; + let promptFile: string | null = null; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + switch (a) { + case "--prompt": opts.prompt = next(); break; + case "--prompt-file": promptFile = next(); break; + case "--image": opts.outputPath = next(); break; + case "--aspect": opts.aspect = next(); break; + case "--ref": opts.refImages.push(next()); break; + case "--timeout": opts.timeoutMs = Number(next()); break; + case "--retries": opts.retries = Number(next()); break; + case "--retry-delay": opts.retryDelayMs = Number(next()); break; + case "--cache-dir": opts.cacheDir = next(); break; + case "--log-file": opts.logFile = next(); break; + case "-v": + case "--verbose": opts.verbose = true; break; + case "-h": + case "--help": process.stdout.write(HELP); process.exit(0); + default: throw new GenError("invalid_args", `Unknown argument: ${a}`, false); + } + } + if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false); + if (!opts.prompt && !promptFile) throw new GenError("invalid_args", "--prompt or --prompt-file required", false); + if (promptFile) { + opts.prompt = ""; // will be loaded later + (opts as any).__promptFile = promptFile; + } + if (!path.isAbsolute(opts.outputPath)) { + opts.outputPath = path.resolve(process.cwd(), opts.outputPath); + } + return opts; +} + +async function loadPrompt(opts: CliOptions): Promise { + if (opts.prompt) return opts.prompt; + const file = (opts as any).__promptFile as string; + try { + return await readFile(file, "utf-8"); + } catch { + throw new GenError("prompt_file_missing", `Prompt file not found: ${file}`, false); + } +} + +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. + +TASK: Generate an image with the spec below, then save to disk. + +PROMPT: +${prompt} + +ASPECT RATIO: ${opts.aspect} +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} +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":} + +HARD CONSTRAINTS: +- 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.`; +} + +async function attemptGenerate( + opts: CliOptions, + instruction: string, + attempt: number, + log: JsonLogger, +): Promise<{ bytes: number; threadId: string | null; usage: any; toolCalls: any[] }> { + await log.info("attempt.start", { attempt, output: opts.outputPath, aspect: opts.aspect }); + + const run = await runCodexExec({ + instruction, + timeoutMs: opts.timeoutMs, + refImages: opts.refImages, + }); + + await log.info("codex.completed", { + duration_ms: run.durationMs, + thread_id: run.threadId, + tool_calls: run.toolCalls.length, + usage: run.usage, + raw_log: run.rawLogPath, + }); + + // verify: thread id must be present + if (!run.threadId) { + throw new GenError("agent_refused", "No thread id in event stream"); + } + + // verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/) + const ver = await verifyImageGenWasInvoked(run.threadId); + if (!ver.ok) { + // secondary verify: did tool_calls include cp/mv from generated_images + const sawCopy = run.toolCalls.some( + (tc) => tc.command && /\b(cp|mv)\b/.test(tc.command) && tc.command.includes("generated_images"), + ); + if (!sawCopy) { + throw new GenError("no_image_gen_tool_use", `image_gen was not invoked: ${ver.reason}`); + } + } + + // verify output + const { bytes } = await verifyOutput(opts.outputPath); + + return { + bytes, + threadId: run.threadId, + usage: run.usage, + toolCalls: run.toolCalls.map((tc) => ({ tool: tc.tool, status: tc.status })), + }; +} + +async function generate(opts: CliOptions, log: JsonLogger): Promise { + const startEpoch = Date.now(); + const prompt = await loadPrompt(opts); + + // Cache lookup + if (opts.cacheDir) { + const key = cacheKey(prompt, opts.aspect, opts.refImages); + const cached = await lookupCache(opts.cacheDir, key); + if (cached) { + await mkdir(path.dirname(opts.outputPath), { recursive: true }); + await copyFile(cached, opts.outputPath); + const s = await stat(opts.outputPath); + await log.info("cache.hit", { key, source: cached }); + return { + status: "ok", + path: opts.outputPath, + bytes: s.size, + elapsed_seconds: 0, + thread_id: null, + attempts: 0, + cached: true, + usage: null, + tool_calls: [], + }; + } + await log.info("cache.miss", { key }); + } + + // lock to prevent concurrent codex exec + const lockDir = opts.cacheDir ?? path.join(homedir(), ".cache", "baoyu-codex-imagegen"); + const lock = new FileLock(path.join(lockDir, "codex-exec.lock")); + try { + await lock.acquire(60_000); + } catch (e) { + throw new GenError("lock_busy", String(e), false); + } + + await mkdir(path.dirname(opts.outputPath), { recursive: true }); + const instruction = buildInstruction(prompt, opts); + + let lastErr: GenError | null = null; + try { + for (let attempt = 1; attempt <= opts.retries + 1; attempt++) { + try { + const result = await attemptGenerate(opts, instruction, attempt, log); + + // write to cache + if (opts.cacheDir) { + const key = cacheKey(prompt, opts.aspect, opts.refImages); + await storeCache(opts.cacheDir, key, opts.outputPath); + await log.info("cache.stored", { key }); + } + + return { + status: "ok", + path: opts.outputPath, + bytes: result.bytes, + elapsed_seconds: Math.round((Date.now() - startEpoch) / 1000), + thread_id: result.threadId, + attempts: attempt, + cached: false, + usage: result.usage, + tool_calls: result.toolCalls, + }; + } catch (e) { + lastErr = e instanceof GenError ? e : new GenError("spawn_failed", String(e)); + await log.warn("attempt.failed", { + attempt, + kind: lastErr.kind, + retryable: lastErr.retryable, + error: lastErr.message, + }); + if (!lastErr.retryable || attempt > opts.retries) break; + const wait = opts.retryDelayMs * Math.pow(2, attempt - 1); + await log.info("retry.wait", { wait_ms: wait, next_attempt: attempt + 1 }); + await delay(wait); + } + } + } finally { + await lock.release(); + } + + throw lastErr ?? new GenError("spawn_failed", "Unknown failure"); +} + +async function main() { + let opts: CliOptions; + try { + opts = parseArgs(process.argv.slice(2)); + } catch (e) { + const err = e instanceof GenError ? e : new GenError("invalid_args", String(e), false); + process.stderr.write(`Error: ${err.message}\n`); + process.exit(2); + } + + const log = new JsonLogger(opts.logFile, opts.verbose); + await log.info("start", { output: opts.outputPath, aspect: opts.aspect, refs: opts.refImages.length }); + + try { + const result = await generate(opts, log); + await log.info("done", { bytes: result.bytes, attempts: result.attempts, cached: result.cached }); + process.stdout.write(JSON.stringify(result) + "\n"); + process.exit(0); + } catch (e) { + const err = e instanceof GenError ? e : new GenError("spawn_failed", String(e)); + await log.error("failed", { kind: err.kind, error: err.message }); + const out: GenerateResult = { + status: "error", + path: opts.outputPath, + bytes: 0, + elapsed_seconds: 0, + thread_id: null, + attempts: 0, + cached: false, + usage: null, + tool_calls: [], + error: err.message, + error_kind: err.kind, + }; + process.stdout.write(JSON.stringify(out) + "\n"); + process.exit(1); + } +} + +main(); diff --git a/scripts/codex-imagegen/parser.test.ts b/scripts/codex-imagegen/parser.test.ts new file mode 100644 index 0000000..03a0c88 --- /dev/null +++ b/scripts/codex-imagegen/parser.test.ts @@ -0,0 +1,62 @@ +import { test, expect } from "bun:test"; +import { parseEventStream, hasImageGenInvocation, parseFinalJson } from "./parser.ts"; + +const REAL_PoC_STREAM = `{"type":"thread.started","thread_id":"019e40d3-30e3-7030-874d-773bc0d6d1eb"} +{"type":"turn.started"} +{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"sed -n '1,5p' /tmp/x.md","status":"in_progress"}} +{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"sed -n '1,5p' /tmp/x.md","exit_code":0,"status":"completed"}} +{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"cp /Users/x/.codex/generated_images/019e40d3/ig_abc.png /tmp/out.png","status":"in_progress"}} +{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"cp /Users/x/.codex/generated_images/019e40d3/ig_abc.png /tmp/out.png","exit_code":0,"status":"completed"}} +{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\\"status\\":\\"ok\\",\\"path\\":\\"/tmp/out.png\\",\\"bytes\\":1234567}"}} +{"type":"turn.completed","usage":{"input_tokens":100000,"cached_input_tokens":80000,"output_tokens":500,"reasoning_output_tokens":50}}`; + +test("parseEventStream extracts threadId, toolCalls, agentMessage, usage", () => { + const r = parseEventStream(REAL_PoC_STREAM); + expect(r.threadId).toBe("019e40d3-30e3-7030-874d-773bc0d6d1eb"); + expect(r.toolCalls.length).toBe(3); + expect(r.usage).toEqual({ + input: 100000, + cached_input: 80000, + output: 500, + reasoning: 50, + }); + expect(r.agentMessage).toContain('"status":"ok"'); +}); + +test("parseEventStream tolerates malformed lines", () => { + const stream = `not json at all +{"type":"thread.started","thread_id":"abc"} +{partial json +{"type":"turn.completed","usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0}}`; + const r = parseEventStream(stream); + expect(r.threadId).toBe("abc"); + expect(r.usage?.input).toBe(1); +}); + +test("hasImageGenInvocation finds shell calls touching generated_images", () => { + const r = parseEventStream(REAL_PoC_STREAM); + // image_gen itself is not an event; inferred via generated_images cp path + // this test only verifies parser behavior; driver logic lives in validator + const hasCp = r.toolCalls.some((tc) => tc.command?.includes("generated_images")); + expect(hasCp).toBe(true); +}); + +test("parseFinalJson extracts {status:ok, path, bytes}", () => { + expect(parseFinalJson('{"status":"ok","path":"/tmp/a.png","bytes":1234}')).toEqual({ + path: "/tmp/a.png", + bytes: 1234, + }); + expect(parseFinalJson("garbage text {\"status\":\"ok\",\"path\":\"/x\",\"bytes\":5} trailer")).toEqual({ + path: "/x", + bytes: 5, + }); + expect(parseFinalJson(null)).toBeNull(); + expect(parseFinalJson('{"status":"error","reason":"x"}')).toBeNull(); +}); + +test("hasImageGenInvocation (proper) returns false when no image_gen tool", () => { + expect(hasImageGenInvocation([{ id: "1", tool: "shell", status: "completed" }])).toBe(false); + expect( + hasImageGenInvocation([{ id: "1", tool: "image_gen", status: "completed" }]), + ).toBe(true); +}); diff --git a/scripts/codex-imagegen/parser.ts b/scripts/codex-imagegen/parser.ts new file mode 100644 index 0000000..c816885 --- /dev/null +++ b/scripts/codex-imagegen/parser.ts @@ -0,0 +1,81 @@ +import type { CodexRunResult, ToolCall, TokenUsage } from "./types.ts"; + +export function parseEventStream(raw: string): Omit { + const lines = raw.split("\n").filter((l) => l.trim().length > 0); + let threadId: string | null = null; + let agentMessage: string | null = null; + let usage: TokenUsage | null = null; + const toolCallsById = new Map(); + + for (const line of lines) { + let event: any; + try { + event = JSON.parse(line); + } catch { + continue; + } + const type = event?.type; + if (type === "thread.started") { + threadId = event.thread_id ?? null; + } else if (type === "item.started" || type === "item.completed") { + const item = event.item; + if (!item?.id) continue; + const tc: ToolCall = { + id: item.id, + tool: deriveToolName(item), + status: item.status ?? (type === "item.completed" ? "completed" : "in_progress"), + command: item.command, + }; + toolCallsById.set(item.id, tc); + if (item.type === "agent_message" && type === "item.completed") { + agentMessage = String(item.text ?? ""); + } + } else if (type === "turn.completed") { + const u = event.usage; + if (u) { + usage = { + input: u.input_tokens ?? 0, + cached_input: u.cached_input_tokens ?? 0, + output: u.output_tokens ?? 0, + reasoning: u.reasoning_output_tokens ?? 0, + }; + } + } + } + + return { + threadId, + toolCalls: Array.from(toolCallsById.values()), + agentMessage, + usage, + }; +} + +function deriveToolName(item: any): string { + if (item.type === "command_execution") return "shell"; + if (item.type === "agent_message") return "agent_message"; + if (item.type === "image_gen" || item.type === "image_generation") return "image_gen"; + if (typeof item.tool === "string") return item.tool; + return item.type ?? "unknown"; +} + +export function hasImageGenInvocation(toolCalls: ToolCall[]): boolean { + return toolCalls.some((tc) => tc.tool === "image_gen"); +} + +export function parseFinalJson(agentMessage: string | null): { path?: string; bytes?: number } | null { + if (!agentMessage) return null; + const trimmed = agentMessage.trim(); + const candidates = [trimmed]; + const match = trimmed.match(/\{[^{}]*"status"\s*:\s*"ok"[^{}]*\}/); + if (match) candidates.push(match[0]); + for (const c of candidates) { + try { + const obj = JSON.parse(c); + if (obj?.status === "ok") return { path: obj.path, bytes: obj.bytes }; + } catch { + continue; + } + } + return null; +} diff --git a/scripts/codex-imagegen/spawn.ts b/scripts/codex-imagegen/spawn.ts new file mode 100644 index 0000000..1e79caf --- /dev/null +++ b/scripts/codex-imagegen/spawn.ts @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; +import { writeFile, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { GenError, type CodexRunResult } from "./types.ts"; +import { parseEventStream } from "./parser.ts"; + +export interface SpawnInput { + instruction: string; + timeoutMs: number; + refImages?: string[]; +} + +export async function runCodexExec(input: SpawnInput): Promise { + const start = Date.now(); + const logDir = await mkdtemp(path.join(tmpdir(), "codex-imggen-")); + const rawLogPath = path.join(logDir, "stream.jsonl"); + + const args = ["exec", "--json", "--sandbox", "danger-full-access"]; + for (const img of input.refImages ?? []) { + args.push("--image", img); + } + args.push("-"); + + let timedOut = false; + const child = spawn("codex", args, { stdio: ["pipe", "pipe", "pipe"] }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + child.stdin.write(input.instruction); + child.stdin.end(); + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 2000); + }, input.timeoutMs); + + const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.on("close", (code, signal) => resolve({ code, signal })); + }); + clearTimeout(timer); + + await writeFile(rawLogPath, stdout + (stderr ? `\n--- stderr ---\n${stderr}` : "")); + + if (timedOut) { + throw new GenError("timeout", `codex exec exceeded ${input.timeoutMs}ms (log: ${rawLogPath})`); + } + if (exit.code !== 0) { + if (stderr.includes("command not found") || stderr.includes("not found: codex")) { + throw new GenError("codex_not_installed", "codex CLI not installed", false); + } + throw new GenError( + "spawn_failed", + `codex exec exited ${exit.code} signal=${exit.signal} (log: ${rawLogPath})`, + ); + } + + const parsed = parseEventStream(stdout); + return { + ...parsed, + rawLogPath, + durationMs: Date.now() - start, + }; +} diff --git a/scripts/codex-imagegen/types.ts b/scripts/codex-imagegen/types.ts new file mode 100644 index 0000000..f5afb4e --- /dev/null +++ b/scripts/codex-imagegen/types.ts @@ -0,0 +1,77 @@ +export interface CliOptions { + prompt: string; + outputPath: string; + aspect: string; + refImages: string[]; + timeoutMs: number; + retries: number; + retryDelayMs: number; + cacheDir: string | null; + logFile: string | null; + verbose: boolean; +} + +export interface ToolCall { + id: string; + tool: string; + status: string; + command?: string; +} + +export interface TokenUsage { + input: number; + cached_input: number; + output: number; + reasoning: number; +} + +export interface CodexRunResult { + threadId: string | null; + toolCalls: ToolCall[]; + agentMessage: string | null; + usage: TokenUsage | null; + rawLogPath: string; + durationMs: number; +} + +export interface GenerateResult { + status: "ok" | "error"; + path: string; + bytes: number; + elapsed_seconds: number; + thread_id: string | null; + attempts: number; + cached: boolean; + usage: TokenUsage | null; + tool_calls: { tool: string; status: string }[]; + error?: string; + error_kind?: ErrorKind; +} + +export type ErrorKind = + | "codex_not_installed" + | "invalid_args" + | "prompt_file_missing" + | "spawn_failed" + | "timeout" + | "no_image_gen_tool_use" + | "output_missing" + | "invalid_png" + | "agent_refused" + | "lock_busy"; + +export const RETRYABLE: ReadonlySet = new Set([ + "spawn_failed", + "timeout", + "no_image_gen_tool_use", + "output_missing", + "invalid_png", + "agent_refused", +]); + +export class GenError extends Error { + constructor(public kind: ErrorKind, message: string, public retryable?: boolean) { + super(message); + this.retryable = retryable ?? RETRYABLE.has(kind); + } +} diff --git a/scripts/codex-imagegen/validator.test.ts b/scripts/codex-imagegen/validator.test.ts new file mode 100644 index 0000000..3e38656 --- /dev/null +++ b/scripts/codex-imagegen/validator.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "bun:test"; +import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { verifyOutput, verifyImageGenWasInvoked, findCpToTarget } from "./validator.ts"; +import { GenError } from "./types.ts"; + +const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +test("verifyOutput passes for valid PNG", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cig-val-")); + try { + const p = path.join(dir, "good.png"); + await writeFile(p, Buffer.concat([PNG_HEADER, Buffer.alloc(5000)])); + const r = await verifyOutput(p); + expect(r.bytes).toBeGreaterThan(1000); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("verifyOutput rejects missing file", async () => { + await expect(verifyOutput("/no/such/file.png")).rejects.toBeInstanceOf(GenError); +}); + +test("verifyOutput rejects tiny file", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cig-val-")); + try { + const p = path.join(dir, "tiny.png"); + await writeFile(p, "tiny"); + await expect(verifyOutput(p)).rejects.toThrow(/too small/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("verifyOutput rejects non-PNG magic", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cig-val-")); + try { + const p = path.join(dir, "fake.png"); + await writeFile(p, Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(5000)])); + await expect(verifyOutput(p)).rejects.toThrow(/not a valid PNG/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("verifyImageGenWasInvoked false when no thread directory", async () => { + const orig = process.env.CODEX_HOME; + const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-")); + process.env.CODEX_HOME = tempHome; + try { + const r = await verifyImageGenWasInvoked("no-such-thread"); + expect(r.ok).toBe(false); + } finally { + process.env.CODEX_HOME = orig; + await rm(tempHome, { recursive: true, force: true }); + } +}); + +test("verifyImageGenWasInvoked true when PNG exists in thread dir", async () => { + const orig = process.env.CODEX_HOME; + const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-")); + process.env.CODEX_HOME = tempHome; + try { + const threadDir = path.join(tempHome, "generated_images", "thread-xyz"); + await mkdir(threadDir, { recursive: true }); + await writeFile(path.join(threadDir, "ig_abc.png"), Buffer.alloc(100)); + const r = await verifyImageGenWasInvoked("thread-xyz"); + expect(r.ok).toBe(true); + } finally { + process.env.CODEX_HOME = orig; + await rm(tempHome, { recursive: true, force: true }); + } +}); + +test("findCpToTarget detects cp from generated_images", () => { + expect( + findCpToTarget( + [ + { + id: "1", + tool: "shell", + status: "completed", + command: "cp ~/.codex/generated_images/thread/ig_x.png /tmp/out.png", + }, + ], + "/tmp/out.png", + ), + ).toBe(true); + + expect( + findCpToTarget( + [{ id: "1", tool: "shell", status: "completed", command: "ls /tmp" }], + "/tmp/out.png", + ), + ).toBe(false); +}); diff --git a/scripts/codex-imagegen/validator.ts b/scripts/codex-imagegen/validator.ts new file mode 100644 index 0000000..487ad81 --- /dev/null +++ b/scripts/codex-imagegen/validator.ts @@ -0,0 +1,55 @@ +import { stat, readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { GenError } from "./types.ts"; +import type { ToolCall } from "./types.ts"; + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export function codexHome(): string { + return process.env.CODEX_HOME ?? path.join(homedir(), ".codex"); +} + +export async function verifyImageGenWasInvoked(threadId: string | null): Promise<{ ok: boolean; reason?: string }> { + if (!threadId) return { ok: false, reason: "no thread id" }; + const dir = path.join(codexHome(), "generated_images", threadId); + try { + const entries = await readdir(dir); + const pngs = entries.filter((e) => e.toLowerCase().endsWith(".png")); + if (pngs.length === 0) return { ok: false, reason: `no PNG in ${dir}` }; + return { ok: true }; + } catch (e: any) { + return { ok: false, reason: `cannot read ${dir}: ${e?.code ?? e?.message}` }; + } +} + +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"), + ); +} + +export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> { + let s; + try { + s = await stat(outputPath); + } catch { + throw new GenError("output_missing", `Output file not created: ${outputPath}`); + } + if (s.size < 1000) { + throw new GenError("invalid_png", `Output file too small (${s.size} bytes)`); + } + const file = Bun.file(outputPath); + const head = new Uint8Array(await file.slice(0, 8).arrayBuffer()); + for (let i = 0; i < PNG_MAGIC.length; i++) { + if (head[i] !== PNG_MAGIC[i]) { + throw new GenError("invalid_png", `Output is not a valid PNG (magic mismatch)`); + } + } + return { bytes: s.size }; +} From 9596d39e7be16983b86c08b741cf0ccfb339ebdb Mon Sep 17 00:00:00 2001 From: Yelban Date: Thu, 21 May 2026 15:23:42 +0800 Subject: [PATCH 2/3] feat(baoyu-cover-image): wire SKILL.md to call codex-imagegen wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on #158: a backend wrapper alone is not enough — skills need explicit invocation guidance so the agent can find and call it. Updates skills/baoyu-cover-image/SKILL.md in two places: 1. ## Image Generation Tools — new bullet under the backend resolution list: 'Codex via codex exec (codex-imagegen)' with the exact command shape (--image / --prompt-file / --aspect / --ref / --cache-dir / --log-file), JSON output handling, and prerequisites. 2. ### Step 4: Generate Image, step 5 — added a concrete example command for the codex-imagegen branch alongside the existing generic instruction. End-to-end verified: with EXTEND.md set to preferred_image_backend: codex-imagegen, calling the command shape written in SKILL.md produces a 1672x941 PNG in 83s (status: ok, attempts: 1, cached: false). Output: /tmp/cover-e2e/cover.png. Structured log: /tmp/cover-e2e/run.jsonl. baoyu-cover-image is the validation skill; if reviewers want the same guidance in baoyu-article-illustrator / baoyu-comic / baoyu-image-cards / baoyu-infographic / baoyu-slide-deck, happy to mirror the change. --- skills/baoyu-cover-image/SKILL.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/skills/baoyu-cover-image/SKILL.md b/skills/baoyu-cover-image/SKILL.md index b742e16..b4effdb 100644 --- a/skills/baoyu-cover-image/SKILL.md +++ b/skills/baoyu-cover-image/SKILL.md @@ -29,6 +29,17 @@ When this skill needs to render an image, resolve the backend in this order: 2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it. 3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available): - **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`. + - **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime does NOT expose a native `imagegen` skill but the `codex` CLI is on `PATH` and `codex login` is active (e.g., Claude Code with Codex CLI installed), invoke the `codex-imagegen` wrapper. Command shape: + ```bash + ./scripts/codex-imagegen.sh \ + --image \ + --prompt-file \ + --aspect \ + [--ref ]... \ + [--cache-dir ~/.cache/baoyu-codex-imagegen] \ + [--log-file ] + ``` + Parse the single-line JSON on stdout. On `{"status":"ok",...}` proceed to Step 5. On `{"status":"error","error_kind":...}` report the `error_kind` to the user and (if retryable) ask whether to retry or fall back to another backend. The wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` needed. - **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way. - Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it. - Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions. @@ -214,7 +225,9 @@ Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](r 4. **Process references** from prompt frontmatter: - `direct` usage → pass via `--ref` (use ref-capable backend) - `style`/`palette` → extract traits, append to prompt -5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio +5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio. + - **`codex-imagegen`**: run `./scripts/codex-imagegen.sh --image --prompt-file prompts/01-cover-[slug].md --aspect ` (add `--ref ` per reference, `--cache-dir ~/.cache/baoyu-codex-imagegen` to enable the idempotency cache). Read the stdout JSON; act on `status` and `error_kind`. + - **Codex `imagegen` (native)** or other runtime-native tools / `baoyu-imagine` skill: per the rule in `## Image Generation Tools` above. 6. On failure: auto-retry once ### Step 5: Completion Report From df16bd5d1a4b19b1fcec53312b52dc1cf9b2a0b5 Mon Sep 17 00:00:00 2001 From: Yelban Date: Thu, 21 May 2026 16:25:44 +0800 Subject: [PATCH 3/3] feat(codex-imagegen): cwd-drift immunity (path resolve + skip-git-repo-check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements so the wrapper works the same regardless of caller cwd or whether cwd is a git repo: 1. main.ts: resolve all filesystem path args (--prompt-file, --ref, --cache-dir, --log-file) to absolute paths up front. Previously only --image was resolved. Agents passing relative paths from arbitrary working directories (e.g. when SKILL.md is interpreted from a plugin install dir) now produce correct file lookups. 2. spawn.ts: pass --skip-git-repo-check to 'codex exec'. Without it, codex refuses to run outside a trusted directory: 'Not inside a trusted directory and --skip-git-repo-check was not specified.' This made the wrapper fail when invoked from /tmp or from a non-git project tree. 3. baoyu-cover-image/SKILL.md: explicit path resolution note — scripts/codex-imagegen.sh lives at plugin/repo root (not shell cwd-relative). From the skill base directory it is at '../../scripts/codex-imagegen.sh'. Agents should resolve to an absolute path before invoking. Verified: - 16 unit tests pass - e2e from /tmp (non-git, relative-path args) → 45s, 1.6MB PNG, status:ok Composes with the existing cover-image wiring (commit e3932e4). --- scripts/codex-imagegen/main.ts | 17 +++++++++++++---- scripts/codex-imagegen/spawn.ts | 11 ++++++++++- skills/baoyu-cover-image/SKILL.md | 14 +++++++------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/scripts/codex-imagegen/main.ts b/scripts/codex-imagegen/main.ts index 76dfbc4..5de567f 100644 --- a/scripts/codex-imagegen/main.ts +++ b/scripts/codex-imagegen/main.ts @@ -71,13 +71,22 @@ function parseArgs(argv: string[]): CliOptions { } if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false); if (!opts.prompt && !promptFile) throw new GenError("invalid_args", "--prompt or --prompt-file required", false); + + // Resolve every filesystem path to absolute up front, so behavior is + // independent of the caller's cwd. This matters when the wrapper is + // invoked from a skill running in an arbitrary working directory. + const cwd = process.cwd(); + const toAbs = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p)); + + opts.outputPath = toAbs(opts.outputPath); if (promptFile) { opts.prompt = ""; // will be loaded later - (opts as any).__promptFile = promptFile; - } - if (!path.isAbsolute(opts.outputPath)) { - opts.outputPath = path.resolve(process.cwd(), opts.outputPath); + (opts as any).__promptFile = toAbs(promptFile); } + opts.refImages = opts.refImages.map(toAbs); + if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir); + if (opts.logFile) opts.logFile = toAbs(opts.logFile); + return opts; } diff --git a/scripts/codex-imagegen/spawn.ts b/scripts/codex-imagegen/spawn.ts index 1e79caf..f422c54 100644 --- a/scripts/codex-imagegen/spawn.ts +++ b/scripts/codex-imagegen/spawn.ts @@ -16,7 +16,16 @@ export async function runCodexExec(input: SpawnInput): Promise { const logDir = await mkdtemp(path.join(tmpdir(), "codex-imggen-")); const rawLogPath = path.join(logDir, "stream.jsonl"); - const args = ["exec", "--json", "--sandbox", "danger-full-access"]; + // --skip-git-repo-check: lets the wrapper run from non-git cwds + // (e.g. /tmp, or a skill installed under ~/.claude/plugins/...). + // Without it, codex refuses with "Not inside a trusted directory". + const args = [ + "exec", + "--json", + "--sandbox", + "danger-full-access", + "--skip-git-repo-check", + ]; for (const img of input.refImages ?? []) { args.push("--image", img); } diff --git a/skills/baoyu-cover-image/SKILL.md b/skills/baoyu-cover-image/SKILL.md index b4effdb..d62b0a7 100644 --- a/skills/baoyu-cover-image/SKILL.md +++ b/skills/baoyu-cover-image/SKILL.md @@ -29,17 +29,17 @@ When this skill needs to render an image, resolve the backend in this order: 2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it. 3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available): - **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`. - - **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime does NOT expose a native `imagegen` skill but the `codex` CLI is on `PATH` and `codex login` is active (e.g., Claude Code with Codex CLI installed), invoke the `codex-imagegen` wrapper. Command shape: + - **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime does NOT expose a native `imagegen` skill but the `codex` CLI is on `PATH` and `codex login` is active (e.g., Claude Code with Codex CLI installed), invoke the `codex-imagegen` wrapper. **Path resolution**: `scripts/codex-imagegen.sh` lives at the plugin/repo root, NOT relative to your shell cwd. From this `SKILL.md`'s base directory, the wrapper is at `../../scripts/codex-imagegen.sh` — resolve to an absolute path before invoking. Command shape: ```bash - ./scripts/codex-imagegen.sh \ + /scripts/codex-imagegen.sh \ --image \ - --prompt-file \ + --prompt-file \ --aspect \ - [--ref ]... \ + [--ref ]... \ [--cache-dir ~/.cache/baoyu-codex-imagegen] \ - [--log-file ] + [--log-file ] ``` - Parse the single-line JSON on stdout. On `{"status":"ok",...}` proceed to Step 5. On `{"status":"error","error_kind":...}` report the `error_kind` to the user and (if retryable) ask whether to retry or fall back to another backend. The wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` needed. + All input paths to the wrapper are auto-resolved against the wrapper's `process.cwd()` if you pass relative ones, but agents should pass absolute paths to be robust against cwd drift. Parse the single-line JSON on stdout. On `{"status":"ok",...}` proceed to Step 5. On `{"status":"error","error_kind":...}` report the `error_kind` to the user and (if retryable) ask whether to retry or fall back to another backend. The wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` needed. - **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way. - Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it. - Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions. @@ -226,7 +226,7 @@ Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](r - `direct` usage → pass via `--ref` (use ref-capable backend) - `style`/`palette` → extract traits, append to prompt 5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio. - - **`codex-imagegen`**: run `./scripts/codex-imagegen.sh --image --prompt-file prompts/01-cover-[slug].md --aspect ` (add `--ref ` per reference, `--cache-dir ~/.cache/baoyu-codex-imagegen` to enable the idempotency cache). Read the stdout JSON; act on `status` and `error_kind`. + - **`codex-imagegen`**: invoke `/scripts/codex-imagegen.sh` (NOT a cwd-relative `./scripts/...` — resolve the absolute path from this skill's base directory: `../../scripts/codex-imagegen.sh`) with `--image ` `--prompt-file ` `--aspect ` (add `--ref ` per reference, `--cache-dir ~/.cache/baoyu-codex-imagegen` to enable the idempotency cache). All input paths to the wrapper are auto-resolved against its `process.cwd()` if relative, but passing absolutes is more robust. Read the stdout JSON; act on `status` and `error_kind`. - **Codex `imagegen` (native)** or other runtime-native tools / `baoyu-imagine` skill: per the rule in `## Image Generation Tools` above. 6. On failure: auto-retry once