chore: release v1.118.0

This commit is contained in:
Jim Liu 宝玉
2026-05-21 16:56:17 -05:00
parent 6026b619f0
commit adbfa3036b
8 changed files with 84 additions and 51 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.117.5"
"version": "1.118.0"
},
"plugins": [
{
+19
View File
@@ -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
+19
View File
@@ -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
### 致谢
+39 -18
View File
@@ -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<string> {
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<GenerateResu
const instruction = buildInstruction(prompt, opts);
let lastErr: GenError | null = null;
let lastAttempt = 0;
try {
for (let attempt = 1; attempt <= opts.retries + 1; attempt++) {
lastAttempt = attempt;
try {
const result = await attemptGenerate(opts, instruction, attempt, log);
@@ -259,7 +278,9 @@ async function generate(opts: CliOptions, log: JsonLogger): Promise<GenerateResu
await lock.release();
}
throw lastErr ?? new GenError("spawn_failed", "Unknown failure");
const err = lastErr ?? new GenError("spawn_failed", "Unknown failure");
err.attempts = lastAttempt;
throw err;
}
async function main() {
@@ -282,14 +303,14 @@ async function main() {
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 });
await log.error("failed", { kind: err.kind, error: err.message, attempts: err.attempts ?? 0 });
const out: GenerateResult = {
status: "error",
path: opts.outputPath,
bytes: 0,
elapsed_seconds: 0,
thread_id: null,
attempts: 0,
attempts: err.attempts ?? 0,
cached: false,
usage: null,
tool_calls: [],
+1 -14
View File
@@ -1,5 +1,5 @@
import { test, expect } from "bun:test";
import { parseEventStream, hasImageGenInvocation, parseFinalJson } from "./parser.ts";
import { parseEventStream, hasImageGenInvocation } from "./parser.ts";
const REAL_PoC_STREAM = `{"type":"thread.started","thread_id":"019e40d3-30e3-7030-874d-773bc0d6d1eb"}
{"type":"turn.started"}
@@ -41,19 +41,6 @@ test("hasImageGenInvocation finds shell calls touching 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(
-17
View File
@@ -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;
}
+2
View File
@@ -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<ErrorKind> = 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);
+3 -1
View File
@@ -36,9 +36,11 @@ When this skill needs to render an image, resolve the backend in this order:
--prompt-file <absolute_path_to_prompts/NN-cover-[slug].md> \
--aspect <ratio> \
[--ref <absolute_file>]... \
[--timeout <ms>] \
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
[--log-file <absolute_jsonl_log_path>]
```
`--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 `<ABSOLUTE_PLUGIN_ROOT>/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 <ABSOLUTE_output>` `--prompt-file <ABSOLUTE_prompts/01-cover-[slug].md>` `--aspect <ratio>` (add `--ref <ABSOLUTE_file>` 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 `<ABSOLUTE_PLUGIN_ROOT>/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 <ABSOLUTE_output>` `--prompt-file <ABSOLUTE_prompts/01-cover-[slug].md>` `--aspect <ratio>` (add `--ref <ABSOLUTE_file>` per reference, `--cache-dir ~/.cache/baoyu-codex-imagegen` to enable the idempotency cache, `--timeout <ms>` 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