misonL commited on
Commit
32151b6
·
verified ·
1 Parent(s): 9ffa416

Deploy 574ece8 to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@574ece844389b5e91bb1828dc3af015a64845e4a

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +1 -1
  2. .gitignore +3 -0
  3. AGENTS.md +2 -0
  4. CHANGELOG.md +4 -4
  5. README.md +57 -6
  6. docs/deployment/huggingface-space-free.md +134 -12
  7. docs/superpowers/plans/2026-05-15-image-download-share.md +20 -20
  8. package.json +5 -1
  9. scripts/agent-skill-scripts.test.mjs +185 -0
  10. scripts/check-node-syntax.mjs +39 -0
  11. scripts/doctor-hf-space.mjs +299 -0
  12. scripts/hf-space-doctor-utils.mjs +166 -0
  13. scripts/hf-space-doctor-utils.test.mjs +101 -0
  14. scripts/init-hf-space-access.mjs +114 -0
  15. scripts/init-hf-space-access.test.mjs +108 -0
  16. scripts/keepalive-hf-space.mjs +10 -1
  17. scripts/keepalive-hf-space.test.mjs +37 -0
  18. scripts/smoke-hf-space-memory.mjs +9 -0
  19. scripts/sync-hf-space-secret.mjs +119 -39
  20. scripts/sync-hf-space-secret.test.mjs +151 -0
  21. skills/gpt-image-playground-agent/SKILL.md +68 -11
  22. skills/gpt-image-playground-agent/references/api.md +131 -3
  23. skills/gpt-image-playground-agent/scripts/edit-image.mjs +131 -31
  24. skills/gpt-image-playground-agent/scripts/generate-image.mjs +373 -76
  25. skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +71 -0
  26. skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs +253 -0
  27. src/app/api/agent/agent-routes.test.ts +466 -2
  28. src/app/api/agent/images/edit/route.ts +5 -5
  29. src/app/api/agent/images/generate/route.ts +4 -2
  30. src/app/api/agent/jobs/[id]/result/route.ts +51 -0
  31. src/app/api/agent/jobs/[id]/route.ts +32 -0
  32. src/app/api/agent/jobs/images/generate/route.ts +78 -0
  33. src/app/api/agent/openapi.json/route.ts +1 -1
  34. src/app/api/auth-verify/route.test.ts +2 -2
  35. src/app/api/auth-verify/route.ts +1 -1
  36. src/app/api/image-delete/route.ts +4 -4
  37. src/app/api/image-route.test.ts +4 -4
  38. src/app/api/images/route.ts +4 -4
  39. src/app/api/logs/route.test.ts +3 -3
  40. src/app/api/logs/route.ts +1 -1
  41. src/app/api/shares/[token]/content/route.ts +4 -1
  42. src/app/api/shares/route.test.ts +68 -3
  43. src/app/api/shares/route.ts +15 -1
  44. src/app/page.tsx +2 -2
  45. src/components/password-dialog.tsx +1 -1
  46. src/lib/agent-api-contracts.test.ts +117 -10
  47. src/lib/agent-api-contracts.ts +175 -331
  48. src/lib/agent-auth.test.ts +4 -4
  49. src/lib/agent-auth.ts +1 -1
  50. src/lib/agent-image-service.test.ts +6 -0
.env.example CHANGED
@@ -49,7 +49,7 @@ OPENAI_API_BASE_URL=
49
  # ENABLE_RESPONSES_IMAGE_BACKEND=true
50
  # OPENAI_RESPONSES_API_MODEL=
51
 
52
- # 可选:给网页加一个访问码。公网部署时建议一定要设置。
53
  APP_PASSWORD=
54
 
55
  # 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。
 
49
  # ENABLE_RESPONSES_IMAGE_BACKEND=true
50
  # OPENAI_RESPONSES_API_MODEL=
51
 
52
+ # 可选:给网页加一个访问码。公网部署时建议一定要设置。
53
  APP_PASSWORD=
54
 
55
  # 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。
.gitignore CHANGED
@@ -49,3 +49,6 @@ generated-images/
49
 
50
  # generated verification artifacts
51
  /artifacts/
 
 
 
 
49
 
50
  # generated verification artifacts
51
  /artifacts/
52
+
53
+ # local agent/tool configuration
54
+ .antigravitycli/
AGENTS.md CHANGED
@@ -86,6 +86,7 @@
86
  ```bash
87
  npm test
88
  npm run lint
 
89
  npm run build
90
  git diff --check
91
  ```
@@ -125,6 +126,7 @@ docker compose up -d --build
125
  ```bash
126
  npm test
127
  npm run lint
 
128
  npm run build
129
  git diff --check
130
  ```
 
86
  ```bash
87
  npm test
88
  npm run lint
89
+ npm run lint:scripts
90
  npm run build
91
  git diff --check
92
  ```
 
126
  ```bash
127
  npm test
128
  npm run lint
129
+ npm run lint:scripts
130
  npm run build
131
  git diff --check
132
  ```
CHANGELOG.md CHANGED
@@ -38,7 +38,7 @@
38
  ### 变更
39
 
40
  - 前端提交图片请求前会刷新运行时能力,并按用户自填 API Key 或服务端渠道池选择不同并发窗口。
41
- - 图片请求构造、流式响应处理和码重试参数改为可复用流程,批处理和单请求共用同一套错误处理。
42
  - README 与 `.env.example` 补充流式批处理、单 credential 并发上限和渠道失败冷却配置说明。
43
  - ESLint 配置显式绑定 Next.js 根目录,TypeScript 配置排除 `dist` 构建产物。
44
 
@@ -48,7 +48,7 @@
48
  - 修正服务端渠道池全部冷却时前端仍可能按旧推荐并发继续批处理的问题。
49
  - 修正 OpenAI SDK 将连接错误放在嵌套 `cause` 中时未触发 channel 冷却的问题。
50
  - 兼容上游错误中的 `requestID` 和 `requestId` 字段,并确保公开能力接口不返回上游错误消息。
51
- - 修正码弹窗重试只保存表单数据、未保存请求模式和流式参数的问题。
52
 
53
  ## [1.2.0] - 2026-05-11
54
 
@@ -58,12 +58,12 @@
58
  - 支持 `sticky`、`round_robin`、`random` 三种服务端凭证路由策略。
59
  - 增加渠道解析、路由选择、有效凭证解析的单元测试。
60
  - 增加仓库执行约束文档 `AGENTS.md`。
61
- - 增加服务端运行时工具测试,覆盖码哈希校验、请求来源选择、批次 ID 和图片文件名生成。
62
  - 增加应用日志工具测试,覆盖日志等级规范化、默认等级、无效配置回退和上下文透传。
63
  ### 变更
64
 
65
  - 统一本地服务默认使用 `4783` 端口启动。
66
- - 抽取服务端运行时工具,复用码校验、输出目录和文件名生成逻辑。
67
  - 将服务端请求路径日志收敛为可配置日志等级,生产环境默认只输出警告和错误。
68
  - 缓存日志等级解析结果,减少热路径重复计算。
69
 
 
38
  ### 变更
39
 
40
  - 前端提交图片请求前会刷新运行时能力,并按用户自填 API Key 或服务端渠道池选择不同并发窗口。
41
+ - 图片请求构造、流式响应处理和访问码重试参数改为可复用流程,批处理和单请求共用同一套错误处理。
42
  - README 与 `.env.example` 补充流式批处理、单 credential 并发上限和渠道失败冷却配置说明。
43
  - ESLint 配置显式绑定 Next.js 根目录,TypeScript 配置排除 `dist` 构建产物。
44
 
 
48
  - 修正服务端渠道池全部冷却时前端仍可能按旧推荐并发继续批处理的问题。
49
  - 修正 OpenAI SDK 将连接错误放在嵌套 `cause` 中时未触发 channel 冷却的问题。
50
  - 兼容上游错误中的 `requestID` 和 `requestId` 字段,并确保公开能力接口不返回上游错误消息。
51
+ - 修正访问码弹窗重试只保存表单数据、未保存请求模式和流式参数的问题。
52
 
53
  ## [1.2.0] - 2026-05-11
54
 
 
58
  - 支持 `sticky`、`round_robin`、`random` 三种服务端凭证路由策略。
59
  - 增加渠道解析、路由选择、有效凭证解析的单元测试。
60
  - 增加仓库执行约束文档 `AGENTS.md`。
61
+ - 增加服务端运行时工具测试,覆盖访问码哈希校验、请求来源选择、批次 ID 和图片文件名生成。
62
  - 增加应用日志工具测试,覆盖日志等级规范化、默认等级、无效配置回退和上下文透传。
63
  ### 变更
64
 
65
  - 统一本地服务默认使用 `4783` 端口启动。
66
+ - 抽取服务端运行时工具,复用访问码校验、输出目录和文件名生成逻辑。
67
  - 将服务端请求路径日志收敛为可配置日志等级,生产环境默认只输出警告和错误。
68
  - 缓存日志等级解析结果,减少热路径重复计算。
69
 
README.md CHANGED
@@ -131,7 +131,7 @@ http://localhost:4783
131
  - 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。
132
  - 发送到编辑:从生成结果或历史记录直接进入编辑模式。
133
  - 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。
134
- - 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加入口密码。
135
  - Agent 状态后端:支持 `memory`、`sqlite`、`postgres`,覆盖临时演示、单实例和集中状态库场景。
136
  - 双语和主题:支持中文、英文、亮色、暗色。
137
  - 两种图片存储模式:服务端文件系统或浏览器 IndexedDB。
@@ -199,6 +199,9 @@ Agent API 面向自动化调用,不要求 Agent 模拟网页表单。接口统
199
  | `GET /api/agent/openapi.json` | 获取机器可读 OpenAPI 描述。 |
200
  | `POST /api/agent/images/generate` | JSON 文生图,默认只返回文件路径和元数据。 |
201
  | `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 PNG mask。 |
 
 
 
202
  | `GET /api/agent/artifacts/{id}` | 查询产物元数据。 |
203
  | `GET /api/agent/artifacts/{id}/content` | 下载产物图片内容。 |
204
  | `DELETE /api/agent/artifacts/{id}` | 删除产物和元数据。 |
@@ -209,6 +212,13 @@ Agent 请求必须带 `Idempotency-Key`,避免超时重试造成重复出图
209
  Authorization: Bearer your-agent-token
210
  ```
211
 
 
 
 
 
 
 
 
212
  生成示例:
213
 
214
  ```bash
@@ -294,7 +304,7 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case
294
  | `OPENAI_RESPONSES_API_MODEL` | 否 | 无 | Responses API 实验后端的 `/responses` 顶层模型。启用 `imageBackend=responses` 时必须设置,或在请求中传 `responsesModel`。 |
295
  | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 否 | `1` | 每个服务端 credential 允许同时执行的流式任务数。 |
296
  | `OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 否 | `60000` | 服务端 credential 或 channel 失败后的默认冷却时间。 |
297
- | `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问码。 |
298
  | `AGENT_API_TOKEN` | 否 | 无 | 设置后,`/api/agent/*` 需要 Bearer token。 |
299
  | `AGENT_STATE_BACKEND` | 否 | `sqlite` | Agent 状态后端,可选 `memory`、`sqlite` 或 `postgres`。 |
300
  | `AGENT_SQLITE_PATH` | 否 | `generated-images/.agent-state/agent.sqlite` | SQLite 状态库路径。 |
@@ -305,7 +315,7 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case
305
  | `AGENT_REQUEST_LEASE_MS` | 否 | `600000` | Agent 请求运行锁租约时间。 |
306
  | `AGENT_REQUEST_TTL_SECONDS` | 否 | `86400` | 幂等请求记录保留秒数。 |
307
  | `AGENT_RECOVERY_INTERVAL_MS` | 否 | `30000` | Agent 请求触发轻量 recovery 的最小间隔。 |
308
- | `AGENT_PUBLIC_BASE_URL` | 否 | `/` | OpenAPI `servers[0].url`,供外部 Agent 生成客户端时使用。 |
309
  | `APP_LOG_LEVEL` | 否 | 生产环境 `warn`,其他环境 `info` | 服务端日志等级,可选 `debug`、`info`、`warn`、`error`。 |
310
  | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | 否 | `fs` | 可选 `fs` 或 `indexeddb`。 |
311
 
@@ -441,9 +451,47 @@ NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb
441
 
442
  本仓库也提供 `docker-compose.memory.yml` 作为本地模拟模板;Hugging Face Docker Space 通常直接通过 Space Variables 和 Secrets 设置环境变量,不需要提交 `.env.local`。完整部署步骤见 [Hugging Face Space 免费层部署](./docs/deployment/huggingface-space-free.md)。
443
 
444
- 免费 CPU Basic 会在长时间无访问后休眠。本仓库提供 `.github/workflows/hf-space-keepalive.yml`,默认每 6 小时访问一次 `/api/auth-status`,只做只读 keepalive,不携带码或 token,不触发生图。若 Space 地址变化,在 GitHub 仓库 Variables 中设置 `HF_SPACE_KEEPALIVE_URL`。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
 
446
- 如果修改了本机访问记录文件里的 `APP_PASSWORD`,可执行 `npm run sync-secret:hf-space` 同步到 Hugging Face Space Secret、重启并验证新码。脚本默认读取 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,不会回显 Secret 值。
447
 
448
  `memory` 模式不创建 SQLite 文件,也不连接 PostgreSQL。它只适合无持久化演示、短会话调试或可接受重启丢失 Agent 幂等状态的环境;容器重启后请求记录、artifact 元数据和 replay 状态都会清空。Web 图片二进制按 `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 保存;HF 免费层推荐 `indexeddb`,让网页结果保存在浏览器侧。Agent API 产物仍写入容器临时文件系统,以便提供 `content_url` 下载。
449
 
@@ -494,10 +542,13 @@ docker logs -f gpt-image-playground-customer
494
  | `npm run dev` | 启动本地开发服务。 |
495
  | `npm run build` | 执行生产构建。 |
496
  | `npm run start` | 启动生产模式服务。 |
 
 
497
  | `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
498
- | `npm run sync-secret:hf-space` | 从本机访问记录文件同步 HF Space Secret,并验证页面码。 |
499
  | `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端和 Agent API 契约。 |
500
  | `npm run lint` | 检查 `src/` 代码。 |
 
501
  | `npm run format` | 格式化 `src/` 下的 TypeScript 和 React 文件。 |
502
 
503
  ## 常见问题
 
131
  - 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。
132
  - 发送到编辑:从生成结果或历史记录直接进入编辑模式。
133
  - 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。
134
+ - 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加访问码。
135
  - Agent 状态后端:支持 `memory`、`sqlite`、`postgres`,覆盖临时演示、单实例和集中状态库场景。
136
  - 双语和主题:支持中文、英文、亮色、暗色。
137
  - 两种图片存储模式:服务端文件系统或浏览器 IndexedDB。
 
199
  | `GET /api/agent/openapi.json` | 获取机器可读 OpenAPI 描述。 |
200
  | `POST /api/agent/images/generate` | JSON 文生图,默认只返回文件路径和元数据。 |
201
  | `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 PNG mask。 |
202
+ | `POST /api/agent/jobs/images/generate` | 创建文生图 job,适合 4K/high 或长耗时请求。 |
203
+ | `GET /api/agent/jobs/{id}` | 轮询 job 状态。 |
204
+ | `GET /api/agent/jobs/{id}/result` | 读取完成后的标准图片响应,运行中返回可重试错误。 |
205
  | `GET /api/agent/artifacts/{id}` | 查询产物元数据。 |
206
  | `GET /api/agent/artifacts/{id}/content` | 下载产物图片内容。 |
207
  | `DELETE /api/agent/artifacts/{id}` | 删除产物和元数据。 |
 
212
  Authorization: Bearer your-agent-token
213
  ```
214
 
215
+ `AGENT_API_TOKEN` 存在时 Agent API 只接受 Bearer token,不会回退到页面访问码哈希。只有未设置 `AGENT_API_TOKEN` 且设置了 `APP_PASSWORD` 时,Agent API 才接受 `X-App-Password-Hash`;实际可用方案以 `/api/agent/capabilities` 的 `auth.schemes` 为准。
216
+
217
+ 同一个 `Idempotency-Key` 如果已进入终态 `failed`,再次请求只会回放该失败,不会重新执行。终态失败回放会返回 `retryable=false`,并保留错误码、上游状态和脱敏诊断字段;需要重新尝试时,应创建新的业务操作和新的 `Idempotency-Key`。
218
+
219
+ Job polling 当前是同一 Next.js 服务实例内的后台任务,结果和错误会写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应继续按状态端点和结构化错误处理,必要时用相同 `Idempotency-Key` 重建同一业务操作。
220
+ 运行中的 job 会定时刷新请求 lease,避免高质量长耗时上游调用仍在执行时被 recovery 误判为孤儿请求。
221
+
222
  生成示例:
223
 
224
  ```bash
 
304
  | `OPENAI_RESPONSES_API_MODEL` | 否 | 无 | Responses API 实验后端的 `/responses` 顶层模型。启用 `imageBackend=responses` 时必须设置,或在请求中传 `responsesModel`。 |
305
  | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 否 | `1` | 每个服务端 credential 允许同时执行的流式任务数。 |
306
  | `OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 否 | `60000` | 服务端 credential 或 channel 失败后的默认冷却时间。 |
307
+ | `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问码。 |
308
  | `AGENT_API_TOKEN` | 否 | 无 | 设置后,`/api/agent/*` 需要 Bearer token。 |
309
  | `AGENT_STATE_BACKEND` | 否 | `sqlite` | Agent 状态后端,可选 `memory`、`sqlite` 或 `postgres`。 |
310
  | `AGENT_SQLITE_PATH` | 否 | `generated-images/.agent-state/agent.sqlite` | SQLite 状态库路径。 |
 
315
  | `AGENT_REQUEST_LEASE_MS` | 否 | `600000` | Agent 请求运行锁租约时间。 |
316
  | `AGENT_REQUEST_TTL_SECONDS` | 否 | `86400` | 幂等请求记录保留秒数。 |
317
  | `AGENT_RECOVERY_INTERVAL_MS` | 否 | `30000` | Agent 请求触发轻量 recovery 的最小间隔。 |
318
+ | `AGENT_PUBLIC_BASE_URL` | 否 | `/` | OpenAPI `servers[0].url`,供外部 Agent 生成客户端时使用;配置时必须是绝对 `http`/`https` URL,不能包含凭据、查询参数或片段。 |
319
  | `APP_LOG_LEVEL` | 否 | 生产环境 `warn`,其他环境 `info` | 服务端日志等级,可选 `debug`、`info`、`warn`、`error`。 |
320
  | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | 否 | `fs` | 可选 `fs` 或 `indexeddb`。 |
321
 
 
451
 
452
  本仓库也提供 `docker-compose.memory.yml` 作为本地模拟模板;Hugging Face Docker Space 通常直接通过 Space Variables 和 Secrets 设置环境变量,不需要提交 `.env.local`。完整部署步骤见 [Hugging Face Space 免费层部署](./docs/deployment/huggingface-space-free.md)。
453
 
454
+ 免费 CPU Basic 会在长时间无访问后休眠。本仓库提供 `.github/workflows/hf-space-keepalive.yml`,默认每 6 小时访问一次 `/api/auth-status`,只做只读 keepalive,不携带访问码或 token,不触发生图。若 Space 地址变化,在 GitHub 仓库 Variables 中设置 `HF_SPACE_KEEPALIVE_URL`。
455
+
456
+ 全新电脑从 0 开始时,先完成系统级前置条件:
457
+
458
+ ```bash
459
+ node --version
460
+ npm --version
461
+ hf --help
462
+ hf auth login
463
+ npm install
464
+ ```
465
+
466
+ 要求 Node.js 20 或更高版本。Hugging Face CLI 安装方式以官方文档为准;当前官方入口是 `hf` 命令,登录使用 Hugging Face Access Token。
467
+
468
+ 不同用户首次接手自己的 Space 时,先生成本机访问记录文件:
469
+
470
+ ```bash
471
+ npm run init-access:hf-space -- \
472
+ --space-id <namespace>/<space-name> \
473
+ --space-url https://<user>-<space>.hf.space
474
+ ```
475
+
476
+ 脚本会写入 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,生成访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`,并记录 `HF_SPACE_ID`、`HF_SPACE_URL` 和默认同步 key。脚本不会打印 Secret 值,且默认不覆盖已有文件。
477
+ `HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。
478
+
479
+ 创建本机访问记录文件不需要 Hugging Face 账号密码。同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token:
480
+
481
+ ```bash
482
+ hf auth whoami
483
+ hf auth login
484
+ ```
485
+
486
+ 如果不确定当前机器缺什么,先运行只读诊断:
487
+
488
+ ```bash
489
+ npm run doctor:hf-space
490
+ ```
491
+
492
+ 该命令会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置;不会写远端 Secret、不会重启 Space、不会打印 Secret 值。
493
 
494
+ 如果修改了本机访问记录文件里的 `APP_PASSWORD`,可执行 `npm run sync-secret:hf-space` 同步到 Hugging Face Space Secret、重启并验证新访问码。脚本默认读取 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,并要求目标 Space 写在 access 文件或环境变量里,避免同用户误写到示例 Space;输出不会回显 Secret 值。
495
 
496
  `memory` 模式不创建 SQLite 文件,也不连接 PostgreSQL。它只适合无持久化演示、短会话调试或可接受重启丢失 Agent 幂等状态的环境;容器重启后请求记录、artifact 元数据和 replay 状态都会清空。Web 图片二进制按 `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 保存;HF 免费层推荐 `indexeddb`,让网页结果保存在浏览器侧。Agent API 产物仍写入容器临时文件系统,以便提供 `content_url` 下载。
497
 
 
542
  | `npm run dev` | 启动本地开发服务。 |
543
  | `npm run build` | 执行生产构建。 |
544
  | `npm run start` | 启动生产模式服务。 |
545
+ | `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、本机 access 文件和远端配置。 |
546
+ | `npm run init-access:hf-space` | 为当前用户生成本机 HF Space 访问记录、随机访问码和 Agent token。 |
547
  | `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
548
+ | `npm run sync-secret:hf-space` | 从本机访问记录文件同步 HF Space Secret,并验证页面访问码。 |
549
  | `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端和 Agent API 契约。 |
550
  | `npm run lint` | 检查 `src/` 代码。 |
551
+ | `npm run lint:scripts` | 跨平台检查仓库脚本和 skill 脚本语法。 |
552
  | `npm run format` | 格式化 `src/` 下的 TypeScript 和 React 文件。 |
553
 
554
  ## 常见问题
docs/deployment/huggingface-space-free.md CHANGED
@@ -24,6 +24,44 @@ app_port: 4783
24
 
25
  - Docker Space 配置、Variables/Secrets 和权限说明:https://huggingface.co/docs/hub/main/spaces-sdks-docker
26
  - 免费 CPU Basic 规格说明:https://huggingface.co/docs/hub/main/spaces-gpus
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  ## Space Variables
29
 
@@ -31,23 +69,30 @@ app_port: 4783
31
 
32
  ```dotenv
33
  AGENT_STATE_BACKEND=memory
34
- AGENT_SQLITE_PATH=
35
- AGENT_DATABASE_URL=
36
- AGENT_DB_PASSWORD=
37
- AGENT_DB_PASSWORD_FILE=
38
  NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb
39
  APP_LOG_LEVEL=warn
40
  ```
41
 
42
  `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明 build arg,Hugging Face Docker Space 会把同名 Variable 作为 build arg 传入构建,并在运行期注入环境变量。
43
 
 
 
 
 
 
 
 
 
 
 
 
44
  可选:
45
 
46
  ```dotenv
47
  AGENT_PUBLIC_BASE_URL=https://<user>-<space>.hf.space
48
  ```
49
 
50
- `AGENT_PUBLIC_BASE_URL` 只影响 OpenAPI `servers[0].url`,Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。
51
 
52
  ## Space Secrets
53
 
@@ -56,11 +101,11 @@ AGENT_PUBLIC_BASE_URL=https://<user>-<space>.hf.space
56
  ```dotenv
57
  OPENAI_API_KEY=<your-api-key>
58
  OPENAI_API_BASE_URL=https://api.openai.com/v1
59
- APP_PASSWORD=<page-password>
60
  AGENT_API_TOKEN=<long-random-agent-token>
61
  ```
62
 
63
- 公网部署建议至少设置 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
64
 
65
  如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets:
66
 
@@ -74,7 +119,7 @@ OPENAI_CHANNEL_1_API_KEYS=<key-a>,<key-b>
74
  ## 手机网页使用
75
 
76
  1. 打开 Space 地址,例如 `https://<user>-<space>.hf.space`。
77
- 2. 如果配置了 `APP_PASSWORD`,输入页面访问码。
78
  3. 直接填写提示词并生图。若 Space 没有配置服务端 API Key,也可以在右上角 `API 设置` 中填写自己的 API Key 和 API URL。
79
  4. `NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb` 时,图片结果保存在当前浏览器 IndexedDB。换设备、清理浏览器数据或隐私模式退出后,本地历史可能消失。
80
 
@@ -95,6 +140,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs
95
  GPT_IMAGE_PLAYGROUND_URL=https://<user>-<space>.hf.space \
96
  GPT_IMAGE_AGENT_TOKEN=<agent-token> \
97
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
 
98
  "a product photo of a ceramic mug on a wooden table"
99
  ```
100
 
@@ -149,9 +195,77 @@ npm run keepalive:hf-space
149
 
150
  注意:keepalive 是免费层的 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应升级到付费硬件并设置永不休眠。
151
 
152
- ## 同步本机访问密码到 Space
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- 如果本机访问记录文件里的 `APP_PASSWORD` 已更新,可以用脚本同步到 HF Space Secret、重启服务并验证新密码:
 
 
 
 
 
 
 
 
 
155
 
156
  ```bash
157
  npm run sync-secret:hf-space
@@ -163,7 +277,7 @@ npm run sync-secret:hf-space
163
  ~/.cache/gpt-image-playground-customer/hf-space-access.txt
164
  ```
165
 
166
- 默认同步 `APP_PASSWORD`,不会在输出中回显码值。可通过环境变量覆盖目标或同步多个 key:
167
 
168
  ```bash
169
  HF_SPACE_ID=misonL/gpt-image-playground-customer \
@@ -173,10 +287,17 @@ HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN \
173
  npm run sync-secret:hf-space
174
  ```
175
 
 
 
 
 
 
 
176
  可选参数:
177
 
178
  - `npm run sync-secret:hf-space -- --no-restart`:只写 Secret,不重启 Space。
179
- - `npm run sync-secret:hf-space -- --skip-verify`:跳过 `/api/auth-verify` 码验证。
 
180
 
181
  ## 验证门禁
182
 
@@ -185,6 +306,7 @@ npm run sync-secret:hf-space
185
  ```bash
186
  npm test
187
  npm run lint
 
188
  npm run build
189
  npm run keepalive:hf-space
190
  npm run smoke:hf-space
 
24
 
25
  - Docker Space 配置、Variables/Secrets 和权限说明:https://huggingface.co/docs/hub/main/spaces-sdks-docker
26
  - 免费 CPU Basic 规格说明:https://huggingface.co/docs/hub/main/spaces-gpus
27
+ - Hugging Face CLI 安装和登录说明:https://huggingface.co/docs/huggingface_hub/en/guides/cli
28
+
29
+ ## 全新电脑前置条件
30
+
31
+ 全新用户、全新电脑需要先准备系统级工具。没有 Node.js 和 npm 时,仓库内 npm 脚本无法运行;没有 HF CLI 登录时,脚本无法把 Secret 写到远端 Space。
32
+
33
+ 先检查:
34
+
35
+ ```bash
36
+ node --version
37
+ npm --version
38
+ hf --help
39
+ hf auth whoami
40
+ ```
41
+
42
+ 要求:
43
+
44
+ - Node.js 20 或更高版本。
45
+ - npm 随 Node.js 一起可用。
46
+ - Hugging Face CLI 使用当前官方 `hf` 命令。
47
+ - `hf auth login` 使用 Hugging Face Access Token,不是账号密码。
48
+ - Docker 只对 `npm run smoke:hf-space` 和本地容器验证必需;只创建 txt 文件和同步 Secret 不需要 Docker。
49
+
50
+ 安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。
51
+
52
+ 第一次拉取仓库后安装依赖:
53
+
54
+ ```bash
55
+ npm install
56
+ ```
57
+
58
+ 如果不确定当前机器缺什么,运行只读诊断:
59
+
60
+ ```bash
61
+ npm run doctor:hf-space
62
+ ```
63
+
64
+ `doctor:hf-space` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置。该命令不会写远端 Secret、不会重启 Space、不会打印 Secret 值。
65
 
66
  ## Space Variables
67
 
 
69
 
70
  ```dotenv
71
  AGENT_STATE_BACKEND=memory
 
 
 
 
72
  NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb
73
  APP_LOG_LEVEL=warn
74
  ```
75
 
76
  `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明 build arg,Hugging Face Docker Space 会把同名 Variable 作为 build arg 传入构建,并在运行期注入环境变量。
77
 
78
+ 如果不使用 `memory`,再按实际状态后端追加可选变量:
79
+
80
+ ```dotenv
81
+ AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite
82
+ AGENT_DATABASE_URL=postgres://...
83
+ AGENT_DB_PASSWORD=<database-password>
84
+ AGENT_DB_PASSWORD_FILE=/path/to/password-file
85
+ ```
86
+
87
+ `AGENT_DATABASE_URL`、`AGENT_DB_PASSWORD` 和 `AGENT_DB_PASSWORD_FILE` 是 PostgreSQL 配置路径,不需要在 `memory` 模式下设置为空值。
88
+
89
  可选:
90
 
91
  ```dotenv
92
  AGENT_PUBLIC_BASE_URL=https://<user>-<space>.hf.space
93
  ```
94
 
95
+ `AGENT_PUBLIC_BASE_URL` 只影响 OpenAPI `servers[0].url`,必须填写绝对 `http`/`https` URL,不能包含凭据、查询参数或片段;Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。
96
 
97
  ## Space Secrets
98
 
 
101
  ```dotenv
102
  OPENAI_API_KEY=<your-api-key>
103
  OPENAI_API_BASE_URL=https://api.openai.com/v1
104
+ APP_PASSWORD=<page-access-code>
105
  AGENT_API_TOKEN=<long-random-agent-token>
106
  ```
107
 
108
+ 公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
109
 
110
  如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets:
111
 
 
119
  ## 手机网页使用
120
 
121
  1. 打开 Space 地址,例如 `https://<user>-<space>.hf.space`。
122
+ 2. 如果配置了 `APP_PASSWORD`,输入页面访问码。
123
  3. 直接填写提示词并生图。若 Space 没有配置服务端 API Key,也可以在右上角 `API 设置` 中填写自己的 API Key 和 API URL。
124
  4. `NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb` 时,图片结果保存在当前浏览器 IndexedDB。换设备、清理浏览器数据或隐私模式退出后,本地历史可能消失。
125
 
 
140
  GPT_IMAGE_PLAYGROUND_URL=https://<user>-<space>.hf.space \
141
  GPT_IMAGE_AGENT_TOKEN=<agent-token> \
142
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
143
+ --allow-billable \
144
  "a product photo of a ceramic mug on a wooden table"
145
  ```
146
 
 
195
 
196
  注意:keepalive 是免费层的 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应升级到付费硬件并设置永不休眠。
197
 
198
+ ## 初始化本机访问记录
199
+
200
+ 不同用户首次接手自己的 Space 时,先在本机生成访问记录文件。该文件保存在用户 home 目录下,不应提交到��库:
201
+
202
+ ```bash
203
+ npm run init-access:hf-space -- \
204
+ --space-id <namespace>/<space-name> \
205
+ --space-url https://<user>-<space>.hf.space
206
+ ```
207
+
208
+ 默认写入:
209
+
210
+ ```text
211
+ ~/.cache/gpt-image-playground-customer/hf-space-access.txt
212
+ ```
213
+
214
+ 文件会包含:
215
+
216
+ ```dotenv
217
+ HF_SPACE_ID=<namespace>/<space-name>
218
+ HF_SPACE_URL=https://<user>-<space>.hf.space
219
+ HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN
220
+ APP_PASSWORD=<generated-page-access-code>
221
+ AGENT_API_TOKEN=<generated-agent-token>
222
+ ```
223
+
224
+ `HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。
225
+
226
+ 脚本不会在输出中回显 `APP_PASSWORD` 或 `AGENT_API_TOKEN`。如果文件已存在,默认拒绝覆盖;确认要重置时使用:
227
+
228
+ ```bash
229
+ npm run init-access:hf-space -- \
230
+ --space-id <namespace>/<space-name> \
231
+ --space-url https://<user>-<space>.hf.space \
232
+ --force
233
+ ```
234
+
235
+ 创建这个 txt 文件不需要 Hugging Face 账号密码。它只保存本项目的访问码、Agent token 和 Space 目标信息。
236
+
237
+ 同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token。先检查登录状态:
238
+
239
+ ```bash
240
+ hf auth whoami
241
+ ```
242
+
243
+ 如果未登录,执行:
244
+
245
+ ```bash
246
+ hf auth login
247
+ ```
248
+
249
+ `hf auth login` 使用的是 Hugging Face Access Token,不是账号密码。不要把 HF 账号密码或 HF Access Token 写入 `hf-space-access.txt`。
250
+
251
+ 生成后可先做本机只读诊断,不写远端:
252
+
253
+ ```bash
254
+ npm run doctor:hf-space -- --skip-remote
255
+ ```
256
+
257
+ 如果诊断提示 access 文件缺少 `HF_SPACE_ID`、`HF_SPACE_URL` 或 `HF_SPACE_SECRET_KEYS`,说明本机可能已有旧格式文件。可手工补齐这些字段,或确认重置后重新生成:
258
 
259
+ ```bash
260
+ npm run init-access:hf-space -- \
261
+ --space-id <namespace>/<space-name> \
262
+ --space-url https://<user>-<space>.hf.space \
263
+ --force
264
+ ```
265
+
266
+ ## 同步本机访问码到 Space
267
+
268
+ 如果本机访问记录文件里的 `APP_PASSWORD` 已更新,可以用脚本同步到 HF Space Secret、重启服务并验证新访问码:
269
 
270
  ```bash
271
  npm run sync-secret:hf-space
 
277
  ~/.cache/gpt-image-playground-customer/hf-space-access.txt
278
  ```
279
 
280
+ 由 `init-access:hf-space` 生成的文件会让同步脚本同时同步 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。旧格式文件默认同步 `APP_PASSWORD`,不会在输出中回显访问码值。可通过环境变量覆盖目标或同步多个 key:
281
 
282
  ```bash
283
  HF_SPACE_ID=misonL/gpt-image-playground-customer \
 
287
  npm run sync-secret:hf-space
288
  ```
289
 
290
+ 同步脚本默认要求 `HF_SPACE_ID` 和 `HF_SPACE_URL` 来自 access 文件或环境变量,避免不同用户误写到仓库示例 Space。只有维护默认示例 Space 时才使用:
291
+
292
+ ```bash
293
+ npm run sync-secret:hf-space -- --use-default-target
294
+ ```
295
+
296
  可选参数:
297
 
298
  - `npm run sync-secret:hf-space -- --no-restart`:只写 Secret,不重启 Space。
299
+ - `npm run sync-secret:hf-space -- --skip-verify`:跳过 `/api/auth-verify` 访问码验证。
300
+ - `npm run sync-secret:hf-space -- --use-default-target`:允许使用脚本内置默认 Space 目标。
301
 
302
  ## 验证门禁
303
 
 
306
  ```bash
307
  npm test
308
  npm run lint
309
+ npm run lint:scripts
310
  npm run build
311
  npm run keepalive:hf-space
312
  npm run smoke:hf-space
docs/superpowers/plans/2026-05-15-image-download-share.md CHANGED
@@ -25,10 +25,10 @@
25
  - 最终验证运行 `npm test`、`npm run lint`、`npm run build`、`git diff --check`;如果本分支完成实现,还要执行 Docker 冒烟验证。
26
 
27
  **护栏指标:**
28
- - 不得把受码保护的 `/api/image/{filename}` 直接暴露为分享机制。
29
- - 不得把 API Key、密码、访问码或原始提示词写入分享 URL。
30
  - 不得破坏 Agent 产物内容路由。
31
- - 不得破坏 `http://localhost:4783` 下无码本地部署的既有行为。
32
  - 不得静默回退到 mock 图片或伪造分享成功。
33
 
34
  **采样计划:**
@@ -49,7 +49,7 @@
49
 
50
  **约束:**
51
  - 当前 `AGENTS.md` 要求中文沟通、基于事实下结论、禁止静默降级,并执行最小充分验证。
52
- - 当前脏工作区包含码和 cookie 适配相关改动。不要回滚或覆盖无关的用户改动或前序代理改动。
53
  - 保持当前 `node:test` 布局;不要引入第二套测试框架。
54
  - 代码和文档尽量使用 ASCII;不使用 Emoji 或装饰性 Unicode。
55
 
@@ -61,7 +61,7 @@
61
 
62
  **耦合说明:**
63
  - 分享创建依赖发送到编辑所使用的同一图片字节读取路径:IndexedDB blob 或 `/api/image/{filename}`。
64
- - 分享内容必须独立于页面码 cookie;否则外部接收者无法访问有效分享。
65
  - 下载是纯浏览器行为,不应要求新增服务端状态。
66
  - 分享 token 必须由 `crypto.randomBytes` 生成,不得使用 `Math.random`、时间戳或可预测输入派生。
67
  - 访问码为空或纯空白时必须按“无访问码分享”处理;非空访问码必须满足最小长度,避免弱访问码被误认为受保护分享。
@@ -102,7 +102,7 @@
102
  | --- | --- |
103
  | 复杂性原位置 | 用户目前依赖受保护图片 URL 或本地浏览器 blob 做临时下载/分享。 |
104
  | 新位置 | 分享产物移动到 `generated-images/.shares`,包含元数据、复制字节、访问码哈希和有效期。 |
105
- | 收益 | 外部分享访问不再依赖页面码 cookie 或浏览器本地 IndexedDB 状态。 |
106
  | 新成本 | 后续必须考虑分享清理和生命周期;分享元数据成为新的文件系统状态面。 |
107
  | 失效模式 | 在后续新增清理任务前,孤立分享文件或过期分享可能持续积累。 |
108
 
@@ -112,7 +112,7 @@
112
  - `src/lib/share-store.ts` 已实现分享元数据、复制内容、访问码哈希、有效期和路径限制。
113
  - `src/lib/share-store.test.ts` 已覆盖存储模块,包括受保护/公开分享、有效期、不安全 token、当前工作目录和内容路径限制。
114
  - `src/lib/server-runtime.ts` 已导出 `createAccessToken(serverPassword)` 和 `verifyAccessToken(clientAccessToken, serverPassword)`。
115
- - `src/lib/page-password-auth.ts` 已导出 `PAGE_PASSWORD_AUTH_ERROR_CODES.missing` 和 `.invalid`,对应页面码错误码。
116
  - `src/components/image-output.tsx` 当前导入 `Grid`、`Loader2`、`Send`、`Terminal` 和 `Trash2`;没有下载/分享图标或 props。
117
  - `src/components/image-output.tsx` 的动作行当前只渲染轮播控制、日志和发送到编辑。
118
  - `src/components/image-output.tsx` 当前已有 `isSingleImageView`,定义为 `typeof viewMode === 'number'`。
@@ -162,7 +162,7 @@
162
  | --- | --- | --- | --- |
163
  | 使用浏览器 blob URL 添加下载按钮 | 用户可以保存已选图片 | 提升本地导出可用性 | blob 来源错误可能下载到过期或缺失图片 |
164
  | 添加分享创建 API | 用户可以创建分享 URL | 提升外部分享能力 | 如果认证绕过不当,可能暴露受保护图片字节 |
165
- | 添加分享内容路由 | 接收者可以查看有效分享 | 启用公开读取路径 | 不得依赖页面码 cookie |
166
  | 添加访问码和有效期检查 | 无效接收者无法查看字节 | 降低未授权暴露 | 如果元数据和内容结果不一致,UI 可能变得困惑 |
167
 
168
  ## 状态模型
@@ -231,7 +231,7 @@ afterEach(async () => {
231
 
232
  function createShareRequest(form: FormData, options: { accessToken?: string | null } = {}) {
233
  const headers = new Headers();
234
- const accessToken = options.accessToken === undefined ? createAccessToken(['customer', 'password'].join('-')) : options.accessToken;
235
  if (accessToken) headers.set('Cookie', `gptImageAccess=${accessToken}`);
236
  return new NextRequest('http://localhost/api/shares', { method: 'POST', headers, body: form });
237
  }
@@ -239,7 +239,7 @@ function createShareRequest(form: FormData, options: { accessToken?: string | nu
239
  describe('POST /api/shares', () => {
240
  it('creates a share from an uploaded image without returning secrets', async () => {
241
  await withTempCwd();
242
- process.env.APP_PASSWORD = ['customer', 'password'].join('-');
243
  const form = new FormData();
244
  form.set('sourceFilename', 'result.png');
245
  form.set('accessCode', '12345678');
@@ -258,9 +258,9 @@ describe('POST /api/shares', () => {
258
  assert.equal('accessCodeSalt' in body, false);
259
  });
260
 
261
- it('rejects unauthenticated share creation when a page password is configured', async () => {
262
  await withTempCwd();
263
- process.env.APP_PASSWORD = ['customer', 'password'].join('-');
264
  const form = new FormData();
265
  form.set('sourceFilename', 'result.png');
266
  form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
@@ -273,7 +273,7 @@ describe('POST /api/shares', () => {
273
 
274
  it('rejects share creation with an invalid page access token', async () => {
275
  await withTempCwd();
276
- process.env.APP_PASSWORD = ['customer', 'password'].join('-');
277
  const form = new FormData();
278
  form.set('sourceFilename', 'result.png');
279
  form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
@@ -284,7 +284,7 @@ describe('POST /api/shares', () => {
284
  assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
285
  });
286
 
287
- it('allows share creation when no page password is configured', async () => {
288
  await withTempCwd();
289
  delete process.env.APP_PASSWORD;
290
  const form = new FormData();
@@ -297,7 +297,7 @@ describe('POST /api/shares', () => {
297
 
298
  it('treats blank access codes as public shares', async () => {
299
  await withTempCwd();
300
- process.env.APP_PASSWORD = ['customer', 'password'].join('-');
301
  const form = new FormData();
302
  form.set('sourceFilename', 'result.png');
303
  form.set('accessCode', ' ');
@@ -311,7 +311,7 @@ describe('POST /api/shares', () => {
311
 
312
  it('rejects short access codes', async () => {
313
  await withTempCwd();
314
- process.env.APP_PASSWORD = ['customer', 'password'].join('-');
315
  const form = new FormData();
316
  form.set('sourceFilename', 'result.png');
317
  form.set('accessCode', '1234567');
@@ -325,7 +325,7 @@ describe('POST /api/shares', () => {
325
 
326
  it('rejects missing image uploads', async () => {
327
  await withTempCwd();
328
- process.env.APP_PASSWORD = ['customer', 'password'].join('-');
329
  const form = new FormData();
330
  form.set('sourceFilename', 'result.png');
331
 
@@ -337,7 +337,7 @@ describe('POST /api/shares', () => {
337
 
338
  it('rejects invalid expiry values', async () => {
339
  await withTempCwd();
340
- process.env.APP_PASSWORD = ['customer', 'password'].join('-');
341
  const form = new FormData();
342
  form.set('sourceFilename', 'result.png');
343
  form.set('expiresInMinutes', '-1');
@@ -1338,7 +1338,7 @@ onShareImage={handleOpenShareImage}
1338
  'share.copyLink': '复制分享链接',
1339
  'share.create': '创建分享',
1340
  'share.createFailed': '创建分享失败。',
1341
- 'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入码后再试。',
1342
  ```
1343
 
1344
  英文:
@@ -1359,7 +1359,7 @@ onShareImage={handleOpenShareImage}
1359
  'share.copyLink': 'Copy share link',
1360
  'share.create': 'Create Share',
1361
  'share.createFailed': 'Failed to create share.',
1362
- 'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the password again and retry.',
1363
  ```
1364
 
1365
  - [x] **步骤 5:运行前端验证**
 
25
  - 最终验证运行 `npm test`、`npm run lint`、`npm run build`、`git diff --check`;如果本分支完成实现,还要执行 Docker 冒烟验证。
26
 
27
  **护栏指标:**
28
+ - 不得把受访问码保护的 `/api/image/{filename}` 直接暴露为分享机制。
29
+ - 不得把 API Key、访问码或原始提示词写入分享 URL。
30
  - 不得破坏 Agent 产物内容路由。
31
+ - 不得破坏 `http://localhost:4783` 下无访问码本地部署的既有行为。
32
  - 不得静默回退到 mock 图片或伪造分享成功。
33
 
34
  **采样计划:**
 
49
 
50
  **约束:**
51
  - 当前 `AGENTS.md` 要求中文沟通、基于事实下结论、禁止静默降级,并执行最小充分验证。
52
+ - 当前脏工作区包含访问码和 cookie 适配相关改动。不要回滚或覆盖无关的用户改动或前序代理改动。
53
  - 保持当前 `node:test` 布局;不要引入第二套测试框架。
54
  - 代码和文档尽量使用 ASCII;不使用 Emoji 或装饰性 Unicode。
55
 
 
61
 
62
  **耦合说明:**
63
  - 分享创建依赖发送到编辑所使用的同一图片字节读取路径:IndexedDB blob 或 `/api/image/{filename}`。
64
+ - 分享内容必须独立于页面访问码 cookie;否则外部接收者无法访问有效分享。
65
  - 下载是纯浏览器行为,不应要求新增服务端状态。
66
  - 分享 token 必须由 `crypto.randomBytes` 生成,不得使用 `Math.random`、时间戳或可预测输入派生。
67
  - 访问码为空或纯空白时必须按“无访问码分享”处理;非空访问码必须满足最小长度,避免弱访问码被误认为受保护分享。
 
102
  | --- | --- |
103
  | 复杂性原位置 | 用户目前依赖受保护图片 URL 或本地浏览器 blob 做临时下载/分享。 |
104
  | 新位置 | 分享产物移动到 `generated-images/.shares`,包含元数据、复制字节、访问码哈希和有效期。 |
105
+ | 收益 | 外部分享访问不再依赖页面访问码 cookie 或浏览器本地 IndexedDB 状态。 |
106
  | 新成本 | 后续必须考虑分享清理和生命周期;分享元数据成为新的文件系统状态面。 |
107
  | 失效模式 | 在后续新增清理任务前,孤立分享文件或过期分享可能持续积累。 |
108
 
 
112
  - `src/lib/share-store.ts` 已实现分享元数据、复制内容、访问码哈希、有效期和路径限制。
113
  - `src/lib/share-store.test.ts` 已覆盖存储模块,包括受保护/公开分享、有效期、不安全 token、当前工作目录和内容路径限制。
114
  - `src/lib/server-runtime.ts` 已导出 `createAccessToken(serverPassword)` 和 `verifyAccessToken(clientAccessToken, serverPassword)`。
115
+ - `src/lib/page-password-auth.ts` 已导出 `PAGE_PASSWORD_AUTH_ERROR_CODES.missing` 和 `.invalid`,对应页面访问码错误码。
116
  - `src/components/image-output.tsx` 当前导入 `Grid`、`Loader2`、`Send`、`Terminal` 和 `Trash2`;没有下载/分享图标或 props。
117
  - `src/components/image-output.tsx` 的动作行当前只渲染轮播控制、日志和发送到编辑。
118
  - `src/components/image-output.tsx` 当前已有 `isSingleImageView`,定义为 `typeof viewMode === 'number'`。
 
162
  | --- | --- | --- | --- |
163
  | 使用浏览器 blob URL 添加下载按钮 | 用户可以保存已选图片 | 提升本地导出可用性 | blob 来源错误可能下载到过期或缺失图片 |
164
  | 添加分享创建 API | 用户可以创建分享 URL | 提升外部分享能力 | 如果认证绕过不当,可能暴露受保护图片字节 |
165
+ | 添加分享内容路由 | 接收者可以查看有效分享 | 启用公开读取路径 | 不得依赖页面访问码 cookie |
166
  | 添加访问码和有效期检查 | 无效接收者无法查看字节 | 降低未授权暴露 | 如果元数据和内容结果不一致,UI 可能变得困惑 |
167
 
168
  ## 状态模型
 
231
 
232
  function createShareRequest(form: FormData, options: { accessToken?: string | null } = {}) {
233
  const headers = new Headers();
234
+ const accessToken = options.accessToken === undefined ? createAccessToken(['customer', 'access', 'code'].join('-')) : options.accessToken;
235
  if (accessToken) headers.set('Cookie', `gptImageAccess=${accessToken}`);
236
  return new NextRequest('http://localhost/api/shares', { method: 'POST', headers, body: form });
237
  }
 
239
  describe('POST /api/shares', () => {
240
  it('creates a share from an uploaded image without returning secrets', async () => {
241
  await withTempCwd();
242
+ process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
243
  const form = new FormData();
244
  form.set('sourceFilename', 'result.png');
245
  form.set('accessCode', '12345678');
 
258
  assert.equal('accessCodeSalt' in body, false);
259
  });
260
 
261
+ it('rejects unauthenticated share creation when a page access code is configured', async () => {
262
  await withTempCwd();
263
+ process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
264
  const form = new FormData();
265
  form.set('sourceFilename', 'result.png');
266
  form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
 
273
 
274
  it('rejects share creation with an invalid page access token', async () => {
275
  await withTempCwd();
276
+ process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
277
  const form = new FormData();
278
  form.set('sourceFilename', 'result.png');
279
  form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
 
284
  assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
285
  });
286
 
287
+ it('allows share creation when no page access code is configured', async () => {
288
  await withTempCwd();
289
  delete process.env.APP_PASSWORD;
290
  const form = new FormData();
 
297
 
298
  it('treats blank access codes as public shares', async () => {
299
  await withTempCwd();
300
+ process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
301
  const form = new FormData();
302
  form.set('sourceFilename', 'result.png');
303
  form.set('accessCode', ' ');
 
311
 
312
  it('rejects short access codes', async () => {
313
  await withTempCwd();
314
+ process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
315
  const form = new FormData();
316
  form.set('sourceFilename', 'result.png');
317
  form.set('accessCode', '1234567');
 
325
 
326
  it('rejects missing image uploads', async () => {
327
  await withTempCwd();
328
+ process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
329
  const form = new FormData();
330
  form.set('sourceFilename', 'result.png');
331
 
 
337
 
338
  it('rejects invalid expiry values', async () => {
339
  await withTempCwd();
340
+ process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
341
  const form = new FormData();
342
  form.set('sourceFilename', 'result.png');
343
  form.set('expiresInMinutes', '-1');
 
1338
  'share.copyLink': '复制分享链接',
1339
  'share.create': '创建分享',
1340
  'share.createFailed': '创建分享失败。',
1341
+ 'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入访问码后再试。',
1342
  ```
1343
 
1344
  英文:
 
1359
  'share.copyLink': 'Copy share link',
1360
  'share.create': 'Create Share',
1361
  'share.createFailed': 'Failed to create share.',
1362
+ 'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the access code again and retry.',
1363
  ```
1364
 
1365
  - [x] **步骤 5:运行前端验证**
package.json CHANGED
@@ -7,13 +7,17 @@
7
  "prebuild": "node scripts/clean-standalone.mjs",
8
  "build": "next build",
9
  "postbuild": "node scripts/patch-standalone-runtime.mjs",
10
- "test": "node --test --import tsx \"src/**/*.test.ts\"",
 
