diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 86cc73f..e604a84 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Skills shared by Baoyu for improving daily work efficiency", - "version": "1.117.5" + "version": "1.118.0" }, "plugins": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 04dd2da..fa6cfcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ English | [中文](./CHANGELOG.zh.md) +## 1.118.0 - 2026-05-21 + +### Features +- `codex-imagegen`: new image-generation backend for non-Codex runtimes (e.g., Claude Code) — spawns `codex exec --json --sandbox danger-full-access` and delegates to Codex CLI's built-in `image_gen` tool, so no `OPENAI_API_KEY` is required. Ships with idempotency cache, file-lock concurrency control, JSONL event-stream parsing, PNG magic-byte validation, and exponential-backoff retries (by @yelban, #158) +- `baoyu-cover-image`: wire `SKILL.md` to call the `codex-imagegen` wrapper when `preferred_image_backend: codex-imagegen` is set, with `--timeout` documented for slow networks + +### Refactor +- `codex-imagegen`: enforce `--prompt` / `--prompt-file` mutual exclusion in code (was docs-only) +- `codex-imagegen`: replace `(opts as any).__promptFile` hack with a typed `promptFile` field on `CliOptions` +- `codex-imagegen`: replace inline `cp|mv ... generated_images` regex with the shared `findCpToTarget` helper +- `codex-imagegen`: propagate `attempts` on error responses (previously hardcoded to `0`) +- `codex-imagegen`: drop dead `parseFinalJson()` + matching test (wrapper ignores agent-reported JSON in favor of disk verification) + +### Security +- `codex-imagegen`: reject `--image` / `--ref` paths containing shell metacharacters before interpolating them into the agent instruction sent to `codex exec --sandbox danger-full-access` + +### Credits +- `codex-imagegen` backend contributed by @yelban (#158) + ## 1.117.5 - 2026-05-21 ### Credits diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 75a7d7b..63cd20c 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -2,6 +2,25 @@ [English](./CHANGELOG.md) | 中文 +## 1.118.0 - 2026-05-21 + +### 新功能 +- `codex-imagegen`:新增面向非 Codex 运行时(如 Claude Code)的图像生成后端 —— 通过 `codex exec --json --sandbox danger-full-access` 调用 Codex CLI 内置的 `image_gen` 工具,无需 `OPENAI_API_KEY`。内置幂等缓存、文件锁并发控制、JSONL 事件流解析、PNG 魔术字节校验和指数退避重试 (by @yelban, #158) +- `baoyu-cover-image`:在 `SKILL.md` 中接入 `codex-imagegen` 包装脚本(当 `preferred_image_backend: codex-imagegen` 时生效),并补充慢网络下的 `--timeout` 参数说明 + +### 重构 +- `codex-imagegen`:在代码中强制校验 `--prompt` 与 `--prompt-file` 互斥(此前仅在文档说明) +- `codex-imagegen`:将 `(opts as any).__promptFile` 这一 hack 改为 `CliOptions` 上类型化的 `promptFile` 字段 +- `codex-imagegen`:用复用的 `findCpToTarget` 辅助函数替换内联的 `cp|mv ... generated_images` 正则 +- `codex-imagegen`:错误返回时正确透传 `attempts`(此前硬编码为 `0`) +- `codex-imagegen`:删除无用的 `parseFinalJson()` 函数及对应测试(包装脚本以磁盘校验为准,不再依赖 agent 自报 JSON) + +### 安全 +- `codex-imagegen`:在拼入发往 `codex exec --sandbox danger-full-access` 的 agent 指令前,拒绝包含 shell 元字符的 `--image` / `--ref` 路径 + +### 致谢 +- `codex-imagegen` 后端由 @yelban 贡献 (#158) + ## 1.117.5 - 2026-05-21 ### 致谢 diff --git a/scripts/codex-imagegen/main.ts b/scripts/codex-imagegen/main.ts index 5de567f..39f36fd 100644 --- a/scripts/codex-imagegen/main.ts +++ b/scripts/codex-imagegen/main.ts @@ -5,8 +5,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 { hasImageGenInvocation } from "./parser.ts"; -import { codexHome, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts"; +import { findCpToTarget, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts"; import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts"; import { JsonLogger } from "./logger.ts"; @@ -34,9 +33,22 @@ Options: Stdout: single JSON line on success or failure. `; +const SHELL_METACHAR = /[;|&`$<>\n\r()'"]/; + +function assertSafePath(label: string, value: string): void { + if (SHELL_METACHAR.test(value)) { + throw new GenError( + "invalid_args", + `${label} contains shell metacharacters and would be unsafe to interpolate into the codex instruction: ${value}`, + false, + ); + } +} + function parseArgs(argv: string[]): CliOptions { const opts: CliOptions = { prompt: "", + promptFile: null, outputPath: "", aspect: "1:1", refImages: [], @@ -47,13 +59,12 @@ function parseArgs(argv: string[]): CliOptions { 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 "--prompt-file": opts.promptFile = next(); break; case "--image": opts.outputPath = next(); break; case "--aspect": opts.aspect = next(); break; case "--ref": opts.refImages.push(next()); break; @@ -70,7 +81,12 @@ 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); + if (opts.prompt && opts.promptFile) { + throw new GenError("invalid_args", "--prompt and --prompt-file are mutually exclusive", false); + } + if (!opts.prompt && !opts.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 @@ -79,20 +95,24 @@ function parseArgs(argv: string[]): CliOptions { 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 = toAbs(promptFile); - } + if (opts.promptFile) opts.promptFile = toAbs(opts.promptFile); opts.refImages = opts.refImages.map(toAbs); if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir); if (opts.logFile) opts.logFile = toAbs(opts.logFile); + // The output and ref paths are interpolated raw into the agent instruction + // sent to `codex exec --sandbox danger-full-access`. A path containing shell + // metacharacters could be misread by the agent's shell when it cp's the + // result into place. Reject upfront rather than trusting the agent to quote. + assertSafePath("--image path", opts.outputPath); + for (const ref of opts.refImages) assertSafePath("--ref path", ref); + return opts; } async function loadPrompt(opts: CliOptions): Promise { if (opts.prompt) return opts.prompt; - const file = (opts as any).__promptFile as string; + const file = opts.promptFile!; try { return await readFile(file, "utf-8"); } catch { @@ -157,11 +177,8 @@ async function attemptGenerate( // 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) { + // 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}`); } } @@ -218,8 +235,10 @@ async function generate(opts: CliOptions, log: JsonLogger): Promise 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( diff --git a/scripts/codex-imagegen/parser.ts b/scripts/codex-imagegen/parser.ts index c816885..0953d45 100644 --- a/scripts/codex-imagegen/parser.ts +++ b/scripts/codex-imagegen/parser.ts @@ -62,20 +62,3 @@ function deriveToolName(item: any): string { 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/types.ts b/scripts/codex-imagegen/types.ts index f5afb4e..671b5d2 100644 --- a/scripts/codex-imagegen/types.ts +++ b/scripts/codex-imagegen/types.ts @@ -1,5 +1,6 @@ export interface CliOptions { prompt: string; + promptFile: string | null; outputPath: string; aspect: string; refImages: string[]; @@ -70,6 +71,7 @@ export const RETRYABLE: ReadonlySet = new Set([ ]); export class GenError extends Error { + attempts?: number; constructor(public kind: ErrorKind, message: string, public retryable?: boolean) { super(message); this.retryable = retryable ?? RETRYABLE.has(kind); diff --git a/skills/baoyu-cover-image/SKILL.md b/skills/baoyu-cover-image/SKILL.md index d62b0a7..378fb35 100644 --- a/skills/baoyu-cover-image/SKILL.md +++ b/skills/baoyu-cover-image/SKILL.md @@ -36,9 +36,11 @@ When this skill needs to render an image, resolve the backend in this order: --prompt-file \ --aspect \ [--ref ]... \ + [--timeout ] \ [--cache-dir ~/.cache/baoyu-codex-imagegen] \ [--log-file ] ``` + `--timeout` defaults to 300000 (5 min) per codex exec attempt; raise it (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts. 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. @@ -226,7 +228,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`**: 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`**: 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, `--timeout ` to override the default 300000 / 5-min per-attempt limit on slow networks). 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