11
  "test:postgres": "node scripts/test-postgres-live.mjs",
 
 
12
  "keepalive:hf-space": "node scripts/keepalive-hf-space.mjs",
13
  "sync-secret:hf-space": "node scripts/sync-hf-space-secret.mjs",
14
  "smoke:hf-space": "node scripts/smoke-hf-space-memory.mjs",
15
  "start": "node scripts/start-standalone.mjs",
16
  "lint": "eslint src",
 
17
  "format": "prettier --write \"src/**/*.{ts,tsx}\""
18
  },
19
  "dependencies": {
 
7
  "prebuild": "node scripts/clean-standalone.mjs",
8
  "build": "next build",
9
  "postbuild": "node scripts/patch-standalone-runtime.mjs",
10
+ "test": "node --test --import tsx \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"",
11
+ "test:scripts": "node --test \"scripts/**/*.test.mjs\"",
12
  "test:postgres": "node scripts/test-postgres-live.mjs",
13
+ "doctor:hf-space": "node scripts/doctor-hf-space.mjs",
14
+ "init-access:hf-space": "node scripts/init-hf-space-access.mjs",
15
  "keepalive:hf-space": "node scripts/keepalive-hf-space.mjs",
16
  "sync-secret:hf-space": "node scripts/sync-hf-space-secret.mjs",
17
  "smoke:hf-space": "node scripts/smoke-hf-space-memory.mjs",
18
  "start": "node scripts/start-standalone.mjs",
19
  "lint": "eslint src",
20
+ "lint:scripts": "node scripts/check-node-syntax.mjs",
21
  "format": "prettier --write \"src/**/*.{ts,tsx}\""
22
  },
23
  "dependencies": {
scripts/agent-skill-scripts.test.mjs ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { createServer } from 'node:http';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { join } from 'node:path';
6
+ import { describe, it } from 'node:test';
7
+ import { parseRetryAfterValue, resolveSameOriginUrl } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
8
+
9
+ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
10
+ const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts');
11
+
12
+ describe('Agent skill script argument validation', () => {
13
+ it('rejects invalid generate numeric options before dry-run output', () => {
14
+ const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']);
15
+
16
+ assert.equal(result.status, 2);
17
+ assert.match(result.stderr, /--n 必须是正整数/);
18
+ assert.equal(result.stdout.trim(), '');
19
+ });
20
+
21
+ it('rejects invalid edit timeout before reading the image path', () => {
22
+ const result = runSkillScript('edit-image.mjs', ['--timeout-ms', 'abc', '/tmp/missing.png', 'prompt']);
23
+
24
+ assert.equal(result.status, 2);
25
+ assert.match(result.stderr, /--timeout-ms 必须是正整数/);
26
+ assert.equal(result.stdout.trim(), '');
27
+ });
28
+
29
+ it('rejects invalid upstream probe timeout before network checks', () => {
30
+ const result = runSkillScript('probe-upstream-image.mjs', ['--timeout-ms', 'abc']);
31
+
32
+ assert.equal(result.status, 2);
33
+ assert.match(result.stderr, /--timeout-ms 必须是正整数/);
34
+ assert.equal(result.stdout.trim(), '');
35
+ });
36
+
37
+ it('rejects invalid retry attempt env values', () => {
38
+ const result = runSkillScript('generate-image.mjs', ['prompt'], {
39
+ GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc'
40
+ });
41
+
42
+ assert.equal(result.status, 2);
43
+ assert.match(result.stderr, /GPT_IMAGE_AGENT_MAX_ATTEMPTS 必须是正整数/);
44
+ assert.equal(result.stdout.trim(), '');
45
+ });
46
+
47
+ it('rejects service base URLs with embedded credentials before dry-run output', () => {
48
+ const result = runSkillScript('generate-image.mjs', ['prompt'], {
49
+ GPT_IMAGE_PLAYGROUND_URL: 'https://user:secret@example.test'
50
+ });
51
+
52
+ assert.equal(result.status, 2);
53
+ assert.match(result.stderr, /base URL/);
54
+ assert.match(result.stderr, /不能包含凭据/);
55
+ assert.doesNotMatch(result.stderr, /secret/);
56
+ assert.equal(result.stdout.trim(), '');
57
+ });
58
+
59
+ it('rejects upstream probe base URLs with embedded credentials before network checks', () => {
60
+ const result = runSkillScript('probe-upstream-image.mjs', ['--base-url', 'https://user:secret@example.test/v1']);
61
+
62
+ assert.equal(result.status, 2);
63
+ assert.match(result.stderr, /base URL/);
64
+ assert.match(result.stderr, /不能包含凭据/);
65
+ assert.doesNotMatch(result.stderr, /secret/);
66
+ assert.equal(result.stdout.trim(), '');
67
+ });
68
+
69
+ it('does not read prompt files during default generate dry-run', () => {
70
+ const result = runSkillScript('generate-image.mjs', ['--prompt-file', '/tmp/missing-agent-prompt.txt']);
71
+
72
+ assert.equal(result.status, 0);
73
+ const body = JSON.parse(result.stdout);
74
+ assert.equal(body.dry_run, true);
75
+ assert.equal(body.billable, false);
76
+ assert.equal(result.stderr.trim(), '');
77
+ });
78
+
79
+ it('shows edit help without validating unrelated env values', () => {
80
+ const result = runSkillScript('edit-image.mjs', ['--help'], {
81
+ GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc'
82
+ });
83
+
84
+ assert.equal(result.status, 0);
85
+ assert.match(result.stderr, /用法:edit-image\.mjs/);
86
+ assert.equal(result.stdout.trim(), '');
87
+ });
88
+
89
+ it('shows skill script help without validating service URL env values', () => {
90
+ const env = { GPT_IMAGE_PLAYGROUND_URL: 'https://user:secret@example.test' };
91
+ const generateHelp = runSkillScript('generate-image.mjs', ['--help'], env);
92
+ const editHelp = runSkillScript('edit-image.mjs', ['--help'], env);
93
+
94
+ assert.equal(generateHelp.status, 0);
95
+ assert.match(generateHelp.stderr, /用法:generate-image\.mjs/);
96
+ assert.equal(generateHelp.stdout.trim(), '');
97
+ assert.equal(editHelp.status, 0);
98
+ assert.match(editHelp.stderr, /用法:edit-image\.mjs/);
99
+ assert.equal(editHelp.stdout.trim(), '');
100
+ });
101
+
102
+ it('rejects cross-origin job result URLs before sending auth headers', () => {
103
+ assert.throws(
104
+ () => resolveSameOriginUrl('https://space.example.test', 'https://evil.example.test/result', 'job.result_url'),
105
+ /不同 origin/
106
+ );
107
+ assert.equal(
108
+ resolveSameOriginUrl('https://space.example.test', '/api/agent/jobs/abc/result', 'job.result_url'),
109
+ 'https://space.example.test/api/agent/jobs/abc/result'
110
+ );
111
+ });
112
+
113
+ it('caps retry-after values before sleeping', () => {
114
+ assert.equal(parseRetryAfterValue('5'), 5);
115
+ assert.equal(parseRetryAfterValue('0'), 1);
116
+ assert.equal(parseRetryAfterValue('999999999999999999999'), 60);
117
+ assert.equal(parseRetryAfterValue('not-a-number', 7), 7);
118
+ });
119
+
120
+ it('preserves non-JSON capabilities status and body in generate failures', async () => {
121
+ await withServer(
122
+ (request, response) => {
123
+ if (request.url === '/api/agent/capabilities') {
124
+ response.writeHead(503, { 'content-type': 'text/plain' });
125
+ response.end('maintenance window');
126
+ return;
127
+ }
128
+ response.writeHead(404, { 'content-type': 'text/plain' });
129
+ response.end('missing');
130
+ },
131
+ async (baseUrl) => {
132
+ const result = await runSkillScriptAsync('generate-image.mjs', ['--allow-billable', 'prompt'], {
133
+ GPT_IMAGE_PLAYGROUND_URL: baseUrl
134
+ });
135
+
136
+ assert.equal(result.status, 1);
137
+ assert.match(result.stderr, /capabilities 请求失败,状态码 503:maintenance window/);
138
+ }
139
+ );
140
+ });
141
+ });
142
+
143
+ function runSkillScript(filename, args, env = {}) {
144
+ return spawnSync(process.execPath, [join(skillScriptsRoot, filename), ...args], {
145
+ cwd: repoRoot,
146
+ encoding: 'utf8',
147
+ env: { ...process.env, ...env }
148
+ });
149
+ }
150
+
151
+ function runSkillScriptAsync(filename, args, env = {}) {
152
+ return new Promise((resolve) => {
153
+ const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], {
154
+ cwd: repoRoot,
155
+ env: { ...process.env, ...env },
156
+ stdio: ['ignore', 'pipe', 'pipe']
157
+ });
158
+ let stdout = '';
159
+ let stderr = '';
160
+ child.stdout.setEncoding('utf8');
161
+ child.stderr.setEncoding('utf8');
162
+ child.stdout.on('data', (chunk) => {
163
+ stdout += chunk;
164
+ });
165
+ child.stderr.on('data', (chunk) => {
166
+ stderr += chunk;
167
+ });
168
+ child.on('close', (status) => {
169
+ resolve({ status, stdout, stderr });
170
+ });
171
+ });
172
+ }
173
+
174
+ async function withServer(handler, run) {
175
+ const server = createServer(handler);
176
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
177
+ const address = server.address();
178
+ try {
179
+ assert.equal(typeof address, 'object');
180
+ assert.ok(address);
181
+ await run(`http://127.0.0.1:${address.port}`);
182
+ } finally {
183
+ await new Promise((resolve) => server.close(resolve));
184
+ }
185
+ }
scripts/check-node-syntax.mjs ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readdirSync } from 'node:fs';
5
+ import { join, relative } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
9
+ const CHECK_DIRS = ['scripts', 'skills'];
10
+
11
+ function listMjsFiles() {
12
+ return CHECK_DIRS.flatMap((dir) => listMjsFilesUnder(join(REPO_ROOT, dir))).sort((a, b) => a.label.localeCompare(b.label));
13
+ }
14
+
15
+ function listMjsFilesUnder(dir) {
16
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
17
+ const path = join(dir, entry.name);
18
+ if (entry.isDirectory()) return listMjsFilesUnder(path);
19
+ if (!entry.isFile() || !entry.name.endsWith('.mjs')) return [];
20
+ return {
21
+ path,
22
+ label: relative(REPO_ROOT, path).replaceAll('\\', '/')
23
+ };
24
+ });
25
+ }
26
+
27
+ let failed = false;
28
+ for (const file of listMjsFiles()) {
29
+ const result = spawnSync(process.execPath, ['--check', file.path], {
30
+ encoding: 'utf8',
31
+ stdio: ['ignore', 'pipe', 'pipe']
32
+ });
33
+ if (result.status === 0) continue;
34
+ failed = true;
35
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
36
+ console.error(output || `Syntax check failed: ${file.label}`);
37
+ }
38
+
39
+ if (failed) process.exit(1);
scripts/doctor-hf-space.mjs ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import {
5
+ assertKnownOptions,
6
+ buildNextActions,
7
+ classifyRequiredAndRecommendedNames,
8
+ DEFAULT_ACCESS_FILE,
9
+ getJsonNames,
10
+ isMainModule,
11
+ missingKeys,
12
+ parseAccessFile,
13
+ readEnvValue,
14
+ readOptionValue,
15
+ runCommand,
16
+ validateSpaceId,
17
+ validateSpaceUrl
18
+ } from './hf-space-doctor-utils.mjs';
19
+
20
+ const MIN_NODE_MAJOR = 20;
21
+ const REQUIRED_ACCESS_KEYS = ['HF_SPACE_ID', 'HF_SPACE_URL', 'HF_SPACE_SECRET_KEYS', 'APP_PASSWORD', 'AGENT_API_TOKEN'];
22
+ const FORBIDDEN_ACCESS_KEYS = ['HF_TOKEN', 'HUGGINGFACE_TOKEN', 'HF_PASSWORD', 'HUGGINGFACE_PASSWORD'];
23
+ const REQUIRED_SPACE_VARIABLES = ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'];
24
+ const RECOMMENDED_SPACE_VARIABLES = ['APP_LOG_LEVEL'];
25
+ const REQUIRED_SPACE_SECRETS = ['APP_PASSWORD', 'AGENT_API_TOKEN'];
26
+ const OPTIONAL_GENERATION_SECRETS = ['OPENAI_API_KEY', 'OPENAI_CHANNEL_1_API_KEYS'];
27
+
28
+ function parseArgs(argv) {
29
+ assertKnownOptions(argv, ['--access-file', '--help', '-h', '--skip-remote']);
30
+ return {
31
+ accessFile: readOptionValue(argv, '--access-file') || readEnvValue('HF_SPACE_ACCESS_FILE') || DEFAULT_ACCESS_FILE,
32
+ help: argv.includes('--help') || argv.includes('-h'),
33
+ skipRemote: argv.includes('--skip-remote')
34
+ };
35
+ }
36
+
37
+ function printHelp() {
38
+ console.log(`Usage:
39
+ npm run doctor:hf-space
40
+
41
+ Options:
42
+ --access-file <path> Override the access file path.
43
+ --skip-remote Skip read-only Hugging Face remote checks.
44
+ --help Show this help.
45
+
46
+ Environment overrides:
47
+ HF_SPACE_ACCESS_FILE`);
48
+ }
49
+
50
+ function addCheck(checks, status, name, message, details = {}) {
51
+ checks.push({ status, name, message, ...details });
52
+ }
53
+
54
+ function checkNode(checks) {
55
+ const major = Number.parseInt(process.versions.node.split('.')[0], 10);
56
+ if (major >= MIN_NODE_MAJOR) {
57
+ addCheck(checks, 'pass', 'node', `Node.js ${process.version} is supported.`);
58
+ return;
59
+ }
60
+ addCheck(checks, 'fail', 'node', `Node.js ${process.version} is too old. Install Node.js ${MIN_NODE_MAJOR} or newer.`);
61
+ }
62
+
63
+ function checkCommand(checks, name, command, args, failureAction) {
64
+ const result = runCommand(command, args);
65
+ if (result.ok) {
66
+ addCheck(checks, 'pass', name, `${command} is available.`, { version: result.stdout.split(/\r?\n/)[0] });
67
+ return true;
68
+ }
69
+ addCheck(checks, 'fail', name, failureAction, { error: result.error });
70
+ return false;
71
+ }
72
+
73
+ function checkAccessFile(checks, accessFile) {
74
+ if (!existsSync(accessFile)) {
75
+ addCheck(checks, 'fail', 'access-file', `Access file is missing: ${accessFile}`, {
76
+ action: 'Run npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space'
77
+ });
78
+ return undefined;
79
+ }
80
+
81
+ let values;
82
+ try {
83
+ values = parseAccessFile(accessFile);
84
+ } catch (error) {
85
+ addCheck(checks, 'fail', 'access-file', 'Access file cannot be read.', {
86
+ error: error instanceof Error ? error.message : String(error)
87
+ });
88
+ return undefined;
89
+ }
90
+
91
+ addCheck(checks, 'pass', 'access-file', `Access file exists: ${accessFile}`);
92
+
93
+ if (process.platform !== 'win32') {
94
+ const mode = statSync(accessFile).mode & 0o777;
95
+ if ((mode & 0o077) === 0) {
96
+ addCheck(checks, 'pass', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}.`);
97
+ } else {
98
+ addCheck(checks, 'fail', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}; expected 600.`, {
99
+ action: `chmod 600 ${accessFile}`
100
+ });
101
+ }
102
+ }
103
+
104
+ const missing = missingKeys(REQUIRED_ACCESS_KEYS, values);
105
+ if (missing.length) {
106
+ addCheck(checks, 'fail', 'access-file-keys', `Access file is missing required keys: ${missing.join(', ')}.`);
107
+ } else {
108
+ addCheck(checks, 'pass', 'access-file-keys', 'Access file contains all required non-empty keys.');
109
+ }
110
+
111
+ const forbidden = FORBIDDEN_ACCESS_KEYS.filter((key) => values.has(key));
112
+ if (forbidden.length) {
113
+ addCheck(checks, 'fail', 'access-file-forbidden-keys', `Access file must not contain Hugging Face credentials: ${forbidden.join(', ')}.`);
114
+ } else {
115
+ addCheck(checks, 'pass', 'access-file-forbidden-keys', 'Access file does not contain Hugging Face account credentials.');
116
+ }
117
+
118
+ validateAccessValues(checks, values);
119
+ return values;
120
+ }
121
+
122
+ function validateAccessValues(checks, values) {
123
+ const spaceId = values.get('HF_SPACE_ID')?.trim();
124
+ const spaceIdError = validateSpaceId(spaceId);
125
+ if (!spaceIdError) {
126
+ addCheck(checks, 'pass', 'space-id', 'HF_SPACE_ID has namespace/space format.');
127
+ } else if (spaceId) {
128
+ addCheck(checks, 'fail', 'space-id', spaceIdError);
129
+ }
130
+
131
+ const spaceUrl = values.get('HF_SPACE_URL')?.trim();
132
+ if (spaceUrl) {
133
+ const spaceUrlError = validateSpaceUrl(spaceUrl);
134
+ if (spaceUrlError) {
135
+ addCheck(checks, 'fail', 'space-url', spaceUrlError);
136
+ } else {
137
+ addCheck(checks, 'pass', 'space-url', 'HF_SPACE_URL looks like a Hugging Face Space URL.');
138
+ }
139
+ }
140
+
141
+ const appPassword = values.get('APP_PASSWORD') || '';
142
+ const agentToken = values.get('AGENT_API_TOKEN') || '';
143
+ if (appPassword.length >= 16 && agentToken.length >= 24) {
144
+ addCheck(
145
+ checks,
146
+ 'pass',
147
+ 'generated-secrets',
148
+ 'APP_PASSWORD access code and AGENT_API_TOKEN meet the minimum length checks.'
149
+ );
150
+ } else {
151
+ addCheck(
152
+ checks,
153
+ 'fail',
154
+ 'generated-secrets',
155
+ 'APP_PASSWORD access code must be at least 16 chars and AGENT_API_TOKEN at least 24 chars.'
156
+ );
157
+ }
158
+ }
159
+
160
+ function checkRemote(checks, values, skipRemote, hfAvailable, hfAuthenticated) {
161
+ if (skipRemote) {
162
+ addCheck(checks, 'skip', 'remote-space', 'Remote checks were skipped by --skip-remote.');
163
+ return;
164
+ }
165
+ if (!hfAvailable || !hfAuthenticated || !values) {
166
+ addCheck(checks, 'skip', 'remote-space', 'Remote checks require hf CLI, hf auth login, and a valid access file.');
167
+ return;
168
+ }
169
+
170
+ const spaceId = values.get('HF_SPACE_ID')?.trim();
171
+ if (!spaceId) {
172
+ addCheck(checks, 'skip', 'remote-space', 'Remote checks require HF_SPACE_ID in the access file.');
173
+ return;
174
+ }
175
+ const info = runCommand('hf', ['spaces', 'info', spaceId, '--format', 'json']);
176
+ if (!info.ok) {
177
+ addCheck(checks, 'fail', 'remote-space', `Cannot read Space info for ${spaceId}.`, { error: info.error });
178
+ return;
179
+ }
180
+ addCheck(checks, 'pass', 'remote-space', `Space ${spaceId} is accessible.`);
181
+ checkRemoteNames(
182
+ checks,
183
+ spaceId,
184
+ 'remote-variables',
185
+ ['spaces', 'variables', 'list', spaceId, '--json'],
186
+ REQUIRED_SPACE_VARIABLES,
187
+ RECOMMENDED_SPACE_VARIABLES
188
+ );
189
+ checkRemoteSecrets(checks, spaceId);
190
+ }
191
+
192
+ function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommendedNames = []) {
193
+ const result = runCommand('hf', args);
194
+ if (!result.ok) {
195
+ addCheck(checks, 'warn', name, `Cannot list ${name} for ${spaceId}.`, { error: result.error });
196
+ return;
197
+ }
198
+ try {
199
+ const names = getJsonNames(result.stdout);
200
+ const { missingRequired, missingRecommended } = classifyRequiredAndRecommendedNames(
201
+ names,
202
+ requiredNames,
203
+ recommendedNames
204
+ );
205
+ if (missingRequired.length) {
206
+ addCheck(checks, 'fail', name, `${name} missing required names: ${missingRequired.join(', ')}.`);
207
+ } else {
208
+ addCheck(checks, 'pass', name, `${name} contains required names.`);
209
+ }
210
+ if (missingRecommended.length) {
211
+ addCheck(checks, 'warn', name, `${name} missing recommended names: ${missingRecommended.join(', ')}.`);
212
+ }
213
+ } catch (error) {
214
+ addCheck(checks, 'warn', name, `Cannot parse ${name} JSON output.`, {
215
+ error: error instanceof Error ? error.message : String(error)
216
+ });
217
+ }
218
+ }
219
+
220
+ function checkRemoteSecrets(checks, spaceId) {
221
+ const result = runCommand('hf', ['spaces', 'secrets', 'list', spaceId, '--json']);
222
+ if (!result.ok) {
223
+ addCheck(checks, 'warn', 'remote-secrets', `Cannot list remote secrets for ${spaceId}.`, { error: result.error });
224
+ return;
225
+ }
226
+ try {
227
+ const names = getJsonNames(result.stdout);
228
+ const missing = REQUIRED_SPACE_SECRETS.filter((key) => !names.has(key));
229
+ const hasGenerationSecret = OPTIONAL_GENERATION_SECRETS.some((key) => names.has(key));
230
+ if (missing.length) {
231
+ addCheck(checks, 'fail', 'remote-secrets', `Remote secrets missing: ${missing.join(', ')}.`);
232
+ } else {
233
+ addCheck(checks, 'pass', 'remote-secrets', 'Remote secrets contain APP_PASSWORD and AGENT_API_TOKEN.');
234
+ }
235
+ if (hasGenerationSecret) {
236
+ addCheck(checks, 'pass', 'remote-generation-secret', 'Remote generation credential is configured.');
237
+ } else {
238
+ addCheck(checks, 'warn', 'remote-generation-secret', 'No OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS secret found; server-side generation may be unavailable.');
239
+ }
240
+ } catch (error) {
241
+ addCheck(checks, 'warn', 'remote-secrets', 'Cannot parse remote secrets JSON output.', {
242
+ error: error instanceof Error ? error.message : String(error)
243
+ });
244
+ }
245
+ }
246
+
247
+ function main() {
248
+ const options = parseArgs(process.argv.slice(2));
249
+ if (options.help) {
250
+ printHelp();
251
+ return;
252
+ }
253
+
254
+ const checks = [];
255
+ checkNode(checks);
256
+ checkCommand(checks, 'npm', 'npm', ['--version'], 'npm is missing. Install Node.js 20 or newer with npm.');
257
+ const hfAvailable = checkCommand(checks, 'hf-cli', 'hf', ['version'], 'hf CLI is missing. Install the Hugging Face CLI.');
258
+ const hfAuth = hfAvailable ? runCommand('hf', ['auth', 'whoami']) : { ok: false };
259
+ if (hfAvailable && hfAuth.ok) {
260
+ addCheck(checks, 'pass', 'hf-auth', 'hf CLI is authenticated.');
261
+ } else if (hfAvailable) {
262
+ addCheck(checks, 'fail', 'hf-auth', 'hf CLI auth check failed.', {
263
+ action: 'hf auth login',
264
+ error: hfAuth.error
265
+ });
266
+ }
267
+ if (existsSync('node_modules')) {
268
+ addCheck(checks, 'pass', 'node-modules', 'node_modules exists.');
269
+ } else {
270
+ addCheck(checks, 'warn', 'node-modules', 'node_modules is missing; build, lint, test, and smoke commands require npm install.');
271
+ }
272
+ checkCommand(checks, 'git', 'git', ['--version'], 'git is missing; install git before cloning or pushing Space repos.');
273
+ const docker = runCommand('docker', ['version', '--format', '{{.Server.Version}}']);
274
+ if (docker.ok) {
275
+ addCheck(checks, 'pass', 'docker', 'docker is available.', { version: docker.stdout.split(/\r?\n/)[0] });
276
+ } else {
277
+ addCheck(checks, 'warn', 'docker', 'Docker is unavailable; npm run smoke:hf-space will not work.', {
278
+ error: docker.error
279
+ });
280
+ const dockerCli = runCommand('docker', ['--version']);
281
+ if (dockerCli.ok) addCheck(checks, 'warn', 'docker-daemon', 'Docker CLI exists but the daemon is not reachable.');
282
+ }
283
+
284
+ const values = checkAccessFile(checks, options.accessFile);
285
+ checkRemote(checks, values, options.skipRemote, hfAvailable, Boolean(hfAuth.ok));
286
+
287
+ const failed = checks.some((check) => check.status === 'fail');
288
+ console.log(JSON.stringify({ ok: !failed, checks, nextActions: buildNextActions(checks) }, null, 2));
289
+ if (failed) process.exit(1);
290
+ }
291
+
292
+ try {
293
+ if (isMainModule(import.meta.url, process.argv[1])) {
294
+ main();
295
+ }
296
+ } catch (error) {
297
+ console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2));
298
+ process.exit(1);
299
+ }
scripts/hf-space-doctor-utils.mjs ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ export const DEFAULT_ACCESS_FILE = join(process.env.HOME || '', '.cache/gpt-image-playground-customer/hf-space-access.txt');
7
+
8
+ export function readEnvValue(name) {
9
+ return process.env[name]?.trim() || undefined;
10
+ }
11
+
12
+ export function readOptionValue(argv, name) {
13
+ const prefix = `${name}=`;
14
+ const inlineValue = argv.find((arg) => arg.startsWith(prefix));
15
+ if (inlineValue !== undefined) {
16
+ const value = inlineValue.slice(prefix.length).trim();
17
+ if (!value) throw new Error(`${name} requires a value`);
18
+ return value;
19
+ }
20
+
21
+ const index = argv.indexOf(name);
22
+ if (index === -1) return undefined;
23
+ const value = argv[index + 1]?.trim();
24
+ if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`);
25
+ return value;
26
+ }
27
+
28
+ export function assertKnownOptions(argv, knownOptions) {
29
+ const known = new Set(knownOptions);
30
+ for (const arg of argv) {
31
+ if (!arg.startsWith('-')) continue;
32
+ const name = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
33
+ if (!known.has(name)) throw new Error(`Unknown option: ${name}`);
34
+ }
35
+ }
36
+
37
+ export function runCommand(command, args = []) {
38
+ const result = spawnSync(command, args, {
39
+ encoding: 'utf8',
40
+ stdio: ['ignore', 'pipe', 'pipe']
41
+ });
42
+ if (result.error) return { ok: false, error: result.error.message };
43
+ if (result.status !== 0) {
44
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
45
+ return { ok: false, error: output || `${command} ${args.join(' ')} failed` };
46
+ }
47
+ return { ok: true, stdout: result.stdout.trim() };
48
+ }
49
+
50
+ export function parseAccessFile(accessFile) {
51
+ const values = new Map();
52
+ const text = readFileSync(accessFile, 'utf8');
53
+ for (const rawLine of text.split(/\r?\n/)) {
54
+ if (!rawLine || rawLine.startsWith('#')) continue;
55
+ const separatorIndex = rawLine.indexOf('=');
56
+ if (separatorIndex <= 0) continue;
57
+ const key = rawLine.slice(0, separatorIndex).trim();
58
+ const value = rawLine.slice(separatorIndex + 1);
59
+ if (key) values.set(key, value);
60
+ }
61
+ return values;
62
+ }
63
+
64
+ export function getJsonNames(text) {
65
+ const parsed = JSON.parse(extractJsonPayload(text));
66
+ const names = new Set();
67
+ const visit = (value) => {
68
+ if (Array.isArray(value)) {
69
+ for (const item of value) visit(item);
70
+ return;
71
+ }
72
+ if (!value || typeof value !== 'object') return;
73
+ for (const key of ['name', 'key', 'id']) {
74
+ if (typeof value[key] === 'string') names.add(value[key]);
75
+ }
76
+ };
77
+ visit(parsed);
78
+ return names;
79
+ }
80
+
81
+ function extractJsonPayload(text) {
82
+ const trimmedText = text.trim();
83
+ if (!trimmedText) return '[]';
84
+
85
+ const lines = trimmedText.split(/\r?\n/);
86
+ const jsonLineIndex = lines.findIndex((line) => {
87
+ const trimmedLine = line.trimStart();
88
+ return trimmedLine.startsWith('{') || trimmedLine.startsWith('[');
89
+ });
90
+ if (jsonLineIndex === -1) return trimmedText;
91
+ return lines.slice(jsonLineIndex).join('\n');
92
+ }
93
+
94
+ export function missingKeys(keys, values) {
95
+ return keys.filter((key) => !values.has(key) || !String(values.get(key)).trim());
96
+ }
97
+
98
+ export function classifyRequiredAndRecommendedNames(names, requiredNames, recommendedNames = []) {
99
+ return {
100
+ missingRequired: requiredNames.filter((key) => !names.has(key)),
101
+ missingRecommended: recommendedNames.filter((key) => !names.has(key))
102
+ };
103
+ }
104
+
105
+ const NEXT_ACTIONS = new Map([
106
+ ['node', 'Install Node.js 20 or newer, then reopen the terminal.'],
107
+ ['npm', 'Install Node.js 20 or newer with npm.'],
108
+ ['hf-cli', 'Install the Hugging Face CLI from the official documentation; avoid piping remote install scripts directly to a shell.'],
109
+ ['hf-auth', 'Check network/proxy access to Hugging Face, then run hf auth login if the token is missing or expired.'],
110
+ ['node-modules', 'Run npm install.'],
111
+ [
112
+ 'access-file-keys',
113
+ 'Regenerate or update the access file with npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space'
114
+ ],
115
+ [
116
+ 'generated-secrets',
117
+ 'Regenerate weak or blank project secrets with npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space --force'
118
+ ],
119
+ ['remote-variables', 'Configure the required and recommended Space Variables in Hugging Face Settings before syncing secrets.'],
120
+ ['remote-secrets', 'Run npm run sync-secret:hf-space after hf auth login succeeds.'],
121
+ ['remote-generation-secret', 'Configure OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS in Space Secrets before real image generation.']
122
+ ]);
123
+
124
+ export function buildNextActions(checks) {
125
+ const actions = new Set();
126
+ for (const check of checks) {
127
+ if (check.status === 'pass' || check.status === 'skip') continue;
128
+ if (check.action) actions.add(check.action);
129
+ const mappedAction = NEXT_ACTIONS.get(check.name);
130
+ if (mappedAction) actions.add(mappedAction);
131
+ }
132
+ return [...actions];
133
+ }
134
+
135
+ export function validateSpaceId(spaceId) {
136
+ if (!spaceId?.trim()) return 'HF Space id is required.';
137
+ if (!/^[^/\s]+\/[^/\s]+$/.test(spaceId)) return 'HF Space id must use namespace/space format.';
138
+ return undefined;
139
+ }
140
+
141
+ export function validateSpaceUrl(spaceUrl) {
142
+ if (!spaceUrl?.trim()) return 'HF Space URL is required.';
143
+ let parsedUrl;
144
+ try {
145
+ parsedUrl = new URL(spaceUrl);
146
+ } catch {
147
+ return 'HF Space URL is not a valid URL.';
148
+ }
149
+ if (parsedUrl.protocol !== 'https:') return 'HF Space URL must use https.';
150
+ if (!parsedUrl.hostname.endsWith('.hf.space')) return 'HF Space URL must be a Hugging Face .hf.space URL.';
151
+ if (parsedUrl.username || parsedUrl.password || parsedUrl.pathname !== '/' || parsedUrl.search || parsedUrl.hash) {
152
+ return 'HF Space URL must be a plain Space origin without credentials, path, query, or fragment.';
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ export function assertSpaceTargetConfig({ spaceId, spaceUrl }) {
158
+ const spaceIdError = validateSpaceId(spaceId);
159
+ if (spaceIdError) throw new Error(spaceIdError);
160
+ const spaceUrlError = validateSpaceUrl(spaceUrl);
161
+ if (spaceUrlError) throw new Error(spaceUrlError);
162
+ }
163
+
164
+ export function isMainModule(moduleUrl, argvPath) {
165
+ return Boolean(argvPath) && moduleUrl === pathToFileURL(resolve(argvPath)).href;
166
+ }
scripts/hf-space-doctor-utils.test.mjs ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import {
7
+ assertKnownOptions,
8
+ assertSpaceTargetConfig,
9
+ buildNextActions,
10
+ classifyRequiredAndRecommendedNames,
11
+ getJsonNames,
12
+ isMainModule,
13
+ readOptionValue,
14
+ validateSpaceId,
15
+ validateSpaceUrl
16
+ } from './hf-space-doctor-utils.mjs';
17
+
18
+ describe('HF Space doctor utilities', () => {
19
+ it('parses hf CLI JSON output after hint lines', () => {
20
+ const names = getJsonNames(
21
+ [
22
+ 'Hint: Use `hf spaces variables add user/space -e KEY=VALUE` to add variables.',
23
+ '[{"key":"APP_PASSWORD"},{"key":"AGENT_API_TOKEN"}]'
24
+ ].join('\n')
25
+ );
26
+
27
+ assert.deepEqual([...names], ['APP_PASSWORD', 'AGENT_API_TOKEN']);
28
+ });
29
+
30
+ it('does not treat square brackets in hint text as JSON', () => {
31
+ const names = getJsonNames(
32
+ [
33
+ 'Hint: Usage: hf spaces secrets list [OPTIONS] SPACE_ID',
34
+ '[{"key":"OPENAI_API_KEY"}]'
35
+ ].join('\n')
36
+ );
37
+
38
+ assert.deepEqual([...names], ['OPENAI_API_KEY']);
39
+ });
40
+
41
+ it('keeps auth remediation broad enough for network and token failures', () => {
42
+ const actions = buildNextActions([
43
+ {
44
+ status: 'fail',
45
+ name: 'hf-auth',
46
+ message: 'hf CLI auth check failed.',
47
+ error: 'TLS failed while checking auth'
48
+ }
49
+ ]);
50
+
51
+ assert.equal(actions.length, 1);
52
+ assert.match(actions[0], /network\/proxy/);
53
+ assert.match(actions[0], /hf auth login/);
54
+ });
55
+
56
+ it('classifies required and recommended remote variables separately', () => {
57
+ const result = classifyRequiredAndRecommendedNames(
58
+ new Set(['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE']),
59
+ ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'],
60
+ ['APP_LOG_LEVEL']
61
+ );
62
+
63
+ assert.deepEqual(result, {
64
+ missingRequired: [],
65
+ missingRecommended: ['APP_LOG_LEVEL']
66
+ });
67
+ });
68
+
69
+ it('detects the main module with file URL encoding', () => {
70
+ const argvPath = 'scripts/path with space.mjs';
71
+ const moduleUrl = pathToFileURL(resolve(argvPath)).href;
72
+
73
+ assert.equal(isMainModule(moduleUrl, argvPath), true);
74
+ assert.equal(isMainModule(moduleUrl, undefined), false);
75
+ });
76
+
77
+ it('validates Hugging Face Space target config consistently', () => {
78
+ assert.equal(validateSpaceId('example/demo'), undefined);
79
+ assert.equal(validateSpaceUrl('https://example-demo.hf.space'), undefined);
80
+ assert.match(validateSpaceId('bad'), /namespace\/space/);
81
+ assert.match(validateSpaceUrl('https://example.com'), /\.hf\.space/);
82
+ assert.match(validateSpaceUrl('https://user:pass@example-demo.hf.space'), /plain Space origin/);
83
+ assert.match(validateSpaceUrl('https://example-demo.hf.space/share/abc'), /plain Space origin/);
84
+ assert.match(validateSpaceUrl('https://example-demo.hf.space?token=secret'), /plain Space origin/);
85
+ assert.throws(
86
+ () =>
87
+ assertSpaceTargetConfig({
88
+ spaceId: 'example/demo',
89
+ spaceUrl: 'https://example.com'
90
+ }),
91
+ /\.hf\.space/
92
+ );
93
+ });
94
+
95
+ it('rejects unknown CLI options and blank inline option values', () => {
96
+ assert.doesNotThrow(() => assertKnownOptions(['--access-file', 'tmp.txt'], ['--access-file']));
97
+ assert.doesNotThrow(() => assertKnownOptions(['--access-file=tmp.txt'], ['--access-file']));
98
+ assert.throws(() => assertKnownOptions(['--unknown'], ['--access-file']), /Unknown option/);
99
+ assert.throws(() => readOptionValue(['--space-id='], '--space-id'), /requires a value/);
100
+ });
101
+ });
scripts/init-hf-space-access.mjs ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { randomBytes } from 'node:crypto';
4
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+
7
+ import {
8
+ assertKnownOptions,
9
+ assertSpaceTargetConfig,
10
+ DEFAULT_ACCESS_FILE,
11
+ readEnvValue,
12
+ readOptionValue,
13
+ isMainModule
14
+ } from './hf-space-doctor-utils.mjs';
15
+
16
+ const DEFAULT_SECRET_KEYS = ['APP_PASSWORD', 'AGENT_API_TOKEN'];
17
+ const APP_PASSWORD_BYTES = 24;
18
+ const AGENT_TOKEN_BYTES = 32;
19
+
20
+ function parseArgs(argv) {
21
+ assertKnownOptions(argv, ['--access-file', '--force', '--help', '-h', '--space-id', '--space-url']);
22
+ return {
23
+ accessFile: readOptionValue(argv, '--access-file') || readEnvValue('HF_SPACE_ACCESS_FILE') || DEFAULT_ACCESS_FILE,
24
+ force: argv.includes('--force'),
25
+ help: argv.includes('--help') || argv.includes('-h'),
26
+ spaceId: readOptionValue(argv, '--space-id') || readEnvValue('HF_SPACE_ID'),
27
+ spaceUrl: readOptionValue(argv, '--space-url') || readEnvValue('HF_SPACE_URL')
28
+ };
29
+ }
30
+
31
+ function printHelp() {
32
+ console.log(`Usage:
33
+ npm run init-access:hf-space -- --space-id <namespace/space> --space-url https://<user>-<space>.hf.space
34
+
35
+ Options:
36
+ --access-file <path> Override the access file path.
37
+ --force Overwrite an existing access file.
38
+ --help Show this help.
39
+
40
+ Environment overrides:
41
+ HF_SPACE_ID
42
+ HF_SPACE_URL
43
+ HF_SPACE_ACCESS_FILE`);
44
+ }
45
+
46
+ function createSecret(bytes) {
47
+ return randomBytes(bytes).toString('base64url');
48
+ }
49
+
50
+ function buildAccessFileContent(options) {
51
+ return [
52
+ '# Hugging Face Space access for gpt-image-playground-customer',
53
+ '# Generated by npm run init-access:hf-space. Do not commit this file.',
54
+ `HF_SPACE_ID=${options.spaceId}`,
55
+ `HF_SPACE_URL=${options.spaceUrl}`,
56
+ `HF_SPACE_SECRET_KEYS=${DEFAULT_SECRET_KEYS.join(',')}`,
57
+ `APP_PASSWORD=${createSecret(APP_PASSWORD_BYTES)}`,
58
+ `AGENT_API_TOKEN=${createSecret(AGENT_TOKEN_BYTES)}`,
59
+ ''
60
+ ].join('\n');
61
+ }
62
+
63
+ function writeAccessFile(options) {
64
+ if (existsSync(options.accessFile) && !options.force) {
65
+ throw new Error(`Access file already exists: ${options.accessFile}. Use --force to overwrite.`);
66
+ }
67
+ mkdirSync(dirname(options.accessFile), { recursive: true });
68
+ writeFileSync(options.accessFile, buildAccessFileContent(options), { encoding: 'utf8', mode: 0o600 });
69
+ chmodSync(options.accessFile, 0o600);
70
+ }
71
+
72
+ function main() {
73
+ const options = parseArgs(process.argv.slice(2));
74
+ if (options.help) {
75
+ printHelp();
76
+ return;
77
+ }
78
+
79
+ assertSpaceTargetConfig(options);
80
+ writeAccessFile(options);
81
+
82
+ console.log(
83
+ JSON.stringify(
84
+ {
85
+ ok: true,
86
+ accessFile: options.accessFile,
87
+ spaceId: options.spaceId,
88
+ spaceUrl: options.spaceUrl,
89
+ secretKeys: DEFAULT_SECRET_KEYS,
90
+ nextCommand: 'npm run sync-secret:hf-space'
91
+ },
92
+ null,
93
+ 2
94
+ )
95
+ );
96
+ }
97
+
98
+ try {
99
+ if (isMainModule(import.meta.url, process.argv[1])) {
100
+ main();
101
+ }
102
+ } catch (error) {
103
+ console.error(
104
+ JSON.stringify(
105
+ {
106
+ ok: false,
107
+ error: error instanceof Error ? error.message : String(error)
108
+ },
109
+ null,
110
+ 2
111
+ )
112
+ );
113
+ process.exit(1);
114
+ }
scripts/init-hf-space-access.test.mjs ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { describe, it } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const SCRIPT_PATH = fileURLToPath(new URL('./init-hf-space-access.mjs', import.meta.url));
10
+ const SCRIPT_URL = new URL('./init-hf-space-access.mjs', import.meta.url).href;
11
+
12
+ function runInit(args) {
13
+ return spawnSync(process.execPath, [SCRIPT_PATH, ...args], {
14
+ encoding: 'utf8',
15
+ stdio: ['ignore', 'pipe', 'pipe']
16
+ });
17
+ }
18
+
19
+ function makeTempDir() {
20
+ return mkdtempSync(join(tmpdir(), 'gpt-image-playground-access-'));
21
+ }
22
+
23
+ describe('HF Space access initializer', () => {
24
+ it('can be imported without executing the CLI', async () => {
25
+ await assert.doesNotReject(import(`${SCRIPT_URL}?import-smoke=${Date.now()}`));
26
+ });
27
+
28
+ it('rejects unknown options before writing an access file', () => {
29
+ const dir = makeTempDir();
30
+ const accessFile = join(dir, 'access.txt');
31
+ const result = runInit([
32
+ '--space-id',
33
+ 'example/demo',
34
+ '--space-url',
35
+ 'https://example-demo.hf.space',
36
+ '--unknown',
37
+ '--access-file',
38
+ accessFile
39
+ ]);
40
+
41
+ assert.notEqual(result.status, 0);
42
+ assert.match(result.stderr, /Unknown option/);
43
+ assert.equal(existsSync(accessFile), false);
44
+ rmSync(dir, { force: true, recursive: true });
45
+ });
46
+
47
+ it('rejects invalid Space ids before writing an access file', () => {
48
+ const dir = makeTempDir();
49
+ const accessFile = join(dir, 'access.txt');
50
+ const result = runInit([
51
+ '--space-id',
52
+ 'bad',
53
+ '--space-url',
54
+ 'https://example-demo.hf.space',
55
+ '--access-file',
56
+ accessFile
57
+ ]);
58
+
59
+ assert.notEqual(result.status, 0);
60
+ assert.match(result.stderr, /namespace\/space/);
61
+ assert.equal(existsSync(accessFile), false);
62
+ rmSync(dir, { force: true, recursive: true });
63
+ });
64
+
65
+ it('rejects non-Hugging Face Space URLs before writing an access file', () => {
66
+ const dir = makeTempDir();
67
+ const accessFile = join(dir, 'access.txt');
68
+ const result = runInit([
69
+ '--space-id',
70
+ 'example/demo',
71
+ '--space-url',
72
+ 'https://example.com',
73
+ '--access-file',
74
+ accessFile
75
+ ]);
76
+
77
+ assert.notEqual(result.status, 0);
78
+ assert.match(result.stderr, /\.hf\.space/);
79
+ assert.equal(existsSync(accessFile), false);
80
+ rmSync(dir, { force: true, recursive: true });
81
+ });
82
+
83
+ it('writes an access file without leaking generated secrets to stdout', () => {
84
+ const dir = makeTempDir();
85
+ const accessFile = join(dir, 'access.txt');
86
+ const result = runInit([
87
+ '--space-id',
88
+ 'example/demo',
89
+ '--space-url',
90
+ 'https://example-demo.hf.space',
91
+ '--access-file',
92
+ accessFile
93
+ ]);
94
+
95
+ assert.equal(result.status, 0, result.stderr);
96
+ assert.doesNotMatch(result.stdout, /APP_PASSWORD=|AGENT_API_TOKEN=/);
97
+
98
+ const content = readFileSync(accessFile, 'utf8');
99
+ assert.match(content, /^HF_SPACE_ID=example\/demo$/m);
100
+ assert.match(content, /^HF_SPACE_URL=https:\/\/example-demo\.hf\.space$/m);
101
+ assert.match(content, /^HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN$/m);
102
+ if (process.platform !== 'win32') {
103
+ assert.equal(statSync(accessFile).mode & 0o777, 0o600);
104
+ }
105
+
106
+ rmSync(dir, { force: true, recursive: true });
107
+ });
108
+ });
scripts/keepalive-hf-space.mjs CHANGED
@@ -1,5 +1,7 @@
1
  #!/usr/bin/env node
2
 
 
 
3
  const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
4
  const DEFAULT_KEEPALIVE_PATH = '/api/auth-status';
5
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -8,7 +10,10 @@ const MIN_TIMEOUT_MS = 1_000;
8
  function readPositiveIntegerEnv(name, fallback) {
9
  const rawValue = process.env[name]?.trim();
10
  if (!rawValue) return fallback;
11
- const value = Number.parseInt(rawValue, 10);
 
 
 
12
  if (!Number.isSafeInteger(value) || value < MIN_TIMEOUT_MS) {
13
  throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`);
14
  }
@@ -16,6 +21,10 @@ function readPositiveIntegerEnv(name, fallback) {
16
  }
17
 
18
  function normalizeUrl(rawUrl, path) {
 
 
 
 
19
  const baseUrl = new URL(rawUrl);
20
  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
21
  return new URL(normalizedPath, baseUrl).toString();
 
1
  #!/usr/bin/env node
2
 
3
+ import { validateSpaceUrl } from './hf-space-doctor-utils.mjs';
4
+
5
  const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
6
  const DEFAULT_KEEPALIVE_PATH = '/api/auth-status';
7
  const DEFAULT_TIMEOUT_MS = 30_000;
 
10
  function readPositiveIntegerEnv(name, fallback) {
11
  const rawValue = process.env[name]?.trim();
12
  if (!rawValue) return fallback;
13
+ if (!/^\d+$/.test(rawValue)) {
14
+ throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`);
15
+ }
16
+ const value = Number(rawValue);
17
  if (!Number.isSafeInteger(value) || value < MIN_TIMEOUT_MS) {
18
  throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`);
19
  }
 
21
  }
22
 
23
  function normalizeUrl(rawUrl, path) {
24
+ const urlError = validateSpaceUrl(rawUrl);
25
+ if (urlError) {
26
+ throw new Error(urlError.replace('HF Space URL', 'HF_SPACE_KEEPALIVE_URL'));
27
+ }
28
  const baseUrl = new URL(rawUrl);
29
  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
30
  return new URL(normalizedPath, baseUrl).toString();
scripts/keepalive-hf-space.test.mjs ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { join } from 'node:path';
5
+ import { describe, it } from 'node:test';
6
+
7
+ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
8
+ const scriptPath = join(repoRoot, 'scripts/keepalive-hf-space.mjs');
9
+
10
+ describe('HF Space keepalive script validation', () => {
11
+ it('rejects non-integer timeout env values before network access', () => {
12
+ const result = runKeepalive({ HF_SPACE_KEEPALIVE_TIMEOUT_MS: '1000abc' });
13
+
14
+ assert.equal(result.status, 1);
15
+ assert.match(result.stderr, /HF_SPACE_KEEPALIVE_TIMEOUT_MS/);
16
+ assert.match(result.stderr, /integer/);
17
+ assert.equal(result.stdout.trim(), '');
18
+ });
19
+
20
+ it('rejects keepalive URLs with embedded credentials before network access', () => {
21
+ const result = runKeepalive({ HF_SPACE_KEEPALIVE_URL: 'https://user:secret@example-demo.hf.space' });
22
+
23
+ assert.equal(result.status, 1);
24
+ assert.match(result.stderr, /HF_SPACE_KEEPALIVE_URL/);
25
+ assert.match(result.stderr, /plain Space origin/);
26
+ assert.doesNotMatch(result.stderr, /secret/);
27
+ assert.equal(result.stdout.trim(), '');
28
+ });
29
+ });
30
+
31
+ function runKeepalive(env) {
32
+ return spawnSync(process.execPath, [scriptPath], {
33
+ cwd: repoRoot,
34
+ encoding: 'utf8',
35
+ env: { ...process.env, ...env }
36
+ });
37
+ }
scripts/smoke-hf-space-memory.mjs CHANGED
@@ -65,6 +65,14 @@ function assertEqual(actual, expected, label) {
65
  }
66
  }
67
 
 
 
 
 
 
 
 
 
68
  cleanup();
69
 
70
  try {
@@ -106,6 +114,7 @@ try {
106
 
107
  const capabilities = await fetchJson('/api/agent/capabilities');
108
  assertEqual(capabilities.auth?.required, true, 'Agent auth required flag');
 
109
  assertEqual(capabilities.defaults?.state_backend, 'memory', 'Agent state backend');
110
  assertEqual(capabilities.storage?.image_storage_mode, 'indexeddb', 'Image storage mode');
111
  assertEqual(capabilities.storage?.postgres_configured, false, 'PostgreSQL configured flag');
 
65
  }
66
  }
67
 
68
+ function assertJsonEqual(actual, expected, label) {
69
+ const actualJson = JSON.stringify(actual);
70
+ const expectedJson = JSON.stringify(expected);
71
+ if (actualJson !== expectedJson) {
72
+ throw new Error(`${label}: expected ${expectedJson}, got ${actualJson}`);
73
+ }
74
+ }
75
+
76
  cleanup();
77
 
78
  try {
 
114
 
115
  const capabilities = await fetchJson('/api/agent/capabilities');
116
  assertEqual(capabilities.auth?.required, true, 'Agent auth required flag');
117
+ assertJsonEqual(capabilities.auth?.schemes, ['bearer'], 'Agent auth schemes');
118
  assertEqual(capabilities.defaults?.state_backend, 'memory', 'Agent state backend');
119
  assertEqual(capabilities.storage?.image_storage_mode, 'indexeddb', 'Image storage mode');
120
  assertEqual(capabilities.storage?.postgres_configured, false, 'PostgreSQL configured flag');
scripts/sync-hf-space-secret.mjs CHANGED
@@ -2,34 +2,68 @@
2
 
3
  import { execFileSync, spawnSync } from 'node:child_process';
4
  import { createHash } from 'node:crypto';
5
- import { readFileSync } from 'node:fs';
6
- import { join } from 'node:path';
7
  import { setTimeout as delay } from 'node:timers/promises';
8
 
 
 
 
 
 
 
 
 
9
  const DEFAULT_SPACE_ID = 'misonL/gpt-image-playground-customer';
10
  const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
11
- const DEFAULT_ACCESS_FILE = join(process.env.HOME || '', '.cache/gpt-image-playground-customer/hf-space-access.txt');
12
  const DEFAULT_SECRET_KEYS = ['APP_PASSWORD'];
 
13
  const STATUS_POLL_ATTEMPTS = 20;
14
  const STATUS_POLL_INTERVAL_MS = 5_000;
15
  const VERIFY_ATTEMPTS = 6;
16
  const VERIFY_INTERVAL_MS = 3_000;
17
 
 
 
18
  function parseArgs(argv) {
 
19
  return {
 
20
  restart: !argv.includes('--no-restart'),
 
21
  verify: !argv.includes('--skip-verify')
22
  };
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  function readRequiredEnv(name, fallback) {
26
  const value = process.env[name]?.trim() || fallback;
27
  if (!value) throw new Error(`${name} is required`);
28
  return value;
29
  }
30
 
31
- function readSecretKeys() {
32
- const rawValue = process.env.HF_SPACE_SECRET_KEYS?.trim();
 
 
 
 
 
 
33
  if (!rawValue) return DEFAULT_SECRET_KEYS;
34
  const keys = rawValue
35
  .split(',')
@@ -39,18 +73,18 @@ function readSecretKeys() {
39
  return keys;
40
  }
41
 
42
- function readAccessFileSecrets(accessFile) {
43
- const secrets = new Map();
44
- const text = readFileSync(accessFile, 'utf8');
45
- for (const rawLine of text.split(/\r?\n/)) {
46
- if (!rawLine || rawLine.startsWith('#')) continue;
47
- const separatorIndex = rawLine.indexOf('=');
48
- if (separatorIndex <= 0) continue;
49
- const key = rawLine.slice(0, separatorIndex).trim();
50
- const value = rawLine.slice(separatorIndex + 1);
51
- if (key) secrets.set(key, value);
52
  }
53
- return secrets;
 
 
 
 
 
 
54
  }
55
 
56
  function redactSensitiveText(text, secretValues = []) {
@@ -62,18 +96,40 @@ function redactSensitiveText(text, secretValues = []) {
62
  }
63
 
64
  function runHf(args, options = {}) {
 
65
  const result = spawnSync('hf', args, {
66
  encoding: 'utf8',
67
  stdio: ['ignore', 'pipe', 'pipe']
68
  });
 
 
 
69
  if (result.status !== 0) {
70
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
71
- const safeOutput = redactSensitiveText(output || `hf ${args.join(' ')} failed`, options.secretValues);
72
  throw new Error(safeOutput);
73
  }
74
  return result.stdout || '';
75
  }
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  async function syncSecret({ spaceId, key, value }) {
78
  let lastError;
79
  for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt += 1) {
@@ -83,6 +139,7 @@ async function syncSecret({ spaceId, key, value }) {
83
  });
84
  return;
85
  } catch (error) {
 
86
  lastError = error;
87
  await delay(VERIFY_INTERVAL_MS);
88
  }
@@ -144,9 +201,9 @@ async function verifyAppPassword({ spaceUrl, appPassword }) {
144
  headers: { 'Content-Type': 'application/json' },
145
  body: JSON.stringify({ passwordHash })
146
  });
147
- const body = await response.json().catch(async () => ({ raw: await response.text() }));
148
  if (!response.ok || body?.authenticated !== true) {
149
- throw new Error(`APP_PASSWORD verification failed with HTTP ${response.status}`);
150
  }
151
  return { status: response.status, authenticated: body.authenticated };
152
  } catch (error) {
@@ -157,18 +214,39 @@ async function verifyAppPassword({ spaceUrl, appPassword }) {
157
  throw lastError;
158
  }
159
 
 
 
 
 
 
 
 
 
 
 
160
  async function main() {
161
  const options = parseArgs(process.argv.slice(2));
162
- const spaceId = readRequiredEnv('HF_SPACE_ID', DEFAULT_SPACE_ID);
163
- const spaceUrl = readRequiredEnv('HF_SPACE_URL', DEFAULT_SPACE_URL);
 
 
 
164
  const accessFile = readRequiredEnv('HF_SPACE_ACCESS_FILE', DEFAULT_ACCESS_FILE);
165
- const secretKeys = readSecretKeys();
166
- const secrets = readAccessFileSecrets(accessFile);
 
 
 
 
 
 
167
  const syncedKeys = [];
168
 
 
 
 
169
  for (const key of secretKeys) {
170
- const value = secrets.get(key);
171
- if (!value?.trim()) throw new Error(`${key} is missing or blank in access file`);
172
  await syncSecret({ spaceId, key, value });
173
  syncedKeys.push(key);
174
  }
@@ -186,7 +264,7 @@ async function main() {
186
  if (options.verify && syncedKeys.includes('APP_PASSWORD')) {
187
  verification = await verifyAppPassword({
188
  spaceUrl,
189
- appPassword: secrets.get('APP_PASSWORD')
190
  });
191
  }
192
 
@@ -206,16 +284,18 @@ async function main() {
206
  );
207
  }
208
 
209
- main().catch((error) => {
210
- console.error(
211
- JSON.stringify(
212
- {
213
- ok: false,
214
- error: error instanceof Error ? error.message : String(error)
215
- },
216
- null,
217
- 2
218
- )
219
- );
220
- process.exit(1);
221
- });
 
 
 
2
 
3
  import { execFileSync, spawnSync } from 'node:child_process';
4
  import { createHash } from 'node:crypto';
 
 
5
  import { setTimeout as delay } from 'node:timers/promises';
6
 
7
+ import {
8
+ assertKnownOptions,
9
+ assertSpaceTargetConfig,
10
+ DEFAULT_ACCESS_FILE,
11
+ isMainModule,
12
+ parseAccessFile
13
+ } from './hf-space-doctor-utils.mjs';
14
+
15
  const DEFAULT_SPACE_ID = 'misonL/gpt-image-playground-customer';
16
  const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
 
17
  const DEFAULT_SECRET_KEYS = ['APP_PASSWORD'];
18
+ const FORBIDDEN_ACCESS_KEYS = ['HF_TOKEN', 'HUGGINGFACE_TOKEN', 'HF_PASSWORD', 'HUGGINGFACE_PASSWORD'];
19
  const STATUS_POLL_ATTEMPTS = 20;
20
  const STATUS_POLL_INTERVAL_MS = 5_000;
21
  const VERIFY_ATTEMPTS = 6;
22
  const VERIFY_INTERVAL_MS = 3_000;
23
 
24
+ class NonRetryableHfError extends Error {}
25
+
26
  function parseArgs(argv) {
27
+ assertKnownOptions(argv, ['--help', '-h', '--no-restart', '--skip-verify', '--use-default-target']);
28
  return {
29
+ help: argv.includes('--help') || argv.includes('-h'),
30
  restart: !argv.includes('--no-restart'),
31
+ useDefaultTarget: argv.includes('--use-default-target'),
32
  verify: !argv.includes('--skip-verify')
33
  };
34
  }
35
 
36
+ function printHelp() {
37
+ console.log(`Usage:
38
+ npm run sync-secret:hf-space
39
+
40
+ Options:
41
+ --no-restart Sync secrets without restarting the Space.
42
+ --skip-verify Skip APP_PASSWORD access-code verification.
43
+ --use-default-target Allow the built-in default Space target.
44
+ --help Show this help.
45
+
46
+ Environment overrides:
47
+ HF_SPACE_ACCESS_FILE
48
+ HF_SPACE_ID
49
+ HF_SPACE_URL
50
+ HF_SPACE_SECRET_KEYS`);
51
+ }
52
+
53
  function readRequiredEnv(name, fallback) {
54
  const value = process.env[name]?.trim() || fallback;
55
  if (!value) throw new Error(`${name} is required`);
56
  return value;
57
  }
58
 
59
+ function readConfigValue(name, secrets, fallback) {
60
+ const value = process.env[name]?.trim() || secrets.get(name)?.trim() || fallback;
61
+ if (!value) throw new Error(`${name} is required`);
62
+ return value;
63
+ }
64
+
65
+ function readSecretKeys(secrets) {
66
+ const rawValue = process.env.HF_SPACE_SECRET_KEYS?.trim() || secrets.get('HF_SPACE_SECRET_KEYS')?.trim();
67
  if (!rawValue) return DEFAULT_SECRET_KEYS;
68
  const keys = rawValue
69
  .split(',')
 
73
  return keys;
74
  }
75
 
76
+ function validateSecretValues(secretKeys, secrets) {
77
+ const forbidden = FORBIDDEN_ACCESS_KEYS.filter((key) => secrets.has(key));
78
+ if (forbidden.length) {
79
+ throw new Error(`Access file must not contain Hugging Face credentials: ${forbidden.join(', ')}.`);
 
 
 
 
 
 
80
  }
81
+ const secretValues = new Map();
82
+ for (const key of secretKeys) {
83
+ const value = secrets.get(key);
84
+ if (!value?.trim()) throw new Error(`${key} is missing or blank in access file`);
85
+ secretValues.set(key, value);
86
+ }
87
+ return secretValues;
88
  }
89
 
90
  function redactSensitiveText(text, secretValues = []) {
 
96
  }
97
 
98
  function runHf(args, options = {}) {
99
+ const commandLabel = redactSensitiveText(`hf ${args.join(' ')}`, options.secretValues);
100
  const result = spawnSync('hf', args, {
101
  encoding: 'utf8',
102
  stdio: ['ignore', 'pipe', 'pipe']
103
  });
104
+ if (result.error) {
105
+ throw new NonRetryableHfError(`${commandLabel} failed: ${result.error.message}`);
106
+ }
107
  if (result.status !== 0) {
108
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
109
+ const safeOutput = redactSensitiveText(output || `${commandLabel} failed`, options.secretValues);
110
  throw new Error(safeOutput);
111
  }
112
  return result.stdout || '';
113
  }
114
 
115
+ function assertHfAuthenticated() {
116
+ try {
117
+ runHf(['auth', 'whoami']);
118
+ } catch (error) {
119
+ const cause = error instanceof Error ? error.message : String(error);
120
+ throw new Error(`Hugging Face CLI is not authenticated. Run "hf auth login" with an access token that can manage the target Space. Cause: ${cause}`);
121
+ }
122
+ }
123
+
124
+ function assertSpaceReadable(spaceId) {
125
+ try {
126
+ runHf(['spaces', 'info', spaceId, '--format', 'json']);
127
+ } catch (error) {
128
+ const cause = error instanceof Error ? error.message : String(error);
129
+ throw new Error(`Cannot read Hugging Face Space "${spaceId}". Check HF_SPACE_ID and the logged-in token permissions. Cause: ${cause}`);
130
+ }
131
+ }
132
+
133
  async function syncSecret({ spaceId, key, value }) {
134
  let lastError;
135
  for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt += 1) {
 
139
  });
140
  return;
141
  } catch (error) {
142
+ if (error instanceof NonRetryableHfError) throw error;
143
  lastError = error;
144
  await delay(VERIFY_INTERVAL_MS);
145
  }
 
201
  headers: { 'Content-Type': 'application/json' },
202
  body: JSON.stringify({ passwordHash })
203
  });
204
+ const body = await readJsonResponseBody(response);
205
  if (!response.ok || body?.authenticated !== true) {
206
+ throw new Error(`APP_PASSWORD access-code verification failed with HTTP ${response.status}`);
207
  }
208
  return { status: response.status, authenticated: body.authenticated };
209
  } catch (error) {
 
214
  throw lastError;
215
  }
216
 
217
+ export async function readJsonResponseBody(response) {
218
+ const text = await response.text();
219
+ if (!text) return {};
220
+ try {
221
+ return JSON.parse(text);
222
+ } catch {
223
+ return { raw: text.slice(0, 200) };
224
+ }
225
+ }
226
+
227
  async function main() {
228
  const options = parseArgs(process.argv.slice(2));
229
+ if (options.help) {
230
+ printHelp();
231
+ return;
232
+ }
233
+
234
  const accessFile = readRequiredEnv('HF_SPACE_ACCESS_FILE', DEFAULT_ACCESS_FILE);
235
+ const secrets = parseAccessFile(accessFile);
236
+ const fallbackSpaceId = options.useDefaultTarget ? DEFAULT_SPACE_ID : undefined;
237
+ const fallbackSpaceUrl = options.useDefaultTarget ? DEFAULT_SPACE_URL : undefined;
238
+ const spaceId = readConfigValue('HF_SPACE_ID', secrets, fallbackSpaceId);
239
+ const spaceUrl = readConfigValue('HF_SPACE_URL', secrets, fallbackSpaceUrl);
240
+ assertSpaceTargetConfig({ spaceId, spaceUrl });
241
+ const secretKeys = readSecretKeys(secrets);
242
+ const secretValues = validateSecretValues(secretKeys, secrets);
243
  const syncedKeys = [];
244
 
245
+ assertHfAuthenticated();
246
+ assertSpaceReadable(spaceId);
247
+
248
  for (const key of secretKeys) {
249
+ const value = secretValues.get(key);
 
250
  await syncSecret({ spaceId, key, value });
251
  syncedKeys.push(key);
252
  }
 
264
  if (options.verify && syncedKeys.includes('APP_PASSWORD')) {
265
  verification = await verifyAppPassword({
266
  spaceUrl,
267
+ appPassword: secretValues.get('APP_PASSWORD')
268
  });
269
  }
270
 
 
284
  );
285
  }
286
 
287
+ if (isMainModule(import.meta.url, process.argv[1])) {
288
+ main().catch((error) => {
289
+ console.error(
290
+ JSON.stringify(
291
+ {
292
+ ok: false,
293
+ error: error instanceof Error ? error.message : String(error)
294
+ },
295
+ null,
296
+ 2
297
+ )
298
+ );
299
+ process.exit(1);
300
+ });
301
+ }
scripts/sync-hf-space-secret.test.mjs ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { describe, it } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const SCRIPT_PATH = fileURLToPath(new URL('./sync-hf-space-secret.mjs', import.meta.url));
10
+ const SCRIPT_URL = new URL('./sync-hf-space-secret.mjs', import.meta.url).href;
11
+
12
+ function makeTempDir() {
13
+ return mkdtempSync(join(tmpdir(), 'gpt-image-playground-sync-'));
14
+ }
15
+
16
+ function runSync(accessFile, env = {}) {
17
+ return spawnSync(process.execPath, [SCRIPT_PATH, '--no-restart', '--skip-verify'], {
18
+ encoding: 'utf8',
19
+ env: {
20
+ ...process.env,
21
+ HF_SPACE_ACCESS_FILE: accessFile,
22
+ ...env
23
+ },
24
+ stdio: ['ignore', 'pipe', 'pipe']
25
+ });
26
+ }
27
+
28
+ describe('HF Space secret sync preflight', () => {
29
+ it('can be imported without executing the CLI', async () => {
30
+ await assert.doesNotReject(import(`${SCRIPT_URL}?import-smoke=${Date.now()}`));
31
+ });
32
+
33
+ it('parses verification responses without reading non-JSON bodies twice', async () => {
34
+ const module = await import(`${SCRIPT_URL}?json-body=${Date.now()}`);
35
+ const body = await module.readJsonResponseBody(new Response('not json', { status: 502 }));
36
+
37
+ assert.deepEqual(body, { raw: 'not json' });
38
+ });
39
+
40
+ it('prints help without requiring an access file or remote auth', () => {
41
+ const result = spawnSync(process.execPath, [SCRIPT_PATH, '--help'], {
42
+ encoding: 'utf8',
43
+ env: {
44
+ ...process.env,
45
+ HF_SPACE_ACCESS_FILE: join(tmpdir(), 'missing-access-file.txt')
46
+ },
47
+ stdio: ['ignore', 'pipe', 'pipe']
48
+ });
49
+
50
+ assert.equal(result.status, 0, result.stderr);
51
+ assert.match(result.stdout, /Usage:/);
52
+ assert.match(result.stdout, /--use-default-target/);
53
+ });
54
+
55
+ it('rejects unknown options before remote operations', () => {
56
+ const result = spawnSync(process.execPath, [SCRIPT_PATH, '--unknown'], {
57
+ encoding: 'utf8',
58
+ stdio: ['ignore', 'pipe', 'pipe']
59
+ });
60
+
61
+ assert.notEqual(result.status, 0);
62
+ assert.match(result.stderr, /Unknown option/);
63
+ assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/);
64
+ });
65
+
66
+ it('rejects non-Hugging Face Space URLs before remote operations', () => {
67
+ const dir = makeTempDir();
68
+ const accessFile = join(dir, 'access.txt');
69
+ writeFileSync(
70
+ accessFile,
71
+ [
72
+ 'HF_SPACE_ID=example/demo',
73
+ 'HF_SPACE_URL=https://example.com',
74
+ 'HF_SPACE_SECRET_KEYS=APP_PASSWORD',
75
+ 'APP_PASSWORD=abcdefghijklmnopqrstuvwxyz',
76
+ ''
77
+ ].join('\n'),
78
+ { encoding: 'utf8', mode: 0o600 }
79
+ );
80
+
81
+ const result = runSync(accessFile);
82
+
83
+ assert.notEqual(result.status, 0);
84
+ assert.match(result.stderr, /\.hf\.space/);
85
+ assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/);
86
+
87
+ rmSync(dir, { force: true, recursive: true });
88
+ });
89
+
90
+ it('rejects Hugging Face account credentials in the access file before remote operations', () => {
91
+ const dir = makeTempDir();
92
+ const accessFile = join(dir, 'access.txt');
93
+ writeFileSync(
94
+ accessFile,
95
+ [
96
+ 'HF_SPACE_ID=example/demo',
97
+ 'HF_SPACE_URL=https://example-demo.hf.space',
98
+ 'HF_SPACE_SECRET_KEYS=APP_PASSWORD',
99
+ 'APP_PASSWORD=abcdefghijklmnopqrstuvwxyz',
100
+ 'HF_TOKEN=hf_account_token_should_not_be_synced',
101
+ ''
102
+ ].join('\n'),
103
+ { encoding: 'utf8', mode: 0o600 }
104
+ );
105
+
106
+ const result = runSync(accessFile);
107
+
108
+ assert.notEqual(result.status, 0);
109
+ assert.match(result.stderr, /must not contain Hugging Face credentials/);
110
+ assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/);
111
+
112
+ rmSync(dir, { force: true, recursive: true });
113
+ });
114
+
115
+ it('redacts secret values if hf fails while syncing a secret', () => {
116
+ const dir = makeTempDir();
117
+ const accessFile = join(dir, 'access.txt');
118
+ const hfPath = join(dir, 'hf');
119
+ const secretValue = 'super-secret-access-code';
120
+ writeFileSync(
121
+ accessFile,
122
+ [
123
+ 'HF_SPACE_ID=example/demo',
124
+ 'HF_SPACE_URL=https://example-demo.hf.space',
125
+ 'HF_SPACE_SECRET_KEYS=APP_PASSWORD',
126
+ `APP_PASSWORD=${secretValue}`,
127
+ ''
128
+ ].join('\n'),
129
+ { encoding: 'utf8', mode: 0o600 }
130
+ );
131
+ writeFileSync(
132
+ hfPath,
133
+ [
134
+ '#!/bin/sh',
135
+ 'if [ "$1 $2" = "auth whoami" ]; then exit 0; fi',
136
+ 'if [ "$1 $2" = "spaces info" ]; then /bin/chmod 000 "$0"; echo "{}"; exit 0; fi',
137
+ 'exit 1',
138
+ ''
139
+ ].join('\n'),
140
+ { encoding: 'utf8', mode: 0o755 }
141
+ );
142
+
143
+ const result = runSync(accessFile, { PATH: dir });
144
+
145
+ assert.notEqual(result.status, 0);
146
+ assert.doesNotMatch(result.stderr, new RegExp(secretValue));
147
+ assert.match(result.stderr, /APP_PASSWORD=\[redacted\]/);
148
+
149
+ rmSync(dir, { force: true, recursive: true });
150
+ });
151
+ });
skills/gpt-image-playground-agent/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
  name: gpt-image-playground-agent
3
- description: 调用已部署的 GPT Image Playground Agent API,面向 CodexClaude Code、Gemini 等自动化 Agent 的图片生成图片编辑任务。适用于用户部署了本项目的任意实例包括本机一键脚本、Docker、局域网服务器、云服务器、域名或自定义端口,需要先定位服务地址,再通过 /api/agent/* 处理 Idempotency-Key、结构化 AgentError、重试错误、产物 metadata/content URLBearer token password-hash 鉴权,以及 response_mode path/base64/both 的场景
4
  ---
5
 
6
  # GPT Image Playground Agent
@@ -11,13 +11,15 @@ description: 调用已部署的 GPT Image Playground Agent API,面向 Codex、
11
 
12
  1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。
13
  2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。
14
- 3. 读取 capabilities 中的认证方式、模型、限制、状态后端和端点路径;不要硬编码假设部署方式。
15
  4. 为每个业务操作生成稳定的 `Idempotency-Key`。同一操作重试时复用原 key;不同操作不要复用。
16
- 5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。
17
- 6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。
18
  7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
19
- 8. 处理失败时读取结构化 `error.code`、`error.retryable` `Retry-After`仅当 `retryable=true` 时等待后重试
20
- 9. 返回结果时优先给出 `content_url``metadata_url``absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存
 
 
21
 
22
  ## 鉴权
23
 
@@ -27,30 +29,85 @@ description: 调用已部署的 GPT Image Playground Agent API,面向 Codex、
27
  Authorization: Bearer <token>
28
  ```
29
 
30
- 如果服务端使用 `APP_PASSWORD`,发送 `X-App-Password-Hash`。下载或删除产物时必须复用同一鉴权方式。
31
 
32
  ## 调用约束
33
 
34
- - 不要把 API Key、token 或码写入源码、文档示例、日志或测试快照。
35
  - 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。
36
  - 不要在模型上下文中展开大体积 base64,除非用户明确要求。
37
  - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
38
  - 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  ## 可用脚本
41
 
42
- - `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。
43
- - `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  脚本读取以下环境变量:
46
 
47
  - `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。
48
  - `GPT_IMAGE_AGENT_TOKEN`:Bearer token。
49
- - `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 部署时发送的 `X-App-Password-Hash`。
50
  - `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键。
51
  - `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。
52
  - `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。
53
 
 
 
54
  脚本会把服务返回的相对产物路径补充为绝对 URL,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。
55
 
56
  ## 参考
 
1
  ---
2
  name: gpt-image-playground-agent
3
+ description: 当用户需要通过 API 调用已部署的 GPT Image Playground 批量请求图片生成时使用;支持文字生成图片文字加图片生成图片,并返回下载图片产物metadata、base64job 结果
4
  ---
5
 
6
  # GPT Image Playground Agent
 
11
 
12
  1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。
13
  2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。
14
+ 3. 读取 capabilities 中的认证方式、模型、模型级限制、Agent 流式边界、状态后端和端点路径;不要硬编码假设部署方式。
15
  4. 为每个业务操作生成稳定的 `Idempotency-Key`。同一操作重试时复用原 key;不同操作不要复用。
16
+ 5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点是非流式端点,当前固定以 `stream: false` 调上游。
17
+ 6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。该 Agent 端点同样是非流式端点。
18
  7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
19
+ 8. 不要把页面端 `POST /api/images` 当成 Agent 默认路径它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明
20
+ 9. 读取 `agent_jobs`。若 `supported=true``mode=job_polling`,4K/high 或长耗时任务优先走 job/polling
21
+ 10. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。
22
+ 11. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。
23
 
24
  ## 鉴权
25
 
 
29
  Authorization: Bearer <token>
30
  ```
31
 
32
+ 此时服务端只接受 Bearer token,不会回退到访问码哈希。如果未配置 `AGENT_API_TOKEN` 但配置了页面访问码 `APP_PASSWORD`,发送 `X-App-Password-Hash`。下载或删除产物时必须复用 capabilities 声明的同一鉴权方式。
33
 
34
  ## 调用约束
35
 
36
+ - 不要把 API Key、token 或访问码写入源码、文档示例、日志或测试快照。
37
  - 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。
38
  - 不要在模型上下文中展开大体积 base64,除非用户明确要求。
39
  - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
40
  - 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
41
+ - 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
42
+ - 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 支持流式;Agent generate/edit 当前以 `non_streaming_only` 声明。
43
+ - 不要调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`。
44
+ - 不要把一次高分辨率、高质量长耗时失败���纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。
45
+
46
+ ## Job Polling
47
+
48
+ 当 `agent_jobs.supported=true` 时,长耗时文生图可使用:
49
+
50
+ 1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。
51
+ 2. `GET /api/agent/jobs/{id}` 轮询状态。
52
+ 3. `GET /api/agent/jobs/{id}/result` 在 `state=succeeded` 后读取标准 `AgentImageResponse`。
53
+
54
+ `GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。
55
+
56
+ 当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新创建同一 `Idempotency-Key` 的 job。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。
57
 
58
  ## 可用脚本
59
 
60
+ - `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实生图。
61
+ - `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
62
+ - `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
63
+
64
+ 生成脚本常用参数:
65
+
66
+ ```bash
67
+ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
68
+ --size 2048x2048 \
69
+ --quality high \
70
+ --response-mode path \
71
+ --idempotency-key stable-operation-key \
72
+ "a product photo of a ceramic mug"
73
+ ```
74
+
75
+ 真实生图必须显式开启:
76
+
77
+ ```bash
78
+ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
79
+ --allow-billable \
80
+ --timeout-ms 420000 \
81
+ --size 2048x2048 \
82
+ "a product photo of a ceramic mug"
83
+ ```
84
+
85
+ 生成脚本会在 capabilities 声明 `agent_jobs.supported=true` 后,对 `quality=high` 且最大边不小于 3072 的请求自动使用 job polling。也可以用 `--job` 强制 job polling,或用 `--no-job` 强制同步 Agent generate。
86
+
87
+ 编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--timeout-ms`、`--idempotency-key`、`--dry-run` 和 `--allow-billable`。
88
+
89
+ 直连上游诊断:
90
+
91
+ ```bash
92
+ OPENAI_API_KEY=... node skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs \
93
+ --base-url https://api.openai.com/v1
94
+ ```
95
+
96
+ 诊断脚本只输出状态、耗时、脱敏错误摘要、白名单响应头和 base64 长度,不输出 API key 或完整图片数据。
97
+
98
+ 上游探针脚本支持 `--base-url`、`--model`、`--prompt`、`--size`、`--quality`、`--format`、`--timeout-ms` 和 `--allow-billable`。默认读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL`,API Key 读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY`。上游 base URL 同样必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 URL。
99
 
100
  脚本读取以下环境变量:
101
 
102
  - `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。
103
  - `GPT_IMAGE_AGENT_TOKEN`:Bearer token。
104
+ - `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时发送的 `X-App-Password-Hash`。
105
  - `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键。
106
  - `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。
107
  - `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。
108
 
109
+ `GPT_IMAGE_PLAYGROUND_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 base URL。不要把 token、访问码或其他 Secret 放进 URL。生成脚本轮询 job result 时只会携带鉴权头访问同 origin URL,避免异常服务返回外部 `result_url` 后泄露 Bearer token 或访问码哈希。
110
+
111
  脚本会把服务返回的相对产物路径补充为绝对 URL,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。
112
 
113
  ## 参考
skills/gpt-image-playground-agent/references/api.md CHANGED
@@ -4,10 +4,52 @@
4
 
5
  - `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。
6
  - `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。
 
7
 
8
- 脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 做读契约检查,不触发真实生图或编辑。
9
- 鉴权支持 `GPT_IMAGE_AGENT_TOKEN` `GPT_IMAGE_APP_PASSWORD_HASH`。
 
 
10
  当服务返回相对 `content_url` 或 `metadata_url` 时,辅助脚本会额外输出 `absolute_content_url` 和 `absolute_metadata_url`。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  ## 能力查询
13
 
@@ -15,7 +57,74 @@
15
  GET /api/agent/capabilities
16
  ```
17
 
18
- 返回 API 版本、支持的模型、限制、鉴权方式、存储模式、状态后端、幂等设置和端点路径。响应不会公开服务端本地 SQLite 文件路径。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  ## 生成图片
21
 
@@ -42,6 +151,8 @@ Content-Type: application/json
42
  }
43
  ```
44
 
 
 
45
  响应:
46
 
47
  ```json
@@ -112,11 +223,26 @@ DELETE /api/agent/artifacts/{id}
112
  "n": "必须是 1 到 10 之间的整数"
113
  }
114
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  "request_id": "uuid"
116
  }
117
  }
118
  ```
119
 
 
 
120
  常见错误码:
121
 
122
  - `validation_error`
@@ -126,6 +252,8 @@ DELETE /api/agent/artifacts/{id}
126
  - `idempotency_conflict`
127
  - `request_in_progress`
128
  - `artifact_not_found`
 
 
129
  - `upstream_rate_limited`
130
  - `upstream_auth_failed`
131
  - `upstream_unavailable`
 
4
 
5
  - `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。
6
  - `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。
7
+ - `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
8
 
9
+ 生成和编辑脚本默认做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会调用 `/api/agent/images/generate` 或 `/api/agent/images/edit`。
10
+ 上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
11
+ 脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
12
+ 鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。
13
  当服务返回相对 `content_url` 或 `metadata_url` 时,辅助脚本会额外输出 `absolute_content_url` 和 `absolute_metadata_url`。
14
+ 同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
15
+
16
+ 生成脚本参数:
17
+
18
+ - `--model`:默认 `gpt-image-2`。
19
+ - `--size`:默认 `1024x1024`。
20
+ - `--quality`:默认 `high`。
21
+ - `--n`:默认 `1`。
22
+ - `--format`:默认 `png`,`jpg` 会规范化为 `jpeg`。
23
+ - `--response-mode`:默认 `path`。
24
+ - `--timeout-ms`:默认 `420000`。
25
+ - `--prompt-file`:从文本文件读取 prompt。
26
+ - `--idempotency-key`:指定稳定幂等键。
27
+ - `--dry-run`:只输出将要发送的 JSON。
28
+ - `--allow-billable`:允许真实调用生图端点。
29
+
30
+ 编辑脚本参数:
31
+
32
+ - `--model`
33
+ - `--size`
34
+ - `--quality`
35
+ - `--response-mode`
36
+ - `--timeout-ms`
37
+ - `--idempotency-key`
38
+ - `--dry-run`
39
+ - `--allow-billable`
40
+
41
+ 上游探针脚本参数:
42
+
43
+ - `--base-url`
44
+ - `--model`
45
+ - `--prompt`
46
+ - `--size`
47
+ - `--quality`
48
+ - `--format`
49
+ - `--timeout-ms`
50
+ - `--allow-billable`
51
+
52
+ 上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。输出不会包含 key,也不会输出完整 base64。
53
 
54
  ## 能力查询
55
 
 
57
  GET /api/agent/capabilities
58
  ```
59
 
60
+ 返回 API 版本、支持的模型、通用限制、模型级限制、Agent 流式边界、鉴权方式、存储模式、状态后端、幂等设置和端点路径。响应不会公开服务端本地 SQLite 文件路径。
61
+
62
+ 关键字段:
63
+
64
+ - `auth.required`:是否需要鉴权。
65
+ - `auth.schemes`:当前部署实际接受的鉴权方案。`AGENT_API_TOKEN` 优先于 `APP_PASSWORD`,两者同时配置时只返回 `bearer`。
66
+ - `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。
67
+ - `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。
68
+ - `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。
69
+ - `model_limits.gpt-image-2.max_aspect`:最大长短边比例,当前为 `3`。
70
+ - `model_limits.gpt-image-2.min_pixels`:最小总像素,当前为 `655360`。
71
+ - `model_limits.gpt-image-2.recommended_presets`:推荐尺寸预设。
72
+ - `model_limits.gpt-image-2.high_4k_risk`:高质量 4K 级请求的长耗时风险说明。
73
+ - `agent_streaming.generate.mode`:当前为 `non_streaming_only`。
74
+ - `agent_streaming.edit.mode`:当前为 `non_streaming_only`。
75
+ - `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。
76
+ - `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。
77
+ - `agent_jobs.mode`:当前为 `job_polling`。
78
+ - `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。
79
+ - `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
80
+
81
+ 当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,4K/high 长耗时请求优先创建 job 并轮询结果;同步 Agent generate 仍适用于普通非流式请求。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。
82
+
83
+ ## Job Polling
84
+
85
+ ```http
86
+ POST /api/agent/jobs/images/generate
87
+ Authorization: Bearer <token>
88
+ Idempotency-Key: <stable-key>
89
+ Content-Type: application/json
90
+ ```
91
+
92
+ 请求体与 `POST /api/agent/images/generate` 相同。创建成功后返回:
93
+
94
+ ```json
95
+ {
96
+ "job": {
97
+ "id": "job-request-uuid",
98
+ "request_id": "job-request-uuid",
99
+ "idempotency_key": "stable-key",
100
+ "mode": "generate",
101
+ "state": "running",
102
+ "created_at": "2026-05-20T00:00:00.000Z",
103
+ "updated_at": "2026-05-20T00:00:00.000Z",
104
+ "expires_at": "2026-05-21T00:00:00.000Z",
105
+ "result_url": "/api/agent/jobs/job-request-uuid/result",
106
+ "retry_after_seconds": 5
107
+ }
108
+ }
109
+ ```
110
+
111
+ 轮询状态:
112
+
113
+ ```http
114
+ GET /api/agent/jobs/{id}
115
+ ```
116
+
117
+ 读取结果:
118
+
119
+ ```http
120
+ GET /api/agent/jobs/{id}/result
121
+ ```
122
+
123
+ `/result` 在运行中返回 `request_in_progress` 和 `Retry-After`;成功后返回标准 `AgentImageResponse`;失败时返回结构化 `AgentError`。失败 job 是终态,`error.retryable` 固定为 `false`,但保留原始错误的 `code`、`message`、`upstream_status` 和 `diagnostics` 用于排查。不存在返回 `job_not_found`,过期返回 `job_expired`。
124
+
125
+ `GET /api/agent/jobs/{id}` 在 `state=failed` 时,`job.error` 也会返回 `retryable=false`,并携带同样的 `code`、`message`、`upstream_status` 和 `diagnostics` 排障字段;`request_id` 已在 `job.request_id` 中提供。
126
+
127
+ 如果服务进程在 job 结束前重启,客户端应按 `GET /api/agent/jobs/{id}` 返回的状态继续处理;必要时使用相同 `Idempotency-Key` 重新创建同一 job,避免重复业务操作。同一个 key 命中终态 failed job 时只会返回该失败状态,不会触发新执行;需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
128
 
129
  ## 生成图片
130
 
 
151
  }
152
  ```
153
 
154
+ Agent 生成端点当前只支持非流式 JSON 响应。不要向该端点发送 `stream: true`;页面 SSE 使用独立的 `POST /api/images` form-data 路径。
155
+
156
  响应:
157
 
158
  ```json
 
223
  "n": "必须是 1 到 10 之间的整数"
224
  }
225
  },
226
+ "diagnostics": {
227
+ "elapsed_ms": 1234,
228
+ "selected_channel_id": "default",
229
+ "upstream_host": "api.example.test",
230
+ "upstream_status": 524,
231
+ "transport_error": false,
232
+ "retry_after_seconds": 15,
233
+ "channel_cooldown_scope": "channel",
234
+ "response_headers": {
235
+ "date": "Wed, 20 May 2026 00:00:00 GMT",
236
+ "cf-ray": "example"
237
+ }
238
+ },
239
  "request_id": "uuid"
240
  }
241
  }
242
  ```
243
 
244
+ `diagnostics` 只包含脱敏诊断字段和白名单响应头,不包含 API key、token、完整上游响应体或图片 base64。SDK/网络层只有 `Connection error.` 时,`transport_error` 会是 `true`,但不会伪造 `upstream_status`。
245
+
246
  常见错误码:
247
 
248
  - `validation_error`
 
252
  - `idempotency_conflict`
253
  - `request_in_progress`
254
  - `artifact_not_found`
255
+ - `job_not_found`
256
+ - `job_expired`
257
  - `upstream_rate_limited`
258
  - `upstream_auth_failed`
259
  - `upstream_unavailable`
skills/gpt-image-playground-agent/scripts/edit-image.mjs CHANGED
@@ -2,33 +2,123 @@
2
  import crypto from 'node:crypto';
3
  import fs from 'node:fs';
4
  import path from 'node:path';
 
 
 
 
 
 
 
 
5
 
6
- const baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
7
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
8
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
9
- const [imagePath, ...promptParts] = process.argv.slice(2);
10
- const prompt = promptParts.join(' ');
11
- const parsedMaxAttempts = parseInt(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS || '3', 10);
12
- const maxAttempts = Number.isInteger(parsedMaxAttempts) && parsedMaxAttempts > 0 ? parsedMaxAttempts : 3;
13
- const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1';
14
- const idempotencyKey = process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-edit-${crypto.randomUUID()}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  if ((!imagePath || !prompt) && !contractCheck) {
17
- console.error('用法:edit-image.mjs <image-path> <prompt>');
18
- console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs');
 
 
 
 
 
 
 
19
  process.exit(2);
20
  }
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  function authHeaders() {
23
  if (token) return { Authorization: `Bearer ${token}` };
24
  if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
25
  return {};
26
  }
27
 
28
- function normalizeBaseUrl(value) {
29
- return value.replace(/\/+$/, '');
30
- }
31
-
32
  function absoluteUrl(value) {
33
  if (typeof value !== 'string' || !value) return undefined;
34
  return new URL(value, `${baseUrl}/`).toString();
@@ -49,11 +139,11 @@ function enrichImageUrls(result) {
49
  async function readCapabilities() {
50
  let response;
51
  try {
52
- response = await fetch(`${baseUrl}/api/agent/capabilities`, {
53
  headers: authHeaders()
54
  });
55
  } catch (error) {
56
- const message = error instanceof Error ? error.message : String(error);
57
  throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`);
58
  }
59
  if (!response.ok) {
@@ -63,28 +153,36 @@ async function readCapabilities() {
63
  return response.json();
64
  }
65
 
66
- function parseRetryAfterValue(value) {
67
- if (!value || !/^\d+$/.test(value)) return 1;
68
- return Math.max(1, Number(value));
69
- }
70
-
71
  function shouldRetry(result) {
72
  return Boolean(result?.error?.retryable);
73
  }
74
 
75
- function sleep(seconds) {
76
- return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  }
78
 
79
  try {
80
  await readCapabilities();
81
  } catch (error) {
82
- console.error(error instanceof Error ? error.message : String(error));
83
  process.exit(1);
84
  }
85
 
86
  if (contractCheck) {
87
- const response = await fetch(`${baseUrl}/api/agent/images/edit`, {
88
  method: 'POST',
89
  headers: {
90
  'Idempotency-Key': idempotencyKey,
@@ -95,10 +193,10 @@ if (contractCheck) {
95
  });
96
  const result = await response.json();
97
  if (response.status === 415 && result?.error?.code === 'validation_error') {
98
- console.log(JSON.stringify({ ok: true, status: response.status, error_code: result.error.code }, null, 2));
99
  process.exit(0);
100
  }
101
- console.error(JSON.stringify({ ok: false, status: response.status, result }, null, 2));
102
  process.exit(1);
103
  }
104
 
@@ -109,7 +207,7 @@ try {
109
  process.exit(2);
110
  }
111
  } catch (error) {
112
- const message = error instanceof Error ? error.message : String(error);
113
  console.error(`无法读取图片文件:${imagePath}。${message}`);
114
  process.exit(2);
115
  }
@@ -120,8 +218,10 @@ const imageType = mimeTypeForPath(imagePath);
120
  function buildFormData() {
121
  const formData = new FormData();
122
  formData.append('prompt', prompt);
123
- formData.append('model', 'gpt-image-2');
124
- formData.append('response_mode', 'path');
 
 
125
  formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
126
  return formData;
127
  }
@@ -140,7 +240,7 @@ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
140
  let response;
141
  let result;
142
  try {
143
- response = await fetch(`${baseUrl}/api/agent/images/edit`, {
144
  method: 'POST',
145
  headers: {
146
  'Idempotency-Key': idempotencyKey,
@@ -150,7 +250,7 @@ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
150
  });
151
  result = await response.json();
152
  } catch (error) {
153
- const message = error instanceof Error ? error.message : String(error);
154
  result = { error: { code: 'network_error', message, retryable: true } };
155
  lastResult = result;
156
  lastRetryAfter = 1;
 
2
  import crypto from 'node:crypto';
3
  import fs from 'node:fs';
4
  import path from 'node:path';
5
+ import {
6
+ errorMessage,
7
+ normalizeBaseUrl,
8
+ parseRetryAfterValue,
9
+ readConfiguredPositiveInteger,
10
+ readOptionValue,
11
+ sleep
12
+ } from './lib/script-utils.mjs';
13
 
 
14
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
15
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
16
+ const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check');
17
+ let options;
18
+ try {
19
+ options = parseArgs(process.argv.slice(2));
20
+ } catch (error) {
21
+ console.error(errorMessage(error));
22
+ printUsage();
23
+ process.exit(2);
24
+ }
25
+ const imagePath = options.imagePath;
26
+ const prompt = options.promptParts.join(' ');
27
+ if (options.help) {
28
+ printUsage();
29
+ process.exit(0);
30
+ }
31
+
32
+ let maxAttempts;
33
+ let timeoutMs;
34
+ try {
35
+ maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3);
36
+ timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
37
+ } catch (error) {
38
+ console.error(errorMessage(error));
39
+ printUsage();
40
+ process.exit(2);
41
+ }
42
+ const idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-edit-${crypto.randomUUID()}`;
43
 
44
  if ((!imagePath || !prompt) && !contractCheck) {
45
+ printUsage();
46
+ process.exit(2);
47
+ }
48
+
49
+ let baseUrl;
50
+ try {
51
+ baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
52
+ } catch (error) {
53
+ console.error(errorMessage(error));
54
  process.exit(2);
55
  }
56
 
57
+ if (options.dryRun || (!contractCheck && !options.allowBillable)) {
58
+ console.log(
59
+ JSON.stringify(
60
+ {
61
+ ok: true,
62
+ billable: false,
63
+ dry_run: true,
64
+ endpoint: `${baseUrl}/api/agent/images/edit`,
65
+ idempotency_key: idempotencyKey,
66
+ request: {
67
+ image_path: imagePath,
68
+ prompt,
69
+ model: options.model,
70
+ size: options.size,
71
+ quality: options.quality,
72
+ response_mode: options.responseMode
73
+ },
74
+ next_step: '重新执行并添加 --allow-billable 才会发起真实图片编辑请求。'
75
+ },
76
+ null,
77
+ 2
78
+ )
79
+ );
80
+ process.exit(0);
81
+ }
82
+
83
+ function parseArgs(argv) {
84
+ const parsed = {
85
+ model: 'gpt-image-2',
86
+ size: 'auto',
87
+ quality: 'auto',
88
+ responseMode: 'path',
89
+ timeoutMs: undefined,
90
+ idempotencyKey: undefined,
91
+ imagePath: undefined,
92
+ dryRun: false,
93
+ allowBillable: false,
94
+ help: false,
95
+ promptParts: []
96
+ };
97
+ for (let index = 0; index < argv.length; index += 1) {
98
+ const arg = argv[index];
99
+ if (arg === '--dry-run') parsed.dryRun = true;
100
+ else if (arg === '--allow-billable') parsed.allowBillable = true;
101
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
102
+ else if (arg === '--contract-check') continue;
103
+ else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
104
+ else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
105
+ else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
106
+ else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
107
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
108
+ else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
109
+ else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
110
+ else if (!parsed.imagePath) parsed.imagePath = arg;
111
+ else parsed.promptParts.push(arg);
112
+ }
113
+ return parsed;
114
+ }
115
+
116
  function authHeaders() {
117
  if (token) return { Authorization: `Bearer ${token}` };
118
  if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
119
  return {};
120
  }
121
 
 
 
 
 
122
  function absoluteUrl(value) {
123
  if (typeof value !== 'string' || !value) return undefined;
124
  return new URL(value, `${baseUrl}/`).toString();
 
139
  async function readCapabilities() {
140
  let response;
141
  try {
142
+ response = await fetchWithTimeout(`${baseUrl}/api/agent/capabilities`, {
143
  headers: authHeaders()
144
  });
145
  } catch (error) {
146
+ const message = errorMessage(error);
147
  throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`);
148
  }
149
  if (!response.ok) {
 
153
  return response.json();
154
  }
155
 
 
 
 
 
 
156
  function shouldRetry(result) {
157
  return Boolean(result?.error?.retryable);
158
  }
159
 
160
+ async function fetchWithTimeout(url, init) {
161
+ const controller = new AbortController();
162
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
163
+ try {
164
+ return await fetch(url, { ...init, signal: controller.signal });
165
+ } finally {
166
+ clearTimeout(timeout);
167
+ }
168
+ }
169
+
170
+ function printUsage() {
171
+ console.error('用法:edit-image.mjs [options] <image-path> <prompt>');
172
+ console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。');
173
+ console.error('常用参数:--model --size --quality --response-mode --timeout-ms --idempotency-key --dry-run --allow-billable');
174
+ console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check');
175
  }
176
 
177
  try {
178
  await readCapabilities();
179
  } catch (error) {
180
+ console.error(errorMessage(error));
181
  process.exit(1);
182
  }
183
 
184
  if (contractCheck) {
185
+ const response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, {
186
  method: 'POST',
187
  headers: {
188
  'Idempotency-Key': idempotencyKey,
 
193
  });
194
  const result = await response.json();
195
  if (response.status === 415 && result?.error?.code === 'validation_error') {
196
+ console.log(JSON.stringify({ ok: true, billable: false, status: response.status, error_code: result.error.code }, null, 2));
197
  process.exit(0);
198
  }
199
+ console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
200
  process.exit(1);
201
  }
202
 
 
207
  process.exit(2);
208
  }
209
  } catch (error) {
210
+ const message = errorMessage(error);
211
  console.error(`无法读取图片文件:${imagePath}。${message}`);
212
  process.exit(2);
213
  }
 
218
  function buildFormData() {
219
  const formData = new FormData();
220
  formData.append('prompt', prompt);
221
+ formData.append('model', options.model);
222
+ formData.append('size', options.size);
223
+ formData.append('quality', options.quality);
224
+ formData.append('response_mode', options.responseMode);
225
  formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
226
  return formData;
227
  }
 
240
  let response;
241
  let result;
242
  try {
243
+ response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, {
244
  method: 'POST',
245
  headers: {
246
  'Idempotency-Key': idempotencyKey,
 
250
  });
251
  result = await response.json();
252
  } catch (error) {
253
+ const message = errorMessage(error);
254
  result = { error: { code: 'network_error', message, retryable: true } };
255
  lastResult = result;
256
  lastRetryAfter = 1;
skills/gpt-image-playground-agent/scripts/generate-image.mjs CHANGED
@@ -1,29 +1,205 @@
1
  #!/usr/bin/env node
2
  import crypto from 'node:crypto';
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- const baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
5
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
6
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
7
- const prompt = process.argv.slice(2).join(' ');
8
- const maxAttempts = Number(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS || '3');
9
- const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1';
 
 
 
 
 
 
 
 
 
 
10
 
11
- if (!prompt && !contractCheck) {
12
- console.error('用法:generate-image.mjs <prompt>');
13
- console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  process.exit(2);
15
  }
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  function authHeaders() {
18
  if (token) return { Authorization: `Bearer ${token}` };
19
  if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
20
  return {};
21
  }
22
 
23
- function normalizeBaseUrl(value) {
24
- return value.replace(/\/+$/, '');
25
- }
26
-
27
  function absoluteUrl(value) {
28
  if (typeof value !== 'string' || !value) return undefined;
29
  return new URL(value, `${baseUrl}/`).toString();
@@ -41,97 +217,218 @@ function enrichImageUrls(result) {
41
  };
42
  }
43
 
 
 
 
 
 
 
44
  async function readCapabilities() {
45
- let response;
46
- try {
47
- response = await fetch(`${baseUrl}/api/agent/capabilities`, {
48
- headers: authHeaders()
49
- });
50
- } catch (error) {
51
- const message = error instanceof Error ? error.message : String(error);
52
- throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`);
53
- }
54
  if (!response.ok) {
55
- const body = await response.text();
56
- throw new Error(`capabilities 请求失败,状态码 ${response.status}:${body}`);
57
  }
58
- return response.json();
59
  }
60
 
61
- function parseRetryAfter(response) {
62
- const value = response.headers.get('retry-after');
63
- if (!value || !/^\d+$/.test(value)) return 1;
64
- return Math.max(1, Number(value));
65
- }
66
 
67
- function shouldRetry(result) {
68
- return Boolean(result?.error?.retryable);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  }
70
 
71
- function sleep(seconds) {
72
- return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  }
74
 
75
- try {
76
- await readCapabilities();
77
- } catch (error) {
78
- console.error(error instanceof Error ? error.message : String(error));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  process.exit(1);
80
  }
81
 
82
- if (contractCheck) {
83
- const response = await fetch(`${baseUrl}/api/agent/images/generate`, {
 
84
  method: 'POST',
85
  headers: {
86
  'Content-Type': 'application/json',
87
  ...authHeaders()
88
  },
89
- body: JSON.stringify({
90
- prompt: 'contract check',
91
- model: 'gpt-image-2',
92
- response_mode: 'path'
93
- })
94
  });
95
- const result = await response.json();
96
  if (response.status === 400 && result?.error?.code === 'idempotency_key_required') {
97
- console.log(JSON.stringify({ ok: true, status: response.status, error_code: result.error.code }, null, 2));
98
- process.exit(0);
 
 
99
  }
100
- console.error(JSON.stringify({ ok: false, status: response.status, result }, null, 2));
101
- process.exit(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  }
103
 
104
- const idempotencyKey = process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-generate-${crypto.randomUUID()}`;
105
- let lastResult;
106
- let lastRetryAfter = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
109
- const response = await fetch(`${baseUrl}/api/agent/images/generate`, {
110
- method: 'POST',
111
- headers: {
112
- 'Content-Type': 'application/json',
113
- 'Idempotency-Key': idempotencyKey,
114
- ...authHeaders()
115
- },
116
- body: JSON.stringify({
117
- prompt,
118
- model: 'gpt-image-2',
119
- response_mode: 'path'
120
- })
121
- });
122
 
123
- const result = await response.json();
124
- if (response.ok) {
125
- console.log(JSON.stringify(enrichImageUrls(result), null, 2));
126
- process.exit(0);
 
 
 
 
 
 
 
127
  }
 
 
 
128
 
129
- const retryAfter = response.headers.get('retry-after');
130
- lastResult = result;
131
- lastRetryAfter = retryAfter;
132
- if (!shouldRetry(result) || attempt === maxAttempts) break;
133
- await sleep(parseRetryAfter(response));
134
  }
135
 
136
- console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
137
- process.exit(1);
 
 
 
 
 
1
  #!/usr/bin/env node
2
  import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import {
5
+ errorMessage,
6
+ normalizeBaseUrl,
7
+ normalizeOutputFormat,
8
+ parseRetryAfterValue,
9
+ readConfiguredPositiveInteger,
10
+ readOptionValue,
11
+ resolveSameOriginUrl,
12
+ sleep
13
+ } from './lib/script-utils.mjs';
14
 
 
15
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
16
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
17
+ const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check');
18
+ let options;
19
+ try {
20
+ options = parseArgs(process.argv.slice(2));
21
+ } catch (error) {
22
+ console.error(errorMessage(error));
23
+ printUsage();
24
+ process.exit(2);
25
+ }
26
+ if (options.help) {
27
+ printUsage();
28
+ process.exit(0);
29
+ }
30
 
31
+ let prompt;
32
+ let maxAttempts;
33
+ let timeoutMs;
34
+ let idempotencyKey;
35
+ let requestBody;
36
+ try {
37
+ maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3);
38
+ timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
39
+ idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-generate-${crypto.randomUUID()}`;
40
+ if (isNonBillableDryRun(options, contractCheck)) {
41
+ if (!hasPromptSource(options)) {
42
+ printUsage();
43
+ process.exit(2);
44
+ }
45
+ requestBody = buildDryRunRequestBody(options);
46
+ } else {
47
+ prompt = readPrompt(options, { readPromptFile: !contractCheck });
48
+ requestBody = buildRequestBody(prompt, options);
49
+ }
50
+ } catch (error) {
51
+ console.error(errorMessage(error));
52
+ printUsage();
53
  process.exit(2);
54
  }
55
 
56
+ if (!isNonBillableDryRun(options, contractCheck) && !prompt && !contractCheck) {
57
+ printUsage();
58
+ process.exit(2);
59
+ }
60
+
61
+ let baseUrl;
62
+ try {
63
+ baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
64
+ } catch (error) {
65
+ console.error(errorMessage(error));
66
+ process.exit(2);
67
+ }
68
+
69
+ if (isNonBillableDryRun(options, contractCheck)) {
70
+ console.log(
71
+ JSON.stringify(
72
+ {
73
+ ok: true,
74
+ billable: false,
75
+ dry_run: true,
76
+ endpoint: dryRunEndpoint(options.jobMode),
77
+ job_mode: options.jobMode,
78
+ idempotency_key: idempotencyKey,
79
+ request: requestBody,
80
+ next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。'
81
+ },
82
+ null,
83
+ 2
84
+ )
85
+ );
86
+ process.exit(0);
87
+ }
88
+
89
+ try {
90
+ var capabilities = await readCapabilities();
91
+ } catch (error) {
92
+ console.error(errorMessage(error));
93
+ process.exit(1);
94
+ }
95
+
96
+ if (contractCheck) {
97
+ await runContractCheck(capabilities);
98
+ process.exit(0);
99
+ }
100
+
101
+ try {
102
+ if (shouldUseJobPolling(capabilities, requestBody, options.jobMode)) {
103
+ await runGenerateJob();
104
+ } else {
105
+ await runGenerateRequest();
106
+ }
107
+ } catch (error) {
108
+ console.error(errorMessage(error));
109
+ process.exit(1);
110
+ }
111
+
112
+ function parseArgs(argv) {
113
+ const parsed = {
114
+ model: 'gpt-image-2',
115
+ size: '1024x1024',
116
+ quality: 'high',
117
+ n: '1',
118
+ format: 'png',
119
+ responseMode: 'path',
120
+ timeoutMs: undefined,
121
+ promptFile: undefined,
122
+ idempotencyKey: undefined,
123
+ jobMode: 'auto',
124
+ dryRun: false,
125
+ allowBillable: false,
126
+ help: false,
127
+ promptParts: []
128
+ };
129
+ for (let index = 0; index < argv.length; index += 1) {
130
+ const arg = argv[index];
131
+ if (arg === '--dry-run') parsed.dryRun = true;
132
+ else if (arg === '--allow-billable') parsed.allowBillable = true;
133
+ else if (arg === '--job') parsed.jobMode = 'always';
134
+ else if (arg === '--no-job') parsed.jobMode = 'never';
135
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
136
+ else if (arg === '--contract-check') continue;
137
+ else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
138
+ else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
139
+ else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
140
+ else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg);
141
+ else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg);
142
+ else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
143
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
144
+ else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg);
145
+ else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
146
+ else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
147
+ else parsed.promptParts.push(arg);
148
+ }
149
+ return parsed;
150
+ }
151
+
152
+ function readPrompt(parsed, { readPromptFile }) {
153
+ if (parsed.promptFile) {
154
+ if (readPromptFile) {
155
+ return fs.readFileSync(parsed.promptFile, 'utf8');
156
+ }
157
+ return parsed.promptParts.join(' ') || 'contract check';
158
+ }
159
+ return parsed.promptParts.join(' ');
160
+ }
161
+
162
+ function buildRequestBody(promptValue, parsed) {
163
+ return {
164
+ prompt: promptValue || 'contract check',
165
+ model: parsed.model,
166
+ n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
167
+ size: parsed.size,
168
+ quality: parsed.quality,
169
+ output_format: normalizeOutputFormat(parsed.format),
170
+ response_mode: parsed.responseMode
171
+ };
172
+ }
173
+
174
+ function buildDryRunRequestBody(parsed) {
175
+ const body = {
176
+ model: parsed.model,
177
+ n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
178
+ size: parsed.size,
179
+ quality: parsed.quality,
180
+ output_format: normalizeOutputFormat(parsed.format),
181
+ response_mode: parsed.responseMode
182
+ };
183
+ if (parsed.promptFile) {
184
+ return { ...body, prompt_file: parsed.promptFile };
185
+ }
186
+ return { ...body, prompt: parsed.promptParts.join(' ') };
187
+ }
188
+
189
+ function hasPromptSource(parsed) {
190
+ return Boolean(parsed.promptFile || parsed.promptParts.length > 0);
191
+ }
192
+
193
+ function isNonBillableDryRun(parsed, isContractCheck) {
194
+ return parsed.dryRun || (!isContractCheck && !parsed.allowBillable);
195
+ }
196
+
197
  function authHeaders() {
198
  if (token) return { Authorization: `Bearer ${token}` };
199
  if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
200
  return {};
201
  }
202
 
 
 
 
 
203
  function absoluteUrl(value) {
204
  if (typeof value !== 'string' || !value) return undefined;
205
  return new URL(value, `${baseUrl}/`).toString();
 
217
  };
218
  }
219
 
220
+ function dryRunEndpoint(jobMode) {
221
+ if (jobMode === 'always') return `${baseUrl}/api/agent/jobs/images/generate`;
222
+ if (jobMode === 'never') return `${baseUrl}/api/agent/images/generate`;
223
+ return `${baseUrl}/api/agent/images/generate 或 ${baseUrl}/api/agent/jobs/images/generate`;
224
+ }
225
+
226
  async function readCapabilities() {
227
+ const { response, result, text } = await fetchJson(`${baseUrl}/api/agent/capabilities`, {
228
+ headers: authHeaders(),
229
+ timeoutMs
230
+ });
 
 
 
 
 
231
  if (!response.ok) {
232
+ throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`);
 
233
  }
234
+ return result;
235
  }
236
 
237
+ async function runGenerateRequest() {
238
+ let lastResult;
239
+ let lastRetryAfter = null;
 
 
240
 
241
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
242
+ const { response, result } = await fetchJson(`${baseUrl}/api/agent/images/generate`, {
243
+ method: 'POST',
244
+ headers: {
245
+ 'Content-Type': 'application/json',
246
+ 'Idempotency-Key': idempotencyKey,
247
+ ...authHeaders()
248
+ },
249
+ body: JSON.stringify(requestBody),
250
+ timeoutMs
251
+ });
252
+
253
+ if (response.ok) {
254
+ console.log(JSON.stringify(enrichImageUrls(result), null, 2));
255
+ process.exit(0);
256
+ }
257
+
258
+ const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
259
+ lastResult = result;
260
+ lastRetryAfter = retryAfter;
261
+ if (!shouldRetry(result) || attempt === maxAttempts) break;
262
+ await sleep(retryAfter);
263
+ }
264
+
265
+ console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
266
+ process.exit(1);
267
  }
268
 
269
+ async function runGenerateJob() {
270
+ let lastResult;
271
+ let lastRetryAfter = null;
272
+
273
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
274
+ const { response, result } = await fetchJson(`${baseUrl}/api/agent/jobs/images/generate`, {
275
+ method: 'POST',
276
+ headers: {
277
+ 'Content-Type': 'application/json',
278
+ 'Idempotency-Key': idempotencyKey,
279
+ ...authHeaders()
280
+ },
281
+ body: JSON.stringify(requestBody),
282
+ timeoutMs
283
+ });
284
+
285
+ if (response.ok) {
286
+ const jobResult = await pollJobResult(result?.job);
287
+ console.log(JSON.stringify(enrichImageUrls(jobResult), null, 2));
288
+ process.exit(0);
289
+ }
290
+
291
+ const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
292
+ lastResult = result;
293
+ lastRetryAfter = retryAfter;
294
+ if (!shouldRetry(result) || attempt === maxAttempts) break;
295
+ await sleep(retryAfter);
296
+ }
297
+
298
+ console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
299
+ process.exit(1);
300
  }
301
 
302
+ async function pollJobResult(job) {
303
+ if (!job || typeof job.id !== 'string') {
304
+ throw new Error('创建 job 的响应缺少 job.id。');
305
+ }
306
+ const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url || `/api/agent/jobs/${job.id}/result`, 'job.result_url');
307
+ const deadlineMs = Date.now() + timeoutMs;
308
+ let lastResult;
309
+ let lastRetryAfter = job.retry_after_seconds || 1;
310
+
311
+ while (Date.now() < deadlineMs) {
312
+ const { response, result } = await fetchJson(resultUrl, {
313
+ headers: authHeaders(),
314
+ timeoutMs
315
+ });
316
+ if (response.ok) return result;
317
+
318
+ const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')) || lastRetryAfter;
319
+ lastResult = result;
320
+ lastRetryAfter = retryAfter;
321
+ if (result?.error?.code !== 'request_in_progress' || !result?.error?.retryable) break;
322
+ await sleep(retryAfter);
323
+ }
324
+
325
+ console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
326
  process.exit(1);
327
  }
328
 
329
+ async function runContractCheck(capabilitiesValue) {
330
+ const checks = [];
331
+ const { response, result } = await fetchJson(`${baseUrl}/api/agent/images/generate`, {
332
  method: 'POST',
333
  headers: {
334
  'Content-Type': 'application/json',
335
  ...authHeaders()
336
  },
337
+ body: JSON.stringify(requestBody),
338
+ timeoutMs
 
 
 
339
  });
 
340
  if (response.status === 400 && result?.error?.code === 'idempotency_key_required') {
341
+ checks.push({ endpoint: '/api/agent/images/generate', status: response.status, error_code: result.error.code });
342
+ } else {
343
+ console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
344
+ process.exit(1);
345
  }
346
+
347
+ if (supportsJobPolling(capabilitiesValue)) {
348
+ const jobCheck = await fetchJson(`${baseUrl}/api/agent/jobs/images/generate`, {
349
+ method: 'POST',
350
+ headers: {
351
+ 'Content-Type': 'application/json',
352
+ ...authHeaders()
353
+ },
354
+ body: JSON.stringify(requestBody),
355
+ timeoutMs
356
+ });
357
+ if (jobCheck.response.status !== 400 || jobCheck.result?.error?.code !== 'idempotency_key_required') {
358
+ console.error(
359
+ JSON.stringify({ ok: false, billable: false, status: jobCheck.response.status, result: jobCheck.result }, null, 2)
360
+ );
361
+ process.exit(1);
362
+ }
363
+ checks.push({
364
+ endpoint: '/api/agent/jobs/images/generate',
365
+ status: jobCheck.response.status,
366
+ error_code: jobCheck.result.error.code
367
+ });
368
+ }
369
+
370
+ console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2));
371
  }
372
 
373
+ async function fetchJson(url, init) {
374
+ const controller = new AbortController();
375
+ const timeout = setTimeout(() => controller.abort(), init.timeoutMs);
376
+ try {
377
+ const fetchInit = { ...init };
378
+ delete fetchInit.timeoutMs;
379
+ const response = await fetch(url, { ...fetchInit, signal: controller.signal });
380
+ const text = await response.text();
381
+ const result = parseJsonResponse(text, response.ok, url);
382
+ return { response, result, text };
383
+ } catch (error) {
384
+ const message = errorMessage(error);
385
+ throw new Error(`请求失败:${url}。${message}`);
386
+ } finally {
387
+ clearTimeout(timeout);
388
+ }
389
+ }
390
 
391
+ function parseJsonResponse(text, isOk, url) {
392
+ if (!text) return null;
393
+ try {
394
+ return JSON.parse(text);
395
+ } catch (error) {
396
+ if (!isOk) return null;
397
+ const message = errorMessage(error);
398
+ throw new Error(`响应不是有效 JSON:${url}。${message}`);
399
+ }
400
+ }
401
+
402
+ function shouldRetry(result) {
403
+ return Boolean(result?.error?.retryable);
404
+ }
405
 
406
+ function supportsJobPolling(capabilitiesValue) {
407
+ return Boolean(capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling');
408
+ }
409
+
410
+ function shouldUseJobPolling(capabilitiesValue, request, jobMode) {
411
+ if (jobMode === 'never') return false;
412
+ if (!supportsJobPolling(capabilitiesValue)) {
413
+ if (jobMode === 'always') {
414
+ throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。');
415
+ }
416
+ return false;
417
  }
418
+ if (jobMode === 'always') return true;
419
+ return request.quality === 'high' && readMaxImageEdge(request.size) >= 3072;
420
+ }
421
 
422
+ function readMaxImageEdge(size) {
423
+ if (typeof size !== 'string') return 0;
424
+ const match = size.match(/^(\d+)x(\d+)$/);
425
+ if (!match) return 0;
426
+ return Math.max(Number(match[1]), Number(match[2]));
427
  }
428
 
429
+ function printUsage() {
430
+ console.error('用法:generate-image.mjs [options] <prompt>');
431
+ console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
432
+ console.error('常用参数:--model --size --quality --n --format --response-mode --timeout-ms --prompt-file --idempotency-key --job --no-job');
433
+ console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check');
434
+ }
skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MAX_RETRY_AFTER_SECONDS = 60;
2
+
3
+ export function readOptionValue(argv, index, name) {
4
+ const value = argv[index];
5
+ if (!value || value.startsWith('--')) {
6
+ throw new Error(`${name} 需要参数值。`);
7
+ }
8
+ return value;
9
+ }
10
+
11
+ export function readConfiguredPositiveInteger(value, name, fallback) {
12
+ if (value === undefined || value === null || value === '') return fallback;
13
+ if (!/^\d+$/.test(String(value))) {
14
+ throw new Error(`${name} 必须是正整数。`);
15
+ }
16
+ const parsed = Number(value);
17
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
18
+ throw new Error(`${name} 必须是正整数。`);
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ export function normalizeBaseUrl(value) {
24
+ const normalized = String(value || '').trim().replace(/\/+$/, '');
25
+ let parsed;
26
+ try {
27
+ parsed = new URL(normalized);
28
+ } catch {
29
+ throw new Error('base URL 必须是有效的 http/https 绝对 URL。');
30
+ }
31
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
32
+ throw new Error('base URL 必须使用 http 或 https。');
33
+ }
34
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
35
+ throw new Error('base URL 不能包含凭据、查询参数或片段。');
36
+ }
37
+ return normalized;
38
+ }
39
+
40
+ export function normalizeOutputFormat(value) {
41
+ return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase();
42
+ }
43
+
44
+ export function parseRetryAfterValue(value, fallback = 1) {
45
+ if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback);
46
+ const parsed = Number(value);
47
+ if (!Number.isSafeInteger(parsed)) return MAX_RETRY_AFTER_SECONDS;
48
+ return clampRetryAfterSeconds(parsed);
49
+ }
50
+
51
+ export function sleep(seconds) {
52
+ return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
53
+ }
54
+
55
+ function clampRetryAfterSeconds(value) {
56
+ if (!Number.isFinite(value)) return MAX_RETRY_AFTER_SECONDS;
57
+ return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, Math.round(value)));
58
+ }
59
+
60
+ export function resolveSameOriginUrl(baseUrl, value, label) {
61
+ const base = new URL(baseUrl);
62
+ const resolved = new URL(value, `${baseUrl}/`);
63
+ if (resolved.origin !== base.origin) {
64
+ throw new Error(`${label} 指向不同 origin,拒绝携带鉴权头访问。`);
65
+ }
66
+ return resolved.toString();
67
+ }
68
+
69
+ export function errorMessage(error) {
70
+ return error instanceof Error ? error.message : String(error);
71
+ }
skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import dns from 'node:dns/promises';
3
+ import tls from 'node:tls';
4
+ import {
5
+ errorMessage,
6
+ normalizeBaseUrl,
7
+ normalizeOutputFormat,
8
+ readConfiguredPositiveInteger,
9
+ readOptionValue
10
+ } from './lib/script-utils.mjs';
11
+
12
+ const HEADER_ALLOWLIST = new Set(['content-type', 'date', 'server', 'cf-ray', 'x-request-id', 'retry-after']);
13
+ let options;
14
+ try {
15
+ options = parseArgs(process.argv.slice(2));
16
+ } catch (error) {
17
+ console.error(errorMessage(error));
18
+ printUsage();
19
+ process.exit(2);
20
+ }
21
+ if (options.help) {
22
+ printUsage();
23
+ process.exit(0);
24
+ }
25
+
26
+ let baseUrl;
27
+ try {
28
+ baseUrl = normalizeBaseUrl(
29
+ options.baseUrl || process.env.GPT_IMAGE_UPSTREAM_BASE_URL || process.env.OPENAI_API_BASE_URL || 'https://api.openai.com/v1'
30
+ );
31
+ } catch (error) {
32
+ console.error(errorMessage(error));
33
+ process.exit(2);
34
+ }
35
+ const apiKey = process.env.GPT_IMAGE_UPSTREAM_API_KEY || process.env.OPENAI_API_KEY || '';
36
+ let timeoutMs;
37
+ try {
38
+ timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 30000);
39
+ } catch (error) {
40
+ console.error(errorMessage(error));
41
+ printUsage();
42
+ process.exit(2);
43
+ }
44
+ let upstream;
45
+ try {
46
+ upstream = new URL(baseUrl);
47
+ } catch {
48
+ console.error(`无效的上游 base URL:${baseUrl}`);
49
+ process.exit(2);
50
+ }
51
+
52
+ const report = {
53
+ ok: false,
54
+ billable: false,
55
+ base_url: baseUrl,
56
+ upstream_host: upstream.host,
57
+ api_key_configured: Boolean(apiKey),
58
+ dns: await probeDns(upstream.hostname),
59
+ tls: await probeTls(upstream),
60
+ models: await probeModels()
61
+ };
62
+
63
+ if (options.allowBillable) {
64
+ report.generation = await probeGeneration();
65
+ report.billable = true;
66
+ }
67
+
68
+ report.ok = Boolean(report.models.ok && (!report.generation || report.generation.ok));
69
+ console.log(JSON.stringify(report, null, 2));
70
+ process.exit(report.ok ? 0 : 1);
71
+
72
+ function parseArgs(argv) {
73
+ const parsed = {
74
+ baseUrl: undefined,
75
+ model: 'gpt-image-2',
76
+ prompt: 'contract probe',
77
+ size: '1024x1024',
78
+ quality: 'low',
79
+ format: 'png',
80
+ timeoutMs: undefined,
81
+ allowBillable: false,
82
+ help: false
83
+ };
84
+ for (let index = 0; index < argv.length; index += 1) {
85
+ const arg = argv[index];
86
+ if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
87
+ else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
88
+ else if (arg === '--prompt') parsed.prompt = readOptionValue(argv, (index += 1), arg);
89
+ else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
90
+ else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
91
+ else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg);
92
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
93
+ else if (arg === '--allow-billable') parsed.allowBillable = true;
94
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
95
+ else throw new Error(`未知参数:${arg}`);
96
+ }
97
+ return parsed;
98
+ }
99
+
100
+ async function probeDns(hostname) {
101
+ const startedAt = Date.now();
102
+ try {
103
+ const result = await dns.lookup(hostname);
104
+ return { ok: true, elapsed_ms: Date.now() - startedAt, address_family: result.family };
105
+ } catch (error) {
106
+ return { ok: false, elapsed_ms: Date.now() - startedAt, error: errorMessage(error) };
107
+ }
108
+ }
109
+
110
+ async function probeTls(url) {
111
+ if (url.protocol !== 'https:') {
112
+ return { ok: true, skipped: true, reason: 'non_https_base_url' };
113
+ }
114
+ const startedAt = Date.now();
115
+ return new Promise((resolve) => {
116
+ const socket = tls.connect({
117
+ host: url.hostname,
118
+ port: Number(url.port || 443),
119
+ servername: url.hostname,
120
+ timeout: timeoutMs
121
+ });
122
+ socket.once('secureConnect', () => {
123
+ const protocol = socket.getProtocol() || undefined;
124
+ socket.end();
125
+ resolve({ ok: true, elapsed_ms: Date.now() - startedAt, authorized: socket.authorized, protocol });
126
+ });
127
+ socket.once('timeout', () => {
128
+ socket.destroy();
129
+ resolve({ ok: false, elapsed_ms: Date.now() - startedAt, error: 'TLS handshake timed out.' });
130
+ });
131
+ socket.once('error', (error) => {
132
+ resolve({ ok: false, elapsed_ms: Date.now() - startedAt, error: errorMessage(error) });
133
+ });
134
+ });
135
+ }
136
+
137
+ async function probeModels() {
138
+ const { response, json, text, elapsedMs } = await fetchJson(`${baseUrl}/models`, { method: 'GET' });
139
+ return {
140
+ ok: response.ok,
141
+ status: response.status,
142
+ elapsed_ms: elapsedMs,
143
+ content_type: response.headers.get('content-type') || undefined,
144
+ response_headers: readAllowedHeaders(response.headers),
145
+ ...summarizeJson(json, text)
146
+ };
147
+ }
148
+
149
+ async function probeGeneration() {
150
+ const { response, json, text, elapsedMs } = await fetchJson(`${baseUrl}/images/generations`, {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({
154
+ model: options.model,
155
+ prompt: options.prompt,
156
+ n: 1,
157
+ size: options.size,
158
+ quality: options.quality,
159
+ output_format: normalizeOutputFormat(options.format)
160
+ })
161
+ });
162
+ return {
163
+ ok: response.ok,
164
+ billable: true,
165
+ status: response.status,
166
+ elapsed_ms: elapsedMs,
167
+ content_type: response.headers.get('content-type') || undefined,
168
+ response_headers: readAllowedHeaders(response.headers),
169
+ ...summarizeGenerationJson(json, text)
170
+ };
171
+ }
172
+
173
+ async function fetchJson(url, init) {
174
+ const controller = new AbortController();
175
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
176
+ const startedAt = Date.now();
177
+ try {
178
+ const response = await fetch(url, {
179
+ ...init,
180
+ headers: {
181
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
182
+ ...(init.headers || {})
183
+ },
184
+ signal: controller.signal
185
+ });
186
+ const text = await response.text();
187
+ return { response, text, json: parseJson(text), elapsedMs: Date.now() - startedAt };
188
+ } catch (error) {
189
+ return {
190
+ response: new Response(JSON.stringify({ error: errorMessage(error) }), { status: 599 }),
191
+ text: '',
192
+ json: { error: errorMessage(error) },
193
+ elapsedMs: Date.now() - startedAt
194
+ };
195
+ } finally {
196
+ clearTimeout(timeout);
197
+ }
198
+ }
199
+
200
+ function summarizeJson(json, text) {
201
+ if (Array.isArray(json?.data)) {
202
+ return { model_count: json.data.length };
203
+ }
204
+ return { error: summarizeError(json, text) };
205
+ }
206
+
207
+ function summarizeGenerationJson(json, text) {
208
+ if (!Array.isArray(json?.data)) {
209
+ return { error: summarizeError(json, text) };
210
+ }
211
+ const firstImage = json.data[0] || {};
212
+ const firstB64 = typeof firstImage.b64_json === 'string' ? firstImage.b64_json : '';
213
+ return {
214
+ image_count: json.data.length,
215
+ first_b64_length: firstB64.length,
216
+ usage: json.usage || undefined
217
+ };
218
+ }
219
+
220
+ function summarizeError(json, text) {
221
+ const error = json?.error;
222
+ if (typeof error === 'object' && error) {
223
+ return {
224
+ code: typeof error.code === 'string' ? error.code : undefined,
225
+ type: typeof error.type === 'string' ? error.type : undefined,
226
+ message: typeof error.message === 'string' ? error.message : undefined
227
+ };
228
+ }
229
+ return text ? { message: text.slice(0, 500) } : undefined;
230
+ }
231
+
232
+ function readAllowedHeaders(headers) {
233
+ const result = {};
234
+ for (const name of HEADER_ALLOWLIST) {
235
+ const value = headers.get(name);
236
+ if (value) result[name] = value;
237
+ }
238
+ return Object.keys(result).length > 0 ? result : undefined;
239
+ }
240
+
241
+ function parseJson(text) {
242
+ try {
243
+ return text ? JSON.parse(text) : null;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ function printUsage() {
250
+ console.error('用法:probe-upstream-image.mjs [options]');
251
+ console.error('默认只请求 /models;添加 --allow-billable 才会调用 /images/generations。');
252
+ console.error('常用参数:--base-url --model --prompt --size --quality --format --timeout-ms --allow-billable');
253
+ }
src/app/api/agent/agent-routes.test.ts CHANGED
@@ -56,14 +56,15 @@ describe('Agent route integration', () => {
56
  const { getCapabilities } = await loadAgentRoutes();
57
  process.env.AGENT_STATE_BACKEND = 'memory';
58
  process.env.AGENT_API_TOKEN = 'capability-token';
59
- process.env.APP_PASSWORD = 'page-password';
60
 
61
  const response = await getCapabilities();
62
  assert.equal(response.status, 200);
63
  const body = await response.json();
64
  assert.equal(body.auth.required, true);
 
65
  assert.equal(JSON.stringify(body).includes('capability-token'), false);
66
- assert.equal(JSON.stringify(body).includes('page-password'), false);
67
  assert.equal(body.defaults.state_backend, 'memory');
68
  });
69
 
@@ -76,6 +77,7 @@ describe('Agent route integration', () => {
76
  assert.equal(response.status, 200);
77
  const body = await response.json();
78
  assert.equal(body.auth.required, false);
 
79
  });
80
 
81
  it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => {
@@ -104,6 +106,7 @@ describe('Agent route integration', () => {
104
  const secondBody = await second.json();
105
  assert.equal(secondBody.cached, true);
106
  assert.equal(second.headers.get('x-idempotent-replay'), 'true');
 
107
  assert.equal(secondBody.request_id, firstBody.request_id);
108
  assert.equal(upstreamCalls, 1);
109
 
@@ -220,6 +223,297 @@ describe('Agent route integration', () => {
220
  assert.equal(body.error.retryable, false);
221
  });
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  it('edits through multipart input and replays the cached response for the same idempotency key', async () => {
224
  const { editImage } = await loadAgentRoutes();
225
  let upstreamCalls = 0;
@@ -242,6 +536,7 @@ describe('Agent route integration', () => {
242
  const secondBody = await second.json();
243
  assert.equal(secondBody.cached, true);
244
  assert.equal(second.headers.get('x-idempotent-replay'), 'true');
 
245
  assert.equal(secondBody.request_id, firstBody.request_id);
246
  assert.equal(upstreamCalls, 1);
247
 
@@ -304,6 +599,126 @@ describe('Agent route integration', () => {
304
  assert.equal(body.error.retryable, false);
305
  });
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  it('requires artifact content authorization and returns image bytes when authorized', async () => {
308
  const { generateImage, getArtifact, getArtifactContent, deleteArtifact } = await loadAgentRoutes();
309
  const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
@@ -424,6 +839,9 @@ describe('Agent route integration', () => {
424
  }
425
  };
426
  },
 
 
 
427
  async saveArtifacts() {
428
  saveCalls += 1;
429
  },
@@ -433,6 +851,9 @@ describe('Agent route integration', () => {
433
  async failRequest() {
434
  failCalls += 1;
435
  },
 
 
 
436
  async getArtifact() {
437
  return undefined;
438
  },
@@ -495,6 +916,9 @@ describe('Agent route integration', () => {
495
  }
496
  };
497
  },
 
 
 
498
  async saveArtifacts() {
499
  throw new Error('artifact metadata save failed');
500
  },
@@ -503,6 +927,9 @@ describe('Agent route integration', () => {
503
  assert.equal(input.requestId, requestId);
504
  assert.equal(input.error.error.retryable, true);
505
  },
 
 
 
506
  async getArtifact() {
507
  return undefined;
508
  },
@@ -596,10 +1023,16 @@ async function loadAgentRoutes() {
596
  const artifactRoute = await import('./artifacts/[id]/route');
597
  const artifactContentRoute = await import('./artifacts/[id]/content/route');
598
  const capabilitiesRoute = await import('./capabilities/route');
 
 
 
599
  return {
600
  getCapabilities: capabilitiesRoute.GET,
601
  generateImage: generateRoute.POST,
602
  editImage: editRoute.POST,
 
 
 
603
  getArtifact: artifactRoute.GET,
604
  deleteArtifact: artifactRoute.DELETE,
605
  getArtifactContent: artifactContentRoute.GET
@@ -618,6 +1051,18 @@ function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>,
618
  });
619
  }
620
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  function agentEditRequest(
622
  idempotencyKey: string,
623
  prompt: string,
@@ -716,3 +1161,22 @@ async function waitFor(predicate: () => boolean): Promise<void> {
716
  }
717
  throw new Error('等待条件超时');
718
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  const { getCapabilities } = await loadAgentRoutes();
57
  process.env.AGENT_STATE_BACKEND = 'memory';
58
  process.env.AGENT_API_TOKEN = 'capability-token';
59
+ process.env.APP_PASSWORD = 'page-access-code';
60
 
61
  const response = await getCapabilities();
62
  assert.equal(response.status, 200);
63
  const body = await response.json();
64
  assert.equal(body.auth.required, true);
65
+ assert.deepEqual(body.auth.schemes, ['bearer']);
66
  assert.equal(JSON.stringify(body).includes('capability-token'), false);
67
+ assert.equal(JSON.stringify(body).includes('page-access-code'), false);
68
  assert.equal(body.defaults.state_backend, 'memory');
69
  });
70
 
 
77
  assert.equal(response.status, 200);
78
  const body = await response.json();
79
  assert.equal(body.auth.required, false);
80
+ assert.deepEqual(body.auth.schemes, []);
81
  });
82
 
83
  it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => {
 
106
  const secondBody = await second.json();
107
  assert.equal(secondBody.cached, true);
108
  assert.equal(second.headers.get('x-idempotent-replay'), 'true');
109
+ assert.equal(second.headers.get('x-request-id'), firstBody.request_id);
110
  assert.equal(secondBody.request_id, firstBody.request_id);
111
  assert.equal(upstreamCalls, 1);
112
 
 
223
  assert.equal(body.error.retryable, false);
224
  });
225
 
226
+ it('returns sanitized upstream diagnostics for failed generate requests', async () => {
227
+ const { generateImage } = await loadAgentRoutes();
228
+ let upstreamCalls = 0;
229
+ const upstream = await startImageUpstream(() => {
230
+ upstreamCalls += 1;
231
+ throw new Error('upstream failed');
232
+ });
233
+ process.env.OPENAI_API_KEY = 'test-key';
234
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
235
+
236
+ try {
237
+ const idempotencyKey = 'route-upstream-diagnostics-key';
238
+ const response = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' }));
239
+ assert.equal(response.status, 502);
240
+ const body = await response.json();
241
+ assert.equal(body.error.code, 'upstream_unavailable');
242
+ assert.equal(body.error.upstream_status, 500);
243
+ assert.equal(body.error.diagnostics.upstream_status, 500);
244
+ assert.equal(body.error.diagnostics.selected_channel_id, 'default');
245
+ assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/);
246
+ assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel');
247
+ assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number');
248
+ assert.equal(JSON.stringify(body).includes('test-key'), false);
249
+ const upstreamCallsAfterFirstFailure = upstreamCalls;
250
+
251
+ const replay = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' }));
252
+ assert.equal(replay.status, 502);
253
+ assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
254
+ const replayBody = await replay.json();
255
+ assert.equal(replayBody.error.code, 'upstream_unavailable');
256
+ assert.equal(replayBody.error.retryable, false);
257
+ assert.equal(replayBody.error.request_id, body.error.request_id);
258
+ assert.equal(upstreamCalls, upstreamCallsAfterFirstFailure);
259
+ } finally {
260
+ await upstream.close();
261
+ }
262
+ });
263
+
264
+ it('creates a generate job, exposes running status, and returns the completed result', async () => {
265
+ const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes();
266
+ let releaseUpstream: (() => void) | undefined;
267
+ let upstreamCalls = 0;
268
+ const upstream = await startImageUpstream(async () => {
269
+ upstreamCalls += 1;
270
+ await new Promise<void>((resolve) => {
271
+ releaseUpstream = resolve;
272
+ });
273
+ return { data: [{ b64_json: PNG_BASE64 }] };
274
+ });
275
+ process.env.OPENAI_API_KEY = 'test-key';
276
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
277
+
278
+ try {
279
+ const created = await createGenerateJob(agentJobJsonRequest('route-job-key', { prompt: 'agent route job' }));
280
+ assert.equal(created.status, 202);
281
+ const createdBody = await created.json();
282
+ assert.equal(createdBody.job.state, 'running');
283
+ assert.equal(createdBody.job.idempotency_key, 'route-job-key');
284
+ assert.equal(createdBody.job.result_url, `/api/agent/jobs/${createdBody.job.id}/result`);
285
+
286
+ await waitFor(() => upstreamCalls === 1);
287
+ const running = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), {
288
+ params: Promise.resolve({ id: createdBody.job.id })
289
+ });
290
+ assert.equal(running.status, 200);
291
+ assert.equal((await running.json()).job.state, 'running');
292
+
293
+ releaseUpstream?.();
294
+ const result = await waitForJobResult(getJobResult, createdBody.job.id);
295
+ assert.equal(result.status, 200);
296
+ const resultBody = await result.json();
297
+ assert.equal(resultBody.request_id, createdBody.job.id);
298
+ assert.equal(resultBody.cached, false);
299
+ assert.equal(resultBody.images.length, 1);
300
+ assert.equal('b64_json' in resultBody.images[0], false);
301
+ } finally {
302
+ releaseUpstream?.();
303
+ await upstream.close();
304
+ }
305
+ });
306
+
307
+ it('reuses the running generate job for the same idempotency key', async () => {
308
+ const { createGenerateJob, getJobResult } = await loadAgentRoutes();
309
+ let releaseUpstream: (() => void) | undefined;
310
+ let upstreamCalls = 0;
311
+ const upstream = await startImageUpstream(async () => {
312
+ upstreamCalls += 1;
313
+ await new Promise<void>((resolve) => {
314
+ releaseUpstream = resolve;
315
+ });
316
+ return { data: [{ b64_json: PNG_BASE64 }] };
317
+ });
318
+ process.env.OPENAI_API_KEY = 'test-key';
319
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
320
+
321
+ try {
322
+ const first = await createGenerateJob(agentJobJsonRequest('route-job-reuse-key', { prompt: 'job reuse' }));
323
+ assert.equal(first.status, 202);
324
+ const firstBody = await first.json();
325
+ await waitFor(() => upstreamCalls === 1);
326
+
327
+ const second = await createGenerateJob(agentJobJsonRequest('route-job-reuse-key', { prompt: 'job reuse' }));
328
+ assert.equal(second.status, 202);
329
+ assert.equal(second.headers.get('x-idempotent-replay'), 'true');
330
+ const secondBody = await second.json();
331
+ assert.equal(secondBody.job.id, firstBody.job.id);
332
+ assert.equal(secondBody.job.state, 'running');
333
+ assert.equal(upstreamCalls, 1);
334
+
335
+ releaseUpstream?.();
336
+ const result = await waitForJobResult(getJobResult, firstBody.job.id);
337
+ assert.equal(result.status, 200);
338
+ } finally {
339
+ releaseUpstream?.();
340
+ await upstream.close();
341
+ }
342
+ });
343
+
344
+ it('rejects generate job idempotency keys reused with a different request body', async () => {
345
+ const { createGenerateJob, getJobResult } = await loadAgentRoutes();
346
+ let releaseUpstream: (() => void) | undefined;
347
+ let upstreamCalls = 0;
348
+ const upstream = await startImageUpstream(async () => {
349
+ upstreamCalls += 1;
350
+ await new Promise<void>((resolve) => {
351
+ releaseUpstream = resolve;
352
+ });
353
+ return { data: [{ b64_json: PNG_BASE64 }] };
354
+ });
355
+ process.env.OPENAI_API_KEY = 'test-key';
356
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
357
+
358
+ try {
359
+ const first = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'first job body' }));
360
+ assert.equal(first.status, 202);
361
+ const firstBody = await first.json();
362
+ await waitFor(() => upstreamCalls === 1);
363
+
364
+ const conflict = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'different job body' }));
365
+ assert.equal(conflict.status, 409);
366
+ assert.equal((await conflict.json()).error.code, 'idempotency_conflict');
367
+ assert.equal(upstreamCalls, 1);
368
+
369
+ releaseUpstream?.();
370
+ const result = await waitForJobResult(getJobResult, firstBody.job.id);
371
+ assert.equal(result.status, 200);
372
+ } finally {
373
+ releaseUpstream?.();
374
+ await upstream.close();
375
+ }
376
+ });
377
+
378
+ it('returns stored Agent errors for failed generate jobs', async () => {
379
+ const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes();
380
+ let upstreamCalls = 0;
381
+ const upstream = await startImageUpstream(() => {
382
+ upstreamCalls += 1;
383
+ throw new Error('job upstream failed');
384
+ });
385
+ process.env.OPENAI_API_KEY = 'test-key';
386
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
387
+
388
+ try {
389
+ const created = await createGenerateJob(agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' }));
390
+ assert.equal(created.status, 202);
391
+ const createdBody = await created.json();
392
+
393
+ const result = await waitForJobResult(getJobResult, createdBody.job.id);
394
+ assert.equal(result.status, 502);
395
+ const resultBody = await result.json();
396
+ assert.equal(resultBody.error.code, 'upstream_unavailable');
397
+ assert.equal(resultBody.error.retryable, false);
398
+ assert.equal(resultBody.error.upstream_status, 500);
399
+ assert.equal(resultBody.error.diagnostics.upstream_status, 500);
400
+ assert.equal(resultBody.error.request_id, createdBody.job.id);
401
+ assert.equal(JSON.stringify(resultBody).includes('test-key'), false);
402
+
403
+ const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), {
404
+ params: Promise.resolve({ id: createdBody.job.id })
405
+ });
406
+ assert.equal(status.status, 200);
407
+ const statusBody = await status.json();
408
+ assert.equal(statusBody.job.state, 'failed');
409
+ assert.equal(statusBody.job.error.code, 'upstream_unavailable');
410
+ assert.equal(statusBody.job.error.retryable, false);
411
+ assert.equal(statusBody.job.error.upstream_status, 500);
412
+ assert.equal(statusBody.job.error.diagnostics.upstream_status, 500);
413
+ assert.equal(upstreamCalls > 0, true);
414
+ } finally {
415
+ await upstream.close();
416
+ }
417
+ });
418
+
419
+ it('keeps a long-running generate job leased while the upstream call is still active', async () => {
420
+ process.env.AGENT_REQUEST_LEASE_MS = '200';
421
+ process.env.AGENT_RECOVERY_INTERVAL_MS = '50';
422
+ const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes();
423
+ let releaseUpstream: (() => void) | undefined;
424
+ let upstreamCalls = 0;
425
+ const upstream = await startImageUpstream(async () => {
426
+ upstreamCalls += 1;
427
+ await new Promise<void>((resolve) => {
428
+ releaseUpstream = resolve;
429
+ });
430
+ return { data: [{ b64_json: PNG_BASE64 }] };
431
+ });
432
+ process.env.OPENAI_API_KEY = 'test-key';
433
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
434
+
435
+ try {
436
+ const created = await createGenerateJob(agentJobJsonRequest('route-job-lease-key', { prompt: 'job lease' }));
437
+ assert.equal(created.status, 202);
438
+ const createdBody = await created.json();
439
+ await waitFor(() => upstreamCalls === 1);
440
+ await new Promise((resolve) => setTimeout(resolve, 350));
441
+
442
+ const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), {
443
+ params: Promise.resolve({ id: createdBody.job.id })
444
+ });
445
+ assert.equal(status.status, 200);
446
+ const statusBody = await status.json();
447
+ assert.equal(statusBody.job.state, 'running');
448
+
449
+ releaseUpstream?.();
450
+ const result = await waitForJobResult(getJobResult, createdBody.job.id);
451
+ assert.equal(result.status, 200);
452
+ } finally {
453
+ releaseUpstream?.();
454
+ await upstream.close();
455
+ }
456
+ });
457
+
458
+ it('returns structured errors for missing and expired jobs', async () => {
459
+ const { getJob, getJobResult } = await loadAgentRoutes();
460
+ const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime');
461
+
462
+ const missing = await getJob(new Request('http://localhost/api/agent/jobs/missing-job'), {
463
+ params: Promise.resolve({ id: 'missing-job' })
464
+ });
465
+ assert.equal(missing.status, 404);
466
+ assert.equal((await missing.json()).error.code, 'job_not_found');
467
+
468
+ setAgentStateStoreFactoryForTests(() => ({
469
+ async init() {},
470
+ async recoverExpiredRequests() {
471
+ return 0;
472
+ },
473
+ async purgeExpiredRequests() {
474
+ return 0;
475
+ },
476
+ async beginRequest() {
477
+ throw new Error('not used');
478
+ },
479
+ async refreshRequestLease() {
480
+ return false;
481
+ },
482
+ async saveArtifacts() {},
483
+ async completeRequest() {},
484
+ async failRequest() {},
485
+ async getRequest() {
486
+ return {
487
+ requestId: 'expired-job',
488
+ idempotencyKey: 'expired-key',
489
+ requestHash: 'hash',
490
+ mode: 'generate',
491
+ status: 'running',
492
+ requestJson: { prompt: 'expired' },
493
+ createdAt: '2026-05-12T00:00:00.000Z',
494
+ updatedAt: '2026-05-12T00:00:00.000Z',
495
+ expiresAt: '2026-05-12T00:00:01.000Z'
496
+ };
497
+ },
498
+ async getArtifact() {
499
+ return undefined;
500
+ },
501
+ async listArtifactsForRequest() {
502
+ return [];
503
+ },
504
+ async deleteArtifact() {
505
+ return false;
506
+ }
507
+ }));
508
+ resetAgentStateStoreForTests();
509
+
510
+ const expired = await getJobResult(new Request('http://localhost/api/agent/jobs/expired-job/result'), {
511
+ params: Promise.resolve({ id: 'expired-job' })
512
+ });
513
+ assert.equal(expired.status, 410);
514
+ assert.equal((await expired.json()).error.code, 'job_expired');
515
+ });
516
+
517
  it('edits through multipart input and replays the cached response for the same idempotency key', async () => {
518
  const { editImage } = await loadAgentRoutes();
519
  let upstreamCalls = 0;
 
536
  const secondBody = await second.json();
537
  assert.equal(secondBody.cached, true);
538
  assert.equal(second.headers.get('x-idempotent-replay'), 'true');
539
+ assert.equal(second.headers.get('x-request-id'), firstBody.request_id);
540
  assert.equal(secondBody.request_id, firstBody.request_id);
541
  assert.equal(upstreamCalls, 1);
542
 
 
599
  assert.equal(body.error.retryable, false);
600
  });
601
 
602
+ it('returns sanitized upstream diagnostics for failed edit requests', async () => {
603
+ const { editImage } = await loadAgentRoutes();
604
+ let upstreamCalls = 0;
605
+ const upstream = await startImageUpstream(() => {
606
+ upstreamCalls += 1;
607
+ throw new Error('edit upstream failed');
608
+ });
609
+ process.env.OPENAI_API_KEY = 'test-key';
610
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
611
+
612
+ try {
613
+ const idempotencyKey = 'route-edit-upstream-diagnostics-key';
614
+ const response = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics'));
615
+ assert.equal(response.status, 502);
616
+ const body = await response.json();
617
+ assert.equal(body.error.code, 'upstream_unavailable');
618
+ assert.equal(body.error.upstream_status, 500);
619
+ assert.equal(body.error.diagnostics.upstream_status, 500);
620
+ assert.equal(body.error.diagnostics.selected_channel_id, 'default');
621
+ assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/);
622
+ assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel');
623
+ assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number');
624
+ assert.equal(JSON.stringify(body).includes('test-key'), false);
625
+ const upstreamCallsAfterFirstFailure = upstreamCalls;
626
+
627
+ const replay = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics'));
628
+ assert.equal(replay.status, 502);
629
+ assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
630
+ const replayBody = await replay.json();
631
+ assert.equal(replayBody.error.code, 'upstream_unavailable');
632
+ assert.equal(replayBody.error.retryable, false);
633
+ assert.equal(replayBody.error.request_id, body.error.request_id);
634
+ assert.equal(upstreamCalls, upstreamCallsAfterFirstFailure);
635
+ } finally {
636
+ await upstream.close();
637
+ }
638
+ });
639
+
640
+ it('does not mark a real upstream success as failed when edit state completion fails', async () => {
641
+ const { editImage } = await loadAgentRoutes();
642
+ const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime');
643
+ let upstreamCalls = 0;
644
+ let failCalls = 0;
645
+ let saveCalls = 0;
646
+ const requestId = 'edit-completion-failure-request';
647
+ const upstream = await startImageUpstream(() => {
648
+ upstreamCalls += 1;
649
+ return { data: [{ b64_json: PNG_BASE64 }] };
650
+ });
651
+ process.env.OPENAI_API_KEY = 'test-key';
652
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
653
+ setAgentStateStoreFactoryForTests(() => ({
654
+ async init() {},
655
+ async recoverExpiredRequests() {
656
+ return 0;
657
+ },
658
+ async purgeExpiredRequests() {
659
+ return 0;
660
+ },
661
+ async beginRequest() {
662
+ return {
663
+ type: 'acquired',
664
+ record: {
665
+ requestId,
666
+ idempotencyKey: 'edit-completion-failure-key',
667
+ requestHash: 'hash',
668
+ mode: 'edit',
669
+ status: 'running',
670
+ requestJson: { fields: { prompt: 'state completion failure' } },
671
+ createdAt: '2026-05-12T00:00:00.000Z',
672
+ updatedAt: '2026-05-12T00:00:00.000Z',
673
+ expiresAt: '2026-05-13T00:00:00.000Z'
674
+ }
675
+ };
676
+ },
677
+ async refreshRequestLease() {
678
+ return false;
679
+ },
680
+ async saveArtifacts() {
681
+ saveCalls += 1;
682
+ },
683
+ async completeRequest() {
684
+ throw new Error('state completion failed');
685
+ },
686
+ async failRequest() {
687
+ failCalls += 1;
688
+ },
689
+ async getRequest() {
690
+ return undefined;
691
+ },
692
+ async getArtifact() {
693
+ return undefined;
694
+ },
695
+ async listArtifactsForRequest() {
696
+ return [];
697
+ },
698
+ async deleteArtifact() {
699
+ return false;
700
+ }
701
+ }));
702
+
703
+ const originalConsoleError = console.error;
704
+ console.error = () => {};
705
+ try {
706
+ const response = await editImage(agentEditRequest('edit-completion-failure-key', 'state completion failure'));
707
+
708
+ assert.equal(response.status, 500);
709
+ const body = await response.json();
710
+ assert.equal(body.error.code, 'unexpected_error');
711
+ assert.equal(body.error.retryable, true);
712
+ assert.equal(body.error.request_id, requestId);
713
+ assert.equal(upstreamCalls, 1);
714
+ assert.equal(saveCalls, 1);
715
+ assert.equal(failCalls, 0);
716
+ } finally {
717
+ console.error = originalConsoleError;
718
+ await upstream.close();
719
+ }
720
+ });
721
+
722
  it('requires artifact content authorization and returns image bytes when authorized', async () => {
723
  const { generateImage, getArtifact, getArtifactContent, deleteArtifact } = await loadAgentRoutes();
724
  const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
 
839
  }
840
  };
841
  },
842
+ async refreshRequestLease() {
843
+ return false;
844
+ },
845
  async saveArtifacts() {
846
  saveCalls += 1;
847
  },
 
851
  async failRequest() {
852
  failCalls += 1;
853
  },
854
+ async getRequest() {
855
+ return undefined;
856
+ },
857
  async getArtifact() {
858
  return undefined;
859
  },
 
916
  }
917
  };
918
  },
919
+ async refreshRequestLease() {
920
+ return false;
921
+ },
922
  async saveArtifacts() {
923
  throw new Error('artifact metadata save failed');
924
  },
 
927
  assert.equal(input.requestId, requestId);
928
  assert.equal(input.error.error.retryable, true);
929
  },
930
+ async getRequest() {
931
+ return undefined;
932
+ },
933
  async getArtifact() {
934
  return undefined;
935
  },
 
1023
  const artifactRoute = await import('./artifacts/[id]/route');
1024
  const artifactContentRoute = await import('./artifacts/[id]/content/route');
1025
  const capabilitiesRoute = await import('./capabilities/route');
1026
+ const createGenerateJobRoute = await import('./jobs/images/generate/route');
1027
+ const jobRoute = await import('./jobs/[id]/route');
1028
+ const jobResultRoute = await import('./jobs/[id]/result/route');
1029
  return {
1030
  getCapabilities: capabilitiesRoute.GET,
1031
  generateImage: generateRoute.POST,
1032
  editImage: editRoute.POST,
1033
+ createGenerateJob: createGenerateJobRoute.POST,
1034
+ getJob: jobRoute.GET,
1035
+ getJobResult: jobResultRoute.GET,
1036
  getArtifact: artifactRoute.GET,
1037
  deleteArtifact: artifactRoute.DELETE,
1038
  getArtifactContent: artifactContentRoute.GET
 
1051
  });
1052
  }
1053
 
1054
+ function agentJobJsonRequest(idempotencyKey: string, body: Record<string, unknown>, headers: Record<string, string> = {}) {
1055
+ return new Request('http://localhost/api/agent/jobs/images/generate', {
1056
+ method: 'POST',
1057
+ headers: {
1058
+ 'Content-Type': 'application/json',
1059
+ 'Idempotency-Key': idempotencyKey,
1060
+ ...headers
1061
+ },
1062
+ body: JSON.stringify(body)
1063
+ });
1064
+ }
1065
+
1066
  function agentEditRequest(
1067
  idempotencyKey: string,
1068
  prompt: string,
 
1161
  }
1162
  throw new Error('等待条件超时');
1163
  }
1164
+
1165
+ async function waitForJobResult(
1166
+ getJobResult: (
1167
+ request: Request,
1168
+ context: { params: Promise<{ id: string }> }
1169
+ ) => Promise<Response>,
1170
+ id: string
1171
+ ): Promise<Response> {
1172
+ for (let attempt = 0; attempt < 200; attempt += 1) {
1173
+ const response = await getJobResult(new Request(`http://localhost/api/agent/jobs/${id}/result`), {
1174
+ params: Promise.resolve({ id })
1175
+ });
1176
+ if (response.status !== 409) {
1177
+ return response;
1178
+ }
1179
+ await new Promise((resolve) => setTimeout(resolve, 20));
1180
+ }
1181
+ throw new Error('等待 job result 超时');
1182
+ }
src/app/api/agent/images/edit/route.ts CHANGED
@@ -39,11 +39,13 @@ export async function POST(request: NextRequest) {
39
 
40
  if (beginResult.type === 'replay') {
41
  const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response);
42
- return NextResponse.json(response, { headers: { 'X-Idempotent-Replay': 'true' } });
 
 
43
  }
44
  if (beginResult.type === 'failed') {
45
  requestId = beginResult.record.requestId;
46
- return storedAgentErrorResponse(beginResult.error);
47
  }
48
  if (beginResult.type === 'conflict') {
49
  throw new AgentApiError({
@@ -93,9 +95,7 @@ export async function POST(request: NextRequest) {
93
  await completeAgentExecutionState(store, execution);
94
  } catch (error) {
95
  appLogger.error('保存 Agent 编辑完成状态失败。', error);
96
- const persistenceError = createCompletionPersistenceError();
97
- await store.failRequest({ requestId, error: errorToAgentErrorBody(persistenceError, requestId) });
98
- throw persistenceError;
99
  }
100
  return NextResponse.json(execution.response, { headers });
101
  } catch (error) {
 
39
 
40
  if (beginResult.type === 'replay') {
41
  const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response);
42
+ return NextResponse.json(response, {
43
+ headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': beginResult.record.requestId }
44
+ });
45
  }
46
  if (beginResult.type === 'failed') {
47
  requestId = beginResult.record.requestId;
48
+ return storedAgentErrorResponse(beginResult.error, { 'X-Idempotent-Replay': 'true' });
49
  }
50
  if (beginResult.type === 'conflict') {
51
  throw new AgentApiError({
 
95
  await completeAgentExecutionState(store, execution);
96
  } catch (error) {
97
  appLogger.error('保存 Agent 编辑完成状态失败。', error);
98
+ throw createCompletionPersistenceError();
 
 
99
  }
100
  return NextResponse.json(execution.response, { headers });
101
  } catch (error) {
src/app/api/agent/images/generate/route.ts CHANGED
@@ -37,11 +37,13 @@ export async function POST(request: NextRequest) {
37
 
38
  if (beginResult.type === 'replay') {
39
  const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response);
40
- return NextResponse.json(response, { headers: { 'X-Idempotent-Replay': 'true' } });
 
 
41
  }
42
  if (beginResult.type === 'failed') {
43
  requestId = beginResult.record.requestId;
44
- return storedAgentErrorResponse(beginResult.error);
45
  }
46
  if (beginResult.type === 'conflict') {
47
  throw new AgentApiError({
 
37
 
38
  if (beginResult.type === 'replay') {
39
  const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response);
40
+ return NextResponse.json(response, {
41
+ headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': beginResult.record.requestId }
42
+ });
43
  }
44
  if (beginResult.type === 'failed') {
45
  requestId = beginResult.record.requestId;
46
+ return storedAgentErrorResponse(beginResult.error, { 'X-Idempotent-Replay': 'true' });
47
  }
48
  if (beginResult.type === 'conflict') {
49
  throw new AgentApiError({
src/app/api/agent/jobs/[id]/result/route.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ assertReadableJobRecord,
3
+ readAgentJobState,
4
+ readCompletedJobResult
5
+ } from '@/lib/agent-job-service';
6
+ import {
7
+ AgentApiError,
8
+ agentErrorResponse,
9
+ normalizeAgentError,
10
+ storedAgentErrorResponse
11
+ } from '@/lib/api-error-response';
12
+ import { assertAgentAuthorized } from '@/lib/agent-auth';
13
+ import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
14
+ import { computeRetryAfterSeconds, createRequestId } from '@/lib/agent-state-store';
15
+ import { NextRequest, NextResponse } from 'next/server';
16
+
17
+ type RouteContext = {
18
+ params: Promise<{ id: string }>;
19
+ };
20
+
21
+ export async function GET(request: NextRequest, context: RouteContext) {
22
+ let requestId = createRequestId();
23
+ try {
24
+ assertAgentAuthorized(request.headers);
25
+ const { id } = await context.params;
26
+ const store = await ensureAgentStateStoreReady();
27
+ const record = assertReadableJobRecord(await store.getRequest(id), id);
28
+ requestId = record.requestId;
29
+ const result = await readCompletedJobResult(store, record);
30
+ if (result.type === 'response') {
31
+ return NextResponse.json(result.response, { headers: { 'X-Request-Id': requestId } });
32
+ }
33
+ if (result.type === 'stored_error') {
34
+ return storedAgentErrorResponse(result.error);
35
+ }
36
+ throw createJobInProgressError(record);
37
+ } catch (error) {
38
+ return agentErrorResponse(normalizeAgentError(error), requestId);
39
+ }
40
+ }
41
+
42
+ function createJobInProgressError(record: Parameters<typeof readAgentJobState>[0]): AgentApiError {
43
+ const retryAfterSeconds = computeRetryAfterSeconds(record.lockedUntil, new Date());
44
+ return new AgentApiError({
45
+ code: 'request_in_progress',
46
+ message: 'Agent job 仍在运行。',
47
+ status: 409,
48
+ retryable: true,
49
+ retryAfterSeconds
50
+ });
51
+ }
src/app/api/agent/jobs/[id]/route.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { assertReadableJobRecord, buildAgentJobStatusResponse, readAgentJobState } from '@/lib/agent-job-service';
2
+ import { agentErrorResponse, normalizeAgentError } from '@/lib/api-error-response';
3
+ import { assertAgentAuthorized } from '@/lib/agent-auth';
4
+ import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
5
+ import { createRequestId, computeRetryAfterSeconds } from '@/lib/agent-state-store';
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+
8
+ type RouteContext = {
9
+ params: Promise<{ id: string }>;
10
+ };
11
+
12
+ export async function GET(request: NextRequest, context: RouteContext) {
13
+ let requestId = createRequestId();
14
+ try {
15
+ assertAgentAuthorized(request.headers);
16
+ const { id } = await context.params;
17
+ const store = await ensureAgentStateStoreReady();
18
+ const record = assertReadableJobRecord(await store.getRequest(id), id);
19
+ requestId = record.requestId;
20
+ const retryAfterSeconds = readRetryAfterSeconds(record);
21
+ return NextResponse.json(buildAgentJobStatusResponse(record, { retryAfterSeconds }), {
22
+ headers: { 'X-Request-Id': requestId }
23
+ });
24
+ } catch (error) {
25
+ return agentErrorResponse(normalizeAgentError(error), requestId);
26
+ }
27
+ }
28
+
29
+ function readRetryAfterSeconds(record: Parameters<typeof readAgentJobState>[0]): number | undefined {
30
+ if (readAgentJobState(record) !== 'running') return undefined;
31
+ return computeRetryAfterSeconds(record.lockedUntil, new Date());
32
+ }
src/app/api/agent/jobs/images/generate/route.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { buildAgentJobStatusResponse, startAgentGenerateJob } from '@/lib/agent-job-service';
2
+ import {
3
+ buildGenerateRequestHash,
4
+ parseAgentGenerateRequest,
5
+ readIdempotencyKey
6
+ } from '@/lib/agent-image-service';
7
+ import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
8
+ import { AgentApiError, agentErrorResponse, normalizeAgentError } from '@/lib/api-error-response';
9
+ import { assertAgentAuthorized } from '@/lib/agent-auth';
10
+ import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
11
+ import { createRequestId } from '@/lib/agent-state-store';
12
+ import { NextRequest, NextResponse } from 'next/server';
13
+
14
+ export async function POST(request: NextRequest) {
15
+ let requestId = createRequestId();
16
+ try {
17
+ assertAgentAuthorized(request.headers);
18
+ const imageRequest = await parseAgentGenerateRequest(request);
19
+ const idempotencyKey = readIdempotencyKey(request.headers);
20
+ const store = await ensureAgentStateStoreReady();
21
+ const leaseMs = readAgentLeaseMs(process.env);
22
+ const beginResult = await store.beginRequest({
23
+ idempotencyKey,
24
+ requestHash: buildGenerateRequestHash(imageRequest),
25
+ mode: 'generate',
26
+ requestJson: imageRequest,
27
+ leaseMs,
28
+ ttlSeconds: readAgentRequestTtlSeconds(process.env)
29
+ });
30
+
31
+ if (beginResult.type === 'conflict') {
32
+ throw createIdempotencyConflictError();
33
+ }
34
+ if (beginResult.type === 'replay' || beginResult.type === 'failed') {
35
+ requestId = beginResult.record.requestId;
36
+ return NextResponse.json(buildAgentJobStatusResponse(beginResult.record), {
37
+ headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': requestId }
38
+ });
39
+ }
40
+ if (beginResult.type === 'in_progress') {
41
+ requestId = beginResult.record.requestId;
42
+ return runningJobResponse(beginResult.record, beginResult.retryAfterSeconds, true);
43
+ }
44
+
45
+ requestId = beginResult.record.requestId;
46
+ startAgentGenerateJob({
47
+ store,
48
+ request: imageRequest,
49
+ headers: new Headers(request.headers),
50
+ requestId,
51
+ idempotencyKey,
52
+ leaseMs
53
+ });
54
+ return runningJobResponse(beginResult.record, 5, false);
55
+ } catch (error) {
56
+ return agentErrorResponse(normalizeAgentError(error), requestId);
57
+ }
58
+ }
59
+
60
+ function createIdempotencyConflictError(): AgentApiError {
61
+ return new AgentApiError({
62
+ code: 'idempotency_conflict',
63
+ message: 'Idempotency-Key 已被不同请求正文使用。',
64
+ status: 409,
65
+ retryable: false
66
+ });
67
+ }
68
+
69
+ function runningJobResponse(record: Parameters<typeof buildAgentJobStatusResponse>[0], retryAfterSeconds: number, replay: boolean) {
70
+ return NextResponse.json(buildAgentJobStatusResponse(record, { retryAfterSeconds }), {
71
+ status: 202,
72
+ headers: {
73
+ 'Retry-After': String(retryAfterSeconds),
74
+ 'X-Request-Id': record.requestId,
75
+ ...(replay ? { 'X-Idempotent-Replay': 'true' } : {})
76
+ }
77
+ });
78
+ }
src/app/api/agent/openapi.json/route.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { buildAgentOpenApiDocument } from '@/lib/agent-api-contracts';
2
  import { NextResponse } from 'next/server';
3
 
4
  export async function GET() {
 
1
+ import { buildAgentOpenApiDocument } from '@/lib/agent-openapi';
2
  import { NextResponse } from 'next/server';
3
 
4
  export async function GET() {
src/app/api/auth-verify/route.test.ts CHANGED
@@ -5,7 +5,7 @@ import { afterEach, describe, it } from 'node:test';
5
  import { NextRequest } from 'next/server';
6
 
7
  const originalAppPassword = process.env.APP_PASSWORD;
8
- const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-');
9
 
10
  afterEach(() => {
11
  if (originalAppPassword === undefined) {
@@ -16,7 +16,7 @@ afterEach(() => {
16
  });
17
 
18
  describe('POST /api/auth-verify', () => {
19
- it('returns a page password error code for invalid password hashes', async () => {
20
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
21
  const request = new NextRequest('http://localhost/api/auth-verify', {
22
  method: 'POST',
 
5
  import { NextRequest } from 'next/server';
6
 
7
  const originalAppPassword = process.env.APP_PASSWORD;
8
+ const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
9
 
10
  afterEach(() => {
11
  if (originalAppPassword === undefined) {
 
16
  });
17
 
18
  describe('POST /api/auth-verify', () => {
19
+ it('returns a page access code error code for invalid access-code hashes', async () => {
20
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
21
  const request = new NextRequest('http://localhost/api/auth-verify', {
22
  method: 'POST',
src/app/api/auth-verify/route.ts CHANGED
@@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
24
  return NextResponse.json(
25
  {
26
  authenticated: false,
27
- error: 'Unauthorized: Invalid password.',
28
  code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid
29
  },
30
  { status: 401 }
 
24
  return NextResponse.json(
25
  {
26
  authenticated: false,
27
+ error: 'Unauthorized: Invalid access code.',
28
  code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid
29
  },
30
  { status: 401 }
src/app/api/image-delete/route.ts CHANGED
@@ -26,12 +26,12 @@ export async function POST(request: NextRequest) {
26
  const clientPasswordHash = requestBody.passwordHash;
27
 
28
  if (!clientPasswordHash) {
29
- appLogger.error('删除操作缺少码哈希。');
30
- return NextResponse.json({ error: '未授权:缺少码哈希。' }, { status: 401 });
31
  }
32
  if (!verifyPasswordHash(clientPasswordHash, appPassword)) {
33
- appLogger.error('删除操作的码哈希无效。');
34
- return NextResponse.json({ error: '未授权:码无效。' }, { status: 401 });
35
  }
36
  }
37
  } catch (e) {
 
26
  const clientPasswordHash = requestBody.passwordHash;
27
 
28
  if (!clientPasswordHash) {
29
+ appLogger.error('删除操作缺少访问码哈希。');
30
+ return NextResponse.json({ error: '未授权:缺少访问码哈希。' }, { status: 401 });
31
  }
32
  if (!verifyPasswordHash(clientPasswordHash, appPassword)) {
33
+ appLogger.error('删除操作的访问码哈希无效。');
34
+ return NextResponse.json({ error: '未授权:访问码无效。' }, { status: 401 });
35
  }
36
  }
37
  } catch (e) {
src/app/api/image-route.test.ts CHANGED
@@ -6,8 +6,8 @@ import { afterEach, describe, it } from 'node:test';
6
  import { NextRequest } from 'next/server';
7
 
8
  const originalAppPassword = process.env.APP_PASSWORD;
9
- const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-');
10
- const OTHER_PAGE_PASSWORD_FIXTURE = ['other', 'password'].join('-');
11
 
12
  afterEach(() => {
13
  if (originalAppPassword === undefined) {
@@ -18,7 +18,7 @@ afterEach(() => {
18
  });
19
 
20
  describe('GET /api/image/[filename]', () => {
21
- it('returns a missing page password code when the access cookie is absent', async () => {
22
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
23
  const request = new NextRequest('http://localhost/api/image/sample.png');
24
 
@@ -29,7 +29,7 @@ describe('GET /api/image/[filename]', () => {
29
  assert.equal(result.code, PAGE_PASSWORD_AUTH_ERROR_CODES.missing);
30
  });
31
 
32
- it('returns an invalid page password code when the access cookie is wrong', async () => {
33
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
34
  const request = new NextRequest('http://localhost/api/image/sample.png', {
35
  headers: {
 
6
  import { NextRequest } from 'next/server';
7
 
8
  const originalAppPassword = process.env.APP_PASSWORD;
9
+ const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
10
+ const OTHER_PAGE_PASSWORD_FIXTURE = ['other', 'access', 'code'].join('-');
11
 
12
  afterEach(() => {
13
  if (originalAppPassword === undefined) {
 
18
  });
19
 
20
  describe('GET /api/image/[filename]', () => {
21
+ it('returns a missing page access code error when the access cookie is absent', async () => {
22
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
23
  const request = new NextRequest('http://localhost/api/image/sample.png');
24
 
 
29
  assert.equal(result.code, PAGE_PASSWORD_AUTH_ERROR_CODES.missing);
30
  });
31
 
32
+ it('returns an invalid page access code error when the access cookie is wrong', async () => {
33
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
34
  const request = new NextRequest('http://localhost/api/image/sample.png', {
35
  headers: {
src/app/api/images/route.ts CHANGED
@@ -101,16 +101,16 @@ export async function POST(request: NextRequest) {
101
  if (appPassword) {
102
  const clientPasswordHash = formData.get('passwordHash');
103
  if (typeof clientPasswordHash !== 'string' || !clientPasswordHash) {
104
- appLogger.error('缺少码哈希。', requestLogContext);
105
  return NextResponse.json(
106
- { error: '未授权:缺少码哈希。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.missing },
107
  { status: 401 }
108
  );
109
  }
110
  if (!verifyPasswordHash(clientPasswordHash, appPassword)) {
111
- appLogger.error('码哈希无效。', requestLogContext);
112
  return NextResponse.json(
113
- { error: '未授权:码无效。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid },
114
  { status: 401 }
115
  );
116
  }
 
101
  if (appPassword) {
102
  const clientPasswordHash = formData.get('passwordHash');
103
  if (typeof clientPasswordHash !== 'string' || !clientPasswordHash) {
104
+ appLogger.error('缺少访问码哈希。', requestLogContext);
105
  return NextResponse.json(
106
+ { error: '未授权:缺少访问码哈希。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.missing },
107
  { status: 401 }
108
  );
109
  }
110
  if (!verifyPasswordHash(clientPasswordHash, appPassword)) {
111
+ appLogger.error('访问码哈希无效。', requestLogContext);
112
  return NextResponse.json(
113
+ { error: '未授权:访问码无效。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid },
114
  { status: 401 }
115
  );
116
  }
src/app/api/logs/route.test.ts CHANGED
@@ -6,7 +6,7 @@ import { NextRequest } from 'next/server';
6
 
7
  const originalAppPassword = process.env.APP_PASSWORD;
8
  const originalAppLogLevel = process.env.APP_LOG_LEVEL;
9
- const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-');
10
 
11
  afterEach(() => {
12
  if (originalAppPassword === undefined) {
@@ -35,7 +35,7 @@ describe('GET /api/logs', () => {
35
  assert.equal(response.status, 403);
36
  });
37
 
38
- it('rejects password hashes sent in the query string', async () => {
39
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
40
  const request = new NextRequest(`http://localhost/api/logs?passwordHash=${sha256(PAGE_PASSWORD_FIXTURE)}`);
41
 
@@ -44,7 +44,7 @@ describe('GET /api/logs', () => {
44
  assert.equal(response.status, 401);
45
  });
46
 
47
- it('accepts password hashes sent as a bearer token', async () => {
48
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
49
  process.env.APP_LOG_LEVEL = 'warn';
50
  const request = new NextRequest('http://localhost/api/logs', {
 
6
 
7
  const originalAppPassword = process.env.APP_PASSWORD;
8
  const originalAppLogLevel = process.env.APP_LOG_LEVEL;
9
+ const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
10
 
11
  afterEach(() => {
12
  if (originalAppPassword === undefined) {
 
35
  assert.equal(response.status, 403);
36
  });
37
 
38
+ it('rejects access-code hashes sent in the query string', async () => {
39
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
40
  const request = new NextRequest(`http://localhost/api/logs?passwordHash=${sha256(PAGE_PASSWORD_FIXTURE)}`);
41
 
 
44
  assert.equal(response.status, 401);
45
  });
46
 
47
+ it('accepts access-code hashes sent as a bearer token', async () => {
48
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
49
  process.env.APP_LOG_LEVEL = 'warn';
50
  const request = new NextRequest('http://localhost/api/logs', {
src/app/api/logs/route.ts CHANGED
@@ -28,7 +28,7 @@ export async function GET(request: NextRequest) {
28
  }
29
 
30
  if (!isAuthorized(request)) {
31
- return NextResponse.json({ error: '未授权:缺少或无效的码哈希。' }, { status: 401 });
32
  }
33
 
34
  appLogger.info('日志查看器已连接。');
 
28
  }
29
 
30
  if (!isAuthorized(request)) {
31
+ return NextResponse.json({ error: '未授权:缺少或无效的访问码哈希。' }, { status: 401 });
32
  }
33
 
34
  appLogger.info('日志查看器已连接。');
src/app/api/shares/[token]/content/route.ts CHANGED
@@ -62,7 +62,9 @@ function contentDispositionFilename(record: { accessCodeRequired: boolean; sourc
62
  if (record.accessCodeRequired) {
63
  return `shared-image${mimeExtension(record.mimeType)}`;
64
  }
65
- return sanitizeHeaderFilename(record.sourceFilename);
 
 
66
  }
67
 
68
  function pruneExpiredAccessFailures(now: number) {
@@ -122,6 +124,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
122
  Pragma: 'no-cache',
123
  Expires: '0',
124
  'Surrogate-Control': 'no-store',
 
125
  'Content-Disposition': `inline; filename="${contentDispositionFilename(record)}"`
126
  }
127
  });
 
62
  if (record.accessCodeRequired) {
63
  return `shared-image${mimeExtension(record.mimeType)}`;
64
  }
65
+ const sanitized = sanitizeHeaderFilename(record.sourceFilename);
66
+ const stem = sanitized.replace(/\.[^.]+$/, '') || 'shared-image';
67
+ return `${stem}${mimeExtension(record.mimeType)}`;
68
  }
69
 
70
  function pruneExpiredAccessFailures(now: number) {
 
124
  Pragma: 'no-cache',
125
  Expires: '0',
126
  'Surrogate-Control': 'no-store',
127
+ 'X-Content-Type-Options': 'nosniff',
128
  'Content-Disposition': `inline; filename="${contentDispositionFilename(record)}"`
129
  }
130
  });
src/app/api/shares/route.test.ts CHANGED
@@ -13,7 +13,7 @@ import { GET as getShare } from './[token]/route';
13
  import { POST } from './route';
14
 
15
  const originalAppPassword = process.env.APP_PASSWORD;
16
- const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-');
17
  const VALID_PNG_BYTES = Buffer.from(
18
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR42mP8z8AABQMBgADeT7UAAAAASUVORK5CYII=',
19
  'base64'
@@ -73,7 +73,7 @@ describe('POST /api/shares', { concurrency: false }, () => {
73
  assert.equal('accessCodeSalt' in body, false);
74
  });
75
 
76
- it('rejects unauthenticated share creation when a page password is configured', async () => {
77
  await withTempCwd();
78
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
79
  const form = new FormData();
@@ -99,7 +99,7 @@ describe('POST /api/shares', { concurrency: false }, () => {
99
  assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
100
  });
101
 
102
- it('allows share creation when no page password is configured', async () => {
103
  await withTempCwd();
104
  delete process.env.APP_PASSWORD;
105
  const form = new FormData();
@@ -193,6 +193,45 @@ describe('POST /api/shares', { concurrency: false }, () => {
193
  const body = await response.json();
194
  assert.equal(body.code, 'invalid_expiry');
195
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  });
197
 
198
  describe('share metadata and content routes', { concurrency: false }, () => {
@@ -272,10 +311,36 @@ describe('share metadata and content routes', { concurrency: false }, () => {
272
  assert.equal(ok.headers.get('content-type'), 'image/png');
273
  assert.match(ok.headers.get('cache-control') || '', /no-store/);
274
  assert.equal(ok.headers.get('surrogate-control'), 'no-store');
 
275
  assert.equal(ok.headers.get('content-disposition'), 'inline; filename="shared-image.png"');
276
  assert.equal(await ok.text(), 'protected-image');
277
  });
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  it('rate limits repeated wrong access codes', async () => {
280
  await withTempCwd();
281
  const record = await createImageShare({
 
13
  import { POST } from './route';
14
 
15
  const originalAppPassword = process.env.APP_PASSWORD;
16
+ const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
17
  const VALID_PNG_BYTES = Buffer.from(
18
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR42mP8z8AABQMBgADeT7UAAAAASUVORK5CYII=',
19
  'base64'
 
73
  assert.equal('accessCodeSalt' in body, false);
74
  });
75
 
76
+ it('rejects unauthenticated share creation when a page access code is configured', async () => {
77
  await withTempCwd();
78
  process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
79
  const form = new FormData();
 
99
  assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
100
  });
101
 
102
+ it('allows share creation when no page access code is configured', async () => {
103
  await withTempCwd();
104
  delete process.env.APP_PASSWORD;
105
  const form = new FormData();
 
193
  const body = await response.json();
194
  assert.equal(body.code, 'invalid_expiry');
195
  });
196
+
197
+ it('rejects unsafe source filenames before storing share metadata', async () => {
198
+ await withTempCwd();
199
+ process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
200
+ const form = new FormData();
201
+ form.set('sourceFilename', '../secret.png');
202
+ form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' }));
203
+
204
+ const response = await POST(createShareRequest(form));
205
+ assert.equal(response.status, 400);
206
+ const body = await response.json();
207
+ assert.equal(body.code, 'invalid_source_filename');
208
+ });
209
+
210
+ it('rejects non-string source filenames before storing share metadata', async () => {
211
+ await withTempCwd();
212
+ process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
213
+ const form = new FormData();
214
+ form.set('sourceFilename', new File([Buffer.from('not-a-name')], 'name.txt', { type: 'text/plain' }));
215
+ form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' }));
216
+
217
+ const response = await POST(createShareRequest(form));
218
+ assert.equal(response.status, 400);
219
+ const body = await response.json();
220
+ assert.equal(body.code, 'invalid_source_filename');
221
+ });
222
+
223
+ it('rejects overlong source filenames before storing share metadata', async () => {
224
+ await withTempCwd();
225
+ process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
226
+ const form = new FormData();
227
+ form.set('sourceFilename', `${'a'.repeat(201)}.png`);
228
+ form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' }));
229
+
230
+ const response = await POST(createShareRequest(form));
231
+ assert.equal(response.status, 400);
232
+ const body = await response.json();
233
+ assert.equal(body.code, 'invalid_source_filename');
234
+ });
235
  });
236
 
237
  describe('share metadata and content routes', { concurrency: false }, () => {
 
311
  assert.equal(ok.headers.get('content-type'), 'image/png');
312
  assert.match(ok.headers.get('cache-control') || '', /no-store/);
313
  assert.equal(ok.headers.get('surrogate-control'), 'no-store');
314
+ assert.equal(ok.headers.get('x-content-type-options'), 'nosniff');
315
  assert.equal(ok.headers.get('content-disposition'), 'inline; filename="shared-image.png"');
316
  assert.equal(await ok.text(), 'protected-image');
317
  });
318
 
319
+ it('uses the detected image MIME type for public content filenames', async () => {
320
+ await withTempCwd();
321
+ const record = await createImageShare({
322
+ imageBuffer: Buffer.from('public-image'),
323
+ sourceFilename: 'invoice.html',
324
+ mimeType: 'image/png',
325
+ accessCode: undefined,
326
+ expiresInMinutes: null
327
+ });
328
+
329
+ const response = await getShareContent(
330
+ new Request(`http://localhost/api/shares/${record.token}/content`, {
331
+ method: 'POST',
332
+ headers: { 'content-type': 'application/json' },
333
+ body: JSON.stringify({})
334
+ }),
335
+ params(record.token)
336
+ );
337
+
338
+ assert.equal(response.status, 200);
339
+ assert.equal(response.headers.get('content-type'), 'image/png');
340
+ assert.equal(response.headers.get('x-content-type-options'), 'nosniff');
341
+ assert.equal(response.headers.get('content-disposition'), 'inline; filename="invoice.png"');
342
+ });
343
+
344
  it('rate limits repeated wrong access codes', async () => {
345
  await withTempCwd();
346
  const record = await createImageShare({
src/app/api/shares/route.ts CHANGED
@@ -6,6 +6,8 @@ import { NextRequest, NextResponse } from 'next/server';
6
  const MAX_SHARE_IMAGE_BYTES = 30 * 1024 * 1024;
7
  const MIN_ACCESS_CODE_LENGTH = 8;
8
  const MAX_ACCESS_CODE_LENGTH = 128;
 
 
9
 
10
  function detectImageMimeType(buffer: Buffer): string | undefined {
11
  if (
@@ -60,6 +62,15 @@ function parseAccessCode(value: FormDataEntryValue | null): string | undefined |
60
  return trimmed;
61
  }
62
 
 
 
 
 
 
 
 
 
 
63
  function verifyShareCreator(request: NextRequest) {
64
  if (!process.env.APP_PASSWORD) return undefined;
65
  const accessToken = request.cookies.get('gptImageAccess')?.value;
@@ -89,7 +100,10 @@ export async function POST(request: NextRequest) {
89
 
90
  const sourceFilenameValue = form.get('sourceFilename');
91
  const fallbackFilename = typeof image.name === 'string' && image.name.trim() ? image.name : 'shared-image.png';
92
- const sourceFilename = typeof sourceFilenameValue === 'string' && sourceFilenameValue.trim() ? sourceFilenameValue.trim() : fallbackFilename;
 
 
 
93
  const expiresInMinutes = parseExpiry(form.get('expiresInMinutes'));
94
  if (expiresInMinutes === undefined) {
95
  return jsonError('invalid_expiry', '分享有效期无效。', 400);
 
6
  const MAX_SHARE_IMAGE_BYTES = 30 * 1024 * 1024;
7
  const MIN_ACCESS_CODE_LENGTH = 8;
8
  const MAX_ACCESS_CODE_LENGTH = 128;
9
+ const MAX_SOURCE_FILENAME_LENGTH = 200;
10
+ const SOURCE_FILENAME_PATTERN = /^[^\x00-\x1f\x7f\\/]+$/u;
11
 
12
  function detectImageMimeType(buffer: Buffer): string | undefined {
13
  if (
 
62
  return trimmed;
63
  }
64
 
65
+ function parseSourceFilename(value: FormDataEntryValue | null, fallback: string): string | null {
66
+ if (value !== null && typeof value !== 'string') return null;
67
+ const candidate = value?.trim() || fallback.trim();
68
+ if (!candidate || candidate.length > MAX_SOURCE_FILENAME_LENGTH || !SOURCE_FILENAME_PATTERN.test(candidate)) {
69
+ return null;
70
+ }
71
+ return candidate;
72
+ }
73
+
74
  function verifyShareCreator(request: NextRequest) {
75
  if (!process.env.APP_PASSWORD) return undefined;
76
  const accessToken = request.cookies.get('gptImageAccess')?.value;
 
100
 
101
  const sourceFilenameValue = form.get('sourceFilename');
102
  const fallbackFilename = typeof image.name === 'string' && image.name.trim() ? image.name : 'shared-image.png';
103
+ const sourceFilename = parseSourceFilename(sourceFilenameValue, fallbackFilename);
104
+ if (sourceFilename === null) {
105
+ return jsonError('invalid_source_filename', '分享文件名无效。', 400);
106
+ }
107
  const expiresInMinutes = parseExpiry(form.get('expiresInMinutes'));
108
  if (expiresInMinutes === undefined) {
109
  return jsonError('invalid_expiry', '分享有效期无效。', 400);
src/app/page.tsx CHANGED
@@ -471,7 +471,7 @@ export default function HomePage() {
471
  }
472
  return 'unavailable';
473
  } catch (error) {
474
- console.error('验证入口码失败:', error);
475
  return 'unavailable';
476
  }
477
  }, []);
@@ -683,7 +683,7 @@ export default function HomePage() {
683
  await handleApiCall(...retryArgs, hash);
684
  }
685
  } catch (e) {
686
- console.error('计算码哈希失败:', e);
687
  setError(createErrorNotice(t('password.hashError')));
688
  }
689
  };
 
471
  }
472
  return 'unavailable';
473
  } catch (error) {
474
+ console.error('验证入口访问码失败:', error);
475
  return 'unavailable';
476
  }
477
  }, []);
 
683
  await handleApiCall(...retryArgs, hash);
684
  }
685
  } catch (e) {
686
+ console.error('计算访问码哈希失败:', e);
687
  setError(createErrorNotice(t('password.hashError')));
688
  }
689
  };
src/components/password-dialog.tsx CHANGED
@@ -26,7 +26,7 @@ export function PasswordDialog({
26
  isOpen,
27
  onOpenChange,
28
  onSave,
29
- title = 'Configure Password',
30
  description
31
  }: PasswordDialogProps) {
32
  const { t } = useI18n();
 
26
  isOpen,
27
  onOpenChange,
28
  onSave,
29
+ title = 'Configure Access Code',
30
  description
31
  }: PasswordDialogProps) {
32
  const { t } = useI18n();
src/lib/agent-api-contracts.test.ts CHANGED
@@ -1,8 +1,13 @@
1
  import {
 
2
  buildAgentCapabilities,
3
- buildAgentOpenApiDocument,
 
 
 
4
  validateAgentGenerateRequest
5
  } from './agent-api-contracts';
 
6
  import { RequestValidationError } from './image-request-utils';
7
  import assert from 'node:assert/strict';
8
  import { describe, it } from 'node:test';
@@ -75,6 +80,30 @@ describe('validateAgentGenerateRequest', () => {
75
  });
76
  });
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  describe('buildAgentCapabilities', () => {
79
  it('exposes machine-readable defaults, limits, auth, and storage metadata', () => {
80
  const capabilities = buildAgentCapabilities({
@@ -86,10 +115,50 @@ describe('buildAgentCapabilities', () => {
86
 
87
  assert.equal(capabilities.defaults.state_backend, 'postgres');
88
  assert.equal(capabilities.auth.required, true);
 
89
  assert.equal(capabilities.storage.postgres_configured, true);
90
  assert.equal('sqlite_path' in capabilities.storage, false);
91
  assert.equal(capabilities.idempotency.header, 'Idempotency-Key');
92
  assert.ok(capabilities.supported.models.includes('gpt-image-2'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  });
94
 
95
  it('exposes memory state backend for ephemeral deployments', () => {
@@ -108,13 +177,29 @@ describe('buildAgentCapabilities', () => {
108
  assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]);
109
  assert.ok('/api/agent/openapi.json' in document.paths);
110
  assert.ok('/api/agent/images/generate' in document.paths);
 
 
 
111
  assert.ok('AgentCapabilities' in document.components.schemas);
112
  assert.ok('AgentImageResponse' in document.components.schemas);
 
113
  assert.ok('AgentArtifact' in document.components.schemas);
114
  assert.ok('EditRequest' in document.components.schemas);
115
  assert.ok('AgentError' in document.components.schemas);
 
 
 
 
116
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['200']);
 
 
117
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['422']);
 
 
 
 
 
 
118
  });
119
 
120
  it('describes public capabilities without server-local SQLite paths', () => {
@@ -126,26 +211,47 @@ describe('buildAgentCapabilities', () => {
126
  assert.equal('sqlite_path' in storageSchema.properties, false);
127
  });
128
 
129
- it('describes Agent authentication and common runtime failures in OpenAPI', () => {
130
- const document = buildAgentOpenApiDocument({});
 
 
 
131
 
132
  assert.ok(document.components.securitySchemes.BearerAuth);
133
- assert.ok(document.components.securitySchemes.AppPasswordHash);
134
- assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [
135
- { BearerAuth: [] },
136
- { AppPasswordHash: [] }
137
- ]);
138
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['401']);
139
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['415']);
140
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['502']);
141
  assert.ok(document.paths['/api/agent/images/edit'].post.responses['401']);
 
142
  assert.ok(document.paths['/api/agent/images/edit'].post.responses['415']);
143
  assert.ok(document.paths['/api/agent/images/edit'].post.responses['502']);
144
  });
145
 
146
- it('marks artifact routes as authenticated in OpenAPI', () => {
 
 
 
 
 
 
 
 
 
 
 
147
  const document = buildAgentOpenApiDocument({});
148
- const expectedSecurity = [{ BearerAuth: [] }, { AppPasswordHash: [] }];
 
 
 
 
 
 
 
 
149
 
150
  assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].get.security, expectedSecurity);
151
  assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].delete.security, expectedSecurity);
@@ -158,6 +264,7 @@ describe('buildAgentCapabilities', () => {
158
  const schema = document.components.schemas.AgentArtifact;
159
 
160
  assert.equal(responseSchema.properties.artifact.$ref, '#/components/schemas/AgentArtifact');
 
161
  assert.equal('filepath' in schema.properties, false);
162
  assert.ok('content_url' in schema.properties);
163
  assert.ok('metadata_url' in schema.properties);
 
1
  import {
2
+ buildAgentAuthCapabilities,
3
  buildAgentCapabilities,
4
+ readAgentLeaseMs,
5
+ readAgentPublicBaseUrl,
6
+ readAgentRecoveryIntervalMs,
7
+ readAgentRequestTtlSeconds,
8
  validateAgentGenerateRequest
9
  } from './agent-api-contracts';
10
+ import { buildAgentOpenApiDocument } from './agent-openapi';
11
  import { RequestValidationError } from './image-request-utils';
12
  import assert from 'node:assert/strict';
13
  import { describe, it } from 'node:test';
 
80
  });
81
  });
82
 
83
+ describe('Agent numeric configuration', () => {
84
+ it('uses defaults when optional numeric env values are absent', () => {
85
+ assert.equal(readAgentRequestTtlSeconds({}), 86400);
86
+ assert.equal(readAgentLeaseMs({}), 600000);
87
+ assert.equal(readAgentRecoveryIntervalMs({}), 30000);
88
+ });
89
+
90
+ it('fails explicitly when numeric env values are invalid', () => {
91
+ assert.throws(() => readAgentRequestTtlSeconds({ AGENT_REQUEST_TTL_SECONDS: 'abc' }), /AGENT_REQUEST_TTL_SECONDS/);
92
+ assert.throws(() => readAgentLeaseMs({ AGENT_REQUEST_LEASE_MS: '0' }), /AGENT_REQUEST_LEASE_MS/);
93
+ assert.throws(() => readAgentRecoveryIntervalMs({ AGENT_RECOVERY_INTERVAL_MS: '-1' }), /AGENT_RECOVERY_INTERVAL_MS/);
94
+ });
95
+
96
+ it('validates the public OpenAPI server URL', () => {
97
+ assert.equal(readAgentPublicBaseUrl({}), '/');
98
+ assert.equal(readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test/' }), 'https://images.example.test');
99
+ assert.equal(readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'http://localhost:4783' }), 'http://localhost:4783');
100
+ assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'not a url' }), /AGENT_PUBLIC_BASE_URL/);
101
+ assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'javascript:alert(1)' }), /AGENT_PUBLIC_BASE_URL/);
102
+ assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://user:pass@images.example.test' }), /AGENT_PUBLIC_BASE_URL/);
103
+ assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test?token=secret' }), /AGENT_PUBLIC_BASE_URL/);
104
+ });
105
+ });
106
+
107
  describe('buildAgentCapabilities', () => {
108
  it('exposes machine-readable defaults, limits, auth, and storage metadata', () => {
109
  const capabilities = buildAgentCapabilities({
 
115
 
116
  assert.equal(capabilities.defaults.state_backend, 'postgres');
117
  assert.equal(capabilities.auth.required, true);
118
+ assert.deepEqual(capabilities.auth.schemes, ['bearer']);
119
  assert.equal(capabilities.storage.postgres_configured, true);
120
  assert.equal('sqlite_path' in capabilities.storage, false);
121
  assert.equal(capabilities.idempotency.header, 'Idempotency-Key');
122
  assert.ok(capabilities.supported.models.includes('gpt-image-2'));
123
+ assert.equal(capabilities.model_limits['gpt-image-2'].max_edge, 3840);
124
+ assert.equal(capabilities.model_limits['gpt-image-2'].edge_multiple, 16);
125
+ assert.equal(capabilities.model_limits['gpt-image-2'].max_pixels, 8294400);
126
+ assert.equal(capabilities.agent_streaming.generate.supported, false);
127
+ assert.equal(capabilities.agent_streaming.generate.mode, 'non_streaming_only');
128
+ assert.equal(capabilities.agent_streaming.page_sse.endpoint, '/api/images');
129
+ assert.equal(capabilities.endpoints.create_generate_job, '/api/agent/jobs/images/generate');
130
+ assert.equal(capabilities.agent_jobs.supported, true);
131
+ assert.equal(capabilities.agent_jobs.mode, 'job_polling');
132
+ assert.equal(capabilities.agent_jobs.endpoints.create_generate_job, '/api/agent/jobs/images/generate');
133
+ assert.deepEqual(capabilities.agent_jobs.states, ['queued', 'running', 'succeeded', 'failed', 'expired']);
134
+ assert.match(capabilities.agent_jobs.current_guidance, /poll/i);
135
+ });
136
+
137
+ it('exposes only the runtime-accepted bearer auth scheme when Agent token is configured', () => {
138
+ assert.deepEqual(
139
+ buildAgentAuthCapabilities({
140
+ AGENT_API_TOKEN: 'token',
141
+ APP_PASSWORD: 'page-access-code'
142
+ }),
143
+ { required: true, schemes: ['bearer'] }
144
+ );
145
+ });
146
+
147
+ it('exposes access-code hash auth only when no Agent token is configured', () => {
148
+ assert.deepEqual(buildAgentAuthCapabilities({ APP_PASSWORD: 'page-access-code' }), {
149
+ required: true,
150
+ schemes: ['x-app-password-hash']
151
+ });
152
+ });
153
+
154
+ it('marks Agent auth as optional when no auth env is configured', () => {
155
+ assert.deepEqual(
156
+ buildAgentAuthCapabilities({
157
+ AGENT_API_TOKEN: ' ',
158
+ APP_PASSWORD: ' '
159
+ }),
160
+ { required: false, schemes: [] }
161
+ );
162
  });
163
 
164
  it('exposes memory state backend for ephemeral deployments', () => {
 
177
  assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]);
178
  assert.ok('/api/agent/openapi.json' in document.paths);
179
  assert.ok('/api/agent/images/generate' in document.paths);
180
+ assert.ok('/api/agent/jobs/images/generate' in document.paths);
181
+ assert.ok('/api/agent/jobs/{id}' in document.paths);
182
+ assert.ok('/api/agent/jobs/{id}/result' in document.paths);
183
  assert.ok('AgentCapabilities' in document.components.schemas);
184
  assert.ok('AgentImageResponse' in document.components.schemas);
185
+ assert.ok('AgentJobStatusResponse' in document.components.schemas);
186
  assert.ok('AgentArtifact' in document.components.schemas);
187
  assert.ok('EditRequest' in document.components.schemas);
188
  assert.ok('AgentError' in document.components.schemas);
189
+ assert.ok('AgentModelLimits' in document.components.schemas);
190
+ assert.ok('AgentStreamingCapabilities' in document.components.schemas);
191
+ assert.ok('AgentJobCapabilities' in document.components.schemas);
192
+ assert.ok('AgentErrorDiagnostics' in document.components.schemas);
193
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['200']);
194
+ assert.ok(document.paths['/api/agent/images/generate'].post.responses['403']);
195
+ assert.ok(document.paths['/api/agent/images/generate'].post.responses['429']);
196
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['422']);
197
+ assert.ok(document.paths['/api/agent/jobs/images/generate'].post.responses['202']);
198
+ assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['200']);
199
+ assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['409']);
200
+ assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['422']);
201
+ assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['429']);
202
+ assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['502']);
203
  });
204
 
205
  it('describes public capabilities without server-local SQLite paths', () => {
 
211
  assert.equal('sqlite_path' in storageSchema.properties, false);
212
  });
213
 
214
+ it('describes bearer authentication and common runtime failures in OpenAPI', () => {
215
+ const document = buildAgentOpenApiDocument({
216
+ AGENT_API_TOKEN: 'token',
217
+ APP_PASSWORD: 'page-access-code'
218
+ });
219
 
220
  assert.ok(document.components.securitySchemes.BearerAuth);
221
+ assert.equal('AppPasswordHash' in document.components.securitySchemes, false);
222
+ assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, ['bearer']);
223
+ assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [{ BearerAuth: [] }]);
 
 
224
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['401']);
225
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['415']);
226
  assert.ok(document.paths['/api/agent/images/generate'].post.responses['502']);
227
  assert.ok(document.paths['/api/agent/images/edit'].post.responses['401']);
228
+ assert.ok(document.paths['/api/agent/images/edit'].post.responses['409'].headers['Retry-After']);
229
  assert.ok(document.paths['/api/agent/images/edit'].post.responses['415']);
230
  assert.ok(document.paths['/api/agent/images/edit'].post.responses['502']);
231
  });
232
 
233
+ it('describes access-code hash authentication in OpenAPI when no Agent token is configured', () => {
234
+ const document = buildAgentOpenApiDocument({ APP_PASSWORD: 'page-access-code' });
235
+
236
+ assert.equal('BearerAuth' in document.components.securitySchemes, false);
237
+ assert.ok(document.components.securitySchemes.AppPasswordHash);
238
+ assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, [
239
+ 'x-app-password-hash'
240
+ ]);
241
+ assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [{ AppPasswordHash: [] }]);
242
+ });
243
+
244
+ it('does not require Agent authentication in OpenAPI when no auth env is configured', () => {
245
  const document = buildAgentOpenApiDocument({});
246
+
247
+ assert.deepEqual(document.components.securitySchemes, {});
248
+ assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, []);
249
+ assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, []);
250
+ });
251
+
252
+ it('marks artifact routes as authenticated in OpenAPI', () => {
253
+ const document = buildAgentOpenApiDocument({ AGENT_API_TOKEN: 'token' });
254
+ const expectedSecurity = [{ BearerAuth: [] }];
255
 
256
  assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].get.security, expectedSecurity);
257
  assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].delete.security, expectedSecurity);
 
264
  const schema = document.components.schemas.AgentArtifact;
265
 
266
  assert.equal(responseSchema.properties.artifact.$ref, '#/components/schemas/AgentArtifact');
267
+ assert.equal('AgentArtifactRecord' in document.components.schemas, false);
268
  assert.equal('filepath' in schema.properties, false);
269
  assert.ok('content_url' in schema.properties);
270
  assert.ok('metadata_url' in schema.properties);
src/lib/agent-api-contracts.ts CHANGED
@@ -7,10 +7,18 @@ import {
7
  type GptImageModel,
8
  type ValidOutputFormat
9
  } from './image-request-utils';
10
- import { validateGptImage2Size } from './size-utils';
 
 
 
 
 
 
 
 
11
 
12
  export const AGENT_API_VERSION = '1.0.0';
13
- export const AGENT_SCHEMA_VERSION = '2026-05-12';
14
  export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite';
15
  export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000;
16
  export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60;
@@ -23,12 +31,15 @@ export const AGENT_QUALITIES = ['low', 'medium', 'high', 'auto'] as const;
23
  export const AGENT_BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const;
24
  export const AGENT_MODERATIONS = ['low', 'auto'] as const;
25
  export const AGENT_LEGACY_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
 
26
 
27
  export type AgentStateBackend = 'memory' | 'sqlite' | 'postgres';
 
28
  export type AgentResponseMode = (typeof AGENT_RESPONSE_MODES)[number];
29
  export type AgentQuality = (typeof AGENT_QUALITIES)[number];
30
  export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number];
31
  export type AgentModeration = (typeof AGENT_MODERATIONS)[number];
 
32
 
33
  export type AgentGenerateRequest = {
34
  model: GptImageModel;
@@ -65,12 +76,35 @@ export type AgentImageResponse = {
65
  created_at: string;
66
  };
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  export type AgentCapabilities = {
69
  api_version: string;
70
  schema_version: string;
71
  auth: {
72
  required: boolean;
73
- schemes: string[];
74
  };
75
  endpoints: Record<string, string>;
76
  defaults: {
@@ -84,6 +118,50 @@ export type AgentCapabilities = {
84
  max_upload_mb: number;
85
  partial_images: { min: number; max: number };
86
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  supported: {
88
  models: readonly string[];
89
  output_formats: readonly string[];
@@ -289,24 +367,15 @@ export function readAgentStateBackend(env: Record<string, string | undefined>):
289
  }
290
 
291
  export function readAgentRequestTtlSeconds(env: Record<string, string | undefined>): number {
292
- const value = env.AGENT_REQUEST_TTL_SECONDS;
293
- if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_REQUEST_TTL_SECONDS;
294
- const parsed = Number(value);
295
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_REQUEST_TTL_SECONDS;
296
  }
297
 
298
  export function readAgentLeaseMs(env: Record<string, string | undefined>): number {
299
- const value = env.AGENT_REQUEST_LEASE_MS;
300
- if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_LEASE_MS;
301
- const parsed = Number(value);
302
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_LEASE_MS;
303
  }
304
 
305
  export function readAgentRecoveryIntervalMs(env: Record<string, string | undefined>): number {
306
- const value = env.AGENT_RECOVERY_INTERVAL_MS;
307
- if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_RECOVERY_INTERVAL_MS;
308
- const parsed = Number(value);
309
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_RECOVERY_INTERVAL_MS;
310
  }
311
 
312
  export function readAgentSqlitePath(env: Record<string, string | undefined>): string {
@@ -314,26 +383,63 @@ export function readAgentSqlitePath(env: Record<string, string | undefined>): st
314
  }
315
 
316
  export function readAgentPublicBaseUrl(env: Record<string, string | undefined>): string {
317
- return env.AGENT_PUBLIC_BASE_URL?.trim() || '/';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  }
319
 
320
  export function validateOptionalAgentApiBaseUrl(baseUrl: string | undefined): void {
321
  if (baseUrl) validateApiBaseUrl(baseUrl);
322
  }
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  export function buildAgentCapabilities(env: Record<string, string | undefined>): AgentCapabilities {
325
  return {
326
  api_version: AGENT_API_VERSION,
327
  schema_version: AGENT_SCHEMA_VERSION,
328
- auth: {
329
- required: Boolean(env.AGENT_API_TOKEN || env.APP_PASSWORD),
330
- schemes: ['bearer', 'x-app-password-hash']
331
- },
332
  endpoints: {
333
  capabilities: '/api/agent/capabilities',
334
  openapi: '/api/agent/openapi.json',
335
  generate: '/api/agent/images/generate',
336
  edit: '/api/agent/images/edit',
 
 
 
337
  artifact_metadata: '/api/agent/artifacts/{id}',
338
  artifact_content: '/api/agent/artifacts/{id}/content',
339
  artifact_delete: '/api/agent/artifacts/{id}'
@@ -349,6 +455,55 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
349
  max_upload_mb: MAX_UPLOAD_BYTES / 1024 / 1024,
350
  partial_images: { min: 1, max: 3 }
351
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  supported: {
353
  models: AGENT_MODELS,
354
  output_formats: AGENT_OUTPUT_FORMATS,
@@ -369,314 +524,3 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
369
  }
370
  };
371
  }
372
-
373
- export function buildAgentOpenApiDocument(env: Record<string, string | undefined>) {
374
- const capabilities = buildAgentCapabilities(env);
375
- const jsonContent = (schemaRef: string) => ({
376
- content: {
377
- 'application/json': {
378
- schema: { $ref: schemaRef }
379
- }
380
- }
381
- });
382
- const agentSecurity = [{ BearerAuth: [] }, { AppPasswordHash: [] }];
383
- const commonAgentErrors = {
384
- '401': jsonContent('#/components/schemas/AgentError'),
385
- '415': jsonContent('#/components/schemas/AgentError'),
386
- '502': jsonContent('#/components/schemas/AgentError')
387
- };
388
- return {
389
- openapi: '3.1.0',
390
- info: {
391
- title: 'GPT Image Playground Agent API',
392
- version: capabilities.api_version
393
- },
394
- servers: [{ url: readAgentPublicBaseUrl(env) }],
395
- paths: {
396
- '/api/agent/capabilities': {
397
- get: {
398
- summary: '获取机器可读的 Agent API 能力信息',
399
- responses: {
400
- '200': jsonContent('#/components/schemas/AgentCapabilities')
401
- }
402
- }
403
- },
404
- '/api/agent/openapi.json': {
405
- get: {
406
- summary: '获取 Agent API 的 OpenAPI 文档',
407
- responses: {
408
- '200': { description: 'OpenAPI 文档' }
409
- }
410
- }
411
- },
412
- '/api/agent/images/generate': {
413
- post: {
414
- summary: '为 Agent 生成图片',
415
- security: agentSecurity,
416
- parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }],
417
- requestBody: {
418
- required: true,
419
- ...jsonContent('#/components/schemas/GenerateRequest')
420
- },
421
- responses: {
422
- '200': jsonContent('#/components/schemas/AgentImageResponse'),
423
- '400': jsonContent('#/components/schemas/AgentError'),
424
- '409': {
425
- ...jsonContent('#/components/schemas/AgentError'),
426
- headers: {
427
- 'Retry-After': { schema: { type: 'integer', minimum: 1 } }
428
- }
429
- },
430
- ...commonAgentErrors,
431
- '422': jsonContent('#/components/schemas/AgentError'),
432
- '500': jsonContent('#/components/schemas/AgentError')
433
- }
434
- }
435
- },
436
- '/api/agent/images/edit': {
437
- post: {
438
- summary: '为 Agent 编辑图片',
439
- security: agentSecurity,
440
- parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }],
441
- requestBody: {
442
- required: true,
443
- content: {
444
- 'multipart/form-data': {
445
- schema: { $ref: '#/components/schemas/EditRequest' }
446
- }
447
- }
448
- },
449
- responses: {
450
- '200': jsonContent('#/components/schemas/AgentImageResponse'),
451
- '400': jsonContent('#/components/schemas/AgentError'),
452
- '409': jsonContent('#/components/schemas/AgentError'),
453
- ...commonAgentErrors,
454
- '422': jsonContent('#/components/schemas/AgentError'),
455
- '500': jsonContent('#/components/schemas/AgentError')
456
- }
457
- }
458
- },
459
- '/api/agent/artifacts/{id}': {
460
- get: {
461
- summary: '获取产物元数据',
462
- security: agentSecurity,
463
- parameters: [{ $ref: '#/components/parameters/ArtifactId' }],
464
- responses: {
465
- '200': jsonContent('#/components/schemas/ArtifactMetadataResponse'),
466
- '401': jsonContent('#/components/schemas/AgentError'),
467
- '404': jsonContent('#/components/schemas/AgentError')
468
- }
469
- },
470
- delete: {
471
- summary: '删除产物',
472
- security: agentSecurity,
473
- parameters: [{ $ref: '#/components/parameters/ArtifactId' }],
474
- responses: {
475
- '200': jsonContent('#/components/schemas/DeleteArtifactResponse'),
476
- '401': jsonContent('#/components/schemas/AgentError'),
477
- '404': jsonContent('#/components/schemas/AgentError')
478
- }
479
- }
480
- },
481
- '/api/agent/artifacts/{id}/content': {
482
- get: {
483
- summary: '下载产物内容',
484
- security: agentSecurity,
485
- parameters: [{ $ref: '#/components/parameters/ArtifactId' }],
486
- responses: {
487
- '200': {
488
- description: '图片二进制内容',
489
- content: {
490
- 'image/png': { schema: { type: 'string', format: 'binary' } },
491
- 'image/jpeg': { schema: { type: 'string', format: 'binary' } },
492
- 'image/webp': { schema: { type: 'string', format: 'binary' } }
493
- }
494
- },
495
- '401': jsonContent('#/components/schemas/AgentError'),
496
- '404': jsonContent('#/components/schemas/AgentError')
497
- }
498
- }
499
- }
500
- },
501
- components: {
502
- securitySchemes: {
503
- BearerAuth: {
504
- type: 'http',
505
- scheme: 'bearer'
506
- },
507
- AppPasswordHash: {
508
- type: 'apiKey',
509
- in: 'header',
510
- name: 'X-App-Password-Hash'
511
- }
512
- },
513
- parameters: {
514
- IdempotencyKey: {
515
- name: 'Idempotency-Key',
516
- in: 'header',
517
- required: true,
518
- schema: { type: 'string', minLength: 1, maxLength: 200 }
519
- },
520
- ArtifactId: {
521
- name: 'id',
522
- in: 'path',
523
- required: true,
524
- schema: { type: 'string', minLength: 1 }
525
- }
526
- },
527
- schemas: {
528
- AgentCapabilities: {
529
- type: 'object',
530
- required: ['api_version', 'schema_version', 'auth', 'endpoints', 'defaults', 'limits', 'supported', 'storage', 'idempotency'],
531
- properties: {
532
- api_version: { type: 'string' },
533
- schema_version: { type: 'string' },
534
- auth: { type: 'object' },
535
- endpoints: { type: 'object', additionalProperties: { type: 'string' } },
536
- defaults: { type: 'object' },
537
- limits: { type: 'object' },
538
- supported: { type: 'object' },
539
- storage: {
540
- type: 'object',
541
- required: ['image_storage_mode', 'postgres_configured'],
542
- properties: {
543
- image_storage_mode: { type: 'string' },
544
- postgres_configured: { type: 'boolean' }
545
- }
546
- },
547
- idempotency: { type: 'object' }
548
- }
549
- },
550
- GenerateRequest: {
551
- type: 'object',
552
- required: ['prompt'],
553
- properties: {
554
- prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH },
555
- model: { type: 'string', enum: AGENT_MODELS },
556
- n: { type: 'integer', minimum: 1, maximum: MAX_IMAGE_COUNT },
557
- size: { type: 'string' },
558
- quality: { type: 'string', enum: AGENT_QUALITIES, default: 'high' },
559
- output_format: { type: 'string', enum: AGENT_OUTPUT_FORMATS },
560
- output_compression: { type: 'integer', minimum: 0, maximum: 100 },
561
- background: { type: 'string', enum: AGENT_BACKGROUNDS },
562
- moderation: { type: 'string', enum: AGENT_MODERATIONS },
563
- response_mode: { type: 'string', enum: AGENT_RESPONSE_MODES, default: 'path' }
564
- }
565
- },
566
- EditRequest: {
567
- type: 'object',
568
- required: ['prompt', 'image_0'],
569
- properties: {
570
- prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH },
571
- model: { type: 'string', enum: AGENT_MODELS, default: 'gpt-image-2' },
572
- n: { type: 'integer', minimum: 1, maximum: MAX_IMAGE_COUNT },
573
- size: { type: 'string', default: 'auto' },
574
- quality: { type: 'string', enum: AGENT_QUALITIES, default: 'auto' },
575
- response_mode: { type: 'string', enum: AGENT_RESPONSE_MODES, default: 'path' },
576
- image_0: { type: 'string', format: 'binary' },
577
- mask: { type: 'string', format: 'binary' }
578
- }
579
- },
580
- AgentArtifact: {
581
- type: 'object',
582
- required: ['id', 'filename', 'content_url', 'metadata_url', 'output_format', 'mime_type', 'size_bytes', 'width', 'height'],
583
- properties: {
584
- id: { type: 'string' },
585
- filename: { type: 'string' },
586
- content_url: { type: 'string' },
587
- metadata_url: { type: 'string' },
588
- output_format: { type: 'string', enum: AGENT_OUTPUT_FORMATS },
589
- mime_type: { type: 'string' },
590
- size_bytes: { type: 'integer', minimum: 0 },
591
- width: { type: ['integer', 'null'] },
592
- height: { type: ['integer', 'null'] },
593
- b64_json: { type: 'string' }
594
- }
595
- },
596
- AgentImageResponse: {
597
- type: 'object',
598
- required: ['request_id', 'idempotency_key', 'cached', 'images', 'created_at'],
599
- properties: {
600
- request_id: { type: 'string' },
601
- idempotency_key: { type: 'string' },
602
- cached: { type: 'boolean' },
603
- images: {
604
- type: 'array',
605
- items: { $ref: '#/components/schemas/AgentArtifact' }
606
- },
607
- usage: { type: 'object' },
608
- created_at: { type: 'string', format: 'date-time' }
609
- }
610
- },
611
- ArtifactMetadataResponse: {
612
- type: 'object',
613
- required: ['artifact'],
614
- properties: {
615
- artifact: { $ref: '#/components/schemas/AgentArtifact' }
616
- }
617
- },
618
- AgentArtifactRecord: {
619
- type: 'object',
620
- required: [
621
- 'id',
622
- 'requestId',
623
- 'filename',
624
- 'filepath',
625
- 'contentUrl',
626
- 'metadataUrl',
627
- 'outputFormat',
628
- 'mimeType',
629
- 'sizeBytes',
630
- 'width',
631
- 'height',
632
- 'model',
633
- 'promptHash',
634
- 'createdAt'
635
- ],
636
- properties: {
637
- id: { type: 'string' },
638
- requestId: { type: 'string' },
639
- filename: { type: 'string' },
640
- filepath: { type: 'string' },
641
- contentUrl: { type: 'string' },
642
- metadataUrl: { type: 'string' },
643
- outputFormat: { type: 'string', enum: AGENT_OUTPUT_FORMATS },
644
- mimeType: { type: 'string' },
645
- sizeBytes: { type: 'integer', minimum: 0 },
646
- width: { type: ['integer', 'null'] },
647
- height: { type: ['integer', 'null'] },
648
- model: { type: 'string' },
649
- promptHash: { type: 'string' },
650
- createdAt: { type: 'string', format: 'date-time' }
651
- }
652
- },
653
- DeleteArtifactResponse: {
654
- type: 'object',
655
- required: ['deleted', 'id'],
656
- properties: {
657
- deleted: { type: 'boolean' },
658
- id: { type: 'string' }
659
- }
660
- },
661
- AgentError: {
662
- type: 'object',
663
- required: ['error'],
664
- properties: {
665
- error: {
666
- type: 'object',
667
- required: ['code', 'message', 'retryable', 'request_id'],
668
- properties: {
669
- code: { type: 'string' },
670
- message: { type: 'string' },
671
- retryable: { type: 'boolean' },
672
- details: { type: 'object' },
673
- upstream_status: { type: 'integer' },
674
- request_id: { type: 'string' }
675
- }
676
- }
677
- }
678
- }
679
- }
680
- }
681
- };
682
- }
 
7
  type GptImageModel,
8
  type ValidOutputFormat
9
  } from './image-request-utils';
10
+ import {
11
+ GPT_IMAGE_2_EDGE_MULTIPLE,
12
+ GPT_IMAGE_2_MAX_ASPECT,
13
+ GPT_IMAGE_2_MAX_EDGE,
14
+ GPT_IMAGE_2_MAX_PIXELS,
15
+ GPT_IMAGE_2_MIN_PIXELS,
16
+ validateGptImage2Size
17
+ } from './size-utils';
18
+ import type { AgentErrorDiagnostics } from './api-error-response';
19
 
20
  export const AGENT_API_VERSION = '1.0.0';
21
+ export const AGENT_SCHEMA_VERSION = '2026-05-20';
22
  export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite';
23
  export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000;
24
  export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60;
 
31
  export const AGENT_BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const;
32
  export const AGENT_MODERATIONS = ['low', 'auto'] as const;
33
  export const AGENT_LEGACY_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
34
+ export const AGENT_JOB_STATES = ['queued', 'running', 'succeeded', 'failed', 'expired'] as const;
35
 
36
  export type AgentStateBackend = 'memory' | 'sqlite' | 'postgres';
37
+ export type AgentAuthScheme = 'bearer' | 'x-app-password-hash';
38
  export type AgentResponseMode = (typeof AGENT_RESPONSE_MODES)[number];
39
  export type AgentQuality = (typeof AGENT_QUALITIES)[number];
40
  export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number];
41
  export type AgentModeration = (typeof AGENT_MODERATIONS)[number];
42
+ export type AgentJobState = (typeof AGENT_JOB_STATES)[number];
43
 
44
  export type AgentGenerateRequest = {
45
  model: GptImageModel;
 
76
  created_at: string;
77
  };
78
 
79
+ export type AgentJobStatusResponse = {
80
+ job: {
81
+ id: string;
82
+ request_id: string;
83
+ idempotency_key: string;
84
+ mode: 'generate' | 'edit';
85
+ state: AgentJobState;
86
+ created_at: string;
87
+ updated_at: string;
88
+ expires_at: string;
89
+ result_url?: string;
90
+ retry_after_seconds?: number;
91
+ error?: {
92
+ code: string;
93
+ message: string;
94
+ retryable: boolean;
95
+ details?: Record<string, unknown>;
96
+ upstream_status?: number;
97
+ diagnostics?: AgentErrorDiagnostics;
98
+ };
99
+ };
100
+ };
101
+
102
  export type AgentCapabilities = {
103
  api_version: string;
104
  schema_version: string;
105
  auth: {
106
  required: boolean;
107
+ schemes: AgentAuthScheme[];
108
  };
109
  endpoints: Record<string, string>;
110
  defaults: {
 
118
  max_upload_mb: number;
119
  partial_images: { min: number; max: number };
120
  };
121
+ model_limits: {
122
+ 'gpt-image-2': {
123
+ max_edge: number;
124
+ max_pixels: number;
125
+ edge_multiple: number;
126
+ max_aspect: number;
127
+ min_pixels: number;
128
+ recommended_presets: Array<{ name: string; size: string; purpose: string }>;
129
+ high_4k_risk: {
130
+ applies_to: string[];
131
+ guidance: string;
132
+ };
133
+ };
134
+ };
135
+ agent_streaming: {
136
+ generate: {
137
+ supported: false;
138
+ mode: 'non_streaming_only';
139
+ endpoint: string;
140
+ };
141
+ edit: {
142
+ supported: false;
143
+ mode: 'non_streaming_only';
144
+ endpoint: string;
145
+ };
146
+ page_sse: {
147
+ supported: true;
148
+ mode: 'form_data_sse';
149
+ endpoint: string;
150
+ contract: 'page_ui_only';
151
+ };
152
+ };
153
+ agent_jobs: {
154
+ supported: true;
155
+ mode: 'job_polling';
156
+ intended_for: string[];
157
+ endpoints: {
158
+ create_generate_job: string;
159
+ get_job: string;
160
+ get_job_result: string;
161
+ };
162
+ states: readonly string[];
163
+ current_guidance: string;
164
+ };
165
  supported: {
166
  models: readonly string[];
167
  output_formats: readonly string[];
 
367
  }
368
 
369
  export function readAgentRequestTtlSeconds(env: Record<string, string | undefined>): number {
370
+ return readPositiveIntegerEnv(env, 'AGENT_REQUEST_TTL_SECONDS', AGENT_DEFAULT_REQUEST_TTL_SECONDS);
 
 
 
371
  }
372
 
373
  export function readAgentLeaseMs(env: Record<string, string | undefined>): number {
374
+ return readPositiveIntegerEnv(env, 'AGENT_REQUEST_LEASE_MS', AGENT_DEFAULT_LEASE_MS);
 
 
 
375
  }
376
 
377
  export function readAgentRecoveryIntervalMs(env: Record<string, string | undefined>): number {
378
+ return readPositiveIntegerEnv(env, 'AGENT_RECOVERY_INTERVAL_MS', AGENT_DEFAULT_RECOVERY_INTERVAL_MS);
 
 
 
379
  }
380
 
381
  export function readAgentSqlitePath(env: Record<string, string | undefined>): string {
 
383
  }
384
 
385
  export function readAgentPublicBaseUrl(env: Record<string, string | undefined>): string {
386
+ const value = env.AGENT_PUBLIC_BASE_URL?.trim();
387
+ if (!value) return '/';
388
+ let parsed: URL;
389
+ try {
390
+ parsed = new URL(value);
391
+ } catch {
392
+ throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 格式无效。', 500);
393
+ }
394
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
395
+ throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 必须是 http 或 https URL。', 500);
396
+ }
397
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
398
+ throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 不能包含凭据、查询参数或片段。', 500);
399
+ }
400
+ return parsed.toString().replace(/\/$/, '');
401
  }
402
 
403
  export function validateOptionalAgentApiBaseUrl(baseUrl: string | undefined): void {
404
  if (baseUrl) validateApiBaseUrl(baseUrl);
405
  }
406
 
407
+ export function buildAgentAuthCapabilities(env: Record<string, string | undefined>): AgentCapabilities['auth'] {
408
+ if (env.AGENT_API_TOKEN?.trim()) {
409
+ return { required: true, schemes: ['bearer'] };
410
+ }
411
+ if (env.APP_PASSWORD?.trim()) {
412
+ return { required: true, schemes: ['x-app-password-hash'] };
413
+ }
414
+ return { required: false, schemes: [] };
415
+ }
416
+
417
+ function readPositiveIntegerEnv(env: Record<string, string | undefined>, fieldName: string, fallback: number): number {
418
+ const value = env[fieldName]?.trim();
419
+ if (!value) return fallback;
420
+ if (!/^\d+$/.test(value)) {
421
+ throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500);
422
+ }
423
+ const parsed = Number(value);
424
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
425
+ throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500);
426
+ }
427
+ return parsed;
428
+ }
429
+
430
  export function buildAgentCapabilities(env: Record<string, string | undefined>): AgentCapabilities {
431
  return {
432
  api_version: AGENT_API_VERSION,
433
  schema_version: AGENT_SCHEMA_VERSION,
434
+ auth: buildAgentAuthCapabilities(env),
 
 
 
435
  endpoints: {
436
  capabilities: '/api/agent/capabilities',
437
  openapi: '/api/agent/openapi.json',
438
  generate: '/api/agent/images/generate',
439
  edit: '/api/agent/images/edit',
440
+ create_generate_job: '/api/agent/jobs/images/generate',
441
+ job: '/api/agent/jobs/{id}',
442
+ job_result: '/api/agent/jobs/{id}/result',
443
  artifact_metadata: '/api/agent/artifacts/{id}',
444
  artifact_content: '/api/agent/artifacts/{id}/content',
445
  artifact_delete: '/api/agent/artifacts/{id}'
 
455
  max_upload_mb: MAX_UPLOAD_BYTES / 1024 / 1024,
456
  partial_images: { min: 1, max: 3 }
457
  },
458
+ model_limits: {
459
+ 'gpt-image-2': {
460
+ max_edge: GPT_IMAGE_2_MAX_EDGE,
461
+ max_pixels: GPT_IMAGE_2_MAX_PIXELS,
462
+ edge_multiple: GPT_IMAGE_2_EDGE_MULTIPLE,
463
+ max_aspect: GPT_IMAGE_2_MAX_ASPECT,
464
+ min_pixels: GPT_IMAGE_2_MIN_PIXELS,
465
+ recommended_presets: [
466
+ { name: 'square', size: '2048x2048', purpose: '通用正方形构图' },
467
+ { name: 'landscape', size: '3072x2048', purpose: '横向宽幅构图' },
468
+ { name: 'portrait', size: '2048x3072', purpose: '纵向主体构图' }
469
+ ],
470
+ high_4k_risk: {
471
+ applies_to: ['quality=high', 'max_edge>=3072', 'long_running_upstream'],
472
+ guidance: '高质量 4K 级请求可能耗时数分钟;失败应归类为上游长耗时风险,不代表低负载路径不可用。'
473
+ }
474
+ }
475
+ },
476
+ agent_streaming: {
477
+ generate: {
478
+ supported: false,
479
+ mode: 'non_streaming_only',
480
+ endpoint: '/api/agent/images/generate'
481
+ },
482
+ edit: {
483
+ supported: false,
484
+ mode: 'non_streaming_only',
485
+ endpoint: '/api/agent/images/edit'
486
+ },
487
+ page_sse: {
488
+ supported: true,
489
+ mode: 'form_data_sse',
490
+ endpoint: '/api/images',
491
+ contract: 'page_ui_only'
492
+ }
493
+ },
494
+ agent_jobs: {
495
+ supported: true,
496
+ mode: 'job_polling',
497
+ intended_for: ['quality=high', 'max_edge>=3072', 'long_running_upstream', 'manual_billable_gate'],
498
+ endpoints: {
499
+ create_generate_job: '/api/agent/jobs/images/generate',
500
+ get_job: '/api/agent/jobs/{id}',
501
+ get_job_result: '/api/agent/jobs/{id}/result'
502
+ },
503
+ states: AGENT_JOB_STATES,
504
+ current_guidance:
505
+ '对 4K/high 或长耗时请求优先使用 job polling:先创建 generate job,再轮询状态,最后读取 result。运行中 job 会刷新 lease。当前执行模型为同实例后台任务,不是跨实例持久队列。'
506
+ },
507
  supported: {
508
  models: AGENT_MODELS,
509
  output_formats: AGENT_OUTPUT_FORMATS,
 
524
  }
525
  };
526
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/agent-auth.test.ts CHANGED
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
4
  import crypto from 'node:crypto';
5
  import { describe, it } from 'node:test';
6
 
7
- const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-');
8
 
9
  describe('assertAgentAuthorized', () => {
10
  it('accepts the configured bearer token', () => {
@@ -20,14 +20,14 @@ describe('assertAgentAuthorized', () => {
20
  );
21
  });
22
 
23
- it('rejects missing bearer tokens without falling back to password auth', () => {
24
  assert.throws(
25
- () => assertAgentAuthorized(new Headers(), { AGENT_API_TOKEN: 'secret-token', APP_PASSWORD: 'password' }),
26
  (error) => error instanceof AgentApiError && error.code === 'unauthorized'
27
  );
28
  });
29
 
30
- it('trims APP_PASSWORD before verifying password hashes', () => {
31
  const passwordHash = crypto.createHash('sha256').update(PAGE_PASSWORD_FIXTURE).digest('hex');
32
  assert.doesNotThrow(() =>
33
  assertAgentAuthorized(new Headers({ 'X-App-Password-Hash': passwordHash }), {
 
4
  import crypto from 'node:crypto';
5
  import { describe, it } from 'node:test';
6
 
7
+ const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
8
 
9
  describe('assertAgentAuthorized', () => {
10
  it('accepts the configured bearer token', () => {
 
20
  );
21
  });
22
 
23
+ it('rejects missing bearer tokens without falling back to access-code auth', () => {
24
  assert.throws(
25
+ () => assertAgentAuthorized(new Headers(), { AGENT_API_TOKEN: 'secret-token', APP_PASSWORD: 'access-code' }),
26
  (error) => error instanceof AgentApiError && error.code === 'unauthorized'
27
  );
28
  });
29
 
30
+ it('trims APP_PASSWORD before verifying access-code hashes', () => {
31
  const passwordHash = crypto.createHash('sha256').update(PAGE_PASSWORD_FIXTURE).digest('hex');
32
  assert.doesNotThrow(() =>
33
  assertAgentAuthorized(new Headers({ 'X-App-Password-Hash': passwordHash }), {
src/lib/agent-auth.ts CHANGED
@@ -25,7 +25,7 @@ export function assertAgentAuthorized(headers: Headers, env: Record<string, stri
25
  if (!passwordHash || !verifyPasswordHash(passwordHash, appPassword)) {
26
  throw new AgentApiError({
27
  code: 'unauthorized',
28
- message: '未授权:码哈希无效或缺失。',
29
  status: 401,
30
  retryable: false
31
  });
 
25
  if (!passwordHash || !verifyPasswordHash(passwordHash, appPassword)) {
26
  throw new AgentApiError({
27
  code: 'unauthorized',
28
+ message: '未授权:访问码哈希无效或缺失。',
29
  status: 401,
30
  retryable: false
31
  });
src/lib/agent-image-service.test.ts CHANGED
@@ -139,9 +139,15 @@ function createReplayStore(artifacts: AgentArtifactRecord[]): AgentStateStore {
139
  async beginRequest() {
140
  throw new Error('not implemented');
141
  },
 
 
 
142
  async saveArtifacts() {},
143
  async completeRequest() {},
144
  async failRequest() {},
 
 
 
145
  async getArtifact() {
146
  return undefined;
147
  },
 
139
  async beginRequest() {
140
  throw new Error('not implemented');
141
  },
142
+ async refreshRequestLease() {
143
+ return false;
144
+ },
145
  async saveArtifacts() {},
146
  async completeRequest() {},
147
  async failRequest() {},
148
+ async getRequest() {
149
+ return undefined;
150
+ },
151
  async getArtifact() {
152
  return undefined;
153
  